diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index aa9b65cd..cab7542a 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -54,7 +54,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, _, err := r.FormFile("plugin") @@ -68,7 +68,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 { @@ -95,7 +95,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) @@ -111,7 +112,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) @@ -127,7 +129,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) @@ -143,7 +146,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/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/main.go b/Server/plugin/examples/hello/main.go index 061e22a0..0655826a 100644 --- a/Server/plugin/examples/hello/main.go +++ b/Server/plugin/examples/hello/main.go @@ -11,6 +11,14 @@ // 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 ( 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/registry.go b/Server/plugin/registry.go index 46d39ec8..573c35cc 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -122,6 +122,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) @@ -394,11 +407,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 @@ -425,16 +443,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/telemetry/telemetry_otel.go b/Server/telemetry/telemetry_otel.go index c48ec60b..33941948 100644 --- a/Server/telemetry/telemetry_otel.go +++ b/Server/telemetry/telemetry_otel.go @@ -75,10 +75,13 @@ func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) if cfg.OTLPEndpoint == "" { return nil, fmt.Errorf("telemetry: exporter=otlp requires otlp_endpoint to be set") } - exp, expErr := otlpgrpc.New(ctx, + otlpOpts := []otlpgrpc.Option{ otlpgrpc.WithEndpoint(cfg.OTLPEndpoint), - otlpgrpc.WithInsecure(), - ) + } + if cfg.OTLPInsecure { + otlpOpts = append(otlpOpts, otlpgrpc.WithInsecure()) + } + exp, expErr := otlpgrpc.New(ctx, otlpOpts...) if expErr != nil { return nil, fmt.Errorf("telemetry: otlp exporter: %w", expErr) } 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)