diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dc94da4..4a741df 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -52,9 +52,7 @@ jobs: - name: Run chart-testing (install) if: steps.list-changed.outputs.changed == 'true' - # paperlessngx-ftp-bridge is excluded: its app image does a one-shot - # FTP connectivity check at startup and exits if it can't connect, - # which it never can from the CI sandbox. Safe to skip for now since - # that app is being rewritten without an FTP dependency; re-include - # once the rewrite lands. + # paperlessngx-ftp-bridge is excluded: its Service is a hardcoded + # LoadBalancer (kind assigns no external IP, so `--wait` would hang) + # and its image is built in the bridge repo, not here. run: ct install --all --chart-dirs charts --excluded-charts paperlessngx-ftp-bridge diff --git a/README.md b/README.md index 389a2cf..a6a89cc 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ README is mirrored to `gh-pages`. |-------|-------------|--------| | [ipsec-vpn-server](charts/ipsec-vpn-server) | Deploy IPsec VPN server inside K8s with optional sealed-secrets | [taskmedia/helm](https://github.com/taskmedia/helm/tree/main/charts/ipsec-vpn-server) | | [paperlessngx-backup](charts/paperlessngx-backup) | Backup paperless-ngx via K8s cronjob to FTP | [taskmedia/helm](https://github.com/taskmedia/helm/tree/main/charts/paperlessngx-backup) | -| [paperlessngx-ftp-bridge](charts/paperlessngx-ftp-bridge) | Upload files from a FTP (TLS) server to paperless-ngx | [taskmedia/helm](https://github.com/taskmedia/helm/tree/main/charts/paperlessngx-ftp-bridge) | +| [paperlessngx-ftp-bridge](charts/paperlessngx-ftp-bridge) | Embedded FTP/FTPS server that forwards scanner uploads to paperless-ngx | [taskmedia/helm](https://github.com/taskmedia/helm/tree/main/charts/paperlessngx-ftp-bridge) | | [vpn-ios-profile](charts/vpn-ios-profile) | Deploy a VPN server in K8s with a provided iOS profile | [taskmedia/helm](https://github.com/taskmedia/helm/tree/main/charts/vpn-ios-profile) | diff --git a/charts/paperlessngx-ftp-bridge/Chart.yaml b/charts/paperlessngx-ftp-bridge/Chart.yaml index e58a2f1..21e6bb9 100644 --- a/charts/paperlessngx-ftp-bridge/Chart.yaml +++ b/charts/paperlessngx-ftp-bridge/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: paperlessngx-ftp-bridge -description: A Helm chart to upload files to from a FTP (TLS) server to paperless-ngx +description: A Helm chart for an embedded FTP/FTPS server that forwards uploaded files to paperless-ngx home: https://helm.task.media/paperlessngx-ftp-bridge keywords: - paperless-ngx @@ -11,7 +11,10 @@ maintainers: - name: fty4 url: https://github.com/fty4 sources: + - https://github.com/taskmedia/paperlessngx-ftp-bridge - https://github.com/taskmedia/helm icon: https://media.task.media/images/logo.png type: application -version: 1.2.0 +version: 2.0.0 +# app image tag (v); bumped manually when the bridge repo releases +appVersion: "2.0.0" diff --git a/charts/paperlessngx-ftp-bridge/README.md b/charts/paperlessngx-ftp-bridge/README.md index b34c6f4..e181f3c 100644 --- a/charts/paperlessngx-ftp-bridge/README.md +++ b/charts/paperlessngx-ftp-bridge/README.md @@ -2,13 +2,9 @@ # Helm chart: paperless-ngx FTP bridge -Kubernetes [Helm](https://helm.sh) chart to automatically upload PDF files from a FTP server to paperless-ngx. +Kubernetes [Helm](https://helm.sh) chart that embeds an FTP/FTPS server and forwards every uploaded file straight to the paperless-ngx API. -This application will automatically search for PDF files on your FTP server and upload them to the paperless-ngx API. -The application will run as a cronjob and will be executed every 5 minutes (can be changed). - -You can use this application e.g. if your document scanner can only upload files to a FTP server. -With this bridge your scan device will be able to upload the documents directly with the FTP as file storage inbetween. +Your scanner connects directly to this bridge over FTP; there's no separate FTP server to run or keep in sync, and uploads reach paperless-ngx the moment the transfer finishes. ## Configuration @@ -16,6 +12,10 @@ The configuration of the application will be set in the [`values.yaml`](./values Everything is pretty straight forward and should be self-explanatory. If you think more information should be provided or need help, feel free to open an issue. +The application itself lives in [taskmedia/paperlessngx-ftp-bridge](https://github.com/taskmedia/paperlessngx-ftp-bridge); the design decisions referenced as ADRs in `values.yaml` and the templates are documented there under `docs/adr/`. + +Upgrading from an older chart version? See [`docs/MIGRATION.md`](./docs/MIGRATION.md) for breaking-change upgrade notes. + ## Installation To deploy the Helm chart first copy the [`values.yaml`](./values.yaml)-file and customize your deployment. diff --git a/charts/paperlessngx-ftp-bridge/ci/ct-values.yaml b/charts/paperlessngx-ftp-bridge/ci/ct-values.yaml index 289adab..b30fffc 100644 --- a/charts/paperlessngx-ftp-bridge/ci/ct-values.yaml +++ b/charts/paperlessngx-ftp-bridge/ci/ct-values.yaml @@ -1,9 +1,19 @@ -# Minimal overrides so `ct lint` can render this chart's templates. -# This chart is excluded from `ct install` (see test.yaml), so these only -# need to satisfy the templates' `required` checks, not start a real pod. +# Minimal overrides so `ct lint` can render this chart's templates (the +# `required` checks). The chart is excluded from `ct install` (see +# .github/workflows/test.yaml). +# +# paperless.url points at the bridge's own health-check server rather than a +# real paperless-ngx: the bridge's Ping() treats any HTTP response as +# reachable, so this keeps /readyz non-flaky without a second service. ftp: - host: "ftp.example.org:21" - user: "ci-test" + accounts: + - username: ctuser + password: ct-test-password + # ftpserverlib requires a real IP here, not a hostname. + pasv: + publicHost: 127.0.0.1 paperless: - username: "ci-test" + url: "http://localhost:8080" + username: ctuser + password: ct-test-password diff --git a/charts/paperlessngx-ftp-bridge/docs/MIGRATION.md b/charts/paperlessngx-ftp-bridge/docs/MIGRATION.md new file mode 100644 index 0000000..c218379 --- /dev/null +++ b/charts/paperlessngx-ftp-bridge/docs/MIGRATION.md @@ -0,0 +1,61 @@ +# Migration notes + +Breaking-change upgrade guides for this chart, newest first. + +## v2: embedded FTP server + +Spec: [#81](https://github.com/taskmedia/paperlessngx-ftp-bridge/issues/81). + +### TL;DR + +The bridge is now the FTP server itself (`scanner -> ftp-paperless-bridge -> paperless-ngx`) instead of polling an external one. Point your scanner(s) at the chart's Service, rewrite `values.yaml` per the table below, and make sure your cluster can satisfy a `LoadBalancer` Service. No dual-mode flag exists — this is a full replacement. + +### Removed + +| Old `values.yaml` key | Old env var | Replacement | +|---|---|---| +| `ftp.host` | `FTP_HOST` | none — scanners connect to the chart's own Service | +| `ftp.user` | `FTP_USERNAME` | `ftp.accounts[].username` | +| `ftp.password` / `ftp.passwordExistingSecret` | `FTP_PASSWORD` | `ftp.accounts[].password` / `ftp.accounts[].passwordExistingSecret` | +| `ftp.path` | `FTP_PATH` | none — uploads are forwarded in-flight, no remote directory | +| `interval` (top-level) | `CRON_SCHEDULE` | none — no poll loop, uploads happen on `STOR` | + +### New required values + +- `ftp.accounts[0].username` / `.password` (or `.passwordExistingSecret`) — at least one account; username must be letters/digits/underscore only. +- `ftp.pasv.publicHost` — IP/host advertised to scanners for PASV data connections. No default. +- `paperless.username` / `.password` (or `.passwordExistingSecret`) — same shape as before, now required at render time. + +### New optional values + +- `ftp.allowedExtensions` (default `[".pdf"]`) — extensions accepted by `STOR`. +- `ftp.pasv.portMin` / `portMax` (default `50000`-`50019`) — PASV port range. +- `ftp.tls.existingSecret.name` — your own `kubernetes.io/tls` Secret instead of the self-signed default; picked up on renewal without a restart. +- `service.loadBalancerIP` / `service.annotations` — passthrough to the Service. + +### New Kubernetes behavior + +- **`service.type: LoadBalancer`** (hardcoded) exposes the control port (`21` -> `2121`) and the full PASV range. Needs a platform that can satisfy a `LoadBalancer` (cloud LB, MetalLB, k3s klipper-lb, ...) — `NodePort` can't carry ~20 ports. +- **`replicas: 1`** (hardcoded) — PASV session affinity can't survive multiple replicas behind an L4 LoadBalancer. +- **`securityContext`** is fully locked down (non-root, dropped capabilities, read-only rootfs, seccomp). Only relevant if your cluster's pod security policies conflict with these. +- **`readinessProbe`** (`/readyz`) added alongside `livenessProbe` (`/healthz`, now reflecting only the FTP listener). `/readyz` also tracks paperless-ngx reachability, taking the pod out of rotation (not restarting it) during an outage. + +### Example + +```yaml +# before +ftp: + host: "ftp.example.org:21" + user: "scanner1" + password: "secret" + path: "." +interval: "*/5 * * * *" + +# after +ftp: + accounts: + - username: "scanner1" + password: "secret" + pasv: + publicHost: "203.0.113.10" # your cluster's external IP/host +``` diff --git a/charts/paperlessngx-ftp-bridge/templates/_helpers.tpl b/charts/paperlessngx-ftp-bridge/templates/_helpers.tpl index d121efe..2d8fd5d 100644 --- a/charts/paperlessngx-ftp-bridge/templates/_helpers.tpl +++ b/charts/paperlessngx-ftp-bridge/templates/_helpers.tpl @@ -50,6 +50,16 @@ app.kubernetes.io/name: {{ include "plngxftpbridge.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} +{{/* +The individual PASV ports (ftp.pasv.portMin..portMax inclusive) shared by +the Service and Deployment templates, since Kubernetes can't express a port +range (see ADR-0006). Usage: {{- range $port := include "plngxftpbridge.pasvPorts" . | splitList " " }} +*/}} +{{- define "plngxftpbridge.pasvPorts" -}} +{{- $ports := untilStep (int .Values.ftp.pasv.portMin) (int (add1 .Values.ftp.pasv.portMax)) 1 }} +{{- join " " $ports }} +{{- end }} + {{/* Create the name of the service account to use */}} diff --git a/charts/paperlessngx-ftp-bridge/templates/deployment.yaml b/charts/paperlessngx-ftp-bridge/templates/deployment.yaml index cc02bcb..521779a 100644 --- a/charts/paperlessngx-ftp-bridge/templates/deployment.yaml +++ b/charts/paperlessngx-ftp-bridge/templates/deployment.yaml @@ -5,6 +5,9 @@ metadata: labels: {{- include "plngxftpbridge.labels" . | nindent 4 }} spec: + # Hardcoded, not a .Values knob: PASV session affinity can't survive more + # than one replica behind an L4 passthrough LoadBalancer (see ADR-0006). + replicas: 1 selector: matchLabels: {{- include "plngxftpbridge.selectorLabels" . | nindent 6 }} @@ -23,24 +26,54 @@ spec: spec: containers: - name: bridge - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default (printf "v%s" .Chart.Version) }}" + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default (printf "v%s" .Chart.AppVersion) }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + # Fully locked down: nothing in the request path touches a real + # filesystem (in-memory ClientDriver, in-memory or read-only + # mounted TLS certs -- see ADR-0001/0003/0006). + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + ports: + - name: ftp-control + containerPort: 2121 + protocol: TCP + {{- range $port := include "plngxftpbridge.pasvPorts" . | splitList " " }} + - name: ftp-pasv-{{ $port }} + containerPort: {{ $port }} + protocol: TCP + {{- end }} env: - name: LOG_LEVEL value: "{{ .Values.logLevel }}" - - name: CRON_SCHEDULE - value: "{{ .Values.interval }}" - - name: FTP_HOST - value: "{{ required "ftp.host is required" .Values.ftp.host }}" - - name: FTP_USERNAME - value: "{{ required "ftp.user is required" .Values.ftp.user }}" - - name: FTP_PASSWORD + {{- range $i, $account := .Values.ftp.accounts }} + {{- $username := required (printf "ftp.accounts[%d].username is required" $i) $account.username }} + {{- if not (regexMatch "^[A-Za-z0-9_]+$" $username) }} + {{- fail (printf "ftp.accounts[%d].username %q must contain only letters, digits, and underscores" $i $username) }} + {{- end }} + {{- $existingSecret := $account.passwordExistingSecret | default dict }} + - name: FTP_ACCOUNT_{{ $username }} valueFrom: secretKeyRef: - name: {{ default (include "plngxftpbridge.fullname" .) .Values.ftp.passwordExistingSecret.name }} - key: {{ default "ftp-password" .Values.ftp.passwordExistingSecret.key }} - - name: FTP_PATH - value: "{{ .Values.ftp.path }}" + name: {{ default (include "plngxftpbridge.fullname" $) $existingSecret.name }} + key: {{ default (printf "ftp-account-%d-password" $i) $existingSecret.key }} + {{- end }} + - name: FTP_ALLOWED_EXTENSIONS + value: "{{ join "," .Values.ftp.allowedExtensions }}" + - name: FTP_PASV_PORT_MIN + value: "{{ .Values.ftp.pasv.portMin }}" + - name: FTP_PASV_PORT_MAX + value: "{{ .Values.ftp.pasv.portMax }}" + - name: FTP_PASV_PUBLIC_HOST + value: "{{ required "ftp.pasv.publicHost is required" .Values.ftp.pasv.publicHost }}" - name: PAPERLESS_URL value: "{{ .Values.paperless.url }}" - name: PAPERLESS_USER @@ -56,4 +89,22 @@ spec: port: 8080 initialDelaySeconds: 30 periodSeconds: 30 + readinessProbe: + httpGet: + path: /readyz + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 30 + {{- with .Values.ftp.tls.existingSecret.name }} + volumeMounts: + - name: ftp-tls + mountPath: /etc/ftp/tls + readOnly: true + {{- end }} + {{- with .Values.ftp.tls.existingSecret.name }} + volumes: + - name: ftp-tls + secret: + secretName: {{ . }} + {{- end }} restartPolicy: Always diff --git a/charts/paperlessngx-ftp-bridge/templates/secrets.yaml b/charts/paperlessngx-ftp-bridge/templates/secrets.yaml index c82fc8f..756c12a 100644 --- a/charts/paperlessngx-ftp-bridge/templates/secrets.yaml +++ b/charts/paperlessngx-ftp-bridge/templates/secrets.yaml @@ -1,14 +1,14 @@ -{{- if or .Values.ftp.password .Values.paperless.password }} apiVersion: v1 kind: Secret metadata: name: "{{ include "plngxftpbridge.fullname" . }}" type: Opaque stringData: - {{- if .Values.ftp.password }} - ftp-password: {{ required "ftp.password is required" .Values.ftp.password | quote }} + {{- range $i, $account := .Values.ftp.accounts }} + {{- if $account.password }} + ftp-account-{{ $i }}-password: {{ $account.password | quote }} + {{- end }} {{- end }} {{- if .Values.paperless.password }} - paperless-password: {{ required "paperless.password is required" .Values.paperless.password | quote }} + paperless-password: {{ .Values.paperless.password | quote }} {{- end }} -{{- end }} diff --git a/charts/paperlessngx-ftp-bridge/templates/service.yaml b/charts/paperlessngx-ftp-bridge/templates/service.yaml new file mode 100644 index 0000000..a98925f --- /dev/null +++ b/charts/paperlessngx-ftp-bridge/templates/service.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: Service +metadata: + name: "{{ include "plngxftpbridge.fullname" . }}" + labels: + {{- include "plngxftpbridge.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + # Hardcoded, not a .Values knob: the control connection and every PASV + # data connection must land on the same pod and be reachable at the one + # address advertised as ftp.pasv.publicHost, so both are carried on a + # single Service. NodePort can't sanely carry the ~20 PASV ports below + # (see ADR-0006). + type: LoadBalancer + {{- with .Values.service.loadBalancerIP }} + loadBalancerIP: {{ . }} + {{- end }} + selector: + {{- include "plngxftpbridge.selectorLabels" . | nindent 4 }} + ports: + - name: ftp-control + port: 21 + targetPort: 2121 + protocol: TCP + {{- range $port := include "plngxftpbridge.pasvPorts" . | splitList " " }} + - name: ftp-pasv-{{ $port }} + port: {{ $port }} + targetPort: {{ $port }} + protocol: TCP + {{- end }} diff --git a/charts/paperlessngx-ftp-bridge/values.yaml b/charts/paperlessngx-ftp-bridge/values.yaml index 4fe7e57..3ac88e6 100644 --- a/charts/paperlessngx-ftp-bridge/values.yaml +++ b/charts/paperlessngx-ftp-bridge/values.yaml @@ -1,21 +1,54 @@ # logLevel for the bridge - DEBUG, INFO, WARNING, ERROR logLevel: INFO -# schedule to check for new documents every 5 minutes -interval: "*/5 * * * *" - -# ftp configuration where to fetch the documents +# ftp configuration for the embedded FTP/FTPS server scanners connect to +# directly (see ADR-0001/0002/0003 for the design behind this schema) ftp: - # e.g. ftp.example.org:21 - host: "" - user: "" - # password for ftp connection - password: "" - # passwordExistingSecret is used to reference an existing secret in the same namespace - passwordExistingSecret: - name: "" - key: "" - path: "." + # accounts accepted by the embedded FTP server. Usernames must be + # env-var-safe (letters, digits, underscore only) -- the chart fails to + # render otherwise. Add a second scanner by appending another entry; no + # code change is required. + accounts: + - username: "" + # password for this FTP account + password: "" + # passwordExistingSecret is used to reference an existing secret in the same namespace + passwordExistingSecret: + name: "" + key: "" + + # file extensions accepted by STOR, including the leading dot + allowedExtensions: + - ".pdf" + + # passive-mode (PASV) data connection configuration + pasv: + # port range advertised for passive data connections + portMin: 50000 + portMax: 50019 + # publicHost is the IP/host advertised to clients for PASV data + # connections. Required for scanners behind NAT to reach the data + # channel -- there is no default or auto-inference. + publicHost: "" + + # TLS certificate for explicit FTPS (AUTH TLS), always offered alongside + # plain FTP (see ADR-0003). Leave existingSecret.name empty to use the + # self-signed certificate generated in memory at startup. + tls: + existingSecret: + # name of an existing kubernetes.io/tls Secret (tls.crt/tls.key) in + # this release's namespace. When set, it is mounted read-only at the + # fixed path /etc/ftp/tls; when empty, no volume is created at all. + name: "" + +# service configures the single LoadBalancer Service that carries both the +# FTP control port and the full PASV range (see ADR-0006). service.type is +# hardcoded to LoadBalancer in the template, not a value here. +service: + # loadBalancerIP pins a stable external IP -- set this to match + # ftp.pasv.publicHost on platforms that don't assign one automatically. + loadBalancerIP: "" + annotations: {} # paperless-ngx configuration to send documents to paperless: