Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

<!-- end charts -->
Expand Down
7 changes: 5 additions & 2 deletions charts/paperlessngx-ftp-bridge/Chart.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<appVersion>); bumped manually when the bridge repo releases
appVersion: "2.0.0"
12 changes: 6 additions & 6 deletions charts/paperlessngx-ftp-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@

# 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

The configuration of the application will be set in the [`values.yaml`](./values.yaml)-file.
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.
Expand Down
22 changes: 16 additions & 6 deletions charts/paperlessngx-ftp-bridge/ci/ct-values.yaml
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions charts/paperlessngx-ftp-bridge/docs/MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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
```
10 changes: 10 additions & 0 deletions charts/paperlessngx-ftp-bridge/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/}}
Expand Down
75 changes: 63 additions & 12 deletions charts/paperlessngx-ftp-bridge/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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
10 changes: 5 additions & 5 deletions charts/paperlessngx-ftp-bridge/templates/secrets.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
33 changes: 33 additions & 0 deletions charts/paperlessngx-ftp-bridge/templates/service.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
61 changes: 47 additions & 14 deletions charts/paperlessngx-ftp-bridge/values.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading