wg-app-link: the WireGuard-and-pinned-TLS half both apps needed
The Rust crate and the Android half of one arrangement: a server that binds the tunnel interface and nothing else, certificates it generates and keeps outside any shared checkout, enrolment that carries a token and the CA to a phone, and a client that trusts exactly that certificate and no other. Extracted because ai-app and dev-updater had written all of it twice and the two copies had already drifted -- one of them carried a bug the other did not. History before this point was squashed away; it was a running record of that extraction and of a personal machine's addresses, and neither is worth keeping in a public repository.
This commit is contained in:
commit
f95bc77f7b
17 files changed
+2552
No files matched your search
@@ -0,0 +1,246 @@
|
||||
//! Reading and writing the RON both projects' config files are in.
|
||||
//!
|
||||
//! Two things are house rules rather than plain RON, and they are here
|
||||
//! together because they are inverses of each other -- change one and the
|
||||
//! other stops round-tripping. Both projects had this, identically, to the
|
||||
//! byte; that is what made it the first thing worth sharing.
|
||||
//!
|
||||
//! **No outer parentheses.** A file *is* the body of the struct, so
|
||||
//! nothing in it is indented for the sake of a wrapper. RON has no
|
||||
//! implicit top-level struct (`de/mod.rs` requires the `(`), so [`parse`]
|
||||
//! adds it and [`render`] takes it back off. The opening paren is not
|
||||
//! followed by a newline, so a parse error's line number still points at
|
||||
//! the real line.
|
||||
//!
|
||||
//! **`Some` is implicit.** Enabled on the deserializer rather than by a
|
||||
//! `#![enable(implicit_some)]` header every file would have to remember,
|
||||
//! and matched on the writing side by `skip_serializing_if` so nothing
|
||||
//! writes back a `Some(...)` a person didn't type. The two halves only
|
||||
//! round-trip together, which is why a caller's own tests should assert
|
||||
//! the written shape rather than only that it loads.
|
||||
|
||||
/// Reading and writing the RON these files are in.
|
||||
///
|
||||
/// Two things are house rules rather than plain RON, and they are here
|
||||
/// together because they are inverses of each other -- change one and the
|
||||
/// other stops round-tripping.
|
||||
///
|
||||
/// **No outer parentheses.** A file *is* the body of the struct, so nothing
|
||||
/// in it is indented for the sake of a wrapper. RON has no implicit
|
||||
/// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`]
|
||||
/// adds it and [`format::render`] takes it back off. The opening paren is not followed by a
|
||||
/// newline, so a parse error's line number still points at the real line.
|
||||
///
|
||||
/// **`Some` is implicit.** Enabled on the deserializer rather than by a
|
||||
/// `#![enable(implicit_some)]` header every project file would have to
|
||||
/// remember, and matched on the writing side by `skip_serializing_if` so
|
||||
/// nothing writes back a `Some(...)` a person didn't type.
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use crate::private;
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/// Renders `value` and replaces `path` with it, atomically and owner-only.
|
||||
///
|
||||
/// Whole-file-and-rename rather than an in-place edit, because both
|
||||
/// projects' config files are small, are read at startup, and hold the
|
||||
/// enrolled token hashes -- a half-written one would take the server down
|
||||
/// on its next start with no way to fix it from a phone. The rename is
|
||||
/// what makes a reader see either the old file or the new one and never
|
||||
/// part of both.
|
||||
///
|
||||
/// The temp file goes through [`private::write_file`] rather than
|
||||
/// `std::fs::write`, and that is the subtle half: **the temp file is not
|
||||
/// always new.** A save killed partway leaves one behind, and opening that
|
||||
/// again keeps whatever mode it already had -- which is then renamed over
|
||||
/// the file holding the token hashes. Setting the mode as it is opened
|
||||
/// covers both the fresh and the leftover case.
|
||||
pub fn write<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
private::create_dir(parent)?;
|
||||
}
|
||||
let text = render(value).context("serialize config")?;
|
||||
// Appended rather than substituted, so `config.ron` yields
|
||||
// `config.ron.tmp` and not `config.tmp` -- a name that cannot collide
|
||||
// with a real file and that says what it is a temporary copy of.
|
||||
let tmp = path.with_file_name(format!(
|
||||
"{}.tmp",
|
||||
path.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new("config"))
|
||||
.to_string_lossy()
|
||||
));
|
||||
private::write_file(&tmp, text.as_bytes())?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))
|
||||
}
|
||||
|
||||
/// Strips the outer `(`/`)` the writer always emits and removes the
|
||||
/// indent level they cost. Deliberately narrow: it accepts only the
|
||||
/// exact shape `PrettyConfig` produces, and leaves anything else alone
|
||||
/// rather than guessing -- a file with stray parentheses is better than
|
||||
/// one silently mangled. `parse` round-trips either way, since a
|
||||
/// wrapped body parses the same as an unwrapped one re-wrapped.
|
||||
fn unwrap_outer(text: &str) -> String {
|
||||
let Some(body) = text
|
||||
.strip_prefix("(\n")
|
||||
.and_then(|rest| rest.strip_suffix("\n)"))
|
||||
else {
|
||||
return text.to_string();
|
||||
};
|
||||
let mut out: String = body
|
||||
.lines()
|
||||
.map(|line| line.strip_prefix(" ").unwrap_or(line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct Demo {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
/// The house rule both halves depend on: what is written is the *body*
|
||||
/// of the struct, with no outer parentheses and nothing indented for
|
||||
/// them. Asserted rather than trusted, because `render` strips what
|
||||
/// `parse` adds -- if only one ever changed, every file on disk would
|
||||
/// still load and only look wrong.
|
||||
#[test]
|
||||
fn a_file_is_the_body_of_the_struct() {
|
||||
let written = render(&Demo {
|
||||
name: "thing".to_string(),
|
||||
note: Some("why".to_string()),
|
||||
})
|
||||
.expect("render");
|
||||
|
||||
assert!(
|
||||
!written.trim_start().starts_with('('),
|
||||
"outer parens: {written}"
|
||||
);
|
||||
assert!(
|
||||
written.starts_with("name: "),
|
||||
"top level sits at column 0: {written}"
|
||||
);
|
||||
assert_eq!(
|
||||
parse::<Demo>(&written).expect("re-read").name,
|
||||
"thing",
|
||||
"what is written must read back",
|
||||
);
|
||||
}
|
||||
|
||||
/// An optional value is written as itself, never wrapped -- and a
|
||||
/// value nobody set is not written at all, so a file stays readable as
|
||||
/// what was actually chosen.
|
||||
#[test]
|
||||
fn an_optional_value_is_written_as_itself_or_not_at_all() {
|
||||
let with = render(&Demo {
|
||||
name: "a".to_string(),
|
||||
note: Some("b".to_string()),
|
||||
})
|
||||
.expect("render");
|
||||
assert!(
|
||||
with.contains(r#"note: "b""#),
|
||||
"no Some(...) wrapper: {with}"
|
||||
);
|
||||
|
||||
let without = render(&Demo::default()).expect("render");
|
||||
assert!(
|
||||
!without.contains("note"),
|
||||
"an unset value writes nothing: {without}"
|
||||
);
|
||||
|
||||
// And the bare form reads back, which is the other half.
|
||||
assert_eq!(
|
||||
parse::<Demo>("name: \"a\",\nnote: \"b\",\n")
|
||||
.expect("parse")
|
||||
.note
|
||||
.as_deref(),
|
||||
Some("b")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_written_reads_back_and_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("nested").join("config.ron");
|
||||
let value = Demo {
|
||||
name: "thing".to_string(),
|
||||
note: None,
|
||||
};
|
||||
|
||||
write(&path, &value).expect("write");
|
||||
|
||||
assert_eq!(
|
||||
parse::<Demo>(&std::fs::read_to_string(&path).expect("read")).expect("re-read"),
|
||||
value,
|
||||
);
|
||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "config holds token hashes: {mode:o}");
|
||||
assert!(
|
||||
!path.with_extension("ron.tmp").exists(),
|
||||
"the temp file is renamed away, not left behind",
|
||||
);
|
||||
}
|
||||
|
||||
/// The case the whole thing turns on, and the one that cannot happen on
|
||||
/// a machine where nothing has ever crashed mid-save: a leftover temp
|
||||
/// file from an interrupted write is reopened, and if its mode came
|
||||
/// along it would be renamed straight over the file holding the enrolled
|
||||
/// token hashes.
|
||||
#[test]
|
||||
fn a_leftover_temp_file_cannot_widen_the_config() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("config.ron");
|
||||
let tmp = dir.path().join("config.ron.tmp");
|
||||
|
||||
std::fs::write(&tmp, b"leftover from a save that died").expect("stale temp");
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).expect("widen");
|
||||
|
||||
write(&path, &Demo::default()).expect("write");
|
||||
|
||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o600,
|
||||
"a world-readable leftover must not become the config: {mode:o}",
|
||||
);
|
||||
}
|
||||
|
||||
/// A parse error's line number has to point at the real line, which is
|
||||
/// why the opening paren is not followed by a newline.
|
||||
#[test]
|
||||
fn a_parse_error_points_at_the_line_it_is_on() {
|
||||
let err = parse::<Demo>("name: \"a\",\nnote: ,\n").expect_err("malformed");
|
||||
assert_eq!(err.span.start.line, 2, "{err}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user