snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
This commit is contained in:
120
weed/security/certreload/certreload.go
Normal file
120
weed/security/certreload/certreload.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Package certreload wraps grpc's pemfile.Provider so both TLS servers
|
||||
// (weed/security) and TLS clients (weed/util/http/client) can share one
|
||||
// reloading cert implementation without an import cycle between them.
|
||||
//
|
||||
// Lives in its own subpackage because weed/security already imports
|
||||
// weed/util/http/client (for LoadHTTPClientFromFile).
|
||||
package certreload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/credentials/tls/certprovider"
|
||||
"google.golang.org/grpc/credentials/tls/certprovider/pemfile"
|
||||
)
|
||||
|
||||
// RefreshIntervalEnv names an environment variable that overrides the
|
||||
// refresh cadence. Accepts any time.ParseDuration value (e.g. "30m",
|
||||
// "500ms"). Primarily a hook for integration tests that need rotation
|
||||
// to complete in seconds, but also useful in production when paired
|
||||
// with short-lived certs (e.g. Vault-issued).
|
||||
const RefreshIntervalEnv = "WEED_TLS_CERT_REFRESH_INTERVAL"
|
||||
|
||||
// DefaultRefreshInterval is the cadence at which the pemfile provider
|
||||
// stats cert/key files on disk. It re-parses only when mtime/contents
|
||||
// change, so the hot path (KeyMaterial() on each TLS handshake) stays
|
||||
// cheap.
|
||||
//
|
||||
// 5 hours matches the prior constant used for gRPC mTLS. Resolved once
|
||||
// at process start from RefreshIntervalEnv if set.
|
||||
var DefaultRefreshInterval = resolveRefreshInterval(5 * time.Hour)
|
||||
|
||||
func resolveRefreshInterval(fallback time.Duration) time.Duration {
|
||||
if s := os.Getenv(RefreshIntervalEnv); s != "" {
|
||||
if d, err := time.ParseDuration(s); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// NewServerGetCertificate returns a callback suitable for
|
||||
// tls.Config.GetCertificate. It reloads certFile and keyFile from disk
|
||||
// on each refresh tick so rotated certs (e.g. from k8s cert-manager)
|
||||
// are picked up without a restart. Caller should Close() the returned
|
||||
// provider at shutdown.
|
||||
func NewServerGetCertificate(certFile, keyFile string) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), certprovider.Provider, error) {
|
||||
return newServerGetCertificate(certFile, keyFile, DefaultRefreshInterval)
|
||||
}
|
||||
|
||||
func newServerGetCertificate(certFile, keyFile string, refresh time.Duration) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), certprovider.Provider, error) {
|
||||
provider, err := newProvider(certFile, keyFile, refresh)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
get := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
// KeyMaterial blocks until the pemfile provider has read the files
|
||||
// for the first time. Use the handshake context so a stuck read
|
||||
// bounds the handshake instead of hanging it forever.
|
||||
ctx := context.Background()
|
||||
if hello != nil {
|
||||
ctx = hello.Context()
|
||||
}
|
||||
return current(ctx, provider, certFile)
|
||||
}
|
||||
return get, provider, nil
|
||||
}
|
||||
|
||||
// NewClientGetCertificate returns a callback suitable for
|
||||
// tls.Config.GetClientCertificate. Fires per TLS handshake, so long-lived
|
||||
// HTTPS clients (FUSE mount, backup, filer→volume, etc.) pick up rotated
|
||||
// client mTLS certs as pooled connections recycle.
|
||||
func NewClientGetCertificate(certFile, keyFile string) (func(*tls.CertificateRequestInfo) (*tls.Certificate, error), certprovider.Provider, error) {
|
||||
return newClientGetCertificate(certFile, keyFile, DefaultRefreshInterval)
|
||||
}
|
||||
|
||||
func newClientGetCertificate(certFile, keyFile string, refresh time.Duration) (func(*tls.CertificateRequestInfo) (*tls.Certificate, error), certprovider.Provider, error) {
|
||||
provider, err := newProvider(certFile, keyFile, refresh)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
get := func(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
||||
ctx := context.Background()
|
||||
if cri != nil {
|
||||
ctx = cri.Context()
|
||||
}
|
||||
return current(ctx, provider, certFile)
|
||||
}
|
||||
return get, provider, nil
|
||||
}
|
||||
|
||||
func newProvider(certFile, keyFile string, refresh time.Duration) (certprovider.Provider, error) {
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, fmt.Errorf("both certFile and keyFile are required")
|
||||
}
|
||||
provider, err := pemfile.NewProvider(pemfile.Options{
|
||||
CertFile: certFile,
|
||||
KeyFile: keyFile,
|
||||
RefreshDuration: refresh,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pemfile.NewProvider: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func current(ctx context.Context, provider certprovider.Provider, certFile string) (*tls.Certificate, error) {
|
||||
km, err := provider.KeyMaterial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if km == nil || len(km.Certs) == 0 {
|
||||
return nil, fmt.Errorf("no TLS key material available for %s", certFile)
|
||||
}
|
||||
cert := km.Certs[0]
|
||||
return &cert, nil
|
||||
}
|
||||
122
weed/security/certreload/certreload_test.go
Normal file
122
weed/security/certreload/certreload_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package certreload
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestServerGetCertificatePicksUpNewFile writes an initial cert/key
|
||||
// pair, starts the reloader, then overwrites the files with a new pair
|
||||
// and asserts that the callback eventually returns the new certificate.
|
||||
func TestServerGetCertificatePicksUpNewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
|
||||
if err := writeSelfSigned(certPath, keyPath, "first"); err != nil {
|
||||
t.Fatalf("write first cert: %v", err)
|
||||
}
|
||||
|
||||
// Use a short refresh window so the test doesn't wait 5h.
|
||||
getCert, provider, err := newServerGetCertificate(certPath, keyPath, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("newServerGetCertificate: %v", err)
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
c1, err := getCert(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("initial getCert: %v", err)
|
||||
}
|
||||
first, err := x509.ParseCertificate(c1.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("parse first cert: %v", err)
|
||||
}
|
||||
if first.Subject.CommonName != "first" {
|
||||
t.Fatalf("want CN=first, got %q", first.Subject.CommonName)
|
||||
}
|
||||
|
||||
// Rotate the files. pemfile watches mtime; sleep so the new files have
|
||||
// a later mtime even on filesystems with 1s granularity.
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
if err := writeSelfSigned(certPath, keyPath, "second"); err != nil {
|
||||
t.Fatalf("write second cert: %v", err)
|
||||
}
|
||||
|
||||
// The pemfile poller can briefly observe a mismatched cert/key pair
|
||||
// during rotation (cert written before key). Tolerate transient errors
|
||||
// and only fail at the deadline; keep the last error for diagnostics.
|
||||
var lastErr error
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c2, err := getCert(nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
parsed, err := x509.ParseCertificate(c2.Certificate[0])
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if parsed.Subject.CommonName == "second" {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("reloader did not pick up rotated cert within timeout; last error: %v", lastErr)
|
||||
}
|
||||
|
||||
func writeSelfSigned(certPath, keyPath, commonName string) error {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certOut, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
|
||||
certOut.Close()
|
||||
return err
|
||||
}
|
||||
if err := certOut.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyOut, err := os.Create(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
|
||||
keyOut.Close()
|
||||
return err
|
||||
}
|
||||
return keyOut.Close()
|
||||
}
|
||||
193
weed/security/guard.go
Normal file
193
weed/security/guard.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized token")
|
||||
)
|
||||
|
||||
/*
|
||||
Guard is to ensure data access security.
|
||||
There are 2 ways to check access:
|
||||
1. white list. It's checking request ip address.
|
||||
2. JSON Web Token(JWT) generated from secretKey.
|
||||
The jwt can come from:
|
||||
1. url parameter jwt=...
|
||||
2. request header "Authorization"
|
||||
3. cookie with the name "jwt"
|
||||
|
||||
The white list is checked first because it is easy.
|
||||
Then the JWT is checked.
|
||||
|
||||
The Guard will also check these claims if provided:
|
||||
1. "exp" Expiration Time
|
||||
2. "nbf" Not Before
|
||||
|
||||
Generating JWT:
|
||||
1. use HS256 to sign
|
||||
2. optionally set "exp", "nbf" fields, in Unix time,
|
||||
the number of seconds elapsed since January 1, 1970 UTC.
|
||||
|
||||
Referenced:
|
||||
https://github.com/pkieltyka/jwtauth/blob/master/jwtauth.go
|
||||
*/
|
||||
// guardState is the immutable snapshot of all hot-reloadable Guard state. The
|
||||
// Update* methods build a new snapshot from the current one and swap it in
|
||||
// atomically, so request-path readers (WhiteList, IsWhiteListed, the SigningKey
|
||||
// accessors) always observe a consistent set of keys and whitelist — never a
|
||||
// torn slice header or a mix of old and new state across a SIGHUP.
|
||||
type guardState struct {
|
||||
signingKey SigningKey
|
||||
expiresAfterSec int
|
||||
readSigningKey SigningKey
|
||||
readExpiresAfterSec int
|
||||
|
||||
whiteListIp map[string]struct{}
|
||||
whiteListCIDR map[string]*net.IPNet
|
||||
isWriteActive bool
|
||||
isEmptyWhiteList bool
|
||||
}
|
||||
|
||||
type Guard struct {
|
||||
// state is swapped atomically by the Update* methods. Read it via Load.
|
||||
state atomic.Pointer[guardState]
|
||||
// updateMu serializes the read-modify-write inside the Update* methods so
|
||||
// concurrent reloads don't clobber each other; readers stay lock-free.
|
||||
updateMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewGuard(whiteList []string, signingKey string, expiresAfterSec int, readSigningKey string, readExpiresAfterSec int) *Guard {
|
||||
g := &Guard{}
|
||||
g.state.Store(&guardState{
|
||||
signingKey: SigningKey(signingKey),
|
||||
expiresAfterSec: expiresAfterSec,
|
||||
readSigningKey: SigningKey(readSigningKey),
|
||||
readExpiresAfterSec: readExpiresAfterSec,
|
||||
})
|
||||
g.UpdateWhiteList(whiteList)
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Guard) SigningKey() SigningKey { return g.state.Load().signingKey }
|
||||
func (g *Guard) ExpiresAfterSec() int { return g.state.Load().expiresAfterSec }
|
||||
func (g *Guard) ReadSigningKey() SigningKey { return g.state.Load().readSigningKey }
|
||||
func (g *Guard) ReadExpiresAfterSec() int { return g.state.Load().readExpiresAfterSec }
|
||||
|
||||
func (g *Guard) WhiteList(f http.HandlerFunc) http.HandlerFunc {
|
||||
if !g.state.Load().isWriteActive {
|
||||
//if no security needed, just skip all checking
|
||||
return f
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := g.checkWhiteList(w, r); err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
f(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func GetActualRemoteHost(r *http.Request) string {
|
||||
// For security reasons, only use RemoteAddr to determine the client's IP address.
|
||||
// Do not trust headers like X-Forwarded-For, as they can be easily spoofed by clients.
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
|
||||
// If SplitHostPort fails, it may be because of a missing port.
|
||||
// We try to parse RemoteAddr as a raw host (IP or hostname).
|
||||
host = strings.TrimSpace(r.RemoteAddr)
|
||||
// It might be an IPv6 address without a port, but with brackets.
|
||||
// e.g. "[::1]"
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
|
||||
// Return the host (can be IP or hostname, just like headers)
|
||||
return host
|
||||
}
|
||||
|
||||
func (g *Guard) checkWhiteList(w http.ResponseWriter, r *http.Request) error {
|
||||
host := GetActualRemoteHost(r)
|
||||
if g.IsWhiteListed(host) {
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("Not in whitelist: %s (original RemoteAddr: %s)", host, r.RemoteAddr)
|
||||
return fmt.Errorf("Not in whitelist: %s", host)
|
||||
}
|
||||
|
||||
// IsWhiteListed returns true if the given host IP is allowed by the guard.
|
||||
// When no whitelist is configured (security inactive), all hosts are allowed.
|
||||
func (g *Guard) IsWhiteListed(host string) bool {
|
||||
st := g.state.Load()
|
||||
if !st.isWriteActive {
|
||||
return true
|
||||
}
|
||||
if st.isEmptyWhiteList {
|
||||
return true
|
||||
}
|
||||
if _, ok := st.whiteListIp[host]; ok {
|
||||
return true
|
||||
}
|
||||
remote := net.ParseIP(host)
|
||||
if remote != nil {
|
||||
for _, cidrnet := range st.whiteListCIDR {
|
||||
if cidrnet.Contains(remote) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateSigningKeys refreshes the JWT signing keys and their expirations so
|
||||
// operators can rotate keys (e.g. via SIGHUP) without restarting the process.
|
||||
// It swaps in a new snapshot carrying the existing whitelist, so a concurrent
|
||||
// reader sees either the old keys or the new ones, never a torn slice header.
|
||||
func (g *Guard) UpdateSigningKeys(signingKey string, expiresAfterSec int, readSigningKey string, readExpiresAfterSec int) {
|
||||
g.updateMu.Lock()
|
||||
defer g.updateMu.Unlock()
|
||||
next := *g.state.Load()
|
||||
next.signingKey = SigningKey(signingKey)
|
||||
next.expiresAfterSec = expiresAfterSec
|
||||
next.readSigningKey = SigningKey(readSigningKey)
|
||||
next.readExpiresAfterSec = readExpiresAfterSec
|
||||
next.isWriteActive = !next.isEmptyWhiteList || len(next.signingKey) != 0
|
||||
g.state.Store(&next)
|
||||
}
|
||||
|
||||
func (g *Guard) UpdateWhiteList(whiteList []string) {
|
||||
whiteListIp := make(map[string]struct{})
|
||||
whiteListCIDR := make(map[string]*net.IPNet)
|
||||
for _, ip := range whiteList {
|
||||
if strings.Contains(ip, "/") {
|
||||
_, cidrnet, err := net.ParseCIDR(ip)
|
||||
if err != nil {
|
||||
glog.Errorf("Parse CIDR %s in whitelist failed: %v", ip, err)
|
||||
continue
|
||||
}
|
||||
whiteListCIDR[ip] = cidrnet
|
||||
} else {
|
||||
whiteListIp[ip] = struct{}{}
|
||||
}
|
||||
}
|
||||
g.updateMu.Lock()
|
||||
defer g.updateMu.Unlock()
|
||||
next := *g.state.Load()
|
||||
next.isEmptyWhiteList = len(whiteListIp) == 0 && len(whiteListCIDR) == 0
|
||||
next.isWriteActive = !next.isEmptyWhiteList || len(next.signingKey) != 0
|
||||
next.whiteListIp = whiteListIp
|
||||
next.whiteListCIDR = whiteListCIDR
|
||||
g.state.Store(&next)
|
||||
}
|
||||
81
weed/security/guard_signing_key_test.go
Normal file
81
weed/security/guard_signing_key_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUpdateSigningKeysRotates pins the hot-reload contract: after a key
|
||||
// rotation the guard validates tokens minted with the new key and rejects the
|
||||
// old one, so a SIGHUP recovers from a key mismatch without a process restart.
|
||||
func TestUpdateSigningKeysRotates(t *testing.T) {
|
||||
g := NewGuard(nil, "old-write", 10, "old-read", 60)
|
||||
|
||||
g.UpdateSigningKeys("new-write", 11, "new-read", 61)
|
||||
|
||||
if string(g.SigningKey()) != "new-write" || g.ExpiresAfterSec() != 11 {
|
||||
t.Fatalf("write key not refreshed: key=%q exp=%d", g.SigningKey(), g.ExpiresAfterSec())
|
||||
}
|
||||
if string(g.ReadSigningKey()) != "new-read" || g.ReadExpiresAfterSec() != 61 {
|
||||
t.Fatalf("read key not refreshed: key=%q exp=%d", g.ReadSigningKey(), g.ReadExpiresAfterSec())
|
||||
}
|
||||
|
||||
tok := GenJwtForVolumeServer(g.SigningKey(), g.ExpiresAfterSec(), "3,01637037d6")
|
||||
if _, err := DecodeJwt(g.SigningKey(), tok, &SeaweedFileIdClaims{}); err != nil {
|
||||
t.Fatalf("token minted with rotated key should verify: %v", err)
|
||||
}
|
||||
if _, err := DecodeJwt(SigningKey("old-write"), tok, &SeaweedFileIdClaims{}); err == nil {
|
||||
t.Fatalf("token minted with rotated key must not verify against the old key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSigningKeysConcurrent rotates keys and whitelist while readers mint
|
||||
// and verify tokens and check whitelist membership, so `go test -race` would
|
||||
// flag any torn read of the signing-key slice header or the whitelist maps.
|
||||
// Each key rotation uses matching keys so every reader's token validates.
|
||||
func TestUpdateSigningKeysConcurrent(t *testing.T) {
|
||||
g := NewGuard([]string{"10.0.0.1"}, "k0", 60, "k0", 60)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 8; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 1000; i++ {
|
||||
key := g.SigningKey()
|
||||
tok := GenJwtForVolumeServer(key, g.ExpiresAfterSec(), "3,01637037d6")
|
||||
if _, err := DecodeJwt(key, tok, &SeaweedFileIdClaims{}); err != nil {
|
||||
t.Errorf("token should verify against the snapshot it was minted from: %v", err)
|
||||
return
|
||||
}
|
||||
g.IsWhiteListed("10.0.0.1")
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 1000; i++ {
|
||||
g.UpdateSigningKeys("kw", 60, "kr", 60)
|
||||
g.UpdateSigningKeys("kw2", 60, "kr2", 60)
|
||||
g.UpdateWhiteList([]string{"10.0.0.1", "192.168.0.0/16"})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestUpdateSigningKeysTogglesWriteActive guards the isWriteActive recompute:
|
||||
// adding a signing key to an otherwise-open guard must activate auth, and
|
||||
// clearing it again must deactivate.
|
||||
func TestUpdateSigningKeysTogglesWriteActive(t *testing.T) {
|
||||
g := NewGuard(nil, "", 0, "", 0)
|
||||
if g.state.Load().isWriteActive {
|
||||
t.Fatalf("no whitelist and no key should be inactive")
|
||||
}
|
||||
|
||||
g.UpdateSigningKeys("write", 10, "", 0)
|
||||
if !g.state.Load().isWriteActive {
|
||||
t.Fatalf("setting a signing key should activate auth")
|
||||
}
|
||||
|
||||
g.UpdateSigningKeys("", 0, "", 0)
|
||||
if g.state.Load().isWriteActive {
|
||||
t.Fatalf("clearing the signing key should deactivate auth")
|
||||
}
|
||||
}
|
||||
50
weed/security/guard_whitelist_test.go
Normal file
50
weed/security/guard_whitelist_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsWhiteListedEmptyConfigAllowsEverything pins the contract that the
|
||||
// gRPC admin auth helper relies on: when no whitelist and no signing key
|
||||
// are configured, IsWhiteListed accepts any host (including the empty
|
||||
// string and unparseable peer addresses like the gRPC "@" passthrough
|
||||
// form). Otherwise insecure default deployments would lock themselves out
|
||||
// of every destructive admin RPC.
|
||||
func TestIsWhiteListedEmptyConfigAllowsEverything(t *testing.T) {
|
||||
g := NewGuard(nil, "", 0, "", 0)
|
||||
for _, host := range []string{"", "@", "127.0.0.1", "::1", "garbage:value"} {
|
||||
if !g.IsWhiteListed(host) {
|
||||
t.Errorf("empty config should accept host=%q, got false", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWhiteListedWithListRejectsUnknown(t *testing.T) {
|
||||
g := NewGuard([]string{"10.0.0.1", "192.168.1.0/24"}, "", 0, "", 0)
|
||||
cases := []struct {
|
||||
host string
|
||||
want bool
|
||||
}{
|
||||
{"10.0.0.1", true},
|
||||
{"192.168.1.42", true},
|
||||
{"192.168.2.1", false},
|
||||
{"127.0.0.1", false},
|
||||
{"", false},
|
||||
{"@", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := g.IsWhiteListed(tc.host); got != tc.want {
|
||||
t.Errorf("IsWhiteListed(%q) = %v want %v", tc.host, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWhiteListedWithSigningKeyButNoWhitelistAllowsAll(t *testing.T) {
|
||||
// JWT-only mode: signing key set, whitelist empty. Without this branch
|
||||
// the gRPC admin auth helper would falsely deny in-process callers when
|
||||
// the operator wires up signing keys but hasn't enumerated IPs.
|
||||
g := NewGuard(nil, "deadbeef", 0, "", 0)
|
||||
for _, host := range []string{"", "@", "127.0.0.1"} {
|
||||
if !g.IsWhiteListed(host) {
|
||||
t.Errorf("signing-key + empty whitelist should accept host=%q", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
147
weed/security/jwt.go
Normal file
147
weed/security/jwt.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
type EncodedJwt string
|
||||
type SigningKey []byte
|
||||
|
||||
// BearerPrefix is the RFC 6750 Authorization header scheme prefix for bearer
|
||||
// tokens. Used when constructing "Authorization: Bearer <token>" headers; the
|
||||
// scheme name itself is matched case-insensitively when parsing (see GetJwt).
|
||||
const BearerPrefix = "Bearer "
|
||||
|
||||
// SeaweedFileIdClaims is created by Master server(s) and consumed by Volume server(s),
|
||||
// restricting the access this JWT allows to only a single file.
|
||||
type SeaweedFileIdClaims struct {
|
||||
Fid string `json:"fid"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// SeaweedFilerClaims is created e.g. by S3 proxy server and consumed by Filer server.
|
||||
// Right now, it only contains the standard claims; but this might be extended later
|
||||
// for more fine-grained permissions.
|
||||
type SeaweedFilerClaims struct {
|
||||
AllowedPrefixes []string `json:"allowed_prefixes,omitempty"`
|
||||
AllowedMethods []string `json:"allowed_methods,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// SeaweedFilerAdminClaims is presented by callers of the filer's IAM gRPC
|
||||
// service to prove they are authorised to administer users, access keys, and
|
||||
// policies. The token is signed with the filer write-signing key
|
||||
// (jwt.filer_signing.key in security.toml).
|
||||
//
|
||||
// Validation is delegated to DecodeJwt below: it enforces HS256 via the
|
||||
// SigningMethodHMAC type check, and jwt/v5 validates exp/nbf on the embedded
|
||||
// RegisteredClaims. Extra JSON fields in the payload are silently ignored by
|
||||
// encoding/json, which is the desired behaviour here (forward-compat).
|
||||
type SeaweedFilerAdminClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenJwtForFilerAdmin mints a Bearer token for the filer IAM gRPC service.
|
||||
// Returns an empty string if the signing key is not configured.
|
||||
func GenJwtForFilerAdmin(signingKey SigningKey, expiresAfterSec int) EncodedJwt {
|
||||
if len(signingKey) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
claims := SeaweedFilerAdminClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{},
|
||||
}
|
||||
if expiresAfterSec > 0 {
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec)))
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
encoded, e := t.SignedString([]byte(signingKey))
|
||||
if e != nil {
|
||||
glog.V(0).Infof("Failed to sign claims %+v: %v", t.Claims, e)
|
||||
return ""
|
||||
}
|
||||
return EncodedJwt(encoded)
|
||||
}
|
||||
|
||||
func GenJwtForVolumeServer(signingKey SigningKey, expiresAfterSec int, fileId string) EncodedJwt {
|
||||
if len(signingKey) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
claims := SeaweedFileIdClaims{
|
||||
fileId,
|
||||
jwt.RegisteredClaims{},
|
||||
}
|
||||
if expiresAfterSec > 0 {
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec)))
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
encoded, e := t.SignedString([]byte(signingKey))
|
||||
if e != nil {
|
||||
glog.V(0).Infof("Failed to sign claims %+v: %v", t.Claims, e)
|
||||
return ""
|
||||
}
|
||||
return EncodedJwt(encoded)
|
||||
}
|
||||
|
||||
// GenJwtForFilerServer creates a JSON-web-token for using the authenticated Filer API. Used f.e. inside
|
||||
// the S3 API
|
||||
func GenJwtForFilerServer(signingKey SigningKey, expiresAfterSec int) EncodedJwt {
|
||||
if len(signingKey) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
claims := SeaweedFilerClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{},
|
||||
}
|
||||
if expiresAfterSec > 0 {
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec)))
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
encoded, e := t.SignedString([]byte(signingKey))
|
||||
if e != nil {
|
||||
glog.V(0).Infof("Failed to sign claims %+v: %v", t.Claims, e)
|
||||
return ""
|
||||
}
|
||||
return EncodedJwt(encoded)
|
||||
}
|
||||
|
||||
func GetJwt(r *http.Request) EncodedJwt {
|
||||
|
||||
// Get token from query params
|
||||
tokenStr := r.URL.Query().Get("jwt")
|
||||
|
||||
// Get token from authorization header
|
||||
if tokenStr == "" {
|
||||
bearer := r.Header.Get("Authorization")
|
||||
if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" {
|
||||
tokenStr = bearer[7:]
|
||||
}
|
||||
}
|
||||
|
||||
// Get token from http only cookie
|
||||
if tokenStr == "" {
|
||||
token, err := r.Cookie("AT")
|
||||
if err == nil {
|
||||
tokenStr = token.Value
|
||||
}
|
||||
}
|
||||
|
||||
return EncodedJwt(tokenStr)
|
||||
}
|
||||
|
||||
func DecodeJwt(signingKey SigningKey, tokenString EncodedJwt, claims jwt.Claims) (token *jwt.Token, err error) {
|
||||
// check exp, nbf
|
||||
return jwt.ParseWithClaims(string(tokenString), claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unknown token method")
|
||||
}
|
||||
return []byte(signingKey), nil
|
||||
})
|
||||
}
|
||||
339
weed/security/tls.go
Normal file
339
weed/security/tls.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security/certreload"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
util_http_client "github.com/seaweedfs/seaweedfs/weed/util/http/client"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/credentials/tls/certprovider/pemfile"
|
||||
"google.golang.org/grpc/security/advancedtls"
|
||||
)
|
||||
|
||||
// CredRefreshingInterval is the refresh cadence for gRPC mTLS certs.
|
||||
// Shares its source of truth with certreload.DefaultRefreshInterval so
|
||||
// a single WEED_TLS_CERT_REFRESH_INTERVAL env var tunes both gRPC and
|
||||
// HTTPS cert reload.
|
||||
var CredRefreshingInterval = certreload.DefaultRefreshInterval
|
||||
|
||||
type Authenticator struct {
|
||||
AllowedWildcardDomain string
|
||||
AllowedCommonNames map[string]bool
|
||||
}
|
||||
|
||||
// SNIStrippingTransportCredentials wraps another TransportCredentials
|
||||
// and strips the port from the authority in ClientHandshake to prevent
|
||||
// advancedtls from using the full "host:port" as ServerName in SNI.
|
||||
type SNIStrippingTransportCredentials struct {
|
||||
creds credentials.TransportCredentials
|
||||
}
|
||||
|
||||
func (s *SNIStrippingTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
host, _, err := net.SplitHostPort(authority)
|
||||
if err == nil {
|
||||
authority = host
|
||||
}
|
||||
return s.creds.ClientHandshake(ctx, authority, rawConn)
|
||||
}
|
||||
|
||||
func (s *SNIStrippingTransportCredentials) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return s.creds.ServerHandshake(rawConn)
|
||||
}
|
||||
|
||||
func (s *SNIStrippingTransportCredentials) Info() credentials.ProtocolInfo {
|
||||
return s.creds.Info()
|
||||
}
|
||||
|
||||
func (s *SNIStrippingTransportCredentials) Clone() credentials.TransportCredentials {
|
||||
return &SNIStrippingTransportCredentials{creds: s.creds.Clone()}
|
||||
}
|
||||
|
||||
func (s *SNIStrippingTransportCredentials) OverrideServerName(serverNameOverride string) error {
|
||||
return s.creds.OverrideServerName(serverNameOverride)
|
||||
}
|
||||
|
||||
func LoadServerTLS(config *util.ViperProxy, component string) (grpc.ServerOption, grpc.ServerOption) {
|
||||
if config == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
serverOptions := pemfile.Options{
|
||||
CertFile: config.GetString(component + ".cert"),
|
||||
KeyFile: config.GetString(component + ".key"),
|
||||
RefreshDuration: CredRefreshingInterval,
|
||||
}
|
||||
if serverOptions.CertFile == "" || serverOptions.KeyFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
serverIdentityProvider, err := pemfile.NewProvider(serverOptions)
|
||||
if err != nil {
|
||||
glog.Warningf("pemfile.NewProvider(%v) %v failed: %v", serverOptions, component, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
serverRootOptions := pemfile.Options{
|
||||
RootFile: config.GetString("grpc.ca"),
|
||||
RefreshDuration: CredRefreshingInterval,
|
||||
}
|
||||
serverRootProvider, err := pemfile.NewProvider(serverRootOptions)
|
||||
if err != nil {
|
||||
glog.Warningf("pemfile.NewProvider(%v) failed: %v", serverRootOptions, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Start a server and create a client using advancedtls API with Provider.
|
||||
options := &advancedtls.Options{
|
||||
IdentityOptions: advancedtls.IdentityCertificateOptions{
|
||||
IdentityProvider: serverIdentityProvider,
|
||||
},
|
||||
RootOptions: advancedtls.RootCertificateOptions{
|
||||
RootProvider: serverRootProvider,
|
||||
},
|
||||
RequireClientCert: true,
|
||||
VerificationType: advancedtls.CertVerification,
|
||||
}
|
||||
options.MinTLSVersion, err = TlsVersionByName(config.GetString("tls.min_version"))
|
||||
if err != nil {
|
||||
glog.Warningf("tls min version parse failed, %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
options.MaxTLSVersion, err = TlsVersionByName(config.GetString("tls.max_version"))
|
||||
if err != nil {
|
||||
glog.Warningf("tls max version parse failed, %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
options.CipherSuites, err = TlsCipherSuiteByNames(config.GetString("tls.cipher_suites"))
|
||||
if err != nil {
|
||||
glog.Warningf("tls cipher suite parse failed, %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
allowedCommonNames := config.GetString(component + ".allowed_commonNames")
|
||||
allowedWildcardDomain := config.GetString("grpc.allowed_wildcard_domain")
|
||||
if allowedCommonNames != "" || allowedWildcardDomain != "" {
|
||||
allowedCommonNamesMap := make(map[string]bool)
|
||||
for _, s := range strings.Split(allowedCommonNames, ",") {
|
||||
allowedCommonNamesMap[s] = true
|
||||
}
|
||||
auther := Authenticator{
|
||||
AllowedCommonNames: allowedCommonNamesMap,
|
||||
AllowedWildcardDomain: allowedWildcardDomain,
|
||||
}
|
||||
options.AdditionalPeerVerification = auther.Authenticate
|
||||
} else {
|
||||
options.AdditionalPeerVerification = func(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
|
||||
return &advancedtls.PostHandshakeVerificationResults{}, nil
|
||||
}
|
||||
}
|
||||
ta, err := advancedtls.NewServerCreds(options)
|
||||
if err != nil {
|
||||
glog.Warningf("advancedtls.NewServerCreds(%v) failed: %v", options, err)
|
||||
return nil, nil
|
||||
}
|
||||
return grpc.Creds(ta), nil
|
||||
}
|
||||
|
||||
func LoadClientTLSFromFile(configFile string, component string) (grpc.DialOption, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configFile)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read security config %s: %v", configFile, err)
|
||||
}
|
||||
// Resolve relative PEM paths against the config file's directory.
|
||||
configDir := filepath.Dir(configFile)
|
||||
for _, key := range []string{"grpc.ca", component + ".cert", component + ".key", component + ".client_cert", component + ".client_key"} {
|
||||
p := v.GetString(key)
|
||||
if p != "" && !filepath.IsAbs(p) {
|
||||
v.Set(key, filepath.Join(configDir, p))
|
||||
}
|
||||
}
|
||||
return LoadClientTLS(&util.ViperProxy{Viper: v}, component), nil
|
||||
}
|
||||
|
||||
func LoadClientTLS(config *util.ViperProxy, component string) grpc.DialOption {
|
||||
if config == nil {
|
||||
return grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
}
|
||||
|
||||
// prefer a dedicated client certificate: CAs may issue certs with only one of the serverAuth/clientAuth EKUs
|
||||
certFileName, keyFileName := config.GetString(component+".client_cert"), config.GetString(component+".client_key")
|
||||
if certFileName == "" || keyFileName == "" {
|
||||
if certFileName != "" || keyFileName != "" {
|
||||
glog.Warningf("%s.client_cert and %s.client_key must both be set, falling back to %s.cert and %s.key", component, component, component, component)
|
||||
}
|
||||
certFileName, keyFileName = config.GetString(component+".cert"), config.GetString(component+".key")
|
||||
}
|
||||
caFileName := config.GetString("grpc.ca")
|
||||
if certFileName == "" || keyFileName == "" || caFileName == "" {
|
||||
return grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
}
|
||||
|
||||
clientOptions := pemfile.Options{
|
||||
CertFile: certFileName,
|
||||
KeyFile: keyFileName,
|
||||
RefreshDuration: CredRefreshingInterval,
|
||||
}
|
||||
clientProvider, err := pemfile.NewProvider(clientOptions)
|
||||
if err != nil {
|
||||
glog.Warningf("pemfile.NewProvider(%v) failed %v", clientOptions, err)
|
||||
return grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
}
|
||||
clientRootOptions := pemfile.Options{
|
||||
RootFile: config.GetString("grpc.ca"),
|
||||
RefreshDuration: CredRefreshingInterval,
|
||||
}
|
||||
clientRootProvider, err := pemfile.NewProvider(clientRootOptions)
|
||||
if err != nil {
|
||||
glog.Warningf("pemfile.NewProvider(%v) failed: %v", clientRootOptions, err)
|
||||
return grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
}
|
||||
options := &advancedtls.Options{
|
||||
IdentityOptions: advancedtls.IdentityCertificateOptions{
|
||||
IdentityProvider: clientProvider,
|
||||
},
|
||||
AdditionalPeerVerification: func(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
|
||||
return &advancedtls.PostHandshakeVerificationResults{}, nil
|
||||
},
|
||||
RootOptions: advancedtls.RootCertificateOptions{
|
||||
RootProvider: clientRootProvider,
|
||||
},
|
||||
VerificationType: advancedtls.CertVerification,
|
||||
}
|
||||
ta, err := advancedtls.NewClientCreds(options)
|
||||
if err != nil {
|
||||
glog.Warningf("advancedtls.NewClientCreds(%v) failed: %v", options, err)
|
||||
return grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
}
|
||||
wrapped := &SNIStrippingTransportCredentials{creds: ta}
|
||||
return grpc.WithTransportCredentials(wrapped)
|
||||
}
|
||||
|
||||
// LoadHTTPClientFromFile creates an HTTP client using the https.client TLS
|
||||
// settings from the given security config file. Returns nil if HTTPS is not
|
||||
// enabled in the config. This is used by filer.sync to create per-cluster
|
||||
// HTTP clients when clusters use different certificates.
|
||||
func LoadHTTPClientFromFile(configFile string) (*util_http_client.HTTPClient, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configFile)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read security config %s: %v", configFile, err)
|
||||
}
|
||||
|
||||
if !v.GetBool("https.client.enabled") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(configFile)
|
||||
resolvePath := func(key string) string {
|
||||
p := v.GetString(key)
|
||||
if p != "" && !filepath.IsAbs(p) {
|
||||
return filepath.Join(configDir, p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
return util_http_client.NewHttpClientWithTLS(
|
||||
resolvePath("https.client.cert"),
|
||||
resolvePath("https.client.key"),
|
||||
resolvePath("https.client.ca"),
|
||||
v.GetBool("https.client.insecure_skip_verify"),
|
||||
util_http_client.AddDialContext,
|
||||
)
|
||||
}
|
||||
|
||||
func LoadClientTLSHTTP(clientCertFile string) *tls.Config {
|
||||
clientCerts, err := os.ReadFile(clientCertFile)
|
||||
if err != nil {
|
||||
glog.Fatal(err)
|
||||
}
|
||||
certPool := x509.NewCertPool()
|
||||
ok := certPool.AppendCertsFromPEM(clientCerts)
|
||||
if !ok {
|
||||
glog.Fatalf("Error processing client certificate in %s\n", clientCertFile)
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
ClientCAs: certPool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
}
|
||||
}
|
||||
|
||||
func (a Authenticator) Authenticate(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
|
||||
if a.AllowedWildcardDomain != "" && strings.HasSuffix(params.Leaf.Subject.CommonName, a.AllowedWildcardDomain) {
|
||||
return &advancedtls.PostHandshakeVerificationResults{}, nil
|
||||
}
|
||||
if _, ok := a.AllowedCommonNames[params.Leaf.Subject.CommonName]; ok {
|
||||
return &advancedtls.PostHandshakeVerificationResults{}, nil
|
||||
}
|
||||
err := fmt.Errorf("Authenticate: invalid subject client common name: %s", params.Leaf.Subject.CommonName)
|
||||
glog.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func FixTlsConfig(viper *util.ViperProxy, config *tls.Config) error {
|
||||
var err error
|
||||
config.MinVersion, err = TlsVersionByName(viper.GetString("tls.min_version"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.MaxVersion, err = TlsVersionByName(viper.GetString("tls.max_version"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.CipherSuites, err = TlsCipherSuiteByNames(viper.GetString("tls.cipher_suites"))
|
||||
return err
|
||||
}
|
||||
|
||||
func TlsVersionByName(name string) (uint16, error) {
|
||||
switch name {
|
||||
case "":
|
||||
return 0, nil
|
||||
case "SSLv3":
|
||||
return tls.VersionSSL30, nil
|
||||
case "TLS 1.0":
|
||||
return tls.VersionTLS10, nil
|
||||
case "TLS 1.1":
|
||||
return tls.VersionTLS11, nil
|
||||
case "TLS 1.2":
|
||||
return tls.VersionTLS12, nil
|
||||
case "TLS 1.3":
|
||||
return tls.VersionTLS13, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid tls version %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TlsCipherSuiteByNames(cipherSuiteNames string) ([]uint16, error) {
|
||||
cipherSuiteNames = strings.TrimSpace(cipherSuiteNames)
|
||||
if cipherSuiteNames == "" {
|
||||
return nil, nil
|
||||
}
|
||||
names := strings.Split(cipherSuiteNames, ",")
|
||||
cipherSuites := tls.CipherSuites()
|
||||
cipherIds := make([]uint16, 0, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
index := slices.IndexFunc(cipherSuites, func(suite *tls.CipherSuite) bool {
|
||||
return name == suite.Name
|
||||
})
|
||||
if index == -1 {
|
||||
return nil, fmt.Errorf("invalid tls cipher suite name %s", name)
|
||||
}
|
||||
cipherIds = append(cipherIds, cipherSuites[index].ID)
|
||||
}
|
||||
return cipherIds, nil
|
||||
}
|
||||
183
weed/security/tls_client_cert_test.go
Normal file
183
weed/security/tls_client_cert_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
type testCA struct {
|
||||
cert *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
file string
|
||||
}
|
||||
|
||||
func newTestCA(t *testing.T, dir string) *testCA {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test-ca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, "ca.pem")
|
||||
writePem(t, file, "CERTIFICATE", der)
|
||||
return &testCA{cert: cert, key: key, file: file}
|
||||
}
|
||||
|
||||
// issue creates a leaf certificate restricted to the given extended key usages.
|
||||
func (ca *testCA) issue(t *testing.T, dir, name string, ekus []x509.ExtKeyUsage) (certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: ekus,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDer, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certFile = filepath.Join(dir, name+".pem")
|
||||
keyFile = filepath.Join(dir, name+".key")
|
||||
writePem(t, certFile, "CERTIFICATE", der)
|
||||
writePem(t, keyFile, "EC PRIVATE KEY", keyDer)
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
func writePem(t *testing.T, file, blockType string, der []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func startTestGrpcServer(t *testing.T, config *util.ViperProxy, component string) string {
|
||||
t.Helper()
|
||||
creds, _ := LoadServerTLS(config, component)
|
||||
if creds == nil {
|
||||
t.Fatal("LoadServerTLS returned nil")
|
||||
}
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := grpc.NewServer(creds)
|
||||
healthpb.RegisterHealthServer(server, health.NewServer())
|
||||
go server.Serve(lis)
|
||||
t.Cleanup(server.Stop)
|
||||
return lis.Addr().String()
|
||||
}
|
||||
|
||||
func healthCheck(t *testing.T, addr string, dialOption grpc.DialOption) error {
|
||||
t.Helper()
|
||||
conn, err := grpc.NewClient(addr, dialOption)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
|
||||
return err
|
||||
}
|
||||
|
||||
// A component configured with a serverAuth-only serving cert plus a
|
||||
// clientAuth-only client_cert/client_key pair must dial with the client pair.
|
||||
func TestLoadClientTLSPrefersClientCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
serverCert, serverKey := ca.issue(t, dir, "server", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
clientCert, clientKey := ca.issue(t, dir, "client", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
|
||||
|
||||
v := &util.ViperProxy{Viper: viper.New()}
|
||||
v.Set("grpc.ca", ca.file)
|
||||
v.Set("grpc.master.cert", serverCert)
|
||||
v.Set("grpc.master.key", serverKey)
|
||||
v.Set("grpc.master.client_cert", clientCert)
|
||||
v.Set("grpc.master.client_key", clientKey)
|
||||
|
||||
addr := startTestGrpcServer(t, v, "grpc.master")
|
||||
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err != nil {
|
||||
t.Fatalf("health check with split client cert failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without client_cert, the component keeps presenting its serving cert.
|
||||
func TestLoadClientTLSFallsBackToServingCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
dualCert, dualKey := ca.issue(t, dir, "dual", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
|
||||
v := &util.ViperProxy{Viper: viper.New()}
|
||||
v.Set("grpc.ca", ca.file)
|
||||
v.Set("grpc.master.cert", dualCert)
|
||||
v.Set("grpc.master.key", dualKey)
|
||||
|
||||
addr := startTestGrpcServer(t, v, "grpc.master")
|
||||
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err != nil {
|
||||
t.Fatalf("health check with dual-EKU cert failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A serverAuth-only cert presented as the client identity fails the peer's
|
||||
// clientAuth EKU verification — the failure mode client_cert exists to fix.
|
||||
func TestLoadClientTLSServerOnlyEkuRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
serverCert, serverKey := ca.issue(t, dir, "server", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
|
||||
v := &util.ViperProxy{Viper: viper.New()}
|
||||
v.Set("grpc.ca", ca.file)
|
||||
v.Set("grpc.master.cert", serverCert)
|
||||
v.Set("grpc.master.key", serverKey)
|
||||
|
||||
addr := startTestGrpcServer(t, v, "grpc.master")
|
||||
if err := healthCheck(t, addr, LoadClientTLS(v, "grpc.master")); err == nil {
|
||||
t.Fatal("expected handshake failure when presenting a serverAuth-only cert as client identity")
|
||||
}
|
||||
}
|
||||
20
weed/security/tls_reload.go
Normal file
20
weed/security/tls_reload.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/security/certreload"
|
||||
"google.golang.org/grpc/credentials/tls/certprovider"
|
||||
)
|
||||
|
||||
// NewReloadingServerCertificate returns a GetCertificate callback for
|
||||
// tls.Config.GetCertificate, backed by a refreshing pem provider so
|
||||
// rotated certs (e.g. from Kubernetes cert-manager) are picked up
|
||||
// without a restart.
|
||||
//
|
||||
// Thin wrapper over certreload; the shared implementation lives in the
|
||||
// subpackage so both server TLS (this package) and HTTP client TLS
|
||||
// (weed/util/http/client) can use it without an import cycle.
|
||||
func NewReloadingServerCertificate(certFile, keyFile string) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), certprovider.Provider, error) {
|
||||
return certreload.NewServerGetCertificate(certFile, keyFile)
|
||||
}
|
||||
219
weed/security/tls_sni_test.go
Normal file
219
weed/security/tls_sni_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/security/advancedtls"
|
||||
)
|
||||
|
||||
func generateSelfSignedCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CA key: %v", err)
|
||||
}
|
||||
caTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CA cert: %v", err)
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(caDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA cert: %v", err)
|
||||
}
|
||||
|
||||
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate leaf key: %v", err)
|
||||
}
|
||||
leafTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
}
|
||||
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCert, &leafKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create leaf cert: %v", err)
|
||||
}
|
||||
|
||||
leafCert := tls.Certificate{
|
||||
Certificate: [][]byte{leafDER},
|
||||
PrivateKey: leafKey,
|
||||
}
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(caCert)
|
||||
|
||||
return leafCert, pool
|
||||
}
|
||||
|
||||
func startTLSListenerCapturingSNI(t *testing.T, cert tls.Certificate) (string, <-chan string) {
|
||||
t.Helper()
|
||||
|
||||
sniChan := make(chan string, 1)
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
select {
|
||||
case sniChan <- hello.ServerName:
|
||||
close(sniChan)
|
||||
default:
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("tls.Listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 1)
|
||||
conn.Read(buf)
|
||||
}()
|
||||
|
||||
return ln.Addr().String(), sniChan
|
||||
}
|
||||
|
||||
func newSNIStrippingCreds(t *testing.T, cert tls.Certificate, pool *x509.CertPool) credentials.TransportCredentials {
|
||||
t.Helper()
|
||||
clientCreds, err := advancedtls.NewClientCreds(&advancedtls.Options{
|
||||
IdentityOptions: advancedtls.IdentityCertificateOptions{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
},
|
||||
RootOptions: advancedtls.RootCertificateOptions{
|
||||
RootCertificates: pool,
|
||||
},
|
||||
VerificationType: advancedtls.CertVerification,
|
||||
AdditionalPeerVerification: func(params *advancedtls.HandshakeVerificationInfo) (*advancedtls.PostHandshakeVerificationResults, error) {
|
||||
return &advancedtls.PostHandshakeVerificationResults{}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientCreds: %v", err)
|
||||
}
|
||||
return &SNIStrippingTransportCredentials{creds: clientCreds}
|
||||
}
|
||||
|
||||
func TestSNI_HostnameStripsPort(t *testing.T) {
|
||||
cert, pool := generateSelfSignedCert(t)
|
||||
wrapped := newSNIStrippingCreds(t, cert, pool)
|
||||
addr, sniChan := startTLSListenerCapturingSNI(t, cert)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// gRPC passes "host:port" as authority; SNI wrapper strips the port
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _, err = wrapped.ClientHandshake(ctx, "localhost:"+portFromAddr(addr), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientHandshake: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case sni := <-sniChan:
|
||||
if sni != "localhost" {
|
||||
t.Errorf("SNI = %q, want %q", sni, "localhost")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SNI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSNI_IPAddressEmptySNI(t *testing.T) {
|
||||
cert, pool := generateSelfSignedCert(t)
|
||||
wrapped := newSNIStrippingCreds(t, cert, pool)
|
||||
addr, sniChan := startTLSListenerCapturingSNI(t, cert)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// RFC 6066: IP addresses MUST NOT be sent as SNI; Go's TLS sends empty ServerName for IPs
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _, err = wrapped.ClientHandshake(ctx, "127.0.0.1:"+portFromAddr(addr), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientHandshake: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case sni := <-sniChan:
|
||||
if sni != "" {
|
||||
t.Errorf("SNI = %q, want empty string", sni)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SNI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSNI_IPv6AddressEmptySNI(t *testing.T) {
|
||||
cert, pool := generateSelfSignedCert(t)
|
||||
wrapped := newSNIStrippingCreds(t, cert, pool)
|
||||
addr, sniChan := startTLSListenerCapturingSNI(t, cert)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _, err = wrapped.ClientHandshake(ctx, "[::1]:"+portFromAddr(addr), conn)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientHandshake: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case sni := <-sniChan:
|
||||
if sni != "" {
|
||||
t.Errorf("SNI = %q, want empty string", sni)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SNI")
|
||||
}
|
||||
}
|
||||
|
||||
func portFromAddr(addr string) string {
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
return port
|
||||
}
|
||||
Reference in New Issue
Block a user