Merge branch 'claude/plan-phases-b-c-bGpoS' of https://github.com/J3vb/OwnCord into claude/plan-phases-b-c-bGpoS

This commit is contained in:
J3vb
2026-04-07 08:00:20 +02:00
8 changed files with 106 additions and 23 deletions
+10 -6
View File
@@ -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)
+5
View File
@@ -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
+11 -6
View File
@@ -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)
+8
View File
@@ -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 (
+3 -4
View File
@@ -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
}
+43 -3
View File
@@ -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
}
+6 -3
View File
@@ -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)
}
+20 -1
View File
@@ -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)