feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs

This commit is contained in:
Jason Staack
2026-03-14 09:05:14 -05:00
parent 7af08276ea
commit 970501e453
86 changed files with 3440 additions and 3764 deletions

View File

@@ -23,7 +23,7 @@ server {
# CSP for React SPA with Tailwind CSS and Leaflet maps
# worker-src required for SRP key derivation Web Worker (Safari won't fall back to script-src)
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; font-src 'self'; connect-src 'self' ws: wss:; worker-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; font-src 'self'; connect-src 'self' ws: wss:; worker-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# Proxy API requests to the backend service
# The api container is reachable via Docker internal DNS as "api" on port 8000
@@ -68,6 +68,29 @@ server {
proxy_buffers 8 512k;
}
# Proxy Xpra HTML5 client requests to the winbox-worker container
location ~ ^/xpra/(\d+)/(.*) {
resolver 127.0.0.11 valid=10s ipv6=off;
set $xpra_port $1;
set $xpra_path $2;
set $worker_upstream winbox-worker;
proxy_pass http://$worker_upstream:$xpra_port/$xpra_path$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
proxy_buffering off;
# Xpra HTML5 client uses inline event handlers and eval — override the
# strict server-level CSP. Adding any add_header in a location block
# replaces all inherited server-level add_header directives in nginx.
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: wss: data: blob:; frame-ancestors 'self';" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Serve static assets with long cache headers
# Note: add_header in a location block clears parent-block headers,
# so we re-add the essential security header for static assets.

View File

@@ -1,7 +1,7 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "mikrotik-portal.name" -}}
{{- define "the-other-dude.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
@@ -10,7 +10,7 @@ Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "mikrotik-portal.fullname" -}}
{{- define "the-other-dude.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
@@ -26,16 +26,16 @@ If release name contains chart name it will be used as a full name.
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "mikrotik-portal.chart" -}}
{{- define "the-other-dude.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels applied to all resources.
*/}}
{{- define "mikrotik-portal.labels" -}}
helm.sh/chart: {{ include "mikrotik-portal.chart" . }}
{{ include "mikrotik-portal.selectorLabels" . }}
{{- define "the-other-dude.labels" -}}
helm.sh/chart: {{ include "the-other-dude.chart" . }}
{{ include "the-other-dude.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
@@ -45,81 +45,81 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
{{/*
Selector labels used in Deployments/Services to match pods.
*/}}
{{- define "mikrotik-portal.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mikrotik-portal.name" . }}
{{- define "the-other-dude.selectorLabels" -}}
app.kubernetes.io/name: {{ include "the-other-dude.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
API component labels
*/}}
{{- define "mikrotik-portal.apiLabels" -}}
{{ include "mikrotik-portal.labels" . }}
{{- define "the-other-dude.apiLabels" -}}
{{ include "the-other-dude.labels" . }}
app.kubernetes.io/component: api
{{- end }}
{{/*
API selector labels
*/}}
{{- define "mikrotik-portal.apiSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
{{- define "the-other-dude.apiSelectorLabels" -}}
{{ include "the-other-dude.selectorLabels" . }}
app.kubernetes.io/component: api
{{- end }}
{{/*
Frontend component labels
*/}}
{{- define "mikrotik-portal.frontendLabels" -}}
{{ include "mikrotik-portal.labels" . }}
{{- define "the-other-dude.frontendLabels" -}}
{{ include "the-other-dude.labels" . }}
app.kubernetes.io/component: frontend
{{- end }}
{{/*
Frontend selector labels
*/}}
{{- define "mikrotik-portal.frontendSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
{{- define "the-other-dude.frontendSelectorLabels" -}}
{{ include "the-other-dude.selectorLabels" . }}
app.kubernetes.io/component: frontend
{{- end }}
{{/*
PostgreSQL component labels
*/}}
{{- define "mikrotik-portal.postgresLabels" -}}
{{ include "mikrotik-portal.labels" . }}
{{- define "the-other-dude.postgresLabels" -}}
{{ include "the-other-dude.labels" . }}
app.kubernetes.io/component: postgres
{{- end }}
{{/*
PostgreSQL selector labels
*/}}
{{- define "mikrotik-portal.postgresSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
{{- define "the-other-dude.postgresSelectorLabels" -}}
{{ include "the-other-dude.selectorLabels" . }}
app.kubernetes.io/component: postgres
{{- end }}
{{/*
Redis component labels
*/}}
{{- define "mikrotik-portal.redisLabels" -}}
{{ include "mikrotik-portal.labels" . }}
{{- define "the-other-dude.redisLabels" -}}
{{ include "the-other-dude.labels" . }}
app.kubernetes.io/component: redis
{{- end }}
{{/*
Redis selector labels
*/}}
{{- define "mikrotik-portal.redisSelectorLabels" -}}
{{ include "mikrotik-portal.selectorLabels" . }}
{{- define "the-other-dude.redisSelectorLabels" -}}
{{ include "the-other-dude.selectorLabels" . }}
app.kubernetes.io/component: redis
{{- end }}
{{/*
Create the name of the service account to use.
*/}}
{{- define "mikrotik-portal.serviceAccountName" -}}
{{- define "the-other-dude.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "mikrotik-portal.fullname" .) .Values.serviceAccount.name }}
{{- default (include "the-other-dude.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
@@ -129,9 +129,9 @@ Create the name of the service account to use.
Database URL for the API service (constructed from service names).
Uses external URL if postgres.enabled=false.
*/}}
{{- define "mikrotik-portal.databaseUrl" -}}
{{- define "the-other-dude.databaseUrl" -}}
{{- if .Values.postgres.enabled }}
{{- printf "postgresql+asyncpg://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.username .Values.secrets.dbPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- printf "postgresql+asyncpg://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.username .Values.secrets.dbPassword (include "the-other-dude.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- else }}
{{- .Values.postgres.externalUrl }}
{{- end }}
@@ -140,9 +140,9 @@ Uses external URL if postgres.enabled=false.
{{/*
App user database URL (RLS enforced).
*/}}
{{- define "mikrotik-portal.appUserDatabaseUrl" -}}
{{- define "the-other-dude.appUserDatabaseUrl" -}}
{{- if .Values.postgres.enabled }}
{{- printf "postgresql+asyncpg://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.appUsername .Values.secrets.dbAppPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- printf "postgresql+asyncpg://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.appUsername .Values.secrets.dbAppPassword (include "the-other-dude.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- else }}
{{- .Values.postgres.externalUrl }}
{{- end }}
@@ -151,9 +151,9 @@ App user database URL (RLS enforced).
{{/*
Sync database URL for Alembic migrations.
*/}}
{{- define "mikrotik-portal.syncDatabaseUrl" -}}
{{- define "the-other-dude.syncDatabaseUrl" -}}
{{- if .Values.postgres.enabled }}
{{- printf "postgresql+psycopg2://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.username .Values.secrets.dbPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- printf "postgresql+psycopg2://%s:%s@%s-postgres:%d/%s" .Values.postgres.auth.username .Values.secrets.dbPassword (include "the-other-dude.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database }}
{{- else }}
{{- .Values.postgres.externalUrl | replace "asyncpg" "psycopg2" }}
{{- end }}
@@ -162,9 +162,9 @@ Sync database URL for Alembic migrations.
{{/*
Redis URL (constructed from service name).
*/}}
{{- define "mikrotik-portal.redisUrl" -}}
{{- define "the-other-dude.redisUrl" -}}
{{- if .Values.redis.enabled }}
{{- printf "redis://%s-redis:%d/0" (include "mikrotik-portal.fullname" .) (int .Values.redis.service.port) }}
{{- printf "redis://%s-redis:%d/0" (include "the-other-dude.fullname" .) (int .Values.redis.service.port) }}
{{- else }}
{{- .Values.redis.externalUrl | default "redis://localhost:6379/0" }}
{{- end }}

View File

@@ -1,18 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-api
name: {{ include "the-other-dude.fullname" . }}-api
labels:
{{- include "mikrotik-portal.apiLabels" . | nindent 4 }}
{{- include "the-other-dude.apiLabels" . | nindent 4 }}
spec:
replicas: {{ .Values.api.replicaCount }}
selector:
matchLabels:
{{- include "mikrotik-portal.apiSelectorLabels" . | nindent 6 }}
{{- include "the-other-dude.apiSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.apiSelectorLabels" . | nindent 8 }}
{{- include "the-other-dude.apiSelectorLabels" . | nindent 8 }}
spec:
securityContext:
runAsNonRoot: true
@@ -29,28 +29,28 @@ spec:
# Load non-sensitive config from ConfigMap
envFrom:
- configMapRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
name: {{ include "the-other-dude.fullname" . }}-config
# Load secrets as individual environment variables
env:
- name: JWT_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: JWT_SECRET_KEY
- name: CREDENTIAL_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: CREDENTIAL_ENCRYPTION_KEY
- name: FIRST_ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: FIRST_ADMIN_EMAIL
- name: FIRST_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: FIRST_ADMIN_PASSWORD
livenessProbe:
httpGet:

View File

@@ -1,9 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-api
name: {{ include "the-other-dude.fullname" . }}-api
labels:
{{- include "mikrotik-portal.apiLabels" . | nindent 4 }}
{{- include "the-other-dude.apiLabels" . | nindent 4 }}
spec:
type: {{ .Values.api.service.type }}
ports:
@@ -12,4 +12,4 @@ spec:
protocol: TCP
name: http
selector:
{{- include "mikrotik-portal.apiSelectorLabels" . | nindent 4 }}
{{- include "the-other-dude.apiSelectorLabels" . | nindent 4 }}

View File

@@ -1,15 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-config
name: {{ include "the-other-dude.fullname" . }}-config
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- include "the-other-dude.labels" . | nindent 4 }}
data:
DATABASE_URL: {{ include "mikrotik-portal.databaseUrl" . | quote }}
SYNC_DATABASE_URL: {{ include "mikrotik-portal.syncDatabaseUrl" . | quote }}
APP_USER_DATABASE_URL: {{ include "mikrotik-portal.appUserDatabaseUrl" . | quote }}
REDIS_URL: {{ include "mikrotik-portal.redisUrl" . | quote }}
NATS_URL: {{ printf "nats://%s-nats:%d" (include "mikrotik-portal.fullname" .) (int .Values.nats.service.port) | quote }}
DATABASE_URL: {{ include "the-other-dude.databaseUrl" . | quote }}
SYNC_DATABASE_URL: {{ include "the-other-dude.syncDatabaseUrl" . | quote }}
APP_USER_DATABASE_URL: {{ include "the-other-dude.appUserDatabaseUrl" . | quote }}
REDIS_URL: {{ include "the-other-dude.redisUrl" . | quote }}
NATS_URL: {{ printf "nats://%s-nats:%d" (include "the-other-dude.fullname" .) (int .Values.nats.service.port) | quote }}
JWT_ALGORITHM: "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: {{ .Values.api.env.jwtAccessTokenExpireMinutes | quote }}
JWT_REFRESH_TOKEN_EXPIRE_DAYS: {{ .Values.api.env.jwtRefreshTokenExpireDays | quote }}

View File

@@ -1,18 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-frontend
name: {{ include "the-other-dude.fullname" . }}-frontend
labels:
{{- include "mikrotik-portal.frontendLabels" . | nindent 4 }}
{{- include "the-other-dude.frontendLabels" . | nindent 4 }}
spec:
replicas: {{ .Values.frontend.replicaCount }}
selector:
matchLabels:
{{- include "mikrotik-portal.frontendSelectorLabels" . | nindent 6 }}
{{- include "the-other-dude.frontendSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.frontendSelectorLabels" . | nindent 8 }}
{{- include "the-other-dude.frontendSelectorLabels" . | nindent 8 }}
spec:
containers:
- name: frontend
@@ -42,9 +42,9 @@ spec:
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-frontend
name: {{ include "the-other-dude.fullname" . }}-frontend
labels:
{{- include "mikrotik-portal.frontendLabels" . | nindent 4 }}
{{- include "the-other-dude.frontendLabels" . | nindent 4 }}
spec:
type: {{ .Values.frontend.service.type }}
ports:
@@ -53,4 +53,4 @@ spec:
protocol: TCP
name: http
selector:
{{- include "mikrotik-portal.frontendSelectorLabels" . | nindent 4 }}
{{- include "the-other-dude.frontendSelectorLabels" . | nindent 4 }}

View File

@@ -2,9 +2,9 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mikrotik-portal.fullname" . }}
name: {{ include "the-other-dude.fullname" . }}
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- include "the-other-dude.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
@@ -16,11 +16,11 @@ spec:
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host | default "mikrotik-portal.local" | quote }}
secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" (include "mikrotik-portal.fullname" .)) | quote }}
- {{ .Values.ingress.host | default "the-other-dude.local" | quote }}
secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" (include "the-other-dude.fullname" .)) | quote }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | default "mikrotik-portal.local" | quote }}
- host: {{ .Values.ingress.host | default "the-other-dude.local" | quote }}
http:
paths:
# API routes — send /api/* to the FastAPI service
@@ -28,7 +28,7 @@ spec:
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-api
name: {{ include "the-other-dude.fullname" . }}-api
port:
number: {{ .Values.api.service.port }}
# Docs routes — proxy /docs and /redoc to API as well
@@ -36,14 +36,14 @@ spec:
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-api
name: {{ include "the-other-dude.fullname" . }}-api
port:
number: {{ .Values.api.service.port }}
- path: /redoc
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-api
name: {{ include "the-other-dude.fullname" . }}-api
port:
number: {{ .Values.api.service.port }}
# Frontend SPA — all other routes go to nginx
@@ -51,7 +51,7 @@ spec:
pathType: Prefix
backend:
service:
name: {{ include "mikrotik-portal.fullname" . }}-frontend
name: {{ include "the-other-dude.fullname" . }}-frontend
port:
number: {{ .Values.frontend.service.port }}
{{- end }}

View File

@@ -4,9 +4,9 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-nats-headless
name: {{ include "the-other-dude.fullname" . }}-nats-headless
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- include "the-other-dude.labels" . | nindent 4 }}
app.kubernetes.io/component: nats
spec:
clusterIP: None
@@ -15,16 +15,16 @@ spec:
port: 4222
targetPort: 4222
selector:
{{- include "mikrotik-portal.selectorLabels" . | nindent 4 }}
{{- include "the-other-dude.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: nats
---
# NATS ClusterIP service for client access
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-nats
name: {{ include "the-other-dude.fullname" . }}-nats
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- include "the-other-dude.labels" . | nindent 4 }}
app.kubernetes.io/component: nats
spec:
type: ClusterIP
@@ -36,28 +36,28 @@ spec:
port: 8222
targetPort: 8222
selector:
{{- include "mikrotik-portal.selectorLabels" . | nindent 4 }}
{{- include "the-other-dude.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: nats
---
# NATS JetStream StatefulSet (needs stable storage)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-nats
name: {{ include "the-other-dude.fullname" . }}-nats
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- include "the-other-dude.labels" . | nindent 4 }}
app.kubernetes.io/component: nats
spec:
replicas: 1
serviceName: {{ include "mikrotik-portal.fullname" . }}-nats-headless
serviceName: {{ include "the-other-dude.fullname" . }}-nats-headless
selector:
matchLabels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 6 }}
{{- include "the-other-dude.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: nats
template:
metadata:
labels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 8 }}
{{- include "the-other-dude.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: nats
spec:
containers:

View File

@@ -2,20 +2,20 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-poller
name: {{ include "the-other-dude.fullname" . }}-poller
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- include "the-other-dude.labels" . | nindent 4 }}
app.kubernetes.io/component: poller
spec:
replicas: {{ .Values.poller.replicaCount }}
selector:
matchLabels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 6 }}
{{- include "the-other-dude.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: poller
template:
metadata:
labels:
{{- include "mikrotik-portal.selectorLabels" . | nindent 8 }}
{{- include "the-other-dude.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: poller
spec:
containers:
@@ -26,32 +26,32 @@ spec:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: POLLER_DATABASE_URL
- name: CREDENTIAL_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: CREDENTIAL_ENCRYPTION_KEY
- name: NATS_URL
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
name: {{ include "the-other-dude.fullname" . }}-config
key: NATS_URL
- name: REDIS_URL
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
name: {{ include "the-other-dude.fullname" . }}-config
key: REDIS_URL
- name: POLL_INTERVAL_SECONDS
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
name: {{ include "the-other-dude.fullname" . }}-config
key: POLL_INTERVAL_SECONDS
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-config
name: {{ include "the-other-dude.fullname" . }}-config
key: POLLER_LOG_LEVEL
resources:
requests:

View File

@@ -2,19 +2,19 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-postgres
name: {{ include "the-other-dude.fullname" . }}-postgres
labels:
{{- include "mikrotik-portal.postgresLabels" . | nindent 4 }}
{{- include "the-other-dude.postgresLabels" . | nindent 4 }}
spec:
serviceName: {{ include "mikrotik-portal.fullname" . }}-postgres
serviceName: {{ include "the-other-dude.fullname" . }}-postgres
replicas: 1
selector:
matchLabels:
{{- include "mikrotik-portal.postgresSelectorLabels" . | nindent 6 }}
{{- include "the-other-dude.postgresSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.postgresSelectorLabels" . | nindent 8 }}
{{- include "the-other-dude.postgresSelectorLabels" . | nindent 8 }}
spec:
containers:
- name: postgres
@@ -32,14 +32,14 @@ spec:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: DB_PASSWORD
- name: APP_USER
value: {{ .Values.postgres.auth.appUsername | quote }}
- name: APP_USER_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
key: DB_APP_PASSWORD
volumeMounts:
- name: postgres-data
@@ -74,7 +74,7 @@ spec:
volumes:
- name: init-scripts
configMap:
name: {{ include "mikrotik-portal.fullname" . }}-postgres-init
name: {{ include "the-other-dude.fullname" . }}-postgres-init
volumeClaimTemplates:
- metadata:
name: postgres-data
@@ -90,9 +90,9 @@ spec:
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-postgres
name: {{ include "the-other-dude.fullname" . }}-postgres
labels:
{{- include "mikrotik-portal.postgresLabels" . | nindent 4 }}
{{- include "the-other-dude.postgresLabels" . | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
@@ -102,14 +102,14 @@ spec:
protocol: TCP
name: postgres
selector:
{{- include "mikrotik-portal.postgresSelectorLabels" . | nindent 4 }}
{{- include "the-other-dude.postgresSelectorLabels" . | nindent 4 }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-postgres-init
name: {{ include "the-other-dude.fullname" . }}-postgres-init
labels:
{{- include "mikrotik-portal.postgresLabels" . | nindent 4 }}
{{- include "the-other-dude.postgresLabels" . | nindent 4 }}
data:
init.sql: |
-- Create non-superuser app_user role for RLS enforcement

View File

@@ -2,18 +2,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-redis
name: {{ include "the-other-dude.fullname" . }}-redis
labels:
{{- include "mikrotik-portal.redisLabels" . | nindent 4 }}
{{- include "the-other-dude.redisLabels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "mikrotik-portal.redisSelectorLabels" . | nindent 6 }}
{{- include "the-other-dude.redisSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mikrotik-portal.redisSelectorLabels" . | nindent 8 }}
{{- include "the-other-dude.redisSelectorLabels" . | nindent 8 }}
spec:
containers:
- name: redis
@@ -45,9 +45,9 @@ spec:
apiVersion: v1
kind: Service
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-redis
name: {{ include "the-other-dude.fullname" . }}-redis
labels:
{{- include "mikrotik-portal.redisLabels" . | nindent 4 }}
{{- include "the-other-dude.redisLabels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
@@ -56,5 +56,5 @@ spec:
protocol: TCP
name: redis
selector:
{{- include "mikrotik-portal.redisSelectorLabels" . | nindent 4 }}
{{- include "the-other-dude.redisSelectorLabels" . | nindent 4 }}
{{- end }}

View File

@@ -1,9 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "mikrotik-portal.fullname" . }}-secrets
name: {{ include "the-other-dude.fullname" . }}-secrets
labels:
{{- include "mikrotik-portal.labels" . | nindent 4 }}
{{- include "the-other-dude.labels" . | nindent 4 }}
type: Opaque
stringData:
JWT_SECRET_KEY: {{ .Values.secrets.jwtSecretKey | quote }}
@@ -12,4 +12,4 @@ stringData:
FIRST_ADMIN_PASSWORD: {{ .Values.secrets.firstAdminPassword | quote }}
DB_PASSWORD: {{ .Values.secrets.dbPassword | quote }}
DB_APP_PASSWORD: {{ .Values.secrets.dbAppPassword | quote }}
POLLER_DATABASE_URL: {{ printf "postgres://poller_user:%s@%s-postgres:%d/%s" .Values.secrets.dbPollerPassword (include "mikrotik-portal.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database | quote }}
POLLER_DATABASE_URL: {{ printf "postgres://poller_user:%s@%s-postgres:%d/%s" .Values.secrets.dbPollerPassword (include "the-other-dude.fullname" .) (int .Values.postgres.service.port) .Values.postgres.auth.database | quote }}

View File

@@ -1,4 +1,4 @@
# Default values for mikrotik-portal.
# Default values for the-other-dude.
# These values should work with `helm install` out of the box for development.
# Production deployments MUST override secrets.jwtSecretKey, secrets.credentialEncryptionKey,
# and secrets.firstAdminPassword.
@@ -10,7 +10,7 @@ api:
replicaCount: 1
image:
repository: mikrotik-portal/api
repository: the-other-dude/api
tag: latest
pullPolicy: IfNotPresent
@@ -54,7 +54,7 @@ frontend:
replicaCount: 1
image:
repository: mikrotik-portal/frontend
repository: the-other-dude/frontend
tag: latest
pullPolicy: IfNotPresent
@@ -161,7 +161,7 @@ poller:
replicaCount: 2
image:
repository: mikrotik-portal/poller
repository: the-other-dude/poller
tag: latest
pullPolicy: IfNotPresent
@@ -191,7 +191,7 @@ ingress:
tls:
enabled: false
# secretName: mikrotik-portal-tls
# secretName: the-other-dude-tls
# -----------------------------------------------------------------------
# Secrets
@@ -206,7 +206,7 @@ secrets:
credentialEncryptionKey: ""
# First admin account (created on first startup)
firstAdminEmail: "admin@mikrotik-portal.local"
firstAdminEmail: "admin@the-other-dude.local"
firstAdminPassword: ""
# PostgreSQL superuser password

View File

@@ -0,0 +1,11 @@
storage "file" {
path = "/openbao/data"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = true
}
api_addr = "http://127.0.0.1:8200"
ui = false

View File

@@ -1,31 +1,107 @@
#!/bin/sh
#!/bin/sh
# OpenBao Transit initialization script
# Runs after OpenBao starts in dev mode
# Handles first-run init, sealed unseal, and already-unsealed cases
set -e
export BAO_ADDR="http://127.0.0.1:8200"
export BAO_TOKEN="${BAO_DEV_ROOT_TOKEN_ID:-dev-openbao-token}"
# Wait for OpenBao to be ready
# ---------------------------------------------------------------------------
# Wait for OpenBao HTTP listener to accept connections.
# We hit /v1/sys/health which returns 200 (unsealed), 429 (standby),
# 472 (perf-standby), 501 (uninitialized), or 503 (sealed).
# Any HTTP response means the server is up; connection refused means not yet.
# ---------------------------------------------------------------------------
echo "Waiting for OpenBao to start..."
until bao status >/dev/null 2>&1; do
until wget -qO /dev/null http://127.0.0.1:8200/v1/sys/health 2>/dev/null; do
# wget returns 0 only on 2xx; for 4xx/5xx it returns 8.
# But connection refused returns 4. Check if we got ANY HTTP response.
rc=0
wget -S -qO /dev/null http://127.0.0.1:8200/v1/sys/health 2>&1 | grep -q "HTTP/" && break
sleep 0.5
done
echo "OpenBao is ready"
# Enable Transit secrets engine (idempotent - ignores "already enabled" errors)
# ---------------------------------------------------------------------------
# Determine current state via structured output
# ---------------------------------------------------------------------------
STATUS_JSON="$(bao status -format=json 2>/dev/null || true)"
INITIALIZED="$(echo "$STATUS_JSON" | grep '"initialized"' | head -1 | awk -F: '{gsub(/[^a-z]/, "", $2); print $2}')"
SEALED="$(echo "$STATUS_JSON" | grep '"sealed"' | head -1 | awk -F: '{gsub(/[^a-z]/, "", $2); print $2}')"
# ---------------------------------------------------------------------------
# Scenario 1 First run (not initialized)
# ---------------------------------------------------------------------------
if [ "$INITIALIZED" != "true" ]; then
echo "OpenBao is not initialized — running first-time setup..."
INIT_JSON="$(bao operator init -key-shares=1 -key-threshold=1 -format=json)"
UNSEAL_KEY="$(echo "$INIT_JSON" | grep '"unseal_keys_b64"' -A1 | tail -1 | tr -d ' ",[]\r')"
ROOT_TOKEN="$(echo "$INIT_JSON" | grep '"root_token"' | awk -F'"' '{print $4}')"
export BAO_TOKEN="$ROOT_TOKEN"
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo " OPENBAO FIRST-RUN CREDENTIALS — SAVE THESE TO .env"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo " BAO_UNSEAL_KEY=$UNSEAL_KEY"
echo " OPENBAO_TOKEN=$ROOT_TOKEN"
echo ""
echo " Add both values to your .env file so subsequent starts"
echo " can unseal and authenticate automatically."
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "Unsealing OpenBao..."
bao operator unseal "$UNSEAL_KEY"
# ---------------------------------------------------------------------------
# Scenario 2 Sealed, key provided
# ---------------------------------------------------------------------------
elif [ "$SEALED" = "true" ]; then
if [ -z "$BAO_UNSEAL_KEY" ]; then
echo "ERROR: OpenBao is sealed but BAO_UNSEAL_KEY is not set." >&2
echo " Provide BAO_UNSEAL_KEY in the environment or .env file." >&2
exit 1
fi
echo "OpenBao is sealed — unsealing..."
bao operator unseal "$BAO_UNSEAL_KEY"
# ---------------------------------------------------------------------------
# Scenario 3 Already unsealed
# ---------------------------------------------------------------------------
else
echo "OpenBao is already unsealed"
fi
# ---------------------------------------------------------------------------
# Verify BAO_TOKEN is available for Transit setup
# (Scenario 1 exports it from init output; Scenarios 2/3 inherit from env)
# ---------------------------------------------------------------------------
if [ -z "$BAO_TOKEN" ]; then
echo "ERROR: BAO_TOKEN is not set. Set OPENBAO_TOKEN in .env / .env.prod." >&2
exit 1
fi
export BAO_TOKEN
# ---------------------------------------------------------------------------
# Transit engine + policy setup (idempotent)
# ---------------------------------------------------------------------------
echo "Configuring Transit engine and policies..."
bao secrets enable transit 2>/dev/null || true
echo "Transit engine enabled"
# Create policy for the API backend (full Transit access)
bao policy write api-policy - <<'POLICY'
path "transit/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
POLICY
# Create policy for the Go poller (encrypt + decrypt only)
bao policy write poller-policy - <<'POLICY'
path "transit/decrypt/tenant_*" {
capabilities = ["update"]

View File

@@ -0,0 +1,71 @@
# The Other Dude — Apache reverse proxy example
#
# Required modules:
# a2enmod proxy proxy_http proxy_wstunnel rewrite ssl headers
#
# This config assumes:
# - TOD frontend runs on FRONTEND_HOST:3000
# - TOD API runs on API_HOST:8001
# - WinBox worker Xpra ports are on WORKER_HOST:10100-10119
#
# Replace tod.example.com and upstream addresses with your values.
<VirtualHost *:80>
ServerName tod.example.com
RewriteEngine On
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>
<VirtualHost *:443>
ServerName tod.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/certs/tod.example.com.pem
SSLCertificateKeyFile /etc/ssl/private/tod.example.com.key
# ── Security headers ──────────────────────────────────────────────
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# ── Xpra (Remote WinBox) ─────────────────────────────────────────
# Must appear BEFORE the general proxy rules.
# WebSocket upgrade is required. Do NOT enable mod_deflate on this path
# — compressing WebSocket binary frames corrupts Xpra mouse/keyboard data.
#
# ProxyPassMatch uses regex to capture the port and forward to the worker.
# Ports 10100-10119 (up to 20 concurrent sessions).
RewriteEngine On
# WebSocket upgrade for Xpra
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^/xpra/(\d+)/(.*)$ ws://YOUR_TOD_HOST:$1/$2 [P,L]
# Regular HTTP requests for Xpra HTML5 client assets
ProxyPassMatch "^/xpra/(\d+)/(.*)" "http://YOUR_TOD_HOST:$1/$2"
# Relaxed CSP for Xpra HTML5 client (inline scripts + eval)
<LocationMatch "^/xpra/">
Header always set Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: wss: data: blob:; frame-ancestors 'self';"
SetEnv no-gzip 1
</LocationMatch>
# ── API ───────────────────────────────────────────────────────────
ProxyPass /api/ http://YOUR_TOD_HOST:8001/api/
ProxyPassReverse /api/ http://YOUR_TOD_HOST:8001/api/
ProxyTimeout 300
RequestHeader set X-Forwarded-Proto "https"
<Location /api/>
# Let the API set its own CSP
Header unset Content-Security-Policy
</Location>
# ── Frontend (SPA) ────────────────────────────────────────────────
ProxyPass / http://YOUR_TOD_HOST:3000/
ProxyPassReverse / http://YOUR_TOD_HOST:3000/
ProxyPreserveHost On
</VirtualHost>

View File

@@ -0,0 +1,71 @@
# The Other Dude — Caddy reverse proxy example
#
# This config assumes:
# - TOD frontend runs on FRONTEND_HOST:3000
# - TOD API runs on API_HOST:8001
# - WinBox worker Xpra ports are on WORKER_HOST:10100-10119
#
# Replace tod.example.com and the upstream IPs with your values.
# Caddy handles TLS automatically via Let's Encrypt.
tod.example.com {
log {
output file /var/log/caddy/tod.log {
roll_size 50mb
roll_keep 5
}
format json
}
encode zstd gzip
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# ── Xpra (Remote WinBox) ──────────────────────────────────────────
# Proxies the Xpra HTML5 client to winbox-worker Xpra ports.
# Port range 10100-10119 (up to 20 concurrent sessions).
# Uses scoped compression to avoid corrupting WebSocket binary frames.
@xpra path_regexp xpra ^/xpra/(101[0-1][0-9])/(.*)$
handle @xpra {
# Override parent encode — only compress text assets, NOT WebSocket frames
encode {
gzip
match {
header Content-Type text/*
header Content-Type application/javascript*
header Content-Type application/json*
}
}
uri strip_prefix /xpra/{re.xpra.1}
reverse_proxy {$WORKER_HOST:YOUR_TOD_HOST}:{re.xpra.1} {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
# ── API ───────────────────────────────────────────────────────────
handle /api/* {
reverse_proxy http://{$API_HOST:YOUR_TOD_HOST}:8001 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
dial_timeout 30s
response_header_timeout 60s
}
}
}
# ── Frontend (SPA) ────────────────────────────────────────────────
handle {
reverse_proxy http://{$FRONTEND_HOST:YOUR_TOD_HOST}:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
}

View File

@@ -0,0 +1,77 @@
# The Other Dude — HAProxy reverse proxy example
#
# This config assumes:
# - TOD frontend runs on FRONTEND_HOST:3000
# - TOD API runs on API_HOST:8001
# - WinBox worker Xpra ports are on WORKER_HOST:10100-10119
# - TLS is terminated by HAProxy
#
# Replace tod.example.com and upstream addresses with your values.
#
# IMPORTANT: Do NOT enable compression on the xpra backend —
# compressing WebSocket binary frames corrupts Xpra mouse/keyboard data.
global
log stdout format raw local0
maxconn 4096
defaults
log global
mode http
option httplog
timeout connect 10s
timeout client 300s
timeout server 300s
timeout tunnel 3600s
# ── Frontend ─────────────────────────────────────────────────────────
frontend https
bind *:443 ssl crt /etc/ssl/certs/tod.example.com.pem
bind *:80
redirect scheme https code 301 if !{ ssl_fc }
# Security headers
http-response set-header X-Frame-Options "SAMEORIGIN"
http-response set-header X-Content-Type-Options "nosniff"
http-response set-header Referrer-Policy "strict-origin-when-cross-origin"
# Routing rules (order matters — first match wins)
acl is_xpra path_beg /xpra/
acl is_api path_beg /api/
use_backend xpra if is_xpra
use_backend api if is_api
default_backend frontend
# ── Backends ─────────────────────────────────────────────────────────
backend api
option forwardfor
http-request set-header X-Forwarded-Proto https
server api1 YOUR_TOD_HOST:8001 check
backend frontend
option forwardfor
server fe1 YOUR_TOD_HOST:3000 check
# Xpra backend — uses a Lua or map-based approach to extract the port
# from the URL path. This example covers port 10100; add servers for
# 10101-10119 as needed, or use HAProxy's Lua scripting for dynamic routing.
#
# WARNING: Do NOT add "compression" directives to this backend.
backend xpra
option forwardfor
# Strip /xpra/{port} prefix
http-request set-path %[path,regsub(^/xpra/[0-9]+/,/)]
# Route to the correct port based on URL
# For dynamic port routing, use a map file or Lua script.
# Static example for port 10100:
acl xpra_10100 path_beg /xpra/10100/
use-server xpra10100 if xpra_10100
server xpra10100 YOUR_TOD_HOST:10100 check
# server xpra10101 YOUR_TOD_HOST:10101 check
# ... add through 10119 as needed

View File

@@ -0,0 +1,90 @@
# The Other Dude — nginx reverse proxy example
#
# This config assumes:
# - TOD frontend runs on FRONTEND_HOST:3000
# - TOD API runs on API_HOST:8001
# - WinBox worker Xpra ports are on WORKER_HOST:10100-10119
# - TLS is terminated by nginx (or upstream load balancer)
#
# Replace tod.example.com and upstream addresses with your values.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream tod_frontend {
server YOUR_TOD_HOST:3000;
}
upstream tod_api {
server YOUR_TOD_HOST:8001;
}
server {
listen 80;
server_name tod.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name tod.example.com;
ssl_certificate /etc/ssl/certs/tod.example.com.pem;
ssl_certificate_key /etc/ssl/private/tod.example.com.key;
# ── Security headers ──────────────────────────────────────────────
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; font-src 'self'; connect-src 'self' ws: wss:; worker-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# ── API ───────────────────────────────────────────────────────────
location /api/ {
proxy_pass http://tod_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_hide_header Content-Security-Policy;
}
# ── Xpra (Remote WinBox) ─────────────────────────────────────────
# Proxies Xpra HTML5 client to winbox-worker ports 10100-10119.
# WebSocket support is required. Do NOT enable gzip on this location
# — compressing WebSocket binary frames corrupts Xpra mouse/keyboard data.
location ~ ^/xpra/(\d+)/(.*) {
set $xpra_port $1;
set $xpra_path $2;
proxy_pass http://YOUR_TOD_HOST:$xpra_port/$xpra_path$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
proxy_buffering off;
# Xpra HTML5 client needs relaxed CSP (inline scripts + eval)
# Adding add_header in a location block replaces all server-level headers in nginx
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: wss: data: blob:; frame-ancestors 'self';" always;
add_header X-Content-Type-Options "nosniff" always;
}
# ── Frontend (SPA) ────────────────────────────────────────────────
location / {
proxy_pass http://tod_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,93 @@
# The Other Dude — Traefik dynamic configuration example
#
# This config assumes:
# - TOD frontend runs on FRONTEND_HOST:3000
# - TOD API runs on API_HOST:8001
# - WinBox worker Xpra ports are on WORKER_HOST:10100-10119
# - Traefik entrypoints: web (80) and websecure (443)
#
# Replace tod.example.com and upstream addresses with your values.
#
# For Docker-based Traefik, labels can replace this file.
# This example uses file provider for clarity.
http:
routers:
# ── Xpra (Remote WinBox) ────────────────────────────────────────
# Must be higher priority than the frontend catch-all.
# Each Xpra port needs its own service since Traefik doesn't
# support dynamic port extraction from path regex.
# Shown for port 10100; duplicate for 10101-10119 as needed.
tod-xpra-10100:
rule: "Host(`tod.example.com`) && PathPrefix(`/xpra/10100/`)"
entryPoints: [websecure]
service: tod-xpra-10100
middlewares: [xpra-strip, xpra-headers]
tls:
certResolver: letsencrypt
priority: 30
# ── API ─────────────────────────────────────────────────────────
tod-api:
rule: "Host(`tod.example.com`) && PathPrefix(`/api/`)"
entryPoints: [websecure]
service: tod-api
middlewares: [security-headers]
tls:
certResolver: letsencrypt
priority: 20
# ── Frontend (SPA) ──────────────────────────────────────────────
tod-frontend:
rule: "Host(`tod.example.com`)"
entryPoints: [websecure]
service: tod-frontend
middlewares: [security-headers]
tls:
certResolver: letsencrypt
priority: 10
services:
tod-xpra-10100:
loadBalancer:
servers:
- url: "http://YOUR_TOD_HOST:10100"
# Add tod-xpra-10101 through tod-xpra-10119 as needed
tod-api:
loadBalancer:
servers:
- url: "http://YOUR_TOD_HOST:8001"
tod-frontend:
loadBalancer:
servers:
- url: "http://YOUR_TOD_HOST:3000"
middlewares:
xpra-strip:
# Strip /xpra/{port} prefix before forwarding
stripPrefixRegex:
regex: ["^/xpra/[0-9]+"]
xpra-headers:
headers:
# Relaxed CSP for Xpra HTML5 client (inline scripts + eval)
customResponseHeaders:
Content-Security-Policy: "default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: wss: data: blob:; frame-ancestors 'self';"
X-Content-Type-Options: "nosniff"
# IMPORTANT: Disable compression for Xpra — compressing WebSocket
# binary frames corrupts mouse/keyboard coordinate data.
security-headers:
headers:
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders:
X-Frame-Options: "SAMEORIGIN"
# IMPORTANT: Disable Traefik's built-in compression for Xpra routes.
# If using --entrypoints.websecure.http.middlewares=compress@...,
# exclude the xpra router or WebSocket binary frames will be corrupted.