mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
merge: reconcile sister branch claude/plan-phases-b-c-bGpoS
Resolves the parallel Phase B/C work that landed on the sister branch
while this branch was in review. Both branches independently implemented
real OTel + Wazero runtimes; this merge keeps the best of each.
Conflict resolution
- Server/plugin/sandbox_wazero.go: rewritten as a hybrid. Keeps the
HEAD lifecycle (eager `platformInit` with WASI preview-1, explicit
`platformDeactivate` per-instance, runtime closed via Registry.Close)
AND adopts the sister branch's richer artefacts:
* `WithMemoryLimitPages` actually enforces `cfg.MaxMemoryMB`,
* the JSON-over-linear-memory ABI
(`allocate` / `command_dispatch(ptr,len) → (ptr,len)` /
`deallocate`),
* `listExportedCommands` auto-binds commands the plugin exports
via `list_commands` at activation time (capability-gated).
- Server/plugin/registry.go: kept the HEAD `activate()` snapshot
pattern (read `runtimePlatform` under RLock, pass into
`activateWithRuntime` as a parameter) so a concurrent Close can't
race the wazero call. Sister branch's LoadAll stale-staging cleanup
and UninstallPlugin on-disk dir removal came in via auto-merge.
- Server/telemetry/telemetry_otel.go: kept the HEAD implementation
(race-fixed AppMetrics rebind, uint64 overflow guard, idempotent
shutdown, trace-provider cleanup on prom failure) and wired in the
sister branch's `OTLPInsecure` config field for plaintext gRPC opt-in.
- Server/go.mod: accepted sister branch's `BurntSushi/toml v1.6.0`
for the new TOML manifest support.
- Client/tauri-client/vitest.config.ts: union of both globs
(`tests/**/*.test.ts`, `src/**/*.test.ts`, `src/**/*.test.tsx`).
- PHASE_BC_LOCAL_TODO.md: combined the two checkbox histories;
TOML manifest, hello.wasm fixture, and OTLPInsecure are all marked
done now.
Sister branch additions accepted via auto-merge
- Server/plugin/examples/hello/{hello.wasm,main.go}: precompiled 925
KiB TinyGo plugin with the full ABI (allocate, deallocate,
list_commands, command_dispatch, on_event).
- Server/plugin/manifest_{toml,nottoml}.go: TOML manifest parser
behind the wazero build tag, JSON fallback elsewhere.
- Server/plugin/loader.go: prefers `plugin.toml`, falls back to
`plugin.json`.
- Server/api/plugins_handler.go: structured error responses + slog.
- Server/main.go, Server/config/config.go: OTLPInsecure plumbing,
defaults polish.
- docs/{contributing.md,server-configuration.md}: documentation
updates.
Test status
- `go build` passes on default, -tags otel, -tags wazero, and
-tags otel,wazero.
- `go vet` passes on every tag combination.
- `go test ./...` passes on default and on -tags otel,wazero.
- Client: `npx tsc --noEmit` clean; vitest 3188/3188 across 112 files.
https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
+54
-45
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+11
-6
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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) {}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+25
-15
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+151
-51
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user