eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
9package main
10
11import (
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.
33func hooksEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-hooks.env") }
34func 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).
38func hooksSocketPath(host string) string { return "/run/lchat/" + host + "-hooks.sock" }
39
40func runHooksCmd(args []string) {
41 if len(args) < 1 {
42 fmt.Fprintln(os.Stderr, `usage: lchat hooks <command>
43
44commands:
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
57func 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.
102func 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.
180func 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