eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// `lchat try` — try Eleven in one command, from any machine, with nothing to
4// configure: it starts a full local instance (a subprocess of this same
5// binary, so the ordinary single-instance path is exercised unchanged) and
6// opens an outbound reverse tunnel to the public gateway, which hands back a
7// real https://try-<slug>.<domain> address under the fleet's wildcard cert.
8// No inbound ports, no DNS, no certificates, no root.
9//
10// `lchat tunnel serve` is the other half: the gateway service that runs on
11// the router box behind the ordinary router, publishing each live tunnel as a
12// registry route. See internal/tunnel and docs/tunnel-try.md.
13
14import (
15 "context"
16 "crypto/rand"
17 "flag"
18 "fmt"
19 "net"
20 "net/http"
21 "os"
22 "os/exec"
23 "os/signal"
24 "strings"
25 "syscall"
26 "time"
27
28 "lchat/internal/tunnel"
29)
30
31const defaultTunnelGateway = "https://tunnel.11ch.at"
32
33func runTryCmd(args []string) {
34 fs := flag.NewFlagSet("try", flag.ExitOnError)
35 gateway := fs.String("gateway", envOr("LCHAT_TUNNEL_GATEWAY", defaultTunnelGateway), "tunnel gateway origin")
36 dbPath := fs.String("db", "lchat-try.db", "where the trial space's data lives (kept across runs)")
37 local := fs.String("local", "", "tunnel an ALREADY-RUNNING local instance at this addr (e.g. 127.0.0.1:1747) instead of starting one")
38 _ = fs.Parse(reorderFlagsFirst(fs, args))
39
40 if *local != "" {
41 runTryLocal(*gateway, *local)
42 return
43 }
44
45 // A local port for the instance subprocess.
46 l, err := net.Listen("tcp", "127.0.0.1:0")
47 if err != nil {
48 die("try: %v", err)
49 }
50 localAddr := l.Addr().String()
51 l.Close()
52
53 // The subprocess is this same binary running the plain single-instance
54 // path. A fresh admin token per run authorises the setup-link mint below;
55 // on an existing db the stored token stays (ensureAdminToken ignores the
56 // env when one exists), so re-running `try` on kept data still works —
57 // we then mint via the token recorded on first run.
58 tokenFile := *dbPath + ".admin-token"
59 adminToken := strings.TrimSpace(readFileOr(tokenFile, ""))
60 fresh := !fileExists(*dbPath)
61 if adminToken == "" {
62 if adminToken, err = randSlugToken(); err != nil {
63 die("try: %v", err)
64 }
65 }
66
67 fmt.Println(" Starting your trial space…")
68 child := exec.Command(mustExecutable(), "-addr", localAddr, "-db", *dbPath)
69 child.Env = append(os.Environ(), "LCHAT_ADMIN_TOKEN="+adminToken)
70 logPath := *dbPath + ".log"
71 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
72 if err != nil {
73 die("try: %v", err)
74 }
75 child.Stdout, child.Stderr = logFile, logFile
76 if err := child.Start(); err != nil {
77 die("try: start local instance: %v", err)
78 }
79 defer func() {
80 _ = child.Process.Signal(syscall.SIGTERM)
81 _, _ = child.Process.Wait()
82 }()
83 if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 15*time.Second); err != nil {
84 die("try: the local instance didn't come up (%v) — see %s", err, logPath)
85 }
86 if fresh {
87 if err := os.WriteFile(tokenFile, []byte(adminToken+"\n"), 0o600); err != nil {
88 die("try: %v", err)
89 }
90 }
91
92 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
93 defer stop()
94
95 cl := &tunnel.Client{Gateway: *gateway, LocalAddr: localAddr, Version: buildVersion}
96 errCh := make(chan error, 1)
97 hostCh := make(chan string, 1)
98 go func() { errCh <- cl.Run(ctx, func(h string) { hostCh <- h }) }()
99
100 var host string
101 select {
102 case host = <-hostCh:
103 case err := <-errCh:
104 die("try: %v", err)
105 case <-time.After(45 * time.Second):
106 die("try: the tunnel gateway didn't answer")
107 }
108
109 origin := "https://" + host
110 fmt.Println(" Address assigned, waiting for it to go live…")
111 if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil {
112 die("try: %s %v", origin, err)
113 }
114 setupLink, err := mintSetupInvite(http.DefaultClient, "http://"+localAddr, origin, adminToken)
115 if err != nil {
116 die("try: mint setup link: %v", err)
117 }
118
119 fmt.Printf(`
120 ✔ Your space is live: %s
121 ✔ Claim it here first: %s
122
123 That second link makes you the admin — open it, pick a name, and
124 invite people straight away. Anyone you invite can join from any
125 browser while this command is running.
126
127 This is a tryout: the address works while this command runs and
128 retires when you stop it (Ctrl-C) or after 24 hours. Your space's
129 data stays here in %s — nothing leaves this machine
130 except the same encrypted traffic a hosted space would send.
131
132 Liked it? A permanent home is one small install:
133 https://elevenmessenger.com/self-host/
134
135`, origin, setupLink, *dbPath)
136
137 select {
138 case err := <-errCh:
139 if err != nil {
140 die("try: %v", err)
141 }
142 case <-ctx.Done():
143 fmt.Println(" Stopping — thanks for trying Eleven.")
144 }
145}
146
147// runTryLocal is `lchat try --local <addr>`: lend a public trial address to an
148// instance you're already running (a dev server, a home-lab box) — no
149// subprocess, no setup link; the instance's own links keep working because it
150// sees the public hostname in every proxied request's Host header.
151func runTryLocal(gateway, localAddr string) {
152 if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 5*time.Second); err != nil {
153 die("try --local: nothing answering at %s (%v)", localAddr, err)
154 }
155 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
156 defer stop()
157 cl := &tunnel.Client{Gateway: gateway, LocalAddr: localAddr, Version: buildVersion}
158 errCh := make(chan error, 1)
159 hostCh := make(chan string, 1)
160 go func() { errCh <- cl.Run(ctx, func(h string) { hostCh <- h }) }()
161 var host string
162 select {
163 case host = <-hostCh:
164 case err := <-errCh:
165 die("try --local: %v", err)
166 case <-time.After(45 * time.Second):
167 die("try --local: the tunnel gateway didn't answer")
168 }
169 origin := "https://" + host
170 if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil {
171 die("try --local: %s %v", origin, err)
172 }
173 fmt.Printf("\n ✔ %s → %s\n The address retires when you stop this (Ctrl-C) or after 24 hours.\n\n", localAddr, origin)
174 select {
175 case err := <-errCh:
176 if err != nil {
177 die("try --local: %v", err)
178 }
179 case <-ctx.Done():
180 fmt.Println(" Stopped.")
181 }
182}
183
184func runTunnelCmd(args []string) {
185 if len(args) >= 1 && args[0] == "kill" {
186 runTunnelKill(args[1:])
187 return
188 }
189 if len(args) < 1 || args[0] != "serve" {
190 die("usage: lchat tunnel serve [--socket P] [--registry P] [--host H] [--domain D]\n lchat tunnel kill <host> [--registry P] [--forget]")
191 }
192 fs := flag.NewFlagSet("tunnel serve", flag.ExitOnError)
193 socket := fs.String("socket", envOr("LCHAT_TUNNEL_SOCKET", "/run/lchat/tunnel.sock"), "unix socket to serve on (the router fronts it)")
194 registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry to publish tunnel routes in")
195 host := fs.String("host", envOr("LCHAT_TUNNEL_HOST", "tunnel.11ch.at"), "the gateway's own control hostname")
196 domain := fs.String("domain", envOr("LCHAT_TUNNEL_DOMAIN", "11ch.at"), "domain trial hosts are minted under (needs the wildcard cert)")
197 maxTunnels := fs.Int("max", 50, "maximum concurrent trial tunnels")
198 relayDomain := fs.String("relay-domain", envOr("LCHAT_TUNNEL_RELAY_DOMAIN", ""), "offer stable relay hostnames under this domain (empty = relays off; docs/relay-mode.md)")
199 claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store (with --relay-domain)")
200 maxRelays := fs.Int("max-relays", 200, "maximum claimed relay hostnames")
201 claimIdleDays := fs.Int("claim-idle-days", 45, "free a relay hostname whose owner hasn't connected for this many days")
202 _ = fs.Parse(reorderFlagsFirst(fs, args))
203
204 reg, err := openRegistry(*registry)
205 if err != nil {
206 die("tunnel serve: open registry: %v", err)
207 }
208 // Any try- routes pointing at our socket are leftovers from a previous
209 // gateway process — their tunnels died with it.
210 if err := reg.deleteTunnelRoutes(*socket); err != nil {
211 die("tunnel serve: clean stale routes: %v", err)
212 }
213
214 gw := &tunnel.Gateway{
215 ControlHost: *host,
216 Domain: *domain,
217 MaxTunnels: *maxTunnels,
218 MaxRelays: *maxRelays,
219 OnRegister: func(h string) error {
220 if existing, err := reg.get(h); err != nil {
221 return err
222 } else if existing != nil && existing.Socket != *socket {
223 // Never steal a hostname something else answers for. Our own
224 // rows are fine to re-assert: a relay reattaching within its
225 // grace window re-registers a route that never went away.
226 return fmt.Errorf("host %s already routed elsewhere", h)
227 }
228 // The router's registry watcher (router.go) notices the write and
229 // reloads within a couple of seconds — no signal needed from here.
230 return reg.upsert(Route{Host: h, Kind: "service", Socket: *socket, Status: "ready"})
231 },
232 OnClose: func(h string) {
233 if err := reg.delete(h); err != nil {
234 fmt.Fprintf(os.Stderr, "tunnel serve: remove route %s: %v\n", h, err)
235 }
236 },
237 }
238
239 if *relayDomain != "" {
240 claims, err := tunnel.OpenClaims(*claimsPath)
241 if err != nil {
242 die("tunnel serve: open claims: %v", err)
243 }
244 gw.RelayDomain = *relayDomain
245 gw.ClaimsDB = claims
246 gw.ClaimIdle = time.Duration(*claimIdleDays) * 24 * time.Hour
247 gw.StartClaimJanitor()
248 }
249
250 _ = os.Remove(*socket)
251 ln, err := net.Listen("unix", *socket)
252 if err != nil {
253 die("tunnel serve: %v", err)
254 }
255 _ = os.Chmod(*socket, 0o660)
256 fmt.Printf("tunnel gateway: %s (control %s, minting try-*.%s)\n", *socket, *host, *domain)
257 srv := &http.Server{Handler: gw, ReadHeaderTimeout: 10 * time.Second}
258 if err := srv.Serve(ln); err != nil {
259 die("tunnel serve: %v", err)
260 }
261}
262
263// runTunnelKill is the operator kill-switch for one tunnel host: removing its
264// registry row makes the router stop serving it within a couple of seconds.
265// (Restarting lchat-tunnel additionally drops every live tunnel connection.)
266// --forget additionally deletes a relay's claim, so the hostname returns to
267// the pool and the keypair that held it starts fresh next time it connects.
268func runTunnelKill(args []string) {
269 fs := flag.NewFlagSet("tunnel kill", flag.ExitOnError)
270 registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry")
271 claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store")
272 forget := fs.Bool("forget", false, "also delete the relay claim (frees the hostname permanently)")
273 _ = fs.Parse(reorderFlagsFirst(fs, args))
274 host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
275 if !strings.HasPrefix(host, "try-") && !strings.HasPrefix(host, "r-") {
276 die("tunnel kill: %q isn't a tunnel host (try-… or r-…)", host)
277 }
278 reg, err := openRegistry(*registry)
279 if err != nil {
280 die("tunnel kill: %v", err)
281 }
282 if err := reg.delete(host); err != nil {
283 die("tunnel kill: %v", err)
284 }
285 fmt.Printf("removed %s — the router drops it within a couple of seconds.\n", host)
286 if *forget {
287 claims, err := tunnel.OpenClaims(*claimsPath)
288 if err != nil {
289 die("tunnel kill: open claims: %v", err)
290 }
291 if err := claims.DeleteByHost(host); err != nil {
292 die("tunnel kill: forget claim: %v", err)
293 }
294 fmt.Printf("forgot the claim on %s — the hostname is back in the pool.\n", host)
295 }
296 fmt.Println("(to also cut the live tunnel connection: systemctl restart lchat-tunnel)")
297}
298
299// --- small helpers -----------------------------------------------------
300
301func mustExecutable() string {
302 exe, err := os.Executable()
303 if err != nil {
304 die("try: %v", err)
305 }
306 return exe
307}
308
309func readFileOr(path, def string) string {
310 b, err := os.ReadFile(path)
311 if err != nil {
312 return def
313 }
314 return string(b)
315}
316
317func fileExists(path string) bool {
318 _, err := os.Stat(path)
319 return err == nil
320}
321
322func randSlugToken() (string, error) {
323 // 32 hex chars, matching the admin tokens add-instance mints.
324 var b [16]byte
325 if _, err := rand.Read(b[:]); err != nil {
326 return "", err
327 }
328 return fmt.Sprintf("%x", b), nil
329}
330
331// deleteTunnelRoutes removes every try- service route pointing at socket —
332// the gateway's own rows from a previous life. Scoped tightly so a registry
333// with a real host that merely starts with "try-" but lives elsewhere is
334// never touched.
335func (r *Registry) deleteTunnelRoutes(socket string) error {
336 _, err := r.sql.Exec(
337 `DELETE FROM routes WHERE kind = 'service' AND socket = ?
338 AND (host LIKE 'try-%' OR host LIKE 'r-%')`, socket)
339 return err
340}
341