package entrypoint import ( "bytes" crypticnet "cryptic-net" "cryptic-net/bootstrap" "cryptic-net/garage" "cryptic-net/tarutil" "errors" "fmt" "io/fs" "net" "os" "path/filepath" "regexp" "github.com/minio/minio-go/v7" "gopkg.in/yaml.v3" ) const nebulaHostPathPrefix = "nebula/hosts/" func nebulaHostPath(name string) string { return filepath.Join(nebulaHostPathPrefix, name+".yml") } var hostNameRegexp = regexp.MustCompile(`^[a-z][a-z0-9\-]*$`) func validateHostName(name string) error { if !hostNameRegexp.MatchString(name) { return errors.New("a host's name must start with a letter and only contain letters, numbers, and dashes") } return nil } var subCmdHostsAdd = subCmd{ name: "add", descr: "Adds a host to the network", checkLock: true, do: func(subCmdCtx subCmdCtx) error { flags := subCmdCtx.flagSet(false) name := flags.StringP( "name", "n", "", "Name of the new host", ) ip := flags.StringP( "ip", "i", "", "IP of the new host", ) if err := flags.Parse(subCmdCtx.args); err != nil { return fmt.Errorf("parsing flags: %w", err) } if *name == "" || *ip == "" { return errors.New("--name and --ip are required") } if err := validateHostName(*name); err != nil { return fmt.Errorf("invalid hostname %q: %w", *name, err) } if net.ParseIP(*ip) == nil { return fmt.Errorf("invalid ip %q", *ip) } // TODO validate that the IP is in the correct CIDR env := subCmdCtx.env client, err := garage.GlobalBucketAPIClient(env) if err != nil { return fmt.Errorf("creating client for global bucket: %w", err) } nebulaHost := crypticnet.NebulaHost{ Name: *name, IP: *ip, } bodyBuf := new(bytes.Buffer) if err := yaml.NewEncoder(bodyBuf).Encode(nebulaHost); err != nil { return fmt.Errorf("marshaling nebula host to yaml: %w", err) } filePath := nebulaHostPath(*name) _, err = client.PutObject( env.Context, garage.GlobalBucket, filePath, bodyBuf, int64(bodyBuf.Len()), minio.PutObjectOptions{}, ) if err != nil { return fmt.Errorf("writing to %q in global bucket: %w", filePath, err) } return nil }, } var subCmdHostsList = subCmd{ name: "list", descr: "Lists all hosts in the network, and their IPs", checkLock: true, do: func(subCmdCtx subCmdCtx) error { env := subCmdCtx.env client, err := garage.GlobalBucketAPIClient(env) if err != nil { return fmt.Errorf("creating client for global bucket: %w", err) } objInfoCh := client.ListObjects( env.Context, garage.GlobalBucket, minio.ListObjectsOptions{ Prefix: nebulaHostPathPrefix, }, ) for { select { case <-env.Context.Done(): return env.Context.Err() case objInfo, ok := <-objInfoCh: if !ok { return nil } else if objInfo.Err != nil { return objInfo.Err } obj, err := client.GetObject( env.Context, garage.GlobalBucket, objInfo.Key, minio.GetObjectOptions{}, ) if err != nil { return fmt.Errorf("retrieving object %q from global bucket: %w", objInfo.Key, err) } var nebulaHost crypticnet.NebulaHost err = yaml.NewDecoder(obj).Decode(&nebulaHost) obj.Close() if err != nil { return fmt.Errorf("yaml decoding %q from global bucket: %w", objInfo.Key, err) } fmt.Fprintf( os.Stdout, "%s\t%s\n", nebulaHost.Name, nebulaHost.IP, ) } } }, } var subCmdHostsDelete = subCmd{ name: "delete", descr: "Deletes a host from the network", checkLock: true, do: func(subCmdCtx subCmdCtx) error { flags := subCmdCtx.flagSet(false) name := flags.StringP( "name", "n", "", "Name of the new host", ) if err := flags.Parse(subCmdCtx.args); err != nil { return fmt.Errorf("parsing flags: %w", err) } if *name == "" { return errors.New("--name is required") } env := subCmdCtx.env filePath := nebulaHostPath(*name) client, err := garage.GlobalBucketAPIClient(env) if err != nil { return fmt.Errorf("creating client for global bucket: %w", err) } err = client.RemoveObject( env.Context, garage.GlobalBucket, filePath, minio.RemoveObjectOptions{}, ) if garage.IsKeyNotFound(err) { return fmt.Errorf("host %q not found", *name) } else if err != nil { return fmt.Errorf("removing object %q from global bucket: %w", filePath, err) } return nil }, } func readAdminFS(path string) (fs.FS, error) { if path == "-" { outFS, err := tarutil.FSFromReader(os.Stdin) if err != nil { return nil, fmt.Errorf("reading admin.tgz from stdin: %w", err) } return outFS, nil } f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("opening file: %w", err) } defer f.Close() return tarutil.FSFromReader(f) } var subCmdHostsMakeBootstrap = subCmd{ name: "make-bootstrap", descr: "Creates a new bootstrap.tgz file for a particular host and writes it to stdout", checkLock: true, do: func(subCmdCtx subCmdCtx) error { flags := subCmdCtx.flagSet(false) name := flags.StringP( "name", "n", "", "Name of the host to generate bootstrap.tgz for", ) adminPath := flags.StringP( "admin-path", "a", "", `Path to admin.tgz file. If the given path is "-" then stdin is used.`, ) if err := flags.Parse(subCmdCtx.args); err != nil { return fmt.Errorf("parsing flags: %w", err) } if *name == "" || *adminPath == "" { return errors.New("--name and --admin-path are required") } adminFS, err := readAdminFS(*adminPath) if err != nil { return fmt.Errorf("reading admin.tgz with --admin-path of %q: %w", *adminPath, err) } return bootstrap.NewForHost(subCmdCtx.env, adminFS, *name, os.Stdout) }, } var subCmdHosts = subCmd{ name: "hosts", descr: "Sub-commands having to do with configuration of hosts in the network", do: func(subCmdCtx subCmdCtx) error { return subCmdCtx.doSubCmd( subCmdHostsAdd, subCmdHostsList, subCmdHostsDelete, subCmdHostsMakeBootstrap, ) }, }