| 1 | // The `lchat hooks` subcommand family (issue #610, docs/webhooks.md): |
| 2 | // |
| 3 | // - `lchat hooks run` IS the webhook-ingest sidecar (internal/hooks) — run on |
| 4 | // the router box behind an `in.<host>` registry route, or anywhere for dev |
| 5 | // with -addr. |
| 6 | // - `lchat add-hooks <host>` is the router-box control command that stands it |
| 7 | // up for an instance: service-identity invite, EnvironmentFile, registry |
| 8 | // route, systemd unit — the add-bot shape. |
| 9 | package main |
| 10 | |
| 11 | import ( |
| 12 | "bytes" |
| 13 | "context" |
| 14 | "encoding/json" |
| 15 | "flag" |
| 16 | "fmt" |
| 17 | "io" |
| 18 | "log" |
| 19 | "net/http" |
| 20 | "os" |
| 21 | "path/filepath" |
| 22 | "strings" |
| 23 | "time" |
| 24 | |
| 25 | "lchat/internal/bot" |
| 26 | "lchat/internal/e2ee" |
| 27 | "lchat/internal/hooks" |
| 28 | ) |
| 29 | |
| 30 | // hooksEnvPath / hooksSessionDir: the per-instance convention, mirroring |
| 31 | // botEnvPath — the EnvironmentFile add-hooks writes and the dir the sidecar |
| 32 | // persists its service-identity session in. |
| 33 | func hooksEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-hooks.env") } |
| 34 | func hooksSessionDir(host string) string { return filepath.Join(instanceDataDir, host+"-hooks") } |
| 35 | |
| 36 | // hooksSocketPath is where the router reaches a host's hooks sidecar (the |
| 37 | // in.<host> registry route's socket). |
| 38 | func hooksSocketPath(host string) string { return "/run/lchat/" + host + "-hooks.sock" } |
| 39 | |
| 40 | func runHooksCmd(args []string) { |
| 41 | if len(args) < 1 { |
| 42 | fmt.Fprintln(os.Stderr, `usage: lchat hooks <command> |
| 43 | |
| 44 | commands: |
| 45 | run run the webhook-ingest sidecar: accept inbound HTTP on the socket/addr, |
| 46 | encrypt each request into a typed event, post it into its webhook's thread`) |
| 47 | os.Exit(2) |
| 48 | } |
| 49 | switch args[0] { |
| 50 | case "run": |
| 51 | runHooksRun(args[1:]) |
| 52 | default: |
| 53 | die("hooks: unknown command %q", args[0]) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func runHooksRun(args []string) { |
| 58 | fs := flag.NewFlagSet("hooks run", flag.ExitOnError) |
| 59 | base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)") |
| 60 | session := fs.String("session", envOr("LCHAT_SESSION", "hooks-session.json"), "service-identity session file (env: LCHAT_SESSION)") |
| 61 | socket := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET); how the router fronts in.<host>") |
| 62 | addr := fs.String("addr", envOr("ADDR", ""), "TCP listen address for local development (no router)") |
| 63 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 64 | |
| 65 | if *base == "" { |
| 66 | die("hooks run: set -base (or LCHAT_BASE) to your instance URL") |
| 67 | } |
| 68 | if *socket == "" && *addr == "" { |
| 69 | die("hooks run: set -socket (production, behind the router) or -addr (dev)") |
| 70 | } |
| 71 | // Self-provision on first boot from the one-time invite add-hooks staged, |
| 72 | // exactly like the bot sidecar (#806). The invite is env-only — a flag |
| 73 | // would leak it into the process table. |
| 74 | sess, err := bot.EnsureSession(strings.TrimRight(*base, "/"), *session, os.Getenv("LCHAT_INVITE"), "Webhooks", log.Printf) |
| 75 | if err != nil { |
| 76 | die("hooks run: %v", err) |
| 77 | } |
| 78 | if sess == nil { |
| 79 | die("hooks run: no session at %s and no LCHAT_INVITE — run add-hooks (or mint an agent invite) first", *session) |
| 80 | } |
| 81 | |
| 82 | sc := hooks.New(*base, sess, log.Printf) |
| 83 | go sc.Run(context.Background(), hooks.DefaultPoll) |
| 84 | |
| 85 | ln, where, err := listen(*addr, *socket) |
| 86 | if err != nil { |
| 87 | die("hooks run: %v", err) |
| 88 | } |
| 89 | log.Printf("hooks run: ingesting on %s for %s (%d webhook(s) at last poll)", where, *base, sc.Routes()) |
| 90 | srv := &http.Server{Handler: sc.Handler(), ReadHeaderTimeout: 10 * time.Second} |
| 91 | if err := srv.Serve(ln); err != nil { |
| 92 | die("hooks run: %v", err) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // runAddHooks: `lchat add-hooks <host>` — stand up the webhook-ingest sidecar |
| 97 | // for an instance on the router box. Mirrors add-bot: mint the service |
| 98 | // identity's agent invite via the instance's admin API, stage the |
| 99 | // EnvironmentFile, then (the part add-bot doesn't have) register the |
| 100 | // `in.<host>` registry route so the router fronts the sidecar's socket, and |
| 101 | // start the unit. Idempotent by env-file existence. |
| 102 | func runAddHooks(args []string) { |
| 103 | fs := flag.NewFlagSet("add-hooks", flag.ExitOnError) |
| 104 | registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path") |
| 105 | unit := fs.String("unit", envOr("LCHAT_HOOKS_UNIT", "lchat-hooks@"), "systemd hooks unit template prefix") |
| 106 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 107 | |
| 108 | host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| 109 | if !validHost(host) { |
| 110 | die("add-hooks: invalid host %q", host) |
| 111 | } |
| 112 | reg, err := openRegistry(*registryPath) |
| 113 | if err != nil { |
| 114 | die("add-hooks: open registry: %v", err) |
| 115 | } |
| 116 | rt, err := reg.get(host) |
| 117 | if err != nil { |
| 118 | die("add-hooks: %v", err) |
| 119 | } |
| 120 | if rt == nil || rt.Kind != "instance" { |
| 121 | die("add-hooks: %q is not a messenger instance (run add-instance first)", host) |
| 122 | } |
| 123 | |
| 124 | envPath := hooksEnvPath(host) |
| 125 | if _, err := os.Stat(envPath); err == nil { |
| 126 | fmt.Printf("add-hooks: %s already staged (%s) — leaving it as is\n", host, envPath) |
| 127 | } else { |
| 128 | env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) |
| 129 | if err != nil { |
| 130 | die("add-hooks: read instance env: %v", err) |
| 131 | } |
| 132 | admin := env["LCHAT_ADMIN_TOKEN"] |
| 133 | if admin == "" { |
| 134 | die("add-hooks: no LCHAT_ADMIN_TOKEN for %s", host) |
| 135 | } |
| 136 | invite, err := mintHooksServiceInvite(botMintBase(host), admin) |
| 137 | if err != nil { |
| 138 | die("add-hooks: mint service invite: %v", err) |
| 139 | } |
| 140 | sessionDir := hooksSessionDir(host) |
| 141 | if err := os.MkdirAll(sessionDir, 0o750); err != nil { |
| 142 | die("add-hooks: mkdir %s: %v", sessionDir, err) |
| 143 | } |
| 144 | if err := writeEnvFile(envPath, map[string]string{ |
| 145 | "LCHAT_BASE": "https://" + host, |
| 146 | "LCHAT_INVITE": invite, |
| 147 | "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"), |
| 148 | "LCHAT_SOCKET": hooksSocketPath(host), |
| 149 | }); err != nil { |
| 150 | die("add-hooks: write env: %v", err) |
| 151 | } |
| 152 | chownToService(sessionDir) |
| 153 | chownToService(envPath) |
| 154 | } |
| 155 | |
| 156 | // The ingest host: a plain registry route — the router proxies it like any |
| 157 | // other host, and per-host autocert mints in.<host>'s certificate on the |
| 158 | // first request (the *.domain wildcard cert only covers one label, so this |
| 159 | // host takes the per-host issuance path; wildcard DNS resolves it with no |
| 160 | // zone change). A custom-domain instance needs its owner to add an |
| 161 | // in.<host> DNS record — see docs/webhooks.md. |
| 162 | ingest := "in." + host |
| 163 | if err := reg.upsert(Route{Host: ingest, Kind: "hooks", Socket: hooksSocketPath(host), Status: "ready"}); err != nil { |
| 164 | die("add-hooks: register %s: %v", ingest, err) |
| 165 | } |
| 166 | |
| 167 | if err := systemctl("enable", "--now", *unit+host); err != nil { |
| 168 | die("add-hooks: start %s%s: %v (env staged at %s; route registered)", *unit, host, err, envPath) |
| 169 | } |
| 170 | fmt.Printf("added webhook ingest for %s\n", host) |
| 171 | fmt.Printf(" ingest host: https://%s (router picks up the route within seconds)\n", ingest) |
| 172 | fmt.Printf(" manage webhooks from the app: admins → Webhooks\n") |
| 173 | fmt.Printf(" logs: journalctl -u %s%s -f\n", *unit, host) |
| 174 | } |
| 175 | |
| 176 | // mintHooksServiceInvite mints the service identity's agent invite — |
| 177 | // mintAgentInvite's shape with hooks_service set, so the server records which |
| 178 | // pubkey webhook definitions seal to. Not catalog-listed: this identity is |
| 179 | // plumbing, not a helper members talk to. |
| 180 | func mintHooksServiceInvite(base, admin string) (string, error) { |
| 181 | t, err := e2ee.RandomSecret() |
| 182 | if err != nil { |
| 183 | return "", err |
| 184 | } |
| 185 | inv, err := e2ee.DeriveInvite(t) |
| 186 | if err != nil { |
| 187 | return "", err |
| 188 | } |
| 189 | body, err := json.Marshal(map[string]any{ |
| 190 | "label": "Webhooks", |
| 191 | "id": inv.ID, |
| 192 | "wrapped": "", // fresh identity: the sidecar generates its own keypair |
| 193 | "claim_hash": inv.ClaimHash, |
| 194 | "agent": true, |
| 195 | "hooks_service": true, |
| 196 | "description": "Incoming-webhooks relay — turns outside services' events into encrypted posts.", |
| 197 | }) |
| 198 | if err != nil { |
| 199 | return "", err |
| 200 | } |
| 201 | req, err := http.NewRequest("POST", base+"/api/admin/key-invites", bytes.NewReader(body)) |
| 202 | if err != nil { |
| 203 | return "", err |
| 204 | } |
| 205 | req.Header.Set("Content-Type", "application/json") |
| 206 | req.Header.Set("Authorization", "Bearer "+admin) |
| 207 | client := &http.Client{Timeout: 30 * time.Second} |
| 208 | resp, err := client.Do(req) |
| 209 | if err != nil { |
| 210 | return "", err |
| 211 | } |
| 212 | defer resp.Body.Close() |
| 213 | if resp.StatusCode != http.StatusOK { |
| 214 | detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| 215 | return "", fmt.Errorf("POST /api/admin/key-invites: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(detail))) |
| 216 | } |
| 217 | return t, nil |
| 218 | } |
| 219 | |