1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
|
// webchat is a minimal end-to-end-encrypted Matrix chat webapp.
//
// It reuses gomuks' pkg/hicli (github.com/gomuks/gomuks) as a library for all
// the hard parts — /sync, olm/megolm, cross-signing, key backup, SQLite event
// cache — and puts a deliberately tiny HTML/SSE frontend on top instead of
// gomuks' React SPA. There is no auth layer: webchat is meant to be bound to a
// tailscale address, which is the trust boundary (see nixos-module.nix).
//
// Note on licensing: gomuks as a whole is AGPL-3.0, but pkg/hicli/** carries
// its own MPL-2.0 licence (see pkg/hicli/LICENSE and the per-file headers).
// webchat only imports go.mau.fi/gomuks/pkg/hicli/..., so MPL-2.0 applies.
//
// IMPORTANT — this package MUST be built with `-tags "fts5 goolm"`:
//
// - fts5: pkg/hicli/nofts.go deliberately fails to compile unless one of
// the FTS5 tags is set; migration 21 creates an fts5 virtual table.
// - goolm: without it, mautrix/crypto pulls in crypto/libolm, which is cgo
// (`#cgo LDFLAGS: -lolm -lstdc++`). The goolm tag selects mautrix's pure-Go
// olm implementation instead, so the whole binary builds with CGO_ENABLED=0.
//
// Usage:
//
// webchat login [--db path] # one-time, interactive
// webchat serve [--addr host:port] [--db path]
//
// serve exposes every joined room: / lists them, /room/{id} opens one. No room
// has to be named upfront, because hicli syncs the whole account regardless.
package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"golang.org/x/term"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
// Pure-Go SQLite. Note that we deliberately do NOT use
// go.mau.fi/util/dbutil/litestream (which gomuks itself uses for the
// "sqlite3-fk-wal" driver): that package is `//go:build cgo` only, so with
// CGO_ENABLED=0 it compiles to an empty package and registers no driver at
// all. We register modernc's "sqlite" driver and set the equivalent pragmas
// via the DSN instead (see openDB).
_ "modernc.org/sqlite"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/jsoncmd"
)
// deviceDisplayName is what other Matrix clients show for this session in the
// device list.
const deviceDisplayName = "webchat"
// dsnParams mirrors what gomuks' "sqlite3-fk-wal" driver does in its
// ConnectHook, expressed as modernc DSN parameters:
//
// foreign_keys(1) hicli's schema relies on ON DELETE CASCADE
// journal_mode(WAL) concurrent reader (HTTP) + writer (syncer)
// busy_timeout(5000) see the note on busy retries below
// _txlock=immediate take the write lock upfront, avoids SQLITE_BUSY on upgrade
//
// NOTE: hicli has an internal retry loop for "database is busy" errors, but the
// predicate it uses (`isDatabaseBusyError`) is only populated in cgo and wasm
// builds and is an unexported package var we cannot set. In a pure-Go build it
// always returns false, so that retry loop is inert. busy_timeout + immediate
// transactions cover us instead; this is acceptable for a single-user client
// where there is exactly one writer.
const dsnParams = "_txlock=immediate" +
"&_pragma=foreign_keys(1)" +
"&_pragma=journal_mode(WAL)" +
"&_pragma=busy_timeout(5000)"
func defaultDBPath() string {
if dir := os.Getenv("STATE_DIRECTORY"); dir != "" {
return filepath.Join(dir, "webchat.db")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", "webchat", "webchat.db")
}
// pickleKey returns the key used to encrypt olm state at rest in the crypto
// store, generating and persisting one on first use.
//
// gomuks hardcodes []byte("meow") here; since we get to choose, we use a real
// random key stored next to the database with 0600 permissions. It must stay
// stable for the lifetime of the DB — losing it invalidates the olm account.
func pickleKey(dbPath string) []byte {
path := filepath.Join(filepath.Dir(dbPath), "pickle-key")
if b, err := os.ReadFile(path); err == nil {
if key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(b))); err == nil && len(key) > 0 {
return key
}
log.Panicf("pickle key %q exists but is unreadable/corrupt; refusing to generate a new one", path)
} else if !errors.Is(err, os.ErrNotExist) {
log.Panicf("read pickle key %q: %v", path, err)
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
log.Panicf("generate pickle key: %v", err)
}
enc := base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(path, []byte(enc+"\n"), 0o600); err != nil {
log.Panicf("write pickle key %q: %v", path, err)
}
log.Printf("generated new crypto pickle key at %s", path)
return key
}
// openDB opens the hicli database with the pure-Go driver.
func openDB(path string, log zerolog.Logger) *dbutil.Database {
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
logPanic("create db dir %q: %v", dir, err)
}
}
db, err := dbutil.NewFromConfig("webchat", dbutil.Config{
PoolConfig: dbutil.PoolConfig{
// "sqlite" is modernc's driver name; dbutil.ParseDialect maps any
// "sqlite*" prefix to its SQLite dialect.
Type: "sqlite",
URI: fmt.Sprintf("file:%s?%s", path, dsnParams),
// hicli runs the syncer and HTTP handlers concurrently.
MaxOpenConns: 5,
MaxIdleConns: 2,
},
}, dbutil.ZeroLogger(log.With().Str("db_section", "main").Logger()))
if err != nil {
logPanic("open db %q: %v", path, err)
}
return db
}
// logPanic is for unexpected/programming errors, where a stack trace helps.
func logPanic(format string, args ...any) {
log.Panicf(format, args...)
}
// fatalf is for expected user-facing errors (not logged in, bad room ID, wrong
// password, ...): print a plain message and exit non-zero, no stack trace.
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
// newClient builds a HiClient against the given database. evtHandler receives
// hicli's sync push events (*jsoncmd.SyncComplete, *jsoncmd.EventsDecrypted,
// ...); pass a no-op for one-shot commands like login.
func newClient(dbPath string, logger zerolog.Logger, evtHandler func(any)) *hicli.HiClient {
hicli.InitialDeviceDisplayName = deviceDisplayName
rawDB := openDB(dbPath, logger)
if evtHandler == nil {
evtHandler = func(any) {}
}
return hicli.New(rawDB, nil, logger, pickleKey(dbPath), evtHandler)
}
func newLogger(debug bool) zerolog.Logger {
level := zerolog.InfoLevel
if debug {
level = zerolog.DebugLevel
}
return zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).
Level(level).With().Timestamp().Logger()
}
// startClient opens the DB and resumes the stored session. It returns an error
// if no account is logged in yet, since every command except `login` needs one.
func startClient(ctx context.Context, dbPath string, logger zerolog.Logger, evtHandler func(any)) (*hicli.HiClient, error) {
cli := newClient(dbPath, logger, evtHandler)
userID, err := cli.DB.Account.GetFirstUserID(ctx)
if err != nil {
return nil, fmt.Errorf("look up stored account: %w", err)
}
if err := cli.Start(ctx, userID, nil); err != nil {
return nil, fmt.Errorf("start client: %w", err)
}
if !cli.IsLoggedIn() {
return nil, fmt.Errorf("not logged in yet — run `webchat login` first")
}
return cli, nil
}
// ---------------------------------------------------------------------------
// login
// ---------------------------------------------------------------------------
func prompt(reader *bufio.Reader, question string) string {
fmt.Fprint(os.Stderr, question)
line, err := reader.ReadString('\n')
if err != nil {
logPanic("read input: %v", err)
}
return strings.TrimSpace(line)
}
// promptSecret reads a secret without echoing it. If envVar is set in the
// environment it is used instead of prompting, so the login can be driven from
// a password manager without the secret ever appearing in argv:
//
// WEBCHAT_PASSWORD=$(pass show .../matrix/pass) \
// WEBCHAT_RECOVERY_KEY=$(pass show .../security-key) \
// webchat login --user @you:augsburg.one
//
// It also falls back to reading a line from stdin when stdin is not a terminal,
// so the command works under a pipe.
func promptSecret(reader *bufio.Reader, envVar, question string) string {
if v, ok := os.LookupEnv(envVar); ok && v != "" {
return strings.TrimSpace(v)
}
if !term.IsTerminal(int(syscall.Stdin)) {
return prompt(reader, question)
}
fmt.Fprint(os.Stderr, question)
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Fprintln(os.Stderr)
if err != nil {
logPanic("read secret: %v", err)
}
return strings.TrimSpace(string(b))
}
// cmdLogin performs the one-time interactive bootstrap: password login plus
// cross-signing verification via the recovery key, so this device can decrypt
// history and is trusted by your other devices. Everything it obtains
// (access token, olm account, cross-signing keys) is persisted in the DB, so
// `serve` never needs credentials.
func cmdLogin(args []string) {
fs := flag.NewFlagSet("login", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath(), "path to the webchat database")
user := fs.String("user", "", "Matrix user ID, e.g. '@you:augsburg.one' (prompted if unset)")
homeserver := fs.String("homeserver", "", "homeserver base URL; default: .well-known discovery, else https://<server-name>")
debug := fs.Bool("debug", false, "verbose logging")
fs.Parse(args)
logger := newLogger(*debug)
ctx := logger.WithContext(context.Background())
cli := newClient(*dbPath, logger, nil)
userID, err := cli.DB.Account.GetFirstUserID(ctx)
if err != nil {
logPanic("look up stored account: %v", err)
}
if err := cli.Start(ctx, userID, nil); err != nil {
logPanic("start client: %v", err)
}
// NB: v0.2607.0 has no IsLoggedInAndVerified() helper (added upstream after
// the release), so check the VerificationState field directly.
if cli.IsLoggedIn() && cli.VerificationState.IsVerified {
fmt.Fprintf(os.Stderr, "already logged in and verified as %s\n", cli.Account.UserID)
stopClient(cli)
return
}
reader := bufio.NewReader(os.Stdin)
rawUserID := *user
if rawUserID == "" {
rawUserID = prompt(reader, "Matrix user ID (e.g. @you:augsburg.one): ")
}
parsed := id.UserID(rawUserID)
_, serverName, err := parsed.Parse()
if err != nil {
fatalf("invalid user ID %q: %v", rawUserID, err)
}
// Resolve the homeserver URL. Well-known discovery is best-effort: servers
// that don't publish /.well-known/matrix/client make DiscoverClientAPI
// return (nil, nil) — not an error — so a nil check is required here.
// augsburg.one is one such server (it 404s), hence the https://<server>
// fallback and the --homeserver escape hatch for delegated setups.
homeserverURL := *homeserver
if homeserverURL == "" {
discovery, err := mautrix.DiscoverClientAPI(ctx, serverName)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: .well-known lookup for %q failed: %v\n", serverName, err)
}
if discovery != nil && discovery.Homeserver.BaseURL != "" {
homeserverURL = discovery.Homeserver.BaseURL
} else {
homeserverURL = "https://" + serverName
fmt.Fprintf(os.Stderr,
"no .well-known delegation for %q, assuming %s (override with --homeserver)\n",
serverName, homeserverURL)
}
}
fmt.Fprintf(os.Stderr, "using homeserver %s\n", homeserverURL)
password := promptSecret(reader, "WEBCHAT_PASSWORD", "Password: ")
if _, set := os.LookupEnv("WEBCHAT_RECOVERY_KEY"); !set {
fmt.Fprintln(os.Stderr,
"Recovery key (a.k.a. security key/phrase) — needed to verify this device\n"+
"and decrypt existing history:")
}
recoveryKey := promptSecret(reader, "WEBCHAT_RECOVERY_KEY", "Recovery key: ")
if err := cli.LoginAndVerify(ctx, homeserverURL, parsed.String(), password, recoveryKey); err != nil {
fatalf("login and verify failed: %v", err)
}
fmt.Fprintf(os.Stderr, "logged in and verified as %s (device %s)\n",
cli.Account.UserID, cli.Account.DeviceID)
stopClient(cli)
fmt.Fprintln(os.Stderr, "run `webchat serve` and pick a room from the list it serves")
}
// stopClient shuts the client down cleanly. A successful login leaves hicli
// syncing in the background (LoginAndVerify kicks off Sync), and several of
// those goroutines — the key request queue, key backup upload — touch the
// database on startup. Closing the DB immediately makes them log spurious
// "sql: database is closed" errors, so give them a moment to settle first.
func stopClient(cli *hicli.HiClient) {
time.Sleep(500 * time.Millisecond)
cli.Stop()
}
// ---------------------------------------------------------------------------
// import-keys
// ---------------------------------------------------------------------------
// cmdImportKeys imports a megolm key export produced by another client
// (Element: Settings → Encryption → Export keys) into the crypto store.
//
// This is the recovery path for history that predates this device and is not in
// the server-side key backup. hicli asks other devices to forward missing
// sessions, but that only works if such a device is online and still holds
// them; an export file works offline and covers every room at once.
func cmdImportKeys(args []string) {
fs := flag.NewFlagSet("import-keys", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath(), "path to the webchat database")
file := fs.String("file", "", "path to the exported keys file (required)")
debug := fs.Bool("debug", false, "verbose logging")
fs.Parse(args)
if *file == "" {
fatalf("--file is required (export from Element: Settings → Encryption → Export keys)")
}
data, err := os.ReadFile(*file)
if err != nil {
fatalf("read key export %q: %v", *file, err)
}
logger := newLogger(*debug)
ctx := logger.WithContext(context.Background())
cli, err := startClient(ctx, *dbPath, logger, nil)
if err != nil {
fatalf("%v", err)
}
defer stopClient(cli)
reader := bufio.NewReader(os.Stdin)
passphrase := promptSecret(reader, "WEBCHAT_KEY_PASSPHRASE",
"Passphrase used when exporting the keys: ")
imported, total, err := cli.Crypto.ImportKeys(ctx, passphrase, data)
if err != nil {
fatalf("import keys: %v", err)
}
fmt.Fprintf(os.Stderr, "imported %d of %d sessions\n", imported, total)
if imported < total {
fmt.Fprintln(os.Stderr,
"(sessions already known, or superseded by a better one, are skipped)")
}
}
// ---------------------------------------------------------------------------
// rooms (debugging helper; `serve` shows the same list in the browser)
// ---------------------------------------------------------------------------
// cmdRooms lists joined rooms. `serve` renders this same list as its landing
// page, so this is for checking from the shell what the sync has picked up —
// useful when the room list comes up empty. It needs a completed sync to show
// anything useful, so it waits for one.
func cmdRooms(args []string) {
fs := flag.NewFlagSet("rooms", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath(), "path to the webchat database")
timeout := fs.Duration("timeout", 60*time.Second, "how long to wait for the initial sync")
debug := fs.Bool("debug", false, "verbose logging")
fs.Parse(args)
logger := newLogger(*debug)
ctx := logger.WithContext(context.Background())
// Wait for a *completed* sync, not merely the first event: hicli emits a
// ClientState/SyncStatus event immediately on Start (before any room data
// has been fetched), so waiting for "any event" would race the initial
// /sync and print an empty list. SyncStatusOK means a sync round finished.
synced := make(chan struct{}, 1)
cli, err := startClient(ctx, *dbPath, logger, func(evt any) {
if st, ok := evt.(*jsoncmd.SyncStatus); ok && st.Type == jsoncmd.SyncStatusOK {
select {
case synced <- struct{}{}:
default:
}
}
})
if err != nil {
fatalf("%v", err)
}
defer stopClient(cli)
select {
case <-synced:
case <-time.After(*timeout):
fmt.Fprintf(os.Stderr,
"warning: no completed sync after %s, listing what is cached locally\n", *timeout)
}
rooms, err := listRooms(ctx, cli)
if err != nil {
logPanic("list rooms: %v", err)
}
for _, r := range rooms {
fmt.Printf("%s\t%s\n", r.ID, r.Name)
}
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
func usage() {
fmt.Fprintf(os.Stderr, `usage: webchat <command> [args]
commands:
login [--user '@you:hs'] [--db path] one-time login + E2EE verification
(reads $WEBCHAT_PASSWORD / $WEBCHAT_RECOVERY_KEY if set, else prompts)
rooms [--db path] list joined rooms
import-keys --file <export.txt> [--db path] import megolm keys exported from another client
(reads $WEBCHAT_KEY_PASSPHRASE if set, else prompts)
serve [--addr host:port] [--db path] serve all joined rooms
`)
}
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
switch os.Args[1] {
case "login":
cmdLogin(os.Args[2:])
case "rooms":
cmdRooms(os.Args[2:])
case "import-keys":
cmdImportKeys(os.Args[2:])
case "serve":
cmdServe(os.Args[2:])
case "-h", "--help", "help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
usage()
os.Exit(1)
}
}
|