Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kubernetes

The Docker walkthrough under the chart: local storage on a volume, the accounts in a Secret, the roles in config.toml.

kubectl create secret generic sftp-host-keys --from-file=host_ed25519
kubectl create secret generic sftp-users --from-file=users.toml
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: sftp-data }
spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 10Gi } } }
EOF
helm install sftp deploy/helm/craft-file-gate -f sftp-values.yaml \
  --set-file config.inline=config.toml
# sftp-values.yaml
hostKeys:
  existingSecret: sftp-host-keys                         # /keys/host_ed25519
extraVolumes:
  - name: data
    persistentVolumeClaim: { claimName: sftp-data }
  - name: users
    secret: { secretName: sftp-users, defaultMode: 0440 }
extraVolumeMounts:
  - { name: data, mountPath: /data }                     # the backend's root = "/data"
  - { name: users, mountPath: /secrets/users, readOnly: true }

config.toml is the Docker one followed by the content of its roles.toml (the chart mounts only config.toml), with host_keys = ["/keys/host_ed25519"] under [sftp] and users_file = "/secrets/users/users.toml" under [auth]. Listeners stay on 0.0.0.0 (the probes reach the pod by its IP) and [[admin.roles]] opens the console, since the chart sets the static token aside. The volume belongs to the pod’s group (fsGroup 65532): create_home creates alice/ there. Beyond one pod, the volume is ReadWriteMany.

The chart

ValueDefaultEffect
replicaCount1number of pods; above 1, see Several pods
config.inline""the content of config.toml, rendered into the ConfigMap <release>-config; a change rolls the pods
config.existingConfigMap""or a ConfigMap of your own, key config.toml; exactly one of the two sources
config.allowStaticTokenfalseallow_static_token, written under [admin] in config.inline unless the configuration sets it; with existingConfigMap, write it yourself. See Authenticating
extraVolumes, extraVolumeMounts, extraEnv, extraEnvFrom[]added as is to the pod and its container, after the chart’s: a volume for the root of a local backend, a Secret
image.repository, image.tag, image.pullPolicycraftogether/craft-file-gate, the chart’s version, IfNotPresentthe image
hostKeys.existingSecret""the Secret of the host keys, mounted in hostKeys.mountPath (/keys), files in 0440 with the pod’s fsGroup; see Secrets
service.sftp.port, service.sftp.type2222, ClusterIPthe Service <release>, SFTP only
service.admin.port, service.admin.type8080, ClusterIPthe Service <release>-api: file API, file explorer; absent with a config.inline without [admin]
service.control.port8081control_listen: /admin, /metrics, probes; "" for a single door; ignored with a config.inline without [admin]
service.control.typeClusterIPthe type of the Service <release>-control: control port and exposed probe port
service.probes.port""probes_listen: /livez, /readyz, /health and /metrics on their own port, over HTTP; the probes go there
service.probes.exposefalsethis port on the Service <release>-control too: without it, probes and scraping go through the pod IP
networkPolicy.enabledtruean ingress NetworkPolicy on the pod
networkPolicy.publicFrom[]: any sourcesources allowed on SFTP and the admin port
networkPolicy.controlFrom- namespaceSelector: {}: any pod of the clustersources allowed on the control port and the probe port; at least one
ban.backendfileconfigmap to share bans between pods
ban.configmapName"": <release>-bansthe bans ConfigMap, created by the chart and given to the server through CRAFT_FILE_GATE_{SFTP,API,ADMIN}_BAN_CONFIGMAP_NAME
stateDir.enabled, stateDir.pathtrue, /var/lib/craft-file-gatean emptyDir for the bans file
logFiles.enabled, logFiles.dirfalse, /var/log/craft-file-gatelog files, in an emptyDir or logFiles.existingClaim
passwordHashing.workers"": requests.cpuhashing threads; see Checking passwords
passwordHashing.queue"": 1024hashing queue
resourcesrequests 100m, 64Mi; limits 500m, 256Misee Memory
tls.enabledfalseprobes over HTTPS, except on service.probes.port; [admin.tls] is set in config.toml
terminationGracePeriodSeconds"": the shutdown_grace_period_secs of config.inline plus 60 s, otherwise 90 sthe pod’s shutdown timeout, above the worst case of a shutdown; set it with an existingConfigMap that lengthens the timeout
clusterSecret.*, adminRevocations.*, adminGrants.*, access.*the session key and the revocations shared between pods: Secrets
cluster.enabled, cluster.port"": as soon as replicaCount exceeds 1; 8083the channel between pods: Cluster
cluster.minPeers, cluster.unreadyWhen"": floor(replicaCount / 2); "": alert onlythe detectors; unreadyWhen: a list, or auto (storage_alone, isolated_and_storage_down)
  • One Service per exposure: publishing SFTP through a LoadBalancer (service.sftp.type) publishes neither the console, nor /metrics, nor the probes.
  • The kubelet probes come from the pod’s node, which most CNIs let through despite the NetworkPolicy; otherwise, add the node CIDR as an ipBlock to networkPolicy.controlFrom. To restrict Prometheus and the ingress, list their namespaces in networkPolicy.controlFrom.

Probes

/livez and /readyz (Probes and health) are probed on the probe port when one is given, otherwise on the control port, otherwise on the admin port.

A port for the probes

service.probes.port: 9090 sets [server] probes_listen: the probes and /metrics get a port of their own, in plain HTTP, even under [admin.tls] (One door or two). The kubelet and Prometheus (per pod: PodMonitor or annotations) no longer need the certificate or the console port; the port is on the Service <release>-control only with service.probes.expose: true. An SFTP pod without [admin] thus has its probes, with /readyz ready as soon as the SFTP door listens.

Pod hardening

The chart applies the restricted profile of the Pod Security Standards. Each field can be overridden in podSecurityContext and securityContext; null removes a field or a block.

SettingValue
userrunAsNonRoot: true, runAsUser, runAsGroup, fsGroup: 65532
privilegesallowPrivilegeEscalation: false, privileged: false, capabilities.drop: [ALL]
seccompRuntimeDefault
rootreadOnlyRootFilesystem: true: the server writes only to a volume
service account tokenmounted with ban.backend: configmap, the shared Secret (clusterSecret) or the access ConfigMap (adminRevocations, adminGrants)
configuration, host keysmounted readOnly: true

The root of a local backend is a volume from extraVolumes. Host keys and the TLS pair come from a Secret (cert-manager for TLS), never from an emptyDir: auto_generate of [admin.tls] does not apply under this chart.

Several pods

What is sharedHowSee
the pods find each otheras soon as replicaCount exceeds 1: headless Service <release>-cluster, port cluster.port reserved to the pods of the release (app.kubernetes.io/instance), certificate in the shared SecretCluster
console session keythe shared Secret (clusterSecret), entry admin-session.keyThe pods’ shared Secret
console revocations, temporary accessthe ConfigMap <release>-access (access.configmapName)Sharing revocations
bansConfigMap (ban.backend: configmap), followed by a watch: a bit over 100 ms; or a file on a ReadWriteMany volume (persist_file), re-read every 5 s, eventual convergenceBans
sessions, metrics, rate limitsnothing: each pod has its own; GET /admin/sessions lists those of the pod that answers, each with its pod_name (HOSTNAME); scope=cluster (the console) those of all

Each Role covers a single name, without create: get, update on the Secret; get, update, patch on each ConfigMap, watch on their collection for bans (helm template ... --show-only templates/rbac.yaml).

Sharing through a ConfigMap

Feature k8s (in the published images). The pod reads the ConfigMap before serving; each publication is conditioned on the resourceVersion it read, and a data.bans erased by kubectl edit is published again on the next one. Without watch, propagation falls back to a re-read every 30 s; a deployment that does not create the ConfigMap adds create on the collection.

[sftp.ban]           # same under [api.ban] and [admin.ban]; the chart provides the name
backend = "configmap"

inotify on a shared node

fs.inotify.max_user_instances (128 by default) is counted per UID, across the whole node. The server takes only one. When other pods have used them up and you cannot tune the node:

[reload]
watch = "poll"   # no inotify instance; a Secret update is seen within 5 s

See Hot reload.