diff --git a/cmd/nook/main.go b/cmd/nook/main.go index 18c60fb..5478b62 100644 --- a/cmd/nook/main.go +++ b/cmd/nook/main.go @@ -75,6 +75,8 @@ func main() { err = importNook(args, asJSON) case "data": err = data(args, asJSON) + case "secret", "secrets": + err = secret(args, asJSON) case "delete": err = deleteNook(args, asJSON) case "mcp": @@ -122,6 +124,7 @@ more: transfer [nook] versions [nook] rollback [nook] [version] export [nook] [-o file] import [--name n] [--share] data list|get|create|update|delete|export|import ... + secret set|list|unset [nook] KEY values come from stdin or a prompt, never argv delete [nook] mcp (run the MCP server on stdio) account whoami|plan|upgrade|logout|stats diff --git a/cmd/nook/secret.go b/cmd/nook/secret.go new file mode 100644 index 0000000..5b59955 --- /dev/null +++ b/cmd/nook/secret.go @@ -0,0 +1,192 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "os/signal" + "regexp" + "strings" +) + +// The server's rule, checked here so a bad name fails before the request rather than as a 400. +var secretNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`) + +// A secret's value is never an argument: argv shows up in shell history and in ps. It comes from +// a pipe, or from a prompt with the terminal's echo off. It is never printed back, by any command. + +func secret(args []string, asJSON bool) error { + if len(args) == 0 { + return fmt.Errorf("usage: nook secret set|list|unset [nook] [KEY]") + } + sub, rest := args[0], args[1:] + name, rest, err := nookName(rest, isSecretName) + if err != nil { + return err + } + switch sub { + case "list", "ls": + out, err := call(http.MethodGet, "/v1/nooks/"+name+"/secrets", nil, "") + if err != nil { + return err + } + return emit(asJSON, out, secretLines(out)) + case "set": + if len(rest) == 0 { + return fmt.Errorf("usage: nook secret set [nook] KEY (the value comes from stdin or a prompt)") + } + if err := checkSecretName(rest[0]); err != nil { + return err + } + value, err := readSecretValue(rest[0]) + if err != nil { + return err + } + out, err := callJSON(http.MethodPut, "/v1/nooks/"+name+"/secrets/"+rest[0], map[string]string{"value": value}) + if err != nil { + return err + } + return emit(asJSON, out, fmt.Sprintf("%s is set for %s. its value is never shown again.\n%s", rest[0], name, secretLines(out))) + case "unset", "rm": + if len(rest) == 0 { + return fmt.Errorf("usage: nook secret unset [nook] KEY") + } + if err := checkSecretName(rest[0]); err != nil { + return err + } + out, err := call(http.MethodDelete, "/v1/nooks/"+name+"/secrets/"+rest[0], nil, "") + if err != nil { + return err + } + return emit(asJSON, out, fmt.Sprintf("%s is gone from %s\n%s", rest[0], name, secretLines(out))) + } + return fmt.Errorf("nook secret set|list|unset") +} + +// isSecretName keeps an uppercase KEY from being mistaken for the nook's name. It is looser than +// the rule below on purpose: "_FOO" should be read as a misspelled key and named as one, not +// silently taken for a nook. +func isSecretName(a string) bool { + return a != "" && a == strings.ToUpper(a) && strings.Trim(a, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") == "" +} + +func checkSecretName(name string) error { + if !secretNameRe.MatchString(name) { + return fmt.Errorf("%q is not a secret name: start with an uppercase letter, then letters, digits, and underscores, like SLACK_TOKEN", name) + } + if strings.HasPrefix(name, "NOOK_") { + return fmt.Errorf("%q is reserved: NOOK_ names are set by the runtime", name) + } + return nil +} + +func readSecretValue(key string) (string, error) { + st, _ := os.Stdin.Stat() + piped := st != nil && st.Mode()&os.ModeCharDevice == 0 + if piped { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("reading the value for %s: %w", key, err) + } + // Exactly one trailing newline comes off, the one a shell or an editor adds. Everything + // else is kept: a PEM key and a JSON credential are both normal things to pipe in. + v := strings.TrimSuffix(string(b), "\n") + v = strings.TrimSuffix(v, "\r") + if v == "" { + return "", fmt.Errorf("%s cannot be empty", key) + } + return v, nil + } + fmt.Fprintf(os.Stderr, "value for %s (not shown): ", key) + restore := echoOff() + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + restore() + fmt.Fprintln(os.Stderr) + if err != nil && strings.TrimSpace(line) == "" { + return "", fmt.Errorf("no value given for %s", key) + } + v := strings.TrimRight(line, "\r\n") + if v == "" { + return "", fmt.Errorf("%s cannot be empty", key) + } + return v, nil +} + +// echoOff hides typing through stty, so the CLI needs no dependency for one prompt. Where stty +// is not there the value echoes, which is worth saying rather than silently showing it. +func echoOff() func() { + if stty("-echo") != nil { + fmt.Fprint(os.Stderr, "\n(cannot hide input on this terminal; it will be visible) ") + return func() {} + } + // Ctrl+C at the prompt must not leave the terminal echoless, typing blind until stty echo. + sig := make(chan os.Signal, 1) + done := make(chan struct{}) + signal.Notify(sig, os.Interrupt) + go func() { + select { + case <-sig: + stty("echo") + fmt.Fprintln(os.Stderr) + os.Exit(130) + case <-done: + } + }() + return func() { + signal.Stop(sig) + close(done) + stty("echo") + } +} + +func stty(arg string) error { + cmd := exec.Command("stty", arg) + cmd.Stdin = os.Stdin // stty acts on the terminal it is given, which is the one being typed at + return cmd.Run() +} + +func secretLines(out map[string]any) string { + set := map[string]any{} + if list, ok := out["secrets"].([]any); ok { + for _, s := range list { + if m, ok := s.(map[string]any); ok { + set[fmt.Sprintf("%v", m["name"])] = m["updated_by"] + } + } + } + var b strings.Builder + seen := map[string]bool{} + if d, ok := out["declared"].([]any); ok { + for _, name := range d { + k := fmt.Sprintf("%v", name) + seen[k] = true + if by, ok := set[k]; ok { + fmt.Fprintf(&b, " %s set by %v\n", k, by) + } else { + fmt.Fprintf(&b, " %s not set\n", k) + } + } + } + for k, by := range set { + if !seen[k] { + fmt.Fprintf(&b, " %s set by %v, not declared in nook.json\n", k, by) + } + } + if b.Len() == 0 { + return " no secrets" + } + if m, ok := out["missing"].([]any); ok && len(m) > 0 { + fmt.Fprintf(&b, "\nthis nook cannot start until %d more %s set.", len(m), plural(len(m), "is", "are")) + } + return strings.TrimRight(b.String(), "\n") +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} diff --git a/cmd/nook/secret_test.go b/cmd/nook/secret_test.go new file mode 100644 index 0000000..17608fe --- /dev/null +++ b/cmd/nook/secret_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// piped runs readSecretValue with b on stdin, the way a shell pipe delivers it. +func piped(t *testing.T, b string) (string, error) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = old }) + go func() { w.WriteString(b); w.Close() }() + return readSecretValue("KEY") +} + +func TestPipedValueIsNotTruncated(t *testing.T) { + pem := "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADAN\nBgkqhkiG9w0B\n-----END PRIVATE KEY-----\n" + got, err := piped(t, pem) + if err != nil { + t.Fatal(err) + } + // One trailing newline comes off; every other line stays. Truncating at the first newline + // would store a value that looks set and fails at runtime. + if want := strings.TrimSuffix(pem, "\n"); got != want { + t.Errorf("got %q, want %q", got, want) + } + if strings.Count(got, "\n") != 3 { + t.Errorf("lost lines: %q", got) + } +} + +func TestPipedValueKeepsWhatMatters(t *testing.T) { + for name, c := range map[string]struct{ in, want string }{ + "one trailing newline comes off": {"abc\n", "abc"}, + "only one comes off": {"abc\n\n", "abc\n"}, + "crlf comes off": {"abc\r\n", "abc"}, + "no newline is fine": {"abc", "abc"}, + "inner spacing is kept": {"a b\tc \n", "a b\tc "}, + "json survives": {"{\n \"k\": \"v\"\n}\n", "{\n \"k\": \"v\"\n}"}, + } { + got, err := piped(t, c.in) + if err != nil || got != c.want { + t.Errorf("%s: got %q (%v), want %q", name, got, err, c.want) + } + } + if _, err := piped(t, "\n"); err == nil { + t.Error("a newline alone is an empty value") + } + if _, err := piped(t, ""); err == nil { + t.Error("nothing piped is an empty value") + } +} + +func TestCheckSecretName(t *testing.T) { + for _, ok := range []string{"A", "SLACK_TOKEN", "A1", "A_B_C"} { + if err := checkSecretName(ok); err != nil { + t.Errorf("%q should be a name: %v", ok, err) + } + } + // These reached the server before and came back as a 400. + for _, bad := range []string{"", "_FOO", "123", "lower", "WITH-DASH", "WITH SPACE", "NOOK_TOKEN", strings.Repeat("A", 65)} { + if err := checkSecretName(bad); err == nil { + t.Errorf("%q should be refused here, not by the server", bad) + } + } + // Still recognised as an intended key, so the error names the key rather than the nook. + for _, k := range []string{"_FOO", "123", "SLACK_TOKEN"} { + if !isSecretName(k) { + t.Errorf("%q should be read as a key", k) + } + } + if isSecretName("board") { + t.Error("a nook name is not a key") + } +} diff --git a/manifest/manifest.go b/manifest/manifest.go index 4c7921f..f352e06 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -26,6 +26,7 @@ var ( hostRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) reserved = map[string]bool{"api": true, "www": true, "app": true, "admin": true, "static": true, "mail": true, "dashboard": true, "nook": true} types = map[string]bool{"static": true, "service": true} + sources = map[string]bool{"editors": true, "viewers": true} ) // A CronEntry runs `Run` inside the machine on `Schedule`, five-field cron in UTC. @@ -40,6 +41,9 @@ type Manifest struct { Entry string `json:"entry,omitempty"` // Service nooks only. See DESIGN.md ยง10b. + // Source is who may read the run code: "editors" (owner and editors, the default) or + // "viewers" (anyone who can open the nook, as a static nook's files always are). + Source string `json:"source,omitempty"` Run string `json:"run,omitempty"` Port int `json:"port,omitempty"` Secrets []string `json:"secrets,omitempty"` @@ -60,6 +64,10 @@ func Parse(b []byte) (*Manifest, error) { if m.Type == "static" && m.Entry == "" { m.Entry = "index.html" } + // Written out explicitly, so a pull or an export of a service nook states who can read it. + if m.Type == "service" && m.Source == "" { + m.Source = "editors" + } if err := m.Validate(); err != nil { return nil, err } @@ -85,7 +93,7 @@ func (m *Manifest) Validate() error { for _, f := range []struct { name string set bool - }{{"run", m.Run != ""}, {"port", m.Port != 0}, {"secrets", len(m.Secrets) > 0}, {"egress", len(m.Egress) > 0}, {"cron", len(m.Cron) > 0}} { + }{{"source", m.Source != ""}, {"run", m.Run != ""}, {"port", m.Port != 0}, {"secrets", len(m.Secrets) > 0}, {"egress", len(m.Egress) > 0}, {"cron", len(m.Cron) > 0}} { if f.set { return fmt.Errorf(`nook.json: %s only applies to a service nook; set "type": "service" or remove it`, f.name) } @@ -97,6 +105,9 @@ func (m *Manifest) validateService() error { if m.Entry != "" { return fmt.Errorf(`nook.json: entry is for static nooks; a service nook serves its own routes from run`) } + if m.Source != "" && !sources[m.Source] { + return fmt.Errorf(`nook.json: source %q must be "editors" (the default) or "viewers"`, m.Source) + } if err := command("run", m.Run); err != nil { return err } @@ -180,3 +191,10 @@ func command(field, s string) error { } return nil } + +// SourceVisibleToViewers says whether anyone who can open the nook may read its code. A static +// nook's files reach every viewer's browser anyway, so they always are; a service nook's do not, +// and stay with the owner and editors unless the manifest opts in. +func (m *Manifest) SourceVisibleToViewers() bool { + return !m.IsService() || m.Source == "viewers" +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 673a8ee..1ee7004 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -21,6 +21,7 @@ func TestParseStatic(t *testing.T) { `{"name":"ok","secrets":["TOKEN"]}`, `{"name":"ok","egress":["example.com"]}`, `{"name":"ok","cron":[{"schedule":"* * * * *","run":"x"}]}`, + `{"name":"ok","source":"viewers"}`, } { if _, err := Parse([]byte(bad)); err == nil { t.Errorf("expected error for %s", bad) @@ -41,6 +42,10 @@ func TestParseService(t *testing.T) { if m.Entry != "" { t.Errorf("a service nook has no entry, got %q", m.Entry) } + // Unset means the run code stays with the owner and editors, and says so when written back. + if m.Source != "editors" || m.SourceVisibleToViewers() { + t.Errorf("source defaults closed, got %q", m.Source) + } if m.Egress[1] != "api.example.com" { t.Errorf("egress hosts are lowercased, got %q", m.Egress[1]) } @@ -68,6 +73,8 @@ func TestServiceValidation(t *testing.T) { "egress bare word": `{"name":"board","type":"service","run":"x","port":8000,"egress":["localhost"]}`, "cron schedule": `{"name":"board","type":"service","run":"x","port":8000,"cron":[{"schedule":"0 9 * *","run":"y"}]}`, "cron run blank": `{"name":"board","type":"service","run":"x","port":8000,"cron":[{"schedule":"* * * * *","run":""}]}`, + "source unknown": `{"name":"board","type":"service","run":"x","port":8000,"source":"everyone"}`, + "source public": `{"name":"board","type":"service","run":"x","port":8000,"source":"public"}`, } { if _, err := Parse([]byte(bad)); err == nil { t.Errorf("%s: expected an error for %s", name, bad) @@ -82,3 +89,18 @@ func TestServiceValidation(t *testing.T) { t.Errorf("egress is optional: %v", err) } } + +func TestSourceVisibility(t *testing.T) { + // A static nook's files reach the browser anyway, so they are always readable. + m, err := Parse([]byte(`{"name":"page"}`)) + if err != nil || !m.SourceVisibleToViewers() { + t.Errorf("static source is always visible: %+v %v", m, err) + } + if m.Source != "" { + t.Errorf("static nooks carry no source field, got %q", m.Source) + } + open, err := Parse([]byte(`{"name":"board","type":"service","run":"x","port":8000,"source":"viewers"}`)) + if err != nil || !open.SourceVisibleToViewers() { + t.Errorf(`"viewers" opts in: %+v %v`, open, err) + } +}