mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-04 11:07:35 +01:00
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:
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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+62
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user