Skip to content

Commit cff3a9a

Browse files
easelclaude
andcommitted
feat(server): embed admin UI in binary; fix Tailscale auth and systemd install
- Embed ui/build into the axon-server binary via rust-embed; UI is always available at /ui without --ui-dir (kept as dev-time override) - Add root / → /ui redirect (falls back to /health when no UI) - Fix Tailscale whois auth: tailscaled returns 404 (not 422) for non-tailnet peers on current versions; map both to Unauthorized - Fix axon service install: separate systemd unit templates for user and global installs (WantedBy=multi-user.target, User=axon/Group=axon for global); add create_axon_system_user() and /var/lib/axon setup - Replace deprecated launchctl load/unload with bootstrap/bootout - Add StandardOutput/StandardError=journal to both unit templates - Replace disk-based UI gateway tests with embedded UI tests Verification: cargo check --workspace, cargo test -p axon-server --lib Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 36342de commit cff3a9a

7 files changed

Lines changed: 234 additions & 48 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ humantime = "2"
119119
dirs = "6"
120120
toml = "0.8"
121121

122+
# Embedded assets
123+
rust-embed = "8"
124+
mime_guess = "2"
125+
122126
# Testing
123127
pretty_assertions = "1"
124128
tempfile = "3"

crates/axon-cli/src/service.rs

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ fn uninstall_service() -> Result<()> {
5656

5757
// ── systemd (Linux) ──────────────────────────────────────────────────────────
5858

59-
const SYSTEMD_UNIT_TEMPLATE: &str = "\
59+
/// User service (~/.config/systemd/user/axon.service).
60+
/// Runs as the invoking user; `WantedBy=default.target` is correct here.
61+
const SYSTEMD_USER_UNIT: &str = "\
6062
[Unit]
6163
Description=Axon Data Store
6264
After=network.target
@@ -66,11 +68,36 @@ Type=simple
6668
ExecStart={binary_path} serve
6769
Restart=on-failure
6870
RestartSec=5
71+
StandardOutput=journal
72+
StandardError=journal
6973
7074
[Install]
7175
WantedBy=default.target
7276
";
7377

78+
/// System service (/etc/systemd/system/axon.service).
79+
/// Runs as the `axon` system user; `WantedBy=multi-user.target` is standard for
80+
/// non-graphical daemons. The user and data directory must be created separately
81+
/// (see `create_axon_system_user`).
82+
const SYSTEMD_GLOBAL_UNIT: &str = "\
83+
[Unit]
84+
Description=Axon Data Store
85+
After=network.target
86+
87+
[Service]
88+
Type=simple
89+
User=axon
90+
Group=axon
91+
ExecStart={binary_path} serve
92+
Restart=on-failure
93+
RestartSec=5
94+
StandardOutput=journal
95+
StandardError=journal
96+
97+
[Install]
98+
WantedBy=multi-user.target
99+
";
100+
74101
fn systemd_unit_path(global: bool) -> Result<PathBuf> {
75102
if global {
76103
Ok(PathBuf::from("/etc/systemd/system/axon.service"))
@@ -84,9 +111,46 @@ fn systemd_unit_path(global: bool) -> Result<PathBuf> {
84111
}
85112
}
86113

114+
fn create_axon_system_user() -> Result<()> {
115+
// Check whether the `axon` system user already exists.
116+
let exists = Command::new("id")
117+
.arg("axon")
118+
.status()
119+
.context("failed to run `id axon`")?
120+
.success();
121+
122+
if !exists {
123+
run_cmd(
124+
"useradd",
125+
&[
126+
"--system",
127+
"--no-create-home",
128+
"--home-dir",
129+
"/var/lib/axon",
130+
"--shell",
131+
"/usr/sbin/nologin",
132+
"--comment",
133+
"Axon Data Store",
134+
"axon",
135+
],
136+
)
137+
.context("failed to create `axon` system user")?;
138+
println!("created system user `axon`");
139+
}
140+
141+
// Ensure the data directory exists and is owned by axon.
142+
std::fs::create_dir_all("/var/lib/axon")
143+
.context("failed to create /var/lib/axon")?;
144+
run_cmd("chown", &["axon:axon", "/var/lib/axon"])
145+
.context("failed to chown /var/lib/axon")?;
146+
println!("data directory: /var/lib/axon");
147+
Ok(())
148+
}
149+
87150
fn install_systemd(bin: &std::path::Path, global: bool) -> Result<()> {
88151
let unit_path = systemd_unit_path(global)?;
89-
let unit_content = SYSTEMD_UNIT_TEMPLATE.replace("{binary_path}", &bin.display().to_string());
152+
let template = if global { SYSTEMD_GLOBAL_UNIT } else { SYSTEMD_USER_UNIT };
153+
let unit_content = template.replace("{binary_path}", &bin.display().to_string());
90154

91155
if let Some(parent) = unit_path.parent() {
92156
std::fs::create_dir_all(parent)
@@ -97,6 +161,7 @@ fn install_systemd(bin: &std::path::Path, global: bool) -> Result<()> {
97161
println!("wrote {}", unit_path.display());
98162

99163
if global {
164+
create_axon_system_user()?;
100165
run_cmd("systemctl", &["daemon-reload"])?;
101166
run_cmd("systemctl", &["enable", "axon"])?;
102167
println!("enabled axon.service (system)");
@@ -156,6 +221,16 @@ const LAUNCHD_PLIST_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
156221

157222
const LAUNCHD_LABEL: &str = "com.axon.server";
158223

224+
/// Returns `gui/<uid>` — the launchctl domain for the current user's GUI session.
225+
fn launchd_user_domain() -> Result<String> {
226+
let out = Command::new("id")
227+
.arg("-u")
228+
.output()
229+
.context("failed to run `id -u`")?;
230+
let uid = String::from_utf8_lossy(&out.stdout).trim().to_string();
231+
Ok(format!("gui/{uid}"))
232+
}
233+
159234
fn launchd_plist_path(global: bool) -> Result<PathBuf> {
160235
if global {
161236
Ok(PathBuf::from(
@@ -182,7 +257,17 @@ fn install_launchd(bin: &std::path::Path, global: bool) -> Result<()> {
182257
.with_context(|| format!("failed to write {}", plist_path.display()))?;
183258
println!("wrote {}", plist_path.display());
184259

185-
run_cmd("launchctl", &["load", &plist_path.display().to_string()])?;
260+
// `launchctl load` is deprecated; use `bootstrap` on macOS 10.15+.
261+
// For user agents: bootstrap gui/<uid>; for system daemons: bootstrap system.
262+
if global {
263+
run_cmd("launchctl", &["bootstrap", "system", &plist_path.display().to_string()])?;
264+
} else {
265+
let domain = launchd_user_domain()?;
266+
run_cmd(
267+
"launchctl",
268+
&["bootstrap", &domain, &plist_path.display().to_string()],
269+
)?;
270+
}
186271
println!("loaded {LAUNCHD_LABEL}");
187272
Ok(())
188273
}
@@ -191,15 +276,16 @@ fn uninstall_launchd() -> Result<()> {
191276
let user_path = launchd_plist_path(false)?;
192277
let global_path = launchd_plist_path(true)?;
193278

194-
let plist_path = if user_path.exists() {
195-
user_path
279+
let (plist_path, domain) = if user_path.exists() {
280+
(user_path, launchd_user_domain()?)
196281
} else if global_path.exists() {
197-
global_path
282+
(global_path, "system".to_string())
198283
} else {
199284
anyhow::bail!("no axon launchd plist found");
200285
};
201286

202-
let _ = run_cmd("launchctl", &["unload", &plist_path.display().to_string()]);
287+
// `launchctl unload` is deprecated; use `bootout`.
288+
let _ = run_cmd("launchctl", &["bootout", &domain, &plist_path.display().to_string()]);
203289
std::fs::remove_file(&plist_path)
204290
.with_context(|| format!("failed to remove {}", plist_path.display()))?;
205291
println!("removed {}", plist_path.display());

crates/axon-server/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ tokio-stream.workspace = true
4242
uuid.workspace = true
4343
humantime.workspace = true
4444
rusqlite.workspace = true
45+
rust-embed.workspace = true
46+
mime_guess.workspace = true
4547

4648
[package.metadata.cargo-machete]
4749
ignored = ["prost"]

crates/axon-server/src/auth.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,12 @@ impl LocalApiWhoisProvider {
304304
AuthError::ProviderUnavailable(format!("body aggregation failed: {e}"))
305305
})?;
306306
Ok(hyper::body::Bytes::from(buf))
307-
} else if status == http::StatusCode::UNPROCESSABLE_ENTITY {
307+
} else if status == http::StatusCode::NOT_FOUND
308+
|| status == http::StatusCode::UNPROCESSABLE_ENTITY
309+
{
310+
// 404: address not known to this tailnet (observed on Tailscale ≥ 1.x).
311+
// 422: legacy "unprocessable entity" returned by older daemon versions.
312+
// Both mean the peer is not a tailnet member — not a provider fault.
308313
Err(AuthError::Unauthorized(
309314
"peer is not a recognized tailnet address".into(),
310315
))
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//! Embedded admin UI — assets from `ui/build` compiled into the binary.
2+
//!
3+
//! The `UiAssets` struct embeds every file under `ui/build` at compile time.
4+
//! The axum handler serves them at `/ui/*path` with correct `Content-Type`
5+
//! headers, and falls back to `index.html` for any unrecognised path so that
6+
//! SvelteKit client-side routing works correctly.
7+
//!
8+
//! **Build prerequisite**: `ui/build` must exist before `cargo build`.
9+
//! Run `cd ui && bun run build` (or `npm run build`) first.
10+
11+
use axum::body::Body;
12+
use axum::http::{header, StatusCode, Uri};
13+
use axum::response::{IntoResponse, Response};
14+
use rust_embed::RustEmbed;
15+
16+
/// All files under `ui/build`, embedded at compile time.
17+
///
18+
/// The `#[folder]` path is relative to the crate root (`crates/axon-server/`),
19+
/// so `../../ui/build` resolves to `ui/build` in the workspace root.
20+
#[derive(RustEmbed)]
21+
#[folder = "../../ui/build"]
22+
struct UiAssets;
23+
24+
/// Axum handler for `GET /ui` and `GET /ui/*path`.
25+
///
26+
/// Strips the `/ui` prefix, looks up the file in [`UiAssets`], and responds
27+
/// with the correct `Content-Type`. Unknown paths fall back to `index.html`
28+
/// so SvelteKit's client-side router can handle them.
29+
pub async fn embedded_ui_handler(uri: Uri) -> Response {
30+
let raw = uri.path();
31+
// Strip the /ui prefix that axum leaves on the URI when not using nest_service.
32+
let asset_path = raw
33+
.strip_prefix("/ui/")
34+
.or_else(|| raw.strip_prefix("/ui"))
35+
.unwrap_or(raw);
36+
let asset_path = if asset_path.is_empty() {
37+
"index.html"
38+
} else {
39+
asset_path
40+
};
41+
42+
serve_asset(asset_path)
43+
}
44+
45+
fn serve_asset(path: &str) -> Response {
46+
match UiAssets::get(path) {
47+
Some(content) => {
48+
let mime = mime_guess::from_path(path).first_or_octet_stream();
49+
Response::builder()
50+
.header(header::CONTENT_TYPE, mime.as_ref())
51+
.body(Body::from(content.data))
52+
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
53+
}
54+
None => {
55+
// SPA fallback: let the client-side router handle unknown paths.
56+
match UiAssets::get("index.html") {
57+
Some(index) => Response::builder()
58+
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
59+
.body(Body::from(index.data))
60+
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
61+
None => StatusCode::NOT_FOUND.into_response(),
62+
}
63+
}
64+
}
65+
}

crates/axon-server/src/gateway.rs

Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,13 +2230,20 @@ pub fn build_router_with_auth(
22302230
);
22312231

22322232
router = router
2233+
.route("/", get(|| async { axum::response::Redirect::temporary("/ui") }))
22332234
.route("/auth/me", get(auth_me))
22342235
.route("/graphql/playground", get(graphql_playground_handler));
22352236

2237+
// UI: disk directory overrides the embedded build (useful during development).
2238+
// When --ui-dir is not set, serve the UI compiled into the binary.
22362239
if let Some(ui_dir) = ui_dir {
22372240
let index_path = ui_dir.join("index.html");
22382241
let ui_service = get_service(ServeDir::new(ui_dir).fallback(ServeFile::new(index_path)));
22392242
router = router.nest_service("/ui", ui_service);
2243+
} else {
2244+
router = router
2245+
.route("/ui", get(crate::embedded_ui::embedded_ui_handler))
2246+
.route("/ui/{*path}", get(crate::embedded_ui::embedded_ui_handler));
22402247
}
22412248

22422249
if let Some(cp) = control_plane {
@@ -4207,54 +4214,70 @@ mod tests {
42074214
.any(|collection| collection == "tasks"));
42084215
}
42094216

4210-
#[tokio::test]
4211-
async fn http_serves_ui_assets_under_ui_prefix() {
4212-
let dir = tempfile::tempdir().unwrap();
4213-
std::fs::write(
4214-
dir.path().join("index.html"),
4215-
"<html><body>Axon UI</body></html>",
4216-
)
4217-
.unwrap();
4218-
std::fs::create_dir_all(dir.path().join("_app")).unwrap();
4219-
std::fs::write(dir.path().join("_app/app.js"), "console.log('ui');").unwrap();
4220-
4221-
let storage: Box<dyn StorageAdapter + Send + Sync> = Box::new(
4222-
SqliteStorageAdapter::open_in_memory().expect("in-memory SQLite should open"),
4223-
);
4224-
let handler: TenantHandler = Arc::new(Mutex::new(AxonHandler::new(storage)));
4225-
let tenant_router = Arc::new(TenantRouter::single(handler));
4226-
let app = build_router(tenant_router, "memory", Some(dir.path().to_path_buf()));
4227-
let server = TestServer::new(app);
4228-
4229-
let index = server.get("/ui").await;
4230-
index.assert_status_ok();
4231-
assert!(index.text().contains("Axon UI"));
4217+
// ── Embedded UI tests ─────────────────────────────────────────────────────
4218+
// These tests exercise the embedded-UI path (ui_dir = None), which is the
4219+
// default when axon is run without --ui-dir. They rely on the real
4220+
// ui/build assets compiled into the binary via rust-embed.
42324221

4233-
let asset = server.get("/ui/_app/app.js").await;
4234-
asset.assert_status_ok();
4235-
assert!(asset.text().contains("console.log"));
4222+
#[tokio::test]
4223+
async fn http_root_redirects_to_ui() {
4224+
let server = test_server();
4225+
let resp = server.get("/").await;
4226+
resp.assert_status(StatusCode::TEMPORARY_REDIRECT);
4227+
let location = resp
4228+
.headers()
4229+
.get(header::LOCATION)
4230+
.and_then(|v| v.to_str().ok())
4231+
.unwrap_or("");
4232+
assert!(location.ends_with("/ui"), "expected redirect to /ui, got: {location}");
42364233
}
42374234

42384235
#[tokio::test]
4239-
async fn http_ui_nested_routes_fallback_to_index_html() {
4240-
let dir = tempfile::tempdir().unwrap();
4241-
std::fs::write(
4242-
dir.path().join("index.html"),
4243-
"<html><body>Axon UI Shell</body></html>",
4244-
)
4245-
.unwrap();
4236+
async fn http_embedded_ui_index_returns_html() {
4237+
let server = test_server();
4238+
let resp = server.get("/ui").await;
4239+
resp.assert_status_ok();
4240+
let ct = resp
4241+
.headers()
4242+
.get(header::CONTENT_TYPE)
4243+
.and_then(|v| v.to_str().ok())
4244+
.unwrap_or("");
4245+
assert!(ct.starts_with("text/html"), "expected text/html, got: {ct}");
4246+
// SvelteKit's static build always starts with <!DOCTYPE html>
4247+
assert!(resp.text().starts_with("<!DOCTYPE html"), "expected HTML document");
4248+
}
42464249

4247-
let storage: Box<dyn StorageAdapter + Send + Sync> = Box::new(
4248-
SqliteStorageAdapter::open_in_memory().expect("in-memory SQLite should open"),
4250+
#[tokio::test]
4251+
async fn http_embedded_ui_static_asset_served_with_correct_content_type() {
4252+
// _app/env.js is a stable filename generated by SvelteKit.
4253+
let server = test_server();
4254+
let resp = server.get("/ui/_app/env.js").await;
4255+
resp.assert_status_ok();
4256+
let ct = resp
4257+
.headers()
4258+
.get(header::CONTENT_TYPE)
4259+
.and_then(|v| v.to_str().ok())
4260+
.unwrap_or("");
4261+
assert!(
4262+
ct.starts_with("application/javascript") || ct.starts_with("text/javascript"),
4263+
"expected JS content-type, got: {ct}"
42494264
);
4250-
let handler: TenantHandler = Arc::new(Mutex::new(AxonHandler::new(storage)));
4251-
let tenant_router = Arc::new(TenantRouter::single(handler));
4252-
let app = build_router(tenant_router, "memory", Some(dir.path().to_path_buf()));
4253-
let server = TestServer::new(app);
4265+
}
42544266

4255-
let resp = server.get("/ui/collections/tasks").await;
4267+
#[tokio::test]
4268+
async fn http_embedded_ui_unknown_path_falls_back_to_index_html() {
4269+
// Unrecognised paths must return index.html so SvelteKit's client-side
4270+
// router can handle them without a hard 404.
4271+
let server = test_server();
4272+
let resp = server.get("/ui/some/deep/spa/route").await;
42564273
resp.assert_status_ok();
4257-
assert!(resp.text().contains("Axon UI Shell"));
4274+
let ct = resp
4275+
.headers()
4276+
.get(header::CONTENT_TYPE)
4277+
.and_then(|v| v.to_str().ok())
4278+
.unwrap_or("");
4279+
assert!(ct.starts_with("text/html"), "fallback must be HTML, got: {ct}");
4280+
assert!(resp.text().starts_with("<!DOCTYPE html"), "expected index.html fallback");
42584281
}
42594282

42604283
#[tokio::test]

0 commit comments

Comments
 (0)