mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
helm: generate the SFTP host key per install (#10390)
* helm: generate the SFTP host key per install The SFTP secret template shipped one fixed ed25519 host key, so every install that did not override it presented the same host identity. Generate the key at install time instead, following the getOrGeneratePassword pattern: an existing secret keeps its key across upgrades, except the previously bundled one, which is replaced with a freshly generated key on the next upgrade. * helm: create the SFTP host-keys secret the deployments mount Both the sftp and all-in-one deployments mount /etc/sw/ssh from <fullname>-sftp-ssh-secret, but no template created it, so a default install could not start its pod and host keys only reached the server when enableAuth happened to mount them elsewhere. Create the secret with a generated ed25519 key, keeping whatever keys an existing secret already holds. The sshPrivateKey default becomes empty: the file it pointed at only exists when enableAuth mounts /etc/sw, and a configured but missing key file is fatal to the server, while hostKeysFolder now always has a key. * helm: test SFTP host key generation and secret lifecycle Template checks: keys render into the secret the deployments mount, parse as PKCS#8 ed25519, differ between installs, and the render carries no key material from the chart itself; existingSshConfigSecret and all-in-one wiring covered. On the kind cluster, exercise the secret lifecycle: a generated key survives upgrades, the key earlier chart versions bundled is replaced, and operator-managed keys are kept untouched. chart-testing now also installs with sftp enabled, where the pod only becomes ready if the server loads the generated host key. * helm: treat a whitespace-only stored SFTP host key as missing A whitespace-only secret value skipped regeneration and then rendered an empty key file. * helm: mount the SFTP host keys secret at the configured hostKeysFolder The secret was mounted at a fixed /etc/sw/ssh, so a custom sftp.hostKeysFolder pointed the server at an empty directory. Mount at the configured path in both the sftp and all-in-one deployments, and pin flag/mount agreement in the rendering tests.
This commit is contained in:
@@ -278,6 +278,166 @@ jobs:
|
||||
helm template test $CHART_DIR --set sftp.enabled=true > /tmp/sftp.yaml
|
||||
grep -q "seaweedfs-sftp" /tmp/sftp.yaml
|
||||
echo "SFTP deployment renders correctly"
|
||||
|
||||
echo ""
|
||||
echo "=== Testing SFTP host key generation ==="
|
||||
# The chart must not ship host key material: keys are generated
|
||||
# per install, land in the secret the deployments mount at
|
||||
# sftp.hostKeysFolder, and must be PKCS#8 ed25519 private keys,
|
||||
# the shape the server's host key loader parses; it fails to
|
||||
# start otherwise.
|
||||
pip install pyyaml -q
|
||||
python3 - "$CHART_DIR" <<'PYEOF'
|
||||
import base64, re, subprocess, sys, yaml
|
||||
|
||||
chart = sys.argv[1]
|
||||
|
||||
def render(values):
|
||||
args = ["helm", "template", "test", chart]
|
||||
for k, v in values.items():
|
||||
args += ["--set", f"{k}={v}"]
|
||||
return subprocess.check_output(args, text=True)
|
||||
|
||||
def docs(manifest):
|
||||
return [d for d in yaml.safe_load_all(manifest) if d]
|
||||
|
||||
def secret(manifest, name):
|
||||
for d in docs(manifest):
|
||||
if d.get("kind") == "Secret" and d["metadata"]["name"] == name:
|
||||
return d
|
||||
return None
|
||||
|
||||
def pod_spec(manifest, name):
|
||||
for d in docs(manifest):
|
||||
if d.get("kind") in ("Deployment", "StatefulSet") and d["metadata"]["name"] == name:
|
||||
return d["spec"]["template"]["spec"]
|
||||
return None
|
||||
|
||||
def script_of(spec):
|
||||
for c in spec["containers"]:
|
||||
cmd = c.get("command", [])
|
||||
if len(cmd) >= 3 and cmd[0] == "/bin/sh":
|
||||
return cmd[2]
|
||||
raise AssertionError("no shell command block found")
|
||||
|
||||
def volume_secret(spec, volname):
|
||||
for v in spec.get("volumes", []):
|
||||
if v["name"] == volname:
|
||||
return v["secret"]["secretName"]
|
||||
return None
|
||||
|
||||
def mount_path(spec, volname):
|
||||
for c in spec["containers"]:
|
||||
for vm in c.get("volumeMounts", []):
|
||||
if vm["name"] == volname:
|
||||
return vm["mountPath"]
|
||||
return None
|
||||
|
||||
def parse_ed25519(pem):
|
||||
m = re.match(r"-----BEGIN PRIVATE KEY-----\n(.+?)-----END PRIVATE KEY-----", pem.strip(), re.S)
|
||||
if not m:
|
||||
raise AssertionError("not a PKCS#8 PEM private key")
|
||||
der = base64.b64decode(m.group(1))
|
||||
# RFC 8410: fixed PKCS#8 prefix, then the 32-byte seed
|
||||
prefix = bytes.fromhex("302e020100300506032b657004220420")
|
||||
if len(der) != 48 or not der.startswith(prefix):
|
||||
raise AssertionError("not an ed25519 PKCS#8 key")
|
||||
|
||||
failed = []
|
||||
# public-key material of the key the chart used to bundle
|
||||
BUNDLED = "H4McwcDphteXVullu6q7ephEN1N60z"
|
||||
|
||||
out1 = render({"sftp.enabled": "true"})
|
||||
out2 = render({"sftp.enabled": "true"})
|
||||
|
||||
for label, out in (("first", out1), ("second", out2)):
|
||||
if BUNDLED in out:
|
||||
failed.append(f"{label} render still contains the formerly bundled host key")
|
||||
|
||||
def folder_key(out):
|
||||
s = secret(out, "test-seaweedfs-sftp-ssh-secret")
|
||||
if s is None:
|
||||
return None
|
||||
return base64.b64decode(s["data"]["ssh_host_ed25519_key"]).decode()
|
||||
|
||||
k1, k2 = folder_key(out1), folder_key(out2)
|
||||
if k1 is None or k2 is None:
|
||||
failed.append("sftp-ssh-secret not rendered with sftp.enabled=true")
|
||||
else:
|
||||
try:
|
||||
parse_ed25519(k1)
|
||||
print("generated host key parses as ed25519")
|
||||
except Exception as e:
|
||||
failed.append(f"generated host key does not parse: {e}")
|
||||
if k1 == k2:
|
||||
failed.append("two renders produced the same host key (key is not generated per install)")
|
||||
else:
|
||||
print("host key differs between installs")
|
||||
|
||||
legacy1 = secret(out1, "test-seaweedfs-sftp-secret")["stringData"]["seaweedfs_sftp_ssh_private_key"]
|
||||
legacy2 = secret(out2, "test-seaweedfs-sftp-secret")["stringData"]["seaweedfs_sftp_ssh_private_key"]
|
||||
try:
|
||||
parse_ed25519(legacy1)
|
||||
except Exception as e:
|
||||
failed.append(f"sftp-secret ssh key does not parse: {e}")
|
||||
if legacy1 == legacy2:
|
||||
failed.append("sftp-secret ssh key identical across renders")
|
||||
else:
|
||||
print("sftp-secret ssh key is generated per install")
|
||||
|
||||
spec = pod_spec(out1, "test-seaweedfs-sftp")
|
||||
script = script_of(spec)
|
||||
if "-sshPrivateKey" in script:
|
||||
failed.append("sftp deployment passes -sshPrivateKey by default; the file only exists "
|
||||
"when enableAuth mounts /etc/sw and a missing key file is fatal")
|
||||
if "-hostKeysFolder=/etc/sw/ssh" not in script:
|
||||
failed.append("sftp deployment missing -hostKeysFolder=/etc/sw/ssh")
|
||||
if volume_secret(spec, "config-ssh") != "test-seaweedfs-sftp-ssh-secret":
|
||||
failed.append("sftp config-ssh volume does not reference the generated secret")
|
||||
else:
|
||||
print("sftp deployment mounts the generated secret at the host keys folder")
|
||||
|
||||
out = render({"sftp.enabled": "true", "sftp.existingSshConfigSecret": "my-keys"})
|
||||
if secret(out, "test-seaweedfs-sftp-ssh-secret") is not None:
|
||||
failed.append("existingSshConfigSecret set but the default ssh secret still renders")
|
||||
if volume_secret(pod_spec(out, "test-seaweedfs-sftp"), "config-ssh") != "my-keys":
|
||||
failed.append("existingSshConfigSecret is not the config-ssh volume source")
|
||||
else:
|
||||
print("existingSshConfigSecret replaces the generated secret")
|
||||
|
||||
out = render({"allInOne.enabled": "true", "allInOne.sftp.enabled": "true"})
|
||||
spec = pod_spec(out, "test-seaweedfs-all-in-one")
|
||||
if secret(out, "test-seaweedfs-sftp-ssh-secret") is None:
|
||||
failed.append("all-in-one: ssh secret not rendered")
|
||||
if "-sftp.hostKeysFolder=/etc/sw/ssh" not in script_of(spec):
|
||||
failed.append("all-in-one: missing -sftp.hostKeysFolder=/etc/sw/ssh")
|
||||
if volume_secret(spec, "config-ssh") != "test-seaweedfs-sftp-ssh-secret":
|
||||
failed.append("all-in-one: config-ssh volume does not reference the generated secret")
|
||||
else:
|
||||
print("all-in-one mounts the generated secret")
|
||||
|
||||
out = render({"sftp.enabled": "true", "sftp.hostKeysFolder": "/keys"})
|
||||
spec = pod_spec(out, "test-seaweedfs-sftp")
|
||||
if "-hostKeysFolder=/keys" not in script_of(spec) or mount_path(spec, "config-ssh") != "/keys":
|
||||
failed.append("custom hostKeysFolder: flag and secret mount do not agree")
|
||||
else:
|
||||
print("custom hostKeysFolder keeps flag and mount aligned")
|
||||
|
||||
out = render({"allInOne.enabled": "true", "allInOne.sftp.enabled": "true",
|
||||
"allInOne.sftp.hostKeysFolder": "/keys"})
|
||||
spec = pod_spec(out, "test-seaweedfs-all-in-one")
|
||||
if "-sftp.hostKeysFolder=/keys" not in script_of(spec) or mount_path(spec, "config-ssh") != "/keys":
|
||||
failed.append("all-in-one custom hostKeysFolder: flag and secret mount do not agree")
|
||||
else:
|
||||
print("all-in-one custom hostKeysFolder keeps flag and mount aligned")
|
||||
|
||||
if failed:
|
||||
print("\nFAIL:", file=sys.stderr)
|
||||
for f in failed:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
echo "SFTP host key generation tests passed"
|
||||
|
||||
echo "=== Testing ingress configurations ==="
|
||||
helm template test $CHART_DIR \
|
||||
@@ -544,3 +704,48 @@ jobs:
|
||||
|
||||
- name: Run chart-testing (install)
|
||||
run: ct install --target-branch ${{ github.event.repository.default_branch }} --all --chart-dirs k8s/charts
|
||||
|
||||
- name: Verify SFTP host key secret lifecycle
|
||||
run: |
|
||||
set -e
|
||||
CHART_DIR="k8s/charts/seaweedfs"
|
||||
NS="sftp-hostkey"
|
||||
SECRET="hk-seaweedfs-sftp-ssh-secret"
|
||||
SFTP_ARGS="--set sftp.enabled=true --set master.enabled=false --set volume.enabled=false --set filer.enabled=false"
|
||||
kubectl create namespace "$NS"
|
||||
|
||||
echo "=== install generates a host key, upgrade keeps it ==="
|
||||
helm install hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
||||
KEY1=$(kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.ssh_host_ed25519_key}')
|
||||
[ -n "$KEY1" ] || { echo "FAIL: install did not create a host key"; exit 1; }
|
||||
echo "$KEY1" | base64 -d | grep -q "BEGIN PRIVATE KEY" || { echo "FAIL: host key is not a PEM private key"; exit 1; }
|
||||
helm upgrade hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
||||
KEY2=$(kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.ssh_host_ed25519_key}')
|
||||
[ "$KEY1" = "$KEY2" ] || { echo "FAIL: host key changed across upgrade"; exit 1; }
|
||||
echo "host key survives upgrade"
|
||||
|
||||
echo "=== the key the chart used to bundle is replaced ==="
|
||||
kubectl delete secret "$SECRET" -n "$NS"
|
||||
kubectl create secret generic "$SECRET" -n "$NS" \
|
||||
--from-literal=ssh_host_ed25519_key="stand-in H4McwcDphteXVullu6q7ephEN1N60z stand-in"
|
||||
helm upgrade hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
||||
ROTATED=$(kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.ssh_host_ed25519_key}' | base64 -d)
|
||||
case "$ROTATED" in
|
||||
*H4McwcDphteXVullu6q7ephEN1N60z*) echo "FAIL: bundled key survived the upgrade"; exit 1;;
|
||||
esac
|
||||
echo "$ROTATED" | grep -q "BEGIN PRIVATE KEY" || { echo "FAIL: replacement is not a generated key"; exit 1; }
|
||||
echo "bundled key rotated to a generated one"
|
||||
|
||||
echo "=== operator-managed keys are kept as-is ==="
|
||||
kubectl delete secret "$SECRET" -n "$NS"
|
||||
ssh-keygen -q -t ed25519 -N "" -C "" -f /tmp/operator_key
|
||||
kubectl create secret generic "$SECRET" -n "$NS" --from-file=my_key=/tmp/operator_key
|
||||
helm upgrade hk $CHART_DIR -n "$NS" $SFTP_ARGS
|
||||
kubectl get secret "$SECRET" -n "$NS" -o jsonpath='{.data.my_key}' | base64 -d | cmp -s - /tmp/operator_key \
|
||||
|| { echo "FAIL: operator key was modified"; exit 1; }
|
||||
NKEYS=$(kubectl get secret "$SECRET" -n "$NS" -o json | jq '.data | length')
|
||||
[ "$NKEYS" = "1" ] || { echo "FAIL: expected only the operator key, found $NKEYS entries"; exit 1; }
|
||||
echo "operator key kept, no extra key generated"
|
||||
|
||||
kubectl delete namespace "$NS"
|
||||
echo "SFTP host key lifecycle tests passed"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Default configuration, kept so chart-testing still installs the chart as-is.
|
||||
@@ -0,0 +1,5 @@
|
||||
# SFTP install: the pod only becomes ready if the server loads a host key
|
||||
# from the generated sftp-ssh-secret, so this exercises the whole path.
|
||||
sftp:
|
||||
enabled: true
|
||||
enableAuth: true
|
||||
@@ -302,7 +302,7 @@ spec:
|
||||
{{- end }}
|
||||
{{- if .Values.allInOne.sftp.enabled }}
|
||||
- name: config-ssh
|
||||
mountPath: /etc/sw/ssh
|
||||
mountPath: {{ .Values.allInOne.sftp.hostKeysFolder | default .Values.sftp.hostKeysFolder | default "/etc/sw/ssh" }}
|
||||
readOnly: true
|
||||
{{- if or .Values.allInOne.sftp.enableAuth .Values.sftp.enableAuth }}
|
||||
- mountPath: /etc/sw/sftp
|
||||
|
||||
@@ -176,7 +176,7 @@ spec:
|
||||
name: config-users
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
- mountPath: /etc/sw/ssh
|
||||
- mountPath: {{ .Values.sftp.hostKeysFolder | default "/etc/sw/ssh" }}
|
||||
name: config-ssh
|
||||
readOnly: true
|
||||
{{- if and .Values.sftp.authMethods (contains "certificate" .Values.sftp.authMethods) }}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
{{- $admin_pwd := include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "admin_password" "length" 20) -}}
|
||||
{{- $read_user_pwd := include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "readonly_password" "length" 20) -}}
|
||||
{{- $public_user_pwd := include "seaweedfs.getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" $secretName "key" "public_user_password" "length" 20) -}}
|
||||
{{- $ssh_private_key := "" -}}
|
||||
{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName -}}
|
||||
{{- if and $existingSecret (index $existingSecret.data "seaweedfs_sftp_ssh_private_key") -}}
|
||||
{{- $ssh_private_key = index $existingSecret.data "seaweedfs_sftp_ssh_private_key" | b64dec | trim -}}
|
||||
{{- end -}}
|
||||
{{/* generate a fresh host key; also replace the key earlier chart versions bundled */}}
|
||||
{{- if or (not $ssh_private_key) (contains "H4McwcDphteXVullu6q7ephEN1N60z" $ssh_private_key) -}}
|
||||
{{- $ssh_private_key = genPrivateKey "ed25519" -}}
|
||||
{{- end -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
type: Opaque
|
||||
@@ -24,11 +33,5 @@ stringData:
|
||||
public_user_password: {{ $public_user_pwd }}
|
||||
seaweedfs_sftp_config: '[{"Username":"admin","Password":"{{ $admin_pwd }}","PublicKeys":[],"HomeDir":"/","Permissions":{"/":["read","write","list"]},"Uid":0,"Gid":0},{"Username":"readonly_user","Password":"{{ $read_user_pwd }}","PublicKeys":[],"HomeDir":"/","Permissions":{"/":["read","list"]},"Uid":1112,"Gid":1112},{"Username":"public_user","Password":"{{ $public_user_pwd }}","PublicKeys":[],"HomeDir":"/public","Permissions":{"/public":["write","read","list"]},"Uid":1113,"Gid":1113}]'
|
||||
seaweedfs_sftp_ssh_private_key: |
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDH4McwcDphteXVullu6q7ephEN1N60z+w0qZw0UVW8OwAAAJDjxkmk48ZJ
|
||||
pAAAAAtzc2gtZWQyNTUxOQAAACDH4McwcDphteXVullu6q7ephEN1N60z+w0qZw0UVW8Ow
|
||||
AAAEAeVy/4+gf6rjj2jla/AHqJpC1LcS5hn04IUs4q+iVq/MfgxzBwOmG15dW6WW7qrt6m
|
||||
EQ3U3rTP7DSpnDRRVbw7AAAADHNla291ckAwMDY2NwE=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
{{ $ssh_private_key | trim | indent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- if or (and .Values.sftp.enabled (not .Values.sftp.existingSshConfigSecret)) (and .Values.allInOne.enabled .Values.allInOne.sftp.enabled (not (or .Values.allInOne.sftp.existingSshConfigSecret .Values.sftp.existingSshConfigSecret))) }}
|
||||
{{/* Host keys mounted at sftp.hostKeysFolder; existing keys are kept across upgrades, except the key earlier chart versions bundled. */}}
|
||||
{{- $secretName := printf "%s-sftp-ssh-secret" (include "seaweedfs.fullname" .) }}
|
||||
{{- $hostKeys := dict }}
|
||||
{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }}
|
||||
{{- if $existingSecret }}
|
||||
{{- range $name, $value := $existingSecret.data }}
|
||||
{{- if not (contains "H4McwcDphteXVullu6q7ephEN1N60z" (b64dec $value)) }}
|
||||
{{- $_ := set $hostKeys $name $value }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if eq (len $hostKeys) 0 }}
|
||||
{{- $_ := set $hostKeys "ssh_host_ed25519_key" (genPrivateKey "ed25519" | b64enc) }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
type: Opaque
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
annotations:
|
||||
"helm.sh/resource-policy": keep
|
||||
"helm.sh/hook": "pre-install,pre-upgrade"
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: sftp
|
||||
data:
|
||||
{{- range $name, $value := $hostKeys }}
|
||||
{{ $name }}: {{ $value }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1170,7 +1170,7 @@ sftp:
|
||||
loggingOverrideLevel: null
|
||||
|
||||
# SSH server configuration
|
||||
sshPrivateKey: "/etc/sw/seaweedfs_sftp_ssh_private_key" # Path to the SSH private key file for host authentication
|
||||
sshPrivateKey: "" # Optional path to a single SSH host key file; the server fails to start if set but missing. Host keys come from hostKeysFolder by default.
|
||||
hostKeysFolder: "/etc/sw/ssh" # path to folder containing SSH private key files for host authentication
|
||||
authMethods: "password,publickey" # Comma-separated list of allowed auth methods: password, publickey, certificate
|
||||
maxAuthTries: 6 # Maximum number of authentication attempts per connection
|
||||
|
||||
Reference in New Issue
Block a user