diff --git a/.github/workflows/helm_ci.yml b/.github/workflows/helm_ci.yml index 30351d557..fe2000d60 100644 --- a/.github/workflows/helm_ci.yml +++ b/.github/workflows/helm_ci.yml @@ -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" diff --git a/k8s/charts/seaweedfs/ci/default-values.yaml b/k8s/charts/seaweedfs/ci/default-values.yaml new file mode 100644 index 000000000..e964005fb --- /dev/null +++ b/k8s/charts/seaweedfs/ci/default-values.yaml @@ -0,0 +1 @@ +# Default configuration, kept so chart-testing still installs the chart as-is. diff --git a/k8s/charts/seaweedfs/ci/sftp-values.yaml b/k8s/charts/seaweedfs/ci/sftp-values.yaml new file mode 100644 index 000000000..13a8b725b --- /dev/null +++ b/k8s/charts/seaweedfs/ci/sftp-values.yaml @@ -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 diff --git a/k8s/charts/seaweedfs/templates/all-in-one/all-in-one-deployment.yaml b/k8s/charts/seaweedfs/templates/all-in-one/all-in-one-deployment.yaml index b0362043e..6bf889e44 100644 --- a/k8s/charts/seaweedfs/templates/all-in-one/all-in-one-deployment.yaml +++ b/k8s/charts/seaweedfs/templates/all-in-one/all-in-one-deployment.yaml @@ -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 diff --git a/k8s/charts/seaweedfs/templates/sftp/sftp-deployment.yaml b/k8s/charts/seaweedfs/templates/sftp/sftp-deployment.yaml index 736963a5b..7e54dbfb3 100644 --- a/k8s/charts/seaweedfs/templates/sftp/sftp-deployment.yaml +++ b/k8s/charts/seaweedfs/templates/sftp/sftp-deployment.yaml @@ -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) }} diff --git a/k8s/charts/seaweedfs/templates/sftp/sftp-secret.yaml b/k8s/charts/seaweedfs/templates/sftp/sftp-secret.yaml index da8212484..eca249237 100644 --- a/k8s/charts/seaweedfs/templates/sftp/sftp-secret.yaml +++ b/k8s/charts/seaweedfs/templates/sftp/sftp-secret.yaml @@ -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 }} \ No newline at end of file diff --git a/k8s/charts/seaweedfs/templates/sftp/sftp-ssh-secret.yaml b/k8s/charts/seaweedfs/templates/sftp/sftp-ssh-secret.yaml new file mode 100644 index 000000000..07722593c --- /dev/null +++ b/k8s/charts/seaweedfs/templates/sftp/sftp-ssh-secret.yaml @@ -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 }} diff --git a/k8s/charts/seaweedfs/values.yaml b/k8s/charts/seaweedfs/values.yaml index 1b21582d6..a016e782b 100644 --- a/k8s/charts/seaweedfs/values.yaml +++ b/k8s/charts/seaweedfs/values.yaml @@ -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