diff --git a/.env.example b/.env.example index ef3908a..9f64144 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,27 @@ # ---- Matrix Connection ---- -# GogoBee authenticates as an appservice (MAS-durable). AS_TOKEN is the -# as_token from registration.yaml — a Synapse-level credential that never -# expires and is unaffected by Matrix Authentication Service. See -# registration.yaml.example for the homeserver-side setup. +# GogoBee authenticates via the MAS (Matrix Authentication Service) OAuth 2.0 +# device grant. No password/token goes here: on first run the bot prints a URL +# + code to approve ONCE in a browser (logged in as BOT_USER_ID), then stores a +# refresh token in DATA_DIR/mas_auth.json and refreshes silently thereafter. +# To force re-authorization, delete data/mas_auth.json. HOMESERVER_URL=https://matrix.example.com BOT_USER_ID=@gogobee:example.com -AS_TOKEN=your_appservice_as_token_here BOT_DISPLAY_NAME=GogoBee +# ---- Auth mode ---- +# masdevice (default) — MAS OAuth 2.0 device grant over /sync (above). +# appservice — Matrix appservice: the registration's as_token is the +# credential (no login/MFA/expiry, MAS-durable). The bot +# runs an HTTP listener and receives events over Synapse's +# transaction push instead of /sync (Synapse forbids AS +# users from /sync). E2EE rides MSC3202/MSC2409 + MSC4190. +AUTH_MODE=masdevice +# The following are required only when AUTH_MODE=appservice: +AS_REGISTRATION= # path to the appservice registration YAML (as_token/hs_token/namespaces) +AS_LISTEN_HOST= # bind address for the transaction listener (e.g. 0.0.0.0 or a tailnet IP) +AS_LISTEN_PORT= # bind port; Synapse's registration url must reach host:port +HOMESERVER_DOMAIN= # server_name, e.g. example.com (derives the bot MXID from sender_localpart) + # Which rooms the bot posts scheduled content to (comma-separated room IDs) BROADCAST_ROOMS=!roomid:example.com diff --git a/README.md b/README.md index 4c6c112..4ceeb49 100644 --- a/README.md +++ b/README.md @@ -91,14 +91,14 @@ Everything is configured through environment variables or a `.env` file. |----------|-------------| | `HOMESERVER_URL` | Matrix homeserver URL, e.g. `https://matrix.org` | | `BOT_USER_ID` | Bot's Matrix user ID, e.g. `@gogobee:matrix.org` | -| `AS_TOKEN` | Appservice `as_token` from `registration.yaml` (see below) | -GogoBee authenticates as a Matrix **application service** rather than with a -password. The `as_token` is a homeserver-level credential that never expires and -is unaffected by Matrix Authentication Service (MAS) removing password login and -UIA. Device E2EE keys are created via MSC4190. See -[`registration.yaml.example`](registration.yaml.example) for the homeserver-side -setup (register the appservice, enable `msc4190_enabled`, then set `AS_TOKEN`). +GogoBee authenticates via the **Matrix Authentication Service (MAS) OAuth 2.0 +device grant** — no password or token in the environment. On first run it prints +a verification URL and user code; approve it once in a browser while logged in +as `BOT_USER_ID`. The bot then stores a refresh token in `DATA_DIR/mas_auth.json` +and refreshes its access token silently for the life of the deployment. Delete +`data/mas_auth.json` to force re-authorization. Requires a homeserver with MAS +enabled (advertised via the `org.matrix.msc2965.authentication` well-known). ### Core (optional) diff --git a/go.mod b/go.mod index cda6a2d..c167584 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/olebedev/when v1.1.0 github.com/robfig/cron/v3 v3.0.1 + github.com/rs/zerolog v1.35.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/image v0.40.0 maunium.net/go/mautrix v0.28.1 @@ -21,6 +22,7 @@ require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/AlekSi/pointer v1.0.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -30,7 +32,6 @@ require ( github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rs/zerolog v1.35.1 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -41,6 +42,7 @@ require ( golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 2171d37..2393f6b 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI= github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -36,8 +38,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -75,8 +75,6 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= -go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -85,12 +83,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= @@ -100,9 +94,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -112,8 +105,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -123,9 +114,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -138,8 +128,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -160,8 +148,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -170,16 +156,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ= -maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0= maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0= maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/internal/bot/appservice.go b/internal/bot/appservice.go new file mode 100644 index 0000000..47f5e0e --- /dev/null +++ b/internal/bot/appservice.go @@ -0,0 +1,204 @@ +package bot + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/crypto/cryptohelper" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// cryptoToDeviceTypes are the to-device event types the crypto machine must see +// to establish Olm/Megolm sessions and share/receive room keys. In /sync mode +// the cryptohelper gets these automatically; under the appservice transaction +// model we route each one to mach.HandleToDeviceEvent ourselves. +var cryptoToDeviceTypes = []event.Type{ + event.ToDeviceEncrypted, + event.ToDeviceRoomKey, + event.ToDeviceForwardedRoomKey, + event.ToDeviceRoomKeyRequest, + event.ToDeviceRoomKeyWithheld, + event.ToDeviceOrgMatrixRoomKeyWithheld, + event.ToDeviceSecretRequest, + event.ToDeviceSecretSend, + event.ToDeviceDummy, +} + +// newAppserviceSession builds the appservice-mode Session: as_token auth, a +// cryptohelper that mints the bot's device via MSC4190, and an EventProcessor +// fed by Synapse's transaction pushes (in place of /sync). The bot is an +// appservice user — Synapse forbids AS users from /sync, so all events, plus the +// E2EE extensions (to-device / device lists / OTK counts), arrive over the +// transaction API instead. +func newAppserviceSession(cfg Config) (*Session, error) { + if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + if cfg.UserID == "" { + return nil, fmt.Errorf("BOT_USER_ID is required") + } + if cfg.RegistrationPath == "" { + return nil, fmt.Errorf("AS_REGISTRATION is required in appservice mode") + } + if cfg.HomeserverDomain == "" { + return nil, fmt.Errorf("HOMESERVER_DOMAIN is required in appservice mode (server_name, e.g. parodia.dev)") + } + if !((&appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}).IsConfigured()) { + return nil, fmt.Errorf("AS_LISTEN_HOST/AS_LISTEN_PORT must be set in appservice mode") + } + + ctx := context.Background() + + reg, err := appservice.LoadRegistration(cfg.RegistrationPath) + if err != nil { + return nil, fmt.Errorf("load appservice registration %q: %w", cfg.RegistrationPath, err) + } + // Gate for to-device delivery: handleTransaction only pumps to-device events + // when this is set (appservice/http.go). Force it on regardless of the yaml so + // E2EE key exchange can't silently break on a missing field. + reg.EphemeralEvents = true + + as, err := appservice.CreateFull(appservice.CreateOpts{ + Registration: reg, + HomeserverDomain: cfg.HomeserverDomain, + HomeserverURL: cfg.Homeserver, + HostConfig: appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}, + }) + if err != nil { + return nil, fmt.Errorf("create appservice: %w", err) + } + // Surface the HTTP listener + transaction logs (default is a silent Nop). + as.Log = zerolog.New(os.Stderr).With().Timestamp().Str("component", "appservice").Logger(). + Level(zerolog.InfoLevel) + + userID := id.UserID(cfg.UserID) + if as.BotMXID() != userID { + return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID) + } + + client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion + + // Validate the token + identity before we start listening. + whoami, err := client.Whoami(ctx) + if err != nil { + return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err) + } + if whoami.UserID != userID { + return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID) + } + slog.Info("appservice token valid", "user_id", whoami.UserID) + + // ---- E2EE via cryptohelper (MSC4190 device creation, no /login) ---- + cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db") + ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath) + if err != nil { + return nil, fmt.Errorf("init crypto helper: %w", err) + } + // MSC4190: the appservice creates/refreshes its own device via PUT /devices + // instead of the UIA-gated login. The crypto store persists the device ID, so + // restarts reuse it. LoginAs carries only the display name (never calls /login + // in MSC4190 mode). client.Syncer is nil here, so Init does NOT wire /sync + // handlers — we drive the crypto machine from transactions below instead. + ch.MSC4190 = true + ch.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: cfg.DisplayName} + if err := ch.Init(ctx); err != nil { + return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err) + } + client.Crypto = ch + + // ---- Event processor: replicate the cryptohelper's /sync wiring against + // the appservice transaction channels ---- + ep := appservice.NewEventProcessor(as) + mach := ch.Machine() + + // Crypto plumbing that /sync would otherwise carry: + ep.OnOTK(mach.HandleOTKCounts) + ep.OnDeviceList(mach.HandleDeviceLists) + for _, t := range cryptoToDeviceTypes { + ep.On(t, mach.HandleToDeviceEvent) + } + + // Keep the client's StateStore current so outgoing sends know which rooms are + // encrypted and who to share keys with (no /sync backfill here), and let the + // crypto machine track membership for key sharing. PrependHandler so state is + // updated before app handlers (auto-join/moderation) run for the same event. + ep.PrependHandler(event.StateEncryption, func(ctx context.Context, evt *event.Event) { + mautrix.UpdateStateStore(ctx, as.StateStore, evt) + }) + ep.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) { + mautrix.UpdateStateStore(ctx, as.StateStore, evt) + mach.HandleMemberEvent(ctx, evt) + }) + + // Without /sync there is no state backfill, so the client's StateStore starts + // empty. Before we hand off a decrypted event (whose reply may need to be + // encrypted), resolve the room once: mark it encrypted and populate its member + // list. Otherwise outbound sends go plaintext (IsEncrypted=false) and, worse, + // the group session is shared to nobody (GetRoomJoinedOrInvitedMembers empty) + // so recipients can't decrypt the bot's replies. + var resolved sync.Map // roomID -> struct{}, resolved once + resolveRoom := func(ctx context.Context, roomID id.RoomID) { + if _, done := resolved.LoadOrStore(roomID, struct{}{}); done { + return + } + var enc event.EncryptionEventContent + if err := client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc); err == nil && enc.Algorithm != "" { + _ = as.StateStore.SetEncryptionEvent(ctx, roomID, &enc) + } + if members, err := client.Members(ctx, roomID); err == nil { + _ = as.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk) + } else { + slog.Warn("appservice: failed to fetch room members; will retry", "room", roomID, "err", err) + resolved.Delete(roomID) // allow a later event to retry + } + } + + // Decrypt inbound encrypted room events and re-dispatch the plaintext so the + // normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted). + ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) { + resolveRoom(ctx, evt.RoomID) + decrypted, err := ch.Decrypt(ctx, evt) + if err != nil { + slog.Warn("appservice: failed to decrypt event", "room", evt.RoomID, "event", evt.ID, "err", err) + return + } + ep.Dispatch(ctx, decrypted) + }) + + return &Session{ + Client: client, + mode: "appservice", + as: as, + ep: ep, + }, nil +} + +// runAppservice starts the transaction dispatchers and the HTTP listener, then +// blocks until ctx is cancelled. +func (s *Session) runAppservice(ctx context.Context) error { + s.ep.Start(ctx) + s.as.Ready = true + + errCh := make(chan struct{}) + go func() { + s.as.Start() // blocks in ListenAndServe until Stop() + close(errCh) + }() + + slog.Info("appservice listener started", "address", s.as.Host.Address()) + + select { + case <-ctx.Done(): + return nil + case <-errCh: + return fmt.Errorf("appservice HTTP listener exited unexpectedly") + } +} diff --git a/internal/bot/client.go b/internal/bot/client.go index c5b2684..be1d9cf 100644 --- a/internal/bot/client.go +++ b/internal/bot/client.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/cryptohelper" @@ -15,133 +16,172 @@ import ( // Config holds the bot's startup configuration. type Config struct { Homeserver string - UserID string // Bot's full Matrix user ID, e.g. @gogobee:example.com - ASToken string // Appservice as_token from registration.yaml + UserID string // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev DataDir string DisplayName string + + // AuthMode selects how the bot connects to Matrix: + // "masdevice" (default) — MAS OAuth 2.0 device grant + /sync (masauth.go). + // "appservice" — Matrix appservice: as_token auth + Synapse→bot + // transaction push (appservice.go). MAS-durable: + // no login, no MFA, no token expiry ever. + // The device-grant path is retained as an instant rollback: flip AUTH_MODE + // back to masdevice and restart, no rebuild. + AuthMode string + + // ---- appservice mode only ---- + // RegistrationPath points at the appservice registration YAML (id, as_token, + // hs_token, sender_localpart, namespaces). Synapse and the bot share it. + RegistrationPath string + // ListenHost/ListenPort is where the bot's HTTP listener binds to receive + // Synapse's transaction pushes (Synapse reaches it via the registration url). + ListenHost string + ListenPort uint16 + // HomeserverDomain is the server_name (e.g. parodia.dev), needed to derive + // the bot's MXID from sender_localpart. + HomeserverDomain string } -// NewClient creates and configures a mautrix client authenticated as an -// application service, with E2EE via the cryptohelper. +// NewClient creates and configures a mautrix client authenticated against +// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with +// E2EE via the cryptohelper. // -// Why appservice instead of password login: +// Auth flow (see masauth.go for detail): +// - Discover MAS OAuth endpoints from the homeserver's well-known + OIDC docs. +// - If we have a persisted refresh token, refresh it for a fresh access token. +// - Otherwise run the device-authorization grant: print a URL + code for a +// human to approve ONCE in a browser (as the bot's user), then store the +// resulting access + refresh tokens. +// - A background goroutine refreshes the access token before it expires and +// updates the live client, so the bot runs indefinitely without re-auth. // -// Under Matrix Authentication Service (MAS), the legacy password login -// (m.login.password) and User-Interactive Auth (UIA) flows are being removed — -// auth moves to OAuth2/OIDC. A bot built on password login + password-UIA -// cross-signing (the old code) breaks in a MAS world: short-lived tokens with -// no refresh, and UIA-gated key uploads that simply fail. -// -// The appservice model sidesteps MAS entirely. The as_token is a Synapse-level -// trust relationship declared in registration.yaml — Synapse validates it -// directly, not via MAS — so it never expires and needs no login flow. Device -// creation uses MSC4190 (PUT /devices), which replaces the UIA-gated device -// dance for appservice users. This is the MAS-durable path. -// -// The cryptohelper still handles everything E2EE: -// - Persistent crypto store in SQLite (device keys, sessions, cross-signing) -// - Megolm session sharing / key exchange, Olm device-to-device sessions -// - The bot trusts all users' devices by default (appropriate for a bot) -// - No interactive emoji/SAS verification needed +// The bot stays a normal Matrix user (not an appservice), so /sync works — an +// appservice user is forbidden from /sync by Synapse. E2EE is unchanged: the +// cryptohelper persists device keys, olm/megolm sessions and cross-signing in +// its own SQLite store, and the bot trusts all users' devices by default. func NewClient(cfg Config) (*mautrix.Client, error) { if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } - if cfg.ASToken == "" { - return nil, fmt.Errorf("AS_TOKEN is required (appservice registration as_token)") - } if cfg.UserID == "" { return nil, fmt.Errorf("BOT_USER_ID is required") } ctx := context.Background() - userID := id.UserID(cfg.UserID) - // The as_token IS the access token. No login flow, no refresh — Synapse - // trusts it for any user in the registration's namespace. - client, err := mautrix.NewClient(cfg.Homeserver, userID, cfg.ASToken) + // ---- MAS OAuth device-grant authentication ---- + auth := newMASAuth(cfg.DataDir) + auth.load() + if err := auth.discover(cfg.Homeserver); err != nil { + return nil, fmt.Errorf("MAS discovery: %w", err) + } + + if auth.refreshToken != "" { + // Returning start: refresh the stored token. + if err := auth.refresh(); err != nil { + return nil, fmt.Errorf("MAS token refresh failed (delete %s to re-authorize): %w", + filepath.Join(cfg.DataDir, "mas_auth.json"), err) + } + slog.Info("MAS auth: refreshed access token from stored session", "device_id", auth.deviceID) + } else { + // First run: register a client and run the interactive device grant. + if err := auth.ensureClient(cfg.DisplayName); err != nil { + return nil, fmt.Errorf("MAS client registration: %w", err) + } + if err := auth.deviceFlow(ctx); err != nil { + return nil, fmt.Errorf("MAS device authorization: %w", err) + } + } + + userID := id.UserID(cfg.UserID) + client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token()) if err != nil { return nil, fmt.Errorf("create client: %w", err) } + client.DeviceID = id.DeviceID(auth.deviceID) - // Assert our own user ID on every request (appservice identity assertion, - // ?user_id=). Required so Synapse knows which namespaced user we're acting - // as — including on /sync and the MSC4190 device creation below. - client.SetAppServiceUserID = true - - // Validate the token + homeserver reachability up front with a clear error. + // Validate the token + identity before proceeding. whoami, err := client.Whoami(ctx) if err != nil { - return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err) + return nil, fmt.Errorf("token validation failed (whoami): %w", err) } if whoami.UserID != userID { - return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID) + return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID) } - slog.Info("appservice token valid", "user_id", whoami.UserID) + slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID) - // Set up E2EE. The cryptohelper persists all crypto state in its own SQLite - // DB (device keys, olm/megolm sessions, cross-signing keys, device trust), - // separate from the main app database. The device ID is persisted there too, - // so restarts reuse the same device — no device.json needed. + // ---- E2EE via cryptohelper ---- + // The crypto store persists device keys, olm/megolm sessions, cross-signing + // and device trust in its own SQLite DB. The stored device ID must match the + // one bound to our OAuth token (device: scope); a fresh mas_auth.json is + // paired with a fresh crypto.db. cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db") ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath) if err != nil { return nil, fmt.Errorf("init crypto helper: %w", err) } - - // MSC4190: create/refresh the bot's device via PUT /devices instead of the - // UIA-gated login dance. On first run the crypto store has no device ID, so - // a fresh one is generated and persisted; on restart the stored ID is - // reused (the PUT is idempotent). LoginAs carries only the display name — - // in MSC4190 mode the cryptohelper never calls /login. - ch.MSC4190 = true - ch.LoginAs = &mautrix.ReqLogin{ - InitialDeviceDisplayName: cfg.DisplayName, - } - + // No LoginAs: we already have a token + device ID; the cryptohelper just + // attaches E2EE to the existing session. if err := ch.Init(ctx); err != nil { - return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err) + return nil, fmt.Errorf("crypto helper init: %w", err) } client.Crypto = ch - // Best-effort cross-signing bootstrap. This makes the bot's device show as - // "verified" to users who have verified the bot's master key. It is NOT - // required for E2EE to function — the bot already trusts all devices and - // shares keys — so any failure is logged and ignored. - // - // Under MSC3202 (appservice device assertion) Synapse may accept the key - // upload without UIA, in which case the callback below is never invoked. If - // the homeserver still demands UIA, we have no password credential to - // satisfy it (that's the whole point of going MAS-native), so it fails soft. + // Best-effort cross-signing bootstrap (makes the bot's device show as + // verified to users who trust its master key). Not required for E2EE to + // function; under MAS the key upload may be refused, which we ignore. mach := ch.Machine() - recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} { - slog.Warn("cross-signing: homeserver requested UIA, but appservice auth has no password credential to satisfy it — skipping verified-shield bootstrap") + if recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} { return map[string]interface{}{"session": ui.Session} - }, "") - if err != nil { - slog.Warn("cross-signing: key upload skipped (may already exist or UIA required)", "err", err) + }, ""); err != nil { + slog.Warn("cross-signing: key upload skipped (may already exist or need UIA)", "err", err) } else { slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey) } - if err := mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil { slog.Warn("cross-signing: sign own device failed", "err", err) - } else { - slog.Info("cross-signing: own device signed") } - if err := mach.SignOwnMasterKey(ctx); err != nil { slog.Warn("cross-signing: sign master key failed", "err", err) - } else { - slog.Info("cross-signing: master key signed") } + // ---- Background token refresher ---- + // Refresh ~60s before expiry and push the new token into the live client so + // in-flight /sync and API calls keep authenticating. + go refreshLoop(context.Background(), auth, client) + slog.Info("E2EE initialized", "user_id", client.UserID, "device_id", client.DeviceID, "crypto_store", "sqlite-persistent", - "auth", "appservice+msc4190", + "auth", "mas-oauth-device-grant", ) return client, nil } + +// refreshLoop keeps the client's access token fresh for the life of the process. +func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) { + for { + wait := time.Until(auth.expiry()) - 60*time.Second + if wait < 10*time.Second { + wait = 10 * time.Second + } + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + if err := auth.refresh(); err != nil { + slog.Error("MAS token refresh failed; retrying in 30s", "err", err) + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + } + continue + } + client.AccessToken = auth.token() + slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339)) + } +} diff --git a/internal/bot/masauth.go b/internal/bot/masauth.go new file mode 100644 index 0000000..164c5cf --- /dev/null +++ b/internal/bot/masauth.go @@ -0,0 +1,375 @@ +package bot + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// masAuth performs MAS (Matrix Authentication Service) OAuth 2.0 device-grant +// authentication for the bot and keeps a valid access token available. +// +// Why this exists: MAS replaces Matrix's legacy password login with OAuth2. A +// bot can't do the interactive authorization-code flow, so we use the OAuth 2.0 +// Device Authorization Grant (RFC 8628): on first run the bot prints a URL + +// user code, a human approves the login as the bot's user ONCE in a browser, +// and MAS returns an access token + refresh token. Thereafter the bot refreshes +// silently forever — no password, no expiry surprises, and the bot stays a +// normal Matrix user so /sync keeps working (unlike an appservice user, which +// Synapse forbids from /sync). +// +// The refresh token is rotated by MAS on every refresh, so we persist the new +// one each time. +type masAuth struct { + http *http.Client + storePath string + scope string + + // discovered OAuth endpoints + deviceEndpoint string + tokenEndpoint string + registrationEndpoint string + + mu sync.RWMutex + clientID string + deviceID string + accessToken string + refreshToken string + expiresAt time.Time +} + +// masStore is the on-disk persisted state (data/mas_auth.json). +type masStore struct { + ClientID string `json:"client_id"` + DeviceID string `json:"device_id"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +const masScopeAPI = "urn:matrix:org.matrix.msc2967.client:api:*" + +func newMASAuth(dataDir string) *masAuth { + return &masAuth{ + http: &http.Client{Timeout: 30 * time.Second}, + storePath: filepath.Join(dataDir, "mas_auth.json"), + } +} + +func (m *masAuth) load() { + data, err := os.ReadFile(m.storePath) + if err != nil { + return // fresh install + } + var s masStore + if err := json.Unmarshal(data, &s); err != nil { + slog.Warn("mas_auth: corrupt store, ignoring", "err", err) + return + } + m.clientID = s.ClientID + m.deviceID = s.DeviceID + m.accessToken = s.AccessToken + m.refreshToken = s.RefreshToken + m.expiresAt = s.ExpiresAt +} + +func (m *masAuth) save() { + m.mu.RLock() + s := masStore{ + ClientID: m.clientID, + DeviceID: m.deviceID, + AccessToken: m.accessToken, + RefreshToken: m.refreshToken, + ExpiresAt: m.expiresAt, + } + m.mu.RUnlock() + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + slog.Error("mas_auth: marshal failed", "err", err) + return + } + if err := os.WriteFile(m.storePath, data, 0o600); err != nil { + slog.Error("mas_auth: write failed", "err", err) + } +} + +func (m *masAuth) token() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.accessToken +} + +// discover resolves the MAS OAuth endpoints from the homeserver's well-known +// document and the issuer's OIDC discovery document. +func (m *masAuth) discover(homeserver string) error { + var wk struct { + Auth struct { + Issuer string `json:"issuer"` + } `json:"org.matrix.msc2965.authentication"` + } + if err := m.getJSON(strings.TrimRight(homeserver, "/")+"/.well-known/matrix/client", &wk); err != nil { + return fmt.Errorf("fetch well-known: %w", err) + } + if wk.Auth.Issuer == "" { + return fmt.Errorf("homeserver does not advertise a MAS issuer (msc2965) — is MAS enabled?") + } + var oidc struct { + DeviceEndpoint string `json:"device_authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + } + if err := m.getJSON(strings.TrimRight(wk.Auth.Issuer, "/")+"/.well-known/openid-configuration", &oidc); err != nil { + return fmt.Errorf("fetch OIDC discovery: %w", err) + } + if oidc.DeviceEndpoint == "" || oidc.TokenEndpoint == "" { + return fmt.Errorf("MAS issuer %q does not advertise a device/token endpoint", wk.Auth.Issuer) + } + m.deviceEndpoint = oidc.DeviceEndpoint + m.tokenEndpoint = oidc.TokenEndpoint + m.registrationEndpoint = oidc.RegistrationEndpoint + slog.Info("mas_auth: discovered endpoints", "issuer", wk.Auth.Issuer) + return nil +} + +// ensureClient registers a public OAuth client via dynamic registration if we +// don't already have a client_id persisted. +func (m *masAuth) ensureClient(displayName string) error { + if m.clientID != "" { + return nil + } + if m.registrationEndpoint == "" { + return fmt.Errorf("no registration endpoint discovered") + } + body := map[string]any{ + "client_name": displayName + " (bot)", + "application_type": "native", + "token_endpoint_auth_method": "none", + "grant_types": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"}, + "response_types": []string{}, + "client_uri": "https://github.com/prosolis/gogobee", + } + raw, _ := json.Marshal(body) + resp, err := m.http.Post(m.registrationEndpoint, "application/json", strings.NewReader(string(raw))) + if err != nil { + return fmt.Errorf("client registration request: %w", err) + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return fmt.Errorf("client registration failed (HTTP %d): %s", resp.StatusCode, string(rb)) + } + var out struct { + ClientID string `json:"client_id"` + } + if err := json.Unmarshal(rb, &out); err != nil || out.ClientID == "" { + return fmt.Errorf("client registration: no client_id in response: %s", string(rb)) + } + m.mu.Lock() + m.clientID = out.ClientID + m.mu.Unlock() + m.save() + slog.Info("mas_auth: registered OAuth client", "client_id", out.ClientID) + return nil +} + +// deviceFlow runs the interactive device-authorization grant. It blocks until +// the user approves the login (or the code expires). +func (m *masAuth) deviceFlow(ctx context.Context) error { + if m.deviceID == "" { + m.deviceID = randomDeviceID() + } + scope := masScopeAPI + " urn:matrix:org.matrix.msc2967.client:device:" + m.deviceID + + form := url.Values{"client_id": {m.clientID}, "scope": {scope}} + var da struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + } + if err := m.postForm(m.deviceEndpoint, form, &da); err != nil { + return fmt.Errorf("device authorization request: %w", err) + } + + // Surface the approval prompt prominently — the operator must act on it. + uri := da.VerificationURI + if da.VerificationURIComplete != "" { + uri = da.VerificationURIComplete + } + banner := fmt.Sprintf(` + +================= TwinBee needs you to authorize its login ================= + Open this URL in a browser (logged in as the bot's Matrix user): + %s + and enter code: %s + (expires in %d minutes) +============================================================================ + +`, uri, da.UserCode, da.ExpiresIn/60) + fmt.Print(banner) + slog.Warn("mas_auth: device authorization required", "verification_uri", da.VerificationURI, "user_code", da.UserCode, "expires_in_s", da.ExpiresIn) + + interval := da.Interval + if interval <= 0 { + interval = 5 + } + deadline := time.Now().Add(time.Duration(da.ExpiresIn) * time.Second) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(interval) * time.Second): + } + pending, err := m.pollToken(url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {da.DeviceCode}, + "client_id": {m.clientID}, + }) + if err != nil { + return err + } + if !pending { + slog.Info("mas_auth: device authorization approved", "device_id", m.deviceID) + return nil + } + } + return fmt.Errorf("device authorization timed out (not approved within %d minutes)", da.ExpiresIn/60) +} + +// pollToken hits the token endpoint. Returns pending=true if the user hasn't +// approved yet (keep polling); on success it stores the tokens. +func (m *masAuth) pollToken(form url.Values) (pending bool, err error) { + resp, err := m.http.PostForm(m.tokenEndpoint, form) + if err != nil { + return false, fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 200 { + return false, m.storeTokenResponse(rb) + } + var oerr struct { + Error string `json:"error"` + } + _ = json.Unmarshal(rb, &oerr) + switch oerr.Error { + case "authorization_pending": + return true, nil + case "slow_down": + return true, nil + default: + return false, fmt.Errorf("token endpoint error (HTTP %d): %s", resp.StatusCode, string(rb)) + } +} + +// refresh exchanges the stored refresh token for a fresh access token. MAS +// rotates the refresh token, so we persist the new one. +func (m *masAuth) refresh() error { + m.mu.RLock() + rt, cid := m.refreshToken, m.clientID + m.mu.RUnlock() + if rt == "" { + return fmt.Errorf("no refresh token") + } + resp, err := m.http.PostForm(m.tokenEndpoint, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {rt}, + "client_id": {cid}, + }) + if err != nil { + return fmt.Errorf("refresh request: %w", err) + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(rb)) + } + return m.storeTokenResponse(rb) +} + +func (m *masAuth) storeTokenResponse(rb []byte) error { + var tr struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(rb, &tr); err != nil { + return fmt.Errorf("parse token response: %w", err) + } + if tr.AccessToken == "" { + return fmt.Errorf("token response missing access_token: %s", string(rb)) + } + m.mu.Lock() + m.accessToken = tr.AccessToken + if tr.RefreshToken != "" { + m.refreshToken = tr.RefreshToken + } + ttl := tr.ExpiresIn + if ttl <= 0 { + ttl = 300 + } + m.expiresAt = time.Now().Add(time.Duration(ttl) * time.Second) + m.mu.Unlock() + m.save() + return nil +} + +func (m *masAuth) expiry() time.Time { + m.mu.RLock() + defer m.mu.RUnlock() + return m.expiresAt +} + +// getJSON GETs a URL and decodes JSON. +func (m *masAuth) getJSON(u string, out any) error { + resp, err := m.http.Get(u) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("GET %s: HTTP %d: %s", u, resp.StatusCode, string(b)) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// postForm POSTs a form and decodes a JSON success body (200). +func (m *masAuth) postForm(u string, form url.Values, out any) error { + resp, err := m.http.PostForm(u, form) + if err != nil { + return err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("POST %s: HTTP %d: %s", u, resp.StatusCode, string(rb)) + } + return json.Unmarshal(rb, out) +} + +// randomDeviceID mirrors mautrix's device-id style: 10 uppercase letters. +func randomDeviceID() string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + b := make([]byte, 10) + if _, err := rand.Read(b); err != nil { + // rand.Read essentially never fails; fall back to a fixed prefix. + return "GOGOBEEBOT" + } + for i := range b { + b[i] = alphabet[int(b[i])%len(alphabet)] + } + return "GB" + string(b[:8]) +} diff --git a/internal/bot/session.go b/internal/bot/session.go new file mode 100644 index 0000000..86e1adc --- /dev/null +++ b/internal/bot/session.go @@ -0,0 +1,122 @@ +package bot + +import ( + "context" + "fmt" + "log/slog" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/event" +) + +// Session is the bot's live Matrix connection plus its event source. It hides +// the difference between the two transports the bot can run under: +// +// - masdevice: a normal Matrix user authenticated via the MAS OAuth device +// grant, receiving events over /sync (mautrix DefaultSyncer). +// - appservice: a Synapse appservice authenticated by its as_token, receiving +// events pushed over the appservice transaction API (mautrix EventProcessor). +// +// Callers register the same three handlers (message/member/reaction) via +// OnEventType and call Run regardless of mode; the plugin layer never knows which +// transport is in use. +type Session struct { + Client *mautrix.Client + mode string + + // masdevice + syncer *mautrix.DefaultSyncer + + // appservice + as *appservice.AppService + ep *appservice.EventProcessor +} + +// EventHandler matches both DefaultSyncer.OnEventType and EventProcessor.On. +type EventHandler = func(ctx context.Context, evt *event.Event) + +// NewSession builds a Session for the configured auth mode. Default and +// "masdevice" use the MAS device grant; "appservice" uses the transaction model. +func NewSession(cfg Config) (*Session, error) { + switch cfg.AuthMode { + case "appservice": + return newAppserviceSession(cfg) + case "", "masdevice": + return newMASDeviceSession(cfg) + default: + return nil, fmt.Errorf("unknown AUTH_MODE %q (want appservice or masdevice)", cfg.AuthMode) + } +} + +// newMASDeviceSession wraps the existing device-grant client (NewClient) and its +// DefaultSyncer. This is the retained rollback path — unchanged behaviour. +func newMASDeviceSession(cfg Config) (*Session, error) { + client, err := NewClient(cfg) + if err != nil { + return nil, err + } + return &Session{ + Client: client, + mode: "masdevice", + syncer: client.Syncer.(*mautrix.DefaultSyncer), + }, nil +} + +// OnEventType registers a handler for the given event type against whichever +// event source backs this session. +func (s *Session) OnEventType(evtType event.Type, fn EventHandler) { + switch s.mode { + case "appservice": + s.ep.On(evtType, fn) + default: + s.syncer.OnEventType(evtType, fn) + } +} + +// Run blocks, delivering events to registered handlers, until ctx is cancelled. +func (s *Session) Run(ctx context.Context) error { + switch s.mode { + case "appservice": + return s.runAppservice(ctx) + default: + return s.runSync(ctx) + } +} + +// Stop halts the event source. Safe to call once. +func (s *Session) Stop() { + switch s.mode { + case "appservice": + if s.ep != nil { + s.ep.Stop() + } + if s.as != nil { + s.as.Stop() + } + default: + s.Client.StopSync() + } +} + +// runSync is the device-grant /sync loop (moved verbatim from main.go): restart +// on transient failure, exit on context cancel. +func (s *Session) runSync(ctx context.Context) error { + for { + err := s.Client.SyncWithContext(ctx) + if ctx.Err() != nil { + return nil // shutdown requested + } + if err != nil { + slog.Error("sync stopped, restarting in 5s", "err", err) + } else { + slog.Warn("sync returned without error, restarting in 5s") + } + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return nil + } + } +} diff --git a/main.go b/main.go index c104410..5ca6931 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,9 @@ import ( "log/slog" "os" "os/signal" + "strconv" "strings" "syscall" - "time" "gogobee/internal/bot" "gogobee/internal/db" @@ -55,20 +55,27 @@ func main() { } db.RecordStartup(version.Version, version.Commit) - // Create Matrix client + // Create Matrix session. AUTH_MODE selects the transport: + // masdevice (default) — MAS OAuth device grant over /sync. + // appservice — as_token auth over Synapse transaction push. cfg := bot.Config{ - Homeserver: os.Getenv("HOMESERVER_URL"), - UserID: os.Getenv("BOT_USER_ID"), - ASToken: os.Getenv("AS_TOKEN"), - DataDir: dataDir, - DisplayName: envOr("BOT_DISPLAY_NAME", "GogoBee"), + Homeserver: os.Getenv("HOMESERVER_URL"), + UserID: os.Getenv("BOT_USER_ID"), + DataDir: dataDir, + DisplayName: envOr("BOT_DISPLAY_NAME", "GogoBee"), + AuthMode: os.Getenv("AUTH_MODE"), + RegistrationPath: os.Getenv("AS_REGISTRATION"), + ListenHost: os.Getenv("AS_LISTEN_HOST"), + ListenPort: uint16(envInt("AS_LISTEN_PORT", 0)), + HomeserverDomain: os.Getenv("HOMESERVER_DOMAIN"), } - client, err := bot.NewClient(cfg) + sess, err := bot.NewSession(cfg) if err != nil { - slog.Error("client init failed", "err", err) + slog.Error("session init failed", "err", err) os.Exit(1) } + client := sess.Client // Create plugin registry registry := bot.NewRegistry() @@ -209,10 +216,8 @@ func main() { // ---- Set up event handlers ---- - syncer := client.Syncer.(*mautrix.DefaultSyncer) - // Auto-join on invite + moderation member tracking - syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { + sess.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { defer func() { if rec := recover(); rec != nil { slog.Error("member event handler panic", "panic", rec, "room", evt.RoomID) @@ -249,7 +254,7 @@ func main() { }) // Message handler - syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + sess.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { defer func() { if rec := recover(); rec != nil { slog.Error("message event handler panic", "panic", rec, @@ -294,7 +299,7 @@ func main() { }) // Reaction handler - syncer.OnEventType(event.EventReaction, func(ctx context.Context, evt *event.Event) { + sess.OnEventType(event.EventReaction, func(ctx context.Context, evt *event.Event) { defer func() { if rec := recover(); rec != nil { slog.Error("reaction event handler panic", "panic", rec, @@ -332,8 +337,8 @@ func main() { // ---- Initial archetype calculation ---- go plugin.RefreshAllArchetypes() - // ---- Start syncing ---- - slog.Info("GogoBee starting sync...") + // ---- Start receiving events ---- + slog.Info("GogoBee starting", "auth_mode", envOr("AUTH_MODE", "masdevice")) ctx, cancel := context.WithCancel(context.Background()) @@ -344,26 +349,12 @@ func main() { sig := <-sigCh slog.Info("shutting down", "signal", sig) scheduler.Stop() - client.StopSync() + sess.Stop() cancel() }() -syncLoop: - for { - err := client.SyncWithContext(ctx) - if ctx.Err() != nil { - break // shutdown requested - } - if err != nil { - slog.Error("sync stopped, restarting in 5s", "err", err) - } else { - slog.Warn("sync returned without error, restarting in 5s") - } - select { - case <-time.After(5 * time.Second): - case <-ctx.Done(): - break syncLoop - } + if err := sess.Run(ctx); err != nil { + slog.Error("event loop stopped", "err", err) } db.Close() @@ -589,6 +580,20 @@ func envOr(key, fallback string) string { return v } +// envInt reads an integer env var, returning fallback if unset or unparseable. +func envInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + slog.Warn("invalid integer env var; using fallback", "key", key, "value", v, "fallback", fallback) + return fallback + } + return n +} + // cronLogger adapts slog to the cron.Logger interface for panic recovery logging. type cronLogger struct{}