diff --git a/cli/ctl.py b/cli/ctl.py index 62cdbf1..d7cc814 100644 --- a/cli/ctl.py +++ b/cli/ctl.py @@ -271,6 +271,10 @@ def parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> None: args = parser().parse_args(argv, namespace=Args()) # type: Args + for attr in Args.__annotations__: + if not hasattr(args, attr): + setattr(args, attr, None) + if args.command == Commands.CERTS: services = {"mqtt", "http"} if args.service == "both" else {args.service} try: diff --git a/cli/models.py b/cli/models.py index 7068d3a..bc3eca1 100644 --- a/cli/models.py +++ b/cli/models.py @@ -202,6 +202,45 @@ def update_dotenv(self, env: dict[str, str]) -> None: pass +class TraefikSettings(BaseModel): + """Traefik settings""" + + model_config = ConfigDict(extra="ignore") + + http_port: int = Field(default=80, gt=0, lt=65536) + """HTTP port for listening to traffic (default: 80)""" + https_port: int = Field(default=443, gt=0, lt=65536) + """HTTPS port for listening to traffic (default: 443)""" + acme_email: str = Field() + """email for ACME certificate registration""" + + def update_dotenv(self, env: dict[str, str]) -> None: + env["TRAEFIK_HTTP_PORT"] = str(self.http_port) + env["TRAEFIK_HTTPS_PORT"] = str(self.https_port) + env["TRAEFIK_ACME_EMAIL"] = self.acme_email + + +class MinioSettings(BaseModel): + """MinIO settings""" + + root_user: str + """root username for MinIO""" + root_password: str + """root password for MinIO""" + api_hostname: str + """hostname for MinIO API (e.g., minio-api.local)""" + console_hostname: str + """hostname for MinIO console (e.g., minio-console.local)""" + + model_config = ConfigDict(extra="ignore") + + def update_dotenv(self, env: dict[str, str]) -> None: + env["MINIO_ROOT_USER"] = self.root_user + env["MINIO_ROOT_PASSWORD"] = self.root_password + env["MINIO_API_HOSTNAME"] = self.api_hostname + env["MINIO_CONSOLE_HOSTNAME"] = self.console_hostname + + class AppConfig(BaseModel): """Representation of the config loaded from the TOML file""" @@ -217,6 +256,10 @@ class AppConfig(BaseModel): """MQTT settings block""" hololinked: HololinkedSettings | None = None """Hololinked settings block""" + traefik: TraefikSettings | None = None + """Traefik settings block""" + minio: MinioSettings | None = None + """MinIO settings block""" model_config = ConfigDict(extra="ignore") @@ -233,6 +276,10 @@ def update_dotenv(self, env: dict[str, str]) -> None: self.mqtt.update_dotenv(env) if self.hololinked: self.hololinked.update_dotenv(env) + if self.traefik: + self.traefik.update_dotenv(env) + if self.minio: + self.minio.update_dotenv(env) def iter_databases(self) -> Iterable[Database]: if self.keycloak and self.keycloak.database: diff --git a/compose.yml b/compose.yml index cc06696..ea6dd95 100644 --- a/compose.yml +++ b/compose.yml @@ -49,7 +49,7 @@ services: - ./data/dbeaver:/opt/cloudbeaver/workspace restart: unless-stopped depends_on: - - postgres + - postgres keycloak: image: quay.io/keycloak/keycloak:26.1.3 @@ -110,12 +110,133 @@ services: volumes: - ./conf/mosquitto.conf:/mosquitto/config/mosquitto.conf - ./conf/passwords.txt:/mosquitto/config/passwords.txt - - ./data/mosquitto:/mosquitto/data + - ./data/mosquitto:/mosquitto/data - ./data/mosquitto/log:/mosquitto/log - ./data/mosquitto/persisted:/mosquitto/data/persisted/ - ./certs:/mosquitto/config/certs restart: unless-stopped + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_SERVER_URL: https://${MINIO_API_HOSTNAME} + MINIO_BROWSER_REDIRECT_URL: https://${MINIO_CONSOLE_HOSTNAME} + volumes: + - ./data/minio:/data + networks: + - proxy + labels: + - traefik.enable=true + # S3 API + - traefik.http.routers.minio-api.rule=Host(`${MINIO_API_HOSTNAME}`) + - traefik.http.routers.minio-api.entrypoints=websecure + - traefik.http.routers.minio-api.tls.certresolver=le + - traefik.http.routers.minio-api.service=minio-api-svc + - traefik.http.routers.minio-api.middlewares=secure-headers@docker + - traefik.http.services.minio-api-svc.loadbalancer.server.port=9000 + # Web console + - traefik.http.routers.minio-console.rule=Host(`${MINIO_CONSOLE_HOSTNAME}`) + - traefik.http.routers.minio-console.entrypoints=websecure + - traefik.http.routers.minio-console.tls.certresolver=le + - traefik.http.routers.minio-console.service=minio-console-svc + - traefik.http.routers.minio-console.middlewares=secure-headers@docker,console-ratelimit@docker + - traefik.http.services.minio-console-svc.loadbalancer.server.port=9001 + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + vault: + image: hashicorp/vault:1.18 + container_name: vault + restart: unless-stopped + cap_add: + - IPC_LOCK # lets Vault lock memory pages so secrets can't be swapped to disk + command: vault server -config=/vault/config/vault.hcl + environment: + VAULT_API_ADDR: https://${VAULT_HOSTNAME} + volumes: + - ./conf/vault.hcl:/vault/config/vault.hcl:ro + - ./data/vault/data:/vault/data + - ./data/vault/logs:/vault/logs + networks: + - proxy + labels: + - traefik.enable=true + - traefik.http.routers.vault.rule=Host(`${VAULT_HOSTNAME}`) + - traefik.http.routers.vault.entrypoints=websecure + - traefik.http.routers.vault.tls.certresolver=le + - traefik.http.routers.vault.middlewares=secure-headers@docker + - traefik.http.services.vault.loadbalancer.server.port=8200 + healthcheck: + # vault status exits 0=active, 2=sealed, 1=error. + # We treat sealed (2) as unhealthy on purpose — operator must unseal. + test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 20s + + traefik: + image: traefik:v3.3 + container_name: traefik + restart: unless-stopped + command: + # No exposed dashboard + - --api.dashboard=false + - --api.insecure=false + # Docker provider (reads labels from other containers) + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=proxy + # Port 80 → 443 permanent redirect + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + # HTTPS with default TLS cert resolver + - --entrypoints.websecure.address=:443 + - --entrypoints.websecure.http.tls.certresolver=le + # Let's Encrypt via TLS-ALPN-01 (no separate port-80 token exchange) + - --certificatesresolvers.le.acme.tlschallenge=true + - --certificatesresolvers.le.acme.email=${TRAEFIK_ACME_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + # Logging + - --log.level=WARN + labels: + - traefik.enable=true + # Security headers + - traefik.http.middlewares.secure-headers.headers.stsSeconds=31536000 + - traefik.http.middlewares.secure-headers.headers.stsIncludeSubdomains=true + - traefik.http.middlewares.secure-headers.headers.forceSTSHeader=true + - traefik.http.middlewares.secure-headers.headers.stsPreload=true + - traefik.http.middlewares.secure-headers.headers.contentTypeNosniff=true + - traefik.http.middlewares.secure-headers.headers.browserXssFilter=true + - traefik.http.middlewares.secure-headers.headers.frameDeny=true + - traefik.http.middlewares.secure-headers.headers.referrerPolicy=strict-origin-when-cross-origin + - traefik.http.middlewares.secure-headers.headers.permissionsPolicy=camera=(), microphone=(), geolocation=() + - "traefik.http.middlewares.secure-headers.headers.contentSecurityPolicy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'" + # Rate limiting for console (brute-force protection) + - traefik.http.middlewares.console-ratelimit.rateLimit.average=100 + - traefik.http.middlewares.console-ratelimit.rateLimit.burst=500 + - traefik.http.middlewares.console-ratelimit.rateLimit.period=2m + ports: + - "${TRAEFIK_HTTP_PORT:-80}:80" + - "${TRAEFIK_HTTPS_PORT:-443}:443" + volumes: + # Read-only Docker socket — Traefik only reads container metadata + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./data/letsencrypt:/letsencrypt + networks: + - proxy + # reverse-proxy: # image: nginx:latest # container_name: reverse-proxy @@ -129,4 +250,8 @@ services: # depends_on: # - keycloak # - dbeaver - # restart: unless-stopped \ No newline at end of file + # restart: unless-stopped + +networks: + proxy: + driver: bridge diff --git a/conf/vault.hcl b/conf/vault.hcl new file mode 100644 index 0000000..4359c2c --- /dev/null +++ b/conf/vault.hcl @@ -0,0 +1,12 @@ +ui = true +log_level = "warn" +disable_mlock = false # requires IPC_LOCK capability — set on the container + +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = 1 # TLS is terminated by Traefik upstream +} + +storage "file" { + path = "/vault/data" +} \ No newline at end of file diff --git a/config.example.toml b/config.example.toml index 000cf09..ba5de6a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -41,4 +41,15 @@ admin_password = "password" [mqtt] password_file = "conf/passwords.txt" iterations = 101 -salt_bytes = 12 \ No newline at end of file +salt_bytes = 12 + +[minio] +api_hostname = "storage.example.com" +console_hostname = "storage-console.example.com" +root_user = "minio-admin" +root_password = "change-me-use-a-strong-random-password" + +[traefik] +http_port = 80 +https_port = 443 +acme_email = "admin@example.com" \ No newline at end of file diff --git a/uv.lock b/uv.lock index 8aee100..9ac3527 100644 --- a/uv.lock +++ b/uv.lock @@ -285,6 +285,7 @@ dependencies = [ { name = "hololinked" }, { name = "pip" }, { name = "psycopg2-binary" }, + { name = "pydantic" }, { name = "sqlalchemy-utils" }, ] @@ -295,6 +296,7 @@ requires-dist = [ { name = "hololinked", specifier = ">=0.3.11" }, { name = "pip", specifier = ">=25.2" }, { name = "psycopg2-binary", specifier = ">=2.9.11" }, + { name = "pydantic", specifier = ">=2.7.0" }, { name = "sqlalchemy-utils", specifier = ">=0.42.0" }, ]