Commit 44e5fb3a83
Verified · cmc
Layout: unified · split
e2e/ssh_test.go +2 −2
| @@ -265,7 +265,7 @@ func TestControlPlaneOverBareSSH(t *testing.T) { | |||
| 265 | t.Fatalf("keys list exit %d:\n%s", code, out) | 265 | t.Fatalf("keys list exit %d:\n%s", code, out) |
| 266 | } | 266 | } |
| 267 | // The key's comment (ssh-keygen -C) is its label; keys label renames it. | 267 | // The key's comment (ssh-keygen -C) is its label; keys label renames it. |
| 268 | if !strings.Contains(out, "\tgit\talice2\n") { | 268 | if !strings.Contains(out, "\tgit\talice2\t") { |
| 269 | t.Fatalf("keys list lacks the comment as label:\n%s", out) | 269 | t.Fatalf("keys list lacks the comment as label:\n%s", out) |
| 270 | } | 270 | } |
| 271 | secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0] | 271 | secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0] |
| @@ -273,7 +273,7 @@ func TestControlPlaneOverBareSSH(t *testing.T) { | |||
| 273 | t.Fatalf("keys label exit %d, stderr: %s", code, errOut) | 273 | t.Fatalf("keys label exit %d, stderr: %s", code, errOut) |
| 274 | } | 274 | } |
| 275 | out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list") | 275 | out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list") |
| 276 | if !strings.Contains(out, "\tgit\tbuild box\n") { | 276 | if !strings.Contains(out, "\tgit\tbuild box\t") { |
| 277 | t.Fatalf("keys list after label:\n%s", out) | 277 | t.Fatalf("keys list after label:\n%s", out) |
| 278 | } | 278 | } |
| 279 | 279 | ||
internal/control/deploykey.go +38 −24
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "errors" | 4 | "errors" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "io" | 6 | "io" |
| 7 | "time" | ||
| 7 | 8 | ||
| 8 | "golang.org/x/crypto/ssh" | 9 | "golang.org/x/crypto/ssh" |
| 9 | 10 | ||
| @@ -15,11 +16,12 @@ import ( | |||
| 15 | func init() { | 16 | func init() { |
| 16 | register(Command{Path: []string{"repo", "deploy-key", "add"}, | 17 | register(Command{Path: []string{"repo", "deploy-key", "add"}, |
| 17 | Summary: "bind a read-only (or --rw) key to one repository", | 18 | Summary: "bind a read-only (or --rw) key to one repository", |
| 18 | Usage: "repo deploy-key add <owner/name> [--rw] < key.pub", | 19 | Usage: "repo deploy-key add <owner/name> [--rw] [--ttl 30d|720h] < key.pub", |
| 19 | Flags: []Flag{ | 20 | Flags: []Flag{ |
| 20 | {"--rw", "", "the key may push, not just fetch", ""}, | 21 | {"--rw", "", "the key may push, not just fetch", ""}, |
| 22 | {"--ttl", "30d|720h", "how long the key authenticates", "never expires"}, | ||
| 21 | }, | 23 | }, |
| 22 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub"}, | 24 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"}, |
| 23 | ReadsStdin: true, | 25 | ReadsStdin: true, |
| 24 | MintsCredential: true, Run: runDeployKeyAdd}) | 26 | MintsCredential: true, Run: runDeployKeyAdd}) |
| 25 | register(Command{Path: []string{"repo", "deploy-key", "list"}, | 27 | register(Command{Path: []string{"repo", "deploy-key", "list"}, |
| @@ -35,22 +37,22 @@ func init() { | |||
| 35 | } | 37 | } |
| 36 | 38 | ||
| 37 | func runDeployKeyAdd(c *Ctx, args []string) int { | 39 | func runDeployKeyAdd(c *Ctx, args []string) int { |
| 38 | mode := "ro" | 40 | f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage}) |
| 39 | var path string | 41 | if err != nil { |
| 40 | for _, a := range args { | 42 | return c.fail(protocol.ExitUsage, "%v", err) |
| 41 | switch a { | ||
| 42 | case "--rw": | ||
| 43 | mode = "rw" | ||
| 44 | default: | ||
| 45 | if path != "" { | ||
| 46 | return c.usage() | ||
| 47 | } | ||
| 48 | path = a | ||
| 49 | } | ||
| 50 | } | 43 | } |
| 44 | path := f.pos(0) | ||
| 51 | if path == "" { | 45 | if path == "" { |
| 52 | return c.usage() | 46 | return c.usage() |
| 53 | } | 47 | } |
| 48 | mode := "ro" | ||
| 49 | if f.Has("--rw") { | ||
| 50 | mode = "rw" | ||
| 51 | } | ||
| 52 | expires, code := c.ttlFlag(f) | ||
| 53 | if code >= 0 { | ||
| 54 | return code | ||
| 55 | } | ||
| 54 | repo, code := resolveRepo(c, path, policy.CanAdmin) | 56 | repo, code := resolveRepo(c, path, policy.CanAdmin) |
| 55 | if code >= 0 { | 57 | if code >= 0 { |
| 56 | return code | 58 | return code |
| @@ -69,14 +71,22 @@ func runDeployKeyAdd(c *Ctx, args []string) int { | |||
| 69 | } | 71 | } |
| 70 | fp := ssh.FingerprintSHA256(pub) | 72 | fp := ssh.FingerprintSHA256(pub) |
| 71 | scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) | 73 | scope := fmt.Sprintf("deploy:%d:%s", repo.ID, mode) |
| 72 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | 74 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil { |
| 73 | if errors.Is(err, store.ErrDuplicateKey) { | 75 | if errors.Is(err, store.ErrDuplicateKey) { |
| 74 | return c.failErr(err) | 76 | return c.failErr(err) |
| 75 | } | 77 | } |
| 76 | return c.fail(protocol.ExitFailure, "%v", err) | 78 | return c.fail(protocol.ExitFailure, "%v", err) |
| 77 | } | 79 | } |
| 78 | return c.emit(map[string]string{"fingerprint": fp, "mode": mode}, func(w io.Writer) { | 80 | d := map[string]any{"fingerprint": fp, "mode": mode} |
| 79 | fmt.Fprintf(w, "deploy key %s (%s) bound to %s\n", fp, mode, repo.Path()) | 81 | if expires != nil { |
| 82 | d["expires_at"] = expires | ||
| 83 | } | ||
| 84 | return c.emit(d, func(w io.Writer) { | ||
| 85 | line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path()) | ||
| 86 | if expires != nil { | ||
| 87 | line += ", " + expiresText(expires, time.Now()) | ||
| 88 | } | ||
| 89 | fmt.Fprintln(w, line) | ||
| 80 | }) | 90 | }) |
| 81 | } | 91 | } |
| 82 | 92 | ||
| @@ -93,10 +103,12 @@ func runDeployKeyList(c *Ctx, args []string) int { | |||
| 93 | return c.fail(protocol.ExitFailure, "%v", err) | 103 | return c.fail(protocol.ExitFailure, "%v", err) |
| 94 | } | 104 | } |
| 95 | type out struct { | 105 | type out struct { |
| 96 | Fingerprint string `json:"fingerprint"` | 106 | Fingerprint string `json:"fingerprint"` |
| 97 | Algo string `json:"algo"` | 107 | Algo string `json:"algo"` |
| 98 | Mode string `json:"mode"` | 108 | Mode string `json:"mode"` |
| 99 | Label string `json:"label"` | 109 | Label string `json:"label"` |
| 110 | LastUsedAt string `json:"last_used_at,omitempty"` | ||
| 111 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | ||
| 100 | } | 112 | } |
| 101 | var ds []out | 113 | var ds []out |
| 102 | for _, k := range keys { | 114 | for _, k := range keys { |
| @@ -104,12 +116,14 @@ func runDeployKeyList(c *Ctx, args []string) int { | |||
| 104 | if policy.DeployScopeAllows(k.Scope, repo.ID, true) { | 116 | if policy.DeployScopeAllows(k.Scope, repo.ID, true) { |
| 105 | mode = "rw" | 117 | mode = "rw" |
| 106 | } | 118 | } |
| 107 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label}) | 119 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt}) |
| 108 | } | 120 | } |
| 121 | now := time.Now() | ||
| 109 | return c.emit(ds, func(w io.Writer) { | 122 | return c.emit(ds, func(w io.Writer) { |
| 110 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL") | 123 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES") |
| 111 | for _, d := range ds { | 124 | for _, d := range ds { |
| 112 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label)) | 125 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label), |
| 126 | cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) | ||
| 113 | } | 127 | } |
| 114 | tb.flush() | 128 | tb.flush() |
| 115 | }) | 129 | }) |
internal/control/identity.go +57 −19
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "fmt" | 5 | "fmt" |
| 6 | "io" | 6 | "io" |
| 7 | "strings" | 7 | "strings" |
| 8 | "time" | ||
| 8 | "unicode" | 9 | "unicode" |
| 9 | 10 | ||
| 10 | "golang.org/x/crypto/ssh" | 11 | "golang.org/x/crypto/ssh" |
| @@ -33,12 +34,13 @@ func init() { | |||
| 33 | register(Command{ | 34 | register(Command{ |
| 34 | Path: []string{"keys", "add"}, | 35 | Path: []string{"keys", "add"}, |
| 35 | Summary: "register an SSH public key (authorized_keys format)", | 36 | Summary: "register an SSH public key (authorized_keys format)", |
| 36 | Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub", | 37 | Usage: "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub", |
| 37 | Flags: []Flag{ | 38 | Flags: []Flag{ |
| 38 | {"--scope", "full|git|runner", "what the key may do", "full"}, | 39 | {"--scope", "full|git|runner", "what the key may do", "full"}, |
| 39 | {"--label", "<text>", "a name for the key", ""}, | 40 | {"--label", "<text>", "a name for the key", ""}, |
| 41 | {"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"}, | ||
| 40 | }, | 42 | }, |
| 41 | Examples: []string{"keys add --label laptop < key.pub"}, | 43 | Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"}, |
| 42 | ReadsStdin: true, | 44 | ReadsStdin: true, |
| 43 | MintsCredential: true, | 45 | MintsCredential: true, |
| 44 | Run: runKeysAdd, | 46 | Run: runKeysAdd, |
| @@ -83,20 +85,24 @@ func runKeysList(c *Ctx, args []string) int { | |||
| 83 | return c.fail(protocol.ExitFailure, "listing keys: %v", err) | 85 | return c.fail(protocol.ExitFailure, "listing keys: %v", err) |
| 84 | } | 86 | } |
| 85 | type out struct { | 87 | type out struct { |
| 86 | Fingerprint string `json:"fingerprint"` | 88 | Fingerprint string `json:"fingerprint"` |
| 87 | Algo string `json:"algo"` | 89 | Algo string `json:"algo"` |
| 88 | Scope string `json:"scope"` | 90 | Scope string `json:"scope"` |
| 89 | Label string `json:"label"` | 91 | Label string `json:"label"` |
| 90 | CreatedBy string `json:"created_by,omitempty"` | 92 | CreatedBy string `json:"created_by,omitempty"` |
| 93 | LastUsedAt string `json:"last_used_at,omitempty"` | ||
| 94 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | ||
| 91 | } | 95 | } |
| 92 | var ds []out | 96 | var ds []out |
| 93 | for _, k := range keys { | 97 | for _, k := range keys { |
| 94 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy}) | 98 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt}) |
| 95 | } | 99 | } |
| 100 | now := time.Now() | ||
| 96 | return c.emit(ds, func(w io.Writer) { | 101 | return c.emit(ds, func(w io.Writer) { |
| 97 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL") | 102 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") |
| 98 | for _, d := range ds { | 103 | for _, d := range ds { |
| 99 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label)) | 104 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), |
| 105 | cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) | ||
| 100 | } | 106 | } |
| 101 | tb.flush() | 107 | tb.flush() |
| 102 | }) | 108 | }) |
| @@ -121,8 +127,32 @@ func keyLabel(s string) (string, error) { | |||
| 121 | return s, nil | 127 | return s, nil |
| 122 | } | 128 | } |
| 123 | 129 | ||
| 130 | // usedText is a key's last use as a list shows it. | ||
| 131 | func (c *Ctx) usedText(ts string) string { | ||
| 132 | switch { | ||
| 133 | case ts == "": | ||
| 134 | return "never used" | ||
| 135 | case c.Term.Cols == 0: | ||
| 136 | return "used " + stamp(ts) | ||
| 137 | } | ||
| 138 | return "used " + relAge(ts, termNow()) | ||
| 139 | } | ||
| 140 | |||
| 141 | // expiresText is a credential's expiry as a list shows it. It is | ||
| 142 | // absolute at a terminal too: relAge reads only the past. | ||
| 143 | func expiresText(t *time.Time, now time.Time) string { | ||
| 144 | if t == nil { | ||
| 145 | return "never expires" | ||
| 146 | } | ||
| 147 | s := stamp(t.UTC().Format(time.RFC3339Nano)) | ||
| 148 | if !t.After(now) { | ||
| 149 | return "expired " + s | ||
| 150 | } | ||
| 151 | return "expires " + s | ||
| 152 | } | ||
| 153 | |||
| 124 | func runKeysAdd(c *Ctx, args []string) int { | 154 | func runKeysAdd(c *Ctx, args []string) int { |
| 125 | f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub"}) | 155 | f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) |
| 126 | if err != nil { | 156 | if err != nil { |
| 127 | return c.fail(protocol.ExitUsage, "%v", err) | 157 | return c.fail(protocol.ExitUsage, "%v", err) |
| 128 | } | 158 | } |
| @@ -134,6 +164,10 @@ func runKeysAdd(c *Ctx, args []string) int { | |||
| 134 | // deploy:* scopes are granted via repo settings, not self-service. | 164 | // deploy:* scopes are granted via repo settings, not self-service. |
| 135 | return c.fail(protocol.ExitUsage, "scope must be full, git or runner") | 165 | return c.fail(protocol.ExitUsage, "scope must be full, git or runner") |
| 136 | } | 166 | } |
| 167 | expires, code := c.ttlFlag(f) | ||
| 168 | if code >= 0 { | ||
| 169 | return code | ||
| 170 | } | ||
| 137 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) | 171 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) |
| 138 | if err != nil { | 172 | if err != nil { |
| 139 | return c.fail(protocol.ExitFailure, "reading key: %v", err) | 173 | return c.fail(protocol.ExitFailure, "reading key: %v", err) |
| @@ -151,24 +185,28 @@ func runKeysAdd(c *Ctx, args []string) int { | |||
| 151 | return c.fail(protocol.ExitUsage, "%v", err) | 185 | return c.fail(protocol.ExitUsage, "%v", err) |
| 152 | } | 186 | } |
| 153 | fp := ssh.FingerprintSHA256(pub) | 187 | fp := ssh.FingerprintSHA256(pub) |
| 154 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | 188 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil { |
| 155 | if errors.Is(err, store.ErrDuplicateKey) { | 189 | if errors.Is(err, store.ErrDuplicateKey) { |
| 156 | return c.failErr(err) | 190 | return c.failErr(err) |
| 157 | } | 191 | } |
| 158 | return c.fail(protocol.ExitFailure, "adding key: %v", err) | 192 | return c.fail(protocol.ExitFailure, "adding key: %v", err) |
| 159 | } | 193 | } |
| 160 | type out struct { | 194 | type out struct { |
| 161 | Fingerprint string `json:"fingerprint"` | 195 | Fingerprint string `json:"fingerprint"` |
| 162 | Scope string `json:"scope"` | 196 | Scope string `json:"scope"` |
| 163 | Label string `json:"label"` | 197 | Label string `json:"label"` |
| 198 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | ||
| 164 | } | 199 | } |
| 165 | d := out{fp, scope, label} | 200 | d := out{fp, scope, label, expires} |
| 166 | return c.emit(d, func(w io.Writer) { | 201 | return c.emit(d, func(w io.Writer) { |
| 202 | line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope) | ||
| 167 | if d.Label != "" { | 203 | if d.Label != "" { |
| 168 | fmt.Fprintf(w, "added %s (%s) %s\n", d.Fingerprint, d.Scope, d.Label) | 204 | line += " " + d.Label |
| 169 | return | 205 | } |
| 206 | if d.ExpiresAt != nil { | ||
| 207 | line += ", " + expiresText(d.ExpiresAt, time.Now()) | ||
| 170 | } | 208 | } |
| 171 | fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope) | 209 | fmt.Fprintln(w, line) |
| 172 | }) | 210 | }) |
| 173 | } | 211 | } |
| 174 | 212 | ||
internal/control/keyexpiry_test.go added +68
| @@ -0,0 +1,68 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "crypto/rand" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "golang.org/x/crypto/ssh" | ||
| 12 | |||
| 13 | "gitbay.org/gitbay/internal/protocol" | ||
| 14 | "gitbay.org/gitbay/internal/store" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // authorizedKey is a fresh public key as an authorized_keys line. | ||
| 18 | func authorizedKey(t *testing.T, comment string) string { | ||
| 19 | t.Helper() | ||
| 20 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 21 | if err != nil { | ||
| 22 | t.Fatal(err) | ||
| 23 | } | ||
| 24 | sp, err := ssh.NewPublicKey(pub) | ||
| 25 | if err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n" | ||
| 29 | } | ||
| 30 | |||
| 31 | func TestKeysAddTTLAndList(t *testing.T) { | ||
| 32 | st, repo, uid := newQueueTestRepo(t) | ||
| 33 | user := store.User{ID: uid, Username: "alice"} | ||
| 34 | run := func(stdin string, argv ...string) (string, string, int) { | ||
| 35 | c, errOut := pruneCtx(st, t.TempDir(), user) | ||
| 36 | c.Cfg.Limits.WriteRate = -1 | ||
| 37 | c.Stdin = strings.NewReader(stdin) | ||
| 38 | code := Dispatch(c, argv) | ||
| 39 | return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code | ||
| 40 | } | ||
| 41 | if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK { | ||
| 42 | t.Fatalf("keys add --ttl: %d %s", code, errOut) | ||
| 43 | } | ||
| 44 | if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK { | ||
| 45 | t.Fatalf("deploy-key add --ttl: %d %s", code, errOut) | ||
| 46 | } | ||
| 47 | if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage { | ||
| 48 | t.Fatalf("bad ttl: exit %d", code) | ||
| 49 | } | ||
| 50 | |||
| 51 | keys, err := st.ListSSHKeys(uid) | ||
| 52 | if err != nil || len(keys) != 2 { | ||
| 53 | t.Fatalf("keys: %+v %v", keys, err) | ||
| 54 | } | ||
| 55 | for _, k := range keys { | ||
| 56 | if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) { | ||
| 57 | t.Errorf("%s expires %v", k.Label, k.ExpiresAt) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | out, _, _ := run("", "keys", "list") | ||
| 61 | if !strings.Contains(out, "\tlaptop\tnever used\texpires ") { | ||
| 62 | t.Fatalf("keys list:\n%s", out) | ||
| 63 | } | ||
| 64 | out, _, _ = run("", "repo", "deploy-key", "list", repo.Path()) | ||
| 65 | if !strings.Contains(out, "\tci\tnever used\texpires ") { | ||
| 66 | t.Fatalf("deploy-key list:\n%s", out) | ||
| 67 | } | ||
| 68 | } | ||
internal/control/token.go +14
| @@ -50,6 +50,20 @@ func parseTTL(s string) (time.Duration, error) { | |||
| 50 | return time.ParseDuration(s) | 50 | return time.ParseDuration(s) |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | // ttlFlag reads --ttl as an expiry; nil when the flag is absent. The | ||
| 54 | // code is -1 when the caller may go on. | ||
| 55 | func (c *Ctx) ttlFlag(f flags) (*time.Time, int) { | ||
| 56 | if !f.Has("--ttl") { | ||
| 57 | return nil, -1 | ||
| 58 | } | ||
| 59 | d, err := parseTTL(f.Value("--ttl")) | ||
| 60 | if err != nil || d <= 0 { | ||
| 61 | return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl")) | ||
| 62 | } | ||
| 63 | t := time.Now().Add(d) | ||
| 64 | return &t, -1 | ||
| 65 | } | ||
| 66 | |||
| 53 | func runTokenCreate(c *Ctx, args []string) int { | 67 | func runTokenCreate(c *Ctx, args []string) int { |
| 54 | f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) | 68 | f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}) |
| 55 | if err != nil { | 69 | if err != nil { |