Merge branch 'app-embeds-ca-from-config'
The app pins the CA of the machine that builds it, read at build time rather than pasted into the source; state and certificates move out of the shared repo; certificates are generated in process; and the sibling project's rename to dev-updater is followed here.
This commit is contained in:
commit
d4a4ee7808
17 files changed
+774
-226
No files matched your search
@@ -22,11 +22,11 @@ session-type branch in shared code (routes, transcript, app screens).
|
||||
|
||||
## Layout
|
||||
|
||||
Mirrors `../local-updater` deliberately — same stack (axum 0.8 +
|
||||
Mirrors `../dev-updater` deliberately — same stack (axum 0.8 +
|
||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform,
|
||||
single `:androidApp` module), same cert scheme, same registry pattern (every
|
||||
session mutation funnels through the manager so in-memory and on-disk state
|
||||
can't come apart). Read local-updater's `README.md` and `AGENTS.md` for the
|
||||
can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the
|
||||
conventions before diverging from them; module-by-module intent for this
|
||||
repo is in PLAN.md's "Backend layout" section.
|
||||
|
||||
@@ -40,10 +40,11 @@ repo is in PLAN.md's "Backend layout" section.
|
||||
`when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the
|
||||
event model mirror; `ServerConfig.kt` settings + Keystore-sealed token;
|
||||
screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
|
||||
- `gen-dev-cert.sh` / `certs/` — copied from local-updater's scheme
|
||||
(idempotent CA, reissued leaf; regenerating the CA strands the installed
|
||||
app — same one-way door). Dev SANs cover 127.0.0.1, 10.0.2.2 (emulator →
|
||||
host), and the LAN IP alongside the WireGuard address.
|
||||
- `server/src/certs.rs` — the TLS certificates, generated in process on
|
||||
first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf
|
||||
reissued every start covering every local IPv4 plus 127.0.0.1 and
|
||||
10.0.2.2 (emulator → host). Regenerating the CA strands the installed
|
||||
app — the one-way door.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -59,8 +60,15 @@ real-phone/WireGuard bring-up, which is operational rather than code.
|
||||
|
||||
- Server: `./run-tests.sh` (or `cargo test`) + `cargo clippy --all-targets`
|
||||
from `server/` — the build stays warning-clean, keep it that way.
|
||||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`;
|
||||
`./run-android.sh` builds, installs, and launches on the emulator.
|
||||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`
|
||||
to typecheck; `./build-apk.sh` to produce the APK to install on a phone
|
||||
(through Dev Updater); `./run-android.sh` to build, install, and launch
|
||||
on the emulator.
|
||||
- **The APK pins the CA of the machine that builds it**, read at build time
|
||||
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and
|
||||
generated into a constant. So the server must have started once on that
|
||||
machine first — the build stops with that instruction otherwise — and an
|
||||
APK built in this VM only works against a server in this VM.
|
||||
- Run the server for development with `--bind 127.0.0.1` (wg0 doesn't exist
|
||||
on this machine yet; the default fails closed). First run prints the
|
||||
enrollment QR/URI with the token — capture it from the log.
|
||||
@@ -75,7 +83,7 @@ real-phone/WireGuard bring-up, which is operational rather than code.
|
||||
Established 2026-08-25, and it decides more than it looks like:
|
||||
|
||||
- **The host (192.168.1.168) is the backend machine.** It runs
|
||||
local-updater's server today and is where `ai-server` belongs in
|
||||
dev-updater's server today and is where `ai-server` belongs in
|
||||
production: it has the LAN address the phone can reach, and it's where
|
||||
WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`,
|
||||
the phone's QR); run it there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||
@@ -115,9 +123,11 @@ Established 2026-08-25, and it decides more than it looks like:
|
||||
PLAN.md's security section), and the repo is shared read-write with the
|
||||
host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.json`
|
||||
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
|
||||
- Certificates are generated **on the machine that serves them**
|
||||
(`./gen-dev-cert.sh`, honours `AI_APP_CERTS`). Running it in the VM makes
|
||||
a separate throwaway dev CA for emulator work — never install a build
|
||||
- Certificates are generated **by the server, on first start**, into
|
||||
`$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created
|
||||
once and then left alone; the leaf is reissued every start, so covering a
|
||||
new address is a restart. Starting the server in the VM therefore makes a
|
||||
separate throwaway dev CA for emulator work — never install a build
|
||||
pinning that on the real phone.
|
||||
- Point development at a scratch state directory rather than the real one:
|
||||
`--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`, or
|
||||
@@ -138,11 +148,19 @@ Established 2026-08-25, and it decides more than it looks like:
|
||||
- **CMP 1.11 deprecates the `compose.*` dependency accessors** — declare
|
||||
`org.jetbrains.compose.<x>:<x>` directly (material3 has its own release
|
||||
train, separate from the CMP version).
|
||||
- **A PEM constant must start at the opening quotes.** A generated
|
||||
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory`
|
||||
its preamble sniff, so it tries DER instead and fails at runtime with
|
||||
`ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it.
|
||||
- **AGP 9 refuses `Provider`s in the source-set API**: generated sources go
|
||||
through `androidComponents.onVariants { it.sources.java?.addGenerated
|
||||
SourceDirectory(task, Task::outputDir) }`, which also carries the task
|
||||
dependency.
|
||||
|
||||
## Environment notes (this machine, learned in local-updater)
|
||||
## Environment notes (this machine, learned in dev-updater)
|
||||
|
||||
- Android SDK is at `~/Android/Sdk`, not the root-owned `/opt/android-sdk`
|
||||
the ambient `$ANDROID_HOME` may point at; copy local-updater's
|
||||
the ambient `$ANDROID_HOME` may point at; copy dev-updater's
|
||||
`android-env.sh` override pattern.
|
||||
- Each agent command runs in a fresh shell — exported environment does not
|
||||
carry over. Chain: `cd app && . ./android-env.sh && ./gradlew …`. Never
|
||||
@@ -153,5 +171,5 @@ Established 2026-08-25, and it decides more than it looks like:
|
||||
(`setsid nohup … & disown -h`, verify `PPID 1`), and every
|
||||
`pgrep -f`/`pkill -f` pattern needs its first character bracketed
|
||||
(`[a]i-server`) in **every** occurrence in the command, or the pattern
|
||||
matches the shell running it. Full explanation in local-updater's
|
||||
matches the shell running it. Full explanation in dev-updater's
|
||||
`AGENTS.md` — it bites exactly the same way here.
|
||||
@@ -7,7 +7,7 @@ the official app gets wrong (e.g. it won't deliver a typed message until the
|
||||
session fully finishes its turn, where the TUI injects it at the next tool
|
||||
boundary).
|
||||
|
||||
Same shape as `../local-updater`: a Rust (Axum) backend on the desktop, a
|
||||
Same shape as `../dev-updater`: a Rust (Axum) backend on the desktop, a
|
||||
Kotlin/Compose Android app, pinned self-signed TLS between them.
|
||||
|
||||
## The one idea everything hangs off
|
||||
@@ -81,12 +81,12 @@ backend (Rust/Axum, desktop)
|
||||
|
||||
### Backend layout (`server/`)
|
||||
|
||||
Mirroring local-updater's stack: axum 0.8, axum-server + rustls, tokio, serde,
|
||||
Mirroring dev-updater's stack: axum 0.8, axum-server + rustls, tokio, serde,
|
||||
clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit.
|
||||
|
||||
- `main.rs` — bootstrap, TLS listener.
|
||||
- `routes.rs` — the whole HTTP table in one module doc comment (as in
|
||||
local-updater).
|
||||
dev-updater).
|
||||
- `session/mod.rs` — `SessionManager`: the live session registry, every
|
||||
mutation funnels through it (the `registry.rs` pattern: in-memory and
|
||||
on-disk state can't come apart).
|
||||
@@ -259,9 +259,24 @@ complete path out of everything spawning one created.
|
||||
|
||||
### Security
|
||||
|
||||
- TLS with a self-signed CA, pinned in the app — `gen-dev-cert.sh` and
|
||||
`PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf
|
||||
scheme, same one-way-door caveat about regenerating the CA.
|
||||
- TLS with a self-signed CA, pinned in the app — same
|
||||
idempotent-CA/reissued-leaf scheme as dev-updater, same one-way-door
|
||||
caveat about regenerating the CA, but generated **in process on first
|
||||
start** (`certs.rs`) rather than by a shell script calling openssl
|
||||
(2026-08-25). One place then decides the extensions, the file modes, and
|
||||
which addresses the leaf covers — every local IPv4 plus loopback and the
|
||||
emulator's host alias, so nobody maintains a hardcoded IP — and there is
|
||||
no setup step to forget.
|
||||
- Unlike dev-updater, the pinned CA is **not a constant in the source**:
|
||||
the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine
|
||||
doing the build and generates the constant (`generatePinnedCert` in
|
||||
`app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). Decided
|
||||
2026-08-25, and it does three things at once — the trust anchor follows
|
||||
the build machine, so an APK built on the backend host pins that host
|
||||
and one built in the dev VM pins the VM's throwaway CA and is only good
|
||||
for its emulator; there is no second anchor to add for development and
|
||||
forget to remove; and regenerating a CA needs a rebuild rather than a
|
||||
paste, so a stale constant can't quietly disagree with the server.
|
||||
- **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't
|
||||
malicious but could become so. It matters because the repo is a
|
||||
read-write virtiofs mount shared between the VM and the backend host, so
|
||||
@@ -377,12 +392,12 @@ complete path out of everything spawning one created.
|
||||
a deliberate flag, never a fallback, so the fail-closed default is
|
||||
untouched (2026-08-24).
|
||||
- The bootstrap-over-HTTP trick from the updater is unnecessary here — the
|
||||
app installs via Local Updater.
|
||||
app installs via Dev Updater.
|
||||
|
||||
## App (`app/`)
|
||||
|
||||
Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
|
||||
local-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
|
||||
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
|
||||
|
||||
1. **Session list** — cards: kind icon, title, host, model, status
|
||||
(running / awaiting answer / idle / exited), last activity. Spawn FAB;
|
||||
@@ -405,7 +420,7 @@ local-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
|
||||
5. **Settings** — server address + token, hosts editor, llama model list
|
||||
editor.
|
||||
|
||||
Networking mirrors local-updater's app layer (`AppsApi.kt` style thin client +
|
||||
Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client +
|
||||
pinned transport), plus an SSE client with `after=` resume driven by
|
||||
connectivity/lifecycle. The app keeps no persistent transcript store — the
|
||||
backend's transcript is the source of truth; the app caches only for the
|
||||
@@ -486,7 +501,7 @@ window just fills.
|
||||
Each phase ends runnable and verified against the real thing (rule 22); the
|
||||
backend gets tests where logic is pure (event normalization, transcript
|
||||
cursors, config persistence, refcounting) — the app is UI over the API and is
|
||||
verified by running it, matching local-updater's posture.
|
||||
verified by running it, matching dev-updater's posture.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
@@ -524,6 +539,6 @@ installed versions when each phase starts):
|
||||
≥180 s polling; wrong User-Agent → aggressive 429 bucket):
|
||||
https://github.com/anthropics/claude-code/issues/31637 and
|
||||
https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor/issues/202
|
||||
- Sibling project this repo's conventions mirror: `../local-updater`
|
||||
- Sibling project this repo's conventions mirror: `../dev-updater`
|
||||
(README.md + AGENTS.md — server/registry/routes layout, cert scheme,
|
||||
testing posture, Android env notes).
|
||||
@@ -4,6 +4,84 @@ plugins {
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
// The CA this app pins is baked in at build time from the certificates on
|
||||
// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`,
|
||||
// which the server generates on first start. AI_APP_CA overrides the path.
|
||||
//
|
||||
// Reading it rather than keeping a pasted copy in the source is what makes
|
||||
// the trust boundary follow the build: an APK built on the backend host
|
||||
// pins the host's CA and never sees any other, while one built in the dev
|
||||
// VM pins that VM's throwaway CA and is only good for its emulator. There
|
||||
// is no second trust anchor to get wrong, and no stale paste to notice
|
||||
// three days later. It also means the private key never has to exist
|
||||
// anywhere near this repo.
|
||||
val pinnedCaPath: String = System.getenv("AI_APP_CA")
|
||||
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
||||
"/ai-app/certs/ca.pem"
|
||||
|
||||
abstract class GeneratePinnedCert : DefaultTask() {
|
||||
/** Where the certificate is looked for, reported in failures. */
|
||||
@get:Input
|
||||
abstract val caPath: Property<String>
|
||||
|
||||
/**
|
||||
* The certificate itself, set only when it exists -- so a missing one
|
||||
* produces this task's own instructions rather than Gradle's "no such
|
||||
* input file", which doesn't say what to run.
|
||||
*/
|
||||
@get:InputFile
|
||||
@get:Optional
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
abstract val caCertificate: RegularFileProperty
|
||||
|
||||
/** Wired by AGP through `addGeneratedSourceDirectory`. */
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val path = caPath.get()
|
||||
val ca = File(path)
|
||||
if (!ca.isFile) {
|
||||
throw GradleException(
|
||||
"No CA certificate at $path.\n" +
|
||||
"Start ai-server once on this machine first -- it generates the CA the " +
|
||||
"app pins, and the certificate has to exist before an APK can embed it.\n" +
|
||||
"Set AI_APP_CA=/path/to/ca.pem to build against a different one.",
|
||||
)
|
||||
}
|
||||
val pem = ca.readText().trim()
|
||||
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
||||
throw GradleException("$path is not a PEM certificate.")
|
||||
}
|
||||
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
|
||||
file.parentFile.mkdirs()
|
||||
// The PEM must start immediately after the opening quotes: a
|
||||
// leading newline makes Android's CertificateFactory stop
|
||||
// recognising the "-----BEGIN" preamble and try to parse the whole
|
||||
// thing as DER, which fails with an ASN.1 decode error at runtime
|
||||
// rather than anywhere near this file.
|
||||
file.writeText(
|
||||
"""
|
||||
|// Generated from $path by the generatePinnedCert task. Do not edit.
|
||||
|package com.example.aiapp
|
||||
|
|
||||
|const val PINNED_CA_PEM = ""${'"'}$pem
|
||||
|""${'"'}
|
||||
|
|
||||
""".trimMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val generatePinnedCert = tasks.register<GeneratePinnedCert>("generatePinnedCert") {
|
||||
val ca = file(pinnedCaPath)
|
||||
caPath.set(pinnedCaPath)
|
||||
if (ca.isFile) {
|
||||
caCertificate.set(ca)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.aiapp"
|
||||
compileSdk = 37
|
||||
@@ -31,6 +109,17 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// AGP 9 wants generated sources registered through the variant API rather
|
||||
// than added to a source set, so the task dependency is carried properly.
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.sources.java?.addGeneratedSourceDirectory(
|
||||
generatePinnedCert,
|
||||
GeneratePinnedCert::outputDir,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
network address, including a plain socket to a LAN IP literal.
|
||||
Without it the traffic is silently dropped, surfacing only as a
|
||||
connect timeout. See MainActivity.kt's runtime request, and
|
||||
local-updater's manifest for the full story. -->
|
||||
dev-updater's manifest for the full story. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
<application
|
||||
|
||||
@@ -36,7 +36,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
// Transparent status bar on every version; the Surface below paints
|
||||
// through underneath it and content insets itself. Same reasoning
|
||||
// as local-updater's MainActivity.
|
||||
// as dev-updater's MainActivity.
|
||||
enableEdgeToEdge()
|
||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true
|
||||
|
||||
|
||||
@@ -10,31 +10,19 @@ import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* PEM-encoded dev CA certificate this repo's `gen-dev-cert.sh` generates --
|
||||
* the sole trust anchor for every request this app makes. The server's TLS
|
||||
* listener presents a leaf certificate signed by this CA. Everything behind
|
||||
* that listener *is* remote code execution on the backend, so this app
|
||||
* trusts exactly this CA and nothing else -- not even the system store.
|
||||
*
|
||||
* Regenerating that CA means updating this to match; its SHA-256
|
||||
* fingerprint is printed by the script and saved to `certs/ca-sha256.txt`.
|
||||
* The QR enrollment deliberately does not carry the CA: trust lives here in
|
||||
* the APK, so photographing the terminal leaks only the (rotatable) token.
|
||||
*/
|
||||
const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE-----
|
||||
MIIBvzCCAWWgAwIBAgIUGCxNZPJIfzGQipZGxWVZ8vMYZXgwCgYIKoZIzj0EAwIw
|
||||
LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe
|
||||
Fw0yNjA4MjUwMTI2MDRaFw0zNjA4MjIwMTI2MDRaMC0xEzARBgNVBAoMCmFpLWFw
|
||||
cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO
|
||||
PQMBBwNCAARE6qRKz1HeCzcvmdT6ztwTR2w4DGP97aaYJhp3z+es6dceNXdpP1qx
|
||||
3DlazArgYLjOcNOHTqonj4H5NwHfeP4So2MwYTAdBgNVHQ4EFgQUL9VAISmEPDhJ
|
||||
dHnl5iS10qLbcekwHwYDVR0jBBgwFoAUL9VAISmEPDhJdHnl5iS10qLbcekwDwYD
|
||||
VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIh
|
||||
AL7Z0wfT0pXD08J6GNbPVfy/PB3EoUtIwA9z9rYGPEhZAiBeLPiXCvIbWWTpsjRr
|
||||
Uk5VTHJchx0xXCdTRSJo2BEh7Q==
|
||||
-----END CERTIFICATE-----
|
||||
"""
|
||||
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
|
||||
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
|
||||
// deliberately not a checked-in constant: the private key that signs against
|
||||
// it must never be anywhere this repo is, and an APK should pin whatever CA
|
||||
// the backend it was built for actually serves.
|
||||
//
|
||||
// So the trust anchor follows the build machine. Built on the backend host,
|
||||
// the app trusts that host and nothing else -- not even the system store.
|
||||
// Built in the dev VM, it trusts that VM's throwaway CA and is good only for
|
||||
// its emulator; never install one of those on a real phone.
|
||||
//
|
||||
// The QR enrollment deliberately carries no CA: trust lives here in the APK,
|
||||
// so photographing the terminal leaks only the (rotatable) token.
|
||||
|
||||
/**
|
||||
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a
|
||||
@@ -43,8 +31,11 @@ Uk5VTHJchx0xXCdTRSJo2BEh7Q==
|
||||
* KeyStore/TrustManager setup from scratch.
|
||||
*/
|
||||
val pinnedSslSocketFactory: SSLSocketFactory by lazy {
|
||||
// Trimmed because CertificateFactory only recognises PEM when the
|
||||
// "-----BEGIN" preamble is the very first thing it sees; surrounding
|
||||
// whitespace sends it down the DER path instead.
|
||||
val caCert = CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.encodeToByteArray()))
|
||||
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray()))
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, null)
|
||||
setCertificateEntry("ai-app-dev-ca", caCert)
|
||||
|
||||
@@ -36,7 +36,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Status colors, keyed by the wire strings in Events.kt. Light theme only,
|
||||
// as in local-updater.
|
||||
// as in dev-updater.
|
||||
private val AWAITING_COLOR = Color(0xFFB26A00)
|
||||
private val RUNNING_COLOR = Color(0xFF2E7D32)
|
||||
|
||||
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
# Builds the app's APK, ready to install on a phone through Dev Updater.
|
||||
#
|
||||
# ./build-apk.sh
|
||||
#
|
||||
# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/ai-app/certs/ca.pem,
|
||||
# or AI_APP_CA), so build it on the machine that runs the backend: an app
|
||||
# built somewhere else trusts a CA that backend can't present, and simply
|
||||
# won't connect. Start ai-server once first if there are no certificates
|
||||
# yet -- it generates them; the build stops with that instruction if it
|
||||
# can't find one.
|
||||
#
|
||||
# Unlike ./run-android.sh, this touches no emulator: it only produces the
|
||||
# file. Installing on a real phone goes through Dev Updater, which serves
|
||||
# whatever is under this project's build directory.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Prefer an SDK this machine has already configured -- the host and the dev
|
||||
# VM don't keep it in the same place, and android-env.sh is written for the
|
||||
# VM's layout (it also installs missing packages, which isn't wanted here).
|
||||
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
|
||||
echo "==> Using ANDROID_HOME=$ANDROID_HOME"
|
||||
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
|
||||
ANDROID_HOME="$ANDROID_SDK_ROOT"
|
||||
export ANDROID_HOME
|
||||
echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||
ANDROID_HOME="$HOME/Android/Sdk"
|
||||
ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export ANDROID_HOME ANDROID_SDK_ROOT
|
||||
echo "==> Using $ANDROID_HOME"
|
||||
else
|
||||
echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2
|
||||
echo "(Android Studio's default location is ~/Android/Sdk)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}"
|
||||
if [ -f "$CA" ]; then
|
||||
# Printed so a wrong or stale certificate is visible here rather than as
|
||||
# a handshake failure on the phone. Compare with the server's own
|
||||
# the CA the server is actually presenting.
|
||||
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
|
||||
| openssl pkey -pubin -outform der 2>/dev/null \
|
||||
| openssl dgst -sha256 -binary 2>/dev/null \
|
||||
| openssl base64 2>/dev/null || echo "(openssl unavailable)")
|
||||
echo "==> Pinning the CA at $CA"
|
||||
echo " fingerprint: $FINGERPRINT"
|
||||
else
|
||||
echo "No CA certificate at $CA -- start ai-server once on this machine" >&2
|
||||
echo "(it generates them), or set AI_APP_CA. The APK embeds it at build time." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Building"
|
||||
./gradlew :androidApp:assembleDebug
|
||||
|
||||
APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.apk"
|
||||
echo
|
||||
echo "==> Built $APK"
|
||||
[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}'
|
||||
echo
|
||||
echo "To get it onto the phone: add this project to Dev Updater (or hit"
|
||||
echo "Update on it if it's already there) and install from there."
|
||||
echo "Then start the backend and scan the enrollment QR it prints:"
|
||||
echo " ./server/target/release/ai-server --rotate-token"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Builds and runs this app on an emulator, creating/booting the AVD first if
|
||||
# it isn't already up. Same flow as local-updater's run-android.sh; see that
|
||||
# it isn't already up. Same flow as dev-updater's run-android.sh; see that
|
||||
# script for the reasoning behind the avd handling.
|
||||
#
|
||||
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Generates the self-signed dev CA and leaf certificate `server/` serves its
|
||||
# TLS listener with. Run it once, ON THE MACHINE THAT RUNS THE BACKEND, before
|
||||
# the first start; the server exits with a clear message if the certificates
|
||||
# are missing.
|
||||
#
|
||||
# Same scheme as ../local-updater's: nothing on a device trusts this
|
||||
# automatically -- the app embeds the CA certificate verbatim and pins to it
|
||||
# (`PinnedCert.kt`), rather than relying on the device's system trust store.
|
||||
# This server's API *is* remote code execution (it spawns AI sessions on
|
||||
# request), so a MITM on it would be as bad as it gets -- hence pinning.
|
||||
#
|
||||
# WHERE THE KEYS LIVE, AND WHY NOT IN THE REPO
|
||||
#
|
||||
# Output goes to $XDG_CONFIG_HOME/ai-app/certs (0700), deliberately *not*
|
||||
# beside this script. The repo is a virtiofs mount shared with the dev VM,
|
||||
# and that VM is treated as untrusted -- a machine that isn't malicious but
|
||||
# could become so. A CA private key it can read is a CA private key it can
|
||||
# sign with, and a leaf signed by this CA is one the phone's pinned app
|
||||
# accepts without question. Keeping the key off the shared mount is what
|
||||
# makes pinning mean anything.
|
||||
#
|
||||
# The same reasoning says the CA key doesn't belong on the backend either,
|
||||
# strictly: the server only ever reads leaf.pem and leaf-key.pem, and the CA
|
||||
# key is needed solely to reissue a leaf. Moving ca-key.pem somewhere offline
|
||||
# once the setup is stable costs nothing but having it to hand at reissue.
|
||||
#
|
||||
# Running this inside the VM is fine and expected for emulator work -- it
|
||||
# just produces a *different*, throwaway CA there. Never install a build
|
||||
# pinning that dev CA on the real phone.
|
||||
#
|
||||
# The CA is idempotent -- skipped if `certs/ca.pem` already exists, so
|
||||
# re-running this doesn't invalidate the certificate the installed app has
|
||||
# pinned against without a reason to. The leaf is cheap and reissued on
|
||||
# every run (still signed by that same, unchanged CA), so adding another SAN
|
||||
# entry only means rerunning this script, not touching anything pinned.
|
||||
#
|
||||
# The leaf's SANs must cover every address a device reaches this server at.
|
||||
# In production that is exactly one: the backend's WireGuard address, which
|
||||
# the phone uses from everywhere (see PLAN.md's off-network section).
|
||||
# Override with SERVER_IP=... if your wg0 address differs.
|
||||
#
|
||||
# Outputs into `certs/` (gitignored -- private key material, and the whole
|
||||
# thing is trivially regeneratable anyway):
|
||||
# ca.pem the CA certificate (not its private key) -- what the
|
||||
# app embeds and pins against.
|
||||
# ca-key.pem the CA's private key -- only this script needs it, to
|
||||
# sign the leaf below. Never shipped anywhere.
|
||||
# leaf.pem the server's own certificate (CA-signed), presented on
|
||||
# every TLS handshake.
|
||||
# leaf-key.pem the leaf's private key -- what the server loads to
|
||||
# terminate TLS.
|
||||
# ca-sha256.txt the CA certificate's SPKI SHA-256 fingerprint, printed
|
||||
# below too. Informational -- the app embeds the whole
|
||||
# `ca.pem`, not this digest.
|
||||
# leaf-sha256.txt the leaf's SPKI SHA-256 fingerprint. Informational --
|
||||
# nothing pins the leaf; the app pins the CA and
|
||||
# validates the chain.
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
CERTS_DIR="${AI_APP_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}"
|
||||
# The backend's WireGuard address -- the one address the phone ever dials in
|
||||
# production (PLAN.md: single-path addressing, no home/away distinction).
|
||||
SERVER_IP="${SERVER_IP:-10.66.0.1}"
|
||||
# Also covered so development before the tunnel exists can complete a real
|
||||
# handshake against the same pinned CA:
|
||||
# 127.0.0.1 curl from the machine itself, and tests binding loopback
|
||||
# 10.0.2.2 the Android emulator's alias for the host's loopback
|
||||
# LAN_IP a real phone on the same LAN, pre-WireGuard
|
||||
LOOPBACK_IP="127.0.0.1"
|
||||
EMULATOR_HOST_IP="10.0.2.2"
|
||||
LAN_IP="${LAN_IP:-192.168.1.168}"
|
||||
|
||||
# Private key material: owner-only from the moment it exists, rather than
|
||||
# created world-readable and chmod'ed a beat later.
|
||||
umask 077
|
||||
mkdir -p "$CERTS_DIR"
|
||||
chmod 700 "$CERTS_DIR"
|
||||
cd "$CERTS_DIR"
|
||||
echo "==> Writing certificates to $CERTS_DIR"
|
||||
|
||||
if [ -f ca.pem ]; then
|
||||
echo "==> ca.pem already exists, reusing existing CA."
|
||||
else
|
||||
echo "==> Generating CA key + self-signed CA certificate"
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out ca-key.pem
|
||||
# Explicit keyUsage: strict verifiers (e.g. Python 3.14's ssl) reject a
|
||||
# CA without it, and Android could follow -- cheap to be proper now,
|
||||
# expensive to regenerate after phones have pinned it.
|
||||
openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 \
|
||||
-subj "/O=ai-app dev/CN=ai-app dev CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||
fi
|
||||
|
||||
echo "==> Generating leaf key + CSR for $SERVER_IP (+ dev addresses)"
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out leaf-key.pem
|
||||
openssl req -new -key leaf-key.pem -out leaf.csr \
|
||||
-subj "/O=ai-app dev/CN=$SERVER_IP"
|
||||
|
||||
echo "==> Signing leaf certificate with the dev CA"
|
||||
cat > leaf.ext <<EOF
|
||||
subjectAltName = IP:$SERVER_IP,IP:$LOOPBACK_IP,IP:$EMULATOR_HOST_IP,IP:$LAN_IP
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = digitalSignature
|
||||
extendedKeyUsage = serverAuth
|
||||
EOF
|
||||
openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||
-out leaf.pem -days 3650 -extfile leaf.ext
|
||||
rm -f leaf.csr leaf.ext ca.srl
|
||||
|
||||
echo "==> Computing certificate fingerprints (SPKI SHA-256)"
|
||||
CA_SHA256=$(openssl x509 -in ca.pem -pubkey -noout \
|
||||
| openssl pkey -pubin -outform der \
|
||||
| openssl dgst -sha256 -binary \
|
||||
| openssl base64)
|
||||
echo "$CA_SHA256" > ca-sha256.txt
|
||||
LEAF_SHA256=$(openssl x509 -in leaf.pem -pubkey -noout \
|
||||
| openssl pkey -pubin -outform der \
|
||||
| openssl dgst -sha256 -binary \
|
||||
| openssl base64)
|
||||
echo "$LEAF_SHA256" > leaf-sha256.txt
|
||||
|
||||
echo
|
||||
echo "==> Done."
|
||||
echo " CA fingerprint (base64): $CA_SHA256"
|
||||
echo " Leaf fingerprint (base64): $LEAF_SHA256"
|
||||
echo
|
||||
echo " Only relevant if the CA was regenerated just now (i.e. ca.pem did"
|
||||
echo " not already exist): the app embeds PINNED_CA_PEM and needs the new"
|
||||
echo " $CERTS_DIR/ca.pem contents pasted in, or it silently"
|
||||
echo " stops being able to reach this server. The app installs via Local"
|
||||
echo " Updater, so recovery is a reinstall through that -- but it's still"
|
||||
echo " a one-way door for the installed copy."
|
||||
echo
|
||||
echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt"
|
||||
echo
|
||||
echo " Only ca.pem is meant to leave this machine. Copy it by hand; do not"
|
||||
echo " put the certs directory back in the repo, which is shared with the"
|
||||
echo " VM (see this script's header)."
|
||||
Generated
+262
-3
@@ -24,11 +24,12 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"clap",
|
||||
"if-addrs",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -109,6 +110,45 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[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 = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -219,12 +259,27 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -363,6 +418,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"
|
||||
@@ -374,6 +455,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 = "dunce"
|
||||
version = "1.0.5"
|
||||
@@ -740,6 +832,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -778,6 +876,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[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 = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -787,6 +895,49 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -799,6 +950,16 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[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 = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -817,6 +978,12 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[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"
|
||||
@@ -864,6 +1031,20 @@ 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 = "regex-automata"
|
||||
version = "0.4.18"
|
||||
@@ -895,6 +1076,15 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -1133,6 +1323,17 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[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"
|
||||
@@ -1175,6 +1376,36 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -1351,7 +1582,7 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
@@ -1368,7 +1599,7 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
@@ -1501,6 +1732,34 @@ 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"
|
||||
|
||||
@@ -30,6 +30,15 @@ qrcode = { version = "0.14", default-features = false }
|
||||
# The wg0-bound listener needs the interface's address; the stdlib has no
|
||||
# getifaddrs. This is the smallest crate that wraps just that.
|
||||
if-addrs = "0.15"
|
||||
# Generates this server's TLS certificates on first start, replacing a
|
||||
# setup script that shelled out to whatever openssl happened to be
|
||||
# installed. In process means 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: reconstructing it from the same
|
||||
# parameters would work only as long as nothing ever changed them, and a
|
||||
# mismatched issuer name yields a chain that fails to validate rather than
|
||||
# anything that looks wrong at generation time.
|
||||
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
|
||||
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
|
||||
# every-few-minutes poll better than pulling in reqwest's tower stack;
|
||||
# rustls-backed like the rest of the TLS here.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! The TLS certificates this server presents, generated in process on
|
||||
//! first start.
|
||||
//!
|
||||
//! 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 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`): the repo is a mount shared with a VM that is not
|
||||
//! trusted, and a CA private key that VM can read is one it can sign with
|
||||
//! -- a certificate signed by a pinned CA is accepted without question,
|
||||
//! which is exactly the attack pinning exists to stop.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||
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(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(dir)
|
||||
.with_context(|| format!("create {}", dir.display()))?;
|
||||
// Set explicitly as well: `mode` applies only when the directory is
|
||||
// created, so a directory that already existed -- made by hand, or by
|
||||
// an older version -- would otherwise keep whatever permissions it had
|
||||
// while holding a private key.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.with_context(|| format!("restrict {}", dir.display()))?;
|
||||
|
||||
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()?;
|
||||
write_private(&ca_key_path, &key)?;
|
||||
write_private(&ca_cert_path, &pem)?;
|
||||
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(&ca_pem, &ca_key_pem, addresses)?;
|
||||
let leaf_cert = dir.join("leaf.pem");
|
||||
let leaf_key = dir.join("leaf-key.pem");
|
||||
write_private(&leaf_key, &leaf_key_pem)?;
|
||||
write_private(&leaf_cert, &leaf_pem)?;
|
||||
|
||||
Ok(Certificates { leaf_cert, leaf_key, ca_is_new })
|
||||
}
|
||||
|
||||
fn generate_ca() -> Result<(String, String)> {
|
||||
let key = KeyPair::generate().context("generate CA key")?;
|
||||
let mut params = CertificateParams::default();
|
||||
params.distinguished_name.push(DnType::OrganizationName, "ai-app dev");
|
||||
params.distinguished_name.push(DnType::CommonName, "ai-app 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(
|
||||
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, "ai-app dev");
|
||||
params.distinguished_name.push(
|
||||
DnType::CommonName,
|
||||
addresses.first().map(|a| a.to_string()).unwrap_or_else(|| "dev-updater".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()))
|
||||
}
|
||||
|
||||
/// Writes owner-readable only, from the moment the file exists rather than
|
||||
/// a `chmod` afterwards.
|
||||
fn write_private(path: &Path, contents: &str) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
file.write_all(contents.as_bytes())
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn addresses() -> Vec<IpAddr> {
|
||||
vec!["10.66.0.1".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(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(dir.path(), &addresses()).expect("regenerate");
|
||||
// The CA is the pinned one: replacing it would strand 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 just
|
||||
// a restart away.
|
||||
assert_ne!(leaf, std::fs::read_to_string(&second.leaf_cert).expect("leaf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_is_owner_only() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
||||
assert_eq!(
|
||||
std::fs::metadata(dir.path()).expect("dir").permissions().mode() & 0o777,
|
||||
0o700,
|
||||
);
|
||||
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
|
||||
let mode = std::fs::metadata(dir.path().join(file))
|
||||
.expect(file)
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "{file} is not owner-only");
|
||||
}
|
||||
assert!(certs.leaf_key.is_file());
|
||||
}
|
||||
|
||||
/// The pair has to be loadable by the TLS stack that will actually
|
||||
/// serve it -- a "file exists" check wouldn't catch a key that doesn't
|
||||
/// match its certificate, which fails at the first handshake instead.
|
||||
#[tokio::test]
|
||||
async fn the_leaf_loads_into_the_real_tls_config() {
|
||||
// main() installs this; tests don't run main. Both rustls crypto
|
||||
// providers are in the graph (ureq brings ring, axum-server
|
||||
// aws-lc-rs), so rustls refuses to pick one on its own. Ignoring
|
||||
// the result because another test may have installed it first.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
||||
axum_server::tls_rustls::RustlsConfig::from_pem_file(&certs.leaf_cert, &certs.leaf_key)
|
||||
.await
|
||||
.expect("the generated leaf and key should load as a TLS identity");
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
//! holds only the metadata needed to list and respawn sessions.
|
||||
|
||||
use std::fs::File;
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -26,7 +26,13 @@ pub fn create_private_dir(dir: &Path) -> Result<()> {
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(dir)
|
||||
.with_context(|| format!("create {}", dir.display()))
|
||||
.with_context(|| format!("create {}", dir.display()))?;
|
||||
// Set explicitly as well: `mode` applies only when the directory is
|
||||
// created, so one that already existed -- made by hand, or by a
|
||||
// version that didn't do this -- would otherwise keep whatever
|
||||
// permissions it had while holding transcripts.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.with_context(|| format!("restrict {}", dir.display()))
|
||||
}
|
||||
|
||||
/// Opens `path` for writing, creating it owner-readable only.
|
||||
|
||||
+48
-16
@@ -14,6 +14,7 @@
|
||||
//! unencrypted by misconfiguration -- even inside the tunnel.
|
||||
|
||||
mod auth;
|
||||
mod certs;
|
||||
mod config;
|
||||
mod routes;
|
||||
mod session;
|
||||
@@ -24,7 +25,7 @@ use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use config::TokenEntry;
|
||||
@@ -79,8 +80,8 @@ struct Args {
|
||||
#[arg(long)]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
/// Directory holding `leaf.pem`/`leaf-key.pem`, as produced by
|
||||
/// `gen-dev-cert.sh`. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
|
||||
/// Directory holding the TLS certificates, generated here on first
|
||||
/// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
|
||||
#[arg(long)]
|
||||
certs: Option<PathBuf>,
|
||||
|
||||
@@ -90,6 +91,30 @@ struct Args {
|
||||
rotate_token: bool,
|
||||
}
|
||||
|
||||
/// Every address this machine answers on, for the leaf's SANs -- so the
|
||||
/// certificate covers whatever the phone actually dials without anyone
|
||||
/// maintaining a hardcoded IP. In production that is the WireGuard
|
||||
/// address; loopback is included for curl and tests, and 10.0.2.2 is the
|
||||
/// alias an Android emulator reaches its host by, which is not a real
|
||||
/// interface anywhere.
|
||||
fn local_addresses() -> Vec<IpAddr> {
|
||||
let mut addresses = vec![IpAddr::from([127, 0, 0, 1]), IpAddr::from([10, 0, 2, 2])];
|
||||
match if_addrs::get_if_addrs() {
|
||||
Ok(interfaces) => {
|
||||
for interface in interfaces {
|
||||
let ip = interface.ip();
|
||||
if ip.is_ipv4() && !addresses.contains(&ip) {
|
||||
addresses.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not fatal: the certificate still covers loopback, which is
|
||||
// enough to start and to diagnose from the machine itself.
|
||||
Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"),
|
||||
}
|
||||
addresses
|
||||
}
|
||||
|
||||
/// The IPv4 address on the WireGuard interface, or a refusal to start.
|
||||
/// Failing closed here (rather than falling back to a wider bind) is part
|
||||
/// of the security posture -- see the module doc comment.
|
||||
@@ -157,6 +182,22 @@ async fn main() -> Result<()> {
|
||||
tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status);
|
||||
}
|
||||
|
||||
// Before the interface check below, deliberately: the certificates are
|
||||
// also what the phone app embeds at build time, so they need to be
|
||||
// obtainable on a machine whose tunnel isn't up yet. The leaf is
|
||||
// reissued on every start, so once wg0 exists the next start covers it.
|
||||
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
|
||||
let certificates = certs::ensure(&certs_dir, &local_addresses())
|
||||
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
|
||||
if certificates.ca_is_new {
|
||||
tracing::warn!(
|
||||
"a new CA was generated in {} -- any installed app pins the previous one and can no \
|
||||
longer reach this server. Rebuild it with app/build-apk.sh, which embeds this CA, \
|
||||
and reinstall through Dev Updater.",
|
||||
certs_dir.display(),
|
||||
);
|
||||
}
|
||||
|
||||
let bind_ip = match args.bind {
|
||||
Some(ip) => {
|
||||
tracing::warn!(
|
||||
@@ -183,19 +224,10 @@ async fn main() -> Result<()> {
|
||||
print_enrollment(bind_ip, args.port, &token)?;
|
||||
}
|
||||
|
||||
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
|
||||
let leaf_cert = certs_dir.join("leaf.pem");
|
||||
let leaf_key = certs_dir.join("leaf-key.pem");
|
||||
if !leaf_cert.is_file() || !leaf_key.is_file() {
|
||||
bail!(
|
||||
"missing {} / {} -- run ./gen-dev-cert.sh on this machine first. The app pins the CA \
|
||||
it generates and this server refuses to serve without TLS, so the private keys must \
|
||||
be generated here and stay here.",
|
||||
leaf_cert.display(),
|
||||
leaf_key.display(),
|
||||
);
|
||||
}
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&leaf_cert, &leaf_key)
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||
&certificates.leaf_cert,
|
||||
&certificates.leaf_key,
|
||||
)
|
||||
.await
|
||||
.context("failed to load TLS cert/key")?;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The live session registry. Every session mutation -- spawn, delete,
|
||||
//! token changes -- funnels through [`SessionManager`] under one lock, so
|
||||
//! in-memory state and `config.json` can't come apart (the same pattern as
|
||||
//! local-updater's `registry.rs`).
|
||||
//! dev-updater's `registry.rs`).
|
||||
//!
|
||||
//! A live session is a driver plus one event pump: the driver reports
|
||||
//! [`Event`]s into an mpsc channel; the pump assigns each a sequence
|
||||
|
||||
+6
-6
@@ -14,8 +14,8 @@
|
||||
# The veth pair stands in for "the internet" carrying WireGuard's UDP; the
|
||||
# wg interfaces are real, with a real handshake and real keys. 10.66.0.1 is
|
||||
# deliberately the same address the leaf certificate carries a SAN for
|
||||
# (gen-dev-cert.sh), so a client inside the tunnel completes the same
|
||||
# pinned-TLS handshake a phone will.
|
||||
# (certs.rs covers every local address), so a client inside the tunnel
|
||||
# completes the same pinned-TLS handshake a phone will.
|
||||
#
|
||||
# ./test-wg-tunnel.sh up create the tunnel (needs sudo)
|
||||
# ./test-wg-tunnel.sh test run the server on wg0 and reach it from "phone"
|
||||
@@ -80,8 +80,9 @@ up() {
|
||||
}
|
||||
|
||||
test_tunnel() {
|
||||
if [ ! -f "$REPO/certs/leaf.pem" ]; then
|
||||
echo "No certs/ -- run ./gen-dev-cert.sh first." >&2
|
||||
CERTS="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs"
|
||||
if [ ! -f "$CERTS/leaf.pem" ]; then
|
||||
echo "No certificates in $CERTS -- start ai-server once; it makes them." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$REPO/server/target/debug/ai-server" ]; then
|
||||
@@ -98,13 +99,12 @@ test_tunnel() {
|
||||
ss -tlnp 2>/dev/null | grep 8443 | sed 's/^/ /' || echo " (nothing on 8443)"
|
||||
|
||||
echo "==> From inside the tunnel: GET /sessions through wg1 -> wg0"
|
||||
TOKEN=$(sudo cat "$REPO/config.json" 2>/dev/null | sed -n 's/.*"sha256": "\(.*\)".*/\1/p' | head -1)
|
||||
if [ -z "${AI_TOKEN:-}" ]; then
|
||||
echo " (set AI_TOKEN=<the enrollment token> to test an authorized call;"
|
||||
echo " without it this only proves reachability + TLS, via a 401)"
|
||||
fi
|
||||
sudo ip netns exec "$NS" curl -s -o /dev/null -w " HTTP %{http_code} (TLS ok, pinned CA)\n" \
|
||||
--cacert "$REPO/certs/ca.pem" \
|
||||
--cacert "$CERTS/ca.pem" \
|
||||
${AI_TOKEN:+-H "Authorization: Bearer $AI_TOKEN"} \
|
||||
"https://$SERVER_WG_IP:8443/sessions" || echo " UNREACHABLE"
|
||||
|
||||
|
||||
Reference in new issue
Block a user