Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
729 changes: 717 additions & 12 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ async-trait = "0.1.89"
tonic = "0.14.5"
prost = "0.14.3"
tonic-prost = "0.14.5"
diesel = "2.3.11"
diesel-async = "0.9.2"
diesel_migrations = "2.3.2"
libsqlite3-sys = "0.30"

[profile.release]
opt-level = 3
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
```
NET_TYPE=VXLAN
CERT_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars>
JWT_SIGNING_KEY=<32 raw bytes or 64 hex chars>
MFA_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars>
DATABASE_URL=/var/nullnet/data/nullnet.db # SQLite db path; default shown
ADMIN_BOOTSTRAP_USERNAME=admin # only used the first time the users table is empty
ADMIN_BOOTSTRAP_PASSWORD=<a real password> # ditto — defaults to admin/admin if either is unset
PROXY_IP=192.168.1.100
ENCRYPTION_ENABLED=true
INGRESS_ALLOW_TCP_PORTS=22,8080 # inbound TCP listeners every node accepts
Expand All @@ -50,6 +55,21 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
keep it stable, since rotating it makes existing encrypted data undecryptable. Generate one with
`openssl rand -hex 32`.

`JWT_SIGNING_KEY` and `MFA_ENCRYPTION_KEY` are also **required** — the server refuses to start
without them. `JWT_SIGNING_KEY` signs the admin UI's session access tokens; `MFA_ENCRYPTION_KEY`
encrypts TOTP secrets at rest. Each is deliberately a separate key from `CERT_ENCRYPTION_KEY` (no
shared blast radius between secret classes) — generate each independently with `openssl rand -hex 32`
and keep them stable, for the same reason as `CERT_ENCRYPTION_KEY`.

`DATABASE_URL` is the path to the server's SQLite database (users, sessions, service config, etc.),
created on first start along with any parent directories. Defaults to `/var/nullnet/data/nullnet.db`
if unset.

`ADMIN_BOOTSTRAP_USERNAME`/`ADMIN_BOOTSTRAP_PASSWORD` create the first admin account the one time
the `users` table is empty — irrelevant on every subsequent start. If either is unset, the server
falls back to `admin`/`admin` and prints a loud warning; change that password immediately after
first login if the admin UI is reachable by anyone else.

`PROXY_IP` is the IP of the host running `nullnet-proxy` (the egress gateway). It is **required to
enable egress brokering**: when a registered service reaches out to the internet the server builds
a per-initiator egress edge to this host. If unset, egress is disabled (the trigger is rejected
Expand All @@ -68,6 +88,12 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
IP equals `PROXY_IP`) is switched to gateway posture automatically — all outbound allowed and
tracked — so no per-node flag is needed; inbound there still obeys these lists, so include `80,443`.

- the admin web UI now requires login (JWT-based, cookie sessions). Roles are `admin` (all
permissions) and `user` (explicit per-resource scopes, assigned from the *Users* page). MFA
(TOTP) is optional per-account — an account without it configured sees a persistent banner
prompting setup (QR code + confirm step), until it's done. See `ADMIN_BOOTSTRAP_USERNAME` above
for how the first admin account is created.

- TLS certificates are issued from Let's Encrypt via a DNS-01 challenge (UI: *Certificates* page).
Each cert stores its DNS-provider credentials encrypted at rest and is **renewed automatically**
before expiry. The renewal scan is tunable via optional env vars (defaults shown):
Expand Down
11 changes: 11 additions & 0 deletions members/nullnet-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ openssl = "0.10"
quick-xml = "0.39"
aws-sdk-route53 = "1"
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
diesel = { workspace = true, features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel-async = { workspace = true, features = ["sync-connection-wrapper", "tokio"] }
diesel_migrations = { workspace = true, features = ["sqlite"] }
libsqlite3-sys = { workspace = true, features = ["bundled"] }
# auth: JWT + password hashing + TOTP MFA
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto", "use_pem"] }
argon2 = "0.5"
totp-rs = { version = "5", features = ["gen_secret", "otpauth"] }
sha2 = "0.11"
axum-extra = { version = "0.12", features = ["cookie"] }
time = { version = "0.3", default-features = false }

[build-dependencies]

27 changes: 25 additions & 2 deletions members/nullnet-server/build.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
use std::path::Path;
use std::process::Command;

fn main() {
println!("cargo:rerun-if-changed=ui/src");
println!("cargo:rerun-if-changed=ui/index.html");
println!("cargo:rerun-if-changed=ui/package.json");
println!("cargo:rerun-if-changed=ui/package-lock.json");

let ui_dir = std::path::Path::new("ui");
if !ui_dir.join("node_modules").exists() {
let ui_dir = Path::new("ui");
let node_modules = ui_dir.join("node_modules");
// npm rewrites this to mirror package-lock.json on every successful
// install; if package-lock.json is newer, node_modules is stale (e.g. a
// dependency was added since the last install) and must be reinstalled —
// not just "does node_modules exist at all".
let installed_lock = node_modules.join(".package-lock.json");
let needs_install =
!node_modules.exists() || is_older(&installed_lock, &ui_dir.join("package-lock.json"));

if needs_install {
let status = Command::new("npm")
.args(["install"])
.current_dir(ui_dir)
Expand All @@ -22,3 +33,15 @@ fn main() {
.expect("failed to run npm run build");
assert!(status.success(), "npm run build failed");
}

/// `true` if `path` doesn't exist, or its mtime predates `reference`'s — in
/// either case treat it as stale rather than trusting it.
fn is_older(path: &Path, reference: &Path) -> bool {
let (Ok(path_meta), Ok(ref_meta)) = (path.metadata(), reference.metadata()) else {
return true;
};
let (Ok(path_mtime), Ok(ref_mtime)) = (path_meta.modified(), ref_meta.modified()) else {
return true;
};
path_mtime < ref_mtime
}
100 changes: 100 additions & 0 deletions members/nullnet-server/src/auth/bootstrap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use crate::auth::{Role, password};
use crate::db::Db;
use nullnet_liberror::Error;
use uuid::Uuid;

/// If any user already exists, this is a no-op. Otherwise creates the first
/// admin account from `bootstrap_username`/`bootstrap_password` if both are
/// non-empty; if either is missing, warns loudly and continues rather than
/// failing startup (mirrors the `INGRESS_ALLOW_TCP_PORTS.is_empty()`
/// warn-not-crash pattern in `main.rs`) — the server starts with zero users,
/// and login rejects everyone until an operator sets
/// `ADMIN_BOOTSTRAP_USERNAME`/`ADMIN_BOOTSTRAP_PASSWORD` and restarts.
/// Idempotent: safe to leave those env vars set permanently, since this only
/// ever runs while the `users` table is empty.
///
/// Takes the candidate username/password as plain params (rather than
/// reading the env vars itself) so callers — `main.rs` in production, tests
/// here — control them directly instead of mutating shared process env state.
pub(crate) async fn ensure_admin_exists(
db: &Db,
bootstrap_username: Option<&str>,
bootstrap_password: Option<&str>,
) -> Result<(), Error> {
let users = db.users();
if users.count().await? > 0 {
return Ok(());
}

let username = bootstrap_username.filter(|s| !s.trim().is_empty());
let plain_password = bootstrap_password.filter(|s| !s.is_empty());
let (Some(username), Some(plain_password)) = (username, plain_password) else {
println!(
"WARNING: no users exist and ADMIN_BOOTSTRAP_USERNAME/ADMIN_BOOTSTRAP_PASSWORD are \
not both set — the admin UI will be inaccessible until an initial admin is created \
(set these env vars and restart)."
);
return Ok(());
};

let id = Uuid::new_v4().to_string();
let password_hash = password::hash(plain_password)?;
users
.create(&id, username, &password_hash, Role::Admin.as_str())
.await?;
println!("Bootstrapped initial admin account '{username}'.");
Ok(())
}

#[cfg(test)]
mod tests {
use super::ensure_admin_exists;
use crate::db::Db;
use std::sync::atomic::{AtomicUsize, Ordering};

async fn test_db() -> Db {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"nullnet-server-bootstrap-test-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
Db::open(dir.join("test.db").to_str().unwrap())
.await
.unwrap()
}

#[tokio::test]
async fn does_nothing_if_bootstrap_args_absent() {
let db = test_db().await;
ensure_admin_exists(&db, None, None).await.unwrap();
assert_eq!(db.users().count().await.unwrap(), 0);
}

#[tokio::test]
async fn creates_admin_when_bootstrap_args_set() {
let db = test_db().await;
ensure_admin_exists(&db, Some("root"), Some("hunter22222"))
.await
.unwrap();

let user = db.users().by_username("root").await.unwrap().unwrap();
assert_eq!(user.role, "admin");
assert_ne!(user.password_hash, "hunter22222", "must be hashed");
}

#[tokio::test]
async fn is_idempotent_once_a_user_exists() {
let db = test_db().await;
db.users()
.create("id-1", "someone", "hash", "user")
.await
.unwrap();

ensure_admin_exists(&db, Some("root"), Some("hunter22222"))
.await
.unwrap();
assert!(db.users().by_username("root").await.unwrap().is_none());
}
}
158 changes: 158 additions & 0 deletions members/nullnet-server/src/auth/jwt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
use crate::auth::{Role, now};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use nullnet_liberror::{Error, ErrorHandler, Location, location};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;

static KEYS: OnceLock<(EncodingKey, DecodingKey)> = OnceLock::new();

/// Normal session access token lifetime. Also used by `http_server::auth` to
/// set the matching cookie `Max-Age`.
pub(crate) const ACCESS_TOKEN_TTL_SECS: i64 = 15 * 60;
/// Short-lived token handed back between login step 1 (password) and the
/// TOTP-verify step, for accounts with MFA enabled.
const MFA_PENDING_TTL_SECS: i64 = 2 * 60;

const PURPOSE_ACCESS: &str = "access";
const PURPOSE_MFA_PENDING: &str = "mfa_pending";

#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct Claims {
/// User id.
pub(crate) sub: String,
pub(crate) role: String,
#[serde(default)]
pub(crate) scopes: Vec<String>,
/// `"access"` for a normal session token, `"mfa_pending"` for the
/// step-1-to-step-2 token — kept distinct so an mfa_pending token can
/// never be accepted by the auth middleware as a real session.
pub(crate) purpose: String,
pub(crate) iat: i64,
pub(crate) exp: i64,
}

/// Initialize the JWT signing/verification key from `JWT_SIGNING_KEY` (32 raw
/// bytes or 64 hex chars, same format as `CERT_ENCRYPTION_KEY`). Call once at
/// startup; fails fast if the key is missing/invalid.
pub(crate) fn init_from_env() -> Result<(), Error> {
let key = crate::crypto::parse_key_from_env("JWT_SIGNING_KEY")?;
let _ = KEYS.set((
EncodingKey::from_secret(&key),
DecodingKey::from_secret(&key),
));
Ok(())
}

fn keys() -> &'static (EncodingKey, DecodingKey) {
KEYS.get().expect("JWT keys not initialized")
}

pub(crate) fn issue_access_token(
user_id: &str,
role: Role,
scopes: &[String],
) -> Result<String, Error> {
let iat = now();
let claims = Claims {
sub: user_id.to_string(),
role: role.as_str().to_string(),
scopes: scopes.to_vec(),
purpose: PURPOSE_ACCESS.to_string(),
iat,
exp: iat + ACCESS_TOKEN_TTL_SECS,
};
encode(&Header::new(Algorithm::HS256), &claims, &keys().0).handle_err(location!())
}

/// Verify an access token, rejecting anything that isn't `purpose: "access"`
/// (e.g. a stray `mfa_pending` token can never authenticate a request).
pub(crate) fn verify_access_token(token: &str) -> Result<Claims, Error> {
let claims = decode_any(token)?;
if claims.purpose != PURPOSE_ACCESS {
return Err::<Claims, _>("token is not an access token").handle_err(location!());
}
Ok(claims)
}

pub(crate) fn issue_mfa_pending_token(user_id: &str) -> Result<String, Error> {
let iat = now();
let claims = Claims {
sub: user_id.to_string(),
role: String::new(),
scopes: Vec::new(),
purpose: PURPOSE_MFA_PENDING.to_string(),
iat,
exp: iat + MFA_PENDING_TTL_SECS,
};
encode(&Header::new(Algorithm::HS256), &claims, &keys().0).handle_err(location!())
}

/// Verify an mfa-pending token, returning the user id it was issued for.
pub(crate) fn verify_mfa_pending_token(token: &str) -> Result<String, Error> {
let claims = decode_any(token)?;
if claims.purpose != PURPOSE_MFA_PENDING {
return Err::<String, _>("token is not an mfa-pending token").handle_err(location!());
}
Ok(claims.sub)
}

fn decode_any(token: &str) -> Result<Claims, Error> {
decode::<Claims>(token, &keys().1, &Validation::new(Algorithm::HS256))
.map(|data| data.claims)
.handle_err(location!())
}

#[cfg(test)]
mod tests {
use super::{
issue_access_token, issue_mfa_pending_token, verify_access_token, verify_mfa_pending_token,
};
use crate::auth::Role;
use std::sync::Once;

fn ensure_keys() {
static INIT: Once = Once::new();
INIT.call_once(|| {
// SAFETY: runs once, before any other test thread reads the env var.
unsafe { std::env::set_var("JWT_SIGNING_KEY", "b".repeat(32)) };
super::init_from_env().unwrap();
});
}

#[test]
fn access_token_round_trip() {
ensure_keys();
let token = issue_access_token("user-1", Role::User, &["events:read".to_string()]).unwrap();
let claims = verify_access_token(&token).unwrap();
assert_eq!(claims.sub, "user-1");
assert_eq!(claims.role, "user");
assert_eq!(claims.scopes, vec!["events:read"]);
}

#[test]
fn mfa_pending_token_round_trip() {
ensure_keys();
let token = issue_mfa_pending_token("user-2").unwrap();
assert_eq!(verify_mfa_pending_token(&token).unwrap(), "user-2");
}

#[test]
fn mfa_pending_token_rejected_as_access_token() {
ensure_keys();
let token = issue_mfa_pending_token("user-2").unwrap();
assert!(verify_access_token(&token).is_err());
}

#[test]
fn access_token_rejected_as_mfa_pending_token() {
ensure_keys();
let token = issue_access_token("user-1", Role::Admin, &[]).unwrap();
assert!(verify_mfa_pending_token(&token).is_err());
}

#[test]
fn garbage_token_is_rejected() {
ensure_keys();
assert!(verify_access_token("not-a-jwt").is_err());
}
}
Loading