diff --git a/API Server/README.md b/API Server/README.md index c466fdb..95e6152 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -379,6 +379,22 @@ Copy `.env.example` to `.env` and fill in. Summary: | `OCPP_PUBLIC_URL` | — | canonical `ws(s)://` base to point chargers at | | `PB_BOOTSTRAP` | `true` | run the on-boot schema create/reconcile (leave on across upgrades) | | `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD` / `_NAME` | — / — / `Administrator` | first `superadmin`, created on boot when absent | +| `PB_S3_ENABLED` | `false` | keep PocketBase's record files in an S3 bucket instead of on its own volume | +| `PB_S3_BUCKET` | `drivervault` | the bucket; it must already exist | +| `PB_S3_ENDPOINT` | — | e.g. `http://seaweedfs:8333`. No default: in-stack and external gateways are different addresses | +| `PB_S3_REGION` | `us-east-1` | SeaweedFS ignores it, PocketBase insists on one | +| `PB_S3_ACCESS_KEY` / `PB_S3_SECRET` | — | S3 credentials | +| `PB_S3_FORCE_PATH_STYLE` | `true` | path-style bucket addressing; `false` for AWS S3 proper | + +The `PB_S3_*` block is applied by the same on-boot bootstrap that creates the +collections, and only when **all** of bucket, endpoint and credentials are set — +a half-filled config logs a warning and leaves uploads on the local volume. It +writes PocketBase's *Files storage* settings and nothing else: backups stay +where they are, and it never turns S3 back *off*, since files already in a bucket +are reachable only while PocketBase still points at it. Attachments are served +through this server either way ([`internal/api/attachments.go`](internal/api/attachments.go)), +so no client can tell the difference. See [`../Docker`](../Docker) for the compose +files that set these. `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are still honoured for older deployments; the modern names win when both are set. diff --git a/API Server/cmd/server/main.go b/API Server/cmd/server/main.go index 486a35c..da3dc78 100644 --- a/API Server/cmd/server/main.go +++ b/API Server/cmd/server/main.go @@ -51,12 +51,31 @@ func main() { // bad service account, must not stop the panel from coming up so a superadmin // can log in and fix the connection. if cfg.Bootstrap && cfg.AdminConfigured() { + // Record files go to S3 only when the whole bucket is described. Asking + // for it and leaving half of it blank is a misconfiguration worth saying + // out loud, not a reason to point PocketBase at nowhere. + var s3 *bootstrap.S3Options + if cfg.StorageConfigured() { + s3 = &bootstrap.S3Options{ + Enabled: true, + Bucket: cfg.S3Bucket, + Region: cfg.S3Region, + Endpoint: cfg.S3Endpoint, + AccessKey: cfg.S3AccessKey, + Secret: cfg.S3Secret, + ForcePathStyle: cfg.S3ForcePathStyle, + } + } else if cfg.S3Enabled { + log.Println("WARNING: PB_S3_ENABLED is set but the bucket, endpoint or credentials are incomplete — file storage stays on the local volume") + } + bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) if err := bootstrap.Run(bootCtx, client, bootstrap.Options{ UsersCollection: cfg.UsersCollection, SuperAdminEmail: cfg.SuperAdminEmail, SuperAdminPassword: cfg.SuperAdminPassword, SuperAdminName: cfg.SuperAdminName, + S3: s3, }); err != nil { log.Printf("WARNING: bootstrap failed: %v", err) } else { diff --git a/API Server/internal/bootstrap/bootstrap.go b/API Server/internal/bootstrap/bootstrap.go index 20cab31..37e86af 100644 --- a/API Server/internal/bootstrap/bootstrap.go +++ b/API Server/internal/bootstrap/bootstrap.go @@ -29,6 +29,24 @@ type Options struct { SuperAdminEmail string SuperAdminPassword string SuperAdminName string + + // S3, when non-nil and Enabled, points PocketBase's record-file storage at + // a bucket. Nil is the normal case — a stack started without one of the + // SeaweedFS compose overlays keeps its uploads on the pb_data volume. + S3 *S3Options +} + +// S3Options describes the bucket PocketBase should keep record files in. It +// mirrors PocketBase's own settings block one field at a time, so there is +// nothing to translate at the wire. +type S3Options struct { + Enabled bool + Bucket string + Region string + Endpoint string + AccessKey string + Secret string + ForcePathStyle bool } // fieldDef is a schema field normalized to a single shape; it is rendered into @@ -190,6 +208,10 @@ func Run(ctx context.Context, client *pb.Client, opts Options) error { if err := ensureSuperAdmin(ctx, client, opts); err != nil { return fmt.Errorf("super-admin: %w", err) } + + if err := ensureFileStorage(ctx, client, opts.S3); err != nil { + return fmt.Errorf("file storage: %w", err) + } return nil } @@ -433,6 +455,102 @@ func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) erro return nil } +// --- file storage ---------------------------------------------------------- + +// storageSettings is the slice of PocketBase's settings this step owns. The +// json names are PocketBase's own (core.S3Config), so the PATCH body is just +// this type marshalled back out. +type storageSettings struct { + Enabled bool `json:"enabled"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` + AccessKey string `json:"accessKey"` + Secret string `json:"secret,omitempty"` + ForcePathStyle bool `json:"forcePathStyle"` +} + +// sameExceptSecret compares everything a read of the settings can be trusted +// on. PocketBase masks the stored secret, so a rotation of the secret alone is +// invisible from here — changing any other PB_S3_* value forces the write, and +// so does editing it in the admin UI. +func (s storageSettings) sameExceptSecret(other storageSettings) bool { + s.Secret, other.Secret = "", "" + return s == other +} + +// ensureFileStorage points PocketBase's record-file storage at the configured +// bucket, and does nothing at all when no bucket was asked for. +// +// It never turns S3 *off*: files already written to a bucket are only reachable +// while PocketBase is still pointed at it, so dropping the overlay leaves the +// setting where it is rather than stranding every existing attachment. Moving +// back to local storage is a deliberate act in the admin UI. +func ensureFileStorage(ctx context.Context, client *pb.Client, opts *S3Options) error { + if opts == nil || !opts.Enabled { + return nil // not requested + } + want := storageSettings{ + Enabled: true, + Bucket: opts.Bucket, + Region: opts.Region, + Endpoint: opts.Endpoint, + AccessKey: opts.AccessKey, + Secret: opts.Secret, + ForcePathStyle: opts.ForcePathStyle, + } + + raw, status, err := client.Raw(ctx, http.MethodGet, "/api/settings", nil) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("read settings: status %d: %s", status, raw) + } + var current struct { + S3 storageSettings `json:"s3"` + } + if err := json.Unmarshal(raw, ¤t); err != nil { + return err + } + + if current.S3.sameExceptSecret(want) { + log.Printf("bootstrap: • file storage already on S3 (%s)", want.Bucket) + } else { + // Only the s3 block is sent: everything else in the settings — mail, + // backups, rate limits — belongs to whoever set it. + raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/settings", + map[string]any{"s3": want}) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("apply settings: status %d: %s", status, raw) + } + log.Printf("bootstrap: ✓ file storage → S3 (%s at %s)", want.Bucket, want.Endpoint) + } + + testFileStorage(ctx, client) + return nil +} + +// testFileStorage asks PocketBase to prove it can actually reach the bucket, +// and only says so in the log. A failure here means uploads will fail, but the +// server still has to come up — the endpoint is fixable from the panel, and a +// stack that refuses to boot cannot be fixed from anywhere. +func testFileStorage(ctx context.Context, client *pb.Client) { + raw, status, err := client.Raw(ctx, http.MethodPost, "/api/settings/test/s3", + map[string]any{"filesystem": "storage"}) + switch { + case err != nil: + log.Printf("bootstrap: WARNING: S3 storage test failed: %v", err) + case status < 200 || status >= 300: + log.Printf("bootstrap: WARNING: S3 storage unreachable (status %d): %s", status, raw) + default: + log.Printf("bootstrap: ✓ S3 storage reachable") + } +} + // --- helpers --------------------------------------------------------------- func asBool(v any) bool { diff --git a/API Server/internal/bootstrap/bootstrap_test.go b/API Server/internal/bootstrap/bootstrap_test.go index c3a3305..9175b87 100644 --- a/API Server/internal/bootstrap/bootstrap_test.go +++ b/API Server/internal/bootstrap/bootstrap_test.go @@ -94,3 +94,39 @@ func TestSchemaConsistency(t *testing.T) { } } } + +// TestSameExceptSecret covers the decision ensureFileStorage makes on every +// boot: write the settings, or leave them alone. The secret is excluded because +// PocketBase masks it on read — comparing it would make every boot a write. +func TestSameExceptSecret(t *testing.T) { + want := storageSettings{ + Enabled: true, + Bucket: "drivervault", + Region: "us-east-1", + Endpoint: "http://seaweedfs:8333", + AccessKey: "key", + Secret: "secret", + ForcePathStyle: true, + } + + cases := []struct { + name string + current storageSettings + same bool + }{ + {"identical", want, true}, + {"masked secret", func() storageSettings { s := want; s.Secret = ""; return s }(), true}, + {"rotated secret only", func() storageSettings { s := want; s.Secret = "other"; return s }(), true}, + {"changed endpoint", func() storageSettings { s := want; s.Endpoint = "http://elsewhere:8333"; return s }(), false}, + {"changed bucket", func() storageSettings { s := want; s.Bucket = "other"; return s }(), false}, + {"changed access key", func() storageSettings { s := want; s.AccessKey = "other"; return s }(), false}, + {"still disabled", func() storageSettings { s := want; s.Enabled = false; return s }(), false}, + {"path style off", func() storageSettings { s := want; s.ForcePathStyle = false; return s }(), false}, + {"untouched settings", storageSettings{}, false}, + } + for _, tc := range cases { + if got := tc.current.sameExceptSecret(want); got != tc.same { + t.Errorf("%s: sameExceptSecret = %v, want %v", tc.name, got, tc.same) + } + } +} diff --git a/API Server/internal/config/config.go b/API Server/internal/config/config.go index 03ef1a7..19c4096 100644 --- a/API Server/internal/config/config.go +++ b/API Server/internal/config/config.go @@ -51,6 +51,26 @@ type Config struct { SuperAdminEmail string SuperAdminPassword string SuperAdminName string + + // PocketBase file storage. With S3Enabled set, bootstrap points PocketBase's + // "Files storage" at this bucket instead of the pb_data volume; left unset, + // uploads stay on disk exactly as they always have. Only *record files* move + // — backups are deliberately not touched. + // + // Nothing here reaches a container unless one of the SeaweedFS compose + // overlays is layered on, so an existing stack is unaffected by an upgrade. + // S3Endpoint has no default: an in-stack SeaweedFS and one outside it are + // different addresses, and guessing either would be worse than not starting. + S3Enabled bool + S3Bucket string + S3Region string + S3Endpoint string + S3AccessKey string + S3Secret string + // S3ForcePathStyle keeps bucket names in the path rather than the hostname. + // True by default because that is what a self-hosted gateway serves — + // virtual-host style would need a DNS entry per bucket. + S3ForcePathStyle bool } // EnvFile is the .env path (relative to the working directory) that Load reads @@ -66,6 +86,15 @@ func (c Config) AdminConfigured() bool { return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != "" } +// StorageConfigured reports whether S3 file storage has been fully specified. +// Anything less than all of it counts as "not asked for": a half-filled .env +// leaves uploads on the local volume rather than pointing PocketBase at a +// bucket it has no way to reach. +func (c Config) StorageConfigured() bool { + return c.S3Enabled && c.S3Bucket != "" && c.S3Endpoint != "" && + c.S3AccessKey != "" && c.S3Secret != "" +} + // Load reads configuration from environment variables, applying sensible // defaults. A .env file, if present in the working directory, is loaded first. func Load() Config { @@ -86,6 +115,13 @@ func Load() Config { SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"), SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"), SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"), + S3Enabled: boolEnv("PB_S3_ENABLED", false), + S3Bucket: getenv("PB_S3_BUCKET", "drivervault"), + S3Region: getenv("PB_S3_REGION", "us-east-1"), + S3Endpoint: strings.TrimRight(getenv("PB_S3_ENDPOINT", ""), "/"), + S3AccessKey: getenv("PB_S3_ACCESS_KEY", ""), + S3Secret: getenv("PB_S3_SECRET", ""), + S3ForcePathStyle: boolEnv("PB_S3_FORCE_PATH_STYLE", true), } } diff --git a/Docker-AIO/.env.prod.s3.example b/Docker-AIO/.env.prod.s3.example new file mode 100644 index 0000000..51cecd6 --- /dev/null +++ b/Docker-AIO/.env.prod.s3.example @@ -0,0 +1,85 @@ +# DriverVault all-in-one — production config. +# Copy to .env and fill in, then: +# docker compose -f docker-compose.prod.s3.yml pull +# docker compose -f docker-compose.prod.s3.yml up -d + +# --- Registry image ---------------------------------------------------------- +AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest + +# --- PocketBase superuser (required) ----------------------------------------- +# Created/updated on first boot. The API Server uses these to manage the database. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# Allowed CORS origin(s) — match your public web URL / WEB_PORT. +CORS_ALLOW_ORIGINS=http://localhost:8090 + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. This image serves plain HTTP, so +# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the +# public wss:// base the charger should be pointed at. Turning the check off is +# for trusted networks only. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# --- Host port mappings (optional; defaults shown) -------------------------- +WEB_PORT=8090 +PB_PORT=8070 +API_PORT=8080 + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the value below is re-applied on every restart and wins. Set it +# here to make a change permanent. (POCKETBASE_URL is fixed to this container's +# own PocketBase and is not meant to be repointed.) +# WEBAPP_URL the Web App address the panel status page probes; nginx +# serves it on port 80 inside this container. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# WEBAPP_URL=http://127.0.0.1:80 + +# --- Storage ----------------------------------------------------------------- +# One Docker-managed named volume by default. Set it to an absolute host path +# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data. +# PB_DATA — the PocketBase database and uploads. It is the only volume in the +# image: the API Server keeps no state on disk, so everything it owns (plugin +# settings included) is backed up by backing up this one path. +PB_DATA=pb_data + +# --- File storage: external S3 ----------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# PB_DATA. The database and PocketBase's own backups stay where they are. +# +# Nothing in this stack runs a gateway: both the endpoint and the bucket must +# already exist. For a gateway on this Docker host use +# http://host.docker.internal:8333 — the compose file adds the host entry that +# makes that name resolve inside the containers. +PB_S3_ENDPOINT=http://10.2.1.10:8333 +PB_S3_BUCKET=drivervault +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# SeaweedFS and MinIO ignore the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 +# true for SeaweedFS and MinIO, false for AWS S3 proper. +PB_S3_FORCE_PATH_STYLE=true + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker-AIO/.env.prod.seaweedfs.example b/Docker-AIO/.env.prod.seaweedfs.example new file mode 100644 index 0000000..d4f046a --- /dev/null +++ b/Docker-AIO/.env.prod.seaweedfs.example @@ -0,0 +1,95 @@ +# DriverVault all-in-one — production config. +# Copy to .env and fill in, then: +# docker compose -f docker-compose.prod.seaweedfs.yml pull +# docker compose -f docker-compose.prod.seaweedfs.yml up -d + +# --- Registry image ---------------------------------------------------------- +AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest + +# --- PocketBase superuser (required) ----------------------------------------- +# Created/updated on first boot. The API Server uses these to manage the database. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# Allowed CORS origin(s) — match your public web URL / WEB_PORT. +CORS_ALLOW_ORIGINS=http://localhost:8090 + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. This image serves plain HTTP, so +# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the +# public wss:// base the charger should be pointed at. Turning the check off is +# for trusted networks only. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# --- Host port mappings (optional; defaults shown) -------------------------- +WEB_PORT=8090 +PB_PORT=8070 +API_PORT=8080 + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the value below is re-applied on every restart and wins. Set it +# here to make a change permanent. (POCKETBASE_URL is fixed to this container's +# own PocketBase and is not meant to be repointed.) +# WEBAPP_URL the Web App address the panel status page probes; nginx +# serves it on port 80 inside this container. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# WEBAPP_URL=http://127.0.0.1:80 + +# --- Storage ----------------------------------------------------------------- +# One Docker-managed named volume by default. Set it to an absolute host path +# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data. +# PB_DATA — the PocketBase database and uploads. It is the only volume in the +# image: the API Server keeps no state on disk, so everything it owns (plugin +# settings included) is backed up by backing up this one path. +PB_DATA=pb_data + +# --- File storage: SeaweedFS ------------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# PB_DATA. The database and PocketBase's own backups stay where they are. +# +# The credentials do double duty: they configure the SeaweedFS gateway's single +# identity *and* are what PocketBase authenticates with. There are no safe +# defaults, and the stack refuses to start without them. +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# The bucket. Created on first boot by the seaweedfs-init container. +PB_S3_BUCKET=drivervault +# SeaweedFS ignores the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 + +# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume +# by default; set an absolute host path for a bind mount, the same way PB_DATA +# works above. Back it up alongside PB_DATA: from here on the attachments live +# here, not in the database volume. +SEAWEED_DATA=seaweed_data +# The gateway image, pinned so a redeploy months from now brings up the same one. +# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45 +# The S3 port is published on loopback only — the stack reaches the gateway over +# the compose network, and this is for tools like aws-cli. Set +# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it. +# SEAWEED_S3_BIND=127.0.0.1 +# SEAWEED_S3_PORT=8333 + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker-AIO/.env.s3.example b/Docker-AIO/.env.s3.example new file mode 100644 index 0000000..5513db6 --- /dev/null +++ b/Docker-AIO/.env.s3.example @@ -0,0 +1,79 @@ +# Copy to .env and fill in. Used by the Docker-AIO docker-compose.s3.yml. + +# --- Required (no defaults) -------------------------------------------------- +# PocketBase superuser, also used by the API Server to authenticate. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# Allowed CORS origin(s) — match your web origin / WEB_PORT. +CORS_ALLOW_ORIGINS=http://localhost:8090 + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. This image serves plain HTTP: +# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public +# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# --- Host port mappings (optional; defaults shown) -------------------------- +WEB_PORT=8090 +PB_PORT=8070 +# API Server + its embedded web panel (served at the API root, http://host:8080/). +API_PORT=8080 + +# --- Build args (optional) --------------------------------------------------- +# Leave empty so the browser uses same-origin /api (proxied by nginx). +VITE_API_BASE= +# PocketBase version. The Dockerfile already pins one; set this only to build a +# different version. Leaving it commented out keeps the pin (an empty value here +# is passed through as-is and would resolve the latest release at build time). +#PB_VERSION=0.39.11 + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the value below is re-applied on every restart and wins. Set it +# here to make a change permanent. (POCKETBASE_URL is fixed to this container's +# own PocketBase and is not meant to be repointed.) +# WEBAPP_URL the Web App address the panel status page probes; nginx +# serves it on port 80 inside this container. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# WEBAPP_URL=http://127.0.0.1:80 + +# --- File storage: external S3 ----------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# pb_data. The database and PocketBase's own backups stay where they are. +# +# Nothing in this stack runs a gateway: both the endpoint and the bucket must +# already exist. For a gateway on this Docker host use +# http://host.docker.internal:8333 — the compose file adds the host entry that +# makes that name resolve inside the containers. +PB_S3_ENDPOINT=http://host.docker.internal:8333 +PB_S3_BUCKET=drivervault +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# SeaweedFS and MinIO ignore the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 +# true for SeaweedFS and MinIO, false for AWS S3 proper. +PB_S3_FORCE_PATH_STYLE=true + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker-AIO/.env.seaweedfs.example b/Docker-AIO/.env.seaweedfs.example new file mode 100644 index 0000000..ddef470 --- /dev/null +++ b/Docker-AIO/.env.seaweedfs.example @@ -0,0 +1,80 @@ +# Copy to .env and fill in. Used by the Docker-AIO docker-compose.seaweedfs.yml. + +# --- Required (no defaults) -------------------------------------------------- +# PocketBase superuser, also used by the API Server to authenticate. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# Allowed CORS origin(s) — match your web origin / WEB_PORT. +CORS_ALLOW_ORIGINS=http://localhost:8090 + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. This image serves plain HTTP: +# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public +# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# --- Host port mappings (optional; defaults shown) -------------------------- +WEB_PORT=8090 +PB_PORT=8070 +# API Server + its embedded web panel (served at the API root, http://host:8080/). +API_PORT=8080 + +# --- Build args (optional) --------------------------------------------------- +# Leave empty so the browser uses same-origin /api (proxied by nginx). +VITE_API_BASE= +# PocketBase version. The Dockerfile already pins one; set this only to build a +# different version. Leaving it commented out keeps the pin (an empty value here +# is passed through as-is and would resolve the latest release at build time). +#PB_VERSION=0.39.11 + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the value below is re-applied on every restart and wins. Set it +# here to make a change permanent. (POCKETBASE_URL is fixed to this container's +# own PocketBase and is not meant to be repointed.) +# WEBAPP_URL the Web App address the panel status page probes; nginx +# serves it on port 80 inside this container. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# WEBAPP_URL=http://127.0.0.1:80 + +# --- File storage: SeaweedFS ------------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# pb_data. The database and PocketBase's own backups stay where they are. +# +# The credentials do double duty: they configure the SeaweedFS gateway's single +# identity *and* are what PocketBase authenticates with. There are no safe +# defaults, and the stack refuses to start without them. +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# The bucket. Created on first boot by the seaweedfs-init container. +PB_S3_BUCKET=drivervault +# SeaweedFS ignores the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 +# The gateway image, pinned so a rebuild months from now brings up the same one. +# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45 +# Host port for the S3 API, so aws-cli and friends can reach it while developing. +# SEAWEED_S3_PORT=8333 + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker-AIO/README.md b/Docker-AIO/README.md index 1b8746e..dfafb45 100644 --- a/Docker-AIO/README.md +++ b/Docker-AIO/README.md @@ -88,6 +88,71 @@ volume; set `PB_DATA` to an absolute host path in the prod file for a bind mount > container. Set the corresponding environment variables to change them > permanently. +## File storage (SeaweedFS / S3) + +Uploaded files — document scans, service and refill receipts, workshop invoices, +part photos — live inside `pb_data` by default, next to the database. Two further +compose files put them in an S3 bucket instead, so the blobs and the database can +be sized, backed up and moved independently. Nothing else changes: an attachment has +always been fetched through the API Server (`GET /api/service-records/{id}/file`), +never from a storage URL, so the Web App, the phone app and the Home Assistant +plugin cannot tell the difference. + +Each shape is one self-contained compose file — nothing to layer, nothing to +remember — with an `.env` example of the same name: + +| Shape | From the registry | From source | +|---|---|---| +| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` | +| **SeaweedFS beside the image** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` | +| **An S3 endpoint elsewhere** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` | + +So `docker-compose.prod.seaweedfs.yml` is configured from +`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`, +and so on: + +```sh +cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults +docker compose -f docker-compose.prod.seaweedfs.yml pull +docker compose -f docker-compose.prod.seaweedfs.yml up -d +``` + +Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to +start without them. The SeaweedFS ones run the gateway as a **second container** +(master, volume, filer and S3 in one process, on its own `seaweed_data` volume) +rather than a fourth process under supervisord: keeping the object store in this +image, on the volume the files are being moved off, would defeat the point and +would mean rebuilding. They also run a one-shot `seaweedfs-init` that creates the +bucket, because PocketBase never issues a `CreateBucket` of its own. The +external-S3 ones add no containers at all: set `PB_S3_ENDPOINT`, and create the +bucket yourself. + +On every boot the API Server's bootstrap writes PocketBase's *Files storage* +settings from those variables, then asks PocketBase to prove it can reach the +bucket. Watch for it in the log: + +``` +[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333) +[api] bootstrap: ✓ S3 storage reachable +``` + +A boot that finds the settings already correct logs `• file storage already on S3` +and writes nothing. + +Two things to know before turning it on: + +- **Existing files are not migrated.** PocketBase copies nothing when the setting + flips, so attachments uploaded before the switch stop resolving. Copy + `pb_data/storage///` into the bucket root, keeping + that layout, *before* enabling it — or start from a stack with no attachments. +- **Going back to the plain compose file is not an off switch.** It leaves + PocketBase pointed at + the bucket, deliberately: files already written there are reachable only while + it is. Move them back and turn it off in PocketBase's own admin UI. For the same + reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap — + PocketBase masks the stored secret on read — so change another `PB_S3_*` value + alongside it, or set it in the admin UI. + ## Charger control (OCPP) Chargers in own/proxy mode dial in to `/ocpp/{serial}` on the **API Server port diff --git a/Docker-AIO/docker-compose.prod.s3.yml b/Docker-AIO/docker-compose.prod.s3.yml new file mode 100644 index 0000000..55248da --- /dev/null +++ b/Docker-AIO/docker-compose.prod.s3.yml @@ -0,0 +1,101 @@ +name: drivervault-aio + +# Production all-in-one, with external S3 — pulls the prebuilt image from the +# registry instead of building. Self-contained: one file, no overlays. +# Everything an operator needs to set lives in .env. +# +# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially) +# 2. docker compose -f docker-compose.prod.s3.yml pull +# 3. docker compose -f docker-compose.prod.s3.yml up -d +# +# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists +# somewhere else — its own host, another compose project, or any S3-compatible +# service. PocketBase keeps its record files — document scans, service and +# refill receipts, workshop invoices, part photos — in that bucket instead of on +# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA. +# Clients cannot tell the difference: an attachment has always been fetched +# through the API Server, never from a storage URL. +# +# The bucket must already exist, and nothing here runs the gateway. For a +# SeaweedFS that comes up with the container, use +# docker-compose.prod.seaweedfs.yml. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. +# +# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API +# Server creates any missing collections and the DriverVault super-admin from +# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent. + +services: + drivervault: + image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}" + container_name: drivervault-aio + restart: unless-stopped + extra_hosts: + # Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal. + # Harmless when the endpoint is somewhere else entirely. + - "host.docker.internal:host-gateway" + environment: + # Superuser (also used by the API Server to authenticate to PocketBase). + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + # Match CORS to the web origin (only used if a browser calls the API directly). + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Probed by the panel status page. nginx serves the Web App on port 80 + # inside this container, so plain localhost:8090 would never answer. + # Override WEBAPP_URL in .env to make a change from the panel's Web App + # screen permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}" + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). This image serves plain HTTP, so a + # charger can only connect when TLS is terminated in front of it (set + # OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network, + # with OCPP_REQUIRE_TLS=false. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # --- File storage -------------------------------------------------- + # supervisord passes these through to the API Server, whose bootstrap + # writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on PB_DATA. The bucket must already exist: nothing here + # creates it. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}" + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + # true for SeaweedFS and MinIO, false for AWS S3 proper. + PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}" + ports: + - "${WEB_PORT:-8090}:80" # Web App + - "${PB_PORT:-8070}:8070" # PocketBase admin UI / API + - "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial} + volumes: + # The only volume — named by default; set PB_DATA to a host path in .env + # for a bind mount. The API Server keeps no state on disk, so everything + # it owns (plugin settings included) is in here. + - "${PB_DATA:-pb_data}:/pb/pb_data" + healthcheck: + # All three processes must answer. Declared here as well as in the image so + # the check is visible, and works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + +volumes: + pb_data: diff --git a/Docker-AIO/docker-compose.prod.seaweedfs.yml b/Docker-AIO/docker-compose.prod.seaweedfs.yml new file mode 100644 index 0000000..1b5e1db --- /dev/null +++ b/Docker-AIO/docker-compose.prod.seaweedfs.yml @@ -0,0 +1,155 @@ +name: drivervault-aio + +# Production all-in-one, with SeaweedFS — pulls the prebuilt image from the +# registry instead of building. Self-contained: one file, no overlays. +# Everything an operator needs to set lives in .env. +# +# 1. cp .env.prod.seaweedfs.example .env (then edit it) +# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull +# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d +# +# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its +# record files — document scans, service and refill receipts, workshop invoices, +# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to +# the database. The database and PocketBase's own backups stay on PB_DATA. +# Clients cannot tell the difference: an attachment has always been fetched +# through the API Server, never from a storage URL. +# +# SeaweedFS runs as a second container beside the all-in-one, not as a fourth +# process inside it: keeping the object store in that image, on the volume the +# files are being moved off, would defeat the point and would mean rebuilding. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. +# +# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API +# Server creates any missing collections and the DriverVault super-admin from +# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent. + +services: + seaweedfs: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-aio-seaweedfs + restart: unless-stopped + # One process, four roles: master, volume, filer and the S3 gateway. -dir is + # the only state it keeps. + command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024 + environment: + # SeaweedFS falls back to these when started without an -s3.config file, + # and configuring one identity is what takes the S3 gateway out of its + # default allow-anyone mode. The same credentials PocketBase authenticates + # with below — one pair to set, in .env. + AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + volumes: + # Named volume by default; set SEAWEED_DATA to a host path in .env for a + # bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA — + # from here on the attachments live here, not in the database volume. + - "${SEAWEED_DATA:-seaweed_data}:/data" + ports: + # Loopback only: the stack reaches the gateway over the compose network, + # so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and + # nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it. + - "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + seaweedfs-init: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-aio-seaweedfs-init + # Runs once and exits. PocketBase never issues a CreateBucket of its own and + # SeaweedFS will not conjure one on first upload, so something has to. + # Creating a bucket that already exists is a no-op, so every later boot + # passes straight through. + restart: "no" + depends_on: + seaweedfs: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + # `|| true` so a restart is never blocked by the shell's exit status: this + # step is best-effort, and a gateway that is genuinely unreachable is + # reported by the API Server's own S3 check at boot, with the reason. + command: + - 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true' + + drivervault: + image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}" + container_name: drivervault-aio + restart: unless-stopped + depends_on: + # PocketBase — inside this container — is the process that reads and + # writes the objects, so the gateway has to be serving first, and the + # bucket has to exist before the bootstrap points PocketBase at it. + seaweedfs: + condition: service_healthy + seaweedfs-init: + condition: service_completed_successfully + environment: + # Superuser (also used by the API Server to authenticate to PocketBase). + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + # Match CORS to the web origin (only used if a browser calls the API directly). + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Probed by the panel status page. nginx serves the Web App on port 80 + # inside this container, so plain localhost:8090 would never answer. + # Override WEBAPP_URL in .env to make a change from the panel's Web App + # screen permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}" + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). This image serves plain HTTP, so a + # charger can only connect when TLS is terminated in front of it (set + # OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network, + # with OCPP_REQUIRE_TLS=false. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # --- File storage -------------------------------------------------- + # supervisord passes these through to the API Server, whose bootstrap + # writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on PB_DATA. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + # The service name: a server-to-server call inside the compose network. + PB_S3_ENDPOINT: "http://seaweedfs:8333" + # SeaweedFS ignores the region; PocketBase insists on having one. + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}" + PB_S3_SECRET: "${PB_S3_SECRET}" + # Path style, because a self-hosted gateway has no per-bucket DNS. + PB_S3_FORCE_PATH_STYLE: "true" + ports: + - "${WEB_PORT:-8090}:80" # Web App + - "${PB_PORT:-8070}:8070" # PocketBase admin UI / API + - "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial} + volumes: + # The only volume — named by default; set PB_DATA to a host path in .env + # for a bind mount. The API Server keeps no state on disk, so everything + # it owns (plugin settings included) is in here. + - "${PB_DATA:-pb_data}:/pb/pb_data" + healthcheck: + # All three processes must answer. Declared here as well as in the image so + # the check is visible, and works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + +volumes: + pb_data: + seaweed_data: diff --git a/Docker-AIO/docker-compose.s3.yml b/Docker-AIO/docker-compose.s3.yml new file mode 100644 index 0000000..98778cf --- /dev/null +++ b/Docker-AIO/docker-compose.s3.yml @@ -0,0 +1,107 @@ +name: drivervault-aio + +# Single all-in-one container, with external S3: PocketBase + API Server + Web +# App (nginx) in one image, storing files in an S3 endpoint that already exists +# somewhere else. Self-contained — one file, nothing to layer. +# +# cp .env.s3.example .env (then edit it — PB_S3_* especially) +# docker compose -f docker-compose.s3.yml up -d --build +# +# The build context is the project root so the Dockerfile can reach both +# "API Server/" and "Web App/". +# +# This is docker-compose.yml plus storage: PocketBase keeps its record files — +# document scans, service and refill receipts, workshop invoices, part photos — +# in that bucket instead of on the pb_data volume next to the database. The +# database and PocketBase's own backups stay on pb_data. Clients cannot tell the +# difference: an attachment has always been fetched through the API Server, +# never from a storage URL. +# +# The bucket must already exist, and nothing here runs the gateway. For a +# SeaweedFS that comes up with the container, use docker-compose.seaweedfs.yml. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. + +services: + drivervault: + build: + # Project root (one level up from this compose file). + context: .. + dockerfile: Docker-AIO/Dockerfile + args: + # Empty -> bundle uses same-origin "/api", proxied internally by nginx. + - VITE_API_BASE=${VITE_API_BASE:-} + # Bare name = pass through only when set in the environment, so an unset + # PB_VERSION leaves the Dockerfile pin in place instead of overriding it + # with an empty string (which would resolve "latest" at build time). + - PB_VERSION + image: drivervault-aio + container_name: drivervault-aio + restart: unless-stopped + extra_hosts: + # Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal. + # Harmless when the endpoint is somewhere else entirely. + - "host.docker.internal:host-gateway" + environment: + # Superuser (also used by the API Server to authenticate to PocketBase). + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + # Match CORS to the web origin (only used if a browser calls the API directly). + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Probed by the panel status page. nginx serves the Web App on port 80 + # inside this container, so plain localhost:8090 would never answer. + # Override WEBAPP_URL in .env to make a change from the panel's Web App + # screen permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}" + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). This image serves plain HTTP, so a + # charger can only connect when TLS is terminated in front of it (set + # OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network, + # with OCPP_REQUIRE_TLS=false. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # --- File storage -------------------------------------------------- + # supervisord passes these through to the API Server, whose bootstrap + # writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on pb_data. The bucket must already exist: nothing here + # creates it. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}" + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + # true for SeaweedFS and MinIO, false for AWS S3 proper. + PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}" + ports: + - "${WEB_PORT:-8090}:80" # Web App + - "${PB_PORT:-8070}:8070" # PocketBase admin UI / API + - "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial} + volumes: + # The only volume: the API Server keeps no state on disk, so everything + # it owns — plugin settings included — lives in the database. + - pb_data:/pb/pb_data + healthcheck: + # All three processes must answer. Declared here as well as in the image so + # the check is visible, and works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + +volumes: + pb_data: diff --git a/Docker-AIO/docker-compose.seaweedfs.yml b/Docker-AIO/docker-compose.seaweedfs.yml new file mode 100644 index 0000000..194e9e7 --- /dev/null +++ b/Docker-AIO/docker-compose.seaweedfs.yml @@ -0,0 +1,160 @@ +name: drivervault-aio + +# Single all-in-one container, with SeaweedFS: PocketBase + API Server + Web App +# (nginx) in one image, plus an S3 object store beside it. Self-contained — one +# file, nothing to layer. +# +# cp .env.seaweedfs.example .env (then edit it) +# docker compose -f docker-compose.seaweedfs.yml up -d --build +# +# The build context is the project root so the Dockerfile can reach both +# "API Server/" and "Web App/". +# +# This is docker-compose.yml plus storage: PocketBase keeps its record files — +# document scans, service and refill receipts, workshop invoices, part photos — +# in a SeaweedFS bucket instead of on the pb_data volume next to the database. +# The database and PocketBase's own backups stay on pb_data. Clients cannot tell +# the difference: an attachment has always been fetched through the API Server, +# never from a storage URL. +# +# SeaweedFS runs as a second container beside the all-in-one, not as a fourth +# process inside it: keeping the object store in that image, on the volume the +# files are being moved off, would defeat the point and would mean rebuilding. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. + +services: + seaweedfs: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-aio-seaweedfs + restart: unless-stopped + # One process, four roles: master, volume, filer and the S3 gateway. -dir is + # the only state it keeps. + command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024 + environment: + # SeaweedFS falls back to these when started without an -s3.config file, + # and configuring one identity is what takes the S3 gateway out of its + # default allow-anyone mode. The same credentials PocketBase authenticates + # with below — one pair to set, in .env. + AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + volumes: + # From here on the attachments live here, not on pb_data. + - seaweed_data:/data + ports: + # The stack reaches the gateway over the compose network; this is here so + # `aws s3 ls --endpoint-url http://localhost:8333` works while developing. + - "${SEAWEED_S3_PORT:-8333}:8333" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + seaweedfs-init: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-aio-seaweedfs-init + # Runs once and exits. PocketBase never issues a CreateBucket of its own and + # SeaweedFS will not conjure one on first upload, so something has to. + # Creating a bucket that already exists is a no-op, so every later boot + # passes straight through. + restart: "no" + depends_on: + seaweedfs: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + # `|| true` so a restart is never blocked by the shell's exit status: this + # step is best-effort, and a gateway that is genuinely unreachable is + # reported by the API Server's own S3 check at boot, with the reason. + command: + - 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true' + + drivervault: + build: + # Project root (one level up from this compose file). + context: .. + dockerfile: Docker-AIO/Dockerfile + args: + # Empty -> bundle uses same-origin "/api", proxied internally by nginx. + - VITE_API_BASE=${VITE_API_BASE:-} + # Bare name = pass through only when set in the environment, so an unset + # PB_VERSION leaves the Dockerfile pin in place instead of overriding it + # with an empty string (which would resolve "latest" at build time). + - PB_VERSION + image: drivervault-aio + container_name: drivervault-aio + restart: unless-stopped + depends_on: + # PocketBase — inside this container — is the process that reads and + # writes the objects, so the gateway has to be serving first, and the + # bucket has to exist before the bootstrap points PocketBase at it. + seaweedfs: + condition: service_healthy + seaweedfs-init: + condition: service_completed_successfully + environment: + # Superuser (also used by the API Server to authenticate to PocketBase). + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + # Match CORS to the web origin (only used if a browser calls the API directly). + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Probed by the panel status page. nginx serves the Web App on port 80 + # inside this container, so plain localhost:8090 would never answer. + # Override WEBAPP_URL in .env to make a change from the panel's Web App + # screen permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}" + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). This image serves plain HTTP, so a + # charger can only connect when TLS is terminated in front of it (set + # OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network, + # with OCPP_REQUIRE_TLS=false. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # --- File storage -------------------------------------------------- + # supervisord passes these through to the API Server, whose bootstrap + # writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on pb_data. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + # The service name: a server-to-server call inside the compose network. + PB_S3_ENDPOINT: "http://seaweedfs:8333" + # SeaweedFS ignores the region; PocketBase insists on having one. + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}" + PB_S3_SECRET: "${PB_S3_SECRET}" + # Path style, because a self-hosted gateway has no per-bucket DNS. + PB_S3_FORCE_PATH_STYLE: "true" + ports: + - "${WEB_PORT:-8090}:80" # Web App + - "${PB_PORT:-8070}:8070" # PocketBase admin UI / API + - "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial} + volumes: + # The only volume: the API Server keeps no state on disk, so everything + # it owns — plugin settings included — lives in the database. + - pb_data:/pb/pb_data + healthcheck: + # All three processes must answer. Declared here as well as in the image so + # the check is visible, and works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + +volumes: + pb_data: + seaweed_data: diff --git a/Docker/.env.example b/Docker/.env.example index f03c3fe..875ba3c 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -75,11 +75,3 @@ VITE_API_BASE= # CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. # POCKETBASE_URL=http://pocketbase:8070 # WEBAPP_URL=http://web-app:8090 - -# --- Public hostname + TLS (docker-compose.tls.yml) -------------------------- -# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this -# host from the internet, with ports 80 and 443 reaching it; the certificate is -# issued automatically on first boot. Setting it also points chargers at -# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on. -DV_DOMAIN= -DV_ACME_EMAIL= diff --git a/Docker/.env.prod.example b/Docker/.env.prod.example index 92c67a5..2341d96 100644 --- a/Docker/.env.prod.example +++ b/Docker/.env.prod.example @@ -97,11 +97,3 @@ API_BIND=127.0.0.1 # stack: the API Server keeps no state on disk, so everything it owns (plugin # settings included) is backed up by backing up this one path. PB_DATA=pb_data - -# --- Public hostname + TLS (docker-compose.tls.yml) -------------------------- -# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this -# host from the internet, with ports 80 and 443 reaching it; the certificate is -# issued automatically on first boot. Setting it also points chargers at -# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on. -DV_DOMAIN= -DV_ACME_EMAIL= diff --git a/Docker/.env.prod.s3.example b/Docker/.env.prod.s3.example new file mode 100644 index 0000000..51421cf --- /dev/null +++ b/Docker/.env.prod.s3.example @@ -0,0 +1,121 @@ +# DriverVault — production stack config. +# Copy to .env and fill in, then: +# docker compose -f docker-compose.prod.s3.yml pull +# docker compose -f docker-compose.prod.s3.yml up -d + +# --- Registry images --------------------------------------------------------- +# Defaults point at the internal registry; override to pin a tag or use a mirror. +PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest +API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest +WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest + +# --- PocketBase superuser ---------------------------------------------------- +# Created/updated on the PocketBase container's first boot. The API Server uses +# these same credentials to manage the database. REQUIRED. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip and +# create the first user by hand. This is the account you log in to the web app +# with — distinct from the PocketBase superuser above. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# --- API Server -------------------------------------------------------------- +# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT). +CORS_ALLOW_ORIGINS=http://localhost:8090 +AUTH_USERS_COLLECTION=users + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. Keep the default and terminate +# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the +# public wss:// base the charger should be pointed at (deriving it from request +# headers is unreliable behind a proxy). Turning the check off is for trusted +# networks only. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's +# cloud broker — the ones DriverVault decodes and the ones it cannot, with their +# bytes. It is how an unnamed frame gets named: hold a control read open, do the +# thing in the Anker app, then read the frames back out of the container log. +# Leave blank on a normal stack; a triggered charger writes a line every few +# seconds. +ANKER_MQTT_FRAME_LOG= + +# The charger can dial either door: the API Server port directly, or the Web +# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel +# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it +# is whichever host the panel itself was reached on, which is the Web App. +# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the +# API Server: needed when TLS ends at a proxy in front of the stack and +# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then +# whatever the client said it was. +TRUST_FORWARDED_PROTO=false + +# --- Ports ------------------------------------------------------------------- +# WEB_PORT is the public front door (bound on all interfaces). +WEB_PORT=8090 +# PocketBase admin UI and the API panel are bound to localhost only by default. +# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network. +PB_PORT=8070 +PB_BIND=127.0.0.1 +API_PORT=8080 +API_BIND=127.0.0.1 + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the values below are re-applied on every restart and win. Set +# them here to make a change permanent. +# POCKETBASE_URL where the API Server looks for the database. Defaults to +# the bundled pocketbase service; set it to reach one +# outside this stack. +# WEBAPP_URL the Web App address the panel status page probes. It is +# a container-to-container call, so it must be reachable +# from the API Server, not from your browser. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# POCKETBASE_URL=http://pocketbase:8070 +# WEBAPP_URL=http://web-app:8090 + +# --- Storage ----------------------------------------------------------------- +# One Docker-managed named volume by default. To store it on a host path +# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data. +# PB_DATA — the PocketBase database and uploads. It is the only volume in the +# stack: the API Server keeps no state on disk, so everything it owns (plugin +# settings included) is backed up by backing up this one path. +PB_DATA=pb_data + +# --- File storage: external S3 ----------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# PB_DATA. The database and PocketBase's own backups stay where they are. +# +# Nothing in this stack runs a gateway: both the endpoint and the bucket must +# already exist. For a gateway on this Docker host use +# http://host.docker.internal:8333 — the compose file adds the host entry that +# makes that name resolve inside the containers. +PB_S3_ENDPOINT=http://10.2.1.10:8333 +PB_S3_BUCKET=drivervault +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# SeaweedFS and MinIO ignore the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 +# true for SeaweedFS and MinIO, false for AWS S3 proper. +PB_S3_FORCE_PATH_STYLE=true + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker/.env.prod.seaweedfs.example b/Docker/.env.prod.seaweedfs.example new file mode 100644 index 0000000..5efffe6 --- /dev/null +++ b/Docker/.env.prod.seaweedfs.example @@ -0,0 +1,131 @@ +# DriverVault — production stack config. +# Copy to .env and fill in, then: +# docker compose -f docker-compose.prod.seaweedfs.yml pull +# docker compose -f docker-compose.prod.seaweedfs.yml up -d + +# --- Registry images --------------------------------------------------------- +# Defaults point at the internal registry; override to pin a tag or use a mirror. +PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest +API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest +WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest + +# --- PocketBase superuser ---------------------------------------------------- +# Created/updated on the PocketBase container's first boot. The API Server uses +# these same credentials to manage the database. REQUIRED. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip and +# create the first user by hand. This is the account you log in to the web app +# with — distinct from the PocketBase superuser above. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# --- API Server -------------------------------------------------------------- +# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT). +CORS_ALLOW_ORIGINS=http://localhost:8090 +AUTH_USERS_COLLECTION=users + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. Keep the default and terminate +# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the +# public wss:// base the charger should be pointed at (deriving it from request +# headers is unreliable behind a proxy). Turning the check off is for trusted +# networks only. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's +# cloud broker — the ones DriverVault decodes and the ones it cannot, with their +# bytes. It is how an unnamed frame gets named: hold a control read open, do the +# thing in the Anker app, then read the frames back out of the container log. +# Leave blank on a normal stack; a triggered charger writes a line every few +# seconds. +ANKER_MQTT_FRAME_LOG= + +# The charger can dial either door: the API Server port directly, or the Web +# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel +# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it +# is whichever host the panel itself was reached on, which is the Web App. +# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the +# API Server: needed when TLS ends at a proxy in front of the stack and +# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then +# whatever the client said it was. +TRUST_FORWARDED_PROTO=false + +# --- Ports ------------------------------------------------------------------- +# WEB_PORT is the public front door (bound on all interfaces). +WEB_PORT=8090 +# PocketBase admin UI and the API panel are bound to localhost only by default. +# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network. +PB_PORT=8070 +PB_BIND=127.0.0.1 +API_PORT=8080 +API_BIND=127.0.0.1 + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the values below are re-applied on every restart and win. Set +# them here to make a change permanent. +# POCKETBASE_URL where the API Server looks for the database. Defaults to +# the bundled pocketbase service; set it to reach one +# outside this stack. +# WEBAPP_URL the Web App address the panel status page probes. It is +# a container-to-container call, so it must be reachable +# from the API Server, not from your browser. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# POCKETBASE_URL=http://pocketbase:8070 +# WEBAPP_URL=http://web-app:8090 + +# --- Storage ----------------------------------------------------------------- +# One Docker-managed named volume by default. To store it on a host path +# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data. +# PB_DATA — the PocketBase database and uploads. It is the only volume in the +# stack: the API Server keeps no state on disk, so everything it owns (plugin +# settings included) is backed up by backing up this one path. +PB_DATA=pb_data + +# --- File storage: SeaweedFS ------------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# PB_DATA. The database and PocketBase's own backups stay where they are. +# +# The credentials do double duty: they configure the SeaweedFS gateway's single +# identity *and* are what PocketBase authenticates with. There are no safe +# defaults, and the stack refuses to start without them. +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# The bucket. Created on first boot by the seaweedfs-init container. +PB_S3_BUCKET=drivervault +# SeaweedFS ignores the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 + +# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume +# by default; set an absolute host path for a bind mount, the same way PB_DATA +# works above. Back it up alongside PB_DATA: from here on the attachments live +# here, not in the database volume. +SEAWEED_DATA=seaweed_data +# The gateway image, pinned so a redeploy months from now brings up the same one. +# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45 +# The S3 port is published on loopback only — the stack reaches the gateway over +# the compose network, and this is for tools like aws-cli. Set +# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it. +# SEAWEED_S3_BIND=127.0.0.1 +# SEAWEED_S3_PORT=8333 + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker/.env.s3.example b/Docker/.env.s3.example new file mode 100644 index 0000000..ef674f5 --- /dev/null +++ b/Docker/.env.s3.example @@ -0,0 +1,99 @@ +# Copy to .env and fill in. Used by the root docker-compose.s3.yml. + +# --- PocketBase superuser (also used by the API Server to authenticate) ------ +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave these blank and the +# schema is still created but no user is, leaving a stack you cannot log into. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# --- API Server ------------------------------------------------------------- +# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL). +# Native mobile apps are not subject to CORS. +CORS_ALLOW_ORIGINS=http://localhost:8090 +AUTH_USERS_COLLECTION=users + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. This dev stack serves plain +# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the +# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's +# cloud broker — the ones DriverVault decodes and the ones it cannot, with their +# bytes. It is how an unnamed frame gets named: hold a control read open, do the +# thing in the Anker app, then read the frames back out of the container log. +# Leave blank on a normal stack; a triggered charger writes a line every few +# seconds. +ANKER_MQTT_FRAME_LOG= + +# The charger can dial either door: the API Server port directly, or the Web +# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel +# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it +# is whichever host the panel itself was reached on, which is the Web App. +# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the +# API Server: needed when TLS ends at a proxy in front of the stack and +# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then +# whatever the client said it was. +TRUST_FORWARDED_PROTO=false + +# --- Host port mappings (optional; defaults shown) -------------------------- +PB_PORT=8070 +API_PORT=8080 +WEB_PORT=8090 + +# --- Web App build ----------------------------------------------------------- +# Leave empty so the browser uses same-origin /api (proxied by the BFF). +VITE_API_BASE= + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the values below are re-applied on every restart and win. Set +# them here to make a change permanent. +# POCKETBASE_URL where the API Server looks for the database. Defaults to +# the bundled pocketbase service; set it to reach one +# outside this stack. +# WEBAPP_URL the Web App address the panel status page probes. It is +# a container-to-container call, so it must be reachable +# from the API Server, not from your browser. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# POCKETBASE_URL=http://pocketbase:8070 +# WEBAPP_URL=http://web-app:8090 + +# --- File storage: external S3 ----------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# pb_data. The database and PocketBase's own backups stay where they are. +# +# Nothing in this stack runs a gateway: both the endpoint and the bucket must +# already exist. For a gateway on this Docker host use +# http://host.docker.internal:8333 — the compose file adds the host entry that +# makes that name resolve inside the containers. +PB_S3_ENDPOINT=http://host.docker.internal:8333 +PB_S3_BUCKET=drivervault +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# SeaweedFS and MinIO ignore the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 +# true for SeaweedFS and MinIO, false for AWS S3 proper. +PB_S3_FORCE_PATH_STYLE=true + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker/.env.seaweedfs.example b/Docker/.env.seaweedfs.example new file mode 100644 index 0000000..7fedbd4 --- /dev/null +++ b/Docker/.env.seaweedfs.example @@ -0,0 +1,100 @@ +# Copy to .env and fill in. Used by the root docker-compose.seaweedfs.yml. + +# --- PocketBase superuser (also used by the API Server to authenticate) ------ +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave these blank and the +# schema is still created but no user is, leaving a stack you cannot log into. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. +PB_BOOTSTRAP=true + +# --- API Server ------------------------------------------------------------- +# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL). +# Native mobile apps are not subject to CORS. +CORS_ALLOW_ORIGINS=http://localhost:8090 +AUTH_USERS_COLLECTION=users + +# --- EV charging control (Anker Solix, OCPP) --------------------------------- +# Only relevant when a charger is set to own/proxy control mode. The charger +# dials in to /ocpp/{serial} on the API Server port, carrying its control token +# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so +# non-TLS connections are rejected by default. This dev stack serves plain +# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the +# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network. +OCPP_REQUIRE_TLS=true +OCPP_PUBLIC_URL= + +# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's +# cloud broker — the ones DriverVault decodes and the ones it cannot, with their +# bytes. It is how an unnamed frame gets named: hold a control read open, do the +# thing in the Anker app, then read the frames back out of the container log. +# Leave blank on a normal stack; a triggered charger writes a line every few +# seconds. +ANKER_MQTT_FRAME_LOG= + +# The charger can dial either door: the API Server port directly, or the Web +# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel +# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it +# is whichever host the panel itself was reached on, which is the Web App. +# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the +# API Server: needed when TLS ends at a proxy in front of the stack and +# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then +# whatever the client said it was. +TRUST_FORWARDED_PROTO=false + +# --- Host port mappings (optional; defaults shown) -------------------------- +PB_PORT=8070 +API_PORT=8080 +WEB_PORT=8090 + +# --- Web App build ----------------------------------------------------------- +# Leave empty so the browser uses same-origin /api (proxied by the BFF). +VITE_API_BASE= + +# --- Settings the API Server panel can also change --------------------------- +# The panel's Settings screens apply these immediately, but only for the life of +# the container — the values below are re-applied on every restart and win. Set +# them here to make a change permanent. +# POCKETBASE_URL where the API Server looks for the database. Defaults to +# the bundled pocketbase service; set it to reach one +# outside this stack. +# WEBAPP_URL the Web App address the panel status page probes. It is +# a container-to-container call, so it must be reachable +# from the API Server, not from your browser. +# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly. +# POCKETBASE_URL=http://pocketbase:8070 +# WEBAPP_URL=http://web-app:8090 + +# --- File storage: SeaweedFS ------------------------------------------------- +# PocketBase keeps its record files — document scans, service and refill +# receipts, workshop invoices, part photos — in the bucket below instead of on +# pb_data. The database and PocketBase's own backups stay where they are. +# +# The credentials do double duty: they configure the SeaweedFS gateway's single +# identity *and* are what PocketBase authenticates with. There are no safe +# defaults, and the stack refuses to start without them. +PB_S3_ACCESS_KEY= +PB_S3_SECRET= +# The bucket. Created on first boot by the seaweedfs-init container. +PB_S3_BUCKET=drivervault +# SeaweedFS ignores the region; PocketBase insists on having one. +PB_S3_REGION=us-east-1 +# The gateway image, pinned so a rebuild months from now brings up the same one. +# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45 +# Host port for the S3 API, so aws-cli and friends can reach it while developing. +# SEAWEED_S3_PORT=8333 + +# Existing uploads are NOT migrated when this is switched on: PocketBase copies +# nothing, so attachments made before the switch stop resolving. Read the file +# storage section of README.md first. diff --git a/Docker/Caddyfile b/Docker/Caddyfile deleted file mode 100644 index 27d9421..0000000 --- a/Docker/Caddyfile +++ /dev/null @@ -1,24 +0,0 @@ -# Caddy in front of the stack: one hostname, TLS from Let's Encrypt, everything -# behind it spoken to over the compose network in plain HTTP. -# -# Both doors are the same door here. Browsers get the Web App; chargers dial -# /ocpp/{serial} on the same hostname, and the BFF carries that through to the -# API Server. Caddy proxies WebSocket upgrades without being told to, so there -# is nothing to configure for the charger case. -# -# DV_DOMAIN and DV_ACME_EMAIL come from .env via docker-compose.tls.yml. - -{ - email {$DV_ACME_EMAIL} -} - -{$DV_DOMAIN} { - encode zstd gzip - - # X-Forwarded-Proto is set by Caddy to the scheme the client used. The API - # Server reads it to decide a charger arrived over TLS, which is why the - # web-app service is given TRUST_FORWARDED_PROTO=true — without that the BFF - # would overwrite this header with its own plaintext hop and a charger would - # be rejected under OCPP_REQUIRE_TLS. - reverse_proxy web-app:8090 -} diff --git a/Docker/README.md b/Docker/README.md index e6b220a..4f15f03 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -78,6 +78,68 @@ instead. PocketBase runs as root, so a root-owned host directory is fine. > `WEBAPP_URL` and `CORS_ALLOW_ORIGINS` in `.env` to change them permanently — > in this stack the compose environment wins over anything the panel writes. +## File storage (SeaweedFS / S3) + +Uploaded files — document scans, service and refill receipts, workshop invoices, +part photos — live inside `pb_data` by default, next to the database. Two further +compose files put them in an S3 bucket instead, so the blobs and the database can +be sized, backed up and moved independently. Nothing else changes: an attachment has +always been fetched through the API Server (`GET /api/service-records/{id}/file`), +never from a storage URL, so the Web App, the phone app and the Home Assistant +plugin cannot tell the difference. + +Each shape is one self-contained compose file — nothing to layer, nothing to +remember — with an `.env` example of the same name: + +| Shape | From the registry | From source | +|---|---|---| +| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` | +| **SeaweedFS in this stack** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` | +| **An S3 endpoint outside it** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` | + +So `docker-compose.prod.seaweedfs.yml` is configured from +`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`, +and so on: + +```sh +cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults +docker compose -f docker-compose.prod.seaweedfs.yml pull +docker compose -f docker-compose.prod.seaweedfs.yml up -d +``` + +Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to +start without them. The SeaweedFS ones add a `seaweedfs` container (master, +volume, filer and S3 gateway in one process, on its own `seaweed_data` volume) +plus a one-shot `seaweedfs-init` that creates the bucket, because PocketBase never +issues a `CreateBucket` of its own. The external-S3 ones add no containers at +all: set `PB_S3_ENDPOINT`, and create the bucket yourself. + +On every boot the API Server's bootstrap writes PocketBase's *Files storage* +settings from those variables, then asks PocketBase to prove it can reach the +bucket. Watch for it in the log: + +``` +[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333) +[api] bootstrap: ✓ S3 storage reachable +``` + +A boot that finds the settings already correct logs `• file storage already on S3` +and writes nothing. + +Two things to know before turning it on: + +- **Existing files are not migrated.** PocketBase copies nothing when the setting + flips, so attachments uploaded before the switch stop resolving. Copy + `pb_data/storage///` into the bucket root, keeping + that layout, *before* enabling it — or start from a stack with no attachments. +- **Going back to the plain compose file is not an off switch.** It leaves + PocketBase pointed at + the bucket, deliberately: files already written there are reachable only while + it is. Move them back and turn it off in PocketBase's own admin UI. For the same + reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap — + PocketBase masks the stored secret on read — so change another `PB_S3_*` value + alongside it, or set it in the admin UI. + ## Charger control (OCPP) Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a @@ -93,34 +155,26 @@ A plaintext `ws://` puts the control token on the wire in the clear, so ### With a public hostname and TLS -`docker-compose.tls.yml` adds Caddy in front of the stack: one hostname, a -certificate issued on first boot, and everything behind it spoken to over the -compose network. Browsers and chargers arrive at the same name. +Nothing in this stack terminates TLS. Put your own reverse proxy in front of the +Web App port, give it a certificate and a hostname, and set four things by hand: ```sh # in .env -DV_DOMAIN=drivervault.example.com -DV_ACME_EMAIL=you@example.com - -docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d +OCPP_PUBLIC_URL=wss://drivervault.example.com +OCPP_REQUIRE_TLS=true +CORS_ALLOW_ORIGINS=https://drivervault.example.com +TRUST_FORWARDED_PROTO=true ``` -The overlay sets the rest for you: `OCPP_PUBLIC_URL=wss://$DV_DOMAIN`, -`OCPP_REQUIRE_TLS=true`, `CORS_ALLOW_ORIGINS=https://$DV_DOMAIN`, and -`TRUST_FORWARDED_PROTO=true` on the Web App so the BFF passes Caddy's -`X-Forwarded-Proto` to the API Server instead of overwriting it with its own -plaintext hop. Point the charger's OCPP backend at the endpoint the panel then -shows, with the control token as its authorization key. +`TRUST_FORWARDED_PROTO` is the easy one to miss: without it the Web App's BFF +overwrites the proxy's `X-Forwarded-Proto` with its own plaintext hop and every +charger is rejected as insecure. Only set it when that proxy really is the only +way in — otherwise a charger could claim `wss` over a plaintext connection. -Two things the overlay cannot arrange: `DV_DOMAIN` must resolve to the host from -the internet with ports 80 and 443 reaching it (Caddy needs `:80` for the ACME -challenge), and the charger must be able to resolve that name too — behind NAT -that usually means hairpin NAT or a split-DNS entry pointing it at the LAN -address. - -Using a proxy you already run instead? Terminate TLS there, forward to the Web -App port, and set the same four variables by hand — the `X-Forwarded-Proto` one -included, or chargers will be rejected as insecure. +Point the charger's OCPP backend at the endpoint the panel then shows, with the +control token as its authorization key. The charger has to resolve that hostname +too: behind NAT that usually means hairpin NAT, or a split-DNS entry pointing the +name at the LAN address. ## Notes diff --git a/Docker/docker-compose.prod.s3.yml b/Docker/docker-compose.prod.s3.yml new file mode 100644 index 0000000..1a63dd2 --- /dev/null +++ b/Docker/docker-compose.prod.s3.yml @@ -0,0 +1,174 @@ +name: drivervault + +# Production DriverVault stack, with external S3 — pulls prebuilt images from +# the registry instead of building from source. Self-contained: one file, no +# overlays. Everything an operator needs to set lives in .env. +# +# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially) +# 2. docker compose -f docker-compose.prod.s3.yml pull +# 3. docker compose -f docker-compose.prod.s3.yml up -d +# +# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists +# somewhere else — its own host, another compose project, or any S3-compatible +# service. PocketBase keeps its record files — document scans, service and +# refill receipts, workshop invoices, part photos — in that bucket instead of on +# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA. +# Clients cannot tell the difference: an attachment has always been fetched +# through the API Server, never from a storage URL. +# +# The bucket must already exist, and nothing here runs the gateway. For a +# SeaweedFS that comes up with the stack, use docker-compose.prod.seaweedfs.yml. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. +# +# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase. +# +# On first boot: +# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing). +# • the API Server creates any missing collections, reconciles existing ones, +# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*. +# Both steps are idempotent, so restarts and upgrades are safe. + +services: + pocketbase: + image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}" + container_name: drivervault-pocketbase + restart: unless-stopped + extra_hosts: + # Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal. + # Harmless when the endpoint is somewhere else entirely. + - "host.docker.internal:host-gateway" + environment: + # The superuser is created/updated on boot (the API Server authenticates + # with it). This is the only place the first superuser can be created — the + # REST API cannot bootstrap it. + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + volumes: + # Named volume by default; set PB_DATA to a host path in .env for a bind mount. + - "${PB_DATA:-pb_data}:/pb/pb_data" + ports: + # Bound to localhost by default — the admin UI (/_/) is reachable only on + # the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network. + - "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + api-server: + image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}" + container_name: drivervault-api + restart: unless-stopped + extra_hosts: + # Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal. + # Harmless when the endpoint is somewhere else entirely. + - "host.docker.internal:host-gateway" + depends_on: + pocketbase: + condition: service_healthy + environment: + API_ADDR: ":8080" + # Reach PocketBase by its service name on the internal network. Override + # POCKETBASE_URL in .env to point the API Server at a database outside + # this stack — that is also how you make a retarget done from the panel + # permanent, since the panel's change lasts only for the container's life. + POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}" + POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + # Probed by the panel status page. This is a server-to-server call inside + # the compose network, so the default is the service name — plain + # localhost:8090 would resolve to this container itself. Override + # WEBAPP_URL in .env to make a change from the panel's Web App screen + # permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}" + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). Chargers are rejected unless they + # reach the server over TLS. Behind a TLS-terminating reverse proxy, set + # OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can + # reach this port; only drop OCPP_REQUIRE_TLS on a trusted network. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the + # charger publishes over Anker's broker, decoded ones and unreadable ones + # alike, with their bytes. It is how a frame nobody has named gets named — + # do something in the Anker app while a control read holds the connection + # open, and read the frames back out of the log. Off by default: with it on + # a charger under a live trigger writes a line every few seconds. + ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}" + # --- File storage -------------------------------------------------- + # Read by the API Server's bootstrap, which writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on PB_DATA. The bucket must already exist: nothing here + # creates it. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}" + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + # true for SeaweedFS and MinIO, false for AWS S3 proper. + PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}" + ports: + # Localhost-only by default (the Web App reaches it over the internal + # network). Set API_BIND=0.0.0.0 to expose the API panel — and the + # /ocpp/{serial} endpoint chargers dial into — on the network. + - "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080" + # No volume: the API Server keeps no state on disk — every setting it owns, + # plugin settings included, lives in PocketBase under PB_DATA. + healthcheck: + # Declared here rather than relying only on the image's HEALTHCHECK, so the + # depends_on gate below still works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + web-app: + image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}" + container_name: drivervault-web + restart: unless-stopped + depends_on: + # The image now ships a HEALTHCHECK, so wait for the API Server to be + # serving rather than merely started. + api-server: + condition: service_healthy + environment: + # The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are + # told to dial — to the API Server over the internal network. + API_BASE: "http://api-server:8080" + # Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a + # charger arrived over TLS, so leave this off unless a TLS-terminating + # proxy in front of the stack is the only way in: otherwise a charger could + # claim wss over a plaintext connection. Set it to true when TLS ends at + # that proxy and OCPP_PUBLIC_URL names a wss:// base through it. + TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}" + ports: + # The public front door. Bound on all interfaces so browsers can reach it. + - "${WEB_PORT:-8090}:8090" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + +volumes: + pb_data: diff --git a/Docker/docker-compose.prod.seaweedfs.yml b/Docker/docker-compose.prod.seaweedfs.yml new file mode 100644 index 0000000..8f9eb96 --- /dev/null +++ b/Docker/docker-compose.prod.seaweedfs.yml @@ -0,0 +1,221 @@ +name: drivervault + +# Production DriverVault stack, with SeaweedFS — pulls prebuilt images from the +# registry instead of building from source. Self-contained: one file, no +# overlays. Everything an operator needs to set lives in .env. +# +# 1. cp .env.prod.seaweedfs.example .env (then edit it) +# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull +# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d +# +# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its +# record files — document scans, service and refill receipts, workshop invoices, +# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to +# the database. The database and PocketBase's own backups stay on PB_DATA. +# Clients cannot tell the difference: an attachment has always been fetched +# through the API Server, never from a storage URL. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. +# +# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase. +# +# On first boot: +# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing). +# • the API Server creates any missing collections, reconciles existing ones, +# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*. +# Both steps are idempotent, so restarts and upgrades are safe. + +services: + seaweedfs: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-seaweedfs + restart: unless-stopped + # One process, four roles: master, volume, filer and the S3 gateway. -dir is + # the only state it keeps. + command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024 + environment: + # SeaweedFS falls back to these when started without an -s3.config file, + # and configuring one identity is what takes the S3 gateway out of its + # default allow-anyone mode. The same credentials PocketBase authenticates + # with below — one pair to set, in .env. + AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + volumes: + # Named volume by default; set SEAWEED_DATA to a host path in .env for a + # bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA — + # from here on the attachments live here, not in the database volume. + - "${SEAWEED_DATA:-seaweed_data}:/data" + ports: + # Loopback only: the stack reaches the gateway over the compose network, + # so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and + # nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it. + - "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + seaweedfs-init: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-seaweedfs-init + # Runs once and exits. PocketBase never issues a CreateBucket of its own and + # SeaweedFS will not conjure one on first upload, so something has to. + # Creating a bucket that already exists is a no-op, so every later boot + # passes straight through. + restart: "no" + depends_on: + seaweedfs: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + # `|| true` so a restart is never blocked by the shell's exit status: this + # step is best-effort, and a gateway that is genuinely unreachable is + # reported by the API Server's own S3 check at boot, with the reason. + command: + - 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true' + + pocketbase: + image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}" + container_name: drivervault-pocketbase + restart: unless-stopped + depends_on: + # PocketBase is the process that reads and writes the objects, so the + # gateway has to be serving before it is asked to store anything. + seaweedfs: + condition: service_healthy + environment: + # The superuser is created/updated on boot (the API Server authenticates + # with it). This is the only place the first superuser can be created — the + # REST API cannot bootstrap it. + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + volumes: + # Named volume by default; set PB_DATA to a host path in .env for a bind mount. + - "${PB_DATA:-pb_data}:/pb/pb_data" + ports: + # Bound to localhost by default — the admin UI (/_/) is reachable only on + # the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network. + - "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + api-server: + image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}" + container_name: drivervault-api + restart: unless-stopped + depends_on: + pocketbase: + condition: service_healthy + # The bucket must exist before the bootstrap points PocketBase at it. + seaweedfs-init: + condition: service_completed_successfully + environment: + API_ADDR: ":8080" + # Reach PocketBase by its service name on the internal network. Override + # POCKETBASE_URL in .env to point the API Server at a database outside + # this stack — that is also how you make a retarget done from the panel + # permanent, since the panel's change lasts only for the container's life. + POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}" + POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + # Probed by the panel status page. This is a server-to-server call inside + # the compose network, so the default is the service name — plain + # localhost:8090 would resolve to this container itself. Override + # WEBAPP_URL in .env to make a change from the panel's Web App screen + # permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}" + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). Chargers are rejected unless they + # reach the server over TLS. Behind a TLS-terminating reverse proxy, set + # OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can + # reach this port; only drop OCPP_REQUIRE_TLS on a trusted network. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the + # charger publishes over Anker's broker, decoded ones and unreadable ones + # alike, with their bytes. It is how a frame nobody has named gets named — + # do something in the Anker app while a control read holds the connection + # open, and read the frames back out of the log. Off by default: with it on + # a charger under a live trigger writes a line every few seconds. + ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}" + # --- File storage -------------------------------------------------- + # Read by the API Server's bootstrap, which writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on PB_DATA. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + # The service name: a server-to-server call inside the compose network. + PB_S3_ENDPOINT: "http://seaweedfs:8333" + # SeaweedFS ignores the region; PocketBase insists on having one. + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}" + PB_S3_SECRET: "${PB_S3_SECRET}" + # Path style, because a self-hosted gateway has no per-bucket DNS. + PB_S3_FORCE_PATH_STYLE: "true" + ports: + # Localhost-only by default (the Web App reaches it over the internal + # network). Set API_BIND=0.0.0.0 to expose the API panel — and the + # /ocpp/{serial} endpoint chargers dial into — on the network. + - "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080" + # No volume: the API Server keeps no state on disk — every setting it owns, + # plugin settings included, lives in PocketBase under PB_DATA. + healthcheck: + # Declared here rather than relying only on the image's HEALTHCHECK, so the + # depends_on gate below still works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + web-app: + image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}" + container_name: drivervault-web + restart: unless-stopped + depends_on: + # The image now ships a HEALTHCHECK, so wait for the API Server to be + # serving rather than merely started. + api-server: + condition: service_healthy + environment: + # The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are + # told to dial — to the API Server over the internal network. + API_BASE: "http://api-server:8080" + # Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a + # charger arrived over TLS, so leave this off unless a TLS-terminating + # proxy in front of the stack is the only way in: otherwise a charger could + # claim wss over a plaintext connection. Set it to true when TLS ends at + # that proxy and OCPP_PUBLIC_URL names a wss:// base through it. + TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}" + ports: + # The public front door. Bound on all interfaces so browsers can reach it. + - "${WEB_PORT:-8090}:8090" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + +volumes: + pb_data: + seaweed_data: diff --git a/Docker/docker-compose.s3.yml b/Docker/docker-compose.s3.yml new file mode 100644 index 0000000..e0c5e9f --- /dev/null +++ b/Docker/docker-compose.s3.yml @@ -0,0 +1,174 @@ +name: drivervault + +# Full DriverVault stack, with external S3: PocketBase (database) + API Server + +# Web App, built from source, storing files in an S3 endpoint that already +# exists somewhere else. Self-contained — one file, nothing to layer. +# +# cp .env.s3.example .env (then edit it — PB_S3_* especially) +# docker compose -f docker-compose.s3.yml up -d --build +# +# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase. +# +# This is docker-compose.yml plus storage: PocketBase keeps its record files — +# document scans, service and refill receipts, workshop invoices, part photos — +# in that bucket instead of on the pb_data volume next to the database. The +# database and PocketBase's own backups stay on pb_data. Clients cannot tell the +# difference: an attachment has always been fetched through the API Server, +# never from a storage URL. +# +# The bucket must already exist, and nothing here runs the gateway. For a +# SeaweedFS that comes up with the stack, use docker-compose.seaweedfs.yml. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. + +services: + pocketbase: + build: + context: ./pocketbase + image: drivervault-pocketbase + container_name: drivervault-pocketbase + restart: unless-stopped + extra_hosts: + # Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal. + # Harmless when the endpoint is somewhere else entirely. + - "host.docker.internal:host-gateway" + environment: + # Superuser is created/updated on boot so the API Server can authenticate. + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + volumes: + - pb_data:/pb/pb_data + ports: + # Admin UI / API exposed on the host for management (http://host:8070/_/). + - "${PB_PORT:-8070}:8070" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + api-server: + build: + context: ../API Server + image: drivervault-api + container_name: drivervault-api + restart: unless-stopped + extra_hosts: + # Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal. + # Harmless when the endpoint is somewhere else entirely. + - "host.docker.internal:host-gateway" + depends_on: + pocketbase: + condition: service_healthy + environment: + API_ADDR: ":8080" + # Reach PocketBase by its service name on the internal network. Override + # POCKETBASE_URL in .env to point the API Server at a database outside + # this stack — that is also how you make a retarget done from the panel + # permanent, since the panel's change lasts only for the container's life. + POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}" + POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + # Probed by the panel status page. This is a server-to-server call inside + # the compose network, so the default is the service name — plain + # localhost:8090 would resolve to this container itself. Override + # WEBAPP_URL in .env to make a change from the panel's Web App screen + # permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}" + # Same-origin requests go through the Web App BFF, so CORS is only needed + # if the browser ever calls the API Server directly. Default to the web origin. + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" + # Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars + # the collections are still created but no app user is, leaving a stack + # you cannot log into. + # + # Leave the bootstrap ON: a release can add collections or fields the + # server needs, and a stack that skips it never gets them. The API Server + # self-heals exactly one thing — app_settings, the collection holding the + # plugin settings, which it creates on demand because it cannot serve the + # plugin panel without it. Every other schema change still depends on this + # flag. Turn it off only for a database you know matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). Chargers are rejected unless they + # connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is + # terminated in front of this stack or for local dev on a trusted network. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the + # charger publishes over Anker's broker, decoded ones and unreadable ones + # alike, with their bytes. It is how a frame nobody has named gets named — + # do something in the Anker app while a control read holds the connection + # open, and read the frames back out of the log. Off by default: with it on + # a charger under a live trigger writes a line every few seconds. + ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}" + # --- File storage -------------------------------------------------- + # Read by the API Server's bootstrap, which writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on pb_data. The bucket must already exist: nothing here + # creates it. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}" + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + # true for SeaweedFS and MinIO, false for AWS S3 proper. + PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}" + ports: + # Optional direct access to the API Server (and its panel at /); the Web + # App reaches it over the internal network, not this host port. Chargers + # dialling /ocpp/{serial} also arrive here. + - "${API_PORT:-8080}:8080" + # No volume: the API Server keeps no state on disk — every setting it owns, + # plugin settings included, lives in PocketBase under pb_data. + healthcheck: + # Declared here rather than relying only on the image's HEALTHCHECK, so the + # depends_on gate below still works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + web-app: + build: + context: ../Web App + args: + # Empty -> bundle uses same-origin "/api", which the BFF proxies below. + VITE_API_BASE: "${VITE_API_BASE:-}" + image: drivervault-web + container_name: drivervault-web + restart: unless-stopped + depends_on: + # The image now ships a HEALTHCHECK, so wait for the API Server to be + # serving rather than merely started. + api-server: + condition: service_healthy + environment: + # The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are + # told to dial — to the API Server over the internal network. + API_BASE: "http://api-server:8080" + # Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a + # charger arrived over TLS, so leave this off unless a TLS-terminating + # proxy in front of the stack is the only way in: otherwise a charger could + # claim wss over a plaintext connection. Set it to true when TLS ends at + # that proxy and OCPP_PUBLIC_URL names a wss:// base through it. + TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}" + ports: + - "${WEB_PORT:-8090}:8090" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + +volumes: + pb_data: diff --git a/Docker/docker-compose.seaweedfs.yml b/Docker/docker-compose.seaweedfs.yml new file mode 100644 index 0000000..64f8e98 --- /dev/null +++ b/Docker/docker-compose.seaweedfs.yml @@ -0,0 +1,219 @@ +name: drivervault + +# Full DriverVault stack, with SeaweedFS: PocketBase (database) + API Server + +# Web App, built from source, plus an S3 object store. Self-contained — one +# file, nothing to layer. +# +# cp .env.seaweedfs.example .env (then edit it) +# docker compose -f docker-compose.seaweedfs.yml up -d --build +# +# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase. +# +# This is docker-compose.yml plus storage: PocketBase keeps its record files — +# document scans, service and refill receipts, workshop invoices, part photos — +# in a SeaweedFS bucket instead of on the pb_data volume next to the database. +# The database and PocketBase's own backups stay on pb_data. Clients cannot tell +# the difference: an attachment has always been fetched through the API Server, +# never from a storage URL. +# +# Before turning this on for a stack that already has uploads: PocketBase does +# NOT copy existing files into the bucket. See README.md. + +services: + seaweedfs: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-seaweedfs + restart: unless-stopped + # One process, four roles: master, volume, filer and the S3 gateway. -dir is + # the only state it keeps. + command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024 + environment: + # SeaweedFS falls back to these when started without an -s3.config file, + # and configuring one identity is what takes the S3 gateway out of its + # default allow-anyone mode. The same credentials PocketBase authenticates + # with below — one pair to set, in .env. + AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" + AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" + volumes: + # From here on the attachments live here, not on pb_data. + - seaweed_data:/data + ports: + # The stack reaches the gateway over the compose network; this is here so + # `aws s3 ls --endpoint-url http://localhost:8333` works while developing. + - "${SEAWEED_S3_PORT:-8333}:8333" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + seaweedfs-init: + image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" + container_name: drivervault-seaweedfs-init + # Runs once and exits. PocketBase never issues a CreateBucket of its own and + # SeaweedFS will not conjure one on first upload, so something has to. + # Creating a bucket that already exists is a no-op, so every later boot + # passes straight through. + restart: "no" + depends_on: + seaweedfs: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + # `|| true` so a restart is never blocked by the shell's exit status: this + # step is best-effort, and a gateway that is genuinely unreachable is + # reported by the API Server's own S3 check at boot, with the reason. + command: + - 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true' + + pocketbase: + build: + context: ./pocketbase + image: drivervault-pocketbase + container_name: drivervault-pocketbase + restart: unless-stopped + depends_on: + # PocketBase is the process that reads and writes the objects, so the + # gateway has to be serving before it is asked to store anything. + seaweedfs: + condition: service_healthy + environment: + # Superuser is created/updated on boot so the API Server can authenticate. + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + volumes: + - pb_data:/pb/pb_data + ports: + # Admin UI / API exposed on the host for management (http://host:8070/_/). + - "${PB_PORT:-8070}:8070" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + api-server: + build: + context: ../API Server + image: drivervault-api + container_name: drivervault-api + restart: unless-stopped + depends_on: + pocketbase: + condition: service_healthy + # The bucket must exist before the bootstrap points PocketBase at it. + seaweedfs-init: + condition: service_completed_successfully + environment: + API_ADDR: ":8080" + # Reach PocketBase by its service name on the internal network. Override + # POCKETBASE_URL in .env to point the API Server at a database outside + # this stack — that is also how you make a retarget done from the panel + # permanent, since the panel's change lasts only for the container's life. + POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}" + POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" + POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + # Probed by the panel status page. This is a server-to-server call inside + # the compose network, so the default is the service name — plain + # localhost:8090 would resolve to this container itself. Override + # WEBAPP_URL in .env to make a change from the panel's Web App screen + # permanent; the panel alone only holds it for the container's life. + WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}" + # Same-origin requests go through the Web App BFF, so CORS is only needed + # if the browser ever calls the API Server directly. Default to the web origin. + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" + # Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars + # the collections are still created but no app user is, leaving a stack + # you cannot log into. + # + # Leave the bootstrap ON: a release can add collections or fields the + # server needs, and a stack that skips it never gets them. The API Server + # self-heals exactly one thing — app_settings, the collection holding the + # plugin settings, which it creates on demand because it cannot serve the + # plugin panel without it. Every other schema change still depends on this + # flag. Turn it off only for a database you know matches the release. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + # OCPP charger control (Anker Solix). Chargers are rejected unless they + # connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is + # terminated in front of this stack or for local dev on a trusted network. + OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" + OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + # Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the + # charger publishes over Anker's broker, decoded ones and unreadable ones + # alike, with their bytes. It is how a frame nobody has named gets named — + # do something in the Anker app while a control read holds the connection + # open, and read the frames back out of the log. Off by default: with it on + # a charger under a live trigger writes a line every few seconds. + ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}" + # --- File storage -------------------------------------------------- + # Read by the API Server's bootstrap, which writes them into PocketBase's + # settings on every boot, idempotently. Only record files move — scans, + # receipts, invoices, part photos. The database and PocketBase's own + # backups stay on pb_data. + PB_S3_ENABLED: "true" + PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" + # The service name: a server-to-server call inside the compose network. + PB_S3_ENDPOINT: "http://seaweedfs:8333" + # SeaweedFS ignores the region; PocketBase insists on having one. + PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" + PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}" + PB_S3_SECRET: "${PB_S3_SECRET}" + # Path style, because a self-hosted gateway has no per-bucket DNS. + PB_S3_FORCE_PATH_STYLE: "true" + ports: + # Optional direct access to the API Server (and its panel at /); the Web + # App reaches it over the internal network, not this host port. Chargers + # dialling /ocpp/{serial} also arrive here. + - "${API_PORT:-8080}:8080" + # No volume: the API Server keeps no state on disk — every setting it owns, + # plugin settings included, lives in PocketBase under pb_data. + healthcheck: + # Declared here rather than relying only on the image's HEALTHCHECK, so the + # depends_on gate below still works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + web-app: + build: + context: ../Web App + args: + # Empty -> bundle uses same-origin "/api", which the BFF proxies below. + VITE_API_BASE: "${VITE_API_BASE:-}" + image: drivervault-web + container_name: drivervault-web + restart: unless-stopped + depends_on: + # The image now ships a HEALTHCHECK, so wait for the API Server to be + # serving rather than merely started. + api-server: + condition: service_healthy + environment: + # The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are + # told to dial — to the API Server over the internal network. + API_BASE: "http://api-server:8080" + # Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a + # charger arrived over TLS, so leave this off unless a TLS-terminating + # proxy in front of the stack is the only way in: otherwise a charger could + # claim wss over a plaintext connection. Set it to true when TLS ends at + # that proxy and OCPP_PUBLIC_URL names a wss:// base through it. + TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}" + ports: + - "${WEB_PORT:-8090}:8090" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + +volumes: + pb_data: + seaweed_data: diff --git a/Docker/docker-compose.tls.yml b/Docker/docker-compose.tls.yml deleted file mode 100644 index 6eb01f9..0000000 --- a/Docker/docker-compose.tls.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: drivervault - -# TLS overlay — put a public hostname and a real certificate in front of the -# stack. Layer it on top of either base file: -# -# docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d -# -# What it changes: -# • Caddy terminates TLS on :443 and proxies everything to the Web App BFF, -# which already carries /api/ and /ocpp/ through to the API Server. One -# hostname serves browsers and chargers alike. -# • The charger endpoint the panel hands out becomes wss://$DV_DOMAIN/ocpp/…, -# so OCPP_REQUIRE_TLS goes back on and the control token stops crossing the -# network in the clear. -# -# Requirements, none of which this file can arrange for you: -# • DV_DOMAIN resolves to this host from the public internet, and ports 80 and -# 443 reach it (Caddy needs :80 for the ACME challenge, and keeps it for the -# redirect afterwards). -# • The charger can resolve DV_DOMAIN too. On a LAN behind NAT that usually -# means hairpin NAT or a split-DNS entry pointing the name at 10.2.1.10 — -# otherwise the charger looks up a public address it cannot route to. - -services: - caddy: - image: "${CADDY_IMAGE:-caddy:2-alpine}" - container_name: drivervault-caddy - restart: unless-stopped - depends_on: - web-app: - condition: service_healthy - environment: - DV_DOMAIN: "${DV_DOMAIN:?set DV_DOMAIN in .env}" - # Let's Encrypt sends expiry warnings here if renewal ever stops working. - DV_ACME_EMAIL: "${DV_ACME_EMAIL:?set DV_ACME_EMAIL in .env}" - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - # Certificates live here. Keep the volume: wiping it re-issues on next - # boot and Let's Encrypt rate-limits that. - - caddy_data:/data - - caddy_config:/config - - api-server: - environment: - # Derived from the one hostname, so there is a single thing to set. - OCPP_REQUIRE_TLS: "true" - OCPP_PUBLIC_URL: "wss://${DV_DOMAIN}" - CORS_ALLOW_ORIGINS: "https://${DV_DOMAIN}" - - web-app: - environment: - # Caddy terminates TLS and says so in X-Forwarded-Proto. Believe it — - # that header is now set by the proxy in front, not by whoever dialed in. - TRUST_FORWARDED_PROTO: "true" - -volumes: - caddy_data: - caddy_config: