// The `lchat hooks` subcommand family (issue #610, docs/webhooks.md): // // - `lchat hooks run` IS the webhook-ingest sidecar (internal/hooks) — run on // the router box behind an `in.` registry route, or anywhere for dev // with -addr. // - `lchat add-hooks ` is the router-box control command that stands it // up for an instance: service-identity invite, EnvironmentFile, registry // route, systemd unit — the add-bot shape. package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "lchat/internal/bot" "lchat/internal/e2ee" "lchat/internal/hooks" ) // hooksEnvPath / hooksSessionDir: the per-instance convention, mirroring // botEnvPath — the EnvironmentFile add-hooks writes and the dir the sidecar // persists its service-identity session in. func hooksEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-hooks.env") } func hooksSessionDir(host string) string { return filepath.Join(instanceDataDir, host+"-hooks") } // hooksSocketPath is where the router reaches a host's hooks sidecar (the // in. registry route's socket). func hooksSocketPath(host string) string { return "/run/lchat/" + host + "-hooks.sock" } func runHooksCmd(args []string) { if len(args) < 1 { fmt.Fprintln(os.Stderr, `usage: lchat hooks commands: run run the webhook-ingest sidecar: accept inbound HTTP on the socket/addr, encrypt each request into a typed event, post it into its webhook's thread`) os.Exit(2) } switch args[0] { case "run": runHooksRun(args[1:]) default: die("hooks: unknown command %q", args[0]) } } func runHooksRun(args []string) { fs := flag.NewFlagSet("hooks run", flag.ExitOnError) base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)") session := fs.String("session", envOr("LCHAT_SESSION", "hooks-session.json"), "service-identity session file (env: LCHAT_SESSION)") socket := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET); how the router fronts in.") addr := fs.String("addr", envOr("ADDR", ""), "TCP listen address for local development (no router)") _ = fs.Parse(reorderFlagsFirst(fs, args)) if *base == "" { die("hooks run: set -base (or LCHAT_BASE) to your instance URL") } if *socket == "" && *addr == "" { die("hooks run: set -socket (production, behind the router) or -addr (dev)") } // Self-provision on first boot from the one-time invite add-hooks staged, // exactly like the bot sidecar (#806). The invite is env-only — a flag // would leak it into the process table. sess, err := bot.EnsureSession(strings.TrimRight(*base, "/"), *session, os.Getenv("LCHAT_INVITE"), "Webhooks", log.Printf) if err != nil { die("hooks run: %v", err) } if sess == nil { die("hooks run: no session at %s and no LCHAT_INVITE — run add-hooks (or mint an agent invite) first", *session) } sc := hooks.New(*base, sess, log.Printf) go sc.Run(context.Background(), hooks.DefaultPoll) ln, where, err := listen(*addr, *socket) if err != nil { die("hooks run: %v", err) } log.Printf("hooks run: ingesting on %s for %s (%d webhook(s) at last poll)", where, *base, sc.Routes()) srv := &http.Server{Handler: sc.Handler(), ReadHeaderTimeout: 10 * time.Second} if err := srv.Serve(ln); err != nil { die("hooks run: %v", err) } } // runAddHooks: `lchat add-hooks ` — stand up the webhook-ingest sidecar // for an instance on the router box. Mirrors add-bot: mint the service // identity's agent invite via the instance's admin API, stage the // EnvironmentFile, then (the part add-bot doesn't have) register the // `in.` registry route so the router fronts the sidecar's socket, and // start the unit. Idempotent by env-file existence. func runAddHooks(args []string) { fs := flag.NewFlagSet("add-hooks", flag.ExitOnError) registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") unit := fs.String("unit", envOr("LCHAT_HOOKS_UNIT", "lchat-hooks@"), "systemd hooks unit template prefix") _ = fs.Parse(reorderFlagsFirst(fs, args)) host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) if !validHost(host) { die("add-hooks: invalid host %q", host) } reg, err := openRegistry(*registryPath) if err != nil { die("add-hooks: open registry: %v", err) } rt, err := reg.get(host) if err != nil { die("add-hooks: %v", err) } if rt == nil || rt.Kind != "instance" { die("add-hooks: %q is not a messenger instance (run add-instance first)", host) } envPath := hooksEnvPath(host) if _, err := os.Stat(envPath); err == nil { fmt.Printf("add-hooks: %s already staged (%s) — leaving it as is\n", host, envPath) } else { env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) if err != nil { die("add-hooks: read instance env: %v", err) } admin := env["LCHAT_ADMIN_TOKEN"] if admin == "" { die("add-hooks: no LCHAT_ADMIN_TOKEN for %s", host) } invite, err := mintHooksServiceInvite(botMintBase(host), admin) if err != nil { die("add-hooks: mint service invite: %v", err) } sessionDir := hooksSessionDir(host) if err := os.MkdirAll(sessionDir, 0o750); err != nil { die("add-hooks: mkdir %s: %v", sessionDir, err) } if err := writeEnvFile(envPath, map[string]string{ "LCHAT_BASE": "https://" + host, "LCHAT_INVITE": invite, "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"), "LCHAT_SOCKET": hooksSocketPath(host), }); err != nil { die("add-hooks: write env: %v", err) } chownToService(sessionDir) chownToService(envPath) } // The ingest host: a plain registry route — the router proxies it like any // other host, and per-host autocert mints in.'s certificate on the // first request (the *.domain wildcard cert only covers one label, so this // host takes the per-host issuance path; wildcard DNS resolves it with no // zone change). A custom-domain instance needs its owner to add an // in. DNS record — see docs/webhooks.md. ingest := "in." + host if err := reg.upsert(Route{Host: ingest, Kind: "hooks", Socket: hooksSocketPath(host), Status: "ready"}); err != nil { die("add-hooks: register %s: %v", ingest, err) } if err := systemctl("enable", "--now", *unit+host); err != nil { die("add-hooks: start %s%s: %v (env staged at %s; route registered)", *unit, host, err, envPath) } fmt.Printf("added webhook ingest for %s\n", host) fmt.Printf(" ingest host: https://%s (router picks up the route within seconds)\n", ingest) fmt.Printf(" manage webhooks from the app: admins → Webhooks\n") fmt.Printf(" logs: journalctl -u %s%s -f\n", *unit, host) } // mintHooksServiceInvite mints the service identity's agent invite — // mintAgentInvite's shape with hooks_service set, so the server records which // pubkey webhook definitions seal to. Not catalog-listed: this identity is // plumbing, not a helper members talk to. func mintHooksServiceInvite(base, admin string) (string, error) { t, err := e2ee.RandomSecret() if err != nil { return "", err } inv, err := e2ee.DeriveInvite(t) if err != nil { return "", err } body, err := json.Marshal(map[string]any{ "label": "Webhooks", "id": inv.ID, "wrapped": "", // fresh identity: the sidecar generates its own keypair "claim_hash": inv.ClaimHash, "agent": true, "hooks_service": true, "description": "Incoming-webhooks relay — turns outside services' events into encrypted posts.", }) if err != nil { return "", err } req, err := http.NewRequest("POST", base+"/api/admin/key-invites", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+admin) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return "", fmt.Errorf("POST /api/admin/key-invites: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(detail))) } return t, nil }