@x47base/pocketbase-addon 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +9 -1
- package/FEATURES.md +17 -0
- package/MIGRATION.md +51 -2
- package/NOTICE.md +2 -0
- package/README.md +53 -13
- package/SECURITY-REVIEW.md +96 -0
- package/VERIFY.md +34 -0
- package/backups/concurrency_test.go +43 -0
- package/backups/register.go +11 -0
- package/bin/launcher.test.mjs +31 -0
- package/bin/pocketbase-extension.mjs +17 -3
- package/cmd/edge/main.go +28 -0
- package/cmd/gateway/main.go +84 -0
- package/cmd/hosting/main.go +27 -0
- package/cmd/pocketbase/main.go +12 -0
- package/deploy/README.md +35 -6
- package/deploy/compose.yaml +5 -0
- package/deploy/edge.json +6 -2
- package/edge/gateway.go +110 -35
- package/edge/openapi.json +226 -1
- package/edge/policy.go +23 -12
- package/edge/telemetry.go +187 -0
- package/edge/telemetry_test.go +181 -0
- package/hosting/README.md +62 -0
- package/hosting/backups.go +381 -0
- package/hosting/backups_test.go +81 -0
- package/hosting/blueprint.example.json +14 -0
- package/hosting/blueprint.go +242 -0
- package/hosting/blueprint_test.go +98 -0
- package/hosting/config.go +126 -0
- package/hosting/deploy/dns.example.json +1 -0
- package/hosting/docs/DEPLOYMENT.md +98 -0
- package/hosting/hosting.example.json +1 -0
- package/hosting/local_target_test.go +29 -0
- package/hosting/scripts/dns.mjs +116 -0
- package/hosting/scripts/routes.mjs +39 -0
- package/hosting/ui/main.js +36 -0
- package/hosting/ui/page.css +86 -0
- package/hosting/ui/page.js +112 -0
- package/multinode/README.md +87 -0
- package/multinode/gateway.docker.json +1 -0
- package/multinode/gateway.example.json +1 -0
- package/multinode/gateway.go +347 -0
- package/multinode/gateway_test.go +217 -0
- package/multinode/security_regression_test.go +106 -0
- package/package.json +11 -6
- package/scripts/check-edge.py +21 -4
- package/scripts/check.sh +5 -2
- package/security/README.md +42 -9
- package/security/config.go +4 -0
- package/security/edge_telemetry.go +123 -0
- package/security/edge_telemetry_test.go +87 -0
- package/security/management.go +3 -1
- package/security/openapi.json +269 -0
- package/security/security.go +37 -13
- package/security/security_test.go +54 -0
- package/security/state.go +12 -7
- package/security/ui/dashboard.css +9 -2
- package/security/ui/dashboard.js +68 -20
- package/security/ui/main.js +16 -4
- package/security/ui/model.js +23 -1
- package/security/ui/model.test.mjs +12 -0
package/Dockerfile
CHANGED
|
@@ -4,20 +4,28 @@ COPY go.mod go.sum ./
|
|
|
4
4
|
RUN go mod download
|
|
5
5
|
COPY . .
|
|
6
6
|
RUN CGO_ENABLED=0 go build -trimpath -o /pocketbase ./cmd/pocketbase && \
|
|
7
|
-
CGO_ENABLED=0 go build -trimpath -o /edge ./cmd/edge
|
|
7
|
+
CGO_ENABLED=0 go build -trimpath -o /edge ./cmd/edge && \
|
|
8
|
+
CGO_ENABLED=0 go build -trimpath -o /gateway ./cmd/gateway
|
|
8
9
|
RUN mkdir -p /data/security && chown -R 65532:65532 /data
|
|
9
10
|
|
|
10
11
|
FROM gcr.io/distroless/static-debian12:nonroot AS edge
|
|
11
12
|
COPY --from=build /edge /edge
|
|
13
|
+
COPY --from=build --chown=65532:65532 /data/security /telemetry
|
|
12
14
|
EXPOSE 8080 8081
|
|
13
15
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD ["/edge", "-probe", "http://127.0.0.1:8081/edge/readyz"]
|
|
14
16
|
ENTRYPOINT ["/edge"]
|
|
15
17
|
|
|
18
|
+
FROM gcr.io/distroless/static-debian12:nonroot AS gateway
|
|
19
|
+
COPY --from=build /gateway /gateway
|
|
20
|
+
EXPOSE 8095
|
|
21
|
+
ENTRYPOINT ["/gateway","-listen",":8095","-config","/config/gateway.json"]
|
|
22
|
+
|
|
16
23
|
FROM gcr.io/distroless/static-debian12:nonroot AS pocketbase
|
|
17
24
|
COPY --from=build /pocketbase /pocketbase
|
|
18
25
|
COPY --from=build /edge /edge
|
|
19
26
|
COPY --from=build --chown=65532:65532 /data /pb_data
|
|
20
27
|
COPY --from=build --chown=65532:65532 /data/security /state
|
|
28
|
+
COPY --from=build --chown=65532:65532 /data/security /telemetry
|
|
21
29
|
COPY --chown=65532:65532 deploy/app /app
|
|
22
30
|
EXPOSE 8090
|
|
23
31
|
VOLUME ["/pb_data"]
|
package/FEATURES.md
CHANGED
|
@@ -30,3 +30,20 @@ The earlier decisions to defer native WebAuthn (#6800) and a new nested storage
|
|
|
30
30
|
The experimental native UI integration and JSVM/watcher compatibility seams are version-pinned. Future PocketBase upgrades require this repository's checks and browser/restore verification; separation makes upgrades manageable, not automatically safe.
|
|
31
31
|
|
|
32
32
|
The Docker edge add-on (`edge/`, `cmd/edge`, `deploy/`) now rejects public traffic before upstream work: explicit collections, CIDR/path blocks, synchronized enforce-mode family actions, bounded budgets/clients/concurrency/bodies, and separate operator capacity. Policy synchronization is asynchronous; existing requests are not recalled. Custom migrations/hooks/assets ship in the image. Gateway counters are local at `/edge/status`. See deployment docs for route and availability limits.
|
|
33
|
+
|
|
34
|
+
## Firewall visibility and upload hardening
|
|
35
|
+
|
|
36
|
+
Native Security now separates public firewall metrics from application traffic,
|
|
37
|
+
shows rejection reasons, body bytes versus declared sizes, one-second traffic
|
|
38
|
+
buckets, upstream health and effective upload/concurrency limits. Every edge denial
|
|
39
|
+
is retained immediately as a grouped policy event. Imported events use the existing
|
|
40
|
+
incident review and acknowledgement workflow. Missing/stale telemetry is explicit.
|
|
41
|
+
Public uploads have bounded buffers, a five-second default body deadline and a
|
|
42
|
+
four-request default client concurrency limit. No change grants guaranteed uptime
|
|
43
|
+
or classifies every rejected request as a malicious attack.
|
|
44
|
+
|
|
45
|
+
## Unified optional features
|
|
46
|
+
|
|
47
|
+
- `hosting/`: named backup targets, superuser backup policies and native Hosting dashboard; enabled by `HOSTING_ADDON_CONFIG` in the CLI. Includes commerce deployment rendering and DNS/route tools.
|
|
48
|
+
- `multinode/`, `cmd/gateway`: explicit host-to-owner routing with opt-in bounded public response caching. Runs independently from the database server.
|
|
49
|
+
- `pocketbase-addon init`: generates missing development configuration without overwriting operator files. All sources and guides ship in the npm tarball.
|
package/MIGRATION.md
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Go application
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install `@x47base/pocketbase-addon` with npm (or install its local `.tgz` before publication). In your Go host, use:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
go mod edit -require=github.com/spink-dev/pocketbase-extension@v0.0.0
|
|
9
|
+
go mod edit -replace=github.com/spink-dev/pocketbase-extension=./node_modules/@x47base/pocketbase-addon
|
|
10
|
+
go mod tidy
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Do not replace `github.com/pocketbase/pocketbase`. Node is only needed for package installation; production may run the compiled Go binary.
|
|
6
14
|
|
|
7
15
|
```go
|
|
8
16
|
import (
|
|
@@ -28,7 +36,7 @@ Register before bootstrap, so field factories, first-run settings and watcher ho
|
|
|
28
36
|
3. Import copies native `backups.encrypted/encryptionEnv` and per-template `locales` into extension-owned `_params.spink_extension_v1`. It preserves existing extension settings and is idempotent. Missing backup keys fail validation instead of disabling encryption. JSON `schema` is preserved by the registered field wrapper.
|
|
29
37
|
4. Start the copied database using this package. Review Security, x47base extensions, field schemas, mail templates and a disposable backup/restore drill. Only then switch the live `--dir` during downtime.
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
Keep the existing database at its explicit `--dir`; package installation never moves or resets it.
|
|
32
40
|
|
|
33
41
|
## Backups
|
|
34
42
|
|
|
@@ -47,3 +55,44 @@ Do not run a plain stock executable against custom field metadata. Roll back wit
|
|
|
47
55
|
## Docker edge deployment
|
|
48
56
|
|
|
49
57
|
New opt-in Compose deployment uses fresh named volumes and ports 8080/8081. It does not attach or migrate the existing host database. CLI adds `PB_SECURITY_STATE_PATH` (absolute), `PB_SECURITY_TRUSTED_PEERS`, `PB_SECURITY_MANAGEMENT_PEERS`, `PB_SECURITY_OPERATOR_PEERS` (comma-separated CIDRs; operator peers are runtime-only), and `PB_BACKUP_ENCRYPTION_KEY_FILE`. Existing persisted security policy retains precedence for peer lists; verify those after moving a database. Compose stores checkpoint state outside `pb_data` so native/encrypted restores can replace database files without moving a mounted state directory. Preserve and recover this volume separately. See deploy/README.md for deliberate public-route restrictions and migration packaging.
|
|
58
|
+
|
|
59
|
+
## Security observability update
|
|
60
|
+
|
|
61
|
+
- Deploy the new PocketBase and edge binaries together. For Docker, use the updated
|
|
62
|
+
Compose template's separate telemetry volume and flags; see deploy/README.md.
|
|
63
|
+
`Config.EdgeStatePath` is runtime-only and survives policy edits/reloads.
|
|
64
|
+
- `/api/security/status` adds `edge`, `edgeConfigured`, and `edgeStale`.
|
|
65
|
+
`/edge/status` preserves `accepted` (alias of `forwarded`) and adds the versioned
|
|
66
|
+
aggregate telemetry contract. Existing admission policy fields remain valid.
|
|
67
|
+
- `TrafficWindow.seconds` describes the actual evaluation interval; clients must
|
|
68
|
+
divide counts by it instead of assuming ten seconds. Application evaluation now
|
|
69
|
+
occurs every second; incident evidence accumulates across suspicious windows.
|
|
70
|
+
- Incidents add optional `source`, `sourceId`, `reason`, and `severity`; historical
|
|
71
|
+
incidents without a source represent PocketBase. Edge incidents share the existing
|
|
72
|
+
ID/cursor/acknowledgement API. Old evidence cannot be reconstructed retrospectively.
|
|
73
|
+
- Edge policy adds `bodyReadTimeoutSeconds` (default 5, range 1–10) and
|
|
74
|
+
`maxClientConcurrent` (default min(4, maxConcurrent)). Slow legitimate uploads may
|
|
75
|
+
need an explicit policy adjustment. These limits apply to the public listener.
|
|
76
|
+
- Snapshots/checkpoints remain best-effort and bounded. Retain the security volume
|
|
77
|
+
for incident history. Firewall process counters reset on gateway restart.
|
|
78
|
+
|
|
79
|
+
- The application operator lane now matches the gateway capacity of eight requests,
|
|
80
|
+
allowing parallel admin resources without prematurely exhausting the previous
|
|
81
|
+
four-request application lane. Authentication requirements are unchanged.
|
|
82
|
+
|
|
83
|
+
## Unified hosting and multinode package
|
|
84
|
+
|
|
85
|
+
Install only `@x47base/pocketbase-addon` for these features. Change Go imports:
|
|
86
|
+
|
|
87
|
+
| Previous import | Combined import |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `github.com/spink-dev/pocketbase-hosting-addon` | `github.com/spink-dev/pocketbase-extension/hosting` |
|
|
90
|
+
| `github.com/spink-dev/pocketbase-multinode-addon` | `github.com/spink-dev/pocketbase-extension/multinode` |
|
|
91
|
+
|
|
92
|
+
Remove the two old Go require/replace entries and npm dependencies after updating imports. Register hosting once, after the base add-on, or use the bundled CLI with `HOSTING_ADDON_CONFIG`. Keep existing JSON configurations, environment names, policy/run collections and data directories. Existing separate package installations are unchanged until migrated; fixes in this combined package do not patch those installations automatically.
|
|
93
|
+
|
|
94
|
+
Fresh CLI configurations now default to `enforce`. Existing persisted policy wins; inspect the Security dashboard when upgrading. Go `DefaultConfig()` retains its prior observe default for embedding compatibility. Explicitly choose enforce for new production hosts.
|
|
95
|
+
|
|
96
|
+
Backup/restore finalizers now share a process-local admission lock even without hosting. Local hosting destinations inside the data tree outside its excluded `backups` directory are rejected. Move such archives offline to an external backup location and update server configuration before enabling hosting; no archive is moved automatically.
|
|
97
|
+
|
|
98
|
+
The gateway strips `X-Spink-Operator` and `X-Real-IP`. Public callers cannot nominate the private operator lane. Request socket deadlines enforce the existing two-minute maximum; streaming clients must reconnect. The bundled gateway accepts at most 512 simultaneous connections. Custom Go hosts must also bound their HTTP listeners.
|
package/NOTICE.md
CHANGED
|
@@ -3,3 +3,5 @@
|
|
|
3
3
|
PocketBase is copyright Gani Georgiev and contributors, licensed under MIT (see LICENSE.md). This extension includes adapted portions of the local PocketBase development fork, notably archive/restore helpers, watcher, custom fields and optional JSVM loader/bindings. The Go dependency is the unmodified upstream v0.40.3 module, not a vendored core fork.
|
|
4
4
|
|
|
5
5
|
The JSVM declarations describe stock core APIs, with trailing whitespace normalized. Extension-specific Go callbacks are documented in their package sources. Upstream issue review history remains in the original local fork's research/2026-09-10 directory; this package's FEATURES.md records delivery scope without claiming upstream acceptance or issue closure.
|
|
6
|
+
|
|
7
|
+
The hosting and multinode source directories were consolidated from the local spink-dev_pocketbase-hosting-addon and spink-dev_pocketbase-multinode-addon repositories. Their prior npm package identities are superseded for this distribution; the original checkouts are preserved.
|
package/README.md
CHANGED
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
# @x47base/pocketbase-addon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One npm package for stock **PocketBase v0.40.3** extensions: security controls and dashboard, encrypted backups, hosting backup policies, single-owner tenant routing, JavaScript hooks/migrations, and custom field features. See [FEATURES.md](FEATURES.md).
|
|
4
4
|
|
|
5
|
+
## Install and start
|
|
6
|
+
|
|
7
|
+
Requires **Node.js 22+** and **Go 1.27+** on the server/build machine. This npm package includes Go source and builds a server; it is not a browser SDK or a plugin loaded into an existing stock executable. You do not need a GitHub account, checkout, or Git installation.
|
|
8
|
+
|
|
9
|
+
Once this version is published to npm:
|
|
5
10
|
|
|
6
11
|
```sh
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
mkdir my-pocketbase
|
|
13
|
+
cd my-pocketbase
|
|
14
|
+
npm init -y
|
|
15
|
+
npm install --save-exact @x47base/pocketbase-addon
|
|
16
|
+
npx pocketbase-addon init
|
|
17
|
+
PB_SECURITY_MODE=enforce npx pocketbase-addon serve --http=127.0.0.1:8090 --dir=./pb_data
|
|
11
18
|
```
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
Open **http://127.0.0.1:8090/_/** and complete first-run superuser setup. `init` writes missing `hosting.json` and `gateway.json` with private file permissions; existing files are preserved. It does not start services or create accounts. Keep the database, configuration and secrets outside `node_modules`.
|
|
21
|
+
|
|
22
|
+
**This checkout has not been published by this change.** To test before publication, run `npm pack` in the package directory and install the resulting `.tgz` in your application with `npm install /absolute/path/to/package.tgz`. The commands above then work unchanged. The tarball includes source, UI assets, examples, deployment tools and documentation.
|
|
23
|
+
|
|
24
|
+
The first build downloads pinned Go dependencies. Every launch runs `go build` using Go's build cache. To require module-proxy downloads without direct GitHub access, set `GOPROXY=https://proxy.golang.org` (without `,direct`); proxy availability is then required. The Go module name remains `github.com/spink-dev/pocketbase-extension` for import compatibility; that name is not an installation instruction.
|
|
25
|
+
|
|
26
|
+
## Choose features
|
|
27
|
+
|
|
28
|
+
| Feature | How to enable |
|
|
29
|
+
|---|---|
|
|
30
|
+
| Security dashboard, field extensions, backups | Included in `serve`; Security is in the native admin UI. Fresh CLI configurations enforce admission limits. Existing persisted security policy retains precedence. |
|
|
31
|
+
| JavaScript hooks and migrations | `--hooksDir=./pb_hooks --migrationsDir=./pb_migrations`; use `--automigrate=false` in production. |
|
|
32
|
+
| Static content | `--publicDir=./pb_public`; `--staticFallback=` disables fallback. |
|
|
33
|
+
| Hosting backup schedules/dashboard | Set `HOSTING_ADDON_CONFIG=./hosting.json` before `serve`. Off when unset. |
|
|
34
|
+
| Tenant routing gateway | Separate process: `npx pocketbase-addon gateway -config ./gateway.json -listen 127.0.0.1:8095`. |
|
|
35
|
+
| Public API security edge | `npx pocketbase-addon edge -help`; see [deployment guide](deploy/README.md). |
|
|
36
|
+
| Deployment rendering | `npx pocketbase-addon hosting -blueprint ./blueprint.json -out ./generated`. |
|
|
37
|
+
|
|
38
|
+
`pocketbase-extension` remains an alias for existing scripts. `loadtest` and `import-fork` commands remain available. Use `npx pocketbase-addon <command> -help` for gateway/edge/hosting flags and `npx pocketbase-addon serve --help` for PocketBase flags.
|
|
39
|
+
|
|
40
|
+
### Hosting and encrypted backups
|
|
14
41
|
|
|
15
42
|
```sh
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
./scripts/check.sh
|
|
19
|
-
npm pack --dry-run
|
|
43
|
+
HOSTING_ADDON_CONFIG=./hosting.json PB_SECURITY_MODE=enforce \
|
|
44
|
+
npx pocketbase-addon serve --http=127.0.0.1:8090 --dir=./pb_data
|
|
20
45
|
```
|
|
21
46
|
|
|
22
|
-
|
|
47
|
+
In **Hosting**, create a paused backup policy, run it manually and verify the snapshot before enabling its schedule. The generated configuration uses PocketBase's native backup destination. Additional named local and S3-compatible targets are documented in [hosting](hosting/README.md).
|
|
48
|
+
|
|
49
|
+
For encryption, supply a 32–1024-byte secret via `PB_BACKUP_ENCRYPTION_KEY` or a secret file referenced by `PB_BACKUP_ENCRYPTION_KEY_FILE`. Enable encryption under **Settings → x47base extensions**. Keys stay in the server environment, not records or generated files. Named S3 targets refuse plaintext writes. Keep keys separately from snapshots and test restoration on a disposable copy. Local destinations must be outside `pb_data` or below `pb_data/backups`; recursive destinations are rejected, including symlink aliases.
|
|
50
|
+
|
|
51
|
+
### Multiple owners
|
|
52
|
+
|
|
53
|
+
The generated gateway config is a **loopback development example**, accepting `Host: localhost:8095` and forwarding to port 8090. It does not enable caching or restrict routes. For production, configure explicit hosts/private origins, TLS ingress and appropriate route restrictions using the [gateway guide](multinode/README.md). Never expose the example origin directly.
|
|
54
|
+
|
|
55
|
+
Every tenant has exactly one authoritative PocketBase owner and its own assigned data partition. Gateways may scale; SQLite writers must not share a data directory. No automatic owner failover, replication, distributed rate limit or guaranteed DDoS capacity is provided. Public caching is opt-in; authenticated requests and writes bypass it. The gateway limits admitted requests, body size, connection count and request lifetime.
|
|
56
|
+
|
|
57
|
+
Store-bound gateway routes and the hosting **commerce blueprint** require the separate Vendure integration and application-level domain guards. Combining hosting/multinode does not add a commerce application to this package. Generic PocketBase usage needs neither Vendure nor PostgreSQL.
|
|
58
|
+
|
|
59
|
+
## Production and development
|
|
23
60
|
|
|
24
|
-
|
|
61
|
+
- Start with [deployment](deploy/README.md) for the private owner/operator listener and public API edge. Container builds from the installed source support `--target pocketbase`, `--target edge`, and `--target gateway`.
|
|
62
|
+
- Read [integration/migration](MIGRATION.md) before changing an existing host or database. No automatic data moves or account resets occur.
|
|
63
|
+
- Run `npm run check` in the package source for Go tests, race tests, vet, JavaScript tests and CLI builds. Tests use disposable databases and local HTTP servers.
|
|
64
|
+
- See [security review](SECURITY-REVIEW.md) for confirmed high-severity findings, trigger scenarios, fixes and verification limits.
|
|
25
65
|
|
|
26
|
-
|
|
66
|
+
Co-developed with AI. MIT license; see [NOTICE.md](NOTICE.md) for third-party notices.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Security and reliability review
|
|
2
|
+
|
|
3
|
+
Reviewed 2026-09-15. Scope: the base extension's authorization/admission, backup/restore and duplication boundaries; hosting configuration, schedules, retention and generated deployments; multinode routing, caching, forwarding and resource admission. Findings below are restricted to **High** severity. No Critical issue was confirmed. This is source review with local regression tests, not a production security certification.
|
|
4
|
+
|
|
5
|
+
## 1. High — concurrent backup/restore finalizers can operate on the same files
|
|
6
|
+
|
|
7
|
+
**Failure mode:** The pinned upstream `CreateBackup`/`RestoreBackup` entrypoints check `Store.Has` and then call `Store.Set` separately. Both operations are thread-safe individually, but this is not atomic admission. The base extension had no independent lock. Concurrent callers can enter snapshot/restore finalizers together, sharing temporary database filenames, filesystem hook IDs and live data files. One caller can remove shared temporary state or replace files while another is using them. Hosting had its own guard, but users of the base package alone were unprotected.
|
|
8
|
+
|
|
9
|
+
**Trigger:** Two callers both observe no active backup before either stores its active marker. Run backup versus backup, or backup versus restore. This requires overlapping administrative/scheduled/application operations; it is not an unauthenticated endpoint bypass.
|
|
10
|
+
|
|
11
|
+
**Corrected code:** [backups/register.go](backups/register.go) installs the same mutex around both hook chains, before the hosting/encryption/finalizer handlers:
|
|
12
|
+
|
|
13
|
+
```go
|
|
14
|
+
var operation sync.Mutex
|
|
15
|
+
guard := func(e *core.BackupEvent) error {
|
|
16
|
+
if !operation.TryLock() {
|
|
17
|
+
return errors.New("backup/restore operation already running")
|
|
18
|
+
}
|
|
19
|
+
defer operation.Unlock()
|
|
20
|
+
return e.Next()
|
|
21
|
+
}
|
|
22
|
+
app.OnBackupCreate().Bind(&hook.Handler[*core.BackupEvent]{
|
|
23
|
+
Id: "spink.backup.guard", Priority: -2000, Func: guard,
|
|
24
|
+
})
|
|
25
|
+
app.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{
|
|
26
|
+
Id: "spink.restore.guard", Priority: -2000, Func: guard,
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Regression:** `TestBackupAndRestoreHooksExcludeConcurrentOperations` reproduces the state after both callers passed upstream admission and proves the second finalizer is rejected. It fails against the original base implementation. The lock is per registration/process; it does not make shared-data multi-writer operation safe.
|
|
31
|
+
|
|
32
|
+
## 2. High — public clients can nominate the private operator lane
|
|
33
|
+
|
|
34
|
+
**Failure mode:** The multinode proxy rebuilt standard forwarded headers but preserved `X-Spink-Operator`. The base security layer grants a reserved management lane when that header is `1` and the network peer is in `OperatorPeers`. When a gateway peer is in that configured set, public callers can bypass ordinary admission and occupy operator capacity. This threatens management availability; it does **not** bypass PocketBase superuser authentication.
|
|
35
|
+
|
|
36
|
+
**Trigger:** Configure the owner to accept the multinode gateway's source address as an operator peer, then send an ordinary proxied request with `X-Spink-Operator: 1`. Eight concurrent long requests can consume the reserved lane. The condition does not apply when the gateway peer is outside `OperatorPeers` or a sanitizing edge removes the header first.
|
|
37
|
+
|
|
38
|
+
**Corrected code:** [multinode/gateway.go](multinode/gateway.go), inside proxy `Rewrite`, before rebuilding forwarded identity:
|
|
39
|
+
|
|
40
|
+
```go
|
|
41
|
+
pr.Out.Header.Del("X-Spink-Operator")
|
|
42
|
+
pr.Out.Header.Del("X-Real-IP")
|
|
43
|
+
pr.SetXForwarded()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Regression:** `TestGatewayStripsPrivateOperatorIdentity` sends forged private/forwarded identity and checks exactly what the origin receives. It fails against the original gateway.
|
|
47
|
+
|
|
48
|
+
## 3. High — non-reading clients can permanently hold gateway admission slots
|
|
49
|
+
|
|
50
|
+
**Failure mode:** A two-minute request context cancels upstream work but does not interrupt a blocked write to the downstream socket. The original gateway server had no write timeout. Once an upstream response fills socket buffers for a client that stops reading, `ReverseProxy` can remain in `Write` beyond the context deadline, retaining its admission slot. Exhausting the default 128 slots prevents ordinary service. Connections themselves were also unbounded at the listener.
|
|
51
|
+
|
|
52
|
+
**Trigger:** Open requests to a configured tenant endpoint returning a sufficiently large response, then stop reading while keeping TCP open. Repeat up to the configured `maxConcurrent`. The regression uses one slot, a bounded large response and a shortened request context to reproduce it locally without a load attack.
|
|
53
|
+
|
|
54
|
+
**Corrected code:** [multinode/gateway.go](multinode/gateway.go), after deriving the existing bounded request context:
|
|
55
|
+
|
|
56
|
+
```go
|
|
57
|
+
deadline, _ := ctx.Deadline()
|
|
58
|
+
controller := http.NewResponseController(w)
|
|
59
|
+
_ = controller.SetReadDeadline(deadline)
|
|
60
|
+
_ = controller.SetWriteDeadline(deadline)
|
|
61
|
+
defer controller.SetReadDeadline(time.Time{})
|
|
62
|
+
defer controller.SetWriteDeadline(time.Time{})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
[cmd/gateway/main.go](cmd/gateway/main.go) also serves through `netutil.LimitListener(listener, 512)`. Standard Go HTTP writers support these deadlines; custom embedding writers must preserve `ResponseController` support. Streaming clients must reconnect within the existing two-minute lifetime.
|
|
66
|
+
|
|
67
|
+
**Regressions:** `TestSlowReaderReleasesAdmissionSlot` uses a real TCP client that does not read; `TestGatewaySetsSocketDeadlinesForAdmittedRequests` checks deadline setup/reset. Both fail against the original gateway.
|
|
68
|
+
|
|
69
|
+
## 4. High — local backup targets can recursively archive prior backups
|
|
70
|
+
|
|
71
|
+
**Failure mode:** Hosting validated only that a local path was absolute. A target such as `/data/archives` is inside the directory PocketBase snapshots and outside its excluded `/data/backups` subtree. Every later snapshot includes previous archives. With retention greater than one, nested archive growth can exhaust the owner's disk, stopping both writes and backups. Retention runs only after creating the next snapshot, too late to prevent exhaustion.
|
|
72
|
+
|
|
73
|
+
**Trigger:** With `--dir=/data`, configure a named local target `/data/archives`, schedule repeated backups and keep multiple snapshots. A symlink outside the data directory pointing into that subtree has the same effect.
|
|
74
|
+
|
|
75
|
+
**Corrected code:** [hosting/config.go](hosting/config.go) adds `resolvedPath` and `validateLocalTarget`, resolving existing ancestors for destinations that do not yet exist. Only physical paths outside the data tree or under its excluded `backups` subtree pass. [hosting/backups.go](hosting/backups.go) applies this at startup and every destination opening:
|
|
76
|
+
|
|
77
|
+
```go
|
|
78
|
+
if err := validateLocalTarget(app.DataDir(), t.Path); err != nil {
|
|
79
|
+
return nil, err
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Regression:** `TestLocalTargetCannotIncludeBackupsInNextSnapshot` covers the data root, nested paths, symlink aliases, allowed external paths and allowed native-backup descendants. Server-controlled filesystem paths remain a trusted configuration boundary; do not allow untrusted local users to change their ancestors concurrently.
|
|
84
|
+
|
|
85
|
+
## Other reviewed boundaries
|
|
86
|
+
|
|
87
|
+
- Management/settings/hosting routes require native superuser authentication. Record duplication checks view/protected-file access and invokes the native create pipeline. No new authorization bypass was confirmed in these paths.
|
|
88
|
+
- Gateway destinations come from validated server configuration, not request URLs. Unknown hosts fail closed. Cache keys separate hosts and paths; credentialed/variant requests and private responses bypass caching. Mutations have no application retry/failover.
|
|
89
|
+
- Named S3 destinations use configured HTTPS endpoints and credential environment references; plaintext backup uploads are rejected. Restore authenticates encrypted content before extraction and retains cancellation/rollback handling. Existing ZIP path traversal defenses remain.
|
|
90
|
+
- Configuration/route counts, cache size, bodies, request admission and policy query sizes have explicit bounds. Snapshot size, backup history and deployment capacity remain operational concerns, not unlimited-capacity guarantees.
|
|
91
|
+
|
|
92
|
+
## Delivery and verification
|
|
93
|
+
|
|
94
|
+
Fixes live in the combined `@x47base/pocketbase-addon` source. The two original standalone checkouts are preserved; their installations must migrate to receive these patches. The root README and MIGRATION guide describe npm tarball installation and changed imports. No upstream PocketBase source/module cache was modified.
|
|
95
|
+
|
|
96
|
+
See the current verification section in [VERIFY.md](VERIFY.md). Local checks do not establish public TLS, real cloud storage delivery, Kubernetes admission/scheduling, browser UI correctness, volumetric DDoS resistance or a real production restore drill. No npm publication or provider deployment is part of this change.
|
package/VERIFY.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Verification — unified package, 2026-09-15
|
|
2
|
+
|
|
3
|
+
- `GOWORK=off sh scripts/check.sh`: passed. Includes all-package Go tests, targeted race tests (including hosting and multinode), `go vet ./...`, 13 JavaScript tests, and builds of PocketBase, edge, gateway and hosting commands.
|
|
4
|
+
- New backup admission and gateway operator/deadline/slow-reader regressions fail against the original implementations and pass against the patched combined source. Slow-reader coverage uses an actual TCP connection and a short fixture timeout, not a public load test.
|
|
5
|
+
- Local target validation covers missing descendants, symlink aliases and allowed external/native-backup destinations. Generated gateway routes are validated against the gateway's actual configuration contract, including the 64-character store bound.
|
|
6
|
+
- npm tarball inventory includes hosting/multinode source, UI, examples, tools and guides, and excludes live databases, environment files and Git metadata.
|
|
7
|
+
- A fresh consumer installed the `.tgz` with `npm install --offline --ignore-scripts`. With `GOPROXY=off` and previously cached Go dependencies, the installed launcher generated configuration and rendered manifests, started an owner and gateway on loopback, proxied a real health request, created hosting collections and denied unauthenticated hosting API access. Temporary processes were stopped.
|
|
8
|
+
- No npm publication, Docker image build, provider deployment, Kubernetes scheduling, browser UI check or new process-restarting restore drill was performed for this consolidation. Historical results below are previous verification, not rerun claims.
|
|
9
|
+
|
|
10
|
+
## Historical verification
|
|
11
|
+
|
|
12
|
+
# Verification — 2026-09-10
|
|
13
|
+
|
|
14
|
+
- Independent `GOWORK=off` module resolves unmodified `github.com/pocketbase/pocketbase v0.40.3`, with no upstream replacement. Original fork is not required.
|
|
15
|
+
- Go tests for all packages, race checks for security/features/localization/loadtest/backups/settings/mail/admin/OTP/watcher, vet, Node model/registration tests and binary build passed.
|
|
16
|
+
- Migrated regression cases cover schema precision and remote-reference denial, duplicate source/create/protected-file rules and cloned file contents, static fallback, authorized image dimensions, first-run environment settings, watcher coalescing, settings revision conflicts, locale isolation and native OTP single-use.
|
|
17
|
+
- Encryption tests cover age round trip, wrong/missing keys, tampering, plaintext downgrade, cancellation, private staging and S3-compatible HTTP destination receiving ciphertext only. This is an isolated S3-compatible fixture, not a live cloud-provider verification.
|
|
18
|
+
- Restore swap tests inject second-move and transaction-commit failures and cancellation; originals are restored. A later registered ordinary-priority application restore hook still runs.
|
|
19
|
+
- Real isolated process drill: create database/record → encrypted backup → mutate record → restore through native API → process restart → original record restored, same admin token valid. Temporary database and process were removed.
|
|
20
|
+
- Browser reproduced missing Security on the existing 127.0.0.1 session. Versioned upstream entry/extension loader restored the native Security link; overview loaded without a new login. Server HTML retains stock CSP. No generated upstream source or bundle was changed.
|
|
21
|
+
- Final browser review confirmed Settings → x47base extensions exposes encryption/key-availability, encrypted upload and localized-template controls; the Security overview remains reachable with the existing session.
|
|
22
|
+
- npm package dry run includes Go sources, UI, launcher and required module/type assets, excludes database files. npm CLI builds and displays help; the extracted npm archive builds independently. Nothing pushed or published.
|
|
23
|
+
- Dockerfile and GitHub workflow supplied; neither was run remotely. No production DDoS capacity, automatic attacker attribution, or future-version compatibility claim.
|
|
24
|
+
|
|
25
|
+
## Docker edge add-on
|
|
26
|
+
|
|
27
|
+
- Full Go tests, race checks (including edge), vet, Node tests and both binaries pass.
|
|
28
|
+
- Both Docker image targets build locally; isolated Compose deployment reaches healthy on ports 18080/18081 with fresh named volumes. No existing development database is attached.
|
|
29
|
+
- Gateway regression tests prove zero upstream calls for blocked networks/paths/families, unknown/admin routes, ambiguous paths, malformed policy and oversized/chunked bodies. Tests cover client bounds, unchanged-reload budgets, stale checkpoints, action expiry, forwarded-header spoofing, and independent operator capacity.
|
|
30
|
+
- Native operator tests require an explicit immediate peer and marker; untrusted requests remain blocked and native superuser authentication remains required.
|
|
31
|
+
- Real Docker drill: checked-in notes migration applies; authenticated public read succeeds; a temporary dashboard API block synchronizes; 100 blocked reads produce zero forwarded requests; operator reads remain available; removing the block recovers public reads. Native backup → mutate → restore succeeds inside the container with the separate state volume.
|
|
32
|
+
- Additional finite smoke: 200 public admin-path requests all rejected, zero public accepted delta, liveness and readiness canaries pass.
|
|
33
|
+
- npm package inspection includes edge sources, Dockerfile, Compose, migration and deployment guide. CI now builds images and runs a disposable Compose smoke; remote CI has not run.
|
|
34
|
+
- These are functional, local checks, not a production capacity benchmark or proof against volumetric DDoS. Gateway policy sync is asynchronous and the documented stale checkpoint bound applies.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package backups
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"github.com/pocketbase/pocketbase/core"
|
|
7
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestBackupAndRestoreHooksExcludeConcurrentOperations(t *testing.T) {
|
|
12
|
+
app, err := tests.NewTestApp()
|
|
13
|
+
if err != nil {
|
|
14
|
+
t.Fatal(err)
|
|
15
|
+
}
|
|
16
|
+
defer app.Cleanup()
|
|
17
|
+
Register(app, func(core.App) (Config, error) { return Config{}, nil })
|
|
18
|
+
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
|
|
19
|
+
event := func() *core.BackupEvent {
|
|
20
|
+
return &core.BackupEvent{App: app, Context: context.Background(), Name: "backup.zip"}
|
|
21
|
+
}
|
|
22
|
+
go func() {
|
|
23
|
+
done <- app.OnBackupCreate().Trigger(event(), func(e *core.BackupEvent) error { close(entered); <-release; return nil })
|
|
24
|
+
}()
|
|
25
|
+
<-entered
|
|
26
|
+
defer func() {
|
|
27
|
+
close(release)
|
|
28
|
+
if err := <-done; err != nil {
|
|
29
|
+
t.Error(err)
|
|
30
|
+
}
|
|
31
|
+
}()
|
|
32
|
+
// Trigger represents two callers already past the upstream Has/Set busy check.
|
|
33
|
+
called := false
|
|
34
|
+
err = app.OnBackupCreate().Trigger(event(), func(e *core.BackupEvent) error { called = true; return nil })
|
|
35
|
+
if err == nil || called {
|
|
36
|
+
t.Error("concurrent backup entered snapshot finalizer")
|
|
37
|
+
}
|
|
38
|
+
app.OnBackupRestore().BindFunc(func(e *core.BackupEvent) error { called = true; return errors.New("restore reached") })
|
|
39
|
+
called = false
|
|
40
|
+
if err = app.OnBackupRestore().Trigger(event()); err == nil || called {
|
|
41
|
+
t.Error("restore entered while snapshot was active")
|
|
42
|
+
}
|
|
43
|
+
}
|
package/backups/register.go
CHANGED
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
"os"
|
|
10
10
|
"path/filepath"
|
|
11
11
|
"strings"
|
|
12
|
+
"sync"
|
|
12
13
|
"time"
|
|
13
14
|
)
|
|
14
15
|
|
|
@@ -38,6 +39,16 @@ func (a *stagedApp) NewBackupsFilesystem() (*filesystem.System, error) {
|
|
|
38
39
|
return filesystem.NewLocal(a.dir)
|
|
39
40
|
}
|
|
40
41
|
func Register(app core.App, config func(core.App) (Config, error)) {
|
|
42
|
+
var operation sync.Mutex
|
|
43
|
+
guard := func(e *core.BackupEvent) error {
|
|
44
|
+
if !operation.TryLock() {
|
|
45
|
+
return errors.New("backup/restore operation already running")
|
|
46
|
+
}
|
|
47
|
+
defer operation.Unlock()
|
|
48
|
+
return e.Next()
|
|
49
|
+
}
|
|
50
|
+
app.OnBackupCreate().Bind(&hook.Handler[*core.BackupEvent]{Id: "spink.backup.guard", Priority: -2000, Func: guard})
|
|
51
|
+
app.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{Id: "spink.restore.guard", Priority: -2000, Func: guard})
|
|
41
52
|
app.OnBackupCreate().BindFunc(func(e *core.BackupEvent) error {
|
|
42
53
|
c, err := config(e.App)
|
|
43
54
|
if err != nil {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {realpathSync,mkdtempSync,readFileSync,writeFileSync,mkdirSync,rmSync} from 'node:fs';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {join} from 'node:path';
|
|
6
|
+
import {spawnSync} from 'node:child_process';
|
|
7
|
+
import {fileURLToPath} from 'node:url';
|
|
8
|
+
const cli=fileURLToPath(new URL('./pocketbase-extension.mjs',import.meta.url));
|
|
9
|
+
function fixture(t){const dir=realpathSync(mkdtempSync(join(tmpdir(),'addon-cli-test-')));t.after(()=>rmSync(dir,{recursive:true,force:true}));return dir;}
|
|
10
|
+
test('init creates private configs and preserves operator edits',t=>{
|
|
11
|
+
const dir=fixture(t);
|
|
12
|
+
const run=()=>spawnSync(process.execPath,[cli,'init'],{cwd:dir,encoding:'utf8'});
|
|
13
|
+
assert.equal(run().status,0);
|
|
14
|
+
assert.deepEqual(JSON.parse(readFileSync(join(dir,'hosting.json'))),{targets:[{key:'native',kind:'native'}]});
|
|
15
|
+
assert.deepEqual(JSON.parse(readFileSync(join(dir,'gateway.json'))).tenants[0],{host:'localhost:8095',origin:'http://127.0.0.1:8090'});
|
|
16
|
+
writeFileSync(join(dir,'hosting.json'),'operator config');
|
|
17
|
+
assert.equal(run().status,0);
|
|
18
|
+
assert.equal(readFileSync(join(dir,'hosting.json'),'utf8'),'operator config');
|
|
19
|
+
});
|
|
20
|
+
for(const command of ['hosting','gateway','edge','loadtest','import-fork','serve'])test(`launcher dispatches ${command} from caller directory`,t=>{
|
|
21
|
+
const dir=fixture(t),bin=join(dir,'bin');mkdirSync(bin);
|
|
22
|
+
writeFileSync(join(bin,'go'),`#!${process.execPath}\nimport fs from 'node:fs';\nconst a=process.argv.slice(2);fs.writeFileSync(process.env.BUILD_LOG,JSON.stringify({args:a,cwd:process.cwd(),gowork:process.env.GOWORK}));fs.writeFileSync(a[a.indexOf('-o')+1],'#!${process.execPath}\\nconsole.log(JSON.stringify({args:process.argv.slice(2),cwd:process.cwd()}))',{mode:0o700});`,{mode:0o700});
|
|
23
|
+
const result=spawnSync(process.execPath,[cli,command,'--help'],{cwd:dir,encoding:'utf8',env:{...process.env,PATH:bin+':'+process.env.PATH,BUILD_LOG:join(dir,'build.json')}});
|
|
24
|
+
assert.equal(result.status,0,result.stderr);
|
|
25
|
+
const build=JSON.parse(readFileSync(join(dir,'build.json')));
|
|
26
|
+
assert.equal(build.args.at(-1),'./cmd/'+(command==='serve'?'pocketbase':command));
|
|
27
|
+
assert.equal(build.gowork,'off');
|
|
28
|
+
const child=JSON.parse(result.stdout);
|
|
29
|
+
assert.equal(child.cwd,dir);
|
|
30
|
+
assert.deepEqual(child.args,command==='serve'?['serve','--help','--dir='+join(dir,'pb_data')]:['--help']);
|
|
31
|
+
});
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {spawn,spawnSync} from 'node:child_process';
|
|
3
|
-
import {mkdtempSync,rmSync} from 'node:fs';
|
|
3
|
+
import {mkdtempSync,rmSync,mkdirSync,writeFileSync} from 'node:fs';
|
|
4
4
|
import {tmpdir} from 'node:os';
|
|
5
5
|
import {fileURLToPath} from 'node:url';
|
|
6
6
|
import {resolve,join} from 'node:path';
|
|
7
7
|
const root=fileURLToPath(new URL('..',import.meta.url));
|
|
8
8
|
const args=process.argv.slice(2);
|
|
9
|
+
if(args[0]==='init'){
|
|
10
|
+
const dir=resolve(process.cwd(),args[1]||'.');
|
|
11
|
+
mkdirSync(dir,{recursive:true});
|
|
12
|
+
const files={
|
|
13
|
+
'hosting.json':JSON.stringify({targets:[{key:'native',kind:'native'}]},null,2)+'\n',
|
|
14
|
+
'gateway.json':JSON.stringify({allowHTTP:true,tenants:[{host:'localhost:8095',origin:'http://127.0.0.1:8090'}]},null,2)+'\n'
|
|
15
|
+
};
|
|
16
|
+
for(const [name,content] of Object.entries(files)){
|
|
17
|
+
try{writeFileSync(join(dir,name),content,{flag:'wx',mode:0o600});}
|
|
18
|
+
catch(error){if(error.code!=='EEXIST')throw error;}
|
|
19
|
+
}
|
|
20
|
+
console.log('Created missing hosting.json and gateway.json. See the packaged README for startup and private-origin deployment.');
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
9
23
|
let target='pocketbase';
|
|
10
|
-
if(
|
|
24
|
+
if(['loadtest','import-fork','hosting','gateway','edge'].includes(args[0]))target=args.shift();
|
|
11
25
|
if(target==='pocketbase'&&args.length===0)args.push('serve');
|
|
12
26
|
let hasDir=false;
|
|
13
27
|
for(let i=0;i<args.length;i++){
|
|
@@ -21,5 +35,5 @@ const result=spawnSync('go',['build','-o',binary,'./cmd/'+target],{cwd:root,stdi
|
|
|
21
35
|
if(result.error||result.status!==0){rmSync(temp,{recursive:true,force:true});console.error('Building the extension requires Go 1.27+.',result.error?.message||'');process.exit(result.status||1);}
|
|
22
36
|
const child=spawn(binary,args,{cwd:process.cwd(),stdio:'inherit',env:process.env});
|
|
23
37
|
child.on('error',error=>{rmSync(temp,{recursive:true,force:true});console.error(error.message);process.exitCode=1;});
|
|
24
|
-
for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
|
|
38
|
+
for(const signal of ['SIGINT','SIGTERM',...(target==='gateway'?['SIGHUP']:[])])process.on(signal,()=>child.kill(signal));
|
|
25
39
|
child.on('exit',(code,signal)=>{rmSync(temp,{recursive:true,force:true});process.exitCode=code??(signal?1:0)});
|
package/cmd/edge/main.go
CHANGED
|
@@ -22,6 +22,7 @@ func main() {
|
|
|
22
22
|
operator := flag.String("operator", ":8081", "Operator listener; publish on loopback only")
|
|
23
23
|
policyPath := flag.String("policy", "/etc/spink/edge.json", "Edge policy file")
|
|
24
24
|
checkpoint := flag.String("checkpoint", "/state/state.json", "Read-only security checkpoint")
|
|
25
|
+
telemetryPath := flag.String("telemetry", "", "Aggregate telemetry output file; use a separate shared volume")
|
|
25
26
|
probe := flag.String("probe", "", "Health URL to check and exit")
|
|
26
27
|
flag.Parse()
|
|
27
28
|
if *probe != "" {
|
|
@@ -54,6 +55,28 @@ func main() {
|
|
|
54
55
|
}
|
|
55
56
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
56
57
|
defer stop()
|
|
58
|
+
if *telemetryPath != "" {
|
|
59
|
+
go func() {
|
|
60
|
+
tick := time.NewTicker(time.Second)
|
|
61
|
+
defer tick.Stop()
|
|
62
|
+
failed := false
|
|
63
|
+
for {
|
|
64
|
+
err := g.WriteTelemetry(*telemetryPath)
|
|
65
|
+
if err != nil && !failed {
|
|
66
|
+
slog.Error("edge telemetry unavailable", "error", err)
|
|
67
|
+
}
|
|
68
|
+
if err == nil && failed {
|
|
69
|
+
slog.Info("edge telemetry restored")
|
|
70
|
+
}
|
|
71
|
+
failed = err != nil
|
|
72
|
+
select {
|
|
73
|
+
case <-ctx.Done():
|
|
74
|
+
return
|
|
75
|
+
case <-tick.C:
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}()
|
|
79
|
+
}
|
|
57
80
|
go func() {
|
|
58
81
|
ticker := time.NewTicker(time.Second)
|
|
59
82
|
defer ticker.Stop()
|
|
@@ -120,4 +143,9 @@ func main() {
|
|
|
120
143
|
for _, s := range servers {
|
|
121
144
|
s.Shutdown(shutdown)
|
|
122
145
|
}
|
|
146
|
+
if *telemetryPath != "" {
|
|
147
|
+
if err := g.WriteTelemetry(*telemetryPath); err != nil {
|
|
148
|
+
slog.Error("final telemetry write", "error", err)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
123
151
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"flag"
|
|
6
|
+
gateway "github.com/spink-dev/pocketbase-extension/multinode"
|
|
7
|
+
"golang.org/x/net/netutil"
|
|
8
|
+
"log"
|
|
9
|
+
"net"
|
|
10
|
+
"net/http"
|
|
11
|
+
"os"
|
|
12
|
+
"os/signal"
|
|
13
|
+
"sync/atomic"
|
|
14
|
+
"syscall"
|
|
15
|
+
"time"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func main() {
|
|
19
|
+
path := flag.String("config", "gateway.json", "routing configuration")
|
|
20
|
+
addr := flag.String("listen", "127.0.0.1:8095", "listen address")
|
|
21
|
+
flag.Parse()
|
|
22
|
+
f, err := os.Open(*path)
|
|
23
|
+
if err != nil {
|
|
24
|
+
log.Fatal(err)
|
|
25
|
+
}
|
|
26
|
+
config, err := gateway.Load(f)
|
|
27
|
+
f.Close()
|
|
28
|
+
if err != nil {
|
|
29
|
+
log.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
g, err := gateway.New(config)
|
|
32
|
+
if err != nil {
|
|
33
|
+
log.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
var current atomic.Pointer[gateway.Gateway]
|
|
36
|
+
current.Store(g)
|
|
37
|
+
s := &http.Server{Addr: *addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { current.Load().ServeHTTP(w, r) }), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 32 << 10}
|
|
38
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
39
|
+
defer stop()
|
|
40
|
+
reload := make(chan os.Signal, 1)
|
|
41
|
+
signal.Notify(reload, syscall.SIGHUP)
|
|
42
|
+
defer signal.Stop(reload)
|
|
43
|
+
go func() {
|
|
44
|
+
for {
|
|
45
|
+
select {
|
|
46
|
+
case <-ctx.Done():
|
|
47
|
+
return
|
|
48
|
+
case <-reload:
|
|
49
|
+
f, err := os.Open(*path)
|
|
50
|
+
if err != nil {
|
|
51
|
+
log.Print("routing reload failed")
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
config, err := gateway.Load(f)
|
|
55
|
+
f.Close()
|
|
56
|
+
if err != nil {
|
|
57
|
+
log.Print("routing reload invalid")
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
next, err := gateway.New(config)
|
|
61
|
+
if err != nil {
|
|
62
|
+
log.Print("routing reload invalid")
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
current.Swap(next).CloseIdleConnections()
|
|
66
|
+
log.Print("routing reloaded")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}()
|
|
70
|
+
go func() {
|
|
71
|
+
<-ctx.Done()
|
|
72
|
+
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
73
|
+
defer cancel()
|
|
74
|
+
s.Shutdown(shutdown)
|
|
75
|
+
}()
|
|
76
|
+
log.Printf("gateway listening on %s", *addr)
|
|
77
|
+
listener, err := net.Listen("tcp", *addr)
|
|
78
|
+
if err != nil {
|
|
79
|
+
log.Fatal(err)
|
|
80
|
+
}
|
|
81
|
+
if err = s.Serve(netutil.LimitListener(listener, 512)); err != nil && err != http.ErrServerClosed {
|
|
82
|
+
log.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
}
|