package main import ( "context" "database/sql" "encoding/json" "errors" "flag" "io" "os" "path/filepath" "strings" "time" "git.yuntuo.hk/k2safe/orbit/internal/config" "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" core "git.yuntuo.hk/k2safe/orbit/model" ) func runCredential(args []string) error { if len(args) == 0 { return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply") } switch args[0] { case "status": return runCredentialStatus(args[1:]) case "rotate": return runCredentialChange(args[1:], "ROTATE") case "expiry": return runCredentialChange(args[1:], "SET_EXPIRY") case "finalize": return runCredentialChange(args[1:], "FINALIZE") case "revoke": return runCredentialChange(args[1:], "REVOKE") case "apply": return runCredentialApply(args[1:]) default: return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply") } } func runCredentialStatus(args []string) error { flags := flag.NewFlagSet("credential status", flag.ContinueOnError) namespace, credential := "", "" flags.StringVar(&namespace, "namespace-id", "", "credential namespace id") flags.StringVar(&credential, "credential-id", "", "credential id") if err := flags.Parse(args); err != nil { return err } if flags.NArg() != 0 { return errors.New("credential status does not accept positional arguments") } ns, id, err := credentialIDs(namespace, credential) if err != nil { return err } ctx, db, closeDB, err := adminDB(15 * time.Second) if err != nil { return err } defer closeDB() status, err := mysql.GetCredentialStatus(ctx, db, ns, id) if err != nil { return errors.New("credential status failed") } return json.NewEncoder(os.Stdout).Encode(status) } func runCredentialChange(args []string, action string) error { flags := flag.NewFlagSet("credential "+strings.ToLower(action), flag.ContinueOnError) namespace, credential, output, reason, validFor := "", "", "", "", "0" grace := time.Duration(0) if action == "ROTATE" { grace = 7 * 24 * time.Hour } var expected uint64 flags.StringVar(&namespace, "namespace-id", "", "credential namespace id") flags.StringVar(&credential, "credential-id", "", "credential id") flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply") flags.StringVar(&reason, "reason", "", "required audit reason") flags.StringVar(&output, "output", "", "new private operation file; an existing file is never overwritten") flags.StringVar(&validFor, "valid-for", "0", "credential validity: 0 permanent, or 1h to 8760h") flags.DurationVar(&grace, "grace", grace, "rotation overlap: 0 immediate, up to 720h; default 168h") if err := flags.Parse(args); err != nil { return err } if flags.NArg() != 0 || output == "" { return errors.New("credential change requires --namespace-id, --credential-id, --expected-revision, --reason and --output") } ns, id, err := credentialIDs(namespace, credential) if err != nil || expected == 0 { return errors.New("credential change requires valid ids and a nonzero --expected-revision") } duration := time.Duration(0) if action == "ROTATE" || action == "SET_EXPIRY" { duration, err = time.ParseDuration(validFor) if err != nil { return errors.New("invalid --valid-for duration") } } ctx, db, closeDB, err := adminDB(30 * time.Second) if err != nil { return err } defer closeDB() op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{ NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason, }) if err != nil { return errors.New("credential operation could not be prepared") } if err := writePrivateOperation(output, op); err != nil { return err } vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE")) if err != nil { return err } receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault) if err != nil { return errors.New("credential operation was not confirmed; the private operation file was retained for reconciliation") } return json.NewEncoder(os.Stdout).Encode(receipt) } func runCredentialApply(args []string) error { flags := flag.NewFlagSet("credential apply", flag.ContinueOnError) file := "" flags.StringVar(&file, "file", "", "existing private credential operation file") if err := flags.Parse(args); err != nil { return err } if flags.NArg() != 0 || file == "" { return errors.New("credential apply requires --file") } op, err := readPrivateOperation(file) if err != nil { return err } ctx, db, closeDB, err := adminDB(30 * time.Second) if err != nil { return err } defer closeDB() vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE")) if err != nil { return err } receipt, err := mysql.ApplyCredentialOperation(ctx, db, op, vault) if err != nil { return errors.New("credential operation was not confirmed; retry with the same private operation file") } return json.NewEncoder(os.Stdout).Encode(receipt) } func credentialIDs(namespace, credential string) (core.ID, core.ID, error) { ns, id := core.ID(namespace), core.ID(credential) if ns.Validate() != nil || id.Validate() != nil { return "", "", errors.New("valid --namespace-id and --credential-id are required") } return ns, id, nil } func adminDB(timeout time.Duration) (context.Context, *sql.DB, func(), error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) cfg, err := config.Load() if err != nil { cancel() return nil, nil, func() {}, err } db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1) if err != nil { cancel() return nil, nil, func() {}, err } return ctx, db, func() { db.Close(); cancel() }, nil } func writePrivateOperation(path string, op mysql.CredentialOperation) error { if path == "" || filepath.Base(path) == "." || filepath.Base(path) == string(filepath.Separator) { return errors.New("invalid private operation path") } file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return errors.New("cannot create private operation file; existing files are never overwritten") } keep := false defer func() { _ = file.Close() if !keep { _ = os.Remove(path) } }() if err := file.Chmod(0600); err != nil { return errors.New("cannot protect private operation file") } if err := json.NewEncoder(file).Encode(op); err != nil { return errors.New("cannot write private operation file") } if err := file.Sync(); err != nil { return errors.New("cannot sync private operation file") } directory, err := os.Open(filepath.Dir(path)) if err != nil { return errors.New("cannot open private operation directory") } err = directory.Sync() closeErr := directory.Close() if err != nil || closeErr != nil { return errors.New("cannot sync private operation directory") } keep = true return nil } func readPrivateOperation(path string) (mysql.CredentialOperation, error) { var op mysql.CredentialOperation info, err := os.Lstat(path) if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 64*1024 { return op, errors.New("private operation file must be a regular 0600 file no larger than 64 KiB") } file, err := os.Open(path) if err != nil { return op, errors.New("cannot read private operation file") } defer file.Close() decoder := json.NewDecoder(io.LimitReader(file, 64*1024+1)) decoder.DisallowUnknownFields() if err := decoder.Decode(&op); err != nil { return op, errors.New("private operation file is invalid") } var extra any if err := decoder.Decode(&extra); err != io.EOF { return op, errors.New("private operation file must contain exactly one JSON object") } if err := mysql.ValidateCredentialOperation(op); err != nil { return op, errors.New("private operation file failed validation") } return op, nil }