mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user