diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16e23f69..675f8991 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,18 +41,12 @@ jobs: run: go build -o ${{ matrix.binary }} -ldflags "-s -w" . # Phase B + C build-tag matrix. Each tag variant must compile so the - # tag boundaries don't drift. The OTel and wazero tags are gated - # behind `continue-on-error: true` until the upstream modules land in - # go.mod (tracked in PHASE_BC_LOCAL_TODO.md). Once the modules are - # added, drop continue-on-error so a missing tag combo fails CI. + # tag boundaries don't drift. - name: Build with -tags otel (Phase B Step 8) - continue-on-error: true run: go build -tags otel ./... - name: Build with -tags wazero (Phase C Step 9) - continue-on-error: true run: go build -tags wazero ./... - name: Build with -tags otel,wazero (full community-hub build) - continue-on-error: true run: go build -tags otel,wazero ./... - name: Go vulnerability check diff --git a/Client/tauri-client/src/components/solid/PluginContainer.tsx b/Client/tauri-client/src/components/solid/PluginContainer.tsx index d032bd57..ad3284a6 100644 --- a/Client/tauri-client/src/components/solid/PluginContainer.tsx +++ b/Client/tauri-client/src/components/solid/PluginContainer.tsx @@ -14,6 +14,7 @@ export interface PluginContainerProps { } export function PluginContainer(props: PluginContainerProps): JSX.Element { + // eslint-disable-next-line no-unassigned-vars -- Solid ref assigned by JSX ref={host} let host!: HTMLDivElement; let dispose: (() => void) | undefined; diff --git a/Client/tauri-client/vitest.config.ts b/Client/tauri-client/vitest.config.ts index 895f0009..949c5b09 100644 --- a/Client/tauri-client/vitest.config.ts +++ b/Client/tauri-client/vitest.config.ts @@ -4,8 +4,9 @@ import solidPlugin from "vite-plugin-solid"; export default defineConfig({ // The Solid plugin must be applied here in addition to vite.config.ts so - // Vitest can transform `.tsx` test files. Without it, JSX in component - // tests is parsed as TypeScript and fails on the angle brackets. + // Vitest can transform `.tsx` test files under src/components/solid/. + // Without it, JSX in component tests is parsed as TypeScript and fails on + // the angle brackets. plugins: [ solidPlugin({ include: ["src/components/solid/**/*.{ts,tsx,js,jsx}"], @@ -22,7 +23,7 @@ export default defineConfig({ }, test: { environment: "jsdom", - // Both legacy `tests/**/*.test.ts` files and component-local + // Both the legacy `tests/**/*.test.ts` suite and component-local // `src/**/*.test.{ts,tsx}` files are picked up. The latter is required // for Phase B Step 6 Solid components, whose tests live alongside the // component file (see src/components/solid/README.md). diff --git a/PHASE_BC_LOCAL_TODO.md b/PHASE_BC_LOCAL_TODO.md index 67ff9847..addbc667 100644 --- a/PHASE_BC_LOCAL_TODO.md +++ b/PHASE_BC_LOCAL_TODO.md @@ -23,12 +23,12 @@ The session-resident plan that was actually executed lives in - [x] `cd Server && go test ./store/... ./ws/... ./plugin/... ./telemetry/...` — all pass; full suite `go test ./...` green. - [x] `cd Server && go vet ./...` — clean. -- [ ] `cd Client/tauri-client && npm install && npm run lint && npm run build` +- [x] `cd Client/tauri-client && npm install && npm run lint && npm run build` — Pulls in `solid-js`, `vite-plugin-solid`, and `@solidjs/testing-library` (added to `package.json`); confirms the Solid pipeline compiles inside the existing Vite + TS setup. -- [ ] `cd Client/tauri-client && npm run test` — runs the new - `Badge.test.tsx` smoke test. +- [x] `cd Client/tauri-client && npm run test` — runs the new + `Badge.test.tsx` smoke test. All 111 test files / 3186 tests pass. --- @@ -47,7 +47,7 @@ The session landed: Still TODO locally: -- [ ] Run `npm install` and verify the build passes (sandbox had no +- [x] Run `npm install` and verify the build passes (sandbox had no network). - [ ] Migrate the remaining leaf components in `src/components/` one PR at a time, following the recipe in @@ -58,9 +58,9 @@ Still TODO locally: in containers with native Solid components and delete the old vanilla DOM utilities (`createComponent`, factory shells) referenced from `src/components/`. -- [ ] Add a Vitest config preset under `vitest.config.ts` that pulls in - `@solidjs/testing-library` automatically (currently the test imports - it directly). +- [x] Add Vitest config preset: vite-plugin-solid added to vitest.config.ts, + include expanded to pick up src/components/solid/**/*.test.tsx. + Badge.test.tsx now runs automatically (112 files, 3188 tests pass). --- @@ -131,27 +131,31 @@ The session landed: Still TODO locally: -- [x] Add the OTel modules to `go.mod` — landed on - `claude/review-phase-completion-PBExk`. go.mod now carries - `go.opentelemetry.io/otel/sdk`, `.../exporters/prometheus`, - `.../exporters/otlp/otlptrace/otlptracegrpc`, and - `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`. - (otelhttp replaces the unmaintained otelchi wrapper referenced by - the original plan; otelhttp is upstream-supported and wraps any - `http.Handler` including a Chi router.) +- [x] Add the OTel modules to `go.mod`: otel v1.43.0, sdk v1.43.0, + exporters/prometheus v0.65.0, + exporters/otlp/otlptrace/otlptracegrpc v1.43.0, and + `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` + v0.67.0 (otelhttp replaces the unmaintained otelchi wrapper + referenced by the original plan; otelhttp is upstream-supported + and wraps any `http.Handler` including a Chi router). - [x] Replace the placeholder body of `telemetry/telemetry_otel.go`'s `Init` with the real tracer + meter provider construction. The tagged build wires an OTel Prometheus exporter (pull), an OTLP/gRPC - trace exporter when `exporter=otlp`, `otelhttp.NewHandler` as the - HTTP middleware, and a real provider that re-binds `AppMetrics` - instruments via `resetAppMetricsForInit`. Tests in + trace exporter when `exporter=otlp` (with `OTLPInsecure` opt-in for + plaintext gRPC), `otelhttp.NewHandler` as the HTTP middleware, and + a real provider that re-binds `AppMetrics` instruments via + `resetAppMetricsForInit`. Tests in `Server/telemetry/telemetry_otel_test.go` (`TestOtelInitPrometheusExporter`, `TestOtelTracerRecordsSpan`, - `TestOtelHistogramRecordsSeconds`, `TestOtelShutdownIdempotent`) - run under `go test -tags otel ./telemetry/...`. + `TestOtelHistogramRecordsSeconds`, `TestOtelShutdownIdempotent`, + `TestOtelConvertAttrsHandlesUnsignedInts`, + `TestOtelConvertAttrsUint64OverflowFallsBackToString`, + `TestOtelAppMetricsRebindsAfterInit`) run under + `go test -tags otel ./telemetry/...`. +- [x] Build with `-tags otel` — passes. Full 4-tag matrix green + (default, otel, wazero, otel+wazero) against Go 1.25.1. - [ ] Add a CI job that exercises `go build -tags otel ./...` and - `go test -tags otel ./telemetry/...`. Both pass locally against - Go 1.25.1. + `go test -tags otel ./telemetry/...`. - [x] Add spans to the remaining service-layer entry points (`DMService`, `VoiceService`, `InviteService`, `ModerationService`, `BlockService`, `UserService`) — landed in Pass 3, one entrypoint @@ -196,27 +200,36 @@ The session landed: Still TODO locally: -- [x] Add wazero to `go.mod` — landed on - `claude/review-phase-completion-PBExk`. `go.mod` now requires - `github.com/tetratelabs/wazero v1.11.0`. +- [x] Add `github.com/tetratelabs/wazero v1.11.0` to `go.mod`. - [x] Replace the placeholder body in `Server/plugin/sandbox_wazero.go` - with real wazero runtime construction. The tagged build now owns a - shared `wazero.Runtime` (created in `platformInit`, with WASI - preview-1 imports pre-instantiated), compiles + instantiates each - plugin's `.wasm` entrypoint in `activateWithRuntime`, and tears the - modules + runtime down in `platformDeactivate` / `Close`. Tests in - `Server/plugin/sandbox_wazero_test.go` + with real wazero runtime construction. The tagged build owns a + shared `wazero.Runtime` created in `platformInit` (with the + configured `MaxMemoryMB` translated to `WithMemoryLimitPages` and + WASI preview-1 imports pre-instantiated), compiles + instantiates + each plugin's `.wasm` entrypoint in `activateWithRuntime`, and + tears modules + runtime down in `platformDeactivate` / `Close`. + The host-guest command ABI is JSON-over-linear-memory: + `allocate(size)` / `command_dispatch(ptr,len) → (ptr,len)` / + `deallocate(ptr,len)`, with optional `list_commands` for command + auto-registration. Tests in `Server/plugin/sandbox_wazero_test.go` (`TestWazeroRegistryCreatesRuntime`, `TestWazeroActivateCompilesModule`, `TestWazeroDispatchCommandMissingExport`, `TestWazeroCloseTearsDownRuntime`, - `TestWazeroInvalidWASMFailsActivation`) run under + `TestWazeroInvalidWASMFailsActivation`, + `TestWazeroDisablePluginFreesModule`) run under `go test -tags wazero ./plugin/...` using a 41-byte embedded WASM - fixture — no external WASM asset required. -- [ ] Replace JSON-only manifest parsing with TOML support behind the - `wazero` build tag (the design doc names `plugin.toml`). Add - `github.com/BurntSushi/toml` and a `parseTOML` shim that falls back - to the existing `ParseManifest` if no `plugin.toml` is found. + fixture for the smoke tests. +- [x] Add a precompiled `Server/plugin/examples/hello/hello.wasm` + (925 KiB) built with TinyGo 0.40.1 + Go 1.25.3 + Binaryen + wasm-opt 129. Source in `examples/hello/main.go`; exports: + `allocate`, `deallocate`, `list_commands`, `command_dispatch`, + `on_event`. +- [x] Replace JSON-only manifest parsing with TOML support behind the + `wazero` build tag. Added `github.com/BurntSushi/toml` v1.6.0, + `manifest_toml.go` (wazero) + `manifest_nottoml.go` (!wazero); + `loader.go` prefers `plugin.toml` and falls back to + `plugin.json`. - [x] Wire `Server/plugin/host_events.go` into the WS pub/sub hub. Landed: `EventSink.SetBroadcaster`/`Emit` added; hub gains `SetPluginEventSink`; `deliverBroadcast` calls `sink.Dispatch` @@ -229,14 +242,10 @@ Still TODO locally: `NewPluginAdminHandler` — landed in Pass 2. The router now accepts a `*plugin.Registry` parameter and the handler is also wrapped in `admin.RequireAdminAuth` (Pass 2 closed the auth bypass too). -- [ ] Add a precompiled trivial `.wasm` blob under - `Server/plugin/examples/hello/hello.wasm` so the example plugin can - actually be loaded by an integration test once wazero is wired. - Build it locally with TinyGo: - ```sh - cd Server/plugin/examples/hello - tinygo build -o hello.wasm -target wasi ./main.go - ``` +- [x] Add precompiled `Server/plugin/examples/hello/hello.wasm` (925 KiB). + Built with TinyGo 0.40.1 + Go 1.25.3 + Binaryen wasm-opt 129. + Source in main.go; exports: allocate, deallocate, list_commands, + command_dispatch, on_event. - [x] Implement plugin marketplace install path (`POST /api/v1/admin/plugins/install` with multipart zip) — landed in Pass 4. `Registry.InstallFromZip` does zip-slip validation, no diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index 374e0bb1..4c96b769 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -7,6 +7,7 @@ package api import ( "io" + "log/slog" "net/http" "strconv" "strings" @@ -54,7 +55,7 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) { // client can't tie up parsing memory. r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+1024) if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil { - http.Error(w, "invalid multipart upload: "+err.Error(), http.StatusBadRequest) + http.Error(w, "invalid multipart upload", http.StatusBadRequest) return } file, header, err := r.FormFile("plugin") @@ -80,7 +81,7 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) { // for archive/zip and the cap is small enough to be safe. body, err := io.ReadAll(io.LimitReader(file, maxPluginUploadBytes+1)) if err != nil { - http.Error(w, "read upload: "+err.Error(), http.StatusBadRequest) + http.Error(w, "failed to read upload", http.StatusBadRequest) return } if int64(len(body)) > maxPluginUploadBytes { @@ -96,7 +97,11 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) { } name, err := h.registry.InstallFromZip(r.Context(), body) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + slog.Error("plugin install failed", "error", err) + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INSTALL_FAILED", + Message: "plugin installation failed", + }) return } writeJSON(w, http.StatusCreated, map[string]any{"name": name}) @@ -110,7 +115,8 @@ func (h *PluginAdminHandler) list(w http.ResponseWriter, r *http.Request) { } rows, err := h.store.ListPlugins(ctx) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + slog.Error("plugin list failed", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, rows) @@ -126,7 +132,8 @@ func (h *PluginAdminHandler) enable(w http.ResponseWriter, r *http.Request) { return } if err := h.registry.EnablePlugin(r.Context(), id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + slog.Error("plugin enable failed", "id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) @@ -142,7 +149,8 @@ func (h *PluginAdminHandler) disable(w http.ResponseWriter, r *http.Request) { return } if err := h.registry.DisablePlugin(r.Context(), id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + slog.Error("plugin disable failed", "id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) @@ -158,7 +166,8 @@ func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) { return } if err := h.registry.UninstallPlugin(r.Context(), id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + slog.Error("plugin uninstall failed", "id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) diff --git a/Server/config/config.go b/Server/config/config.go index ddbb87c6..f60ec240 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -56,6 +56,10 @@ type TelemetryConfig struct { Exporter string `koanf:"exporter"` // OTLPEndpoint is the gRPC endpoint when Exporter == "otlp". OTLPEndpoint string `koanf:"otlp_endpoint"` + // OTLPInsecure disables TLS for the OTLP gRPC connection. Only set + // true in development / private-network deployments. Defaults to false + // (TLS required) to avoid transmitting trace/metric data in plaintext. + OTLPInsecure bool `koanf:"otlp_insecure"` // ServiceName is the resource service.name attribute. ServiceName string `koanf:"service_name"` } @@ -273,6 +277,7 @@ voice: # enabled: false # master switch # exporter: "none" # none | prometheus | otlp # otlp_endpoint: "" # required when exporter == "otlp" (host:port of collector) +# otlp_insecure: false # set true only for dev/private networks (disables TLS) # service_name: "owncord-server" # Phase C Step 9 — Wazero plugin runtime. Disabled by default so existing diff --git a/Server/db/queries/sqlite/events.sql b/Server/db/queries/sqlite/events.sql index ec23190a..2fa05a77 100644 --- a/Server/db/queries/sqlite/events.sql +++ b/Server/db/queries/sqlite/events.sql @@ -3,7 +3,7 @@ INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?); -- name: GetMaxEventSeq :one -SELECT COALESCE(MAX(seq), 0) FROM events; +SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) AS max_seq FROM events; -- name: GetEventsSince :many SELECT seq, event_type, channel_id, payload, created_at diff --git a/Server/go.mod b/Server/go.mod index ba066fc2..fcee7995 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( aead.dev/minisign v0.3.0 + github.com/BurntSushi/toml v1.6.0 github.com/corazawaf/coraza/v3 v3.6.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 diff --git a/Server/go.sum b/Server/go.sum index 95df4429..10b595bf 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -12,6 +12,8 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= diff --git a/Server/main.go b/Server/main.go index 8f1654c0..d58b62c3 100644 --- a/Server/main.go +++ b/Server/main.go @@ -51,6 +51,13 @@ func main() { // run is the real entrypoint — separated for testability. func run(log *slog.Logger, logBuf *admin.RingBuffer) error { + // bgCtx is a cancellable context shared by all background goroutines + // (event persister, event pruner, plugin loader). It is cancelled + // early in the shutdown sequence so in-flight DB operations do not + // block after the database is being torn down. + bgCtx, bgCancel := context.WithCancel(context.Background()) + defer bgCancel() + // Clean up old binary from a previous update. exePath, exeErr := os.Executable() if exeErr != nil { @@ -176,7 +183,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { log.Warn("plugin runtime init failed; continuing without plugins", "error", plugErr) } else { pluginRegistry = registry - if err := registry.LoadAll(context.Background()); err != nil { + if err := registry.LoadAll(bgCtx); err != nil { log.Warn("plugin loader: failed to scan directory", "error", err) } defer func() { @@ -198,7 +205,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { // this, the events table accumulates rows whose payload seqs reset // to 1 after every restart, breaking the reconnect "events since // last_seq" contract. - if maxSeq, seedErr := storeWrapper.GetMaxEventSeq(context.Background()); seedErr != nil { + if maxSeq, seedErr := storeWrapper.GetMaxEventSeq(bgCtx); seedErr != nil { log.Warn("event persistence: failed to read MAX(events.seq); starting hub seq from 0", "error", seedErr) } else if maxSeq > 0 { hub.SeedSeq(uint64(maxSeq)) @@ -211,16 +218,14 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { cfg.EventPersistence.BatchSize, time.Duration(cfg.EventPersistence.BatchFlushMs)*time.Millisecond, ) - persister.Start(context.Background()) + persister.Start(bgCtx) hub.SetEventPersister(persister) hub.SetEventStore(storeWrapper) retention := time.Duration(cfg.EventPersistence.RetentionHours) * time.Hour prunerInterval := time.Duration(cfg.EventPersistence.PrunerIntervalMinutes) * time.Minute - prunerCtx, prunerCancel := context.WithCancel(context.Background()) - ws.StartEventPruner(prunerCtx, storeWrapper, retention, prunerInterval) + ws.StartEventPruner(bgCtx, storeWrapper, retention, prunerInterval) defer func() { - prunerCancel() stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) defer stopCancel() persister.Stop(stopCtx) diff --git a/Server/plugin/examples/hello/hello.wasm b/Server/plugin/examples/hello/hello.wasm new file mode 100644 index 00000000..6adb2837 Binary files /dev/null and b/Server/plugin/examples/hello/hello.wasm differ diff --git a/Server/plugin/examples/hello/main.go b/Server/plugin/examples/hello/main.go new file mode 100644 index 00000000..0655826a --- /dev/null +++ b/Server/plugin/examples/hello/main.go @@ -0,0 +1,95 @@ +// Hello plugin — Phase C Step 9 proof-of-life. +// +// Build with TinyGo: +// +// tinygo build -o hello.wasm -target wasi ./main.go +// +// The module exports the five functions the OwnCord plugin ABI requires: +// +// allocate(size) → ptr — host writes input into module memory +// deallocate(ptr, size) — host signals it's done with a buffer +// list_commands() → (ptr, len) — JSON array of command names +// command_dispatch(p, l) → (ptr, len) — handle a slash command, return JSON reply +// on_event(ptr, len) — receive a broadcast event (no-op here) +// +// The file is guarded with the "tinygo" build constraint so the standard Go +// toolchain (go build / go vet) ignores it. Compile with: +// +// tinygo build -o hello.wasm -target wasi ./main.go + +//go:build tinygo + +package main + +import ( + "encoding/json" + "unsafe" +) + +func main() {} + +// allocations keeps live references so the GC does not reclaim buffers the +// host is still holding a pointer to. +var allocations [][]byte + +//export allocate +func allocate(size uint32) uint32 { + buf := make([]byte, size) + allocations = append(allocations, buf) + return uint32(uintptr(unsafe.Pointer(&buf[0]))) +} + +//export deallocate +func deallocate(ptr uint32, size uint32) { + for i, b := range allocations { + if uint32(len(b)) == size && uint32(uintptr(unsafe.Pointer(&b[0]))) == ptr { + allocations = append(allocations[:i], allocations[i+1:]...) + return + } + } +} + +// listCommandsJSON is the static payload returned by list_commands. +var listCommandsJSON = []byte(`["hello"]`) + +//export list_commands +func listCommands() (uint32, uint32) { + return uint32(uintptr(unsafe.Pointer(&listCommandsJSON[0]))), uint32(len(listCommandsJSON)) +} + +type dispatchInput struct { + UserID int64 `json:"user_id"` + ChannelID int64 `json:"channel_id"` + Command string `json:"command"` + Args []string `json:"args"` +} + +type dispatchOutput struct { + Reply string `json:"reply"` +} + +// resultBuf holds the most recent command_dispatch result. Safe because the +// host is single-threaded within a plugin invocation. +var resultBuf []byte + +//export command_dispatch +func commandDispatch(ptr uint32, length uint32) (uint32, uint32) { + raw := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length) + + var in dispatchInput + if err := json.Unmarshal(raw, &in); err != nil { + resultBuf = []byte(`{"reply":"hello: malformed payload"}`) + } else { + reply := "Hello from the hello plugin!" + if len(in.Args) > 0 { + reply = "Hello, " + in.Args[0] + "!" + } + out, _ := json.Marshal(dispatchOutput{Reply: reply}) + resultBuf = out + } + + return uint32(uintptr(unsafe.Pointer(&resultBuf[0]))), uint32(len(resultBuf)) +} + +//export on_event +func onEvent(_ uint32, _ uint32) {} diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go index 571c4c2c..34a9d787 100644 --- a/Server/plugin/host_http.go +++ b/Server/plugin/host_http.go @@ -110,6 +110,9 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) if !r.hostAllowed(h) { return fmt.Errorf("%w: redirect to %s", ErrHTTPHostDenied, h) } + if err := rejectPrivateAddrs(redirReq.Context(), h); err != nil { + return fmt.Errorf("%w: redirect to private addr: %v", ErrHTTPHostDenied, err) + } return nil }, } @@ -223,9 +226,5 @@ func ipAllowed(ip net.IP) error { if v4 := ip.To4(); v4 != nil && cgnRange.Contains(v4) { return fmt.Errorf("carrier-grade NAT address %s", ip) } - // Reject IPv4-mapped IPv6 forms of the same. - if v4 := ip.To4(); v4 != nil && (v4.IsLoopback() || v4.IsPrivate() || v4.IsLinkLocalUnicast()) { - return fmt.Errorf("disallowed v4-mapped address %s", ip) - } return nil } diff --git a/Server/plugin/loader.go b/Server/plugin/loader.go index 80636635..458660e0 100644 --- a/Server/plugin/loader.go +++ b/Server/plugin/loader.go @@ -29,8 +29,8 @@ type foundPlugin struct { } // scanPluginDirectory walks dir non-recursively and parses plugin.json from -// every immediate subdirectory. Errors on individual plugins are wrapped and -// returned alongside the successful entries. +// every immediate subdirectory. Returns on the first error encountered; +// partial results are not returned alongside errors. func scanPluginDirectory(dir string) ([]foundPlugin, error) { if dir == "" { return nil, nil @@ -49,25 +49,35 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) { continue } pluginDir := filepath.Join(dir, e.Name()) - manifestPath := filepath.Join(pluginDir, "plugin.json") - raw, rdErr := os.ReadFile(manifestPath) - if rdErr != nil { - if os.IsNotExist(rdErr) { - continue - } - return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr) + + // Prefer plugin.toml (wazero build) over plugin.json. + manifest, ok, tomlErr := tryLoadPluginTOML(pluginDir) + if tomlErr != nil { + return nil, fmt.Errorf("plugin %q: %w", e.Name(), tomlErr) } - manifest, parseErr := ParseManifest(raw) - if parseErr != nil { - return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr) + if !ok { + // Fall back to plugin.json. + manifestPath := filepath.Join(pluginDir, "plugin.json") + raw, rdErr := os.ReadFile(manifestPath) + if rdErr != nil { + if os.IsNotExist(rdErr) { + continue + } + return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr) + } + var parseErr error + manifest, parseErr = ParseManifest(raw) + if parseErr != nil { + return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr) + } } // Reject any symlinks anywhere in the plugin directory tree. The asset // handler enforces that resolved paths stay rooted at pluginDir, but // http.ServeFile / os.Open follow symlinks transparently — a malicious // plugin .zip containing `assets/index.html -> /etc/passwd` would - // otherwise serve host files. Lstat (not Stat) is used for the - // entrypoint check below so a symlink is detected instead of - // followed, even when its target is a valid .wasm file. + // otherwise serve host files. os.Lstat is used for the entrypoint + // check below so a symlink is detected instead of followed, even + // when its target is a valid .wasm file. if err := rejectSymlinksUnder(pluginDir); err != nil { return nil, fmt.Errorf("plugin %q: %w", e.Name(), err) } diff --git a/Server/plugin/manifest_nottoml.go b/Server/plugin/manifest_nottoml.go new file mode 100644 index 00000000..9a6938c2 --- /dev/null +++ b/Server/plugin/manifest_nottoml.go @@ -0,0 +1,10 @@ +//go:build !wazero + +// Default build stub — TOML manifest parsing is not compiled in without -tags wazero. +package plugin + +// tryLoadPluginTOML always reports "not present" in the default build so the +// loader unconditionally falls through to plugin.json. +func tryLoadPluginTOML(_ string) (*Manifest, bool, error) { + return nil, false, nil +} diff --git a/Server/plugin/manifest_toml.go b/Server/plugin/manifest_toml.go new file mode 100644 index 00000000..4b856bfb --- /dev/null +++ b/Server/plugin/manifest_toml.go @@ -0,0 +1,39 @@ +//go:build wazero + +// TOML manifest support — compiled only with -tags wazero. +// Plugins may ship either plugin.json or plugin.toml; this file provides +// tryLoadPluginTOML which the loader calls before falling back to JSON. +package plugin + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// tryLoadPluginTOML attempts to parse a plugin.toml from pluginDir. +// Returns the parsed Manifest and true on success. +// Returns nil, false if no plugin.toml exists (caller should try plugin.json). +// Returns nil, false plus logs if plugin.toml exists but is malformed — the +// caller will skip the plugin and log the error. +func tryLoadPluginTOML(pluginDir string) (*Manifest, bool, error) { + tomlPath := filepath.Join(pluginDir, "plugin.toml") + raw, err := os.ReadFile(tomlPath) + if os.IsNotExist(err) { + return nil, false, nil // not present; try JSON + } + if err != nil { + return nil, false, fmt.Errorf("read plugin.toml: %w", err) + } + + var m Manifest + if _, err := toml.Decode(string(raw), &m); err != nil { + return nil, false, fmt.Errorf("parse plugin.toml: %w", err) + } + if err := m.Validate(); err != nil { + return nil, false, fmt.Errorf("invalid plugin.toml: %w", err) + } + return &m, true, nil +} diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index a1219a3e..9daaed5c 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -143,6 +143,19 @@ func (r *Registry) LoadAll(ctx context.Context) error { if r == nil { return nil } + // Clean up any staging directories left over from a previous crash + // during InstallFromZip. These are named ".install-XXXXXX" and are + // safe to remove because a successful install always renames them away. + if entries, rdErr := os.ReadDir(r.cfg.Directory); rdErr == nil { + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), ".install-") { + staleDir := filepath.Join(r.cfg.Directory, e.Name()) + if rmErr := os.RemoveAll(staleDir); rmErr != nil { + slog.Warn("plugin: failed to remove stale staging dir", "dir", staleDir, "err", rmErr) + } + } + } + } manifests, err := scanPluginDirectory(r.cfg.Directory) if err != nil { return fmt.Errorf("plugin: scan %q: %w", r.cfg.Directory, err) @@ -427,11 +440,16 @@ func (r *Registry) EnablePlugin(ctx context.Context, id int64) error { if !ok { return ErrPluginNotFound } + r.mu.Lock() inst.Enabled = true + r.mu.Unlock() if err := r.activate(ctx, inst); err != nil { - // Roll back the DB flag so the next start attempt is consistent. + // Roll back the DB flag and the in-memory flag so the next start + // attempt is consistent. _ = r.cfg.Store.DisablePlugin(ctx, id) + r.mu.Lock() inst.Enabled = false + r.mu.Unlock() return err } return nil @@ -464,16 +482,38 @@ func (r *Registry) DisablePlugin(ctx context.Context, id int64) error { // UninstallPlugin removes a plugin entirely. func (r *Registry) UninstallPlugin(ctx context.Context, id int64) error { - _ = r.DisablePlugin(ctx, id) + if err := r.DisablePlugin(ctx, id); err != nil { + slog.Warn("plugin: disable failed during uninstall", "id", id, "err", err) + } + + // Capture the plugin's on-disk directory before removing the in-memory + // record so we can clean it up after the DB row is gone. + r.mu.RLock() + inst, instOK := r.plugins[id] + var pluginDir string + if instOK { + pluginDir = filepath.Join(r.cfg.Directory, inst.Manifest.Name) + } + r.mu.RUnlock() + if err := r.cfg.Store.UninstallPlugin(ctx, id); err != nil { return err } + r.mu.Lock() - defer r.mu.Unlock() if inst, ok := r.plugins[id]; ok { delete(r.byName, inst.Manifest.Name) } delete(r.plugins, id) + r.mu.Unlock() + + // Remove on-disk files so the plugin isn't resurrected on the next + // startup by scanPluginDirectory. + if pluginDir != "" { + if err := os.RemoveAll(pluginDir); err != nil { + slog.Warn("plugin: failed to remove plugin directory after uninstall", "dir", pluginDir, "err", err) + } + } return nil } diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index 9d412be0..f1710059 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -9,28 +9,33 @@ // // The wazero-tagged build provides: // -// platformInit — creates the shared wazero.Runtime and returns a -// teardown closure consumed by Registry.Close. +// platformInit — creates the shared wazero.Runtime (with the +// configured memory cap and WASI preview-1 imports) +// and returns a teardown closure consumed by +// Registry.Close. // activateWithRuntime — compiles the plugin's .wasm entrypoint, instantiates -// it against the shared runtime with WASI enabled, -// and stores the resulting api.Module on the Instance. +// it against the shared runtime, stores the resulting +// api.Module on the Instance, and auto-registers any +// commands the module exports via list_commands. // platformDeactivate — closes the per-plugin module without tearing down -// the shared runtime. -// invokeCommand — calls the plugin's `command_dispatch` export when -// present. The initial wiring keeps the host/guest -// protocol intentionally small: `command_dispatch()` -// takes no parameters and returns a single i32 status -// code. A future iteration will extend this to pass -// command text via guest memory and return a reply. +// the shared runtime; called by DisablePlugin so a +// disabled plugin frees memory immediately. +// invokeCommand — calls the plugin's `command_dispatch` export using +// the JSON-ABI: +// allocate(size u32) → ptr u32 +// command_dispatch(ptr u32, len u32) → (ptr u32, len u32) +// deallocate(ptr u32, len u32) +// Both input and output payloads are JSON. // -// Any .wasm that does not export command_dispatch is still valid — DispatchCommand -// reports a user-facing "no command_dispatch export" message so operators can -// diagnose mis-built plugins without crashing the server. +// Plugins that do not export command_dispatch / allocate are still loadable +// — DispatchCommand reports a user-facing diagnostic instead of crashing. package plugin import ( "context" + "encoding/json" "fmt" + "io" "os" "github.com/tetratelabs/wazero" @@ -38,23 +43,35 @@ import ( "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" ) +// wazeroPageBytes is the size of a single WASM linear-memory page (64 KiB). +const wazeroPageBytes = 65536 + // platformInit stands up the shared wazero runtime for this Registry. The // runtime is the top-level handle that owns compiled modules, host modules, // and per-instance linear memory; every plugin in this registry shares it. +// +// The configured MaxMemoryMB cap is translated to wazero's per-page memory +// limit (64 KiB pages) and applied at runtime construction. WASI preview-1 +// is pre-instantiated so plugins compiled with the standard TinyGo / Rust +// wasm32-wasi targets can resolve their syscall imports. func platformInit(cfg Config) (any, func(context.Context) error, error) { ctx := context.Background() + + memMB := cfg.MaxMemoryMB + if memMB <= 0 { + memMB = 64 // default 64 MiB per plugin runtime + } + memPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig(). + WithMemoryLimitPages(memPages). WithCloseOnContextDone(true), ) - // WASI is required for TinyGo/Rust plugins that link against the - // standard library; without it even a `main` entrypoint that prints - // anything will fail to instantiate. if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil { _ = rt.Close(ctx) return nil, nil, fmt.Errorf("wazero: wasi snapshot_preview1: %w", err) } - _ = cfg // HTTPAllowlist / resource caps are applied per-module in activateWithRuntime closeFn := func(shutCtx context.Context) error { return rt.Close(shutCtx) } @@ -80,6 +97,7 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err) } + // CompileModule is CPU-bound; do it without holding the registry lock. compiled, err := rt.CompileModule(ctx, wasmBytes) if err != nil { return fmt.Errorf("plugin %q: compile: %w", inst.Manifest.Name, err) @@ -87,22 +105,37 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * // Each plugin gets its own module name so multiple instances can coexist // without colliding in the runtime's global module namespace. Output is - // swallowed to keep misbehaving plugins from flooding server logs. + // swallowed so a misbehaving plugin can't flood server logs. _start is + // suppressed so exports are only invoked on demand. modCfg := wazero.NewModuleConfig(). WithName(inst.Manifest.Name). - WithStdout(discardWriter{}). - WithStderr(discardWriter{}) + WithStdout(io.Discard). + WithStderr(io.Discard). + WithStartFunctions() module, err := rt.InstantiateModule(ctx, compiled, modCfg) if err != nil { + _ = compiled.Close(ctx) return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err) } + + // Register the module and auto-bind any commands the plugin exports + // via list_commands. The plugin must also have declared the `commands` + // capability in its manifest, otherwise no binding happens. + r.mu.Lock() inst.module = module + if inst.Manifest.HasCapability(CapCommands) { + for _, cmd := range listExportedCommands(ctx, module) { + r.commands[cmd] = inst + } + } + r.mu.Unlock() return nil } // platformDeactivate closes the wazero module held by inst without touching // the shared runtime. Safe to call on an instance that was never activated. +// Called from DisablePlugin and Close. func (r *Registry) platformDeactivate(inst *Instance) { if inst == nil || inst.module == nil { return @@ -113,51 +146,118 @@ func (r *Registry) platformDeactivate(inst *Instance) { inst.module = nil } -// invokeCommand is the command-capability entrypoint. The host-guest protocol -// is deliberately minimal in this first iteration: +// invokeCommand calls the plugin's exported `command_dispatch` function +// using a small JSON ABI: // -// - If the plugin exports `command_dispatch` with signature `() -> i32`, the -// host calls it. A return value of 0 is treated as success; any non-zero -// value becomes an error reply. -// - If the export is absent, the host returns a user-facing diagnostic. +// allocate(size u32) → ptr u32 +// command_dispatch(ptr u32, len u32) → (result_ptr u32, result_len u32) +// deallocate(ptr u32, len u32) // -// The plan is to extend this to pass the command string + args through guest -// memory (alloc/free host-side helpers) once the first real plugin needs it. +// Both the input and output payloads are JSON. A plugin that does not +// export command_dispatch returns (nil, false) so the WS dispatcher can +// fall back to the not-found response. A plugin that exports +// command_dispatch but lacks allocate returns a user-facing diagnostic. func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) { - _ = userID - _ = channelID - _ = args - if inst == nil || inst.module == nil { - return &CommandResult{Reply: fmt.Sprintf("plugin %q is not activated", cmd)}, true + return nil, false } mod, ok := inst.module.(api.Module) if !ok { - return &CommandResult{Reply: fmt.Sprintf("plugin %q: module type mismatch", inst.Manifest.Name)}, true + return nil, false } - fn := mod.ExportedFunction("command_dispatch") - if fn == nil { + + dispatchFn := mod.ExportedFunction("command_dispatch") + if dispatchFn == nil { return &CommandResult{ Reply: fmt.Sprintf("plugin %q does not export command_dispatch (rebuild the plugin to handle /%s)", inst.Manifest.Name, cmd), }, true } - res, err := fn.Call(ctx) + allocFn := mod.ExportedFunction("allocate") + deallocFn := mod.ExportedFunction("deallocate") + if allocFn == nil { + return &CommandResult{ + Reply: fmt.Sprintf("plugin %s: missing allocate export (required for command dispatch)", inst.Manifest.Name), + }, true + } + + type dispatchPayload struct { + UserID int64 `json:"user_id"` + ChannelID int64 `json:"channel_id"` + Command string `json:"command"` + Args []string `json:"args"` + } + payload, err := json.Marshal(dispatchPayload{ + UserID: userID, ChannelID: channelID, Command: cmd, Args: args, + }) if err != nil { - return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch errored: %v", inst.Manifest.Name, err)}, true + return &CommandResult{Reply: fmt.Sprintf("plugin %s: marshal payload: %v", inst.Manifest.Name, err)}, true } - status := uint64(0) - if len(res) > 0 { - status = res[0] + + // Allocate guest memory for the input payload. + size := uint64(len(payload)) + ptrs, callErr := allocFn.Call(ctx, size) + if callErr != nil || len(ptrs) == 0 { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: allocate(%d): %v", inst.Manifest.Name, size, callErr)}, true } - if status != 0 { - return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch returned status %d", inst.Manifest.Name, status)}, true + ptr := ptrs[0] + + mem := mod.Memory() + if !mem.Write(uint32(ptr), payload) { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: memory write at %d failed", inst.Manifest.Name, ptr)}, true } - return &CommandResult{Reply: fmt.Sprintf("plugin %q: /%s ok", inst.Manifest.Name, cmd)}, true + + results, callErr := dispatchFn.Call(ctx, ptr, size) + + // Free the input buffer regardless of dispatch outcome. + if deallocFn != nil { + _, _ = deallocFn.Call(ctx, ptr, size) + } + + if callErr != nil { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: dispatch: %v", inst.Manifest.Name, callErr)}, true + } + if len(results) < 2 { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: command_dispatch returned %d values, want 2", inst.Manifest.Name, len(results))}, true + } + + resPtr, resLen := uint32(results[0]), uint32(results[1]) + resBytes, ok2 := mem.Read(resPtr, resLen) + if !ok2 { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: cannot read result at %d+%d", inst.Manifest.Name, resPtr, resLen)}, true + } + + type dispatchResult struct { + Reply string `json:"reply"` + } + var dr dispatchResult + if err := json.Unmarshal(resBytes, &dr); err != nil { + // Fall back to raw bytes if the JSON is malformed. + return &CommandResult{Reply: string(resBytes)}, true + } + return &CommandResult{Reply: dr.Reply}, true } -// discardWriter is a tiny io.Writer that throws everything away. Wazero's -// ModuleConfig accepts any io.Writer for stdout/stderr; using io.Discard would -// pull the extra import just to satisfy two calls. -type discardWriter struct{} - -func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } +// listExportedCommands calls the plugin's optional `list_commands` export +// which returns (ptr u32, len u32) pointing to a JSON array of command name +// strings. If the export is absent or returns invalid JSON, an empty slice +// is returned and no command bindings are created. +func listExportedCommands(ctx context.Context, mod api.Module) []string { + fn := mod.ExportedFunction("list_commands") + if fn == nil { + return nil + } + results, err := fn.Call(ctx) + if err != nil || len(results) < 2 { + return nil + } + ptr, length := uint32(results[0]), uint32(results[1]) + raw, ok := mod.Memory().Read(ptr, length) + if !ok { + return nil + } + var cmds []string + if err := json.Unmarshal(raw, &cmds); err != nil { + return nil + } + return cmds +} diff --git a/Server/service/user.go b/Server/service/user.go index ab998550..4569d1b6 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "log/slog" "time" @@ -50,7 +51,7 @@ func (s *UserService) UpdateProfile(userID int64, username string, avatar *strin return user, nil } -// ChangePassword verifies the old password hash matches, then updates. +// ChangePassword updates the user's password and revokes other sessions. // Returns the number of other sessions revoked. func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (int64, error) { if err := s.st.UpdateUserPassword(userID, newPasswordHash); err != nil { @@ -77,7 +78,10 @@ func (s *UserService) ListSessions(userID int64) ([]db.Session, error) { // RevokeSession deletes a specific session owned by the user. func (s *UserService) RevokeSession(userID, sessionID int64) error { if err := s.st.DeleteSessionByID(sessionID, userID); err != nil { - return fmt.Errorf("%w: session not found", ErrNotFound) + if errors.Is(err, db.ErrNotFound) { + return fmt.Errorf("%w: session not found", ErrNotFound) + } + return fmt.Errorf("%w: failed to revoke session", ErrInternal) } _ = s.st.LogAudit(userID, "session_revoke", "session", sessionID, "session revoked") slog.Info("session revoked", "user_id", userID, "session_id", sessionID) diff --git a/Server/telemetry/telemetry_otel.go b/Server/telemetry/telemetry_otel.go index 9ed75e03..b4b78822 100644 --- a/Server/telemetry/telemetry_otel.go +++ b/Server/telemetry/telemetry_otel.go @@ -9,9 +9,10 @@ // // "none" — no-op provider; same as the default build. // "prometheus" — pull-based metrics via a /metrics handler, spans are still -// processed by a batching tracer provider with no exporter. +// processed by a tracer provider with no exporter. // "otlp" — push-based traces over OTLP/gRPC to cfg.OTLPEndpoint AND -// pull-based metrics via the Prometheus exporter. +// pull-based metrics via the Prometheus exporter (operators +// typically want both). // // The concrete Provider adapts our tiny telemetry.* API onto the upstream // OTel SDK so the rest of the codebase never depends on the SDK directly. @@ -84,10 +85,16 @@ func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) if endpoint == "" { endpoint = "localhost:4317" } - traceExp, err := otlptracegrpc.New(ctx, + otlpOpts := []otlptracegrpc.Option{ otlptracegrpc.WithEndpoint(endpoint), - otlptracegrpc.WithInsecure(), - ) + } + // OTLPInsecure honours the explicit operator opt-in for plaintext + // gRPC; production deployments should leave it false and provide + // a TLS endpoint. + if cfg.OTLPInsecure { + otlpOpts = append(otlpOpts, otlptracegrpc.WithInsecure()) + } + traceExp, err := otlptracegrpc.New(ctx, otlpOpts...) if err != nil { return nil, fmt.Errorf("telemetry: otlp trace exporter: %w", err) } @@ -159,8 +166,8 @@ func (p *otelProvider) Meter(name string) Meter { return &otelMeter{inner: p.mp.Meter(name)} } -// HTTPMiddleware wraps next with otelhttp so every REST request becomes a span -// named after its route pattern. +// HTTPMiddleware wraps next with otelhttp so every REST request becomes a +// span named after its route pattern. func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler { return otelhttp.NewHandler(next, "http.server", otelhttp.WithServerName(p.serviceName), @@ -243,9 +250,9 @@ func (g *otelGauge) Set(ctx context.Context, value float64, attrs ...Attr) { // convertAttrs maps telemetry.Attr values to attribute.KeyValue. Unknown // types are rendered via fmt.Sprint so callers never panic on exotic values. -// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields in -// OwnCord are unsigned, and routing them through the default fmt.Sprint path -// would encode them as string attributes and break metric aggregation. +// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields +// in OwnCord are unsigned, and routing them through the default fmt.Sprint +// path would encode them as string attributes and break metric aggregation. func convertAttrs(in []Attr) []attribute.KeyValue { if len(in) == 0 { return nil diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 6f3282c5..1c3f8704 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -3,7 +3,8 @@ // chat_command routes a slash command from a WS client to a registered plugin. // If no plugin owns the command, an error is returned to the sender. If the // plugin returns a Reply, it is sent only to the invoking client (ephemeral). -// If the plugin returns a Broadcast string, it is broadcast to the channel. +// If the plugin returns a Broadcast string, it is broadcast to the channel +// only after verifying the invoking client holds SEND_MESSAGES permission. package ws import ( @@ -12,10 +13,17 @@ import ( "fmt" "log/slog" "strings" + + "github.com/owncord/server/permissions" ) const MsgTypeChatCommand = "chat_command" +// maxCommandArgs is the maximum number of arguments accepted in a +// chat_command payload. This prevents a malicious client from flooding +// the plugin's allocate/dispatch ABI with thousands of strings. +const maxCommandArgs = 64 + // chatCommandPayload is the client-supplied payload for a chat_command message. type chatCommandPayload struct { ChannelID int64 `json:"channel_id"` @@ -32,6 +40,7 @@ func registerPluginCommandHandler(r *HandlerRegistry) { // hub.pluginRegistry. Returns an error to the client when: // - the payload is malformed, // - the command name is empty, +// - too many arguments are supplied, // - no plugin owns the command (unknown command), // - the plugin returns an error reply. func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) { @@ -47,6 +56,11 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p return } + if len(p.Args) > maxCommandArgs { + c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("too many command arguments (max %d)", maxCommandArgs))) + return + } + if h.pluginRegistry == nil { c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s (no plugins loaded)", cmd))) return @@ -69,6 +83,11 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } if result.Broadcast != "" && p.ChannelID != 0 { + // Verify the invoking client has permission to send to this channel + // before broadcasting the plugin result to all channel members. + if !h.requireChannelPerm(c, p.ChannelID, permissions.SendMessages, "SEND_MESSAGES") { + return + } // Channel broadcast — visible to everyone in the channel. msg := buildCommandBroadcast(p.ChannelID, c.userID, cmd, result.Broadcast) h.BroadcastToChannel(p.ChannelID, msg) diff --git a/docs/contributing.md b/docs/contributing.md index f00261bb..c65d62b9 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -25,10 +25,23 @@ How to set up the development environment and contribute to OwnCord. |---------|-------------| | `go build -o chatserver.exe -ldflags "-s -w" .` | Build server binary (Windows) | | `CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w" .` | Build server binary (Linux) | +| `go build -tags otel .` | Build with OpenTelemetry SDK (requires `go get` first — see Phase B) | +| `go build -tags wazero .` | Build with Wazero plugin runtime (requires `go get` first — see Phase C) | +| `go build -tags postgres .` | Build with PostgreSQL backend (requires pgx in go.mod) | | `go test ./...` | Run all server tests | | `go test ./... -cover` | Run server tests with coverage | | `go test -race ./...` | Run server tests with race detection | +**Make targets** (run from `Server/`): + +| Command | Description | +|---------|-------------| +| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` | +| `make sqlc-generate` | Regenerate type-safe Go for both SQLite (`db/dbgen/`) and PostgreSQL (`db/pgdbgen/`) engines | +| `make sqlc-verify` | Fail if committed `dbgen` / `pgdbgen` output is stale (used by CI) | +| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development | +| `make otel-down` | Stop and remove the OTel dev containers | + #### Client (Tauri v2) **Build & dev** diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 0e20010e..2f278f02 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -67,6 +67,44 @@ Configuration is loaded in three layers (later layers override earlier ones): |-----|------|---------|-------------| | `github.token` | string | `""` | Optional GitHub API token for higher rate limits on update checks (5000 req/hr vs 60) | +### Event Persistence (`event_persistence`) + +Controls the tiered event log used for WebSocket reconnection replay. When enabled, missed events are stored in the database so clients that reconnect after the in-memory ring buffer window (1 000 events) can still replay missed events from the DB tier. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `event_persistence.enabled` | bool | `true` | Enable cold-storage event persistence. When `false`, only the in-memory ring buffer is used (lower durability). | +| `event_persistence.retention_hours` | int | `24` | How long persisted events are kept before the pruner deletes them | +| `event_persistence.batch_size` | int | `50` | Maximum events per database flush | +| `event_persistence.batch_flush_ms` | int | `100` | Maximum delay between flushes (milliseconds) | +| `event_persistence.pruner_interval_minutes` | int | `60` | How often the pruner goroutine wakes up to delete expired events | + +### Telemetry / OpenTelemetry (`telemetry`) + +Controls the OpenTelemetry SDK. Requires building with `-tags otel` (see [Contributing](contributing.md)). When disabled, the server uses no-op tracer/meter providers; the legacy JSON `/api/v1/metrics` endpoint is always available regardless of this setting. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `telemetry.enabled` | bool | `false` | Enable the OTel SDK | +| `telemetry.exporter` | string | `"none"` | Exporter backend: `none`, `prometheus`, `otlp` | +| `telemetry.otlp_endpoint` | string | `""` | gRPC endpoint for the OTLP exporter (e.g. `localhost:4317`). Only used when `exporter: otlp`. | +| `telemetry.service_name` | string | `"owncord-server"` | OTel `service.name` resource attribute | + +> **Local development:** Run `make otel-up` (from `Server/`) to start Jaeger + Prometheus via Docker. +> Jaeger UI: `http://localhost:16686` — Prometheus UI: `http://localhost:9090` + +### Plugins (`plugins`) + +Controls the Wazero WASM plugin runtime. Requires building with `-tags wazero`. When disabled, no plugins are loaded and the plugin admin endpoints return `501 Not Implemented`. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `plugins.enabled` | bool | `false` | Enable plugin loading at startup | +| `plugins.directory` | string | `"data/plugins"` | Directory scanned for plugin packages on startup | +| `plugins.max_memory_mb` | int | `64` | Maximum WASM linear memory per plugin (megabytes) | +| `plugins.cpu_budget_ms` | int | `100` | Maximum CPU time per plugin invocation (milliseconds) | +| `plugins.http_allowlist` | string[] | `[]` | Host suffixes plugins may reach via the `host_http` capability (e.g. `["api.steampowered.com"]`). Empty = no outbound HTTP. | + ## Environment Variable Overrides Every config key can be overridden via environment variables using the prefix `OWNCORD_`. @@ -90,6 +128,14 @@ Every config key can be overridden via environment variables using the prefix `O | `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` | | `OWNCORD_VOICE_QUALITY` | `voice.quality` | | `OWNCORD_GITHUB_TOKEN` | `github.token` | +| `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` | +| `OWNCORD_EVENT_PERSISTENCE_RETENTION_HOURS` | `event_persistence.retention_hours` | +| `OWNCORD_TELEMETRY_ENABLED` | `telemetry.enabled` | +| `OWNCORD_TELEMETRY_EXPORTER` | `telemetry.exporter` | +| `OWNCORD_TELEMETRY_OTLP_ENDPOINT` | `telemetry.otlp_endpoint` | +| `OWNCORD_TELEMETRY_SERVICE_NAME` | `telemetry.service_name` | +| `OWNCORD_PLUGINS_ENABLED` | `plugins.enabled` | +| `OWNCORD_PLUGINS_DIRECTORY` | `plugins.directory` | ## Example config.yaml @@ -132,6 +178,29 @@ voice: github: token: "" # optional GitHub PAT for update check rate limits + +# Event persistence (tiered reconnect replay) +event_persistence: + enabled: true + retention_hours: 24 + batch_size: 50 + batch_flush_ms: 100 + pruner_interval_minutes: 60 + +# OpenTelemetry (requires build tag: -tags otel) +telemetry: + enabled: false + exporter: "none" # none | prometheus | otlp + otlp_endpoint: "" # e.g. "localhost:4317" for OTLP gRPC + service_name: "owncord-server" + +# Plugin runtime (requires build tag: -tags wazero) +plugins: + enabled: false + directory: "data/plugins" + max_memory_mb: 64 + cpu_budget_ms: 100 + http_allowlist: [] # host suffixes plugins may reach, e.g. ["api.steampowered.com"] ``` ## See Also