233 lines
7.5 KiB
Go
233 lines
7.5 KiB
Go
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/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
|
|
}
|
|
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op)
|
|
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()
|
|
receipt, err := mysql.ApplyCredentialOperation(ctx, db, op)
|
|
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
|
|
}
|