@x47base/pocketbase-addon 0.1.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/.dockerignore +9 -0
- package/Dockerfile +26 -0
- package/FEATURES.md +32 -0
- package/LICENSE.md +17 -0
- package/MIGRATION.md +49 -0
- package/NOTICE.md +5 -0
- package/README.md +26 -0
- package/adapter.go +31 -0
- package/admin/register.go +60 -0
- package/admin/register_test.go +37 -0
- package/backups/backup_encryption_test.go +82 -0
- package/backups/encryption.go +138 -0
- package/backups/integration_test.go +51 -0
- package/backups/register.go +120 -0
- package/backups/restore.go +119 -0
- package/backups/s3_test.go +52 -0
- package/backups/swap.go +53 -0
- package/backups/swap_test.go +75 -0
- package/backups/upload.go +52 -0
- package/bin/pocketbase-extension.mjs +25 -0
- package/cmd/edge/main.go +123 -0
- package/cmd/import-fork/main.go +37 -0
- package/cmd/loadtest/main.go +61 -0
- package/cmd/loadtest/sandbox.go +73 -0
- package/cmd/loadtest/sandbox_test.go +20 -0
- package/cmd/pocketbase/main.go +78 -0
- package/deploy/README.md +148 -0
- package/deploy/app/hooks/README.md +2 -0
- package/deploy/app/migrations/1789000000_notes.js +16 -0
- package/deploy/app/public/README.md +2 -0
- package/deploy/compose.secrets.yaml +8 -0
- package/deploy/compose.yaml +68 -0
- package/deploy/edge.json +13 -0
- package/edge/gateway.go +295 -0
- package/edge/gateway_test.go +296 -0
- package/edge/openapi.json +1 -0
- package/edge/policy.go +150 -0
- package/features/collection_singleton.go +13 -0
- package/features/collection_singleton_test.go +46 -0
- package/features/dimensions_test.go +65 -0
- package/features/duplicate.go +178 -0
- package/features/duplicate_test.go +128 -0
- package/features/field_color.go +46 -0
- package/features/field_date_only.go +39 -0
- package/features/field_json_schema.go +92 -0
- package/features/field_scalar_extensions_test.go +66 -0
- package/features/files.go +36 -0
- package/features/filter_has_any_test.go +81 -0
- package/features/generate_test.go +72 -0
- package/features/has_any_visibility_test.go +62 -0
- package/features/json.go +50 -0
- package/features/membership.go +64 -0
- package/features/register.go +45 -0
- package/features/schema_test.go +58 -0
- package/features/ui/main.js +133 -0
- package/features/ui/settings.js +31 -0
- package/go.mod +54 -0
- package/go.sum +159 -0
- package/internal/archive/create.go +91 -0
- package/internal/archive/create_test.go +125 -0
- package/internal/archive/extract.go +99 -0
- package/internal/archive/extract_test.go +88 -0
- package/jsvm/binds.go +1273 -0
- package/jsvm/binds_app_reset_test.go +314 -0
- package/jsvm/binds_test.go +1870 -0
- package/jsvm/form_data.go +149 -0
- package/jsvm/form_data_test.go +225 -0
- package/jsvm/internal/types/generated/embed.go +6 -0
- package/jsvm/internal/types/generated/types.d.ts +24820 -0
- package/jsvm/internal/types/types.go +1408 -0
- package/jsvm/jsvm.go +587 -0
- package/jsvm/mapper.go +67 -0
- package/jsvm/mapper_test.go +42 -0
- package/jsvm/pool.go +73 -0
- package/jsvm/program_source_test.go +24 -0
- package/loadtest/loadtest.go +202 -0
- package/loadtest/loadtest_test.go +84 -0
- package/localization/README.md +23 -0
- package/localization/catalogue.json +483 -0
- package/localization/localization.go +94 -0
- package/localization/localization_test.go +21 -0
- package/mail/register.go +80 -0
- package/mail/register_test.go +49 -0
- package/mail/resolve.go +91 -0
- package/migration/import.go +96 -0
- package/migration/import_test.go +58 -0
- package/otp/otp.go +56 -0
- package/otp/otp_test.go +56 -0
- package/package.json +51 -0
- package/scripts/check-edge.py +42 -0
- package/scripts/check.sh +11 -0
- package/scripts/sync-jsvm-types.sh +10 -0
- package/security/README.md +94 -0
- package/security/assurance_test.go +149 -0
- package/security/compatibility_test.go +128 -0
- package/security/config.go +78 -0
- package/security/dashboard_test.go +103 -0
- package/security/management.go +169 -0
- package/security/openapi.json +508 -0
- package/security/review.go +34 -0
- package/security/security.go +503 -0
- package/security/security_test.go +146 -0
- package/security/state.go +116 -0
- package/security/ui/dashboard.css +4 -0
- package/security/ui/dashboard.js +83 -0
- package/security/ui/main.js +15 -0
- package/security/ui/model.js +32 -0
- package/security/ui/model.test.mjs +25 -0
- package/security/ui/registration.test.mjs +10 -0
- package/settings/env_test.go +41 -0
- package/settings/openapi.json +193 -0
- package/settings/settings.go +155 -0
- package/settings/settings_test.go +31 -0
- package/watcher/watcher.go +192 -0
- package/watcher/watcher_test.go +200 -0
- package/web/static.go +99 -0
- package/web/static_test.go +48 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"flag"
|
|
7
|
+
"fmt"
|
|
8
|
+
"github.com/spink-dev/pocketbase-extension/loadtest"
|
|
9
|
+
"os"
|
|
10
|
+
"os/signal"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func main() {
|
|
15
|
+
var c loadtest.Config
|
|
16
|
+
flag.StringVar(&c.Target, "target", "http://127.0.0.1:8090/api/health", "local disposable PocketBase target")
|
|
17
|
+
flag.IntVar(&c.Rate, "rate", 20, "total requests per second")
|
|
18
|
+
flag.IntVar(&c.Clients, "clients", 4, "maximum concurrent clients")
|
|
19
|
+
flag.DurationVar(&c.Duration, "duration", 10*time.Second, "maximum run duration")
|
|
20
|
+
flag.IntVar(&c.MaxRequests, "requests", 200, "maximum total requests")
|
|
21
|
+
sandbox := flag.Bool("sandbox", false, "create a disposable database with 5000 fixture records")
|
|
22
|
+
mode := flag.String("mode", "observe", "sandbox protection mode: disabled, observe, enforce")
|
|
23
|
+
dryRun := flag.Bool("dry-run", false, "validate budgets without traffic or database creation")
|
|
24
|
+
flag.IntVar(&c.NormalEvery, "normal-every", 0, "send a health canary every N scheduled requests, 2..100")
|
|
25
|
+
flag.Parse()
|
|
26
|
+
if *mode != "disabled" && *mode != "observe" && *mode != "enforce" {
|
|
27
|
+
fmt.Fprintln(os.Stderr, "invalid sandbox mode")
|
|
28
|
+
os.Exit(2)
|
|
29
|
+
}
|
|
30
|
+
if *sandbox {
|
|
31
|
+
c.Target = "http://127.0.0.1:8090/api/collections/pb_sim_records/records"
|
|
32
|
+
}
|
|
33
|
+
if err := loadtest.Validate(c); err != nil {
|
|
34
|
+
fmt.Fprintln(os.Stderr, err)
|
|
35
|
+
os.Exit(2)
|
|
36
|
+
}
|
|
37
|
+
if *dryRun {
|
|
38
|
+
json.NewEncoder(os.Stdout).Encode(c)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
if *sandbox {
|
|
42
|
+
target, cleanup, err := startSandbox(*mode)
|
|
43
|
+
if err != nil {
|
|
44
|
+
fmt.Fprintln(os.Stderr, err)
|
|
45
|
+
os.Exit(1)
|
|
46
|
+
}
|
|
47
|
+
defer cleanup()
|
|
48
|
+
c.Target = target
|
|
49
|
+
}
|
|
50
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
51
|
+
defer stop()
|
|
52
|
+
report, err := loadtest.Run(ctx, c)
|
|
53
|
+
if err != nil {
|
|
54
|
+
fmt.Fprintln(os.Stderr, err)
|
|
55
|
+
os.Exit(1)
|
|
56
|
+
}
|
|
57
|
+
if err := json.NewEncoder(os.Stdout).Encode(report); err != nil {
|
|
58
|
+
fmt.Fprintln(os.Stderr, err)
|
|
59
|
+
os.Exit(1)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http/httptest"
|
|
5
|
+
"os"
|
|
6
|
+
|
|
7
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
8
|
+
"github.com/pocketbase/pocketbase/core"
|
|
9
|
+
_ "github.com/pocketbase/pocketbase/migrations"
|
|
10
|
+
"github.com/pocketbase/pocketbase/tools/types"
|
|
11
|
+
"github.com/spink-dev/pocketbase-extension/security"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func startSandbox(mode string) (string, func(), error) {
|
|
15
|
+
config := security.DefaultConfig()
|
|
16
|
+
config.Mode = mode
|
|
17
|
+
if err := config.Validate(); err != nil {
|
|
18
|
+
return "", nil, err
|
|
19
|
+
}
|
|
20
|
+
dir, err := os.MkdirTemp("", "pb-simulation-*")
|
|
21
|
+
if err != nil {
|
|
22
|
+
return "", nil, err
|
|
23
|
+
}
|
|
24
|
+
app := core.NewBaseApp(core.BaseAppConfig{DataDir: dir})
|
|
25
|
+
cleanup := func() { app.ResetBootstrapState(); os.RemoveAll(dir) }
|
|
26
|
+
if err := app.Bootstrap(); err != nil {
|
|
27
|
+
cleanup()
|
|
28
|
+
return "", nil, err
|
|
29
|
+
}
|
|
30
|
+
collection := core.NewBaseCollection("pb_sim_records")
|
|
31
|
+
collection.ListRule = types.Pointer("")
|
|
32
|
+
collection.ViewRule = types.Pointer("")
|
|
33
|
+
collection.Fields.Add(&core.TextField{Name: "label"}, &core.NumberField{Name: "sequence"})
|
|
34
|
+
if err := app.Save(collection); err != nil {
|
|
35
|
+
cleanup()
|
|
36
|
+
return "", nil, err
|
|
37
|
+
}
|
|
38
|
+
err = app.RunInTransaction(func(tx core.App) error {
|
|
39
|
+
for i := 0; i < 5000; i++ {
|
|
40
|
+
r := core.NewRecord(collection)
|
|
41
|
+
r.Set("label", "simulation fixture")
|
|
42
|
+
r.Set("sequence", i)
|
|
43
|
+
if err := tx.Save(r); err != nil {
|
|
44
|
+
return err
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return nil
|
|
48
|
+
})
|
|
49
|
+
if err != nil {
|
|
50
|
+
cleanup()
|
|
51
|
+
return "", nil, err
|
|
52
|
+
}
|
|
53
|
+
service, err := security.New(config)
|
|
54
|
+
if err != nil {
|
|
55
|
+
cleanup()
|
|
56
|
+
return "", nil, err
|
|
57
|
+
}
|
|
58
|
+
router, err := apis.NewRouter(app)
|
|
59
|
+
if err != nil {
|
|
60
|
+
service.Close()
|
|
61
|
+
cleanup()
|
|
62
|
+
return "", nil, err
|
|
63
|
+
}
|
|
64
|
+
service.Bind(router)
|
|
65
|
+
handler, err := router.BuildMux()
|
|
66
|
+
if err != nil {
|
|
67
|
+
service.Close()
|
|
68
|
+
cleanup()
|
|
69
|
+
return "", nil, err
|
|
70
|
+
}
|
|
71
|
+
server := httptest.NewServer(handler)
|
|
72
|
+
return server.URL + "/api/collections/pb_sim_records/records", func() { server.Close(); service.Close(); cleanup() }, nil
|
|
73
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"github.com/spink-dev/pocketbase-extension/loadtest"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestSandboxRunsRealDatabase(t *testing.T) {
|
|
11
|
+
target, cleanup, err := startSandbox("observe")
|
|
12
|
+
if err != nil {
|
|
13
|
+
t.Fatal(err)
|
|
14
|
+
}
|
|
15
|
+
defer cleanup()
|
|
16
|
+
report, err := loadtest.Run(context.Background(), loadtest.Config{Target: target, Rate: 10, Clients: 1, Duration: time.Second, MaxRequests: 2, NormalEvery: 2})
|
|
17
|
+
if err != nil || report.Errors != 0 || report.StatusClasses[2] != 2 || report.CanarySucceeded != 1 {
|
|
18
|
+
t.Fatalf("sandbox failed: %+v %v", report, err)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/pocketbase/pocketbase"
|
|
5
|
+
"github.com/pocketbase/pocketbase/core"
|
|
6
|
+
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
|
7
|
+
adapter "github.com/spink-dev/pocketbase-extension"
|
|
8
|
+
_ "github.com/spink-dev/pocketbase-extension/features"
|
|
9
|
+
"github.com/spink-dev/pocketbase-extension/jsvm"
|
|
10
|
+
"github.com/spink-dev/pocketbase-extension/web"
|
|
11
|
+
"log"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"strings"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func main() {
|
|
18
|
+
app := pocketbase.New()
|
|
19
|
+
var hooksDir, migrationsDir, publicDir, fallback string
|
|
20
|
+
var watchHooks, automigrate bool
|
|
21
|
+
app.RootCmd.PersistentFlags().StringVar(&hooksDir, "hooksDir", "", "JS hooks directory (default: sibling of data directory)")
|
|
22
|
+
app.RootCmd.PersistentFlags().StringVar(&migrationsDir, "migrationsDir", "", "JS migrations directory (default: sibling of data directory)")
|
|
23
|
+
app.RootCmd.PersistentFlags().BoolVar(&watchHooks, "hooksWatch", false, "Restart on JS hook changes")
|
|
24
|
+
app.RootCmd.PersistentFlags().BoolVar(&automigrate, "automigrate", true, "Generate JS collection migrations")
|
|
25
|
+
app.RootCmd.PersistentFlags().StringVar(&publicDir, "publicDir", "./pb_public", "Static files directory")
|
|
26
|
+
app.RootCmd.PersistentFlags().StringVar(&fallback, "staticFallback", "index.html", "Missing-file fallback; empty disables it")
|
|
27
|
+
if err := app.RootCmd.ParseFlags(os.Args[1:]); err != nil {
|
|
28
|
+
log.Fatal(err)
|
|
29
|
+
}
|
|
30
|
+
config := adapter.DefaultConfig()
|
|
31
|
+
statePath, err := filepath.Abs(filepath.Join(app.DataDir(), "security", "state.json"))
|
|
32
|
+
if err != nil {
|
|
33
|
+
log.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
config.StatePath = statePath
|
|
36
|
+
if override := os.Getenv("PB_SECURITY_STATE_PATH"); override != "" {
|
|
37
|
+
config.StatePath = override
|
|
38
|
+
}
|
|
39
|
+
if secretPath := os.Getenv("PB_BACKUP_ENCRYPTION_KEY_FILE"); secretPath != "" {
|
|
40
|
+
raw, err := os.ReadFile(secretPath)
|
|
41
|
+
if err != nil {
|
|
42
|
+
log.Fatal("cannot read backup encryption key file")
|
|
43
|
+
}
|
|
44
|
+
key := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r")
|
|
45
|
+
if len(key) < 32 || len(key) > 1024 {
|
|
46
|
+
log.Fatal("backup encryption key must contain 32-1024 bytes")
|
|
47
|
+
}
|
|
48
|
+
if err := os.Setenv("PB_BACKUP_ENCRYPTION_KEY", key); err != nil {
|
|
49
|
+
log.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if mode := os.Getenv("PB_SECURITY_MODE"); mode != "" {
|
|
53
|
+
config.Mode = mode
|
|
54
|
+
}
|
|
55
|
+
if value := os.Getenv("PB_SECURITY_TRUSTED_PEERS"); value != "" {
|
|
56
|
+
config.TrustedPeers = strings.Split(value, ",")
|
|
57
|
+
}
|
|
58
|
+
if value := os.Getenv("PB_SECURITY_MANAGEMENT_PEERS"); value != "" {
|
|
59
|
+
config.ManagementPeers = strings.Split(value, ",")
|
|
60
|
+
}
|
|
61
|
+
if value := os.Getenv("PB_SECURITY_OPERATOR_PEERS"); value != "" {
|
|
62
|
+
config.OperatorPeers = strings.Split(value, ",")
|
|
63
|
+
}
|
|
64
|
+
if err := adapter.Register(app, config); err != nil {
|
|
65
|
+
log.Fatal(err)
|
|
66
|
+
}
|
|
67
|
+
jsvm.MustRegister(app, jsvm.Config{HooksDir: hooksDir, MigrationsDir: migrationsDir, HooksWatch: watchHooks, HooksPoolSize: 15})
|
|
68
|
+
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{Dir: migrationsDir, Automigrate: automigrate, TemplateLang: migratecmd.TemplateLangJS})
|
|
69
|
+
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
70
|
+
if !e.Router.HasRoute("GET", "/{path...}") {
|
|
71
|
+
e.Router.GET("/{path...}", web.StaticWithOptions(os.DirFS(publicDir), web.StaticOptions{Fallback: fallback}))
|
|
72
|
+
}
|
|
73
|
+
return e.Next()
|
|
74
|
+
})
|
|
75
|
+
if err := app.Start(); err != nil {
|
|
76
|
+
log.Fatal(err)
|
|
77
|
+
}
|
|
78
|
+
}
|
package/deploy/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Edge-protected PocketBase
|
|
2
|
+
|
|
3
|
+
Two containers: the public gateway rejects traffic before PocketBase; the database
|
|
4
|
+
has no published port. Use this as a single-host deployment template, with upstream
|
|
5
|
+
TLS/DDoS protection appropriate to your hosting provider.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
cd /Users/x47base/git/spink-dev_pocketbase-extension
|
|
9
|
+
docker compose -f deploy/compose.yaml up --build -d --wait
|
|
10
|
+
docker compose -f deploy/compose.yaml logs pocketbase
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Complete the first-run superuser setup link through **http://127.0.0.1:8081/_/**
|
|
14
|
+
(replace the origin in the container's printed link with that address). There are
|
|
15
|
+
no default credentials. This creates a **new database** in named Docker volumes;
|
|
16
|
+
your existing development instance on 8090 and its admin account are untouched.
|
|
17
|
+
|
|
18
|
+
| Address | Purpose |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `http://127.0.0.1:8080` | Public API: explicitly allowed collections only |
|
|
21
|
+
| `http://127.0.0.1:8081/_/` | Native admin, Security, Backups, x47base extensions |
|
|
22
|
+
| `http://127.0.0.1:8081/edge/status` | Gateway accepted/rejected counts, active work, readiness |
|
|
23
|
+
| `http://127.0.0.1:8081/edge/healthz` | Gateway process liveness; no database call |
|
|
24
|
+
| `http://127.0.0.1:8081/edge/readyz` | Policy validity plus cached upstream health |
|
|
25
|
+
|
|
26
|
+
The operator port is loopback-only and keeps native PocketBase authentication.
|
|
27
|
+
Use an SSH tunnel for remote administration. Do not publish port 8081 publicly;
|
|
28
|
+
its diagnostics are intentionally local, without a second login mechanism.
|
|
29
|
+
|
|
30
|
+
```mermaid
|
|
31
|
+
flowchart LR
|
|
32
|
+
Internet[Public client] --> Edge[Gateway checks and budgets]
|
|
33
|
+
Edge --> DB[Private PocketBase]
|
|
34
|
+
Operator[Local admin or SSH tunnel] --> Lane[Reserved operator lane]
|
|
35
|
+
Lane --> DB
|
|
36
|
+
DB --> State[Security checkpoint volume]
|
|
37
|
+
State -. read only, one second poll .-> Edge
|
|
38
|
+
App[Versioned migrations and hooks] --> DB
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Ship your schema and application
|
|
42
|
+
|
|
43
|
+
1. Replace `deploy/app/migrations/1789000000_notes.js` **before first deployment**
|
|
44
|
+
with reviewed PocketBase JS migrations defining your collections, rules and
|
|
45
|
+
indexes. The example creates private `notes` with title/body fields. Native
|
|
46
|
+
migration history applies each version once. Add new migrations for subsequent
|
|
47
|
+
changes; do not edit already-applied versions.
|
|
48
|
+
2. Place trusted JS hooks in `deploy/app/hooks` and static assets in
|
|
49
|
+
`deploy/app/public`. These are baked into the image, with no writable hooks or
|
|
50
|
+
automatic migration generation in production. Static assets are operator-only
|
|
51
|
+
under the default public allowlist; host the public frontend separately.
|
|
52
|
+
3. List the permitted collection names (and IDs if clients use IDs) in
|
|
53
|
+
`deploy/edge.json` → `collections`. This grants network reachability only;
|
|
54
|
+
native collection rules still control data access. Add auth collections too.
|
|
55
|
+
4. Rebuild and deploy. Review migrations against a disposable restored copy first.
|
|
56
|
+
Do not run two PocketBase containers against one data volume. Take a backup
|
|
57
|
+
before a schema upgrade; rollback may require the old binary and database copy.
|
|
58
|
+
|
|
59
|
+
## Pre-database protection
|
|
60
|
+
|
|
61
|
+
`deploy/edge.json` is read every second. Use `blockedCIDRs` for IP/CIDR blocks and
|
|
62
|
+
`blockedPaths` for literal path-prefix blocks. Malformed policy or a missing,
|
|
63
|
+
invalid, or older-than-30-seconds security checkpoint denies public traffic with
|
|
64
|
+
503. Correcting the file automatically recovers. When replacing bind-mounted
|
|
65
|
+
files atomically, recreate the edge container so Docker mounts the new inode:
|
|
66
|
+
`docker compose -f deploy/compose.yaml up -d --force-recreate edge`.
|
|
67
|
+
|
|
68
|
+
Temporary family blocks made in **Security → Protection** are enforced at the
|
|
69
|
+
edge from the last successfully persisted checkpoint, within approximately one
|
|
70
|
+
second. They apply in **enforce** mode and expire at their configured time. Requests
|
|
71
|
+
already admitted are not recalled. If checkpoint writes fail, the old policy can
|
|
72
|
+
remain active for up to 30 seconds before public access fails closed. Admin access
|
|
73
|
+
remains available to diagnose and recover. Preserve the separate security volume
|
|
74
|
+
when replacing the database; it is not included in database backup archives.
|
|
75
|
+
|
|
76
|
+
Defaults: 50 public requests/second (burst 100), 20 per client (burst 40), 32 active
|
|
77
|
+
requests, 1MiB bodies, 4096 tracked clients. No waiting queue is added. New identities
|
|
78
|
+
are denied when the bounded table is full; idle entries expire after a minute.
|
|
79
|
+
Public bodies, including chunked bodies, are read and size-checked **before** any
|
|
80
|
+
upstream side effect. Aggregate configured body buffering is capped at 128MiB.
|
|
81
|
+
Public connections cap at 256, headers at 16KiB, header reads at 3 seconds, request
|
|
82
|
+
reads at 10 seconds, writes at 30 seconds. Realtime streams must reconnect after
|
|
83
|
+
the public write deadline; they share the public concurrency budget.
|
|
84
|
+
|
|
85
|
+
The gateway denies public admin/settings/backups/schema/batch routes, ambiguous
|
|
86
|
+
encoded paths, unknown collections and all unlisted custom routes. Public file
|
|
87
|
+
access is limited to allowed collections; native authorization still runs. The
|
|
88
|
+
separate `/api/files/token` endpoint is not exposed by this template. Deployments
|
|
89
|
+
using batch, custom routes, large uploads or longer streams need reviewed gateway
|
|
90
|
+
changes/limits; there is no catch-all proxy bypass.
|
|
91
|
+
|
|
92
|
+
The runtime-only `PB_SECURITY_OPERATOR_PEERS` list opts the private gateway into
|
|
93
|
+
PocketBase's bounded management lane using `X-Spink-Operator`. Public requests have
|
|
94
|
+
that marker stripped. Native authentication/authorization still run, including for
|
|
95
|
+
operator requests. Do not enable this on a proxy that forwards user-supplied markers.
|
|
96
|
+
The setting is deliberately absent from editable/persisted policy JSON.
|
|
97
|
+
|
|
98
|
+
Client-supplied forwarding headers are replaced. `trustedProxies` is empty by
|
|
99
|
+
default. When putting a TLS proxy in front, restrict the public port at the host
|
|
100
|
+
firewall to that proxy and list only its actual CIDRs; it must sanitize incoming
|
|
101
|
+
forwarding headers. Never trust all addresses. Docker Desktop may present a shared
|
|
102
|
+
host IP, so inspect your production network before relying on per-client limits.
|
|
103
|
+
PocketBase trusts only the gateway's fixed private IP. If changing the subnet,
|
|
104
|
+
change both Compose IPs and the PocketBase peer environment variables. On an
|
|
105
|
+
existing database, review persisted Security policy peer lists as well.
|
|
106
|
+
|
|
107
|
+
## Deployment boundaries and secrets
|
|
108
|
+
|
|
109
|
+
Containers run non-root, with read-only roots, dropped capabilities, resource and
|
|
110
|
+
log limits. The backend network is internal. It has **no external SMTP/S3/OAuth
|
|
111
|
+
provider access by default**; add a controlled egress network/firewall if needed.
|
|
112
|
+
Do not remove the private network or publish the database port to fix egress.
|
|
113
|
+
The default public bind is also loopback. Exposing it uses `PB_PUBLIC_BIND` and
|
|
114
|
+
requires an external TLS/network edge; this template itself serves HTTP.
|
|
115
|
+
|
|
116
|
+
Optional encrypted-backup key via a mounted Docker secret:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
export PB_BACKUP_KEY_FILE=/absolute/protected/backup-key
|
|
120
|
+
# File contains an independently generated 32–1024 byte secret, retained offline.
|
|
121
|
+
docker compose -f deploy/compose.yaml -f deploy/compose.secrets.yaml up -d --build
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Then enable encryption in Settings → x47base extensions. The default key environment
|
|
125
|
+
name remains `PB_BACKUP_ENCRYPTION_KEY`. Do not commit keys or put them in images.
|
|
126
|
+
Docker Compose secret files still require secure host storage. Custom images
|
|
127
|
+
should pin approved base-image digests and pass your image scanning policy.
|
|
128
|
+
|
|
129
|
+
Health checks report health; Docker restart policy restarts exited processes,
|
|
130
|
+
not merely unhealthy ones. Rejection reduces database work; it cannot guarantee
|
|
131
|
+
availability during link saturation, host exhaustion, disk failure or costly
|
|
132
|
+
allowed queries. Use provider/CDN DDoS protection, backups and external monitoring.
|
|
133
|
+
Gateway rejections appear in `/edge/status`; PocketBase incidents only describe
|
|
134
|
+
traffic that reaches PocketBase, so the two counts intentionally differ.
|
|
135
|
+
|
|
136
|
+
## Verification and stopping
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
go test -race ./edge
|
|
140
|
+
# Bounded local smoke; repeated blocked admin paths never reach PocketBase:
|
|
141
|
+
python3 scripts/check-edge.py --public http://127.0.0.1:8080 --operator http://127.0.0.1:8081
|
|
142
|
+
# Stop without deleting data:
|
|
143
|
+
docker compose -f deploy/compose.yaml down
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`down -v` deletes database and security volumes; do not use it for a real deployment.
|
|
147
|
+
Compose behavior follows the [official services](https://docs.docker.com/reference/compose-file/services/)
|
|
148
|
+
and [network documentation](https://docs.docker.com/reference/compose-file/networks/).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Example schema: private until application-specific record rules are added.
|
|
2
|
+
migrate((app) => {
|
|
3
|
+
const collection = new Collection({
|
|
4
|
+
name: "notes",
|
|
5
|
+
type: "base",
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: "title", type: "text", required: true, max: 200 },
|
|
8
|
+
{ name: "body", type: "text", max: 10000 }
|
|
9
|
+
],
|
|
10
|
+
listRule: null, viewRule: null, createRule: null,
|
|
11
|
+
updateRule: null, deleteRule: null
|
|
12
|
+
});
|
|
13
|
+
app.save(collection);
|
|
14
|
+
}, (app) => {
|
|
15
|
+
app.delete(app.findCollectionByNameOrId("notes"));
|
|
16
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
name: spink-pocketbase
|
|
2
|
+
services:
|
|
3
|
+
pocketbase:
|
|
4
|
+
build:
|
|
5
|
+
context: ..
|
|
6
|
+
target: pocketbase
|
|
7
|
+
image: spink-pocketbase:local
|
|
8
|
+
restart: unless-stopped
|
|
9
|
+
read_only: true
|
|
10
|
+
cap_drop: [ALL]
|
|
11
|
+
security_opt: [no-new-privileges:true]
|
|
12
|
+
pids_limit: 128
|
|
13
|
+
mem_limit: 512m
|
|
14
|
+
cpus: 1.5
|
|
15
|
+
tmpfs: ["/tmp:size=64m,mode=1777"]
|
|
16
|
+
environment:
|
|
17
|
+
PB_SECURITY_MODE: enforce
|
|
18
|
+
PB_SECURITY_STATE_PATH: /state/state.json
|
|
19
|
+
PB_SECURITY_OPERATOR_PEERS: 172.30.80.2/32
|
|
20
|
+
PB_SECURITY_TRUSTED_PEERS: 172.30.80.2/32
|
|
21
|
+
PB_SECURITY_MANAGEMENT_PEERS: 172.30.80.2/32,127.0.0.0/8,::1/128
|
|
22
|
+
GOMEMLIMIT: 400MiB
|
|
23
|
+
volumes:
|
|
24
|
+
- data:/pb_data
|
|
25
|
+
- security:/state
|
|
26
|
+
networks:
|
|
27
|
+
backend:
|
|
28
|
+
ipv4_address: 172.30.80.3
|
|
29
|
+
logging: &logs
|
|
30
|
+
driver: json-file
|
|
31
|
+
options: {max-size: 10m, max-file: "3"}
|
|
32
|
+
edge:
|
|
33
|
+
build:
|
|
34
|
+
context: ..
|
|
35
|
+
target: edge
|
|
36
|
+
image: spink-pocketbase-edge:local
|
|
37
|
+
restart: unless-stopped
|
|
38
|
+
read_only: true
|
|
39
|
+
cap_drop: [ALL]
|
|
40
|
+
security_opt: [no-new-privileges:true]
|
|
41
|
+
pids_limit: 128
|
|
42
|
+
mem_limit: 256m
|
|
43
|
+
cpus: 1.0
|
|
44
|
+
environment: {GOMEMLIMIT: 200MiB}
|
|
45
|
+
ports:
|
|
46
|
+
- "${PB_PUBLIC_BIND:-127.0.0.1}:${PB_PUBLIC_PORT:-8080}:8080"
|
|
47
|
+
- "127.0.0.1:${PB_OPERATOR_PORT:-8081}:8081"
|
|
48
|
+
volumes:
|
|
49
|
+
- ./edge.json:/etc/spink/edge.json:ro
|
|
50
|
+
- security:/state:ro
|
|
51
|
+
depends_on:
|
|
52
|
+
pocketbase:
|
|
53
|
+
condition: service_healthy
|
|
54
|
+
networks:
|
|
55
|
+
frontend: {}
|
|
56
|
+
backend:
|
|
57
|
+
ipv4_address: 172.30.80.2
|
|
58
|
+
logging: *logs
|
|
59
|
+
networks:
|
|
60
|
+
frontend: {}
|
|
61
|
+
backend:
|
|
62
|
+
internal: true
|
|
63
|
+
ipam:
|
|
64
|
+
config:
|
|
65
|
+
- subnet: 172.30.80.0/24
|
|
66
|
+
volumes:
|
|
67
|
+
data: {}
|
|
68
|
+
security: {}
|
package/deploy/edge.json
ADDED