From 087a937b7f5e061d4e6897c0184df14fb38aad62 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:22:03 +0200 Subject: [PATCH 1/3] docs: put shell notes on their own line, and test it (#92) --- AGENTS.md | 9 ++- README.md | 9 ++- docs/manual-ntfy-test.md | 9 ++- docs/manual-telegram-test.md | 15 +++-- docs/setup.md | 18 ++++-- docs_test.go | 122 +++++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 docs_test.go diff --git a/AGENTS.md b/AGENTS.md index eb7789b..bf986d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,9 @@ Planned. Each v1 issue adds its part. server, on Android and on an iPhone (issue #24), with a table for the results. - `docs/releasing.md` - how releases are made and checked. + - `docs_test.go` (at the root of the module) checks that no shell + block in `README.md`, `AGENTS.md` or `docs/` ends a command line + with a `#` comment: zsh does not treat it as a comment (#92). - `test/e2e/` - the end-to-end test with `sshd` in a container (DESIGN.md section 17). Build tag `e2e`. The image of the container is `test/e2e/testdata/sshd`. @@ -73,8 +76,10 @@ Planned. Each v1 issue adds its part. ```sh go test -race ./... go vet ./... -golangci-lint run --build-tags e2e # the tag adds the files of test/e2e -go test -race -tags e2e ./test/e2e/... # needs Docker, ssh and git +# the tag adds the files of test/e2e +golangci-lint run --build-tags e2e +# needs Docker, ssh and git +go test -race -tags e2e ./test/e2e/... ``` The last one is the end-to-end test (DESIGN.md section 17). It builds diff --git a/README.md b/README.md index c6e47c4..cc69db1 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,8 @@ root on the broker host on the agent machine useradd --system --home-dir /var/lib/nodkey --shell /usr/sbin/nologin nodkey install -d -m 0755 /etc/nodkey install -d -m 0700 /etc/nodkey/credentials /run/nodkey-setup - nodkey keygen --out /run/nodkey-setup/broker-tls-key # prints the pin of the broker + # prints the pin of the broker + nodkey keygen --out /run/nodkey-setup/broker-tls-key systemd-creds encrypt --with-key=host+tpm2 --name=broker-tls-key \ /run/nodkey-setup/broker-tls-key /etc/nodkey/credentials/broker-tls-key.cred rm /run/nodkey-setup/broker-tls-key @@ -158,14 +159,16 @@ root on the broker host on the agent machine systemd-creds encrypt --with-key=host+tpm2 --name=telegram-token - /etc/nodkey/credentials/telegram-token.cred systemd-ask-password -n "bws access token:" | systemd-creds encrypt --with-key=host+tpm2 --name=bws-token - /etc/nodkey/credentials/bws-token.cred - install -m 0644 /dev/null /etc/nodkey/known_hosts # empty: no SSH keys yet + # empty: no SSH keys yet + install -m 0644 /dev/null /etc/nodkey/known_hosts ``` 4. **On the agent machine**, make the TLS key of the client: ```sh install -d -m 0700 ~/.config/nodkey - nodkey keygen --out ~/.config/nodkey/client.key # prints the pin of the client + # prints the pin of the client + nodkey keygen --out ~/.config/nodkey/client.key ``` Then write `~/.config/nodkey/client.toml`, with mode `0600`: diff --git a/docs/manual-ntfy-test.md b/docs/manual-ntfy-test.md index 5c38620..335eb32 100644 --- a/docs/manual-ntfy-test.md +++ b/docs/manual-ntfy-test.md @@ -60,7 +60,8 @@ on the iPhone. On the ntfy server: ```sh -ntfy user add nodkey-test-broker # asks for a password +# asks for a password +ntfy user add nodkey-test-broker ntfy user add nodkey-test-phone ntfy access nodkey-test-broker nodkey-test-requests write-only ntfy access nodkey-test-broker nodkey-test-answers read-only @@ -78,10 +79,12 @@ In terminal 3: ```sh T=~/.config/nodkey-test install -d -m 0700 "$T" -read -rs TOKEN # paste the token, then press Enter; it is not shown +# paste the token, then press Enter; it is not shown +read -rs TOKEN (umask 077; printf '%s\n' "$TOKEN" > "$T/ntfy-token") unset TOKEN -ls -l "$T/ntfy-token" # must show -rw------- +# must show -rw------- +ls -l "$T/ntfy-token" ``` Check the access rules, without a token: diff --git a/docs/manual-telegram-test.md b/docs/manual-telegram-test.md index 7f200bc..07fb285 100644 --- a/docs/manual-telegram-test.md +++ b/docs/manual-telegram-test.md @@ -59,10 +59,12 @@ In terminal 3: ```sh T=~/.config/nodkey-test install -d -m 0700 "$T" -read -rs TOKEN # paste the token, then press Enter; it is not shown +# paste the token, then press Enter; it is not shown +read -rs TOKEN (umask 077; printf '%s\n' "$TOKEN" > "$T/telegram-token") unset TOKEN -ls -l "$T/telegram-token" # must show -rw------- +# must show -rw------- +ls -l "$T/telegram-token" ``` ### 3. A helper for the Bot API @@ -93,7 +95,8 @@ tg getUpdates '{"allowed_updates":["message"]}' | jq '.result[].message.from.id' Keep the number in a variable: ```sh -CHAT=123456789 # your user ID; the private chat with the bot has the same ID +# your user ID; the private chat with the bot has the same ID +CHAT=123456789 ``` From now on, never call `getUpdates` while the broker runs, except in @@ -104,7 +107,8 @@ From now on, never call `getUpdates` while the broker runs, except in In terminal 3, in the nodkey source: ```sh -git rev-parse HEAD # write this commit into the results +# write this commit into the results +git rev-parse HEAD go build -o "$T/nodkey" ./cmd/nodkey install -d -m 0700 "$T/bin" "$T/state" "$T/home" "$T/home/.config" "$T/home/.config/nodkey" "$T/nodkey" keygen --out "$T/broker.key" @@ -389,7 +393,8 @@ seconds were too short for it. So first give each request 3 minutes. ```sh sed -i 's/^timeout = "60s"$/timeout = "180s"/' "$T/broker.toml" - grep '^timeout' "$T/broker.toml" # must show: timeout = "180s" + # must show: timeout = "180s" + grep '^timeout' "$T/broker.toml" ``` 2. Restart the broker, so that it reads the new timeout. First stop it diff --git a/docs/setup.md b/docs/setup.md index 3881ffc..1a40d13 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -202,7 +202,8 @@ without it, such as for 0.1.0. Then take the file `nodkey` out of the tarball: ```sh -VERSION=0.2.0 # the version you downloaded +# the version you downloaded +VERSION=0.2.0 tar -xzf "nodkey_${VERSION}_linux_amd64.tar.gz" nodkey ``` @@ -400,7 +401,8 @@ of your own. Only your taps count. the broker: ```sh - read -rs TOKEN # paste the token, then press Enter; it is not shown + # paste the token, then press Enter; it is not shown + read -rs TOKEN printf 'url = "https://api.telegram.org/bot%s/getUpdates"\n' "$TOKEN" | curl -sS --config - -H 'Content-Type: application/json' \ -d '{"allowed_updates":["message","my_chat_member"]}' @@ -469,7 +471,8 @@ the broker reads. ([access control](https://docs.ntfy.sh/config/#access-control)): ```sh - ntfy user add nodkey-broker # asks for a password + # asks for a password + ntfy user add nodkey-broker ntfy user add phone ntfy access nodkey-broker nodkey-requests write-only ntfy access nodkey-broker nodkey-answers read-only @@ -904,7 +907,8 @@ access to it: such an account could read them too. bws. For the store `vault`: ```sh - umask 077 # new files private; run `umask 022` before you copy the unit (section 8) + # new files private; run `umask 022` before you copy the unit (section 8) + umask 077 install -d -o nodkey -g nodkey -m 0700 /var/lib/nodkey /var/lib/nodkey/stores /var/lib/nodkey/stores/vault # Only for Bitwarden's EU cloud or your own server: sudo -u nodkey env -i -C /var/lib/nodkey/stores/vault HOME=/var/lib/nodkey/stores/vault \ @@ -1721,8 +1725,10 @@ export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/nodkey/agent.sock" Check it: ```sh -ssh-add -l # the keys that this client may use, with their names -ssh deploy@web-1 true # a message comes for the login +# the keys that this client may use, with their names +ssh-add -l +# a message comes for the login +ssh deploy@web-1 true ``` `ssh`, `git` and Ansible work through it with no changes. Some things to diff --git a/docs_test.go b/docs_test.go new file mode 100644 index 0000000..71abe98 --- /dev/null +++ b/docs_test.go @@ -0,0 +1,122 @@ +package nodkey + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestDocsShellLinesHaveNoTrailingComment checks the shell blocks of the +// docs (issue #92). Interactive zsh does not treat "#" as the start of a +// comment unless the option interactivecomments is on, and it is off by +// default. A reader who pastes "cmd # note" into zsh passes "#" and the +// words after it to cmd. So a note goes on its own line above the command. +func TestDocsShellLinesHaveNoTrailingComment(t *testing.T) { + files, err := filepath.Glob("docs/*.md") + if err != nil { + t.Fatal(err) + } + files = append(files, "README.md", "AGENTS.md") + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, bad := range trailingShellComments(string(data)) { + t.Errorf("%s:%d: a shell line ends in a # comment; put the note on its own line above the command (issue #92): %s", + f, bad.line, bad.text) + } + } +} + +// TestTrailingShellComments checks the finder itself, so that the test +// above cannot pass because the finder sees nothing. +func TestTrailingShellComments(t *testing.T) { + doc := strings.Join([]string{ + "text # not code", + "```sh", + "# a note on its own line", + "read -rs TOKEN # paste the token", + `echo "a # in quotes" 'and # here'`, + "curl https://example.com/#anchor", + "echo $# ${#x}", + "```", + "```toml", + "key = 1 # TOML comment", + "```", + " ```bash", + " VERSION=0.2.0 # the version", + " ```", + "```", + "plain block # not shell", + "```", + }, "\n") + got := trailingShellComments(doc) + want := []int{4, 13} + if len(got) != len(want) { + t.Fatalf("found %v, want lines %v", got, want) + } + for i := range want { + if got[i].line != want[i] { + t.Errorf("finding %d is on line %d, want %d", i, got[i].line, want[i]) + } + } +} + +type shellLine struct { + line int + text string +} + +// trailingShellComments returns the lines of the sh, bash, shell, zsh and +// console blocks of a Markdown text that have a "#" comment after a +// command. +func trailingShellComments(doc string) []shellLine { + var out []shellLine + inBlock, isShell := false, false + for i, line := range strings.Split(doc, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + if inBlock { + inBlock, isShell = false, false + continue + } + inBlock = true + switch strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) { + case "sh", "bash", "shell", "zsh", "console": + isShell = true + } + continue + } + if isShell && hasTrailingComment(trimmed) { + out = append(out, shellLine{line: i + 1, text: trimmed}) + } + } + return out +} + +// hasTrailingComment reports whether a shell line has a word that starts +// with "#" after some other word, outside quotes. +func hasTrailingComment(line string) bool { + var quote rune + prev := ' ' + seenWord := false + for _, r := range line { + switch { + case quote != 0: + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + seenWord = true + case r == '#' && (prev == ' ' || prev == '\t'): + return seenWord + case r != ' ' && r != '\t': + seenWord = true + } + prev = r + } + return false +} From 2c6aa4dcb4d0f8007ec98a251af8e60072f51658 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:23:29 +0200 Subject: [PATCH 2/3] test: check the shell blocks of every Markdown file at the root (#92) --- AGENTS.md | 2 +- docs_test.go | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bf986d0..9aa1356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Planned. Each v1 issue adds its part. the results. - `docs/releasing.md` - how releases are made and checked. - `docs_test.go` (at the root of the module) checks that no shell - block in `README.md`, `AGENTS.md` or `docs/` ends a command line + block in a Markdown file at the root or in `docs/` ends a command line with a `#` comment: zsh does not treat it as a comment (#92). - `test/e2e/` - the end-to-end test with `sshd` in a container (DESIGN.md section 17). Build tag `e2e`. The image of the container is diff --git a/docs_test.go b/docs_test.go index 71abe98..6df3d5f 100644 --- a/docs_test.go +++ b/docs_test.go @@ -11,13 +11,21 @@ import ( // docs (issue #92). Interactive zsh does not treat "#" as the start of a // comment unless the option interactivecomments is on, and it is off by // default. A reader who pastes "cmd # note" into zsh passes "#" and the -// words after it to cmd. So a note goes on its own line above the command. +// words after it to cmd. So a note goes on its own line above the +// command. zsh may print "command not found: #" for such a line, but no +// command gets extra words. func TestDocsShellLinesHaveNoTrailingComment(t *testing.T) { - files, err := filepath.Glob("docs/*.md") - if err != nil { - t.Fatal(err) + var files []string + for _, pattern := range []string{"*.md", "docs/*.md"} { + found, err := filepath.Glob(pattern) + if err != nil { + t.Fatal(err) + } + files = append(files, found...) + } + if len(files) < 3 { + t.Fatalf("found only %d Markdown files: run the test from the root of the module", len(files)) } - files = append(files, "README.md", "AGENTS.md") for _, f := range files { data, err := os.ReadFile(f) if err != nil { From 90761e36f6fa70416ff1f1cceb67d8affd7bb708 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:24:32 +0200 Subject: [PATCH 3/3] test: mark the read of a docs file for gosec (#92) --- docs_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs_test.go b/docs_test.go index 6df3d5f..0b2b7db 100644 --- a/docs_test.go +++ b/docs_test.go @@ -27,7 +27,7 @@ func TestDocsShellLinesHaveNoTrailingComment(t *testing.T) { t.Fatalf("found only %d Markdown files: run the test from the root of the module", len(files)) } for _, f := range files { - data, err := os.ReadFile(f) + data, err := os.ReadFile(f) //nolint:gosec // G304: a Markdown file of the repository, from a fixed glob. if err != nil { t.Fatal(err) }