diff --git a/AGENTS.md b/AGENTS.md index 99006f7..1faa0f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ Planned. Each v1 issue adds its part. - `internal/version/` - versions of nodkey: the rule of a version that the other side sends, release versions, and how two compare (DESIGN.md section 10). + - `internal/release/` - the newest release of nodkey on GitHub: the + lookup for `nodkey version`, and the broker's check for a newer + release, with its notice (DESIGN.md section 15). Its tests use a fake + GitHub server; no test reaches the real GitHub. - `internal/broker/` - the broker's service: it answers each request in the order of DESIGN.md sections 5 and 6. `nodkey broker` makes its parts and runs it. diff --git a/DESIGN.md b/DESIGN.md index 737abc4..970afcf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -90,7 +90,7 @@ One static Go binary holds all parts. The subcommand picks the role: | `nodkey keygen` | both | Make a TLS key pair for the broker or a client. Print its pin. | | `nodkey audit verify` | trusted machine | Check the hash chain of an audit log. | | `nodkey update` | both, as root | Install a release in place of this binary, with the install script that is built into it (section 18). | -| `nodkey version` | both | Print the version. | +| `nodkey version` | both | Print the version. On a terminal, also say if a newer release is out. | Inside the broker, the approver and the stores sit behind small Go interfaces. Version 1 has one approver (Telegram) and two stores (`bws`, @@ -580,6 +580,20 @@ client's version as a claim. A client that sends no version runs nodkey 0.1, which has no `nodkey update`, so that notice points to the update steps of [docs/setup.md](docs/setup.md#a-new-version-of-nodkey) instead. +A notice about a newer release of nodkey (section 15) has no buttons +either. It goes to the notice chat without a sound, once for each newer +release in each start of the broker, rarely more than once (see +"Telegram details" below). The first check comes 10 minutes after the +start, so a broker that ends sooner sends none. The notice: + +``` +ℹ️ nodkey: A newer release is out: nodkey 0.3.0. The broker runs nodkey 0.2.0. To update the broker, run as root on the broker host: nodkey update. Then restart it: systemctl restart nodkey. The restart ends all grants. Update the agent machines too: sudo nodkey update. Release notes: https://github.com/code6x/nodkey/releases/tag/v0.3.0 +``` + +Both versions are release versions: digits and dots. The broker makes +the link from the version, and ends the text with it, so no dot follows +it. It takes no text and no link from the answer of GitHub. + - The host key shows only `SHA256:` and its first 4 characters. The broker has checked the key against its own `known_hosts`, and the host name is what the person reads. @@ -1021,9 +1035,11 @@ Not masked, for example: needs a notice is denied as well. A notice about the version of a client (below) is different: no new try of it starts, but a try that is still being sent goes on until the service stops (section 15). The - approval core would still let grants and "never ask" rules with - `notify = false` allow requests, but the broker denies every request - from then on (503), and `nodkey broker` stops (section 15). On a + check for a newer release (section 15) goes on too, until the stop + ends it first. The approval core would still let grants and "never + ask" rules with `notify = false` allow requests, but the broker denies + every request from then on (503), and `nodkey broker` stops (section + 15). On a polling conflict, the broker first writes the `error` entry with code `poll_conflict`. - Messages about "never ask" uses can go to a second chat @@ -1052,6 +1068,17 @@ Not masked, for example: If the tries of that notice do not get through in time, the use is denied, as for any other failure of its notice. This needs about 18 such uses in one minute, so it is rare. +- A notice about a newer release (above) goes to the same chat, also + silent and with link previews off. It shares the limit of 3 in any 60 + seconds with the notices about versions, so it takes nothing from the + notices of "never ask" uses either. The check for a newer release + sends it on its own, so it never delays or denies a request. Each try + is one call. A try that fails, also one that the limit stops, is + logged, and the next check, 1 hour later, tries again (section 15). A + try can fail after Telegram took the message, such as on a time-out; + then the next try sends it again. Once a notice went out, that release + gets no more. A try that is still being sent when the broker stops is + cancelled (section 15, step 1 of the stop). - The broker sends a notice about a version in the background, so it never delays or denies a request. A client has at most one try at a time, and each try is one call. A try that fails, also one that its @@ -1061,10 +1088,10 @@ Not masked, for example: this start. A try can fail after Telegram took the message, such as on a time-out; then the next try sends it again. Such a repeated notice is rare and does no harm. A try that is still being sent when the - service stops is cancelled (section 15, step 2), and a try that fails - while the broker stops says that no new try comes. So each client gets - one such notice in each start, rarely more than one, and at most one - try a minute. + service stops is cancelled (section 15, step 3 of the stop), and a try + that fails while the broker stops says that no new try comes. So each + client gets one such notice in each start, rarely more than one, and + at most one try a minute. ## 8. Rules and grants @@ -1950,6 +1977,9 @@ notice_chat_id = 123456789 # optional; default: the approver's chat timeout = "120s" # secrets ssh_timeout = "90s" # SSH signatures +[updates] +check = true # optional; false: never ask GitHub for a newer release + [client.laptop] pin = "sha256:Yf3c...base64..." # printed by `nodkey keygen` @@ -2047,6 +2077,9 @@ More rules: `sshd`'s `LoginGraceTime`. - `max_per_day`, if present, is 1 or more. For no limit, leave it out. So `0` cannot mean "no uses" to a person and "no limit" to the broker. +- `check` in `[updates]` is `true` or `false`. Without it, and without + `[updates]`, it is `true`: a release of the broker asks GitHub for a + newer release (section 15). With `false`, the broker never asks. - Two secrets with `ask = "never"` must not have the same `id`, also not through two `[store.*]` sections. Otherwise two clients could get one item without a tap. @@ -2151,7 +2184,7 @@ nodkey ssh-grant --key NAME --user USER --host NAME... --for DURATION [--reason nodkey keygen --out FILE nodkey audit verify FILE [--anchor SEQ:HASH]... [--anchors FILE|-] nodkey update [--version X.Y.Z] [--gh PATH] [--reinstall] -nodkey version +nodkey version [--offline] ``` `nodkey broker` runs the broker. The default config is @@ -2201,28 +2234,51 @@ it started before. alert is out or the broker stops. Each alert has its own tries. Each failed try gets one log line, with the number of the alert, such as `alert=2` for the second one that waited. -10. Only then does it read the connections on the `listen` address and +10. It starts the check for a newer release, unless `[updates]` says + `check = false` (section 14) or the broker is a build from the + source. The log says whether it runs. The check runs on its own: it + never delays the start or the stop, and never changes a request. + - It waits 10 minutes, then asks GitHub, with the lookup of + `nodkey version` (below), and then every 24 hours. After a failed + lookup, or a notice that did not go out, it asks again after 1 hour + instead. A broker that ends within 10 minutes of its start, such as + one that systemd starts again and again after a failure, never + asks GitHub, and sends no notice in each start. + - When the newest release is newer than the broker's own version, it + writes one line in the log, and sends one notice (section 7). A + notice counts as sent only when Telegram took it. So in each + start, each newer release gets one notice, rarely more than one. A + release that is not newer than the broker, or than a release that + got its notice, gets none: the notice never suggests a downgrade. + - Each failed lookup and each failed try of the notice gets one line + in the log, with a fixed reason, such as a rate limit of GitHub, a + network error or an answer that is not valid. A lookup that fails + sends no notice. +11. Only then does it read the connections on the `listen` address and take requests. It stops on SIGTERM or SIGINT, also during the start, when the polling stops for good (section 7), and when its server fails. It stops in this order: -1. The polling: the `getUpdates` call that waits is cancelled and its +1. The check for a newer release: its wait, a lookup or a notice that + is still running is cancelled, also the dial of a lookup. After this + step, it sends no request and no notice. +2. The polling: the `getUpdates` call that waits is cancelled and its connection closed (section 7). From now on no answer counts: every request that waits for the person is denied. -2. The service: new requests get `503`, and the requests in progress end +3. The service: new requests get `503`, and the requests in progress end and get `503` too (section 10). The broker waits for them, and writes the `foreign_tap` entries that still wait. A notice about the version of a client that is still being sent is cancelled (section 7). -3. The server: it closes the `listen` address and every connection. -4. The alerts of the start: each one gets its first try, also when the +4. The server: it closes the `listen` address and every connection. +5. The alerts of the start: each one gets its first try, also when the stop began right after the start, but no try again. The tries run at the same time, and a try takes at most 30 seconds. So they end at most 30 seconds after the stop began, however many alerts wait. An alert that did not go out is lost; the journal says so. -5. The stores: they clear their tokens and session keys. -6. The `broker_stopped` entry. Then the audit log is closed. +6. The stores: they clear their tokens and session keys. +7. The `broker_stopped` entry. Then the audit log is closed. The stop takes at most 80 seconds. A request may need up to 30 seconds to take its answer, and the alert about an audit log that cannot be @@ -2338,6 +2394,52 @@ Before it, it may print one warning about the version of the broker 0, with 64 on wrong usage, and with 1 on any other error. Then its last line on stderr is `nodkey: (exit 1)`. +`nodkey version [--offline]` prints `nodkey ` on stdout, such as +`nodkey 0.2.0`, and nothing else there: scripts and the installer read +this line. Then, only when stdout is a terminal, the version is a +release (section 10) and `--offline` is not given, it asks GitHub for the +newest release, for at most 10 seconds, and prints one line on stderr: + +``` +nodkey: a newer release is out: nodkey 0.3.0; to update, run "sudo nodkey update"; release notes: https://github.com/code6x/nodkey/releases/tag/v0.3.0 +nodkey: this is the newest release +nodkey: cannot check for a newer release: cannot reach GitHub: the lookup timed out +``` + +A newest release that is older than this nodkey gives "this is the +newest release" too: nodkey never suggests a downgrade. Only stdout +decides whether it asks: when stdout is not a terminal, such as a pipe, +a file or `$(...)` in the installer, it prints no such line and makes no +call to GitHub. A build from the source never asks either. It exits with +0, also when the check fails, with 64 on wrong usage, and with 1 when it +cannot write to stdout. + +The lookup, of `nodkey version` and of the broker (step 10 of the start): + +- `GET https://api.github.com/repos/code6x/nodkey/releases/latest`, + without a login, with the headers `Accept: application/vnd.github+json`, + `X-GitHub-Api-Version: 2022-11-28` and `User-Agent: nodkey/`. + So GitHub learns the address and the version (THREAT-MODEL.md T34). +- It uses the proxy from the environment, as the Telegram code does + (section 7): `HTTPS_PROXY` or `https_proxy`, except for the hosts in + `NO_PROXY` or `no_proxy`. TLS 1.2 or newer, with the system's CA + certificates, or those of `SSL_CERT_FILE` and `SSL_CERT_DIR`. HTTP/1.1 + only. It follows no redirect: a redirect is an error. It keeps no + connection open. +- The TCP connection, the TLS handshake and the headers of the answer + each have 10 seconds, and the whole lookup 30 seconds. The answer is + at most 1 MiB; a larger one is an error. +- It reads only `tag_name`, `draft` and `prerelease`, with exactly these + names, and ignores the other keys. A draft, a prerelease, a missing + key, or a tag that is not `v` and a release version, such as `v0.3.0`, + is an error. +- 403 and 429 mean that GitHub limits the calls without a login from + this address for now. Any other status but 200 is an error with only + the status. +- An error has a fixed text, with at most the HTTP status. No text, + header or link of the answer is ever shown or logged. The link to the + page of the release is made from the version. + `nodkey ssh-agent [--socket PATH]` serves the SSH agent protocol on a Unix socket until `SIGINT` or `SIGTERM` (section 6). It holds no SSH key. @@ -2641,7 +2743,11 @@ bws 2.1.0 and bw 2026.9.1 on 2026-10-14 Deny on it, also after a flood of forged taps, polling conflict, code 429 and server errors, and a canary bot token that must not appear in any error or log line). A child process checks the HTTP client with - `GODEBUG=http2debug=2` and a proxy. The SSH checks also use real data: + `GODEBUG=http2debug=2` and a proxy. The lookup of a newer release runs + against a fake GitHub server: every kind of answer that is not valid, + 403 and 429, a redirect, a time-out, an answer over 1 MiB, and, in a + child process, a proxy. The broker's check runs with a fake lookup, a + fake notice and a fake clock. The SSH checks also use real data: from an in-process SSH server and client, and from the OpenSSH client and, when it is installed, `sshd`. - **Fake approver**: a test approver that says yes, no, nothing, or answers @@ -2657,9 +2763,11 @@ bws 2.1.0 and bw 2026.9.1 on 2026-10-14 programs, and the real Telegram approver. The approver reaches a fake Telegram server through a fake HTTPS proxy that answers TLS with a certificate of a test CA, so nothing goes to the real Bot API. The - tests check the order of the start and the stop, the exit codes, that - core dumps are off, a store that does not start, and a canary from the - request to the fake Telegram server, end to end. + same server answers for GitHub's API, for the check for a newer + release, so nothing goes to the real GitHub either. The tests check the + order of the start and the stop, the exit codes, that core dumps are + off, a store that does not start, the notice about a newer release, + and a canary from the request to the fake Telegram server, end to end. - **Fake stores**: the test binary of `internal/store` acts like `bws` or `bw` when it runs under that name. It records its arguments and its environment, so the tests can check that no token, password or session @@ -2700,8 +2808,9 @@ bws 2.1.0 and bw 2026.9.1 on 2026-10-14 message goes out. The config has no "never ask" rule, nothing in the test makes an alert, and the client and the broker are the same build from the source, so no notice about a version goes out either: every - message must have buttons. At the - end, `nodkey audit verify` checks the whole audit log with every + message must have buttons. A build from the source never asks GitHub + for a newer release, and the test checks that the broker says so. + At the end, `nodkey audit verify` checks the whole audit log with every anchor of the broker's journal, and a copy of the log with one changed entry fails. - The root-only test of `internal/client` runs as root in a container diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 57d9881..f8b3057 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -39,6 +39,7 @@ access to another machine and asks a person before each use. | target SSH hosts | Trusted to be who their host key says. | | the secret stores and the `bws` / `bw` programs | Trusted. Install them from the official source and check what you install. | | the Bitwarden accounts that can read the secrets of the stores | Trusted. Anyone who can use such an account can read these secrets in Bitwarden's own apps, with no tap. So these accounts must not be logged in on any agent machine (see T32). | +| GitHub's API (`api.github.com`) | Trusted only to name the newest release. A false answer can only give a wrong notice or hide one; it cannot install anything (see T34). | ## 4. What nodkey promises @@ -284,7 +285,9 @@ notices of other clients cannot use up the uses of a night; it only denies uses while the limit is full. A notice about the version of a client (T33) counts toward neither limit. It has its own: at most 3 in any 60 seconds, at most one try a minute for each client, and one notice -for each client in each start of the broker, rarely more than one. +for each client in each start of the broker, rarely more than one. A +notice about a newer release (T34) shares that limit of 3, with at most +one try an hour after a failure. Risk that remains: low. Telegram counts all the messages of the notice chat. With up to 3 notices about versions in a minute, such as right after a start, about 18 notices of "never ask" uses already pass the @@ -896,6 +899,60 @@ a machine that is up to date (also the broker host), or hide that a client is old. To know what runs on a machine, run `nodkey version` there. +**T34. The check for a newer release tells GitHub about the broker, or +gets a false answer.** +A release of the broker asks GitHub's API for the newest release of +nodkey: 10 minutes after its start, and then once a day, unless +`[updates]` says `check = false` (DESIGN.md section 15). `nodkey version` +does the same on a terminal. The call has no login and holds no secret. +GitHub learns the address of the host, or of its proxy, the time of each +check, and the exact version of nodkey from the User-Agent: so it can +tell whether the broker misses a security fix. A proxy learns that the +host talks to GitHub. +A false answer can come from GitHub itself, from a proxy that inspects +TLS, or from any CA that the host trusts: one in the system's store, or +one in `SSL_CERT_FILE` or `SSL_CERT_DIR` (DESIGN.md section 7). +- It can name a newer release that does not exist, hide one, or make + the check fail. The notice and the line of `nodkey version` show only + release versions, which are digits and dots, and a link that nodkey + makes from the version: never text or a link from the answer. A tag + that is not `vX.Y.Z`, a draft, a prerelease, a redirect, or an answer + over 1 MiB is an error. Each error gives one line with a fixed text, + and no notice. +- It cannot install anything. The notice only says what to run. + `nodkey update` checks the attestation of the release and the version + of the new binary before it installs (T20). A release that does not + exist, or a file that the release workflow did not build, fails there. +- The check never touches a request, a secret, the audit log or an + approval. Its notices are silent, and share the limit of 3 in any 60 + seconds with the notices about versions (T9, T33). In each start, each + newer release gets one notice, rarely more than one: a try that times + out after Telegram took the message sends it again, at the next check + 1 hour later. The first check of a start comes 10 minutes after it. + So a broker that systemd starts again and again sends no notice, + unless each start runs for 10 minutes or more; then it sends at most + one in each start, about 6 an hour. So it cannot fill the notice chat. + +Two cases are worse than a wrong notice: +- GitHub allows 60 calls an hour without a login from one address. An + agent machine behind the same public address as the broker host can + use them up, such as with `nodkey version` on a terminal in a loop, or + with `curl`. Then each check of the broker gets 403 or 429, and a + security release gets no notice. Only the journal shows it: one line for each + failed check. +- A release that the release workflow built from a merged pull request + passes the attestation check of `nodkey update` (the risk that T20 + accepts). Within a day of each release, the notice asks every owner to + install it. So such a release reaches more brokers, and sooner. + +Risk that remains: low. A false answer, or calls that an agent uses up, +can hide a security release for a while: also watch the releases of the +repository on GitHub, and read the release notes before you update. A +false answer can send the person to run `nodkey update` for no reason, +which then installs nothing. To keep the address and the version from +GitHub, set `check = false` in `[updates]`, and use +`nodkey version --offline`. + ## 7. Assumptions - The broker host is patched, has no agent on it, and only the owner has diff --git a/cmd/nodkey/broker.go b/cmd/nodkey/broker.go index 51101d6..7f4a6a6 100644 --- a/cmd/nodkey/broker.go +++ b/cmd/nodkey/broker.go @@ -25,9 +25,11 @@ import ( "github.com/code6x/nodkey/internal/audit" "github.com/code6x/nodkey/internal/broker" "github.com/code6x/nodkey/internal/config" + "github.com/code6x/nodkey/internal/release" "github.com/code6x/nodkey/internal/store" "github.com/code6x/nodkey/internal/telegram" "github.com/code6x/nodkey/internal/transport" + nodkeyversion "github.com/code6x/nodkey/internal/version" ) // defaultBrokerConfig is the broker config of DESIGN.md section 14. @@ -84,6 +86,11 @@ var ( alertMaxWait = 5 * time.Minute ) +// releaseFirstWait is the wait from the start of the broker to its first +// check for a newer release (release.FirstWait). It is a variable only so +// that a test can make it short. +var releaseFirstWait = release.FirstWait + // newStore makes the store of one [store.*] section. It is a variable // only so that a test can run nodkey broker in a child process with a // store whose start fails; nodkey never changes it. @@ -233,6 +240,11 @@ type brokerParts struct { // started is true once the broker_started entry is on disk. started bool + // stopCheck ends the check for a newer release; checkDone is closed + // when the check returned. Both are nil when the check is off. + stopCheck context.CancelFunc + checkDone chan struct{} + // stopRun ends the service's Run; runDone is closed when Run returned, // and runErr is its error, set before runDone is closed. stopRun context.CancelFunc @@ -253,9 +265,10 @@ type brokerParts struct { // 15): the config and the checks of the files it names, no core dumps, // the broker's TLS key, the SSH keys, the audit log, the stores, the // service, the Telegram approver and the approval core, the server and -// its listen address, the broker_started entry, the approver's Run, and -// only then Serve. On an error it returns at once; stop then ends what -// start made. It returns context.Canceled when ctx ended during the start. +// its listen address, the broker_started entry, the approver's Run, the +// check for a newer release, and only then Serve. On an error it returns +// at once; stop then ends what start made. It returns context.Canceled +// when ctx ended during the start. func (b *brokerParts) start(ctx context.Context, path string) error { b.log.Info("nodkey broker: starting", slog.String("version", version), slog.String("config", path)) cfg, err := config.LoadBroker(path) // it runs CheckFiles @@ -357,6 +370,7 @@ func (b *brokerParts) start(ctx context.Context, path string) error { b.started = true b.startRun(core) b.sendAlerts(tg.Alert) + b.startCheck(tg.Inform) b.serve(srv, ln) b.log.Info("nodkey broker: listening; the broker is ready", slog.String("address", ln.Addr().String())) return nil @@ -453,6 +467,42 @@ func (b *brokerParts) startRun(core *approval.Core) { b.log.Info("nodkey broker: the approver runs: it polls Telegram for the answers") } +// startCheck starts the check for a newer release (DESIGN.md section 15): +// it asks GitHub 10 minutes after the start, and then every 24 hours, and +// sends a notice with notice when a newer release is out. A broker that +// ends sooner, such as one that systemd starts again and again, never +// asks. The check runs on its own, so it never delays the start, the stop +// or a request. It is off when [updates] says check = false, and in a +// build from the source, which has no release to compare. A panic in the +// check ends only the check. +func (b *brokerParts) startCheck(notice broker.NoticeFunc) { + own, isRelease := nodkeyversion.ParseRelease(version) + switch { + case !b.cfg.Updates.Check: + b.log.Info("nodkey broker: the check for a newer release is off, as [updates] check = false says") + return + case !isRelease: + b.log.Info("nodkey broker: the check for a newer release is off: this is a build from the source") + return + } + check := release.NewCheck(own, releaseFirstWait, release.New(own).Latest, notice, b.log) + ctx, cancel := context.WithCancel(context.Background()) + b.stopCheck = cancel + b.checkDone = make(chan struct{}) + go func() { + defer close(b.checkDone) + defer func() { + if recover() != nil { + b.log.Error("nodkey broker: the check for a newer release panicked; the panic value is not logged; " + + "the broker goes on without it") + } + }() + check.Run(ctx) + }() + b.log.Info("nodkey broker: the check for a newer release runs: it asks GitHub 10 minutes after the start, " + + "and then every 24 hours") +} + // sendAlerts sends the alert messages that waited for the approver with // send, each in a goroutine of its own. So their tries run at the same // time: a slow try of one alert does not delay the others, and the stop @@ -574,20 +624,28 @@ func errText(err error) string { } // stop ends the parts that start made, in this order (DESIGN.md section -// 15): the approver's Run, so the polling stops and its connection is -// closed; the service, which ends the requests in progress and waits for -// them; the server; the alert messages of the start; the stores; and last -// the broker_stopped entry and the audit log. It waits at most until -// deadline: when a part takes longer, it gives up on the rest, and the -// process ends. It returns the error of Run, and whether the stop was -// clean: every part ended in time without an error or a panic, and the -// audit log took its last entry. +// 15): the check for a newer release; the approver's Run, so the polling +// stops and its connection is closed; the service, which ends the +// requests in progress and waits for them; the server; the alert messages +// of the start; the stores; and last the broker_stopped entry and the +// audit log. It waits at most until deadline: when a part takes longer, +// it gives up on the rest, and the process ends. It returns the error of +// Run, and whether the stop was clean: every part ended in time without +// an error or a panic, and the audit log took its last entry. func (b *brokerParts) stop(deadline time.Time) (runErr error, clean bool) { - b.log.Info("nodkey broker: stopping: the approver, the service, the server, the stores, then the audit log") + b.log.Info("nodkey broker: stopping: the check for a newer release, the approver, the service, the server, " + + "the stores, then the audit log") clean = true if b.alertsStop != nil { close(b.alertsStop) // no more tries again of an alert of the start } + if b.stopCheck != nil { + b.stopCheck() // the wait, a lookup or a notice that runs now is cancelled + if !waitUntil(b.checkDone, deadline) { + return nil, b.late("the check for a newer release") + } + b.log.Info("nodkey broker: the check for a newer release stopped") + } if b.stopRun != nil { b.stopRun() if !waitUntil(b.runDone, deadline) { diff --git a/cmd/nodkey/broker_linux_test.go b/cmd/nodkey/broker_linux_test.go index 426cb6d..a75f0ae 100644 --- a/cmd/nodkey/broker_linux_test.go +++ b/cmd/nodkey/broker_linux_test.go @@ -48,6 +48,9 @@ const ( lineStoresClosed = "nodkey broker: the stores are closed" lineAuditClosed = "nodkey broker: the audit log is closed" lineStopped = "nodkey broker: stopped" + lineCheckRuns = "nodkey broker: the check for a newer release runs" + lineCheckOff = "nodkey broker: the check for a newer release is off" + lineCheckStopped = "nodkey broker: the check for a newer release stopped" ) // secretsRequest returns a request of one variable for the secret name. @@ -243,6 +246,132 @@ func TestBrokerVersionNotice(t *testing.T) { } } +// TestBrokerReleaseNotice: a release of nodkey broker starts its check +// for a newer release after the approver runs. Here its first check comes +// at once, not 10 minutes after the start (TestBrokerReleaseCheckWaits). +// It asks GitHub through the proxy of the environment, with its version in +// the User-Agent. A newer release gives one silent notice in the chat of +// the notices, made only of the versions, the steps and a link made from +// the version: no text or link of GitHub's answer. The check stops first, +// and the stop is clean. +func TestBrokerReleaseNotice(t *testing.T) { + t.Parallel() + e := startListening(t, brokerOptions{newestRelease: "v0.3.0"}, seamReleaseVersion, seamFastReleaseCheck) + e.stderr.wait(t, lineListening) + var notice struct { + ChatID int64 `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode"` + DisableNotification bool `json:"disable_notification"` + LinkPreviewOptions struct { + IsDisabled bool `json:"is_disabled"` + } `json:"link_preview_options"` + } + if err := json.Unmarshal([]byte(e.tg.waitAlert("A newer release is out")), ¬ice); err != nil { + t.Fatal(err) + } + want := "ℹ️ nodkey: A newer release is out: nodkey 0.3.0. The broker runs nodkey 0.2.0. To update the broker, " + + "run as root on the broker host: nodkey update. Then restart it: systemctl restart nodkey. The restart ends " + + "all grants. Update the agent machines too: sudo nodkey update. Release notes: " + + "https://github.com/code6x/nodkey/releases/tag/v0.3.0" + if notice.ChatID != approverID || notice.Text != want || notice.ParseMode != "HTML" || !notice.DisableNotification || + !notice.LinkPreviewOptions.IsDisabled { + t.Errorf("the notice: %+v", notice) + } + e.stderr.wait(t, "release: the notice about a newer release of nodkey went out") + e.stderr.inOrder(t, lineApproverRuns, lineCheckRuns, lineListening) + e.signal(syscall.SIGTERM) + e.mustExit(exitOK) + e.stderr.inOrder(t, lineStopSignal, lineCheckStopped, lineRunStopped, lineSvcStopped, lineStopped+`" exit_code=0`) + if n := e.tg.alertCount("A newer release is out"); n != 1 { + t.Errorf("%d notices about the newer release, want 1", n) + } + if n := e.stderr.count("a newer release of nodkey is out"); n != 1 { + t.Errorf("%d lines in the journal about the newer release, want 1", n) + } + calls := e.tg.githubCalls() + if len(calls) != 1 { + t.Fatalf("%d calls to GitHub, want 1", len(calls)) + } + for name, want := range map[string]string{ + "User-Agent": "nodkey/0.2.0", "Accept": "application/vnd.github+json", "X-Github-Api-Version": "2022-11-28", + "Authorization": "", + } { + if got := strings.Join(calls[0].Values(name), ","); got != want { + t.Errorf("the call to GitHub has %s %q, want %q", name, got, want) + } + } + github := 0 + for _, p := range e.tg.proxiedBy(e.proxyUser) { + switch p { + case "CONNECT " + githubHost + ":443": + github++ + case "CONNECT " + telegramHost + ":443": + default: + t.Errorf("the proxy was asked for %q", p) + } + } + if github != 1 { + t.Errorf("the proxy was asked %d times for GitHub, want 1", github) + } + if strings.Contains(e.tg.bodies()+e.stderr.text(), "not this") { + t.Error("text of GitHub's answer reached Telegram or the journal") + } +} + +// TestBrokerReleaseCheckWaits: the check of a release waits 10 minutes +// after the start, so a broker that stops sooner never asks GitHub, and +// sends no notice. The stop ends that wait first, and is clean. +func TestBrokerReleaseCheckWaits(t *testing.T) { + t.Parallel() + e := startListening(t, brokerOptions{newestRelease: "v0.3.0"}, seamReleaseVersion) + e.stderr.wait(t, lineListening) + e.signal(syscall.SIGTERM) + e.mustExit(exitOK) + e.stderr.inOrder(t, lineApproverRuns, lineCheckRuns+": it asks GitHub 10 minutes after the start", lineListening, + lineStopSignal, lineCheckStopped, lineRunStopped, lineStopped+`" exit_code=0`) + if n := len(e.tg.githubCalls()); n != 0 || slices.Contains(e.tg.proxied(), "CONNECT "+githubHost+":443") { + t.Errorf("%d calls to GitHub before the first check; the proxy was asked for %q", n, e.tg.proxied()) + } + if n := e.tg.alertCount("A newer release is out"); n != 0 { + t.Errorf("%d notices about a newer release, want none", n) + } +} + +// TestBrokerReleaseCheckOff: with [updates] check = false, and in a build +// from the source, the broker never asks GitHub. The journal says why. +func TestBrokerReleaseCheckOff(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + opts brokerOptions + seams []string + why string + }{ + {"check = false", brokerOptions{newestRelease: "v0.3.0", updatesOff: true}, []string{seamReleaseVersion}, + lineCheckOff + ", as [updates] check = false says"}, + {"a build from the source", brokerOptions{newestRelease: "v0.3.0"}, nil, + lineCheckOff + ": this is a build from the source"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + e := startListening(t, tt.opts, tt.seams...) + e.stderr.wait(t, lineListening) + e.signal(syscall.SIGTERM) + e.mustExit(exitOK) + if !e.stderr.has(tt.why) || e.stderr.has(lineCheckRuns) || e.stderr.has(lineCheckStopped) { + t.Errorf("the journal does not say that the check is off; its log:\n%s", e.stderr.text()) + } + if n := len(e.tg.githubCalls()); n != 0 || slices.Contains(e.tg.proxied(), "CONNECT "+githubHost+":443") { + t.Errorf("%d calls to GitHub; the proxy was asked for %q", n, e.tg.proxied()) + } + if n := e.tg.alertCount("A newer release is out"); n != 0 { + t.Errorf("%d notices about a newer release, want none", n) + } + }) + } +} + // TestBrokerPollConflict: when Telegram reports a polling conflict, the // broker sends an alert, writes an error entry with poll_conflict, stops // in order, and exits with exitPollConflict, which the example unit lists diff --git a/cmd/nodkey/brokerenv_linux_test.go b/cmd/nodkey/brokerenv_linux_test.go index 354e86a..d14a288 100644 --- a/cmd/nodkey/brokerenv_linux_test.go +++ b/cmd/nodkey/brokerenv_linux_test.go @@ -85,10 +85,17 @@ const ( // seamReleaseVersion makes the child the release 0.2.0, as a release // build is with -ldflags "-X main.version=0.2.0". seamReleaseVersion = "release-version" + // seamFastReleaseCheck makes the check for a newer release ask at once, + // not 10 minutes after the start. + seamFastReleaseCheck = "fast-release-check" ) -// telegramHost is the host of the Bot API. -const telegramHost = "api.telegram.org" +// telegramHost is the host of the Bot API, and githubHost the host of +// GitHub's API, where the broker's check for a newer release asks. +const ( + telegramHost = "api.telegram.org" + githubHost = "api.github.com" +) // approverID is the made-up Telegram user ID of the approver. const approverID int64 = 7000001 @@ -139,6 +146,8 @@ func TestBrokerChild(t *testing.T) { alertTimeout, alertFirstWait, alertMaxWait = 1500*time.Millisecond, 100*time.Millisecond, 400*time.Millisecond case seamReleaseVersion: version = "0.2.0" + case seamFastReleaseCheck: + releaseFirstWait = 0 case seamNoCoreDumps: if err := syscall.Setrlimit(syscall.RLIMIT_CORE, &syscall.Rlimit{}); err != nil { fmt.Fprintln(os.Stderr, "child: cannot set the hard limit of core dumps to 0") @@ -149,17 +158,20 @@ func TestBrokerChild(t *testing.T) { os.Exit(3) } } - // Every call to Telegram must go to the fake, through the proxy of - // the test: never to the real Bot API. The Telegram client uses + // Every call to Telegram, and to GitHub for the check for a newer + // release, must go to the fake, through the proxy of the test: never + // to the real Bot API or the real GitHub. Both clients use // http.ProxyFromEnvironment, and Go reads it once per process. - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://"+telegramHost+"/", nil) - if err != nil { - os.Exit(3) - } - proxy, err := http.ProxyFromEnvironment(req) - if err != nil || proxy == nil || proxy.Host != os.Getenv(brokerChildProxy) { - fmt.Fprintln(os.Stderr, "child: calls to Telegram would not go to the fake proxy; the broker does not start") - os.Exit(3) + for _, host := range []string{telegramHost, githubHost} { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://"+host+"/", nil) + if err != nil { + os.Exit(3) + } + proxy, err := http.ProxyFromEnvironment(req) + if err != nil || proxy == nil || proxy.Host != os.Getenv(brokerChildProxy) { + fmt.Fprintln(os.Stderr, "child: calls to "+host+" would not go to the fake proxy; the broker does not start") + os.Exit(3) + } } os.Exit(run([]string{"broker", "--config", path}, os.Stdin, os.Stdout, os.Stderr)) } @@ -314,6 +326,11 @@ type brokerOptions struct { // listen is the listen address of the broker; the default is a free // port (freeAddress). listen string + // newestRelease is the tag of the newest release at the fake GitHub; + // the default is v0.2.0, the version of seamReleaseVersion. + newestRelease string + // updatesOff adds [updates] check = false to the config. + updatesOff bool } // brokerEnv is nodkey broker in a child process, with its files and the @@ -477,6 +494,9 @@ ask = "always" hosts = ["db-1"] ` } + if opts.updatesOff { + cfg += "\n[updates]\ncheck = false\n" + } writeTestFile(t, e.cfgPath, []byte(cfg), 0o600) return e } diff --git a/cmd/nodkey/brokerfake_linux_test.go b/cmd/nodkey/brokerfake_linux_test.go index f4b120a..4d48fc1 100644 --- a/cmd/nodkey/brokerfake_linux_test.go +++ b/cmd/nodkey/brokerfake_linux_test.go @@ -27,10 +27,14 @@ import ( ) // The fake Telegram server of the tests of nodkey broker, and the fake -// HTTPS proxy in front of it (see brokerenv_linux_test.go). +// HTTPS proxy in front of it (see brokerenv_linux_test.go). The same +// server is a fake of GitHub's API too, for the broker's check for a +// newer release. // fakeTelegram is a fake Bot API server for the child broker, behind a -// fake HTTPS proxy. It answers like Telegram, and keeps every call. +// fake HTTPS proxy. It answers like Telegram, and keeps every call. For +// the host api.github.com, it answers like GitHub's API with its newest +// release instead (serveGitHub). type fakeTelegram struct { t *testing.T token string // the made-up bot token @@ -51,6 +55,8 @@ type fakeTelegram struct { offline bool // the proxy refuses every new connection, as if Telegram were unreachable connects []proxyRequest // what the proxy was asked to connect to, and by which start changed chan struct{} // closed and made new on each change + newestTag string // the tag of the newest release at the fake GitHub + github []http.Header // the headers of each call to the fake GitHub } // proxyRequest is one request that the fake proxy got. @@ -92,6 +98,10 @@ func newFakeTelegram(t *testing.T, opts brokerOptions) *fakeTelegram { markups: make(map[int64]json.RawMessage), refusePolls: opts.refusePolls, changed: make(chan struct{}), + newestTag: opts.newestRelease, + } + if f.newestTag == "" { + f.newestTag = "v0.2.0" // the version of seamReleaseVersion } if opts.holdAlerts { f.hold = make(chan struct{}) @@ -134,8 +144,8 @@ func newFakeTelegram(t *testing.T, opts brokerOptions) *fakeTelegram { } // connect handles one connection to the fake proxy: a CONNECT to -// api.telegram.org, then TLS with the test CA's certificate, and the -// fake's HTTP server. Anything else is refused. +// api.telegram.org or api.github.com, then TLS with the test CA's +// certificate, and the fake's HTTP server. Anything else is refused. func (f *fakeTelegram) connect(c net.Conn, cfg *tls.Config, tunnels *chanListener) { _ = c.SetReadDeadline(time.Now().Add(brokerWait)) br := bufio.NewReader(c) @@ -153,7 +163,7 @@ func (f *fakeTelegram) connect(c net.Conn, cfg *tls.Config, tunnels *chanListene _ = c.Close() return } - if req.Method != http.MethodConnect || req.Host != telegramHost+":443" { + if req.Method != http.MethodConnect || req.Host != telegramHost+":443" && req.Host != githubHost+":443" { _, _ = io.WriteString(c, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n") _ = c.Close() return @@ -219,7 +229,7 @@ func (l *chanListener) Close() error { func (l *chanListener) Addr() net.Addr { return l.addr } // newTestCA returns a new CA certificate in PEM, and a certificate for -// api.telegram.org that it signed, with its key. +// api.telegram.org and api.github.com that it signed, with its key. func newTestCA(t *testing.T) ([]byte, tls.Certificate) { t.Helper() now := time.Now() @@ -251,7 +261,7 @@ func newTestCA(t *testing.T) ([]byte, tls.Certificate) { leafTemplate := &x509.Certificate{ SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: telegramHost}, - DNSNames: []string{telegramHost}, + DNSNames: []string{telegramHost, githubHost}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, @@ -278,6 +288,10 @@ func writeTelegram(w http.ResponseWriter, status int, body string) { } func (f *fakeTelegram) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Host == githubHost { + f.serveGitHub(w, r) + return + } rest, ok := strings.CutPrefix(r.URL.Path, "/bot") token, method, ok2 := strings.Cut(rest, "/") if !ok || !ok2 || token != f.token { @@ -341,6 +355,30 @@ func (f *fakeTelegram) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// serveGitHub answers like GitHub's API: the newest release of nodkey, +// with newestTag. The other fields hold text and a link that the broker +// must never show. +func (f *fakeTelegram) serveGitHub(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.github = append(f.github, r.Header.Clone()) + tag := f.newestTag + f.notifyLocked() + f.mu.Unlock() + if r.Method != http.MethodGet || r.URL.Path != "/repos/code6x/nodkey/releases/latest" { + writeTelegram(w, http.StatusNotFound, `{"message":"Not Found"}`) + return + } + writeTelegram(w, http.StatusOK, fmt.Sprintf(`{"tag_name":%q,"draft":false,"prerelease":false,`+ + `"name":"not this name","html_url":"https://example.com/not-this-link","body":"not this text"}`, tag)) +} + +// githubCalls returns the headers of each call to the fake GitHub. +func (f *fakeTelegram) githubCalls() []http.Header { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.github) +} + // waitHold makes the alert message of r wait until hold is closed, and // reports true, or until the client goes away, and reports false. held // counts the messages that wait. diff --git a/cmd/nodkey/main.go b/cmd/nodkey/main.go index f0ec2ef..f674f27 100644 --- a/cmd/nodkey/main.go +++ b/cmd/nodkey/main.go @@ -41,7 +41,9 @@ Commands: check the hash chain of an audit log, and its anchors update [--version X.Y.Z] [--gh PATH] [--reinstall] install a release in place of this nodkey; run it as root - version print the version + version [--offline] + print the version; on a terminal, also say if a newer + release is out help print this help ` @@ -81,7 +83,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { case "update": return runUpdate(rest, stdout, stderr) case "version": - return runVersion(rest, stdout, stderr) + return runVersion(rest, version, stdout, stderr, realVersionCheck) case "help": if _, err := io.WriteString(stdout, usageText); err != nil { return exitFailure @@ -94,23 +96,6 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } } -func runVersion(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("nodkey version", flag.ContinueOnError) - fs.SetOutput(stderr) - fs.Usage = func() { _, _ = io.WriteString(stderr, "Usage: nodkey version\n") } - if err := fs.Parse(args); err != nil { - return parseErrorCode(err) - } - if fs.NArg() > 0 { - fs.Usage() - return exitUsage - } - if _, err := fmt.Fprintf(stdout, "nodkey %s\n", version); err != nil { - return exitFailure - } - return exitOK -} - // printUsage writes the usage text to w. A failed write to stderr cannot // be reported anywhere, so the error is ignored. func printUsage(w io.Writer) { diff --git a/cmd/nodkey/terminal_linux.go b/cmd/nodkey/terminal_linux.go new file mode 100644 index 0000000..e91088c --- /dev/null +++ b/cmd/nodkey/terminal_linux.go @@ -0,0 +1,32 @@ +package main + +import ( + "io" + "os" + "syscall" + "unsafe" +) + +// isTerminal reports whether w is a terminal: an *os.File whose file +// descriptor answers the ioctl TCGETS, as isatty(3) checks. A pipe, a +// file and /dev/null are not, though /dev/null is a character device. +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + rc, err := f.SyscallConn() + if err != nil { + return false + } + terminal := false + if err := rc.Control(func(fd uintptr) { + var t syscall.Termios + //nolint:gosec // G103: TCGETS writes a Termios, and the pointer lives only in this call. + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCGETS, uintptr(unsafe.Pointer(&t))) + terminal = errno == 0 + }); err != nil { + return false + } + return terminal +} diff --git a/cmd/nodkey/terminal_linux_test.go b/cmd/nodkey/terminal_linux_test.go new file mode 100644 index 0000000..c0bda91 --- /dev/null +++ b/cmd/nodkey/terminal_linux_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" +) + +// TestIsTerminal: only a terminal is one. A pipe, a regular file, +// /dev/null (a character device) and a writer that is not a file are +// not. The master side of a new pseudo-terminal is a terminal. +func TestIsTerminal(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close(); _ = w.Close() }() + file, err := os.Create(filepath.Join(t.TempDir(), "out")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + null, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer func() { _ = null.Close() }() + closed, err := os.CreateTemp(t.TempDir(), "closed") + if err != nil { + t.Fatal(err) + } + _ = closed.Close() + for name, w := range map[string]io.Writer{ + "a pipe": w, "a file": file, "/dev/null": null, "a closed file": closed, "a buffer": &bytes.Buffer{}, + } { + if isTerminal(w) { + t.Errorf("%s is a terminal", name) + } + } + + // A container may have no pseudo-terminals. + ptmx, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0) + if err != nil { + t.Skipf("no pseudo-terminal: %v", err) + } + defer func() { _ = ptmx.Close() }() + if !isTerminal(ptmx) { + t.Error("a pseudo-terminal is not a terminal") + } +} diff --git a/cmd/nodkey/terminal_other.go b/cmd/nodkey/terminal_other.go new file mode 100644 index 0000000..8363ab2 --- /dev/null +++ b/cmd/nodkey/terminal_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package main + +import "io" + +// isTerminal reports false: nodkey version 1 runs on Linux only, so +// nodkey version asks GitHub only there. +func isTerminal(io.Writer) bool { return false } diff --git a/cmd/nodkey/version.go b/cmd/nodkey/version.go new file mode 100644 index 0000000..7a3600c --- /dev/null +++ b/cmd/nodkey/version.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "time" + + "github.com/code6x/nodkey/internal/release" + nodkeyversion "github.com/code6x/nodkey/internal/version" +) + +// versionCheckTimeout bounds the check of nodkey version for a newer +// release. +const versionCheckTimeout = 10 * time.Second + +const versionUsage = `Usage: nodkey version [--offline] + +Print the version of nodkey on stdout, such as "nodkey 0.2.0". When +stdout is a terminal, a release of nodkey then asks GitHub for the +newest release, for at most 10 seconds, and prints one line on stderr: +whether a newer release is out, or why it could not check. When stdout +is not a terminal, such as a pipe or a file, it prints no such line and +makes no call to GitHub. Neither does a build from the source. + + --offline do not ask GitHub + +The exit code is 0, also when the check fails, 64 on wrong usage, and 1 +when stdout cannot be written. +` + +// versionCheck is what the check of nodkey version for a newer release +// uses. Tests use fakes, so that no test asks GitHub or needs a terminal. +type versionCheck struct { + // isTerminal reports whether stdout is a terminal. + isTerminal func(w io.Writer) bool + // newest asks GitHub for the newest release, for a nodkey whose + // version is own. + newest func(ctx context.Context, own nodkeyversion.Release) (nodkeyversion.Release, error) +} + +// realVersionCheck asks GitHub, on a real terminal. +var realVersionCheck = versionCheck{ + isTerminal: isTerminal, + newest: func(ctx context.Context, own nodkeyversion.Release) (nodkeyversion.Release, error) { + return release.New(own).Latest(ctx) + }, +} + +// runVersion runs nodkey version with args (after "version"), for a nodkey +// whose version is own, and returns the exit code. It prints the line +// "nodkey " on stdout, as before: the installer and scripts read it. +// Then, only when own is a release, --offline is not given and stdout is +// a terminal, it asks GitHub for the newest release, and prints one line +// on stderr. Only stdout decides: when it is a pipe or a file, such as in +// $(...) in the installer, nodkey version never waits for GitHub, and +// prints no other line. +func runVersion(args []string, own string, stdout, stderr io.Writer, check versionCheck) int { + fs := flag.NewFlagSet("nodkey version", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { _, _ = io.WriteString(stderr, versionUsage) } + offline := fs.Bool("offline", false, "do not ask GitHub") + if err := fs.Parse(args); err != nil { + return parseErrorCode(err) + } + if fs.NArg() > 0 { + fs.Usage() + return exitUsage + } + if _, err := fmt.Fprintf(stdout, "nodkey %s\n", own); err != nil { + return exitFailure + } + r, isRelease := nodkeyversion.ParseRelease(own) + if *offline || !isRelease || !check.isTerminal(stdout) { + return exitOK + } + ctx, cancel := context.WithTimeout(context.Background(), versionCheckTimeout) + defer cancel() + newest, err := check.newest(ctx, r) + // A failed write to stderr cannot be reported anywhere; stdout has the + // version. + _, _ = io.WriteString(stderr, releaseLine(r, newest, err)+"\n") + return exitOK +} + +// releaseLine returns the line of nodkey version about the newest release +// newest, for a nodkey whose version is own, or about err. It holds only +// fixed text, release versions, the link made from the version, and the +// error, whose text is fixed too. A newest release that is not newer +// than own, also an older one, is "the newest release": nodkey never +// suggests a downgrade. +func releaseLine(own, newest nodkeyversion.Release, err error) string { + switch { + case err != nil: + return "nodkey: cannot check for a newer release: " + err.Error() + case newest.Compare(own) > 0: + return "nodkey: a newer release is out: nodkey " + newest.String() + + `; to update, run "sudo nodkey update"; release notes: ` + release.PageURL(newest) + } + return "nodkey: this is the newest release" +} diff --git a/cmd/nodkey/version_test.go b/cmd/nodkey/version_test.go new file mode 100644 index 0000000..01cad1b --- /dev/null +++ b/cmd/nodkey/version_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/code6x/nodkey/internal/release" + nodkeyversion "github.com/code6x/nodkey/internal/version" +) + +// fakeVersionCheck is a versionCheck with a fake terminal and a fake +// GitHub. It counts the lookups. +type fakeVersionCheck struct { + terminal bool + newest nodkeyversion.Release + err error + + mu sync.Mutex + lookups int + own nodkeyversion.Release // the version that the last lookup got + timeLeft time.Duration // the time that the context of the last lookup had left +} + +func (f *fakeVersionCheck) check() versionCheck { + return versionCheck{ + isTerminal: func(io.Writer) bool { return f.terminal }, + newest: func(ctx context.Context, own nodkeyversion.Release) (nodkeyversion.Release, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.lookups++ + f.own = own + if dl, ok := ctx.Deadline(); ok { + f.timeLeft = time.Until(dl) + } + return f.newest, f.err + }, + } +} + +func (f *fakeVersionCheck) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.lookups +} + +// rel returns the release major.minor.patch. +func rel(major, minor, patch int) nodkeyversion.Release { + return nodkeyversion.Release{Major: major, Minor: minor, Patch: patch} +} + +// TestVersionCheck: on a terminal, a release asks GitHub, and prints one +// line on stderr: a newer release with its link and the command, or that +// this is the newest release, also when GitHub's newest is older, or why +// it could not check. stdout is the same in every case, and the exit code +// is 0, also after an error. +func TestVersionCheck(t *testing.T) { + newer := `nodkey: a newer release is out: nodkey 0.3.0; to update, run "sudo nodkey update"; ` + + "release notes: https://github.com/code6x/nodkey/releases/tag/v0.3.0\n" + for _, tt := range []struct { + name string + newest nodkeyversion.Release + err error + want string + }{ + {"newer", rel(0, 3, 0), nil, newer}, + {"newer by its numbers", rel(0, 10, 0), nil, "nodkey: a newer release is out: nodkey 0.10.0;"}, + {"the same", rel(0, 2, 0), nil, "nodkey: this is the newest release\n"}, + {"older", rel(0, 1, 9), nil, "nodkey: this is the newest release\n"}, + {"a rate limit", nodkeyversion.Release{}, fmt.Errorf("%w (HTTP 403); try again later", release.ErrRateLimit), + "nodkey: cannot check for a newer release: GitHub limits the calls from this address for now (HTTP 403); " + + "try again later\n"}, + {"a network error", nodkeyversion.Release{}, errors.New("cannot reach GitHub: the lookup timed out"), + "nodkey: cannot check for a newer release: cannot reach GitHub: the lookup timed out\n"}, + } { + f := &fakeVersionCheck{terminal: true, newest: tt.newest, err: tt.err} + var stdout, stderr bytes.Buffer + code := runVersion(nil, "0.2.0", &stdout, &stderr, f.check()) + if code != exitOK || stdout.String() != "nodkey 0.2.0\n" { + t.Errorf("%s: exit code %d, stdout %q", tt.name, code, stdout.String()) + } + if got := stderr.String(); !strings.HasPrefix(got, tt.want) || strings.Count(got, "\n") != 1 { + t.Errorf("%s: stderr %q, want %q", tt.name, got, tt.want) + } + if f.count() != 1 || f.own != rel(0, 2, 0) { + t.Errorf("%s: %d lookups, for %v; want 1, for 0.2.0", tt.name, f.count(), f.own) + } + if f.timeLeft <= 0 || f.timeLeft > 10*time.Second { + t.Errorf("%s: the lookup had %s, want at most 10 seconds", tt.name, f.timeLeft) + } + } +} + +// TestVersionNoCheck: no lookup, and nothing on stderr, when stdout is not +// a terminal, such as a pipe, a file or $(...) in the installer, with +// --offline, or for a build from the source. stdout is the same. +func TestVersionNoCheck(t *testing.T) { + for _, tt := range []struct { + name string + args []string + own string + terminal bool + }{ + {"not a terminal", nil, "0.2.0", false}, + {"--offline", []string{"--offline"}, "0.2.0", true}, + {"-offline", []string{"-offline"}, "0.2.0", true}, + {"--offline=true", []string{"--offline=true"}, "0.2.0", true}, + {"--offline, not a terminal", []string{"--offline"}, "0.2.0", false}, + {"dev", nil, "dev", true}, + {"a commit", nil, "1a2b3c4", true}, + {"a release candidate", nil, "0.3.0-rc.1", true}, + {"a tag", nil, "v0.2.0", true}, + } { + f := &fakeVersionCheck{terminal: tt.terminal, newest: rel(9, 0, 0)} + var stdout, stderr bytes.Buffer + code := runVersion(tt.args, tt.own, &stdout, &stderr, f.check()) + if code != exitOK || stdout.String() != "nodkey "+tt.own+"\n" || stderr.Len() != 0 || f.count() != 0 { + t.Errorf("%s: exit code %d, stdout %q, stderr %q, %d lookups; want 0, the version, nothing, none", + tt.name, code, stdout.String(), stderr.String(), f.count()) + } + } + // --offline=false checks. + f := &fakeVersionCheck{terminal: true, newest: rel(0, 2, 0)} + var stdout, stderr bytes.Buffer + if code := runVersion([]string{"--offline=false"}, "0.2.0", &stdout, &stderr, f.check()); code != exitOK || + f.count() != 1 || stderr.String() != "nodkey: this is the newest release\n" { + t.Errorf("--offline=false: exit code %d, %d lookups, stderr %q", code, f.count(), stderr.String()) + } +} + +// TestVersionUsage: a wrong argument is wrong usage (64), with the usage +// on stderr, nothing on stdout, and no lookup; -h prints the usage and +// exits with 0. A stdout that cannot be written gives 1, and no lookup. +func TestVersionUsage(t *testing.T) { + for _, tt := range []struct { + args []string + code int + }{ + {[]string{"extra"}, exitUsage}, + {[]string{"--offline", "extra"}, exitUsage}, + {[]string{"--bogus"}, exitUsage}, + {[]string{"--offline=maybe"}, exitUsage}, + {[]string{"-h"}, exitOK}, + {[]string{"--help"}, exitOK}, + } { + f := &fakeVersionCheck{terminal: true, newest: rel(0, 3, 0)} + var stdout, stderr bytes.Buffer + code := runVersion(tt.args, "0.2.0", &stdout, &stderr, f.check()) + if code != tt.code || stdout.Len() != 0 || !strings.Contains(stderr.String(), "Usage: nodkey version [--offline]") || + f.count() != 0 { + t.Errorf("%q: exit code %d, stdout %q, stderr %q, %d lookups", tt.args, code, stdout.String(), stderr.String(), + f.count()) + } + } + f := &fakeVersionCheck{terminal: true, newest: rel(0, 3, 0)} + var stderr bytes.Buffer + if code := runVersion(nil, "0.2.0", failingWriter{}, &stderr, f.check()); code != exitFailure || f.count() != 0 { + t.Errorf("a stdout that fails: exit code %d, %d lookups; want %d, none", code, f.count(), exitFailure) + } +} + +// TestVersionRealCheck: nodkey version through run uses the real check, +// which looks at the real stdout: a buffer is not a terminal, so it never +// asks GitHub. +func TestVersionRealCheck(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runVersion(nil, "0.2.0", &stdout, &stderr, realVersionCheck); code != exitOK || + stdout.String() != "nodkey 0.2.0\n" || stderr.Len() != 0 { + t.Errorf("exit code %d, stdout %q, stderr %q", code, stdout.String(), stderr.String()) + } +} diff --git a/contrib/systemd/nodkey.service b/contrib/systemd/nodkey.service index 904c7df..16ef4f4 100644 --- a/contrib/systemd/nodkey.service +++ b/contrib/systemd/nodkey.service @@ -93,12 +93,13 @@ MemoryZSwapMax=0 # log are in its log. The broker limits its own lines. LogRateLimitIntervalSec=0 -# A proxy for the calls to Telegram, if you need one. Never use a proxy -# that inspects TLS: it sees the bot token (DESIGN.md section 7). Every -# user can read Environment= with systemctl show, so a proxy with a user -# name and a password belongs in EnvironmentFile=, a file that only root -# can read (mode 0600). The broker logs the proxy's host at start, and -# SSL_CERT_FILE and SSL_CERT_DIR if they are set. +# A proxy for the calls to Telegram, if you need one. The check for a +# newer release at api.github.com uses it too (DESIGN.md section 15). +# Never use a proxy that inspects TLS: it sees the bot token (DESIGN.md +# section 7). Every user can read Environment= with systemctl show, so a +# proxy with a user name and a password belongs in EnvironmentFile=, a +# file that only root can read (mode 0600). The broker logs the proxy's +# host at start, and SSL_CERT_FILE and SSL_CERT_DIR if they are set. #Environment=HTTPS_PROXY=http://proxy.example.com:3128 #EnvironmentFile=/etc/nodkey/proxy.env diff --git a/docs/releasing.md b/docs/releasing.md index e924ff0..dbfa309 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -375,4 +375,9 @@ needs a login (`gh auth login`), even for this public repository. file is not the version that its name says. On the machine that the binary is for, `nodkey version` must also print the version, after you take it out of the tarball (`tar -xzf nodkey`, then - `./nodkey version`). + `./nodkey version`). Its first line is `nodkey` and the version, such + as `nodkey 0.1.0`. On a terminal, a nodkey newer than 0.1.x then asks + GitHub for the newest release, from the machine where you check the + binary, and prints one more line on stderr. To check it without that + call, run `./nodkey version --offline`. nodkey 0.1.x does not know + `--offline`, and does not ask GitHub. diff --git a/docs/setup.md b/docs/setup.md index c3f49d3..6a44f8d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -227,6 +227,10 @@ install -o root -g root -m 0755 nodkey /usr/local/bin/nodkey nodkey version ``` +On a terminal, `nodkey version` of a release also asks GitHub whether a +newer release is out, and says so in one more line. `--offline` skips +that. + ## 3. The broker host ### The user and the directories @@ -766,6 +770,9 @@ notice_chat_id = 123456789 # optional; default: the approver's chat timeout = "120s" # secrets ssh_timeout = "90s" # SSH signatures +[updates] +check = true # optional; false: never ask GitHub for a newer release + [client.laptop] pin = "sha256:Yf3c...base64..." # printed by `nodkey keygen` @@ -815,6 +822,19 @@ for an SSH login: 15 to 110 seconds, 90 seconds by default. sshd drops a login that takes longer than its `LoginGraceTime` (120 seconds by default). +**`[updates]`.** `check` is optional, `true` by default. Then the broker +asks GitHub 10 minutes after its start, and then once a day, whether a +newer release of nodkey is out, and sends you a silent notice when one +is ([section 13](#a-new-version-of-nodkey)). For this, the broker host +needs outbound HTTPS to `api.github.com`. The check uses the proxy of +`HTTPS_PROXY`, as the calls to Telegram do +([section 8](#8-the-systemd-unit)). GitHub sees the address of the +broker host, or of its proxy, and the version of nodkey +([THREAT-MODEL.md T34](../THREAT-MODEL.md#6-threats-and-answers)). If +the broker host cannot reach GitHub, or GitHub should not see this, set +`check = false`. Otherwise the journal gets a line about the failed +check every hour. + **`[client.NAME]`.** One section for each agent machine, with the pin of its TLS key. Make that key on the agent machine first ([section 11](#the-key-and-the-config-of-the-client)). The name shows in @@ -912,7 +932,9 @@ Then change it to fit your setup: - Change the paths if yours are different. - If the broker must reach Telegram through a proxy, set it as the comments in the unit say. Never use a proxy that inspects TLS: it sees - the bot token. + the bot token. The check for a newer release + ([section 7](#7-the-config-file), `[updates]`) uses the same proxy to + reach `api.github.com`. Check the unit. No output means no problem: @@ -941,7 +963,7 @@ What the settings do: | `MemoryMax=1G` | A memory limit for the broker and its store programs. | | `MemorySwapMax=0`, `MemoryZSwapMax=0` | The memory of the broker, with keys and tokens, never goes to swap. | | `LogRateLimitIntervalSec=0` | journald never drops a line of the broker. The anchors of the audit log are in these lines ([section 12](#12-check-the-audit-log)). The broker limits its own lines. | -| `Environment=`, `EnvironmentFile=` (as comments) | An optional proxy for Telegram. A proxy with a password goes into a file that only root can read. | +| `Environment=`, `EnvironmentFile=` (as comments) | An optional proxy for Telegram, and for the check for a newer release at GitHub. A proxy with a password goes into a file that only root can read. | | `NoNewPrivileges=yes` and the lines after it | The sandbox: no new privileges or capabilities, a read-only system, no access to `/home`, a private `/tmp`, no devices, kernel settings, other namespaces or raw sockets, and only the system calls of a normal service. | | `WantedBy=multi-user.target` | `systemctl enable` starts the broker at boot. | @@ -952,8 +974,8 @@ What the unit leaves out on purpose: - `PrivateUsers=yes`: the credentials and the config, owned by root, would look owned by `nobody`, and the broker would refuse them. - `PrivateNetwork=`, `IPAddressDeny=` and the like: the broker must - reach Telegram, and `bws` and `bw` must reach Bitwarden. Do not add - them. + reach Telegram, and GitHub for the check for a newer release, and + `bws` and `bw` must reach Bitwarden. Do not add them. After `systemctl daemon-reload`, `systemd-analyze security nodkey` shows how much the sandbox limits the broker. @@ -1337,6 +1359,20 @@ cut shows as an anchor whose entry is missing. ### A new version of nodkey +The broker tells you when a newer release is out, unless `[updates]` +says `check = false` ([section 7](#7-the-config-file)). It asks GitHub 10 +minutes after its start, and then once a day. For each newer release, it +writes a line in its journal, and sends you one silent notice in each +start: + +```text +ℹ️ nodkey: A newer release is out: nodkey 0.3.0. The broker runs nodkey 0.2.0. To update the broker, run as root on the broker host: nodkey update. Then restart it: systemctl restart nodkey. The restart ends all grants. Update the agent machines too: sudo nodkey update. Release notes: https://github.com/code6x/nodkey/releases/tag/v0.3.0 +``` + +If the check or the notice fails, the journal says why, and the broker +tries again after 1 hour. On a terminal, `nodkey version` says it too, on +each machine. A build from the source never asks. + 1. Read the release notes. 2. On the broker host, as root, set `VERSION` to the new version, such as `0.2.0`, and install it: diff --git a/internal/config/broker.go b/internal/config/broker.go index acf93c5..f04e148 100644 --- a/internal/config/broker.go +++ b/internal/config/broker.go @@ -48,6 +48,7 @@ type Broker struct { KnownHostsFile string KnownHosts *KnownHosts Telegram Telegram + Updates Updates Clients map[string]Client // by name Stores map[string]Store // by name Secrets map[string]Secret // by name @@ -63,6 +64,14 @@ type Telegram struct { SSHTimeout time.Duration // how long an SSH request waits; default 90s } +// Updates is the [updates] section. Without it, each key has its +// default. +type Updates struct { + // Check turns on the broker's check for a newer release of nodkey + // (DESIGN.md section 15). The default is true. + Check bool +} + // Client is a [client.NAME] section: a machine that may ask. type Client struct { Name string @@ -117,6 +126,7 @@ type ( AuditLog string `toml:"audit_log"` KnownHosts string `toml:"known_hosts"` Telegram telegramSection `toml:"telegram"` + Updates updatesSection `toml:"updates"` Client map[string]clientSection `toml:"client"` Store map[string]storeSection `toml:"store"` Secret map[string]secretSection `toml:"secret"` @@ -129,6 +139,9 @@ type ( Timeout *string `toml:"timeout"` SSHTimeout *string `toml:"ssh_timeout"` } + updatesSection struct { + Check *bool `toml:"check"` + } clientSection struct { Pin string `toml:"pin"` } @@ -214,6 +227,7 @@ func newBroker(f *brokerFile) (*Broker, error) { if b.Telegram, err = parseTelegram(&f.Telegram); err != nil { return nil, err } + b.Updates.Check = f.Updates.Check == nil || *f.Updates.Check if err := b.addClients(f.Client); err != nil { return nil, err } diff --git a/internal/config/broker_test.go b/internal/config/broker_test.go index e3f3c8a..9f57875 100644 --- a/internal/config/broker_test.go +++ b/internal/config/broker_test.go @@ -161,6 +161,7 @@ func TestLoadBrokerValid(t *testing.T) { check("Telegram.NoticeChatID (default)", b.Telegram.NoticeChatID, int64(123456789)) check("Telegram.Timeout (default)", b.Telegram.Timeout, 120*time.Second) check("Telegram.SSHTimeout (default)", b.Telegram.SSHTimeout, 90*time.Second) + check("Updates.Check (default, without [updates])", b.Updates.Check, true) check("number of clients", len(b.Clients), 2) check("Clients[laptop]", b.Clients["laptop"], Client{Name: "laptop", Pin: testPin("laptop")}) @@ -244,6 +245,30 @@ func TestLoadBrokerOptionalValues(t *testing.T) { } } +// TestLoadBrokerUpdates: the broker's check for a newer release is on by +// default, without [updates] and without its key; check = false turns it +// off. +func TestLoadBrokerUpdates(t *testing.T) { + for _, tt := range []struct { + section string + want bool + }{ + {"[updates]\n", true}, + {"[updates]\ncheck = true\n", true}, + {"[updates]\ncheck = false\n", false}, + {"[updates] # the check for a newer release\ncheck = false # off\n", false}, + } { + e := newBrokerEnv(t) + b, err := LoadBroker(e.write(t, edit{"[client.laptop]", tt.section + "\n[client.laptop]"})) + if err != nil { + t.Fatalf("%q: %v", tt.section, err) + } + if b.Updates.Check != tt.want { + t.Errorf("%q: Updates.Check = %v, want %v", tt.section, b.Updates.Check, tt.want) + } + } +} + func TestLoadBrokerErrors(t *testing.T) { pin := testPin("laptop").String() bare := strings.TrimPrefix(pin, "sha256:") @@ -436,6 +461,31 @@ func TestLoadBrokerErrors(t *testing.T) { {name: "ssh_timeout too long", edits: []edit{{"user_id = 123456789", "user_id = 123456789\nssh_timeout = \"111s\""}}, want: "[telegram] ssh_timeout: must be from 15s to 1m50s"}, + // [updates] + {name: "unknown key in [updates]", edits: []edit{{"[client.laptop]", "[updates]\nchek = false\n\n[client.laptop]"}}, + want: `unknown key "updates.chek"`}, + {name: "a second key in [updates]", edits: []edit{{"[client.laptop]", + "[updates]\ncheck = false\nevery = \"1h\"\n\n[client.laptop]"}}, + want: `unknown key "updates.every"`}, + {name: "check in upper case", edits: []edit{{"[client.laptop]", "[updates]\nCHECK = false\n\n[client.laptop]"}}, + want: "1 unknown key;", notWant: "CHECK"}, + {name: "check as text", edits: []edit{{"[client.laptop]", "[updates]\ncheck = \"no\"\n\n[client.laptop]"}}, + want: "line 12: incompatible types: TOML value has type string; destination has type boolean"}, + {name: "check as a number", edits: []edit{{"[client.laptop]", "[updates]\ncheck = 0\n\n[client.laptop]"}}, + want: "line 12: incompatible types"}, + {name: "check as a dotted key at the top", edits: []edit{ + {`listen = "192.0.2.10:7743"`, "listen = \"192.0.2.10:7743\"\nupdates.check = false"}}, + want: `key "updates.check": write it as check = ... below its own [updates] header`}, + {name: "updates not a table", edits: []edit{{`listen = "192.0.2.10:7743"`, "listen = \"192.0.2.10:7743\"\nupdates = false"}}, + want: "line 3: type mismatch for config.updatesSection: expected table but found bool"}, + {name: "updates as an inline table", edits: []edit{ + {`listen = "192.0.2.10:7743"`, "listen = \"192.0.2.10:7743\"\nupdates = { check = false }"}}, + want: "inline tables ({ ... }) are not allowed"}, + {name: "a table below [updates]", edits: []edit{{"[client.laptop]", "[updates.github]\ncheck = false\n\n[client.laptop]"}}, + want: `unknown keys "updates.github", "updates.github.check"`}, + {name: "[[updates]]", edits: []edit{{"[client.laptop]", "[[updates]]\ncheck = false\n\n[client.laptop]"}}, + want: "type mismatch for config.updatesSection: expected table but found []map[string]any"}, + // [client.*] // A name that breaks the rule may be pasted text, so the error does // not show it. diff --git a/internal/release/check.go b/internal/release/check.go new file mode 100644 index 0000000..582e724 --- /dev/null +++ b/internal/release/check.go @@ -0,0 +1,139 @@ +package release + +import ( + "context" + "log/slog" + "time" + + "github.com/code6x/nodkey/internal/version" +) + +// FirstWait is the wait of nodkey broker from its start to its first +// check. A broker that ends sooner, such as one that systemd starts again +// and again, never asks GitHub, and sends no notice in each start +// (DESIGN.md section 15). +const FirstWait = 10 * time.Minute + +// The other times of the broker's check (Check.Run). +const ( + // every is the time from a check that worked to the next one. + every = 24 * time.Hour + // retry is the time from a check that failed, in its lookup or in its + // notice, to the next one. + retry = time.Hour + // noticeTimeout bounds one try of the notice. + noticeTimeout = 30 * time.Second +) + +// Check is the broker's check for a newer release (DESIGN.md section 15). +// It waits first (FirstWait in nodkey broker), then asks GitHub, and then +// every 24 hours; after a failed lookup or a failed notice, it asks again +// after 1 hour instead. When the newest +// release is newer than the broker's own version, it sends one notice +// (DESIGN.md section 7). Each newer release gets one notice that went out +// in a start of the broker: a notice counts as sent only when the notice +// function reports no error. A release that is not newer than the broker, +// or than a release that got its notice, gets none: the notice never +// suggests a downgrade. Make a Check with NewCheck, and call Run once. +type Check struct { + own version.Release + first time.Duration // the wait before the first check + latest func(ctx context.Context) (version.Release, error) + notice func(ctx context.Context, msg string) error + log *slog.Logger + // wait waits for d, or until ctx ends. It is a field only so that a + // test can use a fake. + wait func(ctx context.Context, d time.Duration) error +} + +// NewCheck returns the check of a broker whose version is own. Its first +// check comes after first, such as FirstWait. latest finds the newest +// release, such as Client.Latest. notice sends the notice to the +// approver, such as telegram.Approver.Inform: it makes one try, and its +// ctx has a deadline. log gets a line for each newer release, each +// notice, each failed lookup and each failed notice, with fixed texts and +// release versions only; it may be nil. +func NewCheck(own version.Release, first time.Duration, latest func(ctx context.Context) (version.Release, error), + notice func(ctx context.Context, msg string) error, log *slog.Logger, +) *Check { + if log == nil { + log = slog.New(slog.DiscardHandler) + } + return &Check{own: own, first: first, latest: latest, notice: notice, log: log, wait: sleep} +} + +// Run checks for a newer release until ctx ends. It never stops on its +// own. The wait, a lookup or a notice that runs when ctx ends is +// cancelled, and no request and no notice goes out after Run returned. +// So a ctx that ends during the first wait ends Run without any call. +func (c *Check) Run(ctx context.Context) { + // noticed is the newest release that a notice went out for, or the + // broker's own version; logged is the same for the log line. + noticed, logged := c.own, c.own + for next := c.first; c.wait(ctx, next) == nil; { + next = c.once(ctx, ¬iced, &logged) + } +} + +// once makes one check, and returns the time until the next one. +func (c *Check) once(ctx context.Context, noticed, logged *version.Release) time.Duration { + newest, err := c.latest(ctx) + if ctx.Err() != nil { + return 0 // the broker stops + } + if err != nil { + c.log.Warn("release: cannot check for a newer release of nodkey; the next check is in 1 hour", + slog.String("error", err.Error())) + return retry + } + if newest.Compare(*noticed) <= 0 { + return every // no newer release, or its notice went out + } + if newest.Compare(*logged) > 0 { + *logged = newest + c.log.Warn("release: a newer release of nodkey is out; update the broker host and the agent machines", + slog.String("newest", newest.String()), slog.String("broker_version", c.own.String())) + } + nctx, cancel := context.WithTimeout(ctx, noticeTimeout) + err = c.notice(nctx, noticeText(newest, c.own)) + cancel() + if ctx.Err() != nil { + return 0 + } + if err != nil { + // The errors of the Telegram approver have fixed texts. + c.log.Warn("release: the notice about a newer release of nodkey did not go out; the next check is in 1 hour", + slog.String("newest", newest.String()), slog.String("error", err.Error())) + return retry + } + *noticed = newest + c.log.Info("release: the notice about a newer release of nodkey went out", slog.String("newest", newest.String())) + return every +} + +// noticeText returns the text of the notice about the release newest, for +// a broker that runs own. Both are release versions, which hold only +// digits and dots, and the link is made from newest. The text ends with +// the link, so that no dot follows it. +func noticeText(newest, own version.Release) string { + return "A newer release is out: nodkey " + newest.String() + ". The broker runs nodkey " + own.String() + + ". To update the broker, run as root on the broker host: nodkey update. Then restart it: " + + "systemctl restart nodkey. The restart ends all grants. Update the agent machines too: " + + "sudo nodkey update. Release notes: " + PageURL(newest) +} + +// sleep waits for d, or until ctx ends. A ctx that has ended returns at +// once, also for d 0. +func sleep(ctx context.Context, d time.Duration) error { + if err := ctx.Err(); err != nil { + return err + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-t.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/release/check_test.go b/internal/release/check_test.go new file mode 100644 index 0000000..44c8a64 --- /dev/null +++ b/internal/release/check_test.go @@ -0,0 +1,419 @@ +package release + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "github.com/code6x/nodkey/internal/version" +) + +// lookup is what one lookup of a checkRun returns. +type lookup struct { + newest version.Release + err error +} + +// checkRun is a Check that runs in its own goroutine, with fakes. Its +// wait is a fake clock: the check tells the test each wait it asks for, +// and the wait ends only when the test lets the time pass (tick). The test +// gives the result of each lookup, and decides how each try of the notice +// ends. +type checkRun struct { + t *testing.T + lookups chan lookup + waits chan time.Duration + ticks chan struct{} + cancel context.CancelFunc + done chan struct{} + logs *syncBuffer + + mu sync.Mutex + looked int // the lookups that began + tries []string // the text of each try of the notice + sent []string // the notices that went out + noticeErr error // the result of the next tries + gate chan struct{} // if set, each try waits until it is closed, or its context ends + deadlines []time.Duration +} + +// syncBuffer is a log that many goroutines can write. +type syncBuffer struct { + mu sync.Mutex + b strings.Builder +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +// startCheck runs the check of a broker of version own, with the first +// wait of nodkey broker, and returns when the check waits for its first +// check. That wait must be FirstWait, and no lookup may come before it. +func startCheck(t *testing.T, own version.Release) *checkRun { + t.Helper() + r := &checkRun{t: t, lookups: make(chan lookup), waits: make(chan time.Duration), ticks: make(chan struct{}), + done: make(chan struct{}), logs: &syncBuffer{}} + c := NewCheck(own, FirstWait, r.latest, r.notice, slog.New(slog.NewTextHandler(r.logs, nil))) + c.wait = func(ctx context.Context, d time.Duration) error { + if err := ctx.Err(); err != nil { + return err + } + select { + case r.waits <- d: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-r.ticks: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + ctx, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + go func() { + defer close(r.done) + c.Run(ctx) + }() + t.Cleanup(r.stop) + if got := r.nextWait(); got != 10*time.Minute { + t.Fatalf("the first check in %s, want 10 minutes", got) + } + if n := r.lookupsBegun(); n != 0 { + t.Fatalf("%d lookups before the first wait ended", n) + } + return r +} + +// nextWait returns the next wait that the check asks for. +func (r *checkRun) nextWait() time.Duration { + r.t.Helper() + select { + case d := <-r.waits: + return d + case <-time.After(10 * time.Second): + r.t.Fatal("the check does not wait") + return 0 + } +} + +// tick ends the wait of the check, as the time of the wait passed. +func (r *checkRun) tick() { + r.t.Helper() + select { + case r.ticks <- struct{}{}: + case <-time.After(10 * time.Second): + r.t.Fatal("the check does not wait") + } +} + +// lookupsBegun returns the number of lookups that began. +func (r *checkRun) lookupsBegun() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.looked +} + +func (r *checkRun) latest(ctx context.Context) (version.Release, error) { + r.mu.Lock() + r.looked++ + r.mu.Unlock() + select { + case l := <-r.lookups: + return l.newest, l.err + case <-ctx.Done(): + return version.Release{}, ctx.Err() + } +} + +func (r *checkRun) notice(ctx context.Context, msg string) error { + r.mu.Lock() + r.tries = append(r.tries, msg) + if dl, ok := ctx.Deadline(); ok { + r.deadlines = append(r.deadlines, time.Until(dl)) + } + err, gate := r.noticeErr, r.gate + r.mu.Unlock() + if gate != nil { + select { + case <-gate: + case <-ctx.Done(): + return ctx.Err() + } + } + if err != nil { + return err + } + r.mu.Lock() + r.sent = append(r.sent, msg) + r.mu.Unlock() + return nil +} + +// round lets the wait before the next check pass, gives the lookup of +// that check its result, and returns the wait that the check asks for +// after it. +func (r *checkRun) round(newest version.Release, err error) time.Duration { + r.t.Helper() + r.tick() + r.lookup(newest, err) + return r.nextWait() +} + +// lookup gives the lookup that waits its result. +func (r *checkRun) lookup(newest version.Release, err error) { + r.t.Helper() + select { + case r.lookups <- lookup{newest, err}: + case <-time.After(10 * time.Second): + r.t.Fatal("the check made no lookup") + } +} + +// mustRound fails unless the round asks for the wait want. +func (r *checkRun) mustRound(newest version.Release, err error, want time.Duration) { + r.t.Helper() + if got := r.round(newest, err); got != want { + r.t.Fatalf("after %v, %v: the next check in %s, want %s", newest, err, got, want) + } +} + +// setNotice sets the result of the next tries of the notice. +func (r *checkRun) setNotice(err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.noticeErr = err +} + +// notices returns the tries of the notice, and the notices that went +// out. +func (r *checkRun) notices() (tries, sent []string) { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.tries...), append([]string(nil), r.sent...) +} + +// stop ends the check, and waits until Run returned. +func (r *checkRun) stop() { + r.cancel() + select { + case <-r.done: + case <-time.After(10 * time.Second): + r.t.Error("Run did not return after its context ended") + } +} + +// rel returns the release major.minor.patch. +func rel(major, minor, patch int) version.Release { + return version.Release{Major: major, Minor: minor, Patch: patch} +} + +// TestCheckNotices: a newer release gets one notice, with the versions, +// the steps and the link made from its version. The same release later +// gets none, and neither does the broker's own release, an older one, or +// one older than a release that got its notice: the notice never +// suggests a downgrade. A second newer release gets a second notice. Each +// check that worked waits 24 hours. +func TestCheckNotices(t *testing.T) { + r := startCheck(t, rel(0, 2, 0)) + r.mustRound(rel(0, 2, 0), nil, 24*time.Hour) + r.mustRound(rel(0, 1, 9), nil, 24*time.Hour) + if tries, _ := r.notices(); len(tries) != 0 { + t.Fatalf("notices %q for releases that are not newer", tries) + } + r.mustRound(rel(0, 3, 0), nil, 24*time.Hour) + r.mustRound(rel(0, 3, 0), nil, 24*time.Hour) + r.mustRound(rel(0, 10, 0), nil, 24*time.Hour) // 0.10.0 is newer than 0.3.0 + r.mustRound(rel(0, 4, 0), nil, 24*time.Hour) // newer than the broker, older than 0.10.0 + r.mustRound(rel(0, 1, 0), nil, 24*time.Hour) + r.stop() + tries, sent := r.notices() + want := []string{noticeText(rel(0, 3, 0), rel(0, 2, 0)), noticeText(rel(0, 10, 0), rel(0, 2, 0))} + if strings.Join(tries, "\n") != strings.Join(want, "\n") || len(sent) != 2 { + t.Errorf("tries:\n%s\nwant:\n%s", strings.Join(tries, "\n"), strings.Join(want, "\n")) + } + if want := "A newer release is out: nodkey 0.3.0. The broker runs nodkey 0.2.0. To update the broker, run as " + + "root on the broker host: nodkey update. Then restart it: systemctl restart nodkey. The restart ends all " + + "grants. Update the agent machines too: sudo nodkey update. Release notes: " + + "https://github.com/code6x/nodkey/releases/tag/v0.3.0"; tries[0] != want { + t.Errorf("the notice:\n%s\nwant:\n%s", tries[0], want) + } + logs := r.logs.String() + for _, v := range []string{"0.3.0", "0.10.0"} { + line := `msg="release: a newer release of nodkey is out; update the broker host and the agent machines" ` + + "newest=" + v + " broker_version=0.2.0" + if strings.Count(logs, line) != 1 { + t.Errorf("want one line %q; the log:\n%s", line, logs) + } + } + if strings.Contains(logs, "level=WARN msg=\"release: cannot") || strings.Contains(logs, "did not go out") { + t.Errorf("the log has an error:\n%s", logs) + } +} + +// TestCheckRetries: a failed lookup gives one log line with its error, no +// notice, and the next check after 1 hour. So does a notice that did not +// go out, also one that the limit of the notices stopped; the next check +// tries it again. Once it went out, no more tries come for that release. +// The log line about the newer release comes once. +func TestCheckRetries(t *testing.T) { + r := startCheck(t, rel(0, 2, 0)) + errs := []error{ + fmt.Errorf("%w (HTTP 403); try again later", ErrRateLimit), + errors.New("cannot reach GitHub: the lookup timed out"), + errors.New("the tag of the newest release on GitHub is not vX.Y.Z"), + } + for _, err := range errs { + r.mustRound(version.Release{}, err, time.Hour) + } + if tries, _ := r.notices(); len(tries) != 0 { + t.Fatalf("notices %q after failed lookups", tries) + } + r.setNotice(errors.New("telegram: sendMessage: Telegram has a server error (500)")) + r.mustRound(rel(0, 3, 0), nil, time.Hour) + r.setNotice(errors.New("telegram: too many notices of the broker in the last minute")) + r.mustRound(rel(0, 3, 0), nil, time.Hour) + r.setNotice(nil) + r.mustRound(rel(0, 3, 0), nil, 24*time.Hour) + r.mustRound(rel(0, 3, 0), nil, 24*time.Hour) + r.mustRound(rel(0, 3, 0), errors.New("cannot reach GitHub: the connection failed"), time.Hour) + r.mustRound(rel(0, 3, 0), nil, 24*time.Hour) + r.stop() + tries, sent := r.notices() + if len(tries) != 3 || len(sent) != 1 { + t.Errorf("%d tries and %d notices, want 3 and 1", len(tries), len(sent)) + } + logs := r.logs.String() + for _, err := range append(errs, errors.New("cannot reach GitHub: the connection failed")) { + line := `msg="release: cannot check for a newer release of nodkey; the next check is in 1 hour" error="` + + err.Error() + `"` + if !strings.Contains(logs, line) { + t.Errorf("no line %q; the log:\n%s", line, logs) + } + } + for _, line := range []string{ + `msg="release: the notice about a newer release of nodkey did not go out; the next check is in 1 hour" ` + + `newest=0.3.0 error="telegram: sendMessage: Telegram has a server error (500)"`, + `msg="release: the notice about a newer release of nodkey did not go out; the next check is in 1 hour" ` + + `newest=0.3.0 error="telegram: too many notices of the broker in the last minute"`, + `msg="release: the notice about a newer release of nodkey went out" newest=0.3.0`, + } { + if strings.Count(logs, line) != 1 { + t.Errorf("want one line %q; the log:\n%s", line, logs) + } + } + if n := strings.Count(logs, "a newer release of nodkey is out"); n != 1 { + t.Errorf("%d lines about the newer release, want 1:\n%s", n, logs) + } + r.mu.Lock() + defer r.mu.Unlock() + for _, d := range r.deadlines { + if d <= 0 || d > noticeTimeout { + t.Errorf("a try of the notice had %s left, want at most %s", d, noticeTimeout) + } + } + if len(r.deadlines) != 3 { + t.Errorf("%d tries had a deadline, want 3", len(r.deadlines)) + } +} + +// TestCheckWaitsFirst: the first check comes 10 minutes after the start +// (startCheck checks the wait), with no lookup before. A stop during that +// wait ends Run at once, without a lookup, a notice or a log line. So a +// broker that systemd starts again and again, and that ends within 10 +// minutes, never asks GitHub, and sends no notice in each start. +func TestCheckWaitsFirst(t *testing.T) { + r := startCheck(t, rel(0, 2, 0)) + r.stop() + tries, _ := r.notices() + if n := r.lookupsBegun(); n != 0 || len(tries) != 0 || r.logs.String() != "" { + t.Errorf("a stop during the first wait: %d lookups, %d notices, the log %q; want none", n, len(tries), + r.logs.String()) + } +} + +// TestCheckStops: when the context ends, Run returns, whether it waits +// for a lookup, for a notice or for the next check. A lookup or a notice +// that it ends logs no failure, and nothing goes out after Run returned. +// (TestCheckRealWait: the real wait ends with the context too.) +func TestCheckStops(t *testing.T) { + // During the first lookup, during the wait after a check, and during a + // later lookup. + r := startCheck(t, rel(0, 2, 0)) + r.tick() + r.stop() + r = startCheck(t, rel(0, 2, 0)) + r.mustRound(rel(0, 2, 0), nil, 24*time.Hour) + r.stop() + r = startCheck(t, rel(0, 2, 0)) + r.mustRound(rel(0, 2, 0), nil, 24*time.Hour) + r.tick() + r.stop() + if n := r.lookupsBegun(); n != 2 { + t.Errorf("%d lookups, want 2", n) + } + + // During a notice. + r = startCheck(t, rel(0, 2, 0)) + gate := make(chan struct{}) + r.mu.Lock() + r.gate = gate + r.mu.Unlock() + r.tick() + r.lookup(rel(0, 3, 0), nil) + deadline := time.Now().Add(10 * time.Second) + for tries, _ := r.notices(); len(tries) == 0; tries, _ = r.notices() { + if time.Now().After(deadline) { + t.Fatal("no try of the notice") + } + time.Sleep(time.Millisecond) + } + r.stop() + close(gate) + tries, sent := r.notices() + if len(tries) != 1 || len(sent) != 0 { + t.Errorf("%d tries and %d notices after the stop, want 1 and 0", len(tries), len(sent)) + } + if logs := r.logs.String(); strings.Contains(logs, "did not go out") || strings.Contains(logs, "cannot check") { + t.Errorf("the stop logged a failure:\n%s", logs) + } +} + +// TestCheckRealWait: the wait of a Check is a timer that ends with its +// context, also a wait of 0 after the context ended. +func TestCheckRealWait(t *testing.T) { + if err := sleep(t.Context(), time.Millisecond); err != nil { + t.Errorf("sleep: %v", err) + } + if err := sleep(t.Context(), 0); err != nil { + t.Errorf("sleep for 0: %v", err) + } + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + if err := sleep(ctx, time.Hour); !errors.Is(err, context.Canceled) { + t.Errorf("sleep while the context ends: %v", err) + } + for range 100 { + if err := sleep(ctx, 0); !errors.Is(err, context.Canceled) { + t.Fatalf("sleep for 0 with a context that ended: %v", err) + } + } +} diff --git a/internal/release/proxy_test.go b/internal/release/proxy_test.go new file mode 100644 index 0000000..62c66fe --- /dev/null +++ b/internal/release/proxy_test.go @@ -0,0 +1,140 @@ +package release + +import ( + "bufio" + "context" + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" +) + +// Go reads the proxy setting of the environment once per process. So the +// test of the proxy runs the lookup in a child process: the test binary +// again, with TestLatestThroughProxyChild only. + +// proxyChildEnv makes TestLatestThroughProxyChild run. +const proxyChildEnv = "NODKEY_TEST_RELEASE_PROXY_CHILD" + +// tunnelProxy is a small HTTPS proxy. It keeps what it reads before the +// tunnel, and connects every CONNECT to target. +type tunnelProxy struct { + target string + mu sync.Mutex + seen strings.Builder +} + +func (p *tunnelProxy) serve(ln net.Listener) { + for { + c, err := ln.Accept() + if err != nil { + return + } + go p.handle(c) + } +} + +func (p *tunnelProxy) handle(c net.Conn) { + defer func() { _ = c.Close() }() + br := bufio.NewReader(c) + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + p.mu.Lock() + p.seen.WriteString(line) + p.mu.Unlock() + if line == "\r\n" { + break + } + } + var d net.Dialer + up, err := d.DialContext(context.Background(), "tcp", p.target) + if err != nil { + return + } + defer func() { _ = up.Close() }() + _, _ = io.WriteString(c, "HTTP/1.1 200 Connection established\r\n\r\n") + go func() { _, _ = io.Copy(up, br) }() + _, _ = io.Copy(c, up) +} + +// TestLatestThroughProxyChild runs only as the child of +// TestLatestThroughProxy. The lookup asks https://example.com; the proxy +// connects it to a fake GitHub, whose certificate holds example.com. The +// client may dial only the proxy, so nothing goes to example.com itself. +func TestLatestThroughProxyChild(t *testing.T) { + if os.Getenv(proxyChildEnv) == "" { + t.Skip("runs only as the child of TestLatestThroughProxy") + } + srv := httptest.NewTLSServer(answer(http.StatusOK, releaseJSON("v0.3.0"))) + defer srv.Close() + ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + proxy := &tunnelProxy{target: srv.Listener.Addr().String()} + go proxy.serve(ln) + // Before the first request: Go reads the setting once. + t.Setenv("HTTPS_PROXY", "http://proxyuser:proxypass@"+ln.Addr().String()) + + c := New(own) + c.base = "https://example.com" + tr := c.http.Transport.(*http.Transport) + pool := x509.NewCertPool() + pool.AddCert(srv.Certificate()) + tr.TLSClientConfig.RootCAs = pool + var d net.Dialer + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + if addr != ln.Addr().String() { + return nil, errors.New("the client dialed another address than the proxy") + } + return d.DialContext(ctx, network, addr) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + got, err := c.Latest(ctx) + if err != nil || got != (rel(0, 3, 0)) { + t.Fatalf("Latest through the proxy: %v, %v", got, err) + } + proxy.mu.Lock() + seen := proxy.seen.String() + proxy.mu.Unlock() + // The proxy sees the host, and the user name and the password of the + // setting, but not the path: it is inside TLS. + if !strings.HasPrefix(seen, "CONNECT example.com:443 HTTP/1.1\r\n") || strings.Contains(seen, latestPath) { + t.Errorf("the proxy saw: %q", seen) + } +} + +// TestLatestThroughProxy: the lookup takes the proxy from HTTPS_PROXY, as +// the calls to Telegram do. +func TestLatestThroughProxy(t *testing.T) { + if os.Getenv(proxyChildEnv) != "" { + t.Skip("this is the child") + } + //nolint:gosec // G204: the test binary itself, with fixed arguments. + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestLatestThroughProxyChild$", "-test.v") + // No other proxy setting of the test's environment, such as NO_PROXY, + // may change where the child's call goes. + for _, kv := range os.Environ() { + if name, _, _ := strings.Cut(kv, "="); !strings.HasSuffix(strings.ToUpper(name), "_PROXY") { + cmd.Env = append(cmd.Env, kv) + } + } + cmd.Env = append(cmd.Env, proxyChildEnv+"=1") + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "--- PASS: TestLatestThroughProxyChild") { + t.Fatalf("the child: %v\n%s", err, out) + } +} diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 0000000..2f20d34 --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,228 @@ +// Package release finds the newest release of nodkey on GitHub (DESIGN.md +// section 15). nodkey version uses it on a terminal, and the broker uses it +// 10 minutes after its start, and then once a day, to send a notice when a +// newer release is out (check.go). +// +// The lookup asks GitHub's API, without a login, for the newest release, +// and reads only its tag, and whether it is a draft or a prerelease. It +// takes only a tag "vX.Y.Z" with numbers that version.ParseRelease +// accepts. Nothing else from the answer is ever shown or logged: the link +// to the release page is made from the version (PageURL). An error has a +// fixed text, with at most the HTTP status. +package release + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/code6x/nodkey/internal/version" +) + +const ( + // defaultBaseURL is the address of GitHub's API. Only tests change it. + defaultBaseURL = "https://api.github.com" + // latestPath is the newest release of nodkey that is not a draft or a + // prerelease. + latestPath = "/repos/code6x/nodkey/releases/latest" + // pageURL is the start of the address of the page of a release. The + // tag follows it. + pageURL = "https://github.com/code6x/nodkey/releases/tag/v" + // apiVersion is the version of GitHub's API that the lookup asks for. + apiVersion = "2022-11-28" +) + +const ( + // maxAnswer is the largest answer that the lookup reads. GitHub's + // answer is a few KiB, with the release notes. + maxAnswer = 1 << 20 + // maxTag is the longest tag of a release: "v" and three numbers of at + // most 6 digits (version.ParseRelease). A longer tag is refused before + // it is parsed: a tag of 1 MiB of dots would cost about 16 MiB there. + maxTag = len("v999999.999999.999999") + // callTimeout bounds one lookup, with its connection and its answer. + callTimeout = 30 * time.Second + // stepTimeout bounds each step of a connection: the TCP connection, + // the TLS handshake, and the wait for the headers of the answer. + stepTimeout = 10 * time.Second +) + +// ErrRateLimit is the error of a lookup that GitHub refused with 403 or +// 429. Without a login, GitHub allows only a few calls an hour from one +// address. Try again later. +var ErrRateLimit = errors.New("GitHub limits the calls from this address for now") + +// Client asks GitHub for the newest release of nodkey. Make it with New. +type Client struct { + // http and base are fields only so that a test can use a fake GitHub + // server. Neither the config nor the environment changes them. + http *http.Client + base string + userAgent string + timeout time.Duration // the whole lookup +} + +// New returns a Client for this nodkey, whose version is own. It sends +// own in its User-Agent, as GitHub asks every client to: so GitHub learns +// the version of nodkey (THREAT-MODEL.md T34). +func New(own version.Release) *Client { + return &Client{http: newHTTPClient(), base: defaultBaseURL, userAgent: "nodkey/" + own.String(), timeout: callTimeout} +} + +// newHTTPClient returns the HTTP client of the lookup. +// +// - It uses the proxy from the environment, as the Telegram code does: +// HTTPS_PROXY or https_proxy, except for the hosts in NO_PROXY or +// no_proxy. +// - TLS 1.2 or newer, with the system's CA certificates. +// - It speaks only HTTP/1.1, as the Telegram code does. +// - It follows no redirect: Latest takes a redirect as an error. +// - Each step of a connection has a time limit; Latest bounds the whole +// lookup. +// - It keeps no connection open after a lookup. The broker asks only +// once a day. +func newHTTPClient() *http.Client { + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + dialer := &net.Dialer{Timeout: stepTimeout} + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + TLSHandshakeTimeout: stepTimeout, + ResponseHeaderTimeout: stepTimeout, + MaxResponseHeaderBytes: 64 << 10, + DisableKeepAlives: true, + Protocols: protocols, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// PageURL returns the address of the page of the release r, with its +// release notes. It is made from the version, never taken from an answer +// of GitHub. +func PageURL(r version.Release) string { return pageURL + r.String() } + +// Latest asks GitHub for the newest release of nodkey, and returns its +// version. It fails on any answer but 200 with a release that is not a +// draft, not a prerelease, and has the tag "vX.Y.Z". 403 and 429 give +// ErrRateLimit. The error text is fixed, with at most the HTTP status: it +// never holds text or a header of the answer, or Go's error text, which +// can name the proxy. So the error may go to a terminal or the journal as +// it is. When it returns, no dial of it goes on. +func (c *Client) Latest(ctx context.Context) (version.Release, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + // Go's HTTP client goes on with a dial after its request ended, such as + // when the broker stops during a lookup: it would still connect through + // the proxy, and make a TLS handshake with GitHub. CloseIdleConnections + // ends such a dial too. + defer c.http.CloseIdleConnections() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+latestPath, nil) + if err != nil { + return version.Release{}, errors.New("cannot make the request to GitHub") + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", apiVersion) + req.Header.Set("User-Agent", c.userAgent) + resp, err := c.http.Do(req) + if err != nil { + return version.Release{}, errors.New("cannot reach GitHub: " + netReason(err)) + } + defer func() { _ = resp.Body.Close() }() // a close error adds nothing + switch resp.StatusCode { + case http.StatusOK: + case http.StatusForbidden, http.StatusTooManyRequests: + return version.Release{}, fmt.Errorf("%w (HTTP %d); try again later", ErrRateLimit, resp.StatusCode) + default: + // Also a redirect, which the client does not follow. + return version.Release{}, fmt.Errorf("GitHub answered with HTTP %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswer+1)) + if err != nil { + return version.Release{}, errors.New("cannot read the answer of GitHub: " + netReason(err)) + } + if len(data) > maxAnswer { + return version.Release{}, errors.New("the answer of GitHub is too large") + } + return parseLatest(data) +} + +// errNotValid is the error for an answer that does not have the form of a +// release. +var errNotValid = errors.New("the answer of GitHub is not a valid release") + +// parseLatest reads the answer of GitHub: a JSON object with tag_name, +// draft and prerelease. Other keys do not matter. A key counts only when +// it is written exactly so: Go's JSON decoder would also take TAG_NAME +// for a struct field tag_name. +func parseLatest(data []byte) (version.Release, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil || fields == nil { + return version.Release{}, errNotValid + } + var tag *string + var draft, prerelease *bool + for key, dst := range map[string]any{"tag_name": &tag, "draft": &draft, "prerelease": &prerelease} { + raw, ok := fields[key] + // A JSON null leaves the pointer nil. + if !ok || json.Unmarshal(raw, dst) != nil { + return version.Release{}, errNotValid + } + } + switch { + case tag == nil || draft == nil || prerelease == nil: + return version.Release{}, errNotValid + case *draft: + return version.Release{}, errors.New("the newest release on GitHub is a draft") + case *prerelease: + return version.Release{}, errors.New("the newest release on GitHub is a prerelease") + } + errTag := errors.New("the tag of the newest release on GitHub is not vX.Y.Z") + if len(*tag) > maxTag { + return version.Release{}, errTag + } + rest, ok := strings.CutPrefix(*tag, "v") + r, isRelease := version.ParseRelease(rest) + if !ok || !isRelease { + return version.Release{}, errTag + } + return r, nil +} + +// netReason returns a fixed reason for an error of http.Client.Do or of +// reading the answer. It looks only at the types in err, never at its +// text: the text can hold the address of the proxy, or the text of the +// status line that the proxy sent. +func netReason(err error) string { + var dnsErr *net.DNSError + var certErr *tls.CertificateVerificationError + var netErr net.Error + var opErr *net.OpError + switch { + case errors.Is(err, context.Canceled): + return "the lookup was cancelled" + case errors.As(err, &dnsErr): + return "cannot find the address of the server" + case errors.As(err, &certErr): + return "the TLS certificate of the server is not valid" + case errors.As(err, &netErr) && netErr.Timeout(): + return "the lookup timed out" + case errors.As(err, &opErr) && opErr.Op == "proxyconnect": + return "cannot connect to the proxy" + case errors.As(err, &opErr) && opErr.Op == "dial": + return "cannot connect to the server" + } + return "the connection failed" +} diff --git a/internal/release/release_test.go b/internal/release/release_test.go new file mode 100644 index 0000000..0da8c02 --- /dev/null +++ b/internal/release/release_test.go @@ -0,0 +1,487 @@ +package release + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/code6x/nodkey/internal/version" +) + +// own is the version of the nodkey of the tests. +var own = rel(0, 2, 0) + +// canary is text in the answers of the fake GitHub that no error may +// hold. +const canary = "CANARY-made-up-text" + +// releaseJSON returns an answer of GitHub with the tag, and a few of the +// other fields that GitHub sends, with the canary in them. +func releaseJSON(tag string) string { + return fmt.Sprintf(`{"url":"https://example.com/%[2]s","html_url":"https://example.com/%[2]s",`+ + `"tag_name":%[1]q,"name":"%[2]s","draft":false,"prerelease":false,"body":"%[2]s",`+ + `"assets":[{"name":"%[2]s","size":1}]}`, tag, canary) +} + +// fakeGitHub is a fake of GitHub's API: a TLS server on the loopback +// address. It keeps each request, and answers with its handler. +type fakeGitHub struct { + srv *httptest.Server + mu sync.Mutex + reqs []*http.Request +} + +// newFakeGitHub starts a fake GitHub whose handler is h, and returns it +// with a Client that asks it and trusts its certificate. The Client is +// the real one, with its own HTTP client: only the address and the CA +// certificates differ. +func newFakeGitHub(t *testing.T, h http.HandlerFunc) (*fakeGitHub, *Client) { + t.Helper() + f := &fakeGitHub{} + f.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.reqs = append(f.reqs, r.Clone(context.Background())) + f.mu.Unlock() + h(w, r) + })) + t.Cleanup(f.srv.Close) + return f, f.client() +} + +// client returns a Client that asks the fake. +func (f *fakeGitHub) client() *Client { + c := New(own) + c.base = f.srv.URL + pool := x509.NewCertPool() + pool.AddCert(f.srv.Certificate()) + c.http.Transport.(*http.Transport).TLSClientConfig.RootCAs = pool + return c +} + +// requests returns the requests that the fake got. +func (f *fakeGitHub) requests() []*http.Request { + f.mu.Lock() + defer f.mu.Unlock() + return append([]*http.Request(nil), f.reqs...) +} + +// answer returns a handler that answers with status and body. +func answer(status int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + } +} + +// checkErr fails unless err holds want, and holds neither the canary nor +// the address of the fake. +func checkErr(t *testing.T, what string, err error, want string) { + t.Helper() + switch { + case err == nil: + t.Errorf("%s: no error, want %q", what, want) + case !strings.Contains(err.Error(), want): + t.Errorf("%s: error %q, want %q", what, err, want) + case strings.Contains(err.Error(), canary), strings.Contains(err.Error(), "127.0.0.1"): + t.Errorf("%s: the error holds text of the answer or an address: %q", what, err) + } +} + +// TestLatest: the lookup takes the version from the tag. It does not +// compare: a newer, the same and an older release than its own come back +// as they are. The other fields of the answer do not matter. +func TestLatest(t *testing.T) { + for tag, want := range map[string]version.Release{ + "v0.3.0": rel(0, 3, 0), + "v0.2.0": rel(0, 2, 0), + "v0.1.0": rel(0, 1, 0), + "v1.10.0": rel(1, 10, 0), + "v0.0.0": rel(0, 0, 0), + "v999999.999999.999999": rel(999999, 999999, 999999), + } { + _, c := newFakeGitHub(t, answer(http.StatusOK, releaseJSON(tag))) + got, err := c.Latest(t.Context()) + if err != nil || got != want { + t.Errorf("%s: %v, %v; want %v", tag, got, err, want) + } + } + // Only the exact keys count. Go's decoder would take any case for a + // field of a struct. + _, c := newFakeGitHub(t, answer(http.StatusOK, + `{"TAG_NAME":"v9.9.9","Draft":true,"tag_name":"v0.3.0","draft":false,"prerelease":false,"Prerelease":true}`)) + if got, err := c.Latest(t.Context()); err != nil || got != (rel(0, 3, 0)) { + t.Errorf("keys in other cases: %v, %v; want 0.3.0", got, err) + } +} + +// TestLatestRequest: one GET to the path of the newest release, with the +// headers that GitHub asks for, the version of nodkey in the User-Agent, +// and no login, no cookie and no body. +func TestLatestRequest(t *testing.T) { + f, c := newFakeGitHub(t, answer(http.StatusOK, releaseJSON("v0.3.0"))) + if _, err := c.Latest(t.Context()); err != nil { + t.Fatal(err) + } + reqs := f.requests() + if len(reqs) != 1 { + t.Fatalf("%d requests, want 1", len(reqs)) + } + r := reqs[0] + if r.Method != http.MethodGet || r.URL.Path != "/repos/code6x/nodkey/releases/latest" || r.URL.RawQuery != "" || + r.Proto != "HTTP/1.1" || r.ContentLength != 0 { + t.Errorf("the request: %s %s?%s %s, %d bytes", r.Method, r.URL.Path, r.URL.RawQuery, r.Proto, r.ContentLength) + } + for name, want := range map[string]string{ + "Accept": "application/vnd.github+json", + "X-Github-Api-Version": "2022-11-28", + "User-Agent": "nodkey/0.2.0", + "Authorization": "", + "Cookie": "", + } { + if got := r.Header.Values(name); strings.Join(got, ",") != want || len(got) > 1 { + t.Errorf("header %s: %q, want %q", name, got, want) + } + } + if !r.Close { + t.Error("the request keeps its connection open") + } +} + +// TestLatestRefusesAnswers: every answer that is not a release in the +// expected form is an error, with a fixed text: never the text of the +// answer. 403 and 429 are ErrRateLimit; any other status but 200 is an +// error with only the status. +func TestLatestRefusesAnswers(t *testing.T) { + const ( + badTag = "the tag of the newest release on GitHub is not vX.Y.Z" + notValid = "the answer of GitHub is not a valid release" + ) + tests := []struct { + name string + status int + body string + want string + rateLimit bool + }{ + {"403", http.StatusForbidden, `{"message":"API rate limit exceeded ` + canary + `"}`, + "GitHub limits the calls from this address for now (HTTP 403); try again later", true}, + {"429", http.StatusTooManyRequests, canary, "GitHub limits the calls from this address for now (HTTP 429)", true}, + {"500", http.StatusInternalServerError, canary, "GitHub answered with HTTP 500", false}, + {"502", http.StatusBadGateway, releaseJSON("v0.3.0"), "GitHub answered with HTTP 502", false}, + {"404", http.StatusNotFound, `{"message":"Not Found"}`, "GitHub answered with HTTP 404", false}, + {"401", http.StatusUnauthorized, releaseJSON("v0.3.0"), "GitHub answered with HTTP 401", false}, + {"201", http.StatusCreated, releaseJSON("v0.3.0"), "GitHub answered with HTTP 201", false}, + {"204", http.StatusNoContent, "", "GitHub answered with HTTP 204", false}, + {"draft", 200, `{"tag_name":"v0.3.0","draft":true,"prerelease":false}`, "the newest release on GitHub is a draft", false}, + {"prerelease", 200, `{"tag_name":"v0.3.0","draft":false,"prerelease":true}`, + "the newest release on GitHub is a prerelease", false}, + {"no draft", 200, `{"tag_name":"v0.3.0","prerelease":false}`, notValid, false}, + {"draft null", 200, `{"tag_name":"v0.3.0","draft":null,"prerelease":false}`, notValid, false}, + {"draft as text", 200, `{"tag_name":"v0.3.0","draft":"false","prerelease":false}`, notValid, false}, + {"no prerelease", 200, `{"tag_name":"v0.3.0","draft":false}`, notValid, false}, + {"prerelease null", 200, `{"tag_name":"v0.3.0","draft":false,"prerelease":null}`, notValid, false}, + {"no tag", 200, `{"name":"v0.3.0","draft":false,"prerelease":false}`, notValid, false}, + {"tag null", 200, `{"tag_name":null,"draft":false,"prerelease":false}`, notValid, false}, + {"tag as a number", 200, `{"tag_name":3,"draft":false,"prerelease":false}`, notValid, false}, + {"tag as a list", 200, `{"tag_name":["v0.3.0"],"draft":false,"prerelease":false}`, notValid, false}, + {"tag in upper case only", 200, `{"TAG_NAME":"v0.3.0","draft":false,"prerelease":false}`, notValid, false}, + {"empty", 200, "", notValid, false}, + {"not JSON", 200, "" + canary + "", notValid, false}, + {"cut", 200, `{"tag_name":"v0.3.0","draft":false`, notValid, false}, + {"more after the object", 200, releaseJSON("v0.3.0") + "x", notValid, false}, + {"two objects", 200, releaseJSON("v0.3.0") + releaseJSON("v0.4.0"), notValid, false}, + {"null", 200, "null", notValid, false}, + {"a list", 200, "[" + releaseJSON("v0.3.0") + "]", notValid, false}, + {"a string", 200, `"v0.3.0"`, notValid, false}, + } + for _, tag := range []string{ + "0.3.0", "v", "", "v01.2.3", "v1.02.3", "v1.2.03", "v00.1.2", "vjunk", "v1.2", "v1.2.3.4", "v1..3", + "v1.2.3-rc.1", "v1.2.3+build", "V1.2.3", " v1.2.3", "v1.2.3 ", "v1.2.3\n", "vv1.2.3", "v-1.2.3", + "v1000000.0.0", "v1.2.x", "v١.٢.٣", "v" + canary + "", "refs/tags/v1.2.3", "nodkey-v1.2.3", + "v999999.999999.9999990", "v1.2.3" + strings.Repeat(" ", 100), + } { + tests = append(tests, struct { + name string + status int + body string + want string + rateLimit bool + }{"tag " + tag, 200, releaseJSON(tag), badTag, false}) + } + for _, tt := range tests { + _, c := newFakeGitHub(t, answer(tt.status, tt.body)) + got, err := c.Latest(t.Context()) + checkErr(t, tt.name, err, tt.want) + if errors.Is(err, ErrRateLimit) != tt.rateLimit { + t.Errorf("%s: ErrRateLimit is %v, want %v", tt.name, errors.Is(err, ErrRateLimit), tt.rateLimit) + } + if got != (version.Release{}) { + t.Errorf("%s: a version with the error: %v", tt.name, got) + } + } +} + +// TestLatestLongTag: a tag longer than the longest release tag, +// "v999999.999999.999999" (21 bytes), is refused before it is parsed. A +// tag of almost 1 MiB of dots then costs about 2 MiB (the answer is +// decoded twice), not the 17 MiB that version.ParseRelease takes for it. +func TestLatestLongTag(t *testing.T) { + if maxTag != 21 { + t.Errorf("maxTag = %d, want 21", maxTag) + } + head, tail := `{"tag_name":"v`, `","draft":false,"prerelease":false}` + body := []byte(head + strings.Repeat(".", maxAnswer-len(head)-len(tail)) + tail) + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + _, err := parseLatest(body) + runtime.ReadMemStats(&after) + checkErr(t, "a tag of 1 MiB of dots", err, "the tag of the newest release on GitHub is not vX.Y.Z") + if n := after.TotalAlloc - before.TotalAlloc; n > 6<<20 { + t.Errorf("a tag of 1 MiB of dots took %d MiB, want at most 6", n>>20) + } +} + +// TestLatestSize: an answer of 1 MiB is read; one byte more is an error, +// with a length in the header and without one. +func TestLatestSize(t *testing.T) { + body := releaseJSON("v0.3.0") + full := body + strings.Repeat(" ", maxAnswer-len(body)) // JSON allows space after the value + _, c := newFakeGitHub(t, answer(http.StatusOK, full)) + if got, err := c.Latest(t.Context()); err != nil || got != (rel(0, 3, 0)) { + t.Errorf("an answer of 1 MiB: %v, %v", got, err) + } + _, c = newFakeGitHub(t, answer(http.StatusOK, full+" ")) + checkErr(t, "an answer of 1 MiB and 1 byte", errOf(c.Latest(t.Context())), "the answer of GitHub is too large") + // Without a length, the server sends the answer in chunks. + _, c = newFakeGitHub(t, func(w http.ResponseWriter, _ *http.Request) { + for range 2 * maxAnswer / 4096 { + _, _ = io.WriteString(w, strings.Repeat(" ", 4096)) + w.(http.Flusher).Flush() + } + }) + checkErr(t, "an answer in chunks of 2 MiB", errOf(c.Latest(t.Context())), "the answer of GitHub is too large") +} + +// errOf returns only the error. +func errOf(_ version.Release, err error) error { return err } + +// TestLatestFollowsNoRedirect: a redirect is an error with its status. +// The client does not follow it, also not to the same server. +func TestLatestFollowsNoRedirect(t *testing.T) { + for _, status := range []int{http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, + http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + f, c := newFakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != latestPath { + answer(http.StatusOK, releaseJSON("v9.0.0"))(w, r) + return + } + w.Header().Set("Location", "/elsewhere") + w.WriteHeader(status) + }) + checkErr(t, fmt.Sprint(status), errOf(c.Latest(t.Context())), fmt.Sprintf("GitHub answered with HTTP %d", status)) + if n := len(f.requests()); n != 1 { + t.Errorf("%d: %d requests, want 1: the client followed the redirect", status, n) + } + } +} + +// TestLatestTimeout: a server that does not answer, or stops in the +// middle of its answer, makes the lookup fail after its time limit, also +// a limit of the caller. +func TestLatestTimeout(t *testing.T) { + // The client closes the connection when its time is up, and the + // server then ends the context of the request. + hang := func(_ http.ResponseWriter, r *http.Request) { <-r.Context().Done() } + hangInBody := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"tag_name":"v0.3.0",`) + w.(http.Flusher).Flush() + hang(w, r) + } + for _, tt := range []struct { + name string + h http.HandlerFunc + limit time.Duration // the limit of the Client + caller time.Duration // the limit of the caller's context; 0 for none + wantErr string + }{ + {"no answer", hang, 300 * time.Millisecond, 0, "cannot reach GitHub: the lookup timed out"}, + {"no answer, the caller's limit", hang, time.Minute, 300 * time.Millisecond, + "cannot reach GitHub: the lookup timed out"}, + {"a cut answer", hangInBody, 300 * time.Millisecond, 0, "cannot read the answer of GitHub: the lookup timed out"}, + } { + _, c := newFakeGitHub(t, tt.h) + c.timeout = tt.limit + ctx, cancel := t.Context(), context.CancelFunc(func() {}) + if tt.caller > 0 { + ctx, cancel = context.WithTimeout(t.Context(), tt.caller) + } + start := time.Now() + _, err := c.Latest(ctx) + cancel() + checkErr(t, tt.name, err, tt.wantErr) + if d := time.Since(start); d > 10*time.Second { + t.Errorf("%s: the lookup took %s", tt.name, d) + } + } + + // A context that ended before the lookup. + _, c := newFakeGitHub(t, answer(http.StatusOK, releaseJSON("v0.3.0"))) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + checkErr(t, "a cancelled context", errOf(c.Latest(ctx)), "cannot reach GitHub: the lookup was cancelled") +} + +// TestLatestEndsItsDial: a lookup that ends during its dial ends the dial +// too, such as when the broker stops while the proxy has not answered the +// CONNECT yet. Go's HTTP client goes on with a dial after its request +// ended. Without the end, the dial would wait for the proxy up to a +// minute, and then make a TLS handshake with GitHub, after the check +// stopped. +func TestLatestEndsItsDial(t *testing.T) { + ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + connect, closed := make(chan struct{}, 1), make(chan struct{}, 1) + // The proxy takes the CONNECT and never answers it. + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = c.Close() }() + br := bufio.NewReader(c) + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + if line == "\r\n" { + break + } + } + connect <- struct{}{} + // The client sends nothing more before the answer, so this read + // ends only when the client closes the connection. + _, _ = br.ReadByte() + closed <- struct{}{} + }() + _, c := newFakeGitHub(t, answer(http.StatusOK, releaseJSON("v0.3.0"))) + c.http.Transport.(*http.Transport).Proxy = http.ProxyURL(&url.URL{Scheme: "http", Host: ln.Addr().String()}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + errs := make(chan error, 1) + go func() { + _, err := c.Latest(ctx) + errs <- err + }() + select { + case <-connect: + case <-time.After(10 * time.Second): + t.Fatal("no CONNECT came to the proxy") + } + cancel() + checkErr(t, "a lookup that ends during its dial", <-errs, "cannot reach GitHub: the lookup was cancelled") + select { + case <-closed: + case <-time.After(10 * time.Second): + t.Fatal("the dial goes on after the lookup ended") + } +} + +// TestLatestTLS: the lookup needs a certificate that the CA certificates +// of the client trust, and TLS 1.2 or newer. Without them, no request is +// sent. +func TestLatestTLS(t *testing.T) { + f, c := newFakeGitHub(t, answer(http.StatusOK, releaseJSON("v0.3.0"))) + c.http.Transport.(*http.Transport).TLSClientConfig.RootCAs = x509.NewCertPool() // trusts nothing + checkErr(t, "an untrusted certificate", errOf(c.Latest(t.Context())), + "cannot reach GitHub: the TLS certificate of the server is not valid") + + old := httptest.NewUnstartedServer(answer(http.StatusOK, releaseJSON("v0.3.0"))) + //nolint:gosec // G402: a test server that offers only TLS 1.0 and 1.1, which the client must refuse. + old.TLS = &tls.Config{MinVersion: tls.VersionTLS10, MaxVersion: tls.VersionTLS11} + old.StartTLS() + t.Cleanup(old.Close) + f2 := &fakeGitHub{srv: old} + checkErr(t, "TLS 1.1", errOf(f2.client().Latest(t.Context())), "cannot reach GitHub: ") + if n := len(f.requests()); n != 0 { + t.Errorf("%d requests reached the server with the untrusted certificate", n) + } +} + +// TestLatestNoServer: when nothing listens at the address, the lookup +// fails with a fixed reason. +func TestLatestNoServer(t *testing.T) { + f, c := newFakeGitHub(t, answer(http.StatusOK, releaseJSON("v0.3.0"))) + f.srv.Close() + checkErr(t, "no server", errOf(c.Latest(t.Context())), "cannot reach GitHub: cannot connect to the server") +} + +// TestNetReason: each kind of error of the HTTP client gets a fixed +// reason, whatever its text: the text can name the proxy, or hold the +// status line that the proxy sent. +func TestNetReason(t *testing.T) { + wrap := func(err error) error { + return &url.Error{Op: "Get", URL: "https://api.github.com" + latestPath, Err: err} + } + for _, tt := range []struct { + err error + want string + }{ + {wrap(context.Canceled), "the lookup was cancelled"}, + {wrap(&net.DNSError{Err: "no such host " + canary, Name: canary}), "cannot find the address of the server"}, + {wrap(&tls.CertificateVerificationError{Err: errors.New(canary)}), "the TLS certificate of the server is not valid"}, + {wrap(context.DeadlineExceeded), "the lookup timed out"}, + {wrap(&net.OpError{Op: "proxyconnect", Net: "tcp", Err: errors.New(canary)}), "cannot connect to the proxy"}, + {wrap(&net.OpError{Op: "dial", Net: "tcp", Err: errors.New(canary)}), "cannot connect to the server"}, + {wrap(errors.New("Forbidden " + canary)), "the connection failed"}, + {errors.New(canary), "the connection failed"}, + } { + if got := netReason(tt.err); got != tt.want { + t.Errorf("netReason(%v) = %q, want %q", tt.err, got, tt.want) + } + } +} + +// TestPageURL: the link to a release is made from its version. +func TestPageURL(t *testing.T) { + if got := PageURL(rel(1, 10, 2)); got != "https://github.com/code6x/nodkey/releases/tag/v1.10.2" { + t.Errorf("PageURL = %q", got) + } +} + +// TestNewClient: the real client asks GitHub's API, follows no redirect, +// keeps no connection, and takes the proxy from the environment +// (TestLatestThroughProxy shows that it uses it). +func TestNewClient(t *testing.T) { + c := New(rel(1, 2, 3)) + tr, ok := c.http.Transport.(*http.Transport) + switch { + case c.base != "https://api.github.com", c.userAgent != "nodkey/1.2.3", c.timeout != 30*time.Second: + t.Errorf("base %q, User-Agent %q, timeout %s", c.base, c.userAgent, c.timeout) + case !ok || tr.Proxy == nil || tr.TLSClientConfig.MinVersion != tls.VersionTLS12 || tr.TLSClientConfig.InsecureSkipVerify || + tr.TLSClientConfig.RootCAs != nil || !tr.DisableKeepAlives || tr.TLSHandshakeTimeout != 10*time.Second || + tr.ResponseHeaderTimeout != 10*time.Second: + t.Errorf("the transport: %+v", tr) + case c.http.CheckRedirect == nil: + t.Error("the client follows redirects") + } +} diff --git a/test/e2e/env_test.go b/test/e2e/env_test.go index 892b07b..052b398 100644 --- a/test/e2e/env_test.go +++ b/test/e2e/env_test.go @@ -429,6 +429,9 @@ const ( lineListening = "nodkey broker: listening; the broker is ready" lineProxy = "telegram: calls to Telegram go through a proxy from the environment" lineStopped = "nodkey broker: stopped" + // lineNoCheck: the broker is a build from the source, so it never asks + // GitHub for a newer release (DESIGN.md section 15). + lineNoCheck = "nodkey broker: the check for a newer release is off: this is a build from the source" ) // startBroker starts nodkey broker, and waits until it is ready. The port @@ -463,6 +466,10 @@ func (e *env) startBroker(t *testing.T) { t.Errorf("the broker does not log that it calls Telegram through the fake proxy; its log:\n%s", redact(e.broker.stderr.text())) } + if !e.broker.stderr.has(lineNoCheck) { + t.Errorf("the broker does not log that its check for a newer release is off; its log:\n%s", + redact(e.broker.stderr.text())) + } } // freeAddress returns an address on the loopback interface with a port