From 9b5f7bf65b7a1cccd3ad27a7e2d7ad32fcd09646 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 3 Nov 2026 14:49:39 -0800 Subject: [PATCH] weed-worker: find jwt.filer_signing.key where the Go worker does, and let the chart deliver it (#12373) * seaweed-common: share the security.toml search path with the volume server * weed-worker: read jwt.filer_signing from security.toml and WEED_JWT_* like the Go worker * helm: give the worker-lance container security.toml and the extra environment * weed-worker: treat an empty signing key in the environment as unset Viper ignores an empty environment value, so the Go worker falls back to the security.toml key when WEED_JWT_FILER_SIGNING_KEY is blank. The Rust worker took the blank value and connected with no token. An empty --filer-signing-key / WEED_FILER_SIGNING_KEY also falls through to the security.toml lookup now. Co-Authored-By: Claude Opus 5.5 * weed-worker: read a quoted expires_after_seconds like viper The Go worker reads jwt.filer_signing.expires_after_seconds with GetInt, which also accepts a quoted number. The Rust worker dropped a quoted value and minted tokens with no expiry. Co-Authored-By: Claude Opus 5.5 * weed-worker: warn when security.toml cannot be read or parsed Viper logs a warning when an optional config file is broken. The Rust worker skipped it silently, so a typo looked like an install with no signing key. Log the path and the parse message, without the quoted line, which can hold a key. Co-Authored-By: Claude Opus 5.5 * helm: keep POD_NAME and LANCE_NAMESPACE_URL out of worker-lance's extra environment A duplicate name renders after the chart's entry and wins, silently changing the Lance worker's --id or --namespace. Co-Authored-By: Claude Opus 5.5 * weed-worker: list /usr/local/etc/seaweedfs in the security.toml search path Co-Authored-By: Claude Opus 5.5 * weed-worker: an explicit --filer-signing-key-expires-after-seconds wins The flag was silently ignored whenever the key was discovered, from WEED_JWT_FILER_SIGNING_KEY as well as from security.toml. Use it when given, else the expiry configured beside the discovered key, else zero. Co-Authored-By: Claude Opus 5.5 * weed-worker: test where filer_signing looks for security.toml Co-Authored-By: Claude Opus 5.5 * weed-worker: read a float expires_after_seconds as viper's GetInt does Co-Authored-By: Claude Opus 5.5 * weed-worker: cap the minted exp so a huge expiry cannot overflow Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../templates/worker/worker-deployment.yaml | 22 ++ seaweed-common/src/lib.rs | 3 + seaweed-common/src/security_config.rs | 79 ++++++ seaweed-volume/src/config.rs | 39 +-- seaweed-worker/Cargo.lock | 63 ++++- seaweed-worker/README.md | 9 +- seaweed-worker/crates/core/Cargo.toml | 5 + seaweed-worker/crates/core/src/lib.rs | 1 + .../crates/core/src/security_config.rs | 249 ++++++++++++++++++ seaweed-worker/crates/core/src/token.rs | 17 +- seaweed-worker/crates/lance/src/main.rs | 33 ++- 11 files changed, 470 insertions(+), 50 deletions(-) create mode 100644 seaweed-common/src/security_config.rs create mode 100644 seaweed-worker/crates/core/src/security_config.rs diff --git a/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml b/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml index 2e7f4432be..d2aae98dd9 100644 --- a/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml +++ b/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml @@ -239,6 +239,22 @@ spec: {{- /* the URL crosses a shell line; metacharacters must arrive as data, not syntax */}} - name: LANCE_NAMESPACE_URL value: {{ include "seaweedfs.worker.lanceNamespaceUrl" . | quote }} + {{- $mergedExtraEnvironmentVars := dict }} + {{- include "seaweedfs.mergeExtraEnvironmentVars" (dict "global" .Values.global.seaweedfs "component" .Values.worker "target" $mergedExtraEnvironmentVars) }} + {{- /* a duplicate would win over the chart's value and change --id or --namespace */}} + {{- $mergedExtraEnvironmentVars = omit $mergedExtraEnvironmentVars "POD_NAME" "LANCE_NAMESPACE_URL" }} + {{- range $key := keys $mergedExtraEnvironmentVars | sortAlpha }} + {{- $value := index $mergedExtraEnvironmentVars $key }} + - name: {{ $key }} + {{- if kindIs "string" $value }} + value: {{ tpl $value $ | quote }} + {{- else if kindIs "map" $value }} + valueFrom: + {{ toYaml $value | nindent 16 | trim }} + {{- else if not (kindIs "invalid" $value) }} + value: {{ toJson $value | quote }} + {{- end -}} + {{- end }} command: - "/bin/sh" - "-ec" @@ -268,6 +284,12 @@ spec: --max-concurrency={{ .Values.worker.maxExecute }} volumeMounts: {{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.worker.containerSecurityContext "" "") | nindent 12 }} + {{- if include "seaweedfs.securityConfigEnabled" . }} + - name: security-config + readOnly: true + mountPath: /etc/seaweedfs/security.toml + subPath: security.toml + {{- end }} {{- if .Values.global.seaweedfs.enableSecurity }} - name: ca-cert readOnly: true diff --git a/seaweed-common/src/lib.rs b/seaweed-common/src/lib.rs index 817f46216a..27f9f630df 100644 --- a/seaweed-common/src/lib.rs +++ b/seaweed-common/src/lib.rs @@ -6,6 +6,9 @@ //! second copy is a correctness risk rather than a typing cost: the HTTP↔gRPC //! address rule, which two copies had already drifted on, and the process-wide //! rustls provider, which only works if every binary installs the same one. +//! The security.toml search path joined them for the same reason: an operator +//! mounts the file once and every binary has to look where `weed` looks. pub mod address; +pub mod security_config; pub mod tls; diff --git a/seaweed-common/src/security_config.rs b/seaweed-common/src/security_config.rs new file mode 100644 index 0000000000..75e0ca58d5 --- /dev/null +++ b/seaweed-common/src/security_config.rs @@ -0,0 +1,79 @@ +//! Where a SeaweedFS process finds security.toml, the way `weed` does. +//! +//! Mirrors the search list in `weed/util/config.go`: the working directory, +//! `$HOME/.seaweedfs`, `/usr/local/etc/seaweedfs` and `/etc/seaweedfs`, first +//! existing file wins. The volume server and the workers each read the file +//! for different sections, but an operator mounts it in one place and expects +//! every binary to pick it up from there. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +pub const SECURITY_CONFIG_FILE_NAME: &str = "security.toml"; + +/// The locations searched for security.toml, in order. +pub fn candidates(current_dir: Option<&Path>, home_dir: Option<&Path>) -> Vec { + let mut candidates = Vec::new(); + if let Some(dir) = current_dir { + candidates.push(dir.join(SECURITY_CONFIG_FILE_NAME)); + } + if let Some(home) = home_dir { + candidates.push(home.join(".seaweedfs").join(SECURITY_CONFIG_FILE_NAME)); + } + candidates.push(PathBuf::from("/usr/local/etc/seaweedfs").join(SECURITY_CONFIG_FILE_NAME)); + candidates.push(PathBuf::from("/etc/seaweedfs").join(SECURITY_CONFIG_FILE_NAME)); + candidates +} + +/// The first candidate that is a file. +pub fn find(current_dir: Option<&Path>, home_dir: Option<&Path>) -> Option { + candidates(current_dir, home_dir) + .into_iter() + .find(|candidate| candidate.is_file()) +} + +/// `$HOME`, or `%USERPROFILE%` where that is what the platform sets, read +/// through an injected lookup so tests need not touch the process environment. +pub fn home_dir(env: &dyn Fn(&str) -> Option) -> Option { + env("HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| { + env("USERPROFILE") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_order_matches_go() { + let found = candidates(Some(Path::new("/work")), Some(Path::new("/home/u"))); + assert_eq!( + found, + vec![ + PathBuf::from("/work/security.toml"), + PathBuf::from("/home/u/.seaweedfs/security.toml"), + PathBuf::from("/usr/local/etc/seaweedfs/security.toml"), + PathBuf::from("/etc/seaweedfs/security.toml"), + ] + ); + } + + #[test] + fn home_falls_back_to_userprofile() { + let env = |key: &str| match key { + "USERPROFILE" => Some(OsString::from("/users/u")), + _ => None, + }; + assert_eq!(home_dir(&env), Some(PathBuf::from("/users/u"))); + let empty_home = |key: &str| match key { + "HOME" => Some(OsString::new()), + _ => None, + }; + assert_eq!(home_dir(&empty_home), None); + } +} diff --git a/seaweed-volume/src/config.rs b/seaweed-volume/src/config.rs index 0fcc08f73f..fdbb8574e5 100644 --- a/seaweed-volume/src/config.rs +++ b/seaweed-volume/src/config.rs @@ -1,7 +1,7 @@ use clap::Parser; use std::ffi::OsString; use std::net::UdpSocket; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::security::tls::TlsPolicy; @@ -1051,8 +1051,6 @@ pub struct SecurityConfig { pub guard_white_list: Vec, } -const SECURITY_CONFIG_FILE_NAME: &str = "security.toml"; - /// Parse a security.toml file to extract JWT signing keys and TLS configuration. /// Format: /// ```toml @@ -1272,39 +1270,10 @@ fn resolve_security_config_path(path: &str, env: EnvLookup<'_>) -> Option, - home_dir: Option<&Path>, -) -> Vec { - let mut candidates = Vec::new(); - if let Some(dir) = current_dir { - candidates.push(dir.join(SECURITY_CONFIG_FILE_NAME)); - } - if let Some(home) = home_dir { - candidates.push(home.join(".seaweedfs").join(SECURITY_CONFIG_FILE_NAME)); - } - candidates.push(PathBuf::from("/usr/local/etc/seaweedfs").join(SECURITY_CONFIG_FILE_NAME)); - candidates.push(PathBuf::from("/etc/seaweedfs").join(SECURITY_CONFIG_FILE_NAME)); - candidates -} - -fn home_dir_from_env(env: EnvLookup<'_>) -> Option { - env("HOME") - .filter(|v| !v.is_empty()) - .map(PathBuf::from) - .or_else(|| { - env("USERPROFILE") - .filter(|v| !v.is_empty()) - .map(PathBuf::from) - }) } /// Apply WEED_ environment variable overrides to a SecurityConfig. @@ -1403,7 +1372,9 @@ fn detect_host_address() -> String { #[cfg(test)] mod tests { use super::*; + use seaweed_common::security_config::SECURITY_CONFIG_FILE_NAME; use std::collections::HashMap; + use std::path::Path; use std::sync::{Mutex, MutexGuard, OnceLock}; /// Serialises the tests that move the process working directory against diff --git a/seaweed-worker/Cargo.lock b/seaweed-worker/Cargo.lock index 7b61f81248..e1a57e6611 100644 --- a/seaweed-worker/Cargo.lock +++ b/seaweed-worker/Cargo.lock @@ -5351,8 +5351,10 @@ dependencies = [ "prost-types", "protoc-bin-vendored", "seaweed-common", + "tempfile", "tokio", "tokio-stream", + "toml", "tonic", "tonic-prost", "tonic-prost-build", @@ -5467,6 +5469,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5784,7 +5795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -5977,6 +5988,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tonic" version = "0.14.6" @@ -6627,6 +6679,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/seaweed-worker/README.md b/seaweed-worker/README.md index 02497807d9..d9a40ea6af 100644 --- a/seaweed-worker/README.md +++ b/seaweed-worker/README.md @@ -96,9 +96,12 @@ and `--secret-key` supply a fallback; anything the namespace does vend wins over them. In a vector bucket's `vector` namespace the gateway vends write credentials only -to a caller that proves it holds `jwt.filer_signing.key`. Pass that key with -`--filer-signing-key` so compaction, cleanup and index builds can rewrite -vector indexes. +to a caller that proves it holds `jwt.filer_signing.key`. The same key signs +the admin stream when admin requires it. The worker reads it from where +`weed worker` does: `WEED_JWT_FILER_SIGNING_KEY`, or `[jwt.filer_signing]` in +the security.toml found in the working directory, `~/.seaweedfs`, +`/usr/local/etc/seaweedfs` or `/etc/seaweedfs`. `--filer-signing-key` overrides +both. ## State diff --git a/seaweed-worker/crates/core/Cargo.toml b/seaweed-worker/crates/core/Cargo.toml index d75d5b9a4f..94d9ac30c3 100644 --- a/seaweed-worker/crates/core/Cargo.toml +++ b/seaweed-worker/crates/core/Cargo.toml @@ -16,6 +16,8 @@ seaweed-common = { path = "../../../seaweed-common" } # admin token can sign with the same HMAC the Go worker uses. aws-lc-rs = "1" base64 = "0.22" +# The same requirement seaweed-volume states for its security.toml reader. +toml = "0.8" anyhow.workspace = true async-trait.workspace = true prost.workspace = true @@ -28,6 +30,9 @@ axum.workspace = true prometheus.workspace = true tracing.workspace = true +[dev-dependencies] +tempfile = "3" + [build-dependencies] tonic-prost-build.workspace = true # Ships protoc with the build so neither CI nor a developer needs a system diff --git a/seaweed-worker/crates/core/src/lib.rs b/seaweed-worker/crates/core/src/lib.rs index d297a7b2f1..059ac7b83b 100644 --- a/seaweed-worker/crates/core/src/lib.rs +++ b/seaweed-worker/crates/core/src/lib.rs @@ -11,6 +11,7 @@ pub mod config; pub mod config_form; pub mod metrics; pub mod registry; +pub mod security_config; pub mod senders; pub mod stream; pub mod token; diff --git a/seaweed-worker/crates/core/src/security_config.rs b/seaweed-worker/crates/core/src/security_config.rs new file mode 100644 index 0000000000..10393a7e08 --- /dev/null +++ b/seaweed-worker/crates/core/src/security_config.rs @@ -0,0 +1,249 @@ +//! The admin-stream signing key, read from where the Go worker reads it. +//! +//! `weed worker` takes `jwt.filer_signing` from security.toml, with the +//! `WEED_JWT_FILER_SIGNING_*` environment variables overriding the file the +//! way viper does. A Rust worker started beside it, from the same image with +//! the same mounts, has to find the key in the same places or it connects +//! with no token and admin turns it away. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use seaweed_common::security_config as location; + +/// `jwt.filer_signing` as the Go worker resolves it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FilerSigning { + pub key: String, + /// Zero mints a token without an expiry, the Go default. + pub expires_after_seconds: u64, + /// Where the key came from, for the startup log. + pub source: String, +} + +/// The key from the process environment and the discovered security.toml, or +/// None when neither configures one. +pub fn filer_signing() -> Option { + let env = |key: &str| std::env::var_os(key); + filer_signing_in(&env, std::env::current_dir().ok().as_deref()) +} + +fn filer_signing_in( + env: &dyn Fn(&str) -> Option, + current_dir: Option<&Path>, +) -> Option { + let file = location::find(current_dir, location::home_dir(env).as_deref()); + filer_signing_from(env, file.as_deref()) +} + +fn filer_signing_from( + env: &dyn Fn(&str) -> Option, + file: Option<&Path>, +) -> Option { + // Viper treats an empty environment value as unset. + let env_string = |key: &str| { + env(key) + .and_then(|v| v.into_string().ok()) + .filter(|v| !v.is_empty()) + }; + let (file_key, file_expires) = file.map(read_file).unwrap_or_default(); + + let (key, source) = match env_string("WEED_JWT_FILER_SIGNING_KEY") { + Some(key) => (key, "WEED_JWT_FILER_SIGNING_KEY".to_string()), + None => (file_key?, file.map(PathBuf::from)?.display().to_string()), + }; + if key.is_empty() { + return None; + } + let expires_after_seconds = env_string("WEED_JWT_FILER_SIGNING_EXPIRES_AFTER_SECONDS") + .and_then(|v| v.parse().ok()) + .or(file_expires) + .unwrap_or(0); + Some(FilerSigning { + key, + expires_after_seconds, + source, + }) +} + +/// `[jwt.filer_signing]` key and expires_after_seconds from one file. A file +/// that cannot be read or parsed configures nothing, as it does for viper, +/// which also logs a warning. +fn read_file(path: &Path) -> (Option, Option) { + // The message alone: the full parse error quotes the offending line, + // which can hold a key. + let parsed = std::fs::read_to_string(path) + .map_err(|e| e.to_string()) + .and_then(|content| { + content + .parse::() + .map_err(|e| e.message().to_string()) + }); + let table = match parsed { + Ok(table) => table, + Err(error) => { + tracing::warn!(path = %path.display(), %error, "skipping unreadable security.toml"); + return (None, None); + } + }; + let section = table + .get("jwt") + .and_then(|jwt| jwt.get("filer_signing")) + .and_then(|section| section.as_table()); + let Some(section) = section else { + return (None, None); + }; + let key = section + .get("key") + .and_then(|v| v.as_str()) + .map(str::to_string); + // Viper's GetInt also reads a quoted number and truncates a float. + let expires = section.get("expires_after_seconds").and_then(|v| match v { + toml::Value::Integer(n) => u64::try_from(*n).ok(), + toml::Value::Float(f) if f.is_finite() && *f >= 0.0 => Some(*f as u64), + toml::Value::String(s) => s.trim().parse().ok(), + _ => None, + }); + (key, expires) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), OsString::from(v))) + .collect(); + move |key: &str| map.get(key).cloned() + } + + fn write(dir: &tempfile::TempDir, content: &str) -> PathBuf { + let path = dir.path().join(location::SECURITY_CONFIG_FILE_NAME); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn reads_the_section_the_go_worker_reads() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write( + &dir, + r#" +[jwt.signing] +key = "volume-key" + +[jwt.filer_signing] +key = "cluster-key" +expires_after_seconds = 300 + +[jwt.filer_signing.read] +key = "read-key" +"#, + ); + let got = filer_signing_from(&env_of(&[]), Some(&path)).unwrap(); + assert_eq!(got.key, "cluster-key"); + assert_eq!(got.expires_after_seconds, 300); + assert_eq!(got.source, path.display().to_string()); + } + + #[test] + fn quoted_expiry_reads_as_a_number() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write( + &dir, + "[jwt.filer_signing]\nkey = \"k\"\nexpires_after_seconds = \"300\"\n", + ); + let got = filer_signing_from(&env_of(&[]), Some(&path)).unwrap(); + assert_eq!(got.expires_after_seconds, 300); + } + + #[test] + fn float_expiry_truncates_to_seconds() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write( + &dir, + "[jwt.filer_signing]\nkey = \"k\"\nexpires_after_seconds = 300.9\n", + ); + let got = filer_signing_from(&env_of(&[]), Some(&path)).unwrap(); + assert_eq!(got.expires_after_seconds, 300); + } + + #[test] + fn environment_overrides_the_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write( + &dir, + "[jwt.filer_signing]\nkey = \"file-key\"\nexpires_after_seconds = 5\n", + ); + let env = env_of(&[ + ("WEED_JWT_FILER_SIGNING_KEY", "env-key"), + ("WEED_JWT_FILER_SIGNING_EXPIRES_AFTER_SECONDS", "60"), + ]); + let got = filer_signing_from(&env, Some(&path)).unwrap(); + assert_eq!(got.key, "env-key"); + assert_eq!(got.expires_after_seconds, 60); + assert_eq!(got.source, "WEED_JWT_FILER_SIGNING_KEY"); + } + + #[test] + fn environment_key_alone_is_enough() { + let got = + filer_signing_from(&env_of(&[("WEED_JWT_FILER_SIGNING_KEY", "k")]), None).unwrap(); + assert_eq!(got.key, "k"); + assert_eq!(got.expires_after_seconds, 0); + } + + #[test] + fn empty_environment_values_fall_back_to_the_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = write( + &dir, + "[jwt.filer_signing]\nkey = \"file-key\"\nexpires_after_seconds = 5\n", + ); + let env = env_of(&[ + ("WEED_JWT_FILER_SIGNING_KEY", ""), + ("WEED_JWT_FILER_SIGNING_EXPIRES_AFTER_SECONDS", ""), + ]); + let got = filer_signing_from(&env, Some(&path)).unwrap(); + assert_eq!(got.key, "file-key"); + assert_eq!(got.expires_after_seconds, 5); + assert_eq!(got.source, path.display().to_string()); + } + + #[test] + fn discovers_the_file_in_the_working_directory_then_home() { + let work = tempfile::TempDir::new().unwrap(); + let home = tempfile::TempDir::new().unwrap(); + std::fs::create_dir(home.path().join(".seaweedfs")).unwrap(); + let in_home = home + .path() + .join(".seaweedfs") + .join(location::SECURITY_CONFIG_FILE_NAME); + std::fs::write(&in_home, "[jwt.filer_signing]\nkey = \"home-key\"\n").unwrap(); + let vars = [("HOME", home.path().to_str().unwrap())]; + let env = env_of(&vars); + + let got = filer_signing_in(&env, Some(work.path())).unwrap(); + assert_eq!(got.key, "home-key"); + assert_eq!(got.source, in_home.display().to_string()); + + write(&work, "[jwt.filer_signing]\nkey = \"work-key\"\n"); + assert_eq!( + filer_signing_in(&env, Some(work.path())).unwrap().key, + "work-key" + ); + } + + #[test] + fn nothing_configured_is_none() { + let dir = tempfile::TempDir::new().unwrap(); + let other_section = write(&dir, "[jwt.signing]\nkey = \"volume-key\"\n"); + assert_eq!(filer_signing_from(&env_of(&[]), Some(&other_section)), None); + assert_eq!(filer_signing_from(&env_of(&[]), None), None); + let empty = env_of(&[("WEED_JWT_FILER_SIGNING_KEY", "")]); + assert_eq!(filer_signing_from(&empty, None), None); + } +} diff --git a/seaweed-worker/crates/core/src/token.rs b/seaweed-worker/crates/core/src/token.rs index 21c01b9e08..5466fd15be 100644 --- a/seaweed-worker/crates/core/src/token.rs +++ b/seaweed-worker/crates/core/src/token.rs @@ -3,6 +3,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use aws_lc_rs::hmac; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +/// 9999-12-31T23:59:59Z; golang-jwt reads `exp` through float64 and time.Unix. +const MAX_EXP_SECS: u64 = 253_402_300_799; + /// Mints the HS256 Bearer token the admin control stream requires when /// jwt.filer_signing.key is configured, matching the SeaweedFilerAdminClaims /// the Go worker sends: registered claims only, no session id. @@ -15,7 +18,8 @@ pub fn admin_token(signing_key: &[u8], expires_after_secs: u64) -> Option Vec { #[cfg(test)] mod tests { - use super::{admin_token, derive_key}; + use super::{admin_token, derive_key, MAX_EXP_SECS}; #[test] fn derive_key_matches_the_go_derivation() { @@ -75,4 +79,13 @@ mod tests { let claims = URL_SAFE_NO_PAD.decode(payload).unwrap(); assert_eq!(claims, b"{}"); } + + #[test] + fn huge_expiry_is_capped() { + let token = admin_token(b"secret", u64::MAX).unwrap(); + let payload = token.split('.').nth(1).unwrap(); + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + let claims = URL_SAFE_NO_PAD.decode(payload).unwrap(); + assert_eq!(claims, format!("{{\"exp\":{MAX_EXP_SECS}}}").as_bytes()); + } } diff --git a/seaweed-worker/crates/lance/src/main.rs b/seaweed-worker/crates/lance/src/main.rs index b47a20bf8a..0a269217b8 100644 --- a/seaweed-worker/crates/lance/src/main.rs +++ b/seaweed-worker/crates/lance/src/main.rs @@ -61,18 +61,20 @@ struct Args { #[arg(long)] tls_server_name: Option, - /// Shared secret for the admin control stream, the `jwt.filer_signing.key` - /// value the Go worker reads from its config file. Required by admins that - /// authenticate the worker stream with a signing key. The Lance namespace - /// takes it too: only a holder gets write credentials to vector indexes. + /// Shared secret for the admin control stream, `jwt.filer_signing.key`. + /// Required by admins that authenticate the worker stream with a signing + /// key. The Lance namespace takes it too: only a holder gets write + /// credentials to vector indexes. When not given, read from where the Go + /// worker reads it: WEED_JWT_FILER_SIGNING_KEY, then security.toml. #[arg(long, env = "WEED_FILER_SIGNING_KEY")] filer_signing_key: Option, /// Lifetime of the token minted from the signing key, mirroring /// `jwt.filer_signing.expires_after_seconds`. Zero mints a token with no - /// expiry. - #[arg(long, default_value = "0")] - filer_signing_key_expires_after_seconds: u64, + /// expiry. When not given, the expiry configured beside a discovered key, + /// else zero. + #[arg(long)] + filer_signing_key_expires_after_seconds: Option, /// Serve /health, /ready and /metrics on this port, the way /// `weed worker -metricsPort` does. Zero, the default, serves nothing. @@ -137,7 +139,15 @@ async fn main() -> Result<()> { let args = Args::parse(); let tls = args.tls()?; - let signing_key = weed_lance_worker::catalog::SigningKey::new(args.filer_signing_key.clone()); + let filer_signing = match args.filer_signing_key.filter(|key| !key.is_empty()) { + Some(key) => Some((key, 0)), + None => seaweed_worker_core::security_config::filer_signing().map(|signing| { + tracing::info!(source = %signing.source, "using jwt.filer_signing.key"); + (signing.key, signing.expires_after_seconds) + }), + }; + let (filer_signing_key, configured_expiry) = filer_signing.unzip(); + let signing_key = weed_lance_worker::catalog::SigningKey::new(filer_signing_key.clone()); let options = WorkerOptions { admin_address: args.admin, worker_id: args.id, @@ -145,8 +155,11 @@ async fn main() -> Result<()> { max_detection_concurrency: args.max_concurrency, max_execution_concurrency: args.max_concurrency, tls, - signing_key: args.filer_signing_key, - signing_key_expires_after_seconds: args.filer_signing_key_expires_after_seconds, + signing_key: filer_signing_key, + signing_key_expires_after_seconds: args + .filer_signing_key_expires_after_seconds + .or(configured_expiry) + .unwrap_or(0), ..Default::default() };