Compare commits

...
6 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 c9a95131a5 The single-process object store follows its split sibling into the image
Dockerfile.seaweedfs bakes `weed server -s3` into the all-in-one as a
fourth supervisord program, sharing every build stage and the nginx
config with Dockerfile.seaweedfs.split. The gateway's single identity is
mapped from the PB_S3_* pair PocketBase already authenticates with, and
an empty pair refuses to start rather than serving allow-anyone. The
seaweedfs-init container becomes a step in PocketBase's program — wait
for the gateway, create the bucket, then serve — so the bucket exists
before the API Server's bootstrap checks it, in the order compose kept
with depends_on. The volume server moves to 8081 because 8080 is the API
Server; weed server has one bind flag for all four roles, so it stays
open and the comment says never to publish it.

docker-compose.prod.seaweedfs.yml goes from three services to one, with
build: and image: both set under a registry name of its own. The env
example and README follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:59:16 +02:00
tajniak81andClaude Opus 5 28f5fda451 The split object store moves into the image it was kept out of
Dockerfile.seaweedfs.split bakes SeaweedFS master, volume, filer, S3
gateway and admin UI into the all-in-one as five more supervisord
programs, with the weed binary copied from the official image and pinned
by SEAWEED_VERSION. The readiness chain the compose split expressed with
depends_on and healthchecks becomes until-wget loops: master, volume,
filer, then the gateway, which seeds the bucket and PocketBase's identity
and refuses to serve if the grep gate fails. The volume server sits on
8081 because 8080 is the API Server; every role except the gateway and
the admin UI binds loopback.

docker-compose.prod.seaweedfs.split.yml goes from six services to one,
with build: and image: both set so the same file builds, pushes and
pulls, under a registry name of its own since this image expects three
volumes. The env example and README follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:43:49 +02:00
tajniak81andClaude Opus 5 dcdc437efa The brace that closed nothing took the car screens with it
CarStrings' placeholder pattern escaped its opening brace and left the
closing one bare. Desktop java.util.regex reads a lone "}" as a literal;
Android compiles through ICU, which refuses it. So the companion object
threw PatternSyntaxException the first time anything touched it, which is
VaultScreen's constructor, which is every car screen there is — the host
bound the service, the app died, and the head unit sat black with nothing
in the log but a clean bind.

i18n.dart, which this is the twin of, escapes both. Do the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:16:55 +02:00
tajniak81andClaude Opus 5 6393543bf5 The car is not somewhere you can sideload an app into
Android Auto's Unknown sources setting covers media, messaging and
parked apps; it has never covered the Car App Library, so the README's
advice to turn it on could not have worked. A head unit lists DriverVault
only when Play was the installer — an internal track is enough — and the
Desktop Head Unit is what takes a sideloaded build. Say that instead, and
say which APK the DHU wants, since only a debug build allows its host.

minCarApiLevel also claimed a floor lower than the screens stand on:
GarageScreen asks for ConstraintManager, which is level 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:24:30 +02:00
tajniak81andClaude Opus 5 3c34b708b9 A mode that does not work stops being on the menu
The control-mode picker offered all five paths to everyone, always. When one of
them is broken in a deployment — OCPP is, right now — there was nothing to do
about it: the superadmin could pick a different mode for the global layer, but
the option stayed in every organization's and every user's dropdown, waiting to
be chosen. The cascade could impose a mode. It could not withdraw one.

So each layer now carries a second, separate thing: a list of the modes it hides
from the layers below it. controlModesDisabled sits beside controlMode, on the
global layer as a plugin config field and on an organization as part of the same
pluginSettings blob its credentials already live in. A superadmin ticking Own
CSMS and Proxy CSMS takes both OCPP paths out of every picker underneath; an org
admin ticking Modbus takes it out of their own users'.

Three decisions are worth naming.

A hide-list governs the layers below, not the layer holding it. The superadmin
can keep running Proxy globally while hiding it from everyone else, which is
what you want while a mode is being repaired rather than retired: the operator
testing the fix is the one person who still needs to select it. The alternative,
a list that also invalidates its own layer's choice, would have made the panel
contradict itself — a mode chosen in one field and switched off in the one below
it.

But a hidden mode really is hidden, not merely absent from a dropdown. A user
who had picked Proxy last month stops resolving to Proxy the moment the
superadmin hides it, and falls back to monitoring only. Filtering the picker
alone would have left every existing charger on the broken path and quietly
disagreed with the list the operator had just filled in. Resolution now walks
the layers accumulating what each hides from the next, so a stored value only
takes effect if the layers above it still permit it.

And off is never hideable. It is what a charger falls back to and what an empty
cascade resolves to, so a layer that could take it away could leave the layer
below with a picker holding no valid choice at all. It is not among the
checkboxes in any of the three clients, and the parser drops it if it arrives
anyway.

The panel needed a field shape it did not have — several options, any number
chosen — so ConfigField grows a "multiselect" type, stored as the
comma-separated string that fits the flat map every other field already uses.
That is generic: any plugin can declare one now, and the PUT body is unchanged.
The phone's field specs grew the same way, a scopeOptions hook that narrows a
declared option list to what the server still offers, rather than teaching the
integration card about control modes specifically.

Both clients clamp a stored mode that has since been hidden back to off before
drawing the picker, so the box shows what will actually happen rather than a
choice that would be dropped on save.

Verified: Go tests pass, both frontends build, flutter analyze is clean, and the
panel's new checkbox field was rendered against the real stylesheet. The
end-to-end path — superadmin hides a mode, an org admin and then a user reload
and find it gone — has not been walked on a live stack; the panel is embedded in
the Go binary, so the remote deployment needs a rebuild before any of this is
visible there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:54:30 +02:00
tajniak81andClaude Opus 5 d4b9d0870e The server notices its own token has run out
Signing in worked and everything after it did not: the login screen took the
password, PocketBase handed back the record and the token, and then /api/me
answered "The requested resource wasn't found." The three symptoms underneath
looked unrelated — a 404 on the profile, an organization whose name came back
empty, a user list with nobody in it — and they are one thing. The API Server's
superuser token had expired.

PocketBase does not say so. A record call carrying a token it no longer accepts
is not refused; the header is ignored and the request is served as a guest, and
the collection rules answer in the transport's place. The organizations
collection is superuser-only, so it 403s. A user record is guarded by a view
rule, so it 404s — hidden rather than denied. The users list is rule-filtered,
so it comes back 200 with an empty array. Not one of those is a 401, and a 401
was the only thing that made this client sign in again.

So the token was acquired once at startup and then kept for the life of the
container, and the retry meant to renew it could never fire. Uptime longer than
the token's lifetime was all it took. Nothing had to change for it to break,
which is why it broke on a stack nobody had touched.

The client now reads the exp claim PocketBase stamps into the token and renews
before spending it, a minute early so a call cannot land just after it lapses.
A token with no readable expiry is still taken at face value, and the 401 retry
stays where it is as the backstop for the case this does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 11:52:39 +02:00
33 changed files with 1886 additions and 485 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-BBZfstAT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-_8IdQ1wh.css">
<script type="module" crossorigin src="/assets/index-CUgU2Fxs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cd85xDqG.css">
</head>
<body>
<div id="app"></div>
@@ -62,6 +62,52 @@ func normalizeControlMode(v string) string {
}
}
// ankerControlModesAll lists every control mode, in the order the clients offer
// them. off leads because it is the default and the fallback.
var ankerControlModesAll = []string{ankerControlOff, ankerControlMqtt, ankerControlModbus, ankerControlOwn, ankerControlProxy}
// parseControlModes reads a stored comma-separated mode list into a set,
// dropping anything unrecognized. off is never in it: monitoring-only is what a
// charger falls back to, so no layer may take it away from the layers below.
func parseControlModes(v string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(v, ",") {
if m := normalizeControlMode(part); m != "" && m != ankerControlOff {
out[m] = true
}
}
return out
}
// joinControlModes renders a mode set back to its stored form, in the canonical
// order, so what a client sends round-trips to a predictable string.
func joinControlModes(set map[string]bool) string {
return strings.Join(controlModeList(set), ",")
}
// controlModeList sorts a mode set into the canonical order.
func controlModeList(set map[string]bool) []string {
out := []string{}
for _, m := range ankerControlModesAll {
if set[m] {
out = append(out, m)
}
}
return out
}
// controlModesOffered lists the modes a layer may still choose once the layers
// above it have hidden theirs.
func controlModesOffered(hidden map[string]bool) []string {
out := []string{}
for _, m := range ankerControlModesAll {
if !hidden[m] {
out = append(out, m)
}
}
return out
}
// ankerConfig is one layer's Anker Solix settings.
type ankerConfig struct {
Email string `json:"email"`
@@ -70,6 +116,12 @@ type ankerConfig struct {
// ControlMode is the control path: off | mqtt | modbus | own | proxy.
// It resolves independently of the credentials, like Country.
ControlMode string `json:"controlMode"`
// ControlModesDisabled is a comma-separated list of modes this layer hides
// from the layers below it — a mode a deployment (or an organization) does
// not want offered at all. It does not constrain this layer's own choice, and
// it never hides off. Meaningful on the global and organization layers only:
// a user has nobody below them.
ControlModesDisabled string `json:"controlModesDisabled,omitempty"`
}
// ankerChargerBinding is what we know about one charger the caller controls:
@@ -128,6 +180,14 @@ type ankerResolution struct {
available bool // global master switch (plugin enabled in the panel)
orgEnabled bool // org gate (default true; gates the org's users)
enabled bool // caller's personal enable flag
// hiddenForOrg is what the global layer hides from everyone below it;
// hiddenForUser adds what the caller's organization hides from its own users.
// They gate both the pickers the clients draw and which layer's stored mode
// is allowed to take effect.
hiddenForOrg map[string]bool
hiddenForUser map[string]bool
orgHidden map[string]bool // the organization layer's own list, for its editor
}
// ankerLayerRank orders the cascade layers; a higher number is lower priority.
@@ -137,7 +197,8 @@ var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
// pluginSettings blob (read from their user record).
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"], ControlMode: g["controlMode"]}
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"],
ControlMode: g["controlMode"], ControlModesDisabled: g["controlModesDisabled"]}
var oStored ankerStored
if who.OrgID != "" {
@@ -166,6 +227,21 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
enabled: uStored.Enabled,
}
// What each layer below may still be offered. The global list reaches
// everyone; an organization's own list reaches only its users, so it narrows
// the set once more on the way down.
res.hiddenForOrg = parseControlModes(gc.ControlModesDisabled)
res.orgHidden = parseControlModes(oc.ControlModesDisabled)
res.hiddenForUser = map[string]bool{}
for m := range res.hiddenForOrg {
res.hiddenForUser[m] = true
}
if who.OrgID != "" {
for m := range res.orgHidden {
res.hiddenForUser[m] = true
}
}
// Ordered layers, top (highest priority) first.
type layer struct {
name string
@@ -189,13 +265,18 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
// Control mode resolves independently too, defaulting to off (monitoring
// only). The highest layer that sets a recognized value wins; an explicit
// "off" set above still wins (and locks lower layers to monitoring).
// A mode hidden by a layer above is not merely absent from the picker: a value
// stored below before it was hidden stops taking effect too, so switching a
// mode off really does switch it off everywhere underneath.
res.eff.ControlMode = ankerControlOff
res.source["controlMode"] = "unset"
for _, l := range layers {
if v := normalizeControlMode(l.c.ControlMode); v != "" {
res.eff.ControlMode, res.source["controlMode"] = v, l.name
break
v := normalizeControlMode(l.c.ControlMode)
if v == "" || res.hiddenAt(l.name)[v] {
continue
}
res.eff.ControlMode, res.source["controlMode"] = v, l.name
break
}
// Credentials resolve as a pair from the highest layer with an email, so the
@@ -212,6 +293,19 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
return res
}
// hiddenAt is the set of modes hidden from one cascade layer by the layers above
// it. The global layer answers to nobody, so nothing is hidden from it.
func (r ankerResolution) hiddenAt(layer string) map[string]bool {
switch layer {
case "org":
return r.hiddenForOrg
case "user":
return r.hiddenForUser
default:
return map[string]bool{}
}
}
// ankerLockedFor reports whether a field whose value comes from source is locked
// for a caller whose editable layer is editable (i.e. the value is set above them).
func ankerLockedFor(source, editable string) bool {
@@ -301,7 +395,13 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
}
return fv
}
return map[string]any{
// The modes this scope may pick from: everything the layers above it left
// standing. A superadmin's read-only view answers as the user layer they are.
hidden := res.hiddenForUser
if editable == "org" {
hidden = res.hiddenForOrg
}
out := map[string]any{
"editableLayer": editable,
"fields": map[string]ankerFieldView{
"email": field("email", res.eff.Email, own.Email, false),
@@ -309,7 +409,15 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
"country": field("country", res.eff.Country, own.Country, false),
"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
},
"controlModes": controlModesOffered(hidden),
}
if editable == "org" {
// The organization's own hide-list, which its admin edits here. Modes the
// global layer already hid are not in controlModes above, so they simply
// never come up.
out["controlModesDisabled"] = controlModeList(res.orgHidden)
}
return out
}
// ankerView builds the masked, client-safe response body from a resolution.
@@ -319,6 +427,7 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"controlMode": res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy)
"controlModes": controlModesOffered(res.hiddenForUser), // modes still offered to this caller
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
@@ -430,7 +539,21 @@ func (s *Server) handlePutAnker(w http.ResponseWriter, r *http.Request) {
applyField("email", func(c *ankerConfig, v string) { c.Email = v })
applyField("password", func(c *ankerConfig, v string) { c.Password = v })
applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
applyField("controlMode", func(c *ankerConfig, v string) { c.ControlMode = normalizeControlMode(v) })
applyField("controlMode", func(c *ankerConfig, v string) {
m := normalizeControlMode(v)
// A mode hidden above is not a choice this layer can make; keep whatever it
// had rather than storing something that would never take effect.
if m != "" && res.hiddenAt(editable)[m] {
return
}
c.ControlMode = m
})
if editable == "org" {
// Only a layer with users under it has anything to hide from them.
applyField("controlModesDisabled", func(c *ankerConfig, v string) {
c.ControlModesDisabled = joinControlModes(parseControlModes(v))
})
}
// Persist the organization layer (admins) via the service account.
if editable == "org" {
@@ -241,3 +241,83 @@ func TestAnkerCardRoutesAreRegistered(t *testing.T) {
}
}
}
func TestParseControlModes(t *testing.T) {
got := parseControlModes(" Proxy , own ,bogus,, off ")
// off is never hideable: monitoring only is what a charger falls back to.
want := map[string]bool{"proxy": true, "own": true}
if len(got) != len(want) {
t.Fatalf("parseControlModes = %v, want %v", got, want)
}
for m := range want {
if !got[m] {
t.Errorf("parseControlModes is missing %q", m)
}
}
if s := joinControlModes(got); s != "own,proxy" {
t.Errorf("joinControlModes = %q, want own,proxy (canonical order)", s)
}
}
func TestControlModesOffered(t *testing.T) {
got := controlModesOffered(map[string]bool{"own": true, "proxy": true})
want := []string{"off", "mqtt", "modbus"}
if len(got) != len(want) {
t.Fatalf("controlModesOffered = %v, want %v", got, want)
}
for i, m := range want {
if got[i] != m {
t.Errorf("controlModesOffered[%d] = %q, want %q", i, got[i], m)
}
}
}
// A mode the global layer hides is not merely absent from a lower picker: a
// value stored below before it was hidden stops taking effect too.
func TestHiddenControlModeDoesNotTakeEffect(t *testing.T) {
res := ankerResolution{
hiddenForOrg: map[string]bool{"own": true, "proxy": true},
hiddenForUser: map[string]bool{"own": true, "proxy": true},
}
if !res.hiddenAt("user")["proxy"] {
t.Error("proxy should be hidden from the user layer")
}
if res.hiddenAt("global")["proxy"] {
t.Error("nothing is hidden from the global layer — it is the one hiding")
}
view := (&Server{}).ankerScopeView(res, "user")
for _, m := range view["controlModes"].([]string) {
if m == "own" || m == "proxy" {
t.Errorf("hidden mode %q is still offered to the user scope", m)
}
}
if _, ok := view["controlModesDisabled"]; ok {
t.Error("the user scope has nobody below it and must carry no hide-list")
}
}
// An organization narrows the set once more for its own users, and its admin
// edits that list in the org scope.
func TestOrgScopeCarriesItsOwnHideList(t *testing.T) {
res := ankerResolution{
hiddenForOrg: map[string]bool{"proxy": true},
hiddenForUser: map[string]bool{"proxy": true, "own": true},
orgHidden: map[string]bool{"own": true},
}
org := (&Server{}).ankerScopeView(res, "org")
offered := org["controlModes"].([]string)
var sawOwn, sawProxy bool
for _, m := range offered {
sawOwn = sawOwn || m == "own"
sawProxy = sawProxy || m == "proxy"
}
if !sawOwn {
t.Error("an org may still choose a mode it only hides from its users")
}
if sawProxy {
t.Error("a mode the global layer hid must not reach the org picker")
}
if got := org["controlModesDisabled"].([]string); len(got) != 1 || got[0] != "own" {
t.Errorf("org hide-list = %v, want [own]", got)
}
}
+56 -3
View File
@@ -6,12 +6,14 @@ package pb
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -29,6 +31,7 @@ type Client struct {
email string
password string
token string
tokenExp time.Time
}
func New(baseURL, email, password string) *Client {
@@ -69,6 +72,7 @@ func (c *Client) Reconfigure(baseURL, email, password string) {
c.email = email
c.password = password
c.token = ""
c.tokenExp = time.Time{}
c.mu.Unlock()
}
@@ -120,6 +124,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
}
c.mu.Lock()
c.token = out.Token
c.tokenExp = jwtExpiry(out.Token)
c.mu.Unlock()
return nil
}
@@ -134,8 +139,46 @@ func (c *Client) currentToken() string {
return c.token
}
// ensureToken acquires a superuser token when none is cached yet, so that a
// superuser call never goes out unauthenticated.
// tokenSkew is how long before its stated expiry a cached token is treated as
// spent. It covers clock drift between this server and PocketBase, and the
// flight time of a request that passes the check and then arrives just late.
const tokenSkew = 60 * time.Second
// tokenLive reports whether the cached token can still be used. A token with no
// readable expiry is taken at face value — the 401 retry remains the backstop.
func (c *Client) tokenLive() bool {
c.mu.RLock()
defer c.mu.RUnlock()
if c.token == "" {
return false
}
return c.tokenExp.IsZero() || time.Now().Add(tokenSkew).Before(c.tokenExp)
}
// jwtExpiry reads the exp claim out of a PocketBase auth token. The signature is
// PocketBase's business — this only needs the expiry the server itself stamped,
// so the payload is decoded without verification. Anything unreadable comes back
// as the zero time, which tokenLive treats as "no expiry known".
func jwtExpiry(token string) time.Time {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
return time.Time{}
}
return time.Unix(claims.Exp, 0)
}
// ensureToken acquires a superuser token when the cached one is missing or
// spent, so that a superuser call never goes out unauthenticated.
//
// It matters because PocketBase answers a record read it will not allow with
// 404, not 401 — it hides the record rather than refusing the credentials. So a
@@ -149,8 +192,18 @@ func (c *Client) currentToken() string {
// With no service account configured there is nothing to acquire, so the call
// proceeds as before — the endpoints that need superuser access answer 503 on
// their own.
//
// Expiry is checked here rather than left to the 401 retry below, because that
// retry never fires for this client: PocketBase does not reject a stale token on
// a record call, it ignores the header and serves the request as a guest. The
// collection rules then answer instead of the transport — a superuser-only
// collection 403s, a rule-guarded record 404s, and a rule-filtered list comes
// back 200 with nothing in it. None of those are a 401, so a server whose token
// has lapsed keeps sending it and keeps being treated as a stranger to its own
// database until it is restarted. Superuser tokens are long-lived, which only
// means the failure waits weeks and then arrives as "the app forgot my account".
func (c *Client) ensureToken(ctx context.Context) error {
if c.currentToken() != "" || !c.Configured() {
if c.tokenLive() || !c.Configured() {
return nil
}
return c.Authenticate(ctx)
+118 -3
View File
@@ -2,11 +2,13 @@ package pb
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// PocketBase hides a record it will not let you read behind a 404 rather than a
@@ -31,9 +33,14 @@ type fakePB struct {
email string
password string
// When set, tokens are minted as JWTs carrying this lifetime in their exp
// claim, the way PocketBase issues them. Zero keeps the opaque test token.
jwtTTL time.Duration
authCalls int
lastIdentity string
guestAttempts int // record reads that arrived without the superuser token
issued string // the token most recently handed out
guestAttempts int // record reads that arrived without the superuser token
}
func (f *fakePB) handler() http.Handler {
@@ -51,11 +58,21 @@ func (f *fakePB) handler() http.Handler {
writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
return
}
writeTestJSON(w, 200, map[string]any{"token": fakeSvcToken})
token := fakeSvcToken
if f.jwtTTL > 0 {
token = testJWT(time.Now().Add(f.jwtTTL))
}
f.mu.Lock()
f.issued = token
f.mu.Unlock()
writeTestJSON(w, 200, map[string]any{"token": token})
})
mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != fakeSvcToken {
f.mu.Lock()
live := f.issued
f.mu.Unlock()
if got := r.Header.Get("Authorization"); got == "" || got != live {
f.mu.Lock()
f.guestAttempts++
f.mu.Unlock()
@@ -190,3 +207,101 @@ func TestNoServiceAccountSkipsAuthentication(t *testing.T) {
t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth)
}
}
// testJWT builds a token shaped like PocketBase's: three base64url segments,
// the middle one carrying the exp claim. Only that claim is ever read, so the
// header and signature are filler.
func testJWT(exp time.Time) string {
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
return enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." +
enc(map[string]int64{"exp": exp.Unix()}) + ".sig"
}
func TestJWTExpiry(t *testing.T) {
want := time.Now().Add(time.Hour).Truncate(time.Second)
if got := jwtExpiry(testJWT(want)); !got.Equal(want) {
t.Errorf("jwtExpiry = %v, want %v", got, want)
}
// An opaque (non-JWT) token has no readable expiry, and must not be mistaken
// for one that expired at the zero time — tokenLive takes it at face value.
for _, tok := range []string{"", "opaque", "a.b", "a.!!.c", "a." + base64.RawURLEncoding.EncodeToString([]byte("{}")) + ".c"} {
if got := jwtExpiry(tok); !got.IsZero() {
t.Errorf("jwtExpiry(%q) = %v, want zero", tok, got)
}
}
}
// The failure this whole mechanism exists for: a superuser token that has run
// out. PocketBase does not answer 401 for one — it ignores the header and serves
// the request as a guest, so the 401 retry never fires and the client would go
// on presenting a dead token forever. The expiry check has to catch it first.
func TestExpiredTokenIsRenewedBeforeUse(t *testing.T) {
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
c := New(newFakePB(t, f), f.email, f.password)
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne: %v", err)
}
// Age the cached token past its expiry, as an uptime longer than the token's
// lifetime would.
c.mu.Lock()
c.tokenExp = time.Now().Add(-time.Minute)
c.mu.Unlock()
var rec struct{ ID string }
if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil {
t.Fatalf("GetOne with an expired token: %v", err)
}
if rec.ID != fakeRecordID {
t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID)
}
auth, guest := f.counts()
if guest != 0 {
t.Errorf("%d record read(s) went out on a dead token, want 0", guest)
}
if auth != 2 {
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
}
}
// A token close enough to its expiry that it could lapse mid-flight is renewed
// rather than spent, so a call cannot land just after the token dies.
func TestTokenNearingExpiryIsRenewed(t *testing.T) {
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
c := New(newFakePB(t, f), f.email, f.password)
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne: %v", err)
}
c.mu.Lock()
c.tokenExp = time.Now().Add(tokenSkew / 2)
c.mu.Unlock()
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne near expiry: %v", err)
}
if auth, _ := f.counts(); auth != 2 {
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
}
}
// A token with a real lifetime still ahead of it is reused — the expiry check
// must not turn every call into a fresh sign-in.
func TestLiveJWTIsReused(t *testing.T) {
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
c := New(newFakePB(t, f), f.email, f.password)
for i := 0; i < 3; i++ {
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
t.Fatalf("GetOne #%d: %v", i, err)
}
}
if auth, _ := f.counts(); auth != 1 {
t.Errorf("authenticated %d time(s), want 1", auth)
}
}
@@ -334,6 +334,19 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
{Value: "own", Label: "Own CSMS (full control)"},
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
}},
// controlModesDisabled hides modes from the layers *below* this one. It
// is how a superadmin takes a mode that is broken or unwanted in this
// deployment (OCPP, say) out of every organization's and user's picker
// without touching the mode this layer itself runs. Organizations carry
// the same field for their own users; see integrations_ankersolix.go.
{Key: "controlModesDisabled", Label: "Hidden control modes", Type: "multiselect",
Help: "Control modes to hide from organizations and users. A hidden mode disappears from their picker and stops taking effect for them; the mode chosen above, which is this layer's own, is unaffected. Off (monitoring only) can never be hidden — it is what a charger falls back to.",
Options: []plugins.SelectOption{
{Value: "mqtt", Label: "Anker cloud (works anywhere)"},
{Value: "modbus", Label: "Modbus TCP (local network)"},
{Value: "own", Label: "Own CSMS (full control)"},
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
}},
},
}
}
@@ -66,6 +66,31 @@ func TestDescriptor(t *testing.T) {
t.Errorf("controlMode is missing option %q", v)
}
}
// controlModesDisabled is the superadmin's hide-list: which of the modes the
// organizations and users below are offered at all. off is not among them —
// monitoring only is the fallback, so no layer may take it away.
hide, ok := fields["controlModesDisabled"]
if !ok {
t.Fatal("controlModesDisabled config field should be present")
}
if hide.Type != "multiselect" {
t.Errorf("controlModesDisabled type = %q, want multiselect", hide.Type)
}
hideable := map[string]bool{"mqtt": false, "modbus": false, "own": false, "proxy": false}
for _, o := range hide.Options {
if o.Value == "off" {
t.Error("off must not be hideable — it is what a charger falls back to")
}
if _, known := hideable[o.Value]; known {
hideable[o.Value] = true
}
}
for v, seen := range hideable {
if !seen {
t.Errorf("controlModesDisabled is missing option %q", v)
}
}
}
func TestRegistered(t *testing.T) {
+4 -3
View File
@@ -44,7 +44,8 @@ const (
StatusDown = "down"
)
// SelectOption is one choice for a ConfigField of Type "select".
// SelectOption is one choice for a ConfigField of Type "select" (pick one) or
// "multiselect" (pick any number; stored as a comma-separated list of values).
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
@@ -55,12 +56,12 @@ type SelectOption struct {
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // "text" | "password" | "number" | "select"
Type string `json:"type"` // "text" | "password" | "number" | "select" | "multiselect"
Required bool `json:"required"`
Secret bool `json:"secret"` // never echoed back to clients in clear
Help string `json:"help,omitempty"`
Default string `json:"default,omitempty"` // effective default when unset
Options []SelectOption `json:"options,omitempty"` // for Type "select"
Options []SelectOption `json:"options,omitempty"` // for Type "select" and "multiselect"
}
// Capability is one operation a plugin exposes. It maps a stable id to the
@@ -62,6 +62,31 @@ function expand(p) {
open.value = p.name;
}
// A multiselect field is a comma-separated list of option values in the same
// flat string map every other field uses, so the PUT body stays unchanged.
function multiValues(name, key) {
return String(drafts[name]?.[key] ?? "")
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
function hasMulti(name, key, value) {
return multiValues(name, key).includes(value);
}
function toggleMulti(name, key, value, on) {
const set = new Set(multiValues(name, key));
if (on) set.add(value);
else set.delete(value);
// Keep the declared option order rather than click order, so the stored value
// is stable across edits.
const p = plugins.value.find((x) => x.name === name);
const order = (p?.configFields || []).find((f) => f.key === key)?.options || [];
drafts[name][key] = order
.map((o) => o.value)
.filter((v) => set.has(v))
.join(",");
}
async function save(p, enabled) {
busy.value = true;
rowNotice[p.name] = "";
@@ -224,7 +249,25 @@ const healthClass = (s) =>
<label class="dh-label">
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
</label>
<select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
<!-- multiselect: any number of the declared options, stored as a
comma-separated list. Nothing checked means the field imposes
nothing, which is the unset state for a select too. -->
<div v-if="f.type === 'multiselect'" class="flex flex-col gap-1.5 pt-1">
<label
v-for="o in f.options || []"
:key="o.value"
class="flex items-center gap-2 text-sm text-body"
>
<input
type="checkbox"
class="dh-checkbox"
:checked="hasMulti(p.name, f.key, o.value)"
@change="toggleMulti(p.name, f.key, o.value, $event.target.checked)"
/>
<span>{{ o.label || o.value }}</span>
</label>
</div>
<select v-else-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
<!-- A non-required select can be left unset (empty), so the global
layer abstains and lower layers (org / user) may choose. -->
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
+14
View File
@@ -293,6 +293,20 @@ body {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
}
/* Checkbox for multiselect config fields. accent-color keeps the native control
(and its keyboard behaviour) while tinting it to the panel's accent. */
.dh-checkbox {
width: 15px;
height: 15px;
flex: none;
accent-color: var(--accent);
cursor: pointer;
}
.dh-checkbox:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring);
border-radius: 3px;
}
.dh-label {
display: block;
margin-bottom: 5px;
+23 -16
View File
@@ -1,10 +1,17 @@
# DriverVault all-in-one — production config.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.seaweedfs.yml pull
# DriverVault all-in-one — production config, SeaweedFS built into the same
# image (Dockerfile.seaweedfs). Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.seaweedfs.yml build
# docker compose -f docker-compose.prod.seaweedfs.yml up -d
# The build needs the repo checkout; to deploy elsewhere, `push` after building
# and `pull` on the deploy host.
# --- Registry image ----------------------------------------------------------
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
# --- Image -------------------------------------------------------------------
# The tag the build produces and `push`/`pull` use. Its own name, not
# drivervault-aio: this image carries SeaweedFS and expects two volumes.
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio-seaweedfs:latest
# Build args, both pinned in the Dockerfile. Set to override at build time.
# PB_VERSION=0.39.11
# SEAWEED_VERSION=4.45
# --- PocketBase superuser (required) -----------------------------------------
# Created/updated on first boot. The API Server uses these to manage the database.
@@ -57,9 +64,9 @@ API_PORT=8080
# --- 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 — the PocketBase database and its backups. The API Server keeps no
# state on disk, so everything it owns (plugin settings included) is backed up
# by backing up this one path — together with SEAWEED_DATA below.
PB_DATA=pb_data
# --- File storage: SeaweedFS -------------------------------------------------
@@ -69,23 +76,23 @@ PB_DATA=pb_data
#
# 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.
# defaults, and the container refuses to start without them.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
# The bucket. Created on first boot by PocketBase's program, before it serves.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# The endpoint, region and path style are fixed by the image — the gateway is
# inside the container at a loopback address that cannot change.
# 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.
# It is mounted at /seaweed/data, the layout `weed server -dir` writes — the
# same one the .split. files use, so the two are interchangeable on it.
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
# The S3 port is published on loopback only — PocketBase reaches the gateway
# inside the container, 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
+32 -25
View File
@@ -1,10 +1,18 @@
# DriverVault all-in-one — production config, SeaweedFS split into its four roles.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.seaweedfs.split.yml pull
# DriverVault all-in-one — production config, SeaweedFS split into its roles and
# built into the same image (Dockerfile.seaweedfs.split). Copy to .env and fill
# in, then:
# docker compose -f docker-compose.prod.seaweedfs.split.yml build
# docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
# The build needs the repo checkout; to deploy elsewhere, `push` after building
# and `pull` on the deploy host.
# --- Registry image ----------------------------------------------------------
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
# --- Image -------------------------------------------------------------------
# The tag the build produces and `push`/`pull` use. Its own name, not
# drivervault-aio: this image carries SeaweedFS and expects three volumes.
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio-seaweedfs-split:latest
# Build args, both pinned in the Dockerfile. Set to override at build time.
# PB_VERSION=0.39.11
# SEAWEED_VERSION=4.45
# --- PocketBase superuser (required) -----------------------------------------
# Created/updated on first boot. The API Server uses these to manage the database.
@@ -67,41 +75,39 @@ PB_DATA=pb_data
# 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: seaweedfs-init writes them into the filer's
# IAM store as the identity named "drivervault" *and* they are what PocketBase
# authenticates with. There are no safe defaults, and the stack refuses to start
# without them. Change them here and restart to rotate: the seed updates the
# identity in place rather than adding a second one.
# The credentials do double duty: the S3 gateway's program writes them into the
# filer's IAM store as the identity named "drivervault" *and* they are what
# PocketBase authenticates with. There are no safe defaults, and the container
# refuses to start without them. Change them here and restart to rotate: the
# seed updates the identity in place rather than adding a second one.
PB_S3_ACCESS_KEY=
PB_S3_SECRET=
# The bucket. Created on first boot by the seaweedfs-init container.
# The bucket. Created on first boot by the S3 gateway's program.
PB_S3_BUCKET=drivervault
# SeaweedFS ignores the region; PocketBase insists on having one.
PB_S3_REGION=us-east-1
# The endpoint, region and path style are fixed by the image — the gateway is
# inside the container at a loopback address that cannot change.
# 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.
#
# The master, volume and filer containers all mount it at /data, which is the
# layout `weed server -dir=/data` writes — so this file and
# Master, volume and filer share it (mounted at /seaweed/data), which is the
# layout `weed server -dir` writes — so this file and
# docker-compose.prod.seaweedfs.yml are interchangeable on the same volume, with
# nothing to migrate either way.
SEAWEED_DATA=seaweed_data
# The SeaweedFS image, pinned so a redeploy months from now brings up the same
# one. All five SeaweedFS containers run it.
# 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
# The S3 port is published on loopback only — PocketBase reaches the gateway
# inside the container, 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
#
# The master, volume and filer publish no host port at all. The admin UI below
# shows what they would: the volume server in particular serves file content by
# id with no authentication, so it stays on the compose network. Reach the
# others with `docker compose exec`.
# The master, volume and filer listen on the container's loopback and publish
# no host port at all. The admin UI below shows what they would: the volume
# server in particular serves file content by id with no authentication. Reach
# them with `docker compose exec drivervault wget -qO- http://127.0.0.1:9333/...`
# (volume 8081, filer 8888).
# --- SeaweedFS admin UI ------------------------------------------------------
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
@@ -110,7 +116,8 @@ SEAWEED_DATA=seaweed_data
# without a restart.
#
# REQUIRED: weed disables authentication entirely when the password is empty,
# and this panel can mint credentials for the bucket.
# and this panel can mint credentials for the bucket — so the image refuses to
# start the panel at all without one, and the container stays unhealthy.
SEAWEED_ADMIN_USER=admin
SEAWEED_ADMIN_PASSWORD=
# Optional view-only login.
+361
View File
@@ -0,0 +1,361 @@
# syntax=docker/dockerfile:1
#
# All-in-one image WITH the object store inside: PocketBase + API Server + Web
# App, plus SeaweedFS as one `weed server -s3` process — master, volume, filer
# and S3 gateway as goroutines — under supervisord in the same container.
#
# This is Dockerfile with the two SeaweedFS containers of
# docker-compose.prod.seaweedfs.yml folded in. That compose file kept them
# outside the image so SeaweedFS could be upgraded without a rebuild; this file
# trades that away for a single image and a single container. What it costs: a
# new SeaweedFS means a rebuild (--build-arg SEAWEED_VERSION). For per-role
# restart, metrics and the admin UI, see Dockerfile.seaweedfs.split.
#
# The build context MUST be the project root so this file can reach both
# "API Server/" and "Web App/". The root .dockerignore is an allow-list of the
# paths copied below — add to it if you add a COPY here. Build it with:
#
# docker build -f "Docker-AIO/Dockerfile.seaweedfs" -t drivervault-aio-seaweedfs .
#
# Run it (everything starts together):
#
# docker run -d --name drivervault -p 80:80 -p 8070:8070 -p 8080:8080 \
# -e PB_ADMIN_EMAIL=admin@example.com \
# -e PB_ADMIN_PASSWORD=change-me \
# -e PB_S3_ACCESS_KEY=drivervault -e PB_S3_SECRET=change-me \
# -v drivervault_pb:/pb/pb_data \
# -v drivervault_seaweed:/seaweed/data \
# drivervault-aio-seaweedfs
#
# Then: web app on http://host/ and PocketBase admin on http://host:8070/_/.
# On first boot PocketBase's program creates the bucket, and the API Server
# creates the collections, the super-admin, and points PocketBase's file
# storage at the bucket.
#
# Port map inside the container (only 80, 8070, 8080 and 8333 are meant to be
# published):
#
# 80 nginx (Web App, /api/ and /ocpp/ proxied)
# 8070 PocketBase
# 8080 API Server ← which is why the volume server is NOT on its
# 8081 SeaweedFS volume usual 8080 here
# 8333 SeaweedFS S3 gateway (PocketBase's endpoint; publish on loopback)
# 8888 SeaweedFS filer
# 9333 SeaweedFS master
# SeaweedFS release to bake in, pinned like PB_VERSION so a rebuild months from
# now brings up the same one. Same tag the compose SeaweedFS files run as
# SEAWEED_IMAGE. Override with --build-arg SEAWEED_VERSION=... to upgrade.
ARG SEAWEED_VERSION="4.45"
# --- Stage 1: build the Go API Server ---------------------------------------
FROM golang:1.26-alpine3.24 AS api-build
WORKDIR /src
COPY ["API Server/go.mod", "./"]
COPY ["API Server/go.su[m]", "./"]
RUN go mod download
# Only cmd/ + internal are needed; the panel is already built into
# internal/api/dist and embedded via //go:embed. Entry point is cmd/server.
COPY ["API Server/cmd", "./cmd"]
COPY ["API Server/internal", "./internal"]
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/server
# --- Stage 2: build the Vue Web App -----------------------------------------
# The Vue source lives under "Web App/web/".
FROM node:22-alpine3.24 AS web-build
WORKDIR /app
COPY ["Web App/web/package.json", "Web App/web/package-lock.json", "./"]
RUN npm ci
COPY ["Web App/web/index.html", "Web App/web/vite.config.js", "./"]
COPY ["Web App/web/src", "./src"]
COPY ["Web App/web/public", "./public"]
# Empty -> bundle uses same-origin "/api", proxied to the API Server by nginx.
ARG VITE_API_BASE
# vite.config writes to ../server/dist by default; emit into ./dist here.
RUN npm run build -- --outDir dist --emptyOutDir
# --- Stage 3: SeaweedFS -----------------------------------------------------
# The one static binary from the official image. SEAWEED_VERSION is declared at
# the top of the file: an ARG used in a FROM has to be global, and one declared
# here would belong to the stage above and expand to nothing.
FROM chrislusf/seaweedfs:${SEAWEED_VERSION} AS seaweed
# --- Stage 4: runtime (all services) ----------------------------------------
FROM alpine:3.24
# Pinned so a rebuild months from now produces the same PocketBase. Override to
# upgrade (--build-arg PB_VERSION=0.40.0); set it to empty to resolve the latest
# release at build time, which needs an unauthenticated GitHub API call and is
# therefore subject to that GitHub rate limit (60/hour per IP).
ARG PB_VERSION="0.39.11"
# Provided automatically by BuildKit (amd64 / arm64).
ARG TARGETARCH="amd64"
RUN apk add --no-cache ca-certificates tzdata unzip wget nginx supervisor \
&& mkdir -p /run/nginx
# Unprivileged account for PocketBase, the API Server and SeaweedFS. Only nginx
# stays root, because it binds port 80; supervisord drops to this user for
# everything else.
RUN addgroup -S app && adduser -S -G app app
# PocketBase from the official release (pinned via PB_VERSION, else latest).
WORKDIR /pb
RUN set -eux; \
ver="${PB_VERSION}"; \
if [ -z "$ver" ]; then \
ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \
| grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \
fi; \
echo "Installing PocketBase v${ver} (${TARGETARCH})"; \
wget -q -O /tmp/pb.zip \
"https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \
unzip /tmp/pb.zip -d /pb; \
rm /tmp/pb.zip
# API Server binary, built Web App static assets, and the weed binary.
COPY --from=api-build /out/api-server /usr/local/bin/api-server
COPY --from=web-build /app/dist /usr/share/nginx/html
COPY --from=seaweed /usr/bin/weed /usr/local/bin/weed
# WebSocket handshakes need Connection/Upgrade forwarded, and the map deriving
# them must sit in the http context, not inside a server block. It goes in
# http.d/ (which Alpine nginx includes from http{}; it does not read conf.d/),
# and the 00- prefix keeps it ahead of default.conf in the include order.
RUN cat > /etc/nginx/http.d/00-upgrade.conf <<'NGINXMAP'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
NGINXMAP
# nginx: serve the SPA and proxy /api/ + /ocpp/ to the API Server on localhost.
RUN cat > /etc/nginx/http.d/default.conf <<'NGINX'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# A real liveness route, so the SPA fallback below cannot answer a health
# probe with index.html and make a broken container look healthy.
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok";
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Forward WebSocket upgrades instead of silently stripping them.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# The chargers' door, not the browser's. The API Server tells a charger to
# dial the host it was itself asked on — this one — so without this location
# the SPA fallback would answer the WebSocket handshake with index.html.
location /ocpp/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# A charging session is idle between heartbeats; the default 60s would
# close it under the charger.
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
NGINX
# supervisord runs the four processes and keeps them alive. Priorities only
# order the launches; readiness is the `until wget` loop in front of each
# program that needs another one up, the same chain the compose file spells out
# with depends_on + healthchecks: SeaweedFS → bucket → PocketBase → API Server.
RUN cat > /etc/supervisord.conf <<'SUPERVISOR'
[supervisord]
nodaemon=true
user=root
pidfile=/run/supervisord.pid
logfile=/dev/null
logfile_maxbytes=0
; SeaweedFS: one process, four roles — master, volume, filer and the S3
; gateway. -dir is the only state it keeps. -ip=127.0.0.1 is the address the
; roles advertise to each other, which is where they are; -ip.bind stays open
; so the gateway can be published (weed server has one bind flag for all four,
; and the volume server, on 8081 because 8080 is the API Server, serves file
; content by id with no authentication — so never publish 8081).
;
; SeaweedFS falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY when started
; without an -s3.config file, and configuring one identity is what takes the
; gateway out of its default allow-anyone mode. They are the PB_S3_* pair
; PocketBase authenticates with, mapped here so there is one pair to set — and
; an empty pair means no gateway rather than an open one.
[program:seaweedfs]
user=app
command=/bin/sh -c 'if [ -z "$PB_S3_ACCESS_KEY" ] || [ -z "$PB_S3_SECRET" ]; then echo "seaweedfs: PB_S3_ACCESS_KEY and PB_S3_SECRET are required; refusing to start an unauthenticated gateway" >&2; exit 1; fi; export AWS_ACCESS_KEY_ID="$PB_S3_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$PB_S3_SECRET"; exec /usr/local/bin/weed server -dir=/seaweed/data -ip=127.0.0.1 -ip.bind=0.0.0.0 -volume.port=8081 -s3 -master.volumeSizeLimitMB=1024'
priority=1
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; PocketBase: wait for the S3 gateway — it is the process that reads and writes
; the objects, so the bucket has to be serving before it does, exactly as the
; compose file gates the whole container on the gateway's health. Then create
; the bucket: PocketBase never issues a CreateBucket of its own and SeaweedFS
; will not conjure one on first upload. Creating a bucket that already exists is
; a no-op, and `|| true` keeps a restart from being blocked by the shell's exit
; status — a gateway that is genuinely broken is reported by the API Server's
; own S3 check at boot, with the reason. Then upsert the superuser (idempotent)
; and serve. Runs as the unprivileged app user, which owns /pb and the pb_data
; volume.
[program:pocketbase]
directory=/pb
user=app
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs..."; sleep 1; done; echo "s3.bucket.create -name $PB_S3_BUCKET" | /usr/local/bin/weed shell -master=127.0.0.1:9333 || true; /pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070'
priority=10
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; API Server: wait for PocketBase to be healthy, then start. Its bootstrap
; points PocketBase's file storage at the bucket and asks it to prove the
; gateway is reachable. It keeps no state on disk — plugin settings, like
; everything else it owns, live in PocketBase — so its working directory is
; just a place to run from.
[program:api-server]
directory=/app
user=app
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
priority=20
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; nginx stays root so it can bind :80; its own workers drop to the nginx user.
[program:nginx]
command=/usr/sbin/nginx -g 'daemon off;'
priority=30
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
SUPERVISOR
# The entrypoint stays root only long enough to make the data volumes writable
# by the app user, then hands off to supervisord. The chown matters for a host
# bind mount, which arrives owned by root rather than inheriting the image's
# owner.
RUN cat > /entrypoint.sh <<'ENTRY'
#!/bin/sh
set -e
for dir in /pb/pb_data /seaweed/data; do
mkdir -p "$dir"
if [ "$(stat -c %U "$dir" 2>/dev/null)" != "app" ]; then
echo "entrypoint: taking ownership of $dir"
chown -R app:app "$dir"
fi
done
exec supervisord -c /etc/supervisord.conf
ENTRY
RUN chmod +x /entrypoint.sh
# API Server config: everything is local to this container. WEBAPP_URL is what
# the panel status page probes; nginx serves the Web App on :80 in here, so the
# stock default of localhost:8090 would always report the Web App as down.
#
# File storage is on by construction: the gateway is in this image, at a fixed
# loopback address, path style because a self-hosted gateway has no per-bucket
# DNS. The region is a formality SeaweedFS ignores and PocketBase insists on.
# supervisord passes these through to the API Server, whose bootstrap writes
# them into PocketBase's settings on every boot. Only record files move — scans,
# receipts, invoices, part photos; the database and PocketBase's own backups
# stay on /pb/pb_data.
ENV API_ADDR=:8080 \
POCKETBASE_URL=http://127.0.0.1:8070 \
CORS_ALLOW_ORIGINS=http://localhost:8090 \
AUTH_USERS_COLLECTION=users \
WEBAPP_URL=http://127.0.0.1:80 \
PB_S3_ENABLED=true \
PB_S3_ENDPOINT=http://127.0.0.1:8333 \
PB_S3_BUCKET=drivervault \
PB_S3_REGION=us-east-1 \
PB_S3_FORCE_PATH_STYLE=true
# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD;
# PB_S3_ACCESS_KEY, PB_S3_SECRET (the gateway's single identity AND what
# PocketBase authenticates with — the gateway refuses to start without them).
# Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create
# the first app super-admin on boot. PB_BOOTSTRAP=false skips schema setup —
# leave it on: a release can add collections or fields the server needs, and a
# stack that skips the bootstrap never gets them. (app_settings, which holds the
# plugin settings, is created on demand; nothing else is.)
# For Anker Solix charger control, OCPP_REQUIRE_TLS (default true) rejects
# chargers that did not arrive over TLS — this image serves plain HTTP, so put a
# TLS-terminating proxy in front and set OCPP_PUBLIC_URL to the public wss://
# base, or set OCPP_REQUIRE_TLS=false on a trusted network.
# Pass them with `docker run -e ...`.
# Two volumes. /pb/pb_data: the database, the uploads made before S3 was on,
# PocketBase's own backups, and the server settings — the API Server keeps no
# state on disk. /seaweed/data: everything SeaweedFS keeps, in the layout
# `weed server -dir` writes — the same one the split image's master, volume and
# filer share, so a SEAWEED_DATA volume from any of the SeaweedFS compose files
# mounts here unchanged, and vice versa. Both pre-created and owned by app so a
# fresh named volume inherits that ownership.
RUN mkdir -p /pb/pb_data /seaweed/data /app \
&& chown -R app:app /pb /seaweed /app
VOLUME ["/pb/pb_data", "/seaweed/data"]
# 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel
# (also the /ocpp/{serial} endpoint chargers dial into), 8333 = S3 gateway (for
# aws-cli and the like; loopback is enough).
EXPOSE 80 8070 8080 8333
# Every process must answer, so a wedged component shows up in `docker ps`
# instead of a container that looks up while part of it is dead. start-period
# covers SeaweedFS coming up plus the first-boot schema bootstrap on a cold
# database.
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
CMD wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:80/healthz >/dev/null 2>&1 \
|| exit 1
ENTRYPOINT ["/entrypoint.sh"]
+462
View File
@@ -0,0 +1,462 @@
# syntax=docker/dockerfile:1
#
# All-in-one image WITH the object store inside: PocketBase + API Server + Web
# App, plus SeaweedFS split into its roles — master, volume, filer, S3 gateway
# and the admin UI — each its own supervisord program in the same container.
#
# This is Dockerfile with the six SeaweedFS containers of
# docker-compose.prod.seaweedfs.split.yml folded in. The compose split kept
# them outside the image so SeaweedFS could be upgraded without a rebuild; this
# file trades that away for a single image and a single container. What the
# split still buys in here: per-role restart under supervisord, per-role
# metrics ports, and the admin UI, where S3 identities are minted and revoked.
# What it costs: a new SeaweedFS means a rebuild (--build-arg SEAWEED_VERSION).
#
# The build context MUST be the project root so this file can reach both
# "API Server/" and "Web App/". The root .dockerignore is an allow-list of the
# paths copied below — add to it if you add a COPY here. Build it with:
#
# docker build -f "Docker-AIO/Dockerfile.seaweedfs.split" -t drivervault-aio-seaweedfs-split .
#
# Run it (everything starts together):
#
# docker run -d --name drivervault -p 80:80 -p 8070:8070 -p 8080:8080 \
# -p 127.0.0.1:23646:23646 \
# -e PB_ADMIN_EMAIL=admin@example.com \
# -e PB_ADMIN_PASSWORD=change-me \
# -e PB_S3_ACCESS_KEY=drivervault -e PB_S3_SECRET=change-me \
# -e WEED_ADMIN_PASSWORD=change-me \
# -v drivervault_pb:/pb/pb_data \
# -v drivervault_seaweed:/seaweed/data \
# -v drivervault_seaweed_admin:/seaweed/admin \
# drivervault-aio-seaweedfs-split
#
# Then: web app on http://host/, PocketBase admin on http://host:8070/_/, and
# the SeaweedFS admin UI on http://127.0.0.1:23646/. On first boot the S3
# gateway's program creates the bucket and seeds PocketBase's identity, and the
# API Server creates the collections, the super-admin, and points PocketBase's
# file storage at the bucket.
#
# Port map inside the container (only 80, 8070, 8080, 8333 and 23646 are meant
# to be published; the rest are bound to loopback):
#
# 80 nginx (Web App, /api/ and /ocpp/ proxied)
# 8070 PocketBase
# 8080 API Server ← which is why the volume server is NOT on its
# 8081 SeaweedFS volume usual 8080 here
# 8333 SeaweedFS S3 gateway (PocketBase's endpoint; publish on loopback)
# 8888 SeaweedFS filer
# 9333 SeaweedFS master
# 23646 SeaweedFS admin UI (publish on loopback, behind a proxy if remote)
# SeaweedFS release to bake in, pinned like PB_VERSION so a rebuild months from
# now brings up the same one. Same tag the compose SeaweedFS files run as
# SEAWEED_IMAGE. Override with --build-arg SEAWEED_VERSION=... to upgrade.
ARG SEAWEED_VERSION="4.45"
# --- Stage 1: build the Go API Server ---------------------------------------
FROM golang:1.26-alpine3.24 AS api-build
WORKDIR /src
COPY ["API Server/go.mod", "./"]
COPY ["API Server/go.su[m]", "./"]
RUN go mod download
# Only cmd/ + internal are needed; the panel is already built into
# internal/api/dist and embedded via //go:embed. Entry point is cmd/server.
COPY ["API Server/cmd", "./cmd"]
COPY ["API Server/internal", "./internal"]
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/server
# --- Stage 2: build the Vue Web App -----------------------------------------
# The Vue source lives under "Web App/web/".
FROM node:22-alpine3.24 AS web-build
WORKDIR /app
COPY ["Web App/web/package.json", "Web App/web/package-lock.json", "./"]
RUN npm ci
COPY ["Web App/web/index.html", "Web App/web/vite.config.js", "./"]
COPY ["Web App/web/src", "./src"]
COPY ["Web App/web/public", "./public"]
# Empty -> bundle uses same-origin "/api", proxied to the API Server by nginx.
ARG VITE_API_BASE
# vite.config writes to ../server/dist by default; emit into ./dist here.
RUN npm run build -- --outDir dist --emptyOutDir
# --- Stage 3: SeaweedFS -----------------------------------------------------
# The one static binary from the official image. SEAWEED_VERSION is declared at
# the top of the file: an ARG used in a FROM has to be global, and one declared
# here would belong to the stage above and expand to nothing.
FROM chrislusf/seaweedfs:${SEAWEED_VERSION} AS seaweed
# --- Stage 4: runtime (all services) ----------------------------------------
FROM alpine:3.24
# Pinned so a rebuild months from now produces the same PocketBase. Override to
# upgrade (--build-arg PB_VERSION=0.40.0); set it to empty to resolve the latest
# release at build time, which needs an unauthenticated GitHub API call and is
# therefore subject to that GitHub rate limit (60/hour per IP).
ARG PB_VERSION="0.39.11"
# Provided automatically by BuildKit (amd64 / arm64).
ARG TARGETARCH="amd64"
RUN apk add --no-cache ca-certificates tzdata unzip wget nginx supervisor \
&& mkdir -p /run/nginx
# Unprivileged account for PocketBase, the API Server and every SeaweedFS role.
# Only nginx stays root, because it binds port 80; supervisord drops to this
# user for everything else.
RUN addgroup -S app && adduser -S -G app app
# PocketBase from the official release (pinned via PB_VERSION, else latest).
WORKDIR /pb
RUN set -eux; \
ver="${PB_VERSION}"; \
if [ -z "$ver" ]; then \
ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \
| grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \
fi; \
echo "Installing PocketBase v${ver} (${TARGETARCH})"; \
wget -q -O /tmp/pb.zip \
"https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \
unzip /tmp/pb.zip -d /pb; \
rm /tmp/pb.zip
# API Server binary, built Web App static assets, and the weed binary.
COPY --from=api-build /out/api-server /usr/local/bin/api-server
COPY --from=web-build /app/dist /usr/share/nginx/html
COPY --from=seaweed /usr/bin/weed /usr/local/bin/weed
# WebSocket handshakes need Connection/Upgrade forwarded, and the map deriving
# them must sit in the http context, not inside a server block. It goes in
# http.d/ (which Alpine nginx includes from http{}; it does not read conf.d/),
# and the 00- prefix keeps it ahead of default.conf in the include order.
RUN cat > /etc/nginx/http.d/00-upgrade.conf <<'NGINXMAP'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
NGINXMAP
# nginx: serve the SPA and proxy /api/ + /ocpp/ to the API Server on localhost.
RUN cat > /etc/nginx/http.d/default.conf <<'NGINX'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# A real liveness route, so the SPA fallback below cannot answer a health
# probe with index.html and make a broken container look healthy.
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok";
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Forward WebSocket upgrades instead of silently stripping them.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# The chargers' door, not the browser's. The API Server tells a charger to
# dial the host it was itself asked on — this one — so without this location
# the SPA fallback would answer the WebSocket handshake with index.html.
location /ocpp/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# A charging session is idle between heartbeats; the default 60s would
# close it under the charger.
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
NGINX
# Bucket and identity seed, run by the S3 gateway's program before it serves.
# Two jobs, both idempotent, so every boot re-applies the values from the
# environment and changes nothing else — which is also how a rotated
# PB_S3_SECRET reaches the gateway:
#
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
# 2. write PocketBase's S3 identity into the filer's IAM store.
#
# (2) is why the gateway below runs with neither an -config file nor
# AWS_ACCESS_KEY_ID: the env vars are the lowest-priority credential source in
# SeaweedFS, read only while the filer's store is empty, so the first identity
# added in the admin UI would silently displace them and lock PocketBase out.
# Seeding the store the admin UI itself writes leaves one source of truth, and
# PocketBase's key shows under Object Store → Users like any other.
#
# The closing grep is the gate: an empty IAM store means the gateway would come
# up in its allow-anyone default, so this fails loudly instead and the gateway
# never starts (supervisord retries it, and the healthcheck stays red).
RUN cat > /usr/local/bin/seaweedfs-seed <<'SEED'
#!/bin/sh
set -e
: "${PB_S3_ACCESS_KEY:?seaweedfs-seed: PB_S3_ACCESS_KEY is required}"
: "${PB_S3_SECRET:?seaweedfs-seed: PB_S3_SECRET is required}"
bucket="${PB_S3_BUCKET:-drivervault}"
shell() { weed shell -master=127.0.0.1:9333 -filer=127.0.0.1:8888; }
printf '%s\n' \
"s3.bucket.create -name $bucket" \
"s3.configure -user drivervault -access_key $PB_S3_ACCESS_KEY -secret_key $PB_S3_SECRET -actions Admin -apply" \
| shell
if ! echo "s3.configure" | shell | grep -q "$PB_S3_ACCESS_KEY"; then
echo "seaweedfs-seed: PocketBase's identity is not in the filer's IAM store; refusing to start the gateway" >&2
exit 1
fi
SEED
RUN chmod +x /usr/local/bin/seaweedfs-seed
# supervisord runs the eight processes and keeps them alive. Priorities only
# order the launches; readiness is the `until wget` loop in front of each
# program that needs another one up, the same chain the compose split spells
# out with depends_on + healthchecks: master → volume → filer → seed + S3 →
# PocketBase → API Server.
#
# Every SeaweedFS role advertises and binds 127.0.0.1 (-ip / -ip.bind): they only
# ever talk to each other in here, and the volume server in particular serves
# file content by id with no authentication at all. The S3 gateway and the admin
# UI bind everywhere so they can be published — on loopback, by the compose
# file's default.
RUN cat > /etc/supervisord.conf <<'SUPERVISOR'
[supervisord]
nodaemon=true
user=root
pidfile=/run/supervisord.pid
logfile=/dev/null
logfile_maxbytes=0
; SeaweedFS master: volume/topology metadata and file ids.
[program:seaweedfs-master]
user=app
command=/usr/local/bin/weed master -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=9333 -mdir=/seaweed/data -volumeSizeLimitMB=1024 -metricsPort=9324
priority=1
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; SeaweedFS volume server: where the bytes land. On 8081 because 8080 is the
; API Server in this container. -max=0 sizes itself from free disk rather than
; the default cap of 8 volumes.
[program:seaweedfs-volume]
user=app
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1; do echo "waiting for seaweedfs master..."; sleep 1; done; exec /usr/local/bin/weed volume -master=127.0.0.1:9333 -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=8081 -dir=/seaweed/data -max=0 -metricsPort=9325'
priority=2
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; SeaweedFS filer: the directory tree over the flat volume store — buckets,
; object keys — and the IAM store the S3 identities live in. -defaultStoreDir
; keeps its embedded leveldb on the data volume so they survive a recreate.
[program:seaweedfs-filer]
user=app
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8081/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs volume..."; sleep 1; done; exec /usr/local/bin/weed filer -master=127.0.0.1:9333 -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=8888 -defaultStoreDir=/seaweed/data -metricsPort=9326'
priority=3
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; SeaweedFS S3 gateway: PocketBase's endpoint. Seeds the bucket and identity
; first (see /usr/local/bin/seaweedfs-seed) and never serves if that fails. No
; -config file: with only -filer given, credentials come from the filer's IAM
; store, which is what lets the admin UI add and revoke identities without a
; restart.
[program:seaweedfs-s3]
user=app
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8888/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs filer..."; sleep 1; done; /usr/local/bin/seaweedfs-seed && exec /usr/local/bin/weed s3 -filer=127.0.0.1:8888 -ip.bind=0.0.0.0 -port=8333 -metricsPort=9327'
priority=4
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; SeaweedFS admin UI: cluster view, buckets, maintenance, and Object Store →
; Users. It can mint credentials for the bucket, and weed leaves auth off
; entirely when WEED_ADMIN_PASSWORD is empty — so an empty password means no
; panel at all rather than an open one. -dataDir persists the session key and
; the maintenance-task settings.
[program:seaweedfs-admin]
user=app
command=/bin/sh -c 'if [ -z "$WEED_ADMIN_PASSWORD" ]; then echo "seaweedfs-admin: WEED_ADMIN_PASSWORD is empty; refusing to serve an unauthenticated panel" >&2; exit 1; fi; until wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1; do echo "waiting for seaweedfs master..."; sleep 1; done; exec /usr/local/bin/weed admin -port=23646 -master=127.0.0.1:9333 -dataDir=/seaweed/admin -metricsPort=9328'
priority=5
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; PocketBase: wait for the S3 gateway — it is the process that reads and writes
; the objects, so the bucket has to be serving before it does, exactly as the
; compose split gates the whole container on the gateway's health. Then upsert
; the superuser (idempotent) and serve. Runs as the unprivileged app user, which
; owns /pb and the pb_data volume.
[program:pocketbase]
directory=/pb
user=app
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs s3..."; sleep 1; done; /pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070'
priority=10
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; API Server: wait for PocketBase to be healthy, then start. Its bootstrap
; points PocketBase's file storage at the bucket and asks it to prove the
; gateway is reachable. It keeps no state on disk — plugin settings, like
; everything else it owns, live in PocketBase — so its working directory is
; just a place to run from.
[program:api-server]
directory=/app
user=app
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
priority=20
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; nginx stays root so it can bind :80; its own workers drop to the nginx user.
[program:nginx]
command=/usr/sbin/nginx -g 'daemon off;'
priority=30
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
SUPERVISOR
# The entrypoint stays root only long enough to make the data volumes writable
# by the app user, then hands off to supervisord. The chown matters for a host
# bind mount, which arrives owned by root rather than inheriting the image's
# owner.
RUN cat > /entrypoint.sh <<'ENTRY'
#!/bin/sh
set -e
for dir in /pb/pb_data /seaweed/data /seaweed/admin; do
mkdir -p "$dir"
if [ "$(stat -c %U "$dir" 2>/dev/null)" != "app" ]; then
echo "entrypoint: taking ownership of $dir"
chown -R app:app "$dir"
fi
done
exec supervisord -c /etc/supervisord.conf
ENTRY
RUN chmod +x /entrypoint.sh
# API Server config: everything is local to this container. WEBAPP_URL is what
# the panel status page probes; nginx serves the Web App on :80 in here, so the
# stock default of localhost:8090 would always report the Web App as down.
#
# File storage is on by construction: the gateway is in this image, at a fixed
# loopback address, path style because a self-hosted gateway has no per-bucket
# DNS. The region is a formality SeaweedFS ignores and PocketBase insists on.
# supervisord passes these through to the API Server, whose bootstrap writes
# them into PocketBase's settings on every boot. Only record files move — scans,
# receipts, invoices, part photos; the database and PocketBase's own backups
# stay on /pb/pb_data.
ENV API_ADDR=:8080 \
POCKETBASE_URL=http://127.0.0.1:8070 \
CORS_ALLOW_ORIGINS=http://localhost:8090 \
AUTH_USERS_COLLECTION=users \
WEBAPP_URL=http://127.0.0.1:80 \
PB_S3_ENABLED=true \
PB_S3_ENDPOINT=http://127.0.0.1:8333 \
PB_S3_BUCKET=drivervault \
PB_S3_REGION=us-east-1 \
PB_S3_FORCE_PATH_STYLE=true \
WEED_ADMIN_USER=admin
# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD;
# PB_S3_ACCESS_KEY, PB_S3_SECRET (the identity seeded into SeaweedFS AND what
# PocketBase authenticates with — the gateway refuses to start without them);
# WEED_ADMIN_PASSWORD (the admin UI refuses to start without it).
# Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create
# the first app super-admin on boot; WEED_ADMIN_READONLY_USER / _PASSWORD add a
# view-only login to the admin UI. PB_BOOTSTRAP=false skips schema setup —
# leave it on: a release can add collections or fields the server needs, and a
# stack that skips the bootstrap never gets them. (app_settings, which holds the
# plugin settings, is created on demand; nothing else is.)
# For Anker Solix charger control, OCPP_REQUIRE_TLS (default true) rejects
# chargers that did not arrive over TLS — this image serves plain HTTP, so put a
# TLS-terminating proxy in front and set OCPP_PUBLIC_URL to the public wss://
# base, or set OCPP_REQUIRE_TLS=false on a trusted network.
# Pass them with `docker run -e ...`.
# Three volumes. /pb/pb_data: the database, the uploads made before S3 was on,
# PocketBase's own backups, and the server settings — the API Server keeps no
# state on disk. /seaweed/data: master, volume and filer share it, in exactly
# the layout `weed server -dir` writes (master raft state, volume .dat/.idx, the
# filer's filerldb2/ — no filename overlap), so a SEAWEED_DATA volume from
# either compose SeaweedFS file mounts here unchanged, and vice versa.
# /seaweed/admin: the admin UI's session key and maintenance-task state, small
# and no part of the object store. All pre-created and owned by app so a fresh
# named volume inherits that ownership.
RUN mkdir -p /pb/pb_data /seaweed/data /seaweed/admin /app \
&& chown -R app:app /pb /seaweed /app
VOLUME ["/pb/pb_data", "/seaweed/data", "/seaweed/admin"]
# 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel
# (also the /ocpp/{serial} endpoint chargers dial into), 8333 = S3 gateway (for
# aws-cli and the like; loopback is enough), 23646 = SeaweedFS admin UI.
EXPOSE 80 8070 8080 8333 23646
# Every process must answer, so a wedged component shows up in `docker ps`
# instead of a container that looks up while part of it is dead. start-period
# covers the SeaweedFS chain plus the first-boot schema bootstrap on a cold
# database.
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
CMD wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8081/healthz >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8888/healthz >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:23646/health >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 \
&& wget -qO- http://127.0.0.1:80/healthz >/dev/null 2>&1 \
|| exit 1
ENTRYPOINT ["/entrypoint.sh"]
+45 -24
View File
@@ -15,6 +15,8 @@ scale, upgrade or restart the pieces independently.
| File | Use |
|---|---|
| `Dockerfile` | the all-in-one image (build context must be the **repo root**) |
| `Dockerfile.seaweedfs` | the same, with SeaweedFS (`weed server -s3`) baked in — see below |
| `Dockerfile.seaweedfs.split` | the same, with SeaweedFS split into its roles baked in — see below |
| `docker-compose.yml` | **builds from source** — for development and local testing |
| `docker-compose.prod.yml` | **pulls the prebuilt image** from the registry |
| `.env.example` / `.env.prod.example` | copy to `.env` for the matching compose file |
@@ -104,8 +106,8 @@ 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` |
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` | `docker-compose.seaweedfs.split.yml` |
| **SeaweedFS beside the image** | `docker-compose.prod.seaweedfs.yml` (builds `Dockerfile.seaweedfs`) | `docker-compose.seaweedfs.yml` |
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` (builds `Dockerfile.seaweedfs.split`) | `docker-compose.seaweedfs.split.yml` |
| **An S3 endpoint elsewhere** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
So `docker-compose.prod.seaweedfs.yml` is configured from
@@ -119,24 +121,42 @@ 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.
start without them. The from-source SeaweedFS files run the gateway as a
**second container** (master, volume, filer and S3 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 prod SeaweedFS
files go the other way and **build it into the image**: `Dockerfile.seaweedfs`
runs `weed server -s3` as a fourth supervisord program (the `weed` binary is
copied from the official image, pinned by the `SEAWEED_VERSION` build arg), on
a `/seaweed/data` volume of its own, with PocketBase's program creating the
bucket before it serves. One container, two volumes; the cost is that a new
SeaweedFS is a rebuild rather than a tag change. Inside, the volume server sits
on **8081** since 8080 is the API Server, and only the gateway is published, on
loopback by default. The build needs the repo checkout, so either build on the
deploy host or `docker compose push` the image to `AIO_IMAGE` and `pull` it
there. The external-S3 ones add no containers at all: set `PB_S3_ENDPOINT`, and
create the bucket yourself.
### Split SeaweedFS
`weed server -s3` runs master, volume, filer and gateway as four goroutines in
one process. The `.split.` files run them as four containers beside the
all-in-one, plus a fifth: the SeaweedFS **admin UI** on port 23646, where the
cluster can be inspected and — under *Object Store → Users* — further S3
identities minted and revoked. Split also gets you per-role restarts and
one process. `docker-compose.seaweedfs.split.yml` runs them as four containers
beside the all-in-one, plus a fifth: the SeaweedFS **admin UI** on port 23646,
where the cluster can be inspected and — under *Object Store → Users* — further
S3 identities minted and revoked. Split also gets you per-role restarts and
upgrades, per-role Prometheus metrics, and room to add a second volume server
later. Still none of them inside the image, for the reason above.
later.
The prod twin, like its non-split sibling, builds it in:
`docker-compose.prod.seaweedfs.split.yml` **builds `Dockerfile.seaweedfs.split`**,
which bakes all five roles into the all-in-one image as five more supervisord
programs. One container, three volumes (`PB_DATA`, `SEAWEED_DATA`,
`SEAWEED_ADMIN_DATA`), and the bucket-and-identity seed runs inside the
gateway's program instead of a `seaweedfs-init` container. What survives of
"split" in there is per-role restart, per-role metrics ports and the admin UI;
what does not is spreading the roles over hosts. Inside, every role listens on
loopback, and only the gateway and the admin UI are published, on loopback by
default.
Identities work differently there, and it matters. SeaweedFS reads credentials
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
@@ -152,16 +172,17 @@ updates that identity in place.
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
authentication when it is empty, and a panel that can mint bucket credentials is
the bucket. In the prod file it is bound to loopback like `SEAWEED_S3_BIND` — it
is storage plumbing, not one of the app's own panels — so a remote host needs
`SEAWEED_ADMIN_BIND=0.0.0.0` behind a reverse proxy. That file publishes nothing
for master, volume and filer: the volume server serves file content by id with
no authentication of any kind, and the admin UI already shows what those ports
would.
the bucket (the baked-in image refuses to start the panel at all without one).
In the prod file it is bound to loopback like `SEAWEED_S3_BIND` — it is storage
plumbing, not one of the app's own panels — so a remote host needs
`SEAWEED_ADMIN_BIND=0.0.0.0` behind a reverse proxy. Neither file publishes
anything for master, volume and filer: the volume server serves file content by
id with no authentication of any kind, and the admin UI already shows what those
ports would.
Switching between `docker-compose.seaweedfs.yml` and its `.split.` twin needs no
migration: master, volume and filer share one `/data` mount, which is exactly
the layout `weed server -dir=/data` writes.
Switching between any of the SeaweedFS files needs no migration: master, volume
and filer share one data mount (`/data` in the side containers, `/seaweed/data`
in the baked-in image), which is exactly the layout `weed server -dir` writes.
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
+104 -264
View File
@@ -1,261 +1,73 @@
name: drivervault-aio
# Production all-in-one, with SeaweedFS split into its four roles — 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.
# Production all-in-one with SeaweedFS split into its roles — and, unlike the
# other prod files, built into ONE image here: Dockerfile.seaweedfs.split bakes
# master, volume, filer, S3 gateway and the admin UI into the same container as
# PocketBase, the API Server and the Web App. One service, one container, three
# volumes. Self-contained: one file, no overlays. Everything an operator needs
# to set lives in .env.
#
# 1. cp .env.prod.seaweedfs.split.example .env (then edit it)
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml pull
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml build
# 3. docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
#
# This is docker-compose.prod.seaweedfs.yml with the storage layer taken apart.
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
# process; here each is its own container, plus the SeaweedFS admin UI. What
# that buys:
# The build context is the repo root, so this has to run on a host that has the
# checkout. To deploy elsewhere, `push` the built image to the registry named in
# AIO_IMAGE and `pull` it there — `image:` and `build:` are both set, so the
# same file does either.
#
# the admin UI (weed admin) — a cluster view, and Object Store → Users,
# where S3 identities are created and revoked without touching a file;
# • per-role restart, upgrade and Prometheus metrics;
# • room to add a second volume server later, on this host or another.
# What the split buys inside one image: per-role restart under supervisord,
# per-role metrics ports, and the SeaweedFS admin UI (weed admin) — a cluster
# view, and Object Store → Users, where S3 identities are created and revoked
# without touching a file. What it costs against the six-container shape it
# replaces: a new SeaweedFS is a rebuild (SEAWEED_VERSION below), not a tag
# change, and the roles cannot be spread over hosts. If the object store should
# stay outside the app image, use docker-compose.prod.seaweedfs.yml — the S3
# behaviour is identical.
#
# What it costs: five containers beside the all-in-one instead of one, five
# healthchecks to keep the boot order honest, and one more port worth binding
# carefully. If none of the above is wanted, use
# docker-compose.prod.seaweedfs.yml — the S3 behaviour is identical.
# The on-disk layout is the same as every other SeaweedFS shape here: master,
# volume and filer share one data mount, exactly as `weed server -dir` lays it
# out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no filename
# overlap). So a SEAWEED_DATA volume from docker-compose.prod.seaweedfs.yml
# mounts here unchanged, and vice versa, with no migration either way.
#
# None of them run inside the all-in-one image, for the same reason the single
# gateway does not: keeping the object store in that image, on the volume the
# files are being moved off, would defeat the point and would mean rebuilding.
#
# The on-disk layout is deliberately the same as the single-process file's:
# master, volume and filer share one /data mount, exactly as `weed server -dir`
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
# filename overlap). So the two files are interchangeable on the same
# SEAWEED_DATA, with no migration either way. A *second* volume server would
# need its own.
#
# Only the S3 gateway and the admin UI publish a host port, both on loopback,
# the way the single-gateway file publishes the S3 port. Master, volume and
# filer are reachable over the compose network, through the admin UI, or with
# `docker compose exec` — the volume server in particular serves file content by
# id with no authentication at all, so it has no business on a public interface.
# Inside the container every SeaweedFS role listens on loopback except the S3
# gateway and the admin UI, and those two are published on loopback by default,
# the way the other SeaweedFS files publish them. The volume server serves file
# content by id with no authentication at all, so it is never reachable from
# outside; the admin UI shows what its port would.
#
# 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.
# On first boot the S3 gateway's program creates the bucket and seeds
# PocketBase's identity into the filer's IAM store, PocketBase upserts the
# superuser from PB_ADMIN_*, and the API Server creates any missing collections,
# the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*, and points
# PocketBase's file storage at the bucket. Every step is idempotent.
services:
# --- SeaweedFS: master -----------------------------------------------------
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
# the other roles are told to reach it by, so it must be the service name and
# not the container IP the process would otherwise detect.
seaweedfs-master:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-master
restart: unless-stopped
command: >
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
-volumeSizeLimitMB=1024 -metricsPort=9324
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"
# No published port: the master UI is one of the pages the admin UI serves.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: volume server ---------------------------------------------
# Where the bytes actually land. -max=0 lets it size itself from free disk
# rather than the default cap of 8 volumes.
seaweedfs-volume:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-volume
restart: unless-stopped
command: >
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
-port=8080 -dir=/data -max=0 -metricsPort=9325
depends_on:
seaweedfs-master:
condition: service_healthy
volumes:
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port, and this one is not an oversight: 8080 serves file
# content by file id with NO authentication — the S3 credentials do not
# apply to it. Publishing it would publish every attachment in the stack.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: filer ------------------------------------------------------
# Gives the flat volume store a directory tree — buckets, object keys — and
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
# embedded leveldb goes; without it that would be the container's working
# directory, and the identities would not survive a recreate.
seaweedfs-filer:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-filer
restart: unless-stopped
command: >
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
-port=8888 -defaultStoreDir=/data -metricsPort=9326
depends_on:
seaweedfs-volume:
condition: service_healthy
volumes:
- "${SEAWEED_DATA:-seaweed_data}:/data"
# No published port. The filer's gRPC side (8888 + 10000) carries the IAM
# service that mints S3 credentials; keep both ends of it on the compose
# network.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: bucket and identity seed ----------------------------------
# Runs once and exits, before the gateway starts. Two jobs:
#
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
# 2. write PocketBase's S3 identity into the filer's IAM store.
#
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
# docker-compose.prod.seaweedfs.yml does. Those env vars are the *lowest*
# priority credential source in SeaweedFS: they are read only while the filer's
# store is empty, so the first identity added in the admin UI would silently
# displace them and lock PocketBase out. Seeding the store the admin UI itself
# writes leaves one source of truth, and the key PocketBase uses appears under
# Object Store → Users like any other.
#
# Both commands update in place, so every later boot re-applies the values from
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
# reaches the gateway.
#
# The closing grep is the gate: an empty IAM store means the gateway would come
# up in its allow-anyone default, so this fails loudly instead and the gateway
# below never starts. No `|| true` here, deliberately.
seaweedfs-init:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-init
restart: "no"
depends_on:
seaweedfs-filer:
condition: service_healthy
environment:
# Passed as env and expanded by the shell inside the container, so the
# secret stays out of the container's argv.
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
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}"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
printf '%s\n' \
"s3.bucket.create -name $$PB_S3_BUCKET" \
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
echo "s3.configure" \
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
| grep -q "$$PB_S3_ACCESS_KEY"
# --- SeaweedFS: S3 gateway -------------------------------------------------
# The endpoint PocketBase talks to. No -config file: with only -filer given,
# credentials come from the filer's IAM store, which is what lets the admin UI
# add and revoke identities without a restart. A config file would take
# priority over that store and make the admin UI's users inert.
seaweedfs-s3:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-s3
restart: unless-stopped
command: >
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
-metricsPort=9327
depends_on:
seaweedfs-filer:
condition: service_healthy
# Never serve before an identity exists — see seaweedfs-init above.
seaweedfs-init:
condition: service_completed_successfully
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:8333/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
# --- SeaweedFS: admin UI ---------------------------------------------------
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
# Users, where S3 access keys are minted and revoked. It finds the filer
# through the master, so -master is all it needs.
#
# Bound to loopback by default — the same call SEAWEED_S3_BIND makes above,
# for the same reason: this is storage plumbing, not one of the app's own
# panels. On a remote host that means unreachable, so set
# SEAWEED_ADMIN_BIND=0.0.0.0 and put it behind a reverse proxy.
#
# An unauthenticated panel that can mint credentials for the bucket *is* the
# bucket, so the password is required rather than defaulted — weed leaves auth
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
# flag, which keeps it off the process command line. -dataDir persists the
# session key and the maintenance-task settings.
seaweedfs-admin:
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
container_name: drivervault-aio-seaweedfs-admin
restart: unless-stopped
command: >
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
-metricsPort=9328
depends_on:
seaweedfs-master:
condition: service_healthy
environment:
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
# Optional view-only login. weed ignores it unless the admin password
# above is set, which it is.
WEED_ADMIN_READONLY_USER: "${SEAWEED_ADMIN_READONLY_USER:-}"
WEED_ADMIN_READONLY_PASSWORD: "${SEAWEED_ADMIN_READONLY_PASSWORD:-}"
volumes:
# Its own small volume: session key and maintenance state, no object data.
- "${SEAWEED_ADMIN_DATA:-seaweed_admin}:/data"
ports:
- "${SEAWEED_ADMIN_BIND:-127.0.0.1}:${SEAWEED_ADMIN_PORT:-23646}:23646"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 15s
drivervault:
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
build:
# Project root (one level up from this compose file), so the Dockerfile
# can reach both "API Server/" and "Web App/".
context: ..
dockerfile: Docker-AIO/Dockerfile.seaweedfs.split
args:
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
- VITE_API_BASE=${VITE_API_BASE:-}
# Bare names = pass through only when set in the environment, so an
# unset PB_VERSION / SEAWEED_VERSION leaves the Dockerfile pin in place
# instead of overriding it with an empty string (which for PB_VERSION
# would resolve "latest" at build time, and for SEAWEED_VERSION would
# not build at all).
- PB_VERSION
- SEAWEED_VERSION
# Tagged for the registry so `docker compose push` lands it where `pull`
# on the deploy host expects it.
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio-seaweedfs-split: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-s3:
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}"
@@ -285,43 +97,71 @@ services:
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"
# The image fixes the rest (PB_S3_ENABLED, the loopback endpoint, path
# style, the region): the gateway is inside, at an address that cannot
# change. 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 credentials do double duty: the gateway's program seeds them into
# the filer's IAM store as the identity named "drivervault" (updating it
# in place on every boot — that is how a rotated secret lands) *and* they
# are what PocketBase authenticates with. No safe defaults; the gateway
# refuses to start without them, and with it the whole container.
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
# The gateway's service name: a server-to-server call inside the compose
# network.
PB_S3_ENDPOINT: "http://seaweedfs-s3: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"
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}"
# --- SeaweedFS admin UI --------------------------------------------
# An unauthenticated panel that can mint credentials for the bucket *is*
# the bucket, so the password is required rather than defaulted — weed
# leaves auth off entirely when it is empty, and the image refuses to
# start the panel at all in that case. Read from WEED_ADMIN_* rather
# than a flag, which keeps it off the process command line.
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
# Optional view-only login. weed ignores it unless the admin password
# above is set, which it is.
WEED_ADMIN_READONLY_USER: "${SEAWEED_ADMIN_READONLY_USER:-}"
WEED_ADMIN_READONLY_PASSWORD: "${SEAWEED_ADMIN_READONLY_PASSWORD:-}"
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}
# The S3 gateway, loopback only: PocketBase reaches it inside the
# container, 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"
# The admin UI, loopback for the same reason: storage plumbing, not one
# of the app's own panels. On a remote host that means unreachable, so
# set SEAWEED_ADMIN_BIND=0.0.0.0 and put it behind a reverse proxy.
- "${SEAWEED_ADMIN_BIND:-127.0.0.1}:${SEAWEED_ADMIN_PORT:-23646}:23646"
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.
# Named volumes by default; set any of them 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 PB_DATA. Back up PB_DATA and
# SEAWEED_DATA together — from here on the attachments live in the
# second, not the first.
- "${PB_DATA:-pb_data}:/pb/pb_data"
- "${SEAWEED_DATA:-seaweed_data}:/seaweed/data"
# The admin UI's own small volume: session key and maintenance state, no
# object data.
- "${SEAWEED_ADMIN_DATA:-seaweed_admin}:/seaweed/admin"
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"]
# Every process must answer — the five SeaweedFS roles and the three app
# processes. Declared here as well as in the image so the check is
# visible, and works against an older pulled image. start_period covers
# the SeaweedFS chain plus the first-boot schema bootstrap.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null && wget -qO- http://127.0.0.1:8081/healthz >/dev/null && wget -qO- http://127.0.0.1:8888/healthz >/dev/null && wget -qO- http://127.0.0.1:8333/healthz >/dev/null && wget -qO- http://127.0.0.1:23646/health >/dev/null && 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
timeout: 10s
retries: 3
start_period: 60s
start_period: 90s
volumes:
pb_data:
# Shared by master, volume and filer — the same layout `weed server -dir`
# Master, volume and filer share it — the same layout `weed server -dir`
# writes, so this file and docker-compose.prod.seaweedfs.yml can swap places
# on it.
seaweed_data:
+75 -90
View File
@@ -1,13 +1,21 @@
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.
# Production all-in-one with SeaweedFS — and, unlike docker-compose.prod.yml,
# built into ONE image here: Dockerfile.seaweedfs bakes `weed server -s3`
# (master, volume, filer and S3 gateway in one process) into the same container
# as PocketBase, the API Server and the Web App. One service, one container,
# two volumes. 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
# 2. docker compose -f docker-compose.prod.seaweedfs.yml build
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
#
# The build context is the repo root, so this has to run on a host that has the
# checkout. To deploy elsewhere, `push` the built image to the registry named in
# AIO_IMAGE and `pull` it there — `image:` and `build:` are both set, so the
# same file does either.
#
# 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
@@ -15,79 +23,42 @@ name: drivervault-aio
# 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.
# What baking it in costs against the two-container shape it replaces: a new
# SeaweedFS is a rebuild (SEAWEED_VERSION below), not a tag change. For
# per-role restart, metrics and the SeaweedFS admin UI, see
# docker-compose.prod.seaweedfs.split.yml — the S3 behaviour is identical, and
# the data volume is interchangeable (both write the `weed server -dir` layout).
#
# 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.
# On first boot PocketBase's program creates the bucket and upserts the
# superuser from PB_ADMIN_*, and the API Server creates any missing
# collections, the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*, and
# points PocketBase's file storage at the bucket. Every step is 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}"
build:
# Project root (one level up from this compose file), so the Dockerfile
# can reach both "API Server/" and "Web App/".
context: ..
dockerfile: Docker-AIO/Dockerfile.seaweedfs
args:
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
- VITE_API_BASE=${VITE_API_BASE:-}
# Bare names = pass through only when set in the environment, so an
# unset PB_VERSION / SEAWEED_VERSION leaves the Dockerfile pin in place
# instead of overriding it with an empty string (which for PB_VERSION
# would resolve "latest" at build time, and for SEAWEED_VERSION would
# not build at all).
- PB_VERSION
- SEAWEED_VERSION
# Tagged for the registry so `docker compose push` lands it where `pull`
# on the deploy host expects it.
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio-seaweedfs: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}"
@@ -117,39 +88,53 @@ services:
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"
# The image fixes the rest (PB_S3_ENABLED, the loopback endpoint, path
# style, the region): the gateway is inside, at an address that cannot
# change. 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 credentials do double duty: they configure the gateway's single
# identity — which is what takes it out of its default allow-anyone mode
# — *and* they are what PocketBase authenticates with. No safe defaults;
# the gateway refuses to start without them, and with it the whole
# container.
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"
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}"
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}
# The S3 gateway, loopback only: PocketBase reaches it inside the
# container, 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. Nothing else of SeaweedFS is published: the
# volume server (8081 inside) serves file content by id with no
# authentication at all.
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
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.
# Named volumes by default; set either 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 PB_DATA. Back up PB_DATA and
# SEAWEED_DATA together — from here on the attachments live in the
# second, not the first.
- "${PB_DATA:-pb_data}:/pb/pb_data"
- "${SEAWEED_DATA:-seaweed_data}:/seaweed/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"]
# Every process must answer — SeaweedFS's master and gateway and the three
# app processes. Declared here as well as in the image so the check is
# visible, and works against an older pulled image. start_period covers
# SeaweedFS coming up plus the first-boot schema bootstrap.
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null && wget -qO- http://127.0.0.1:8333/healthz >/dev/null && 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
timeout: 10s
retries: 3
start_period: 60s
start_period: 90s
volumes:
pb_data:
# The `weed server -dir` layout, so this file and its .split. twin can swap
# places on it.
seaweed_data:
+17 -9
View File
@@ -295,15 +295,23 @@ Running it on your own car:
- The service is declared under `androidx.car.app.category.IOT`. It is the
closest of the categories the library defines — DriverVault is a garage, not a
map, a media player or a parking service — and a Play Store submission would
be reviewed against it. This build is sideloaded, so what matters instead is
the next line.
- Android Auto refuses apps it did not get from the Play Store until you tell it
otherwise: in the **Android Auto** settings on the phone, tap the version ten
times to unlock **Developer settings**, then turn on **Unknown sources**.
- To try it without a car, run Google's
[Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu).
A debug build accepts any host so the DHU can connect; a release build only
accepts the signed hosts the library ships an allowlist for.
be reviewed against it. Getting it onto a car at all is the next line.
- **A sideloaded build never appears on a real head unit**, and no setting on the
phone changes that. Android Auto's **Unknown sources** developer option is the
one everybody reaches for here, but it covers media, messaging-notification and
parked apps only — [Google's testing
guide](https://developer.android.com/training/cars/testing) says in as many
words that it does not apply to apps built with the Car App Library. A car will
list DriverVault only if the APK was installed *from Play*: an internal testing
track, or internal app sharing, is enough — no public listing, no review.
- To try it without Play — which is how these screens were written — run Google's
[Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu),
which does take a sideloaded build. In the **Android Auto** settings on the
phone, tap the version ten times to unlock **Developer settings**, then
**Start head unit server**; on the machine, `adb forward tcp:5277 tcp:5277` and
run `desktop-head-unit`. Install the **debug** APK for this: a debug build
accepts any host so the DHU can connect, while a release build only accepts the
signed hosts the library ships an allowlist for.
## Configure the API endpoint
@@ -50,9 +50,13 @@
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!-- Level 2, not 1: GarageScreen asks the host for ConstraintManager to
size its list, and that service arrived at level 2. Every shipping
host is far above this; the floor just has to not claim less than
the screens actually use. -->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>
android:value="2"/>
<service
android:name=".car.DriverVaultCarAppService"
android:exported="true">
@@ -40,7 +40,12 @@ class CarStrings private constructor(
companion object {
private const val BASE = "en"
private val PLACEHOLDER = Regex("""\{(\w+)}""")
// Both braces escaped, as in i18n.dart's twin of this pattern.
// Android compiles regexes through ICU rather than java.util.regex,
// and ICU refuses a closing brace that opens no quantifier — a lone
// "}" here throws PatternSyntaxException the first time CarStrings
// is touched, which on a head unit is a screen that never draws.
private val PLACEHOLDER = Regex("""\{(\w+)\}""")
/** Loads the files for the account's language, falling back to English. */
fun of(context: Context): CarStrings {
+2
View File
@@ -661,6 +661,8 @@
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
"controlMode": "Styringstilstand",
"controlModeHint": "Hvordan DriverVault styrer laderen.",
"controlModesHidden": "Skjul for dine brugere",
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
"controlOff": "Fra (kun overvågning)",
"controlOwn": "Eget CSMS (fuld styring)",
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
+2
View File
@@ -531,6 +531,8 @@
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
"controlMode": "Control mode",
"controlModeHint": "How DriverVault controls the charger.",
"controlModesHidden": "Hide from your users",
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
"controlOff": "Off (monitoring only)",
"controlOwn": "Own CSMS (full control)",
"controlProxy": "Proxy CSMS (relay + control)",
+2
View File
@@ -669,6 +669,8 @@
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
"controlMode": "Tryb sterowania",
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
"controlModesHidden": "Ukryj przed użytkownikami",
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
"controlOff": "Wyłączone (tylko monitorowanie)",
"controlOwn": "Własny CSMS (pełne sterowanie)",
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
+17 -1
View File
@@ -998,7 +998,21 @@ class IntegrationScope {
final String editableLayer; // "user" | "org"
final Map<String, IntegrationField> fields;
const IntegrationScope({this.editableLayer = "user", this.fields = const {}});
/// Anker only. The control modes this scope may still choose — the server has
/// already dropped whatever the layers above it hid. Empty for an integration
/// that has no such list.
final List<String> controlModes;
/// Anker, org scope only. The modes this organization hides from its own
/// users, which its admin edits here.
final List<String> controlModesDisabled;
const IntegrationScope({
this.editableLayer = "user",
this.fields = const {},
this.controlModes = const [],
this.controlModesDisabled = const [],
});
factory IntegrationScope.fromJson(Map<String, dynamic> j) {
final raw = j["fields"];
@@ -1011,6 +1025,8 @@ class IntegrationScope {
return IntegrationScope(
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
fields: fields,
controlModes: _asStrList(j["controlModes"]),
controlModesDisabled: _asStrList(j["controlModesDisabled"]),
);
}
+134 -10
View File
@@ -1547,20 +1547,42 @@ class _DangerSectionState extends State<_DangerSection> {
// second "org" scope to edit organization-wide defaults; superadmins manage the
// shared layer in the API Server panel, so here it is read-only.
enum _FieldType { text, number, password, select }
enum _FieldType { text, number, password, select, multiselect }
/// The control modes the server still offers in a scope, and the ones this
/// organization hides from its own users. Named functions rather than closures
/// so the field specs below can stay const.
List<String> _scopeControlModes(IntegrationScope s) => s.controlModes;
List<String> _scopeHiddenControlModes(IntegrationScope s) => s.controlModesDisabled;
/// Describes one credential field within an integration card.
class _FieldSpec {
final String key;
final String labelKey;
final _FieldType type;
final List<(String, String)> options; // (value, labelKey) for selects
final List<(String, String)> options; // (value, labelKey) for select/multiselect
final String? placeholder; // literal placeholder
final String? hintKey; // shown below the field when not locked
final String defaultValue;
final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked
final int? maxLength;
/// Narrows the declared options to those the server still offers in this
/// scope — the control-mode hide-list, which is what makes a mode a superadmin
/// switched off disappear from an organization's picker, and one an
/// organization switched off disappear from its users'. Null leaves every
/// declared option standing.
final List<String> Function(IntegrationScope)? scopeOptions;
/// Where a multiselect reads its current value from — a scope list rather than
/// a field, because a hide-list is not a cascaded value: it is this layer's
/// own instruction to the layers below.
final List<String> Function(IntegrationScope)? scopeValue;
/// Only shown when editing the organization layer. A user has nobody below
/// them, so a hide-list would mean nothing there.
final bool orgOnly;
const _FieldSpec({
required this.key,
required this.labelKey,
@@ -1571,6 +1593,9 @@ class _FieldSpec {
this.defaultValue = "",
this.showEffectiveWhenLocked = false,
this.maxLength,
this.scopeOptions,
this.scopeValue,
this.orgOnly = false,
});
}
@@ -1656,6 +1681,7 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
hintKey: "settings.integrations.controlModeHint",
defaultValue: "off",
showEffectiveWhenLocked: true,
scopeOptions: _scopeControlModes,
options: [
("off", "settings.integrations.controlOff"),
("mqtt", "settings.integrations.controlCloud"),
@@ -1664,6 +1690,23 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
("proxy", "settings.integrations.controlProxy"),
],
),
// What this organization hides from its own users. Off is absent on
// purpose: monitoring only is the fallback, so no layer may take it away.
_FieldSpec(
key: "controlModesDisabled",
labelKey: "settings.integrations.controlModesHidden",
type: _FieldType.multiselect,
hintKey: "settings.integrations.controlModesHiddenHint",
orgOnly: true,
scopeOptions: _scopeControlModes,
scopeValue: _scopeHiddenControlModes,
options: [
("mqtt", "settings.integrations.controlCloud"),
("modbus", "settings.integrations.controlModbus"),
("own", "settings.integrations.controlOwn"),
("proxy", "settings.integrations.controlProxy"),
],
),
],
);
@@ -1799,6 +1842,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
final Map<String, TextEditingController> _controllers = {};
final Map<String, String> _selects = {};
final Map<String, Set<String>> _multi = {}; // multiselect fields (hide-lists)
_IntegrationConfig get _c => widget.config;
@@ -1806,7 +1850,9 @@ class _IntegrationCardState extends State<_IntegrationCard> {
void initState() {
super.initState();
for (final f in _c.fields) {
if (f.type != _FieldType.select) _controllers[f.key] = TextEditingController();
if (f.type != _FieldType.select && f.type != _FieldType.multiselect) {
_controllers[f.key] = TextEditingController();
}
}
_load();
}
@@ -1825,6 +1871,25 @@ class _IntegrationCardState extends State<_IntegrationCard> {
bool get _readOnly => _view?.isSuperadmin ?? false;
IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope();
IntegrationField _field(String k) => _scopeData.field(k);
/// The fields this scope actually shows: an org-only field (a hide-list) is
/// out of the user scope, and a field whose options the layers above have all
/// hidden has nothing left to offer.
List<_FieldSpec> get _shownFields => [
for (final f in _c.fields)
if ((!f.orgOnly || _editingOrg) && (f.options.isEmpty || _optionsFor(f).isNotEmpty)) f,
];
/// A field's options after the scope's own list has narrowed them.
List<(String, String)> _optionsFor(_FieldSpec f) {
if (f.scopeOptions == null) return f.options;
final allowed = f.scopeOptions!(_scopeData);
if (allowed.isEmpty) return f.options;
return [
for (final o in f.options)
if (allowed.contains(o.$1)) o,
];
}
bool _locked(String k) => _readOnly || _field(k).locked;
bool get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false);
@@ -1839,6 +1904,12 @@ class _IntegrationCardState extends State<_IntegrationCard> {
// fields and the secret password are never prefilled.
void _fillForm() {
for (final f in _c.fields) {
if (f.type == _FieldType.multiselect) {
// A hide-list is this layer's own, not something inherited, so it comes
// from the scope rather than from a cascaded field.
_multi[f.key] = {...?f.scopeValue?.call(_scopeData)};
continue;
}
final field = _field(f.key);
String value;
if (f.type == _FieldType.password) {
@@ -1851,7 +1922,11 @@ class _IntegrationCardState extends State<_IntegrationCard> {
value = field.own.isNotEmpty ? field.own : f.defaultValue;
}
if (f.type == _FieldType.select) {
_selects[f.key] = value;
// A stored mode the layers above have since hidden is no longer on
// offer, so the picker starts from the default instead of showing a
// choice that would not take effect anyway.
final options = _optionsFor(f);
_selects[f.key] = options.any((o) => o.$1 == value) ? value : f.defaultValue;
} else {
_controllers[f.key]!.text = value;
}
@@ -1896,9 +1971,19 @@ class _IntegrationCardState extends State<_IntegrationCard> {
_saved = false;
});
final config = <String, dynamic>{};
for (final f in _c.fields) {
for (final f in _shownFields) {
if (_locked(f.key)) continue;
final val = f.type == _FieldType.select ? (_selects[f.key] ?? "") : _controllers[f.key]!.text;
final String val;
switch (f.type) {
case _FieldType.multiselect:
// The server stores a hide-list as the same comma-separated string the
// API Server panel writes, so both layers round-trip identically.
val = _optionsFor(f).map((o) => o.$1).where(_multi[f.key]!.contains).join(",");
case _FieldType.select:
val = _selects[f.key] ?? "";
default:
val = _controllers[f.key]!.text;
}
if (f.type == _FieldType.password && val.isEmpty) continue;
config[f.key] = val;
}
@@ -2058,7 +2143,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
),
// Credential fields.
for (final f in _c.fields) ...[
for (final f in _shownFields) ...[
const SizedBox(height: 12),
_fieldWidget(f),
],
@@ -2146,12 +2231,51 @@ class _IntegrationCardState extends State<_IntegrationCard> {
final field = _field(f.key);
Widget input;
if (f.type == _FieldType.select) {
final options = _optionsFor(f);
if (f.type == _FieldType.multiselect) {
final chosen = _multi[f.key] ??= {};
input = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final o in options)
InkWell(
onTap: locked
? null
: () => setState(() {
if (!chosen.remove(o.$1)) chosen.add(o.$1);
}),
child: Row(children: [
Checkbox(
value: chosen.contains(o.$1),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: locked
? null
: (v) => setState(() {
if (v ?? false) {
chosen.add(o.$1);
} else {
chosen.remove(o.$1);
}
}),
),
const SizedBox(width: 4),
Expanded(child: Text(t(o.$2), style: const TextStyle(fontSize: 14))),
]),
),
],
);
} else if (f.type == _FieldType.select) {
// A mode hidden above is gone from the list, so a stored value naming one
// has nothing to select — fall back to the default rather than crashing
// the dropdown on a value it does not carry.
final current = _selects[f.key] ?? f.defaultValue;
final value = options.any((o) => o.$1 == current) ? current : f.defaultValue;
input = DropdownButtonFormField<String>(
initialValue: _selects[f.key] ?? f.defaultValue,
initialValue: value,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final o in f.options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
for (final o in options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
],
onChanged: locked ? null : (v) => setState(() => _selects[f.key] = v ?? ""),
);
+2
View File
@@ -626,6 +626,8 @@
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
"controlMode": "Styringstilstand",
"controlModeHint": "Hvordan DriverVault styrer laderen.",
"controlModesHidden": "Skjul for dine brugere",
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
"controlOff": "Fra (kun overvågning)",
"controlOwn": "Eget CSMS (fuld styring)",
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
+2
View File
@@ -625,6 +625,8 @@
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
"controlMode": "Control mode",
"controlModeHint": "How DriverVault controls the charger.",
"controlModesHidden": "Hide from your users",
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
"controlOff": "Off (monitoring only)",
"controlOwn": "Own CSMS (full control)",
"controlProxy": "Proxy CSMS (relay + control)",
+2
View File
@@ -632,6 +632,8 @@
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
"controlMode": "Tryb sterowania",
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
"controlModesHidden": "Ukryj przed użytkownikami",
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
"controlOff": "Wyłączone (tylko monitorowanie)",
"controlOwn": "Własny CSMS (pełne sterowanie)",
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
+15
View File
@@ -361,6 +361,21 @@ body {
box-shadow: var(--shadow-focus);
}
/* Checkbox for the small multi-choice lists (hidden control modes, ...).
accent-color keeps the native control and its keyboard behaviour. */
.dh-checkbox {
width: 1rem;
height: 1rem;
flex: none;
accent-color: var(--accent);
cursor: pointer;
}
.dh-checkbox:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: 3px;
}
.dh-label {
display: block;
margin-bottom: 0.375rem;
+75 -8
View File
@@ -519,7 +519,7 @@ function showsIntegration(id) {
const anker = ref(null); // resolved view from the server
const ankerScope = ref("user"); // "user" | "org" (org admins only)
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off" });
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off", controlModesDisabled: [] });
const ankerSaving = ref(false);
const ankerSaved = ref(false);
const ankerError = ref("");
@@ -548,17 +548,63 @@ function ankerSourceLabel(k) {
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
}
// Clamp a stored mode to what this scope still offers.
function ankerModeInScope(mode) {
const modes = ankerScopeData.value.controlModes;
if (!mode) return "off";
if (modes?.length && !modes.includes(mode)) return "off";
return mode;
}
function fillAnkerForm() {
const f = ankerScopeData.value.fields || {};
ankerForm.value = {
email: f.email?.locked ? "" : f.email?.own || "",
password: "",
country: f.country?.locked ? "" : f.country?.own || "",
// controlMode isn't secret, so show the effective value when it's locked.
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
// controlMode isn't secret, so show the effective value when it's locked. A
// mode the layers above have since hidden is no longer on offer, so the
// picker starts from monitoring-only rather than showing a dead choice.
controlMode: ankerModeInScope(
f.controlMode?.locked ? f.controlMode?.effective : f.controlMode?.own,
),
// Which modes this organization hides from its own users (org scope only).
controlModesDisabled: [...(ankerScopeData.value.controlModesDisabled || [])],
};
}
// The modes this scope may still pick: the server has already removed whatever
// the layers above hid. An empty list would leave the picker blank, so fall back
// to monitoring-only, which no layer can take away.
const ankerControlModeOptions = computed(() => {
const modes = ankerScopeData.value.controlModes;
return modes?.length ? modes : ["off"];
});
// The modes an org admin may hide from their users — everything they can choose
// themselves, minus off, which is the fallback.
const ankerHideableModes = computed(() =>
ankerControlModeOptions.value.filter((m) => m !== "off"),
);
const ankerModeLabels = {
off: "controlOff",
mqtt: "controlCloud",
modbus: "controlModbus",
own: "controlOwn",
proxy: "controlProxy",
};
function ankerModeLabel(m) {
return t("settings.integrations." + (ankerModeLabels[m] || "controlOff"));
}
function ankerHidesMode(m) {
return ankerForm.value.controlModesDisabled.includes(m);
}
function toggleAnkerHiddenMode(m, on) {
const set = new Set(ankerForm.value.controlModesDisabled);
if (on) set.add(m);
else set.delete(m);
ankerForm.value.controlModesDisabled = ankerHideableModes.value.filter((v) => set.has(v));
}
// The effective control mode (off | mqtt | modbus | own | proxy) — it gates the
// control panel.
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
@@ -717,6 +763,9 @@ async function saveAnkerSettings() {
if (k === "password" && !ankerForm.value.password) continue;
config[k] = ankerForm.value[k];
}
// The hide-list travels as the comma-separated string the server stores; only
// a layer with users under it has one.
if (ankerEditingOrg.value) config.controlModesDisabled = ankerForm.value.controlModesDisabled.join(",");
try {
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
ankerSaved.value = true;
@@ -1602,15 +1651,33 @@ onBeforeUnmount(() => {
<div>
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
<option value="off">{{ t("settings.integrations.controlOff") }}</option>
<option value="mqtt">{{ t("settings.integrations.controlCloud") }}</option>
<option value="modbus">{{ t("settings.integrations.controlModbus") }}</option>
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
<option v-for="m in ankerControlModeOptions" :key="m" :value="m">{{ ankerModeLabel(m) }}</option>
</select>
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
</div>
<!-- An org admin decides which of the modes left to them their own
users get to see. Modes the superadmin already hid are not in
the list at all, so they cannot be handed back. -->
<div v-if="ankerEditingOrg && ankerHideableModes.length">
<label class="dh-label">{{ t("settings.integrations.controlModesHidden") }}</label>
<div class="flex flex-col gap-1.5 pt-1">
<label
v-for="m in ankerHideableModes"
:key="m"
class="flex items-center gap-2 text-sm text-body"
>
<input
type="checkbox"
class="dh-checkbox"
:checked="ankerHidesMode(m)"
@change="toggleAnkerHiddenMode(m, $event.target.checked)"
/>
<span>{{ ankerModeLabel(m) }}</span>
</label>
</div>
<p class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModesHiddenHint") }}</p>
</div>
</div>
<div class="mt-4 flex items-center gap-2">