Compare commits

..

10 commits

Author SHA1 Message Date
05915aae30 Fix nfc 2026-05-01 16:19:51 -07:00
56c555d699 Security key 2026-05-01 14:14:58 -07:00
6c6ee030cd Makefile 2026-03-31 21:54:28 -07:00
57bfbce24b Fix permissions for reboot / shutdown 2026-03-30 20:07:54 -07:00
ac01c03094 Permissions, shutdown route 2026-03-30 19:53:55 -07:00
15d0d174a9 Handle totp in userspace 2026-03-30 17:17:24 -07:00
2f04653df4 make file for deployment 2026-03-29 16:01:17 -07:00
ca87adfe0f hash 2026-03-28 23:42:32 -07:00
256546164f Remove pam and implement totp in rust 2026-03-28 23:29:55 -07:00
043ab278f5 remove pam 2026-03-28 21:59:34 -07:00
9 changed files with 1542 additions and 189 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target /target
/result

1285
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,12 +8,18 @@ axum = "0.8.8"
base64 = "0.22.1" base64 = "0.22.1"
bcrypt = "0.19.0" bcrypt = "0.19.0"
chrono = "0.4.44" chrono = "0.4.44"
crypt = "0.1.0"
yescrypt = { version = "0.1.0-pre.4", features = ["password-hash"] }
password-hash = { version = "0.6", features = ["phc"] }
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
libc = "0.2.183"
pam = { version = "0.8.0", features = ["client"] } pam = { version = "0.8.0", features = ["client"] }
pwhash = "1.0.0"
rand = "0.10.0" rand = "0.10.0"
rustls = { version = "0.23.37", features = ["ring"] } rustls = { version = "0.23.37", features = ["ring"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
shadow-rs = "1.7.1"
sysinfo = "0.38.4" sysinfo = "0.38.4"
tokio = { version = "1.50.0", features = [ tokio = { version = "1.50.0", features = [
"macros", "macros",
@ -21,3 +27,6 @@ tokio = { version = "1.50.0", features = [
"process", "process",
] } ] }
zbus = "5.14.0" zbus = "5.14.0"
webauthn-rs = "0.5"
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }

26
Makefile Normal file
View file

@ -0,0 +1,26 @@
PKG := server-dash-api
USER := server-dash-api
BIN := ./result/bin/$(PKG)
ARGS ?=
.PHONY: build run logs clean
build:
nix build
run: build
sudo -u $(USER) $(BIN) $(ARGS)
logs:
journalctl -u $(PKG) -f
clean:
rm -f result
deploy:
cargo build --release
sudo cp target/release/server-dash-api /var/lib/server-dash-api/server-dash-api
sudo chown server-dash-api:server-dash-api /var/lib/server-dash-api/server-dash-api
sudo chmod 755 /var/lib/server-dash-api/server-dash-api
sudo systemctl restart server-dash-api

View file

@ -38,20 +38,22 @@
linux-pam linux-pam
libclang libclang
glibc.dev glibc.dev
gnumake
]; ];
in package = pkgs.rustPlatform.buildRustPackage {
{
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "server-dash-api"; pname = "server-dash-api";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
cargoHash = "sha256-z2sdfkRN25CAiXepQRzftoWGwbl8lI4KGuezGg4rD/A="; cargoLock.lockFile = ./Cargo.lock;
inherit nativeBuildInputs buildInputs; inherit nativeBuildInputs buildInputs;
OPENSSL_NO_VENDOR = 1; OPENSSL_NO_VENDOR = 1;
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib"; LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
BINDGEN_EXTRA_CLANG_ARGS = "-I${pkgs.linux-pam}/include -I${pkgs.glibc.dev}/include"; BINDGEN_EXTRA_CLANG_ARGS = "-I${pkgs.linux-pam}/include -I${pkgs.glibc.dev}/include";
}; };
in
{
packages.default = package;
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
inherit nativeBuildInputs buildInputs; inherit nativeBuildInputs buildInputs;
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
@ -80,6 +82,11 @@
{ {
options.services.server-dash-api = { options.services.server-dash-api = {
enable = lib.mkEnableOption "server-dash-api system stats API"; enable = lib.mkEnableOption "server-dash-api system stats API";
useNixBuild = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Build the binary via Nix instead of using a manually deployed binary";
};
}; };
config = lib.mkIf config.services.server-dash-api.enable { config = lib.mkIf config.services.server-dash-api.enable {
@ -94,22 +101,21 @@
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /var/lib/server-dash-api 0750 server-dash-api server-dash-api -" "d /var/lib/server-dash-api 0750 server-dash-api server-dash-api -"
"d /var/lib/server-dash-api/google-auth 0750 server-dash-api server-dash-api -" "d /var/lib/server-dash-api/webauthn-credentials 0750 server-dash-api server-dash-api -"
]; ];
security.pam.services.server-dash-api = {
text = ''
auth required ${pkgs.google-authenticator}/lib/security/pam_google_authenticator.so secret=/var/lib/server-dash-api/google-auth/%u user=server-dash-api no_increment_hotp
auth sufficient ${pkgs.linux-pam}/lib/security/pam_unix.so likeauth nullok
auth required ${pkgs.linux-pam}/lib/security/pam_unix.so
account required ${pkgs.linux-pam}/lib/security/pam_unix.so
'';
};
security.polkit.extraConfig = '' security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) { polkit.addRule(function(action, subject) {
if ((action.id == "org.freedesktop.systemd1.manage-units" || if ((action.id == "org.freedesktop.systemd1.manage-units" ||
action.id == "org.freedesktop.login1.reboot") && action.id == "org.freedesktop.login1.reboot" ||
action.id == "org.freedesktop.login1.reboot-multiple-sessions" ||
action.id == "org.freedesktop.login1.reboot-ignore-inhibit" ||
action.id == "org.freedesktop.login1.power-off" ||
action.id == "org.freedesktop.login1.power-off-multiple-sessions" ||
action.id == "org.freedesktop.login1.power-off-ignore-inhibit" ||
action.id == "org.freedesktop.login1.halt" ||
action.id == "org.freedesktop.login1.halt-multiple-sessions" ||
action.id == "org.freedesktop.login1.halt-ignore-inhibit") &&
subject.user == "server-dash-api") { subject.user == "server-dash-api") {
return polkit.Result.YES; return polkit.Result.YES;
} }
@ -125,8 +131,13 @@
User = "server-dash-api"; User = "server-dash-api";
Group = "server-dash-api"; Group = "server-dash-api";
SupplementaryGroups = [ "shadow" ]; SupplementaryGroups = [ "shadow" ];
ExecStart = "${self.packages.${pkgs.system}.default}/bin/server-dash-api"; ExecStart =
Restart = "always"; if config.services.server-dash-api.useNixBuild then
"${self.packages.${pkgs.system}.default}/bin/server-dash-api"
else
"/var/lib/server-dash-api/server-dash-api";
Restart = "on-failure";
RestartSec = "10s";
StateDirectory = "server-dash-api"; StateDirectory = "server-dash-api";
Environment = [ Environment = [
"RUST_LOG=info" "RUST_LOG=info"

View file

@ -1,19 +1,61 @@
use axum::body::Body; use axum::body::Body;
use axum::extract::State;
use axum::{ use axum::{
http::HeaderMap, http::Request, http::StatusCode, middleware::Next, response::IntoResponse, http::HeaderMap,
response::Json, response::Response, http::Request,
http::StatusCode,
middleware::Next,
response::IntoResponse,
response::Json,
response::Response,
}; };
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use pam::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use url::Url;
use uuid::Uuid;
use webauthn_rs::prelude::*;
use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
static JWT_SECRET: OnceLock<String> = OnceLock::new(); static JWT_SECRET: OnceLock<String> = OnceLock::new();
const ROTATION_DAYS: u64 = 7; const ROTATION_DAYS: u64 = 7;
const CREDENTIAL_DIR: &str = "/var/lib/server-dash-api/webauthn-credentials";
const CHALLENGE_TTL: Duration = Duration::from_secs(300);
const RP_ID: &str = "jackmechem.dev";
const RP_ORIGIN: &str = "https://dashboard.jackmechem.dev";
#[derive(Serialize, Deserialize)]
struct StoredCredentials {
user_id: Uuid,
credentials: Vec<SecurityKey>,
}
pub struct AppState {
pub webauthn: Webauthn,
pending_auth: Mutex<HashMap<String, (SecurityKeyAuthentication, Instant, String)>>,
pending_reg: Mutex<HashMap<String, (SecurityKeyRegistration, Instant, String, Uuid)>>,
}
impl AppState {
pub fn new() -> Self {
let rp_origin = Url::parse(RP_ORIGIN).expect("Invalid RP origin");
let webauthn = WebauthnBuilder::new(RP_ID, &rp_origin)
.expect("Invalid WebAuthn config")
.rp_name("Server Dashboard")
.build()
.expect("Failed to build WebAuthn");
Self {
webauthn,
pending_auth: Mutex::new(HashMap::new()),
pending_reg: Mutex::new(HashMap::new()),
}
}
}
fn secret_path() -> PathBuf { fn secret_path() -> PathBuf {
PathBuf::from("/var/lib/server-dash-api/jwt_secret") PathBuf::from("/var/lib/server-dash-api/jwt_secret")
@ -39,7 +81,6 @@ pub fn jwt_secret() -> &'static str {
let path = secret_path(); let path = secret_path();
std::fs::create_dir_all(path.parent().unwrap()).ok(); std::fs::create_dir_all(path.parent().unwrap()).ok();
// file format: "timestamp:secret"
if let Ok(contents) = std::fs::read_to_string(&path) { if let Ok(contents) = std::fs::read_to_string(&path) {
if let Some((ts_str, secret)) = contents.trim().split_once(':') { if let Some((ts_str, secret)) = contents.trim().split_once(':') {
if let Ok(ts) = ts_str.parse::<u64>() { if let Ok(ts) = ts_str.parse::<u64>() {
@ -103,19 +144,71 @@ pub fn decode_basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
let encoded = val.strip_prefix("Basic ")?; let encoded = val.strip_prefix("Basic ")?;
let decoded = general_purpose::STANDARD.decode(encoded).ok()?; let decoded = general_purpose::STANDARD.decode(encoded).ok()?;
let s = String::from_utf8(decoded).ok()?; let s = String::from_utf8(decoded).ok()?;
let (user, pass) = s.split_once(':')?; let (user, password) = s.split_once(':')?;
Some((user.to_string(), pass.to_string())) Some((user.to_string(), password.to_string()))
} }
pub fn verify_system_credentials(username: &str, password: &str) -> bool { fn verify_password(username: &str, password: &str) -> bool {
let mut client = match Client::with_password("server-dash-api") { let shadow_content = match std::fs::read_to_string("/etc/shadow") {
Ok(c) => c, Ok(c) => c,
Err(_) => return false, Err(e) => {
println!("Failed to read /etc/shadow: {}", e);
return false;
}
}; };
client for line in shadow_content.lines() {
.conversation_mut() let fields: Vec<&str> = line.split(':').collect();
.set_credentials(username, password); if fields.len() < 2 {
client.authenticate().is_ok() continue;
}
if fields[0] != username {
continue;
}
return verify_shadow_hash(password, fields[1]);
}
println!("User not found in shadow");
false
}
fn verify_shadow_hash(password: &str, hash: &str) -> bool {
let parsed_hash = match PasswordHash::new(hash) {
Ok(h) => h,
Err(e) => {
println!("Failed to parse hash: {:?}", e);
return false;
}
};
Yescrypt::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
}
fn load_credentials(username: &str) -> Option<StoredCredentials> {
let path = PathBuf::from(CREDENTIAL_DIR).join(format!("{}.json", username));
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> {
let dir = PathBuf::from(CREDENTIAL_DIR);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.json", username));
let data = serde_json::to_string(creds).map_err(|e| e.to_string())?;
std::fs::write(&path, &data).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
}
Ok(())
}
fn generate_session_id() -> String {
format!(
"{:016x}{:016x}",
rand::random::<u64>(),
rand::random::<u64>()
)
} }
pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next) -> Response { pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next) -> Response {
@ -126,23 +219,206 @@ pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next
} }
} }
// POST /auth/login // POST /auth/login — verifies password, returns a WebAuthn challenge for the YubiKey
pub async fn post_login(headers: HeaderMap) -> impl IntoResponse { pub async fn post_login(
let (username, password_and_totp) = match decode_basic_auth(&headers) { State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
let (username, password) = match decode_basic_auth(&headers) {
Some(c) => c, Some(c) => c,
None => { None => {
return ( return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header")
StatusCode::UNAUTHORIZED, .into_response()
"Missing or invalid Authorization header",
)
.into_response();
} }
}; };
if !verify_system_credentials(&username, &password_and_totp) { if !verify_password(&username, &password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(); return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
} }
let stored = match load_credentials(&username) {
Some(s) => s,
None => {
return (StatusCode::UNAUTHORIZED, "No YubiKey registered for this user")
.into_response()
}
};
let (rcr, auth_state) = match state
.webauthn
.start_securitykey_authentication(&stored.credentials)
{
Ok(r) => r,
Err(e) => {
println!("WebAuthn start auth error: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "WebAuthn error").into_response();
}
};
let session_id = generate_session_id();
{
let mut pending = state.pending_auth.lock().unwrap();
pending.retain(|_, (_, t, _)| t.elapsed() < CHALLENGE_TTL);
pending.insert(session_id.clone(), (auth_state, Instant::now(), username));
}
(
StatusCode::OK,
Json(serde_json::json!({
"session_id": session_id,
"challenge": rcr,
})),
)
.into_response()
}
#[derive(Deserialize)]
pub struct VerifyRequest {
session_id: String,
credential: PublicKeyCredential,
}
// POST /auth/verify — verifies the YubiKey assertion and returns a JWT
pub async fn post_verify(
State(state): State<Arc<AppState>>,
Json(body): Json<VerifyRequest>,
) -> impl IntoResponse {
let (auth_state, username) = {
let mut pending = state.pending_auth.lock().unwrap();
match pending.remove(&body.session_id) {
Some((s, created, u)) if created.elapsed() < CHALLENGE_TTL => (s, u),
Some(_) => return (StatusCode::UNAUTHORIZED, "Challenge expired").into_response(),
None => return (StatusCode::UNAUTHORIZED, "Invalid session").into_response(),
}
};
let auth_result = match state
.webauthn
.finish_securitykey_authentication(&body.credential, &auth_state)
{
Ok(r) => r,
Err(e) => {
println!("WebAuthn finish auth error: {:?}", e);
return (StatusCode::UNAUTHORIZED, "WebAuthn verification failed").into_response();
}
};
// Persist updated credential counter
if let Some(mut stored) = load_credentials(&username) {
for cred in &mut stored.credentials {
cred.update_credential(&auth_result);
}
save_credentials(&username, &stored).ok();
}
let token = create_token(&username); let token = create_token(&username);
(StatusCode::OK, Json(serde_json::json!({ "token": token }))).into_response() (StatusCode::OK, Json(serde_json::json!({ "token": token }))).into_response()
} }
// POST /auth/register/start — verifies password, returns a WebAuthn registration challenge
pub async fn post_register_start(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
let (username, password) = match decode_basic_auth(&headers) {
Some(c) => c,
None => {
return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header")
.into_response()
}
};
if !verify_password(&username, &password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let stored = load_credentials(&username);
let user_id = stored.as_ref().map(|s| s.user_id).unwrap_or_else(Uuid::new_v4);
let exclude: Option<Vec<CredentialID>> = stored.as_ref().map(|s| {
s.credentials
.iter()
.map(|c| c.cred_id().clone())
.collect()
});
let (ccr, reg_state) = match state
.webauthn
.start_securitykey_registration(user_id, &username, &username, exclude, None, None)
{
Ok(r) => r,
Err(e) => {
println!("WebAuthn start reg error: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "WebAuthn error").into_response();
}
};
let session_id = generate_session_id();
{
let mut pending = state.pending_reg.lock().unwrap();
pending.retain(|_, (_, t, _, _)| t.elapsed() < CHALLENGE_TTL);
pending.insert(
session_id.clone(),
(reg_state, Instant::now(), username, user_id),
);
}
(
StatusCode::OK,
Json(serde_json::json!({
"session_id": session_id,
"challenge": ccr,
})),
)
.into_response()
}
#[derive(Deserialize)]
pub struct RegisterFinishRequest {
session_id: String,
credential: RegisterPublicKeyCredential,
}
// POST /auth/register/finish — completes YubiKey enrollment and saves the credential
pub async fn post_register_finish(
State(state): State<Arc<AppState>>,
Json(body): Json<RegisterFinishRequest>,
) -> impl IntoResponse {
let (reg_state, username, user_id) = {
let mut pending = state.pending_reg.lock().unwrap();
match pending.remove(&body.session_id) {
Some((s, created, u, id)) if created.elapsed() < CHALLENGE_TTL => (s, u, id),
Some(_) => return (StatusCode::UNAUTHORIZED, "Challenge expired").into_response(),
None => return (StatusCode::UNAUTHORIZED, "Invalid session").into_response(),
}
};
let passkey = match state
.webauthn
.finish_securitykey_registration(&body.credential, &reg_state)
{
Ok(p) => p,
Err(e) => {
println!("WebAuthn finish reg error: {:?}", e);
return (StatusCode::BAD_REQUEST, "WebAuthn registration failed").into_response();
}
};
let mut stored = load_credentials(&username).unwrap_or(StoredCredentials {
user_id,
credentials: vec![],
});
stored.credentials.push(passkey);
if let Err(e) = save_credentials(&username, &stored) {
println!("Failed to save credentials: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save credential").into_response();
}
(
StatusCode::OK,
Json(serde_json::json!({ "message": "YubiKey registered successfully" })),
)
.into_response()
}

View file

@ -2,8 +2,8 @@ pub const ALLOWED_SERVICES: &[&str] = &[
"syncthing", "syncthing",
"caddy", "caddy",
"sshd", "sshd",
"dashboard", "server-dash",
"sysapi", "server-dash-api",
"cloudflare-dyndns.timer", "cloudflare-dyndns.timer",
"cloudflare-dyndns", "cloudflare-dyndns",
"docker", "docker",

View file

@ -1,6 +1,7 @@
use axum::middleware; use axum::middleware;
use axum::response::Redirect; use axum::response::Redirect;
use axum::{Router, routing::get, routing::post}; use axum::{Router, routing::get, routing::post};
use std::sync::Arc;
mod auth; mod auth;
mod config; mod config;
@ -9,6 +10,8 @@ mod routes;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let state = Arc::new(auth::AppState::new());
let protected = Router::new() let protected = Router::new()
.route("/stats", get(routes::stats::get_stats)) .route("/stats", get(routes::stats::get_stats))
.route( .route(
@ -28,12 +31,17 @@ async fn main() {
get(routes::services::service_logs), get(routes::services::service_logs),
) )
.route("/system/reboot", post(routes::system::system_reboot)) .route("/system/reboot", post(routes::system::system_reboot))
.route("/system/shutdown", post(routes::system::system_shutdown))
.route_layer(middleware::from_fn(auth::require_auth)); .route_layer(middleware::from_fn(auth::require_auth));
let app = Router::new() let app = Router::new()
.route("/", get(|| async { Redirect::permanent("/stats") })) .route("/", get(|| async { Redirect::permanent("/stats") }))
.route("/auth/login", post(auth::post_login)) .route("/auth/login", post(auth::post_login))
.merge(protected); .route("/auth/verify", post(auth::post_verify))
.route("/auth/register/start", post(auth::post_register_start))
.route("/auth/register/finish", post(auth::post_register_finish))
.merge(protected)
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001") let listener = tokio::net::TcpListener::bind("127.0.0.1:3001")
.await .await

View file

@ -4,6 +4,33 @@ use zbus::Connection;
use crate::auth; use crate::auth;
use crate::models; use crate::models;
// POST /system/shutdown
pub async fn system_shutdown(headers: HeaderMap) -> impl IntoResponse {
let conn = match Connection::system().await {
Ok(c) => c,
Err(e) => {
return models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
.into_response();
}
};
let result = conn
.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
"PowerOff",
&(false,),
)
.await;
match result {
Ok(_) => models::ActionResponse::ok("Shutting down...".to_string()).into_response(),
Err(e) => models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
.into_response(),
}
}
// POST /system/reboot // POST /system/reboot
pub async fn system_reboot(headers: HeaderMap) -> impl IntoResponse { pub async fn system_reboot(headers: HeaderMap) -> impl IntoResponse {
let conn = match Connection::system().await { let conn = match Connection::system().await {