Skip to content

feat: warn when the client and the broker run different versions - #93

Merged
groupsky merged 2 commits into
mainfrom
feat/version-header
Oct 17, 2026
Merged

groupsky merged 2 commits into
mainfrom
feat/version-header

Conversation

@groupsky

Copy link
Copy Markdown
Contributor

Merge after #88. The warning and the notice name nodkey update, which #88 adds. Both must be in the same release.

What this changes

The client and the broker now tell each other their version, and say so
when the versions differ. Today a mixed pair can fail with only "the
answer of the broker is not valid".

  • The header. Each request has Nodkey-Version with the client's
    version. Each answer of the broker has it with the broker's version:
    every status, also errors, 404, 405 and the 102 Processing (which still
    has the short code). It is a header, not a JSON field, so old and new
    versions still read each other.
  • The rule. A side takes the other side's value only when it is one
    header that matches ^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$. Any other
    value counts as "no version" and is never printed or logged. Only
    release versions (X.Y.Z, numbers 0 to 999999, no leading zeros) are
    compared, as numbers. dev, a commit or 1.2.0-rc.1 is a build from
    the source: no warning and no notice on either side.
  • Client. After each final answer, whatever its status, the client
    compares. When the releases differ, or the broker sends no valid
    version, it prints one warning on stderr, before its last line or
    before the command starts. It says which side is older and what to do
    there: sudo nodkey update on this machine, or nodkey update as root
    on the broker host. A broker that sends no version runs 0.1, which has
    no nodkey update, so then the warning points to section 13 of
    docs/setup.md. nodkey run and nodkey ssh-grant make one
    request, so at most one line. nodkey ssh-agent prints one line for
    each version that the broker sends (at most 16). The warning never goes
    into the command's environment or output.
  • Broker. The client's version is a claim. It changes no answer, no
    check and no audit entry, and the request never waits for it. When the
    claim is missing or names another release, the broker writes one
    journal line, once for each client in each start, however many values
    the client sends, and sends one silent notice. For a client that sends
    no version, the notice points to section 13 of docs/setup.md, not to
    nodkey update.
  • The notice path. A new telegram.Approver.Inform sends the
    broker's own text to the notice chat (notice_chat_id, else the
    approver's chat), silent, link previews off. It has its own count, at
    most 3 in any 60 seconds, apart from the 20 of Notify and the 6 per
    client of the approval core, so it never takes from the limits of the
    notices of "never ask" uses. Each call is one try. The broker sends it
    from a goroutine that the service tracks, so it never delays a
    request; Service.Close cancels it (stop step 2).
  • Tries. Each config client has a state: none, sending, sent. Only a
    notice that went out ends the tries in this start. A client has at
    most one try at a time. After a failed try, also one that the limit of
    3 stops, the next try comes with a request of that client at least 60
    seconds after the failure; there is no timer. Each failed try gets one
    journal line. So 4 or more old clients in the first minute, or a start
    while Telegram cannot be reached, still get their notices later, and 3
    client keys cannot use up the places to hide another old client. A try
    that failed after Telegram took the message can rarely give the notice
    more than once; the real bound is one try a minute for each client.
    DESIGN.md says so. A try that fails while the broker stops says in the
    journal that no new try comes.
  • Code. New package internal/version (the rule, ParseRelease,
    Compare, Check), which feat: say when a newer release is out #90 can reuse. The audit log now uses
    version.Valid for the broker_started version, instead of its own
    copy of the pattern. api.VersionHeader and api.Version hold the
    protocol part. The binary's version is passed in explicitly:
    broker.Parts.Version, a new notice argument of Service.Run, and a
    version argument of client.Run, client.SSHAgent and
    client.SSHGrant. cmd/nodkey passes main.version.
  • Docs. DESIGN.md: section 5 (the warning can come before the last
    line; no other header is printed), section 7 (the notice, its text,
    its limit, its tries, the rare 429 of Telegram in a full minute, and
    what happens when the polling stops), section 8 (the notice counts
    toward neither "never ask" limit), section 10 (a new "Versions" part
    of the protocol, and the log rule for a claimed release version),
    section 15 (the warning line of each command, and the stop step),
    section 17 (the e2e rule). THREAT-MODEL.md: a new T33 (a client claims
    a false version; every pinned client learns the broker's version, so
    after a security release, update the broker first), notes in T9 (own
    limits, and the rare 429), T15 and T31 (the release version in the
    output). docs/setup.md: sections 2, 13 and 14. AGENTS.md: the new
    package.

Tests

  • internal/version: valid and bad versions, release parsing (leading
    zeros, too big, empty, junk, other scripts), numeric compare, and
    Check for every case, also builds from the source on each side.
  • internal/api: Version takes only one valid header.
  • internal/telegram: Inform goes to the notice chat, silent, escaped;
    one try after 429 and server errors, with no wait; its own limit;
    20 Notify calls still pass right after the informs; the token canary
    test covers Inform.
  • internal/broker: the header on 200, 400, 403, 404, 405, 413, 415,
    429, 503 and the 102 (the short code still parses), and none from a
    broker without a valid version; one journal line and one notice per
    client per start with many claimed values; no notice for the same
    version, builds from the source, a broker without a version, or a
    client key that is not pinned; the same answers and audit entries with
    any header value; 6 "never ask" uses with notices right after a version
    notice, then the limit; a failed or hanging notice does not change or
    delay the answer, and Close stops it; no notice before Run or after the
    approver stopped; a failed try is tried again only with a request at
    least 60 seconds after the failure, never by the time alone; 5 old
    clients in one minute, with a limit of 3, all get their notice in the
    end; 20 requests of one client at the same time never make two tries
    at once; a try that fails at Close, or after the approver stopped,
    says that no new try comes.
  • internal/client: nodkey run with the same version, an older client,
    an older broker, no header, bad header values (never shown), builds
    from the source, and error answers (the warning comes before the last
    line); the request header; nodkey ssh-grant; nodkey ssh-agent with
    a broker that changes its version; the bound of 16 lines; the real
    broker service with a version.
  • cmd/nodkey: a child broker built as release 0.2.0 sends its version,
    and sends one notice through the real Telegram approver to a fake
    Telegram server; the stop is clean.
  • Mutations checked by hand: no header on answers, no once-per-client
    mark, a notice sent in the request goroutine, no memory of warned
    versions, a check only on 200, a raw value in the warning, no retry, no
    wait of 60 seconds, two tries at once, and the journal line on every
    try each make a new test fail.

Results on 2026-10-17, after the fixes of the security review:

  • CI=true go test -race ./... with Go 1.27.1 and with Go 1.26.0: pass.
  • go vet ./... (both versions) and go vet -tags e2e ./test/e2e/...:
    pass. gofmt -l .: nothing.
  • go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 run --build-tags e2e:
    0 issues.
  • The new tests with -race -count=20 -cpu 1,2,4, package by package:
    pass.
  • CI=true go test -race -tags e2e ./test/e2e/...: pass. Both sides
    there are dev builds, so no notice goes out, and the rule "no message
    without buttons" stays as it was.

Left out, and why

  • Two kinds of answers have no version header: the answers that net/http
    writes itself to a request that is not valid HTTP, and the 403 of the
    TLS layer for a request without a stored client name. Both come before
    the broker's code, and the client never causes them. DESIGN.md says so.
  • The client does not compare on the 102, only on the final answer.
  • The warning and the notice name nodkey update only when both sides
    send their version. That command comes with feat: install script and nodkey update #88: merge feat: install script and nodkey update #88 before or
    with this, in the same release.
  • The security review found nothing high. Its notes are fixed here: the
    tries of the notice (M1), no nodkey update advice for a side without
    a version (M2), older texts in DESIGN.md and THREAT-MODEL.md (M3), the
    rare 429 (L1), what a pinned client learns (L2), and two wordings. The
    fix review found only nits, also fixed: "about 18" "never ask" notices
    for the rare 429, "rarely more than one" notice, the place of the
    warning in DESIGN.md section 5, the log line of a try that fails while
    the broker stops, and "nodkey 0.1" (also 0.1.1) in docs/setup.md.

Closes #89

@groupsky
groupsky merged commit a79bf9a into main Oct 17, 2026
7 checks passed
@groupsky
groupsky deleted the feat/version-header branch October 17, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: warn when the client and the broker run different versions

1 participant