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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* weed-worker: list /usr/local/etc/seaweedfs in the security.toml search path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* weed-worker: test where filer_signing looks for security.toml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-worker: read a float expires_after_seconds as viper's GetInt does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* weed-worker: cap the minted exp so a huge expiry cannot overflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-03 14:49:39 -08:00
1 parent de4a71665e
commit 9b5f7bf65b
11 files changed
+470 -50

No files matched your search

@@ -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
+3
View File
@@ -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;
+79
View File
@@ -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<PathBuf> {
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<PathBuf> {
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<OsString>) -> Option<PathBuf> {
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);
}
}
+5 -34
View File
@@ -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<String>,
}
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<PathBu
return Some(PathBuf::from(path));
}
default_security_config_candidates(
seaweed_common::security_config::find(
std::env::current_dir().ok().as_deref(),
home_dir_from_env(env).as_deref(),
seaweed_common::security_config::home_dir(env).as_deref(),
)
.into_iter()
.find(|candidate| candidate.is_file())
}
fn default_security_config_candidates(
current_dir: Option<&Path>,
home_dir: Option<&Path>,
) -> Vec<PathBuf> {
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<PathBuf> {
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
+62 -1
View File
@@ -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"
+6 -3
View File
@@ -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
+5
View File
@@ -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
+1
View File
@@ -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;
@@ -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<FilerSigning> {
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<OsString>,
current_dir: Option<&Path>,
) -> Option<FilerSigning> {
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<OsString>,
file: Option<&Path>,
) -> Option<FilerSigning> {
// 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<String>, Option<u64>) {
// 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::<toml::Table>()
.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<OsString> {
let map: HashMap<String, OsString> = 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);
}
}
+15 -2
View File
@@ -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<String
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
+ expires_after_secs;
.saturating_add(expires_after_secs)
.min(MAX_EXP_SECS);
format!("{{\"exp\":{exp}}}")
} else {
"{}".to_string()
@@ -42,7 +46,7 @@ pub fn derive_key(signing_key: &[u8], purpose: &str) -> Vec<u8> {
#[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());
}
}
+23 -10
View File
@@ -61,18 +61,20 @@ struct Args {
#[arg(long)]
tls_server_name: Option<String>,
/// 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<String>,
/// 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<u64>,
/// 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()
};