Make the crate do what its documentation already claimed
Review from ai-app's session, acted on. **A test that asserted nothing.** `a_missing_tunnel_explains_itself` returned early whenever `wg0` was up -- which it is on this VM and on the host, so it passed everywhere and never once checked the message. The same shape of test that let the file-permissions bug survive three codebases. `netif` now splits the lookup from the decision: `wg_address_among` and `addresses_among` take the interfaces, so the failure is reachable by handing in an empty list rather than by hoping the machine has no tunnel. Four tests where there were two, including that an IPv6-only `wg0` is not an answer. **Two modules the docs promised and the crate did not have.** `certs`, which is the piece where being written twice is worst -- a trust anchor built two ways can be built differently two ways, and the difference reaches a phone as an opaque handshake failure. And `format`, the RON house rules, which were byte-identical in both projects and so the clearest thing in the evidence table. `product` names the certificate and is the whole of what is per-project; the test decodes the DER and looks for it there rather than trusting what was passed in. **The README title still said wg-server-app**, three commits after everything else was renamed. Two judgement calls promoted from silent to written down, both of which would otherwise be inherited rather than chosen: `WG_INTERFACE` is a constant because *these* projects have one tunnel, which is the first thing a third user should expect to change; and `local_addresses` puts the emulator's host alias in every certificate, a SAN for an address the machine does not own. And a review heuristic the day kept proving: when you find a rule stated, grep for its siblings. Three bugs today were the correct rule already written down and applied to one member of a set.
This commit is contained in:
1 parent
592114bfc9
commit
841a3a8372
7 files changed
+1053
-38
No files matched your search
@@ -1,4 +1,4 @@
|
||||
# wg-server-app
|
||||
# wg-app-link
|
||||
|
||||
The private link between a phone and a machine you run, extracted from the
|
||||
two projects that had each written it.
|
||||
@@ -94,21 +94,21 @@ time the sharing paid rather than merely being argued for.
|
||||
|
||||
## What is here
|
||||
|
||||
`server/` — the `wg-app-link` crate. Three modules, each extracted only after
|
||||
`server/` — the `wg-app-link` crate. Five modules, each extracted only after
|
||||
diffing the two copies and finding nothing but a name between them.
|
||||
|
||||
- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining.
|
||||
- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining. Both split the *lookup* from the *decision*, so the failure path can be tested on a machine that has a tunnel — every machine this runs on does.
|
||||
- **`enroll`** — token generation, hex-SHA-256 storage, constant-time comparison, the `<scheme>://enroll?…` URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app.
|
||||
- **`certs`** — the CA generated once and never replaced, the leaf reissued every start. `product` names the organisation and common name and is the whole of what is per-project. Shared because being written twice is worst here: a trust anchor built two ways can be built differently two ways, and the difference surfaces as an opaque handshake failure on a phone.
|
||||
- **`format`** — the two RON house rules. This was byte-identical in both projects, which made it the clearest thing in the evidence table and the easiest deletion.
|
||||
- **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened.
|
||||
|
||||
## What should follow, and what should not
|
||||
|
||||
**Should follow, in this order.** Each is already near-identical:
|
||||
|
||||
1. `certs` — CA generated once and never replaced, leaf reissued every start. One `product: &str`.
|
||||
2. The RON `format` house rules — byte-identical today, so this is pure deletion.
|
||||
3. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions.
|
||||
4. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
|
||||
1. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions — and `applyPinnedTls` is security-critical and identical, which is the same argument `certs` won on.
|
||||
2. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
|
||||
|
||||
**Should not.** Naming these is the point of the exercise:
|
||||
|
||||
@@ -117,6 +117,37 @@ diffing the two copies and finding nothing but a name between them.
|
||||
- **Config schemas.** Shared house rules, separate contents.
|
||||
- **Anything above the link.** Projects, builds, sessions, providers. If a third project would not want it, it does not belong here.
|
||||
|
||||
## Two decisions worth stating, rather than inheriting
|
||||
|
||||
**`WG_INTERFACE` is a constant, not a parameter.** For these two projects a
|
||||
second answer would mean two ideas of what "the tunnel" is, and there is
|
||||
one tunnel. That reasoning is about *these* projects rather than about the
|
||||
world, so it is the first thing a third user should expect to change.
|
||||
|
||||
**`local_addresses` puts `10.0.2.2` in every certificate.** It is the alias
|
||||
an Android emulator reaches its host by, and not an address the machine
|
||||
owns — so this is a SAN for something that is not this host, present so a
|
||||
debug build can reach a server running beside it. Inherited from ai-app
|
||||
and correct there; written down here because promoting it makes it every
|
||||
future project's default rather than one project's choice.
|
||||
|
||||
## A review heuristic this repo keeps proving
|
||||
|
||||
**When you find a rule stated somewhere, grep for its siblings.** Three
|
||||
separate bugs today were the correct rule already written down, applied to
|
||||
one member of a set and not the others:
|
||||
|
||||
- `create_dir` set its mode a second time and explained why; `create_file`
|
||||
and `append_file`, three functions below, did not.
|
||||
- ai-app explained one ssh failure and not its sibling.
|
||||
- dev-updater's acceptance gate compared components by *declaration*, and
|
||||
`BuildState::matches` — which meant the same thing — compared them by
|
||||
full equality, so writing a measured field replaced a running build's
|
||||
state.
|
||||
|
||||
None was found by a test. All three were found by a second reader looking
|
||||
at code as a set, which is the thing this crate is actually buying.
|
||||
|
||||
## The name
|
||||
|
||||
`wg-app-link`: the link between an app and the machine it talks to, over
|
||||
|
||||
Generated
+518
-8
@@ -8,17 +8,80 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -29,6 +92,16 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -70,6 +143,32 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
@@ -81,6 +180,17 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -88,7 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -97,6 +207,23 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
@@ -125,9 +252,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -140,18 +273,99 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -189,7 +403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
@@ -199,6 +413,57 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -209,7 +474,46 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -223,6 +527,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -240,6 +550,28 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -247,10 +579,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -272,7 +654,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -284,6 +666,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -296,15 +684,30 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wg-app-link"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"if-addrs",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"rcgen",
|
||||
"ron",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
@@ -317,6 +720,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -325,3 +737,101 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
@@ -6,6 +6,9 @@ description = "The private link between a phone and a machine you run: WireGuard
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
# The config format both projects use, and the two house rules they share.
|
||||
ron = "0.12.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Enumerating this machine's addresses, and finding the tunnel's.
|
||||
if-addrs = "0.15"
|
||||
# Token auth: hash for storage, constant-time compare for verification,
|
||||
@@ -17,6 +20,11 @@ base64 = "0.23"
|
||||
# Renders the enrollment QR straight to the terminal; no image output.
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
tracing = "0.1"
|
||||
# Generates the CA and leaf in process, so one place decides the
|
||||
# extensions, the file modes, and which addresses the leaf covers.
|
||||
# x509-parser so the issuer is read back from the CA actually on disk
|
||||
# rather than reconstructed from parameters that may since have changed.
|
||||
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,239 @@
|
||||
//! The TLS certificates a server presents, generated in process on first
|
||||
//! start, from a CA the app pins.
|
||||
//!
|
||||
//! Shared because it existed twice and differed by an organisation name.
|
||||
//! It is also the piece where being written twice is worst: a trust
|
||||
//! anchor built two ways can be built differently two ways, and the
|
||||
//! difference would surface as an opaque handshake failure on a phone.
|
||||
//!
|
||||
//! `product` names the certificate's organisation and common name, and is
|
||||
//! the whole of what is per-project.
|
||||
//!
|
||||
//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a
|
||||
//! setup step to remember, a second place for the "which SANs?" answer to
|
||||
//! live, and a dependency on whatever openssl was installed. Doing it here
|
||||
//! means the server can simply ensure its own certificates exist, with the
|
||||
//! file modes and extensions it wants, and with the address it is actually
|
||||
//! about to bind already in the leaf.
|
||||
//!
|
||||
//! The split that matters is between the two:
|
||||
//!
|
||||
//! - The **CA** is generated once and then left alone. The updater app
|
||||
//! pins it, so replacing it strands every installed copy -- recovery is
|
||||
//! a reinstall over the plain-HTTP bootstrap port. It is the one thing
|
||||
//! here that is a one-way door.
|
||||
//! - The **leaf** is cheap and reissued on every start, signed by that
|
||||
//! same unchanged CA. Nothing pins it, so covering a new address is just
|
||||
//! a restart rather than anything the phone has to be told about.
|
||||
//!
|
||||
//! Everything is written owner-only into a directory outside the repo (see
|
||||
//! `config_home`): a CA private key readable by another machine is one it
|
||||
//! can sign with, and a certificate signed by a pinned CA is accepted
|
||||
//! without question.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
||||
};
|
||||
|
||||
/// Where the leaf lives, for handing to the TLS listener.
|
||||
pub struct Certificates {
|
||||
pub leaf_cert: PathBuf,
|
||||
pub leaf_key: PathBuf,
|
||||
/// True when the CA was created just now, i.e. anything already
|
||||
/// installed pins the wrong one and has to be reinstalled.
|
||||
pub ca_is_new: bool,
|
||||
}
|
||||
|
||||
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
|
||||
/// is missing. Safe to call on every start.
|
||||
pub fn ensure(product: &str, dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
||||
crate::private::create_dir(dir)?;
|
||||
|
||||
let ca_cert_path = dir.join("ca.pem");
|
||||
let ca_key_path = dir.join("ca-key.pem");
|
||||
let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file();
|
||||
|
||||
let (ca_pem, ca_key_pem) = if ca_is_new {
|
||||
let (pem, key) = generate_ca(product)?;
|
||||
crate::private::write_file(&ca_key_path, key.as_bytes())?;
|
||||
crate::private::write_file(&ca_cert_path, pem.as_bytes())?;
|
||||
tracing::info!("generated a new CA in {}", dir.display());
|
||||
(pem, key)
|
||||
} else {
|
||||
(
|
||||
std::fs::read_to_string(&ca_cert_path)
|
||||
.with_context(|| format!("read {}", ca_cert_path.display()))?,
|
||||
std::fs::read_to_string(&ca_key_path)
|
||||
.with_context(|| format!("read {}", ca_key_path.display()))?,
|
||||
)
|
||||
};
|
||||
|
||||
let (leaf_pem, leaf_key_pem) = generate_leaf(product, &ca_pem, &ca_key_pem, addresses)?;
|
||||
let leaf_cert = dir.join("leaf.pem");
|
||||
let leaf_key = dir.join("leaf-key.pem");
|
||||
crate::private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
|
||||
crate::private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
|
||||
|
||||
Ok(Certificates {
|
||||
leaf_cert,
|
||||
leaf_key,
|
||||
ca_is_new,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_ca(product: &str) -> Result<(String, String)> {
|
||||
let key = KeyPair::generate().context("generate CA key")?;
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::OrganizationName, format!("{product} dev"));
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, format!("{product} dev CA"));
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
// Explicit, because strict verifiers reject a CA without them -- and
|
||||
// that rejection surfaces as an opaque handshake failure on a phone.
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let certificate = params.self_signed(&key).context("self-sign CA")?;
|
||||
Ok((certificate.pem(), key.serialize_pem()))
|
||||
}
|
||||
|
||||
fn generate_leaf(
|
||||
product: &str,
|
||||
ca_pem: &str,
|
||||
ca_key_pem: &str,
|
||||
addresses: &[IpAddr],
|
||||
) -> Result<(String, String)> {
|
||||
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
|
||||
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
|
||||
|
||||
let key = KeyPair::generate().context("generate leaf key")?;
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::OrganizationName, format!("{product} dev"));
|
||||
params.distinguished_name.push(
|
||||
DnType::CommonName,
|
||||
addresses
|
||||
.first()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| product.to_string()),
|
||||
);
|
||||
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
|
||||
params.is_ca = IsCa::ExplicitNoCa;
|
||||
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
||||
params.use_authority_key_identifier_extension = true;
|
||||
let certificate = params.signed_by(&key, &issuer).context("sign leaf")?;
|
||||
Ok((certificate.pem(), key.serialize_pem()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn decode_pem(pem: &str) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
let body: String = pem
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("-----"))
|
||||
.collect();
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(body)
|
||||
.expect("base64")
|
||||
}
|
||||
|
||||
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.any(|window| window == needle)
|
||||
}
|
||||
|
||||
fn addresses() -> Vec<IpAddr> {
|
||||
vec![
|
||||
"192.168.1.168".parse().unwrap(),
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let first = ensure("demo", dir.path(), &addresses()).expect("generate");
|
||||
assert!(first.ca_is_new);
|
||||
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
|
||||
let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf");
|
||||
assert!(ca.starts_with("-----BEGIN CERTIFICATE-----"));
|
||||
|
||||
let second = ensure("demo", dir.path(), &addresses()).expect("regenerate");
|
||||
// The CA is the pinned one: replacing it strands every installed
|
||||
// app, so it must survive a restart untouched.
|
||||
assert!(!second.ca_is_new);
|
||||
assert_eq!(
|
||||
ca,
|
||||
std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca")
|
||||
);
|
||||
// The leaf is not pinned, and is reissued so a new address is a
|
||||
// restart away rather than a reinstall.
|
||||
assert_ne!(
|
||||
leaf,
|
||||
std::fs::read_to_string(&second.leaf_cert).expect("leaf")
|
||||
);
|
||||
}
|
||||
|
||||
/// Private keys are rewritten on every start, which is the case that
|
||||
/// makes the mode matter: `private` narrows a file that already
|
||||
/// exists, and this is the caller that depends on it.
|
||||
#[test]
|
||||
fn everything_is_owner_only_even_on_the_second_start() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
ensure("demo", dir.path(), &addresses()).expect("generate");
|
||||
|
||||
// What an older version could have left behind.
|
||||
for file in ["leaf.pem", "leaf-key.pem"] {
|
||||
std::fs::set_permissions(
|
||||
dir.path().join(file),
|
||||
std::fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.expect("chmod");
|
||||
}
|
||||
ensure("demo", dir.path(), &addresses()).expect("reissue");
|
||||
|
||||
let mode = |path: std::path::PathBuf| {
|
||||
std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777
|
||||
};
|
||||
assert_eq!(mode(dir.path().to_path_buf()), 0o700);
|
||||
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
|
||||
assert_eq!(
|
||||
mode(dir.path().join(file)),
|
||||
0o600,
|
||||
"{file} is not owner-only"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The product name is the whole of what is per-project, so it has to
|
||||
/// reach the certificate rather than being decoration.
|
||||
#[test]
|
||||
fn the_product_names_the_certificate() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
ensure("dev-updater", dir.path(), &addresses()).expect("generate");
|
||||
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
|
||||
|
||||
// Decoded rather than searched as base64: a distinguished name is
|
||||
// stored in the DER as literal bytes, so this checks the value
|
||||
// that actually reaches a phone rather than the value we passed in.
|
||||
let der = decode_pem(&ca);
|
||||
assert!(
|
||||
contains(&der, b"dev-updater dev CA"),
|
||||
"the CA's common name"
|
||||
);
|
||||
assert!(contains(&der, b"dev-updater dev"), "the organisation");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Reading and writing the RON both projects' config files are in.
|
||||
//!
|
||||
//! Two things are house rules rather than plain RON, and they are here
|
||||
//! together because they are inverses of each other -- change one and the
|
||||
//! other stops round-tripping. Both projects had this, identically, to the
|
||||
//! byte; that is what made it the first thing worth sharing.
|
||||
//!
|
||||
//! **No outer parentheses.** A file *is* the body of the struct, so
|
||||
//! nothing in it is indented for the sake of a wrapper. RON has no
|
||||
//! implicit top-level struct (`de/mod.rs` requires the `(`), so [`parse`]
|
||||
//! adds it and [`render`] takes it back off. The opening paren is not
|
||||
//! followed by a newline, so a parse error's line number still points at
|
||||
//! the real line.
|
||||
//!
|
||||
//! **`Some` is implicit.** Enabled on the deserializer rather than by a
|
||||
//! `#![enable(implicit_some)]` header every file would have to remember,
|
||||
//! and matched on the writing side by `skip_serializing_if` so nothing
|
||||
//! writes back a `Some(...)` a person didn't type. The two halves only
|
||||
//! round-trip together, which is why a caller's own tests should assert
|
||||
//! the written shape rather than only that it loads.
|
||||
|
||||
/// Reading and writing the RON these files are in.
|
||||
///
|
||||
/// Two things are house rules rather than plain RON, and they are here
|
||||
/// together because they are inverses of each other -- change one and the
|
||||
/// other stops round-tripping.
|
||||
///
|
||||
/// **No outer parentheses.** A file *is* the body of the struct, so nothing
|
||||
/// in it is indented for the sake of a wrapper. RON has no implicit
|
||||
/// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`]
|
||||
/// adds it and [`format::render`] takes it back off. The opening paren is not followed by a
|
||||
/// newline, so a parse error's line number still points at the real line.
|
||||
///
|
||||
/// **`Some` is implicit.** Enabled on the deserializer rather than by a
|
||||
/// `#![enable(implicit_some)]` header every project file would have to
|
||||
/// remember, and matched on the writing side by `skip_serializing_if` so
|
||||
/// nothing writes back a `Some(...)` a person didn't type.
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
fn options() -> ron::Options {
|
||||
ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
|
||||
}
|
||||
|
||||
pub fn parse<T: DeserializeOwned>(text: &str) -> Result<T, ron::error::SpannedError> {
|
||||
options().from_str(&format!("({text})"))
|
||||
}
|
||||
|
||||
pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
|
||||
let pretty = ron::ser::PrettyConfig::new();
|
||||
let text = options().to_string_pretty(value, pretty)?;
|
||||
Ok(unwrap_outer(&text))
|
||||
}
|
||||
|
||||
/// Strips the outer `(`/`)` the writer always emits and removes the
|
||||
/// indent level they cost. Deliberately narrow: it accepts only the
|
||||
/// exact shape `PrettyConfig` produces, and leaves anything else alone
|
||||
/// rather than guessing -- a file with stray parentheses is better than
|
||||
/// one silently mangled. `parse` round-trips either way, since a
|
||||
/// wrapped body parses the same as an unwrapped one re-wrapped.
|
||||
fn unwrap_outer(text: &str) -> String {
|
||||
let Some(body) = text
|
||||
.strip_prefix("(\n")
|
||||
.and_then(|rest| rest.strip_suffix("\n)"))
|
||||
else {
|
||||
return text.to_string();
|
||||
};
|
||||
let mut out: String = body
|
||||
.lines()
|
||||
.map(|line| line.strip_prefix(" ").unwrap_or(line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct Demo {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
/// The house rule both halves depend on: what is written is the *body*
|
||||
/// of the struct, with no outer parentheses and nothing indented for
|
||||
/// them. Asserted rather than trusted, because `render` strips what
|
||||
/// `parse` adds -- if only one ever changed, every file on disk would
|
||||
/// still load and only look wrong.
|
||||
#[test]
|
||||
fn a_file_is_the_body_of_the_struct() {
|
||||
let written = render(&Demo {
|
||||
name: "thing".to_string(),
|
||||
note: Some("why".to_string()),
|
||||
})
|
||||
.expect("render");
|
||||
|
||||
assert!(
|
||||
!written.trim_start().starts_with('('),
|
||||
"outer parens: {written}"
|
||||
);
|
||||
assert!(
|
||||
written.starts_with("name: "),
|
||||
"top level sits at column 0: {written}"
|
||||
);
|
||||
assert_eq!(
|
||||
parse::<Demo>(&written).expect("re-read").name,
|
||||
"thing",
|
||||
"what is written must read back",
|
||||
);
|
||||
}
|
||||
|
||||
/// An optional value is written as itself, never wrapped -- and a
|
||||
/// value nobody set is not written at all, so a file stays readable as
|
||||
/// what was actually chosen.
|
||||
#[test]
|
||||
fn an_optional_value_is_written_as_itself_or_not_at_all() {
|
||||
let with = render(&Demo {
|
||||
name: "a".to_string(),
|
||||
note: Some("b".to_string()),
|
||||
})
|
||||
.expect("render");
|
||||
assert!(
|
||||
with.contains(r#"note: "b""#),
|
||||
"no Some(...) wrapper: {with}"
|
||||
);
|
||||
|
||||
let without = render(&Demo::default()).expect("render");
|
||||
assert!(
|
||||
!without.contains("note"),
|
||||
"an unset value writes nothing: {without}"
|
||||
);
|
||||
|
||||
// And the bare form reads back, which is the other half.
|
||||
assert_eq!(
|
||||
parse::<Demo>("name: \"a\",\nnote: \"b\",\n")
|
||||
.expect("parse")
|
||||
.note
|
||||
.as_deref(),
|
||||
Some("b")
|
||||
);
|
||||
}
|
||||
|
||||
/// A parse error's line number has to point at the real line, which is
|
||||
/// why the opening paren is not followed by a newline.
|
||||
#[test]
|
||||
fn a_parse_error_points_at_the_line_it_is_on() {
|
||||
let err = parse::<Demo>("name: \"a\",\nnote: ,\n").expect_err("malformed");
|
||||
assert_eq!(err.span.start.line, 2, "{err}");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
//! that both files are written in are shared, since those were identical
|
||||
//! to the byte.
|
||||
|
||||
pub mod certs;
|
||||
pub mod enroll;
|
||||
pub mod format;
|
||||
pub mod netif;
|
||||
pub mod private;
|
||||
+92
-23
@@ -30,21 +30,30 @@ const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2];
|
||||
/// loopback, which is enough to start and to diagnose from the machine
|
||||
/// itself.
|
||||
pub fn local_addresses() -> Vec<IpAddr> {
|
||||
match if_addrs::get_if_addrs() {
|
||||
Ok(interfaces) => addresses_among(interfaces.iter().map(|iface| iface.ip())),
|
||||
Err(err) => {
|
||||
tracing::warn!("couldn't enumerate interfaces for the certificate: {err}");
|
||||
addresses_among(std::iter::empty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SAN list built from `found`, which is the part worth testing.
|
||||
///
|
||||
/// Split from the lookup so the outcome does not depend on what this
|
||||
/// machine happens to have; see [`wg_address_among`] for the same
|
||||
/// reasoning stated at length.
|
||||
pub fn addresses_among(found: impl IntoIterator<Item = IpAddr>) -> Vec<IpAddr> {
|
||||
let mut addresses = vec![
|
||||
IpAddr::from([127, 0, 0, 1]),
|
||||
IpAddr::from(EMULATOR_HOST_ALIAS),
|
||||
];
|
||||
match if_addrs::get_if_addrs() {
|
||||
Ok(interfaces) => {
|
||||
for interface in interfaces {
|
||||
let ip = interface.ip();
|
||||
for ip in found {
|
||||
if ip.is_ipv4() && !addresses.contains(&ip) {
|
||||
addresses.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"),
|
||||
}
|
||||
addresses
|
||||
}
|
||||
|
||||
@@ -59,10 +68,30 @@ pub fn local_addresses() -> Vec<IpAddr> {
|
||||
/// advice rather than as a library complaining.
|
||||
pub fn wg_address(product: &str) -> Result<IpAddr> {
|
||||
let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
|
||||
wg_address_among(
|
||||
product,
|
||||
interfaces
|
||||
.iter()
|
||||
.map(|iface| (iface.name.as_str(), iface.ip())),
|
||||
)
|
||||
}
|
||||
|
||||
/// The same answer, from interfaces the caller supplies.
|
||||
///
|
||||
/// This exists because the failure is the interesting half -- it is the
|
||||
/// message somebody reads when nothing works -- and it cannot be reached
|
||||
/// on any machine this runs on, all of which have the tunnel up. A test
|
||||
/// that calls [`wg_address`] and skips when it succeeds passes everywhere
|
||||
/// and checks nothing, which is the shape of test this project has been
|
||||
/// bitten by more than once.
|
||||
pub fn wg_address_among<'a>(
|
||||
product: &str,
|
||||
interfaces: impl IntoIterator<Item = (&'a str, IpAddr)>,
|
||||
) -> Result<IpAddr> {
|
||||
interfaces
|
||||
.into_iter()
|
||||
.find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4())
|
||||
.map(|iface| iface.ip())
|
||||
.find(|(name, ip)| *name == WG_INTERFACE && ip.is_ipv4())
|
||||
.map(|(_, ip)| ip)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \
|
||||
@@ -76,14 +105,24 @@ pub fn wg_address(product: &str) -> Result<IpAddr> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whatever this machine has, the two that are not interfaces must be
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().expect("an address")
|
||||
}
|
||||
|
||||
/// Whatever the machine has, the two that are not interfaces must be
|
||||
/// there -- loopback for tests and curl, the alias for an emulator --
|
||||
/// and nothing may appear twice, since these become certificate SANs.
|
||||
#[test]
|
||||
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
|
||||
let addresses = local_addresses();
|
||||
assert!(addresses.contains(&IpAddr::from([127, 0, 0, 1])));
|
||||
assert!(addresses.contains(&IpAddr::from(EMULATOR_HOST_ALIAS)));
|
||||
let found = [ip("192.168.1.168"), ip("10.66.0.1"), ip("192.168.1.168")];
|
||||
let addresses = addresses_among(found);
|
||||
|
||||
assert!(addresses.contains(&ip("127.0.0.1")));
|
||||
assert!(addresses.contains(&ip("10.0.2.2")));
|
||||
assert!(
|
||||
addresses.contains(&ip("10.66.0.1")),
|
||||
"the tunnel's own address"
|
||||
);
|
||||
|
||||
let mut seen = addresses.clone();
|
||||
seen.sort();
|
||||
@@ -92,20 +131,50 @@ mod tests {
|
||||
assert!(addresses.iter().all(|ip| ip.is_ipv4()));
|
||||
}
|
||||
|
||||
/// The failure is the thing a person reads at 2am, so it has to name
|
||||
/// A machine with nothing at all still gets a usable certificate,
|
||||
/// because failing to enumerate must not leave the server unable to
|
||||
/// answer on loopback and diagnose itself.
|
||||
#[test]
|
||||
fn a_machine_with_no_interfaces_still_gets_a_usable_certificate() {
|
||||
assert_eq!(
|
||||
addresses_among(std::iter::empty()),
|
||||
[ip("127.0.0.1"), ip("10.0.2.2")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tunnel_is_found_by_name_and_only_over_ipv4() {
|
||||
let found = wg_address_among(
|
||||
"demo-server",
|
||||
[
|
||||
("lo", ip("127.0.0.1")),
|
||||
("eth0", ip("192.168.1.5")),
|
||||
(WG_INTERFACE, ip("10.66.0.1")),
|
||||
],
|
||||
);
|
||||
assert_eq!(found.expect("the tunnel"), ip("10.66.0.1"));
|
||||
|
||||
// An interface of the right name carrying only IPv6 is not an
|
||||
// answer: both listeners bind an IPv4 socket.
|
||||
let v6_only = wg_address_among("demo-server", [(WG_INTERFACE, ip("fd00::1"))]);
|
||||
assert!(v6_only.is_err(), "IPv6 on wg0 is not the address to bind");
|
||||
}
|
||||
|
||||
/// The failure is the thing somebody reads at 2am, so it has to name
|
||||
/// the binary, the interface, and the way out.
|
||||
///
|
||||
/// Reached by handing in an empty interface list rather than by hoping
|
||||
/// the machine has no tunnel. Every machine this runs on has one up,
|
||||
/// so a test that called the real lookup and skipped on success would
|
||||
/// pass everywhere and assert nothing -- which is exactly what it did
|
||||
/// before this was rewritten.
|
||||
#[test]
|
||||
fn a_missing_tunnel_explains_itself() {
|
||||
// Only meaningful where there is no wg0; where there is one, the
|
||||
// call succeeds and there is no message to check.
|
||||
if wg_address("demo-server").is_ok() {
|
||||
return;
|
||||
}
|
||||
let err = wg_address("demo-server")
|
||||
let err = wg_address_among("demo-server", [])
|
||||
.expect_err("no tunnel")
|
||||
.to_string();
|
||||
assert!(err.contains("demo-server"), "{err}");
|
||||
assert!(err.contains(WG_INTERFACE), "{err}");
|
||||
assert!(err.contains("--bind"), "{err}");
|
||||
assert!(err.contains("demo-server"), "names the binary: {err}");
|
||||
assert!(err.contains(WG_INTERFACE), "names the interface: {err}");
|
||||
assert!(err.contains("--bind"), "names the way out: {err}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user