Take the link from wg-app-link instead of keeping a second copy
The five modules underneath this backend that were never about AI sessions -- the pinned CA and leaf, QR enrollment and the bearer token, wg0 binding and the certificate's SANs, owner-only files, and the RON house rules -- were written twice, once here and once in dev-updater, and had drifted. They now come from the submodule, as a path dependency so both projects stay locked to one commit. What stayed is what makes this project itself: the routes, the drivers, the config schema, and the auth middleware, which is generic over this server's state. Sharing a transport is worth doing; sharing an API would mean inventing a vocabulary neither project wants. Four dependencies go with the code -- rcgen, qrcode, subtle and if-addrs are no longer named here at all -- and the three that remain are now described by what still uses them rather than by what used to. Verified by running it, not only by building: a fresh server generates its CA, prints an `aiapp://enroll` QR with the scheme now passed as a parameter, covers 127.0.0.1, 10.0.2.2 and wg0's 10.66.0.1 in the leaf, answers an enrolled token and returns 401 without one, and writes config.ron in the house rules with every file owner-only. 36 tests pass, clippy is silent, rustfmt is clean.
This commit is contained in:
1 parent
a83dbcff6a
commit
aa05ff9336
13 files changed
+76
-526
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "wg-app-link"]
|
||||||
|
path = wg-app-link
|
||||||
|
url = git@git.arirex.me:iris/wg-app-link.git
|
||||||
@@ -39,7 +39,7 @@ repo is in PLAN.md's "Backend layout" section.
|
|||||||
- `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the
|
- `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the
|
||||||
auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP
|
auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP
|
||||||
table in its module doc comment, `auth.rs` the bearer-token middleware,
|
table in its module doc comment, `auth.rs` the bearer-token middleware,
|
||||||
`config.rs` the persisted schema and the RON its file is written in,
|
`config.rs` the persisted schema (written in the shared RON house rules),
|
||||||
`session/` the manager (registry pattern), `Driver` trait + event model,
|
`session/` the manager (registry pattern), `Driver` trait + event model,
|
||||||
`EchoDriver`, and transcripts.
|
`EchoDriver`, and transcripts.
|
||||||
- `app/` — Compose Android app, single `:androidApp` module, package
|
- `app/` — Compose Android app, single `:androidApp` module, package
|
||||||
@@ -52,11 +52,24 @@ repo is in PLAN.md's "Backend layout" section.
|
|||||||
`server/service`) and then the APK (built in `app/`), in that order. The
|
`server/service`) and then the APK (built in `app/`), in that order. The
|
||||||
project it serves is the repository, not either half of it, which is why
|
project it serves is the repository, not either half of it, which is why
|
||||||
this sits at the root rather than in `app/`.
|
this sits at the root rather than in `app/`.
|
||||||
- `server/src/certs.rs` — the TLS certificates, generated in process on
|
- `wg-app-link/` — a **git submodule**, and the half of this backend that
|
||||||
first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf
|
dev-updater also needed: the pinned CA and leaf (`certs`), QR enrollment
|
||||||
reissued every start covering every local IPv4 plus 127.0.0.1 and
|
and the bearer token (`enroll`), wg0 binding and the certificate's SANs
|
||||||
10.0.2.2 (emulator → host). Regenerating the CA strands the installed
|
(`netif`), owner-only files (`private`), and the RON house rules
|
||||||
app — the one-way door.
|
(`format`). Both projects had written all five, and they had drifted;
|
||||||
|
see that repo's `README.md` for the diff that decided each one and the
|
||||||
|
permissions bug the extraction found. Clone with
|
||||||
|
`git clone --recurse-submodules`, or `git submodule update --init` in an
|
||||||
|
existing checkout — `server/` will not build without it, since it is a
|
||||||
|
path dependency rather than a registry one, which is what keeps the two
|
||||||
|
projects version-locked to the commit this repo pins.
|
||||||
|
The certificates are still the one-way door: the CA is generated once on
|
||||||
|
first start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it
|
||||||
|
strands the installed app.
|
||||||
|
What deliberately did **not** move is the API surface and the config
|
||||||
|
*schema* — routes, drivers, sessions and setups are what makes this
|
||||||
|
project itself, and a shared vocabulary for them would be inventing one
|
||||||
|
neither project wants.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -129,7 +142,8 @@ mangles an argument.
|
|||||||
|
|
||||||
## Checking your work
|
## Checking your work
|
||||||
|
|
||||||
- Server: from `server/`, `./run-tests.sh` (or `cargo test`) +
|
- Server: `./run-tests.sh` from the repo root (or `cargo test` from
|
||||||
|
`server/`) +
|
||||||
`cargo clippy --all-targets` + `cargo fmt`. The build stays
|
`cargo clippy --all-targets` + `cargo fmt`. The build stays
|
||||||
warning-clean and rustfmt-clean at the defaults — there is no
|
warning-clean and rustfmt-clean at the defaults — there is no
|
||||||
`rustfmt.toml` and there should not be one.
|
`rustfmt.toml` and there should not be one.
|
||||||
|
|||||||
Generated
+18
-4
@@ -26,16 +26,12 @@ dependencies = [
|
|||||||
"axum-server",
|
"axum-server",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"clap",
|
"clap",
|
||||||
"if-addrs",
|
|
||||||
"qrcode",
|
|
||||||
"rand",
|
"rand",
|
||||||
"rcgen",
|
|
||||||
"ron",
|
"ron",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -44,6 +40,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"wg-app-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1868,6 +1865,23 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wg-app-link"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"base64 0.23.1",
|
||||||
|
"if-addrs",
|
||||||
|
"qrcode",
|
||||||
|
"rand",
|
||||||
|
"rcgen",
|
||||||
|
"ron",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
+11
-19
@@ -8,6 +8,11 @@ name = "ai-server"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# The link both this and dev-updater need in order to be reached from a
|
||||||
|
# phone: wg binding, the pinned CA, QR enrollment, owner-only files, and
|
||||||
|
# the RON house rules. Extracted from the two copies that had drifted --
|
||||||
|
# see that repo's README for the evidence and the bug the extraction found.
|
||||||
|
wg-app-link = { path = "../wg-app-link/server" }
|
||||||
axum = { version = "0.8", features = ["json", "multipart"] }
|
axum = { version = "0.8", features = ["json", "multipart"] }
|
||||||
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "process", "io-util", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "process", "io-util", "signal"] }
|
||||||
@@ -17,32 +22,19 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
# The config file's format. Not JSON, because this file is written and read
|
# The config file's format. Not JSON, because this file is written and read
|
||||||
# by hand and RON says a sum type as syntax -- the same choice, and the same
|
# by hand and RON says a sum type as syntax. The two house rules both
|
||||||
# house rules, as the sibling dev-updater project's config.
|
# projects write it under live in wg-app-link; this is here for the error
|
||||||
|
# types the schema's own signatures name.
|
||||||
ron = "0.12.2"
|
ron = "0.12.2"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
# Token auth: hash for storage, constant-time compare for verification,
|
# Verifying a downloaded model against HuggingFace's published digest.
|
||||||
# CSPRNG-backed generation, base64url for the enrollment string.
|
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
subtle = "2"
|
# Naming a session directory, and an attachment inside one.
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
|
# Decoding the images a phone attaches, and encoding them for a driver.
|
||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
# Renders the enrollment QR straight to the terminal; no image output needed.
|
|
||||||
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
|
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
|
||||||
# every-few-minutes poll better than pulling in reqwest's tower stack;
|
# every-few-minutes poll better than pulling in reqwest's tower stack;
|
||||||
# rustls-backed like the rest of the TLS here.
|
# rustls-backed like the rest of the TLS here.
|
||||||
|
|||||||
+3
-45
@@ -21,9 +21,7 @@ use axum::extract::{ConnectInfo, Request, State};
|
|||||||
use axum::http::{StatusCode, header};
|
use axum::http::{StatusCode, header};
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use base64::Engine;
|
use wg_app_link::enroll::token_matches;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use subtle::ConstantTimeEq;
|
|
||||||
|
|
||||||
use crate::session::SessionManager;
|
use crate::session::SessionManager;
|
||||||
|
|
||||||
@@ -32,35 +30,6 @@ use crate::session::SessionManager;
|
|||||||
/// drip rather than a fast one.
|
/// drip rather than a fast one.
|
||||||
const REJECT_DELAY: Duration = Duration::from_millis(300);
|
const REJECT_DELAY: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// 256 bits from the OS CSPRNG, base64url. A machine credential carried by
|
|
||||||
/// a QR code, never typed, so unguessable costs nothing.
|
|
||||||
pub fn generate_token() -> String {
|
|
||||||
use rand::Rng;
|
|
||||||
let mut bytes = [0u8; 32];
|
|
||||||
rand::rng().fill_bytes(&mut bytes);
|
|
||||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What `config.ron` stores instead of the token: hex SHA-256. A plain
|
|
||||||
/// hash is enough for high-entropy random input, and buys that a leaked
|
|
||||||
/// config doesn't leak the credential.
|
|
||||||
pub fn token_hash_hex(token: &str) -> String {
|
|
||||||
Sha256::digest(token.as_bytes())
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hash-then-constant-time-compare against every enrolled hash. The fold
|
|
||||||
/// visits every entry regardless of match so the timing doesn't say which
|
|
||||||
/// entry (if any) matched.
|
|
||||||
fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
|
|
||||||
let presented = token_hash_hex(presented);
|
|
||||||
stored_hashes.iter().fold(false, |matched, stored| {
|
|
||||||
matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn require_token(
|
pub async fn require_token(
|
||||||
State(manager): State<Arc<SessionManager>>,
|
State(manager): State<Arc<SessionManager>>,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -104,6 +73,8 @@ mod tests {
|
|||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use wg_app_link::enroll::{generate_token, token_hash_hex};
|
||||||
|
|
||||||
use crate::config::TokenEntry;
|
use crate::config::TokenEntry;
|
||||||
|
|
||||||
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
|
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
|
||||||
@@ -139,19 +110,6 @@ mod tests {
|
|||||||
builder.body(Body::empty()).expect("request")
|
builder.body(Body::empty()).expect("request")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hashing_is_stable_and_tokens_verify() {
|
|
||||||
let token = generate_token();
|
|
||||||
assert_eq!(token_hash_hex(&token), token_hash_hex(&token));
|
|
||||||
assert_ne!(token, generate_token(), "tokens must not repeat");
|
|
||||||
|
|
||||||
let hashes = vec![token_hash_hex(&token), token_hash_hex("other")];
|
|
||||||
assert!(token_matches(&token, &hashes));
|
|
||||||
assert!(token_matches("other", &hashes));
|
|
||||||
assert!(!token_matches("wrong", &hashes));
|
|
||||||
assert!(!token_matches(&token, &[]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One test rather than separate gating and logging tests,
|
/// One test rather than separate gating and logging tests,
|
||||||
/// deliberately: tracing caches callsite interest process-wide, so a
|
/// deliberately: tracing caches callsite interest process-wide, so a
|
||||||
/// test that hits the rejection path with no subscriber installed can
|
/// test that hits the rejection path with no subscriber installed can
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
//! 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::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use rcgen::{
|
|
||||||
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::private;
|
|
||||||
|
|
||||||
/// 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> {
|
|
||||||
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()?;
|
|
||||||
private::write_file(&ca_key_path, key.as_bytes())?;
|
|
||||||
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(&ca_pem, &ca_key_pem, addresses)?;
|
|
||||||
let leaf_cert = dir.join("leaf.pem");
|
|
||||||
let leaf_key = dir.join("leaf-key.pem");
|
|
||||||
private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
|
|
||||||
private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
|
|
||||||
|
|
||||||
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(|| "ai-app".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 super::*;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-60
@@ -7,9 +7,10 @@
|
|||||||
//! funnels through `SessionManager` (the registry pattern), so in-memory
|
//! funnels through `SessionManager` (the registry pattern), so in-memory
|
||||||
//! and on-disk state can't come apart.
|
//! and on-disk state can't come apart.
|
||||||
//!
|
//!
|
||||||
//! The file is RON, in the shape the [`mod@format`] module describes -- the
|
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the
|
||||||
//! same format, and the same two house rules, as the sibling dev-updater
|
//! same format, and the same two house rules, as the sibling dev-updater
|
||||||
//! project's config, because both are written and read by hand.
|
//! project's config, because both are written and read by hand, and both
|
||||||
|
//! now read and write them through the one module.
|
||||||
//!
|
//!
|
||||||
//! Transcripts do NOT live here -- each session's events are an append-only
|
//! Transcripts do NOT live here -- each session's events are an append-only
|
||||||
//! JSONL file in its own directory (see `session::transcript`); this file
|
//! JSONL file in its own directory (see `session::transcript`); this file
|
||||||
@@ -21,64 +22,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::private;
|
use wg_app_link::{format, private};
|
||||||
|
|
||||||
/// Reading and writing the RON this file is 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 the file would have to carry, and
|
|
||||||
/// matched on the writing side by `skip_serializing_if` on every optional
|
|
||||||
/// field so nothing writes back a `Some(...)` a person didn't type.
|
|
||||||
pub(crate) mod format {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
|||||||
+8
-70
@@ -14,11 +14,9 @@
|
|||||||
//! unencrypted by misconfiguration -- even inside the tunnel.
|
//! unencrypted by misconfiguration -- even inside the tunnel.
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod certs;
|
|
||||||
mod config;
|
mod config;
|
||||||
mod media;
|
mod media;
|
||||||
mod models;
|
mod models;
|
||||||
mod private;
|
|
||||||
mod routes;
|
mod routes;
|
||||||
mod session;
|
mod session;
|
||||||
mod setups;
|
mod setups;
|
||||||
@@ -33,11 +31,13 @@ use anyhow::{Context, Result};
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
|
|
||||||
|
use wg_app_link::enroll;
|
||||||
|
use wg_app_link::netif::{self, WG_INTERFACE};
|
||||||
|
|
||||||
use config::TokenEntry;
|
use config::TokenEntry;
|
||||||
use session::SessionManager;
|
use session::SessionManager;
|
||||||
|
|
||||||
const DEFAULT_PORT: u16 = 8443;
|
const DEFAULT_PORT: u16 = 8443;
|
||||||
const WG_INTERFACE: &str = "wg0";
|
|
||||||
|
|
||||||
/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.ron`
|
/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.ron`
|
||||||
/// and `certs/`.
|
/// and `certs/`.
|
||||||
@@ -107,68 +107,6 @@ struct Args {
|
|||||||
rotate_token: bool,
|
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.
|
|
||||||
fn wg_address() -> Result<IpAddr> {
|
|
||||||
let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
|
|
||||||
interfaces
|
|
||||||
.into_iter()
|
|
||||||
.find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4())
|
|
||||||
.map(|iface| iface.ip())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"no IPv4 address on interface {WG_INTERFACE} -- this server binds only to the \
|
|
||||||
WireGuard tunnel and refuses to fall back to a wider address. Bring the tunnel \
|
|
||||||
up, or pass --bind <ip> explicitly for development."
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prints the one-time enrollment QR: an `aiapp://enroll` URI carrying
|
|
||||||
/// where to connect and the bearer token. The CA stays embedded in the APK,
|
|
||||||
/// so this carries no trust material -- photographing the terminal leaks
|
|
||||||
/// only the token, which is rotatable (`--rotate-token`). Printed to
|
|
||||||
/// stdout, not the log: it is for the human at the terminal, once.
|
|
||||||
fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> {
|
|
||||||
let uri = format!("aiapp://enroll?host={host}&port={port}&token={token}");
|
|
||||||
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
|
|
||||||
let rendered = code
|
|
||||||
.render::<qrcode::render::unicode::Dense1x2>()
|
|
||||||
.quiet_zone(true)
|
|
||||||
.build();
|
|
||||||
println!("\n{rendered}\n");
|
|
||||||
println!("Scan with the phone's camera to enroll (or paste into the app's settings):");
|
|
||||||
println!(" {uri}");
|
|
||||||
println!("The token is not stored in the clear and won't be shown again;");
|
|
||||||
println!("a lost phone means `--rotate-token`.\n");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Both rustls crypto providers are in the dependency graph (ureq
|
// Both rustls crypto providers are in the dependency graph (ureq
|
||||||
@@ -226,7 +164,7 @@ async fn main() -> Result<()> {
|
|||||||
// obtainable on a machine whose tunnel isn't up yet. The leaf is
|
// 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.
|
// 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 certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
|
||||||
let certificates = certs::ensure(&certs_dir, &local_addresses())
|
let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses())
|
||||||
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
|
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
|
||||||
if certificates.ca_is_new {
|
if certificates.ca_is_new {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -244,7 +182,7 @@ async fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
ip
|
ip
|
||||||
}
|
}
|
||||||
None => wg_address()?,
|
None => netif::wg_address("ai-server")?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Token bootstrap: first run generates one; --rotate-token replaces
|
// Token bootstrap: first run generates one; --rotate-token replaces
|
||||||
@@ -252,15 +190,15 @@ async fn main() -> Result<()> {
|
|||||||
// the QR printed here.
|
// the QR printed here.
|
||||||
if args.rotate_token || manager.tokens().is_empty() {
|
if args.rotate_token || manager.tokens().is_empty() {
|
||||||
let rotating = args.rotate_token && !manager.tokens().is_empty();
|
let rotating = args.rotate_token && !manager.tokens().is_empty();
|
||||||
let token = auth::generate_token();
|
let token = enroll::generate_token();
|
||||||
manager.set_tokens(vec![TokenEntry {
|
manager.set_tokens(vec![TokenEntry {
|
||||||
name: "phone".to_string(),
|
name: "phone".to_string(),
|
||||||
sha256: auth::token_hash_hex(&token),
|
sha256: enroll::token_hash_hex(&token),
|
||||||
}])?;
|
}])?;
|
||||||
if rotating {
|
if rotating {
|
||||||
tracing::info!("rotated the enrolled token; the previous one is now invalid");
|
tracing::info!("rotated the enrolled token; the previous one is now invalid");
|
||||||
}
|
}
|
||||||
print_enrollment(bind_ip, args.port, &token)?;
|
enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::private;
|
use wg_app_link::private;
|
||||||
|
|
||||||
/// Identifies this client to HuggingFace. They ask for one, and a request
|
/// Identifies this client to HuggingFace. They ask for one, and a request
|
||||||
/// without it is more likely to be rate-limited.
|
/// without it is more likely to be rate-limited.
|
||||||
@@ -471,8 +471,8 @@ fn sha256_of(path: &Path) -> Result<String> {
|
|||||||
}
|
}
|
||||||
hasher.update(&buffer[..read]);
|
hasher.update(&buffer[..read]);
|
||||||
}
|
}
|
||||||
// Hex the same way auth.rs does, since this sha2 version's output
|
// Hex by hand, as wg_app_link::enroll::token_hash_hex also has to,
|
||||||
// type does not implement LowerHex.
|
// since this sha2 version's output type does not implement LowerHex.
|
||||||
Ok(hasher
|
Ok(hasher
|
||||||
.finalize()
|
.finalize()
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
//! Creating files and directories this server alone can read.
|
|
||||||
//!
|
|
||||||
//! Everything the server writes outside the repo goes through here: the
|
|
||||||
//! config (token hashes, hosts, sessions), the TLS private keys, and the
|
|
||||||
//! session directories holding whole transcripts. One module owns the
|
|
||||||
//! modes so "owner-only" is a property that can be checked in one place
|
|
||||||
//! rather than re-argued at every `create`.
|
|
||||||
|
|
||||||
use std::fs::File;
|
|
||||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
|
|
||||||
/// Creates `dir` and its parents, owner-accessible only.
|
|
||||||
pub fn create_dir(dir: &Path) -> Result<()> {
|
|
||||||
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 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 secrets.
|
|
||||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
|
||||||
.with_context(|| format!("restrict {}", dir.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens `path` for writing, truncating it, owner-readable only.
|
|
||||||
///
|
|
||||||
/// `mode` covers the file this call creates; [`restrict`] covers the one
|
|
||||||
/// that was already there. Both are needed, and the second is the one that
|
|
||||||
/// is easy to miss: the leaf certificate's private key is rewritten on
|
|
||||||
/// every start, so a key that ever existed with loose permissions would
|
|
||||||
/// keep them for the rest of its life.
|
|
||||||
pub fn create_file(path: &Path) -> Result<File> {
|
|
||||||
let file = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("write {}", path.display()))?;
|
|
||||||
restrict(&file, path)?;
|
|
||||||
Ok(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Narrows an already-open file to owner-only.
|
|
||||||
///
|
|
||||||
/// Through the handle rather than the path, deliberately: `set_permissions`
|
|
||||||
/// on a path re-resolves it, so between opening and chmod-ing something
|
|
||||||
/// could put a different file -- or a symlink to one -- where this one was,
|
|
||||||
/// and the mode would land on that instead. The handle cannot be
|
|
||||||
/// redirected.
|
|
||||||
fn restrict(file: &File, path: &Path) -> Result<()> {
|
|
||||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
|
||||||
.with_context(|| format!("restrict {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes `contents` to `path`, owner-readable only.
|
|
||||||
pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
|
|
||||||
use std::io::Write;
|
|
||||||
create_file(path)?
|
|
||||||
.write_all(contents)
|
|
||||||
.with_context(|| format!("write {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
/// The case `mode` alone does not cover: a file that already exists
|
|
||||||
/// keeps whatever permissions it had, because `mode` applies only to a
|
|
||||||
/// file this call creates. `create_dir` three functions up has carried
|
|
||||||
/// a comment about exactly this since it was written; the file path
|
|
||||||
/// did not, until dev-updater's session read the module as a unit and
|
|
||||||
/// noticed the same line was wrong twice.
|
|
||||||
fn rewriting_a_file_narrows_permissions_it_did_not_set() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("secret");
|
|
||||||
|
|
||||||
// As an older version, or a hand-edit, might have left it.
|
|
||||||
std::fs::write(&path, b"old").expect("plant");
|
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("loosen");
|
|
||||||
|
|
||||||
write_file(&path, b"new").expect("rewrite");
|
|
||||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
|
|
||||||
assert_eq!(mode, 0o600, "rewriting left it at {mode:o}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_new_file_is_owner_only_from_the_start() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("fresh");
|
|
||||||
write_file(&path, b"x").expect("write");
|
|
||||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
|
|
||||||
assert_eq!(mode, 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_existing_directory_is_narrowed_too() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let nested = dir.path().join("state");
|
|
||||||
std::fs::create_dir(&nested).expect("create");
|
|
||||||
std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o755)).expect("loosen");
|
|
||||||
create_dir(&nested).expect("recreate");
|
|
||||||
let mode = std::fs::metadata(&nested)
|
|
||||||
.expect("stat")
|
|
||||||
.permissions()
|
|
||||||
.mode()
|
|
||||||
& 0o777;
|
|
||||||
assert_eq!(mode, 0o700, "left at {mode:o}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -341,7 +341,7 @@ impl Translator {
|
|||||||
.unwrap_or("png");
|
.unwrap_or("png");
|
||||||
let name = format!("{}.{extension}", super::super::random_hex());
|
let name = format!("{}.{extension}", super::super::random_hex());
|
||||||
let dir = self.session_dir.join("files");
|
let dir = self.session_dir.join("files");
|
||||||
if let Err(err) = crate::private::create_dir(&dir)
|
if let Err(err) = wg_app_link::private::create_dir(&dir)
|
||||||
.map_err(std::io::Error::other)
|
.map_err(std::io::Error::other)
|
||||||
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ impl LiveSession {
|
|||||||
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
|
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
|
||||||
let name = format!("{}.{extension}", random_hex());
|
let name = format!("{}.{extension}", random_hex());
|
||||||
let dir = self.dir().join("attachments");
|
let dir = self.dir().join("attachments");
|
||||||
crate::private::create_dir(&dir)?;
|
wg_app_link::private::create_dir(&dir)?;
|
||||||
std::fs::write(dir.join(&name), bytes)
|
std::fs::write(dir.join(&name), bytes)
|
||||||
.with_context(|| format!("write attachment {name}"))?;
|
.with_context(|| format!("write attachment {name}"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
@@ -216,7 +216,7 @@ impl SessionManager {
|
|||||||
/// session spawns its event pump).
|
/// session spawns its event pump).
|
||||||
pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result<Self> {
|
pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result<Self> {
|
||||||
let config = Config::load(&config_path)?;
|
let config = Config::load(&config_path)?;
|
||||||
crate::private::create_dir(&data_dir)?;
|
wg_app_link::private::create_dir(&data_dir)?;
|
||||||
|
|
||||||
let mut live = HashMap::new();
|
let mut live = HashMap::new();
|
||||||
for meta in &config.sessions {
|
for meta in &config.sessions {
|
||||||
@@ -625,7 +625,7 @@ fn launch(
|
|||||||
models_dir: &Path,
|
models_dir: &Path,
|
||||||
) -> Result<Arc<LiveSession>> {
|
) -> Result<Arc<LiveSession>> {
|
||||||
let dir = data_dir.join(&meta.id);
|
let dir = data_dir.join(&meta.id);
|
||||||
crate::private::create_dir(&dir)?;
|
wg_app_link::private::create_dir(&dir)?;
|
||||||
let transcript_path = dir.join("transcript.jsonl");
|
let transcript_path = dir.join("transcript.jsonl");
|
||||||
let transcript = Transcript::open(&transcript_path)?;
|
let transcript = Transcript::open(&transcript_path)?;
|
||||||
|
|
||||||
|
|||||||
Submodule
+1
Submodule wg-app-link added at 841a3a8372.
Reference in new issue
Block a user