Skip to content

Commit 7d81180

Browse files
committed
Merge branch 'main' into nexy7574/protections
2 parents 9af0fa4 + f92947f commit 7d81180

29 files changed

+724
-188
lines changed

.github/workflows/go.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ jobs:
1111
strategy:
1212
fail-fast: false
1313
matrix:
14-
go-version: ["1.23", "1.24"]
15-
name: Lint ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
14+
go-version: ["1.24", "1.25"]
15+
name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }}
1616

1717
steps:
1818
- uses: actions/checkout@v4
@@ -32,5 +32,9 @@ jobs:
3232
go install honnef.co/go/tools/cmd/staticcheck@latest
3333
export PATH="$HOME/go/bin:$PATH"
3434
35+
- name: Enable jsonv2
36+
run: export GOEXPERIMENT=jsonv2
37+
if: matrix.go-version == '1.25'
38+
3539
- name: Run pre-commit
3640
uses: pre-commit/action@v3.0.1

.gitlab-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ include:
44

55
variables:
66
BINARY_NAME: meowlnir
7+
GOEXPERIMENT: jsonv2

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v5.0.0
3+
rev: v6.0.0
44
hooks:
55
- id: trailing-whitespace
66
exclude_types: [markdown]
@@ -9,7 +9,7 @@ repos:
99
- id: check-added-large-files
1010

1111
- repo: https://github.com/tekwizely/pre-commit-golang
12-
rev: v1.0.0-rc.1
12+
rev: v1.0.0-rc.2
1313
hooks:
1414
- id: go-imports-repo
1515
args:

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
# v0.7.0 (2025-08-16)
2+
3+
* Bumped minimum Go version to 1.24.
4+
* Added support for creator power in room v12.
5+
* Added appservice ping at startup to ensure homeserver -> meowlnir connection
6+
works similar to what bridges do.
7+
* Added support for `federated_user_may_invite` callback and [MSC4311].
8+
* Added custom API for querying policy lists that Meowlnir has cached.
9+
* Fixed various bugs in experimental built-in policy server.
10+
* Note that the policy server is not considered stable yet, so it should
11+
not be used in production.
12+
13+
[MSC4311]: https://github.com/matrix-org/matrix-spec-proposals/pull/4311
14+
115
# v0.6.0 (2025-06-16)
216

317
* Added experimental built-in policy server as per [MSC4284]

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Meowlnir
22
An opinionated Matrix moderation bot. Optimized for Synapse, but works with
3-
other server implementations to some extent.
3+
other server implementations to some extent. Not a fork of Mjolnir/Draupnir
4+
despite the similar name.
45

56
## Documentation
67
All setup and usage instructions are located on [docs.mau.fi](https://docs.mau.fi/meowlnir/).

cmd/meowlnir/antispam.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/rs/zerolog/hlog"
99
"go.mau.fi/util/exhttp"
1010
"maunium.net/go/mautrix"
11+
"maunium.net/go/mautrix/event"
1112
"maunium.net/go/mautrix/id"
1213
)
1314

@@ -17,6 +18,10 @@ type ReqUserMayInvite struct {
1718
Room id.RoomID `json:"room_id"`
1819
}
1920

21+
type ReqFederatedUserMayInvite struct {
22+
Event *event.Event `json:"event"`
23+
}
24+
2025
type ReqUserMayJoinRoom struct {
2126
UserID id.UserID `json:"user"`
2227
RoomID id.RoomID `json:"room"`
@@ -33,6 +38,8 @@ func (m *Meowlnir) PostCallback(w http.ResponseWriter, r *http.Request) {
3338
switch cbType {
3439
case "user_may_invite":
3540
m.PostUserMayInvite(w, r)
41+
case "federated_user_may_invite":
42+
m.PostFederatedUserMayInvite(w, r)
3643
case "accept_make_join":
3744
m.PostAcceptMakeJoin(w, r)
3845
case "user_may_join_room":
@@ -114,7 +121,31 @@ func (m *Meowlnir) PostUserMayInvite(w http.ResponseWriter, r *http.Request) {
114121
mautrix.MNotFound.WithMessage("Antispam configuration issue: policy list not found").Write(w)
115122
return
116123
}
117-
errResp := mgmtRoom.HandleUserMayInvite(r.Context(), req.Inviter, req.Invitee, req.Room)
124+
errResp := mgmtRoom.HandleUserMayInvite(r.Context(), req.Inviter, req.Invitee, req.Room, "")
125+
if errResp != nil {
126+
errResp.Write(w)
127+
} else {
128+
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
129+
}
130+
}
131+
132+
func (m *Meowlnir) PostFederatedUserMayInvite(w http.ResponseWriter, r *http.Request) {
133+
var req ReqFederatedUserMayInvite
134+
err := json.NewDecoder(r.Body).Decode(&req)
135+
if err != nil {
136+
hlog.FromRequest(r).Err(err).Msg("Failed to parse request body")
137+
mautrix.MNotJSON.WithMessage("Antispam request error: invalid JSON").Write(w)
138+
return
139+
}
140+
141+
m.MapLock.RLock()
142+
mgmtRoom, ok := m.EvaluatorByManagementRoom[id.RoomID(r.PathValue("policyListID"))]
143+
m.MapLock.RUnlock()
144+
if !ok {
145+
mautrix.MNotFound.WithMessage("Antispam configuration issue: policy list not found").Write(w)
146+
return
147+
}
148+
errResp := mgmtRoom.HandleFederatedUserMayInvite(r.Context(), req.Event)
118149
if errResp != nil {
119150
errResp.Write(w)
120151
} else {

cmd/meowlnir/botmanagement.go

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import (
1111

1212
"github.com/rs/zerolog/hlog"
1313
"go.mau.fi/util/exhttp"
14+
"go.mau.fi/util/exslices"
1415
"go.mau.fi/util/ptr"
1516
"maunium.net/go/mautrix"
1617
"maunium.net/go/mautrix/id"
1718

1819
"go.mau.fi/meowlnir/database"
20+
"go.mau.fi/meowlnir/policylist"
1921
"go.mau.fi/meowlnir/util"
2022
)
2123

@@ -39,26 +41,24 @@ type RespGetBots struct {
3941
Bots []*RespBot `json:"bots"`
4042
}
4143

42-
func (m *Meowlnir) ManagementAuth(next http.Handler) http.Handler {
43-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44-
authHash := util.SHA256String(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
45-
if !hmac.Equal(authHash[:], m.ManagementSecret[:]) {
46-
mautrix.MUnknownToken.WithMessage("Invalid management secret").Write(w)
47-
return
48-
}
49-
next.ServeHTTP(w, r)
50-
})
44+
func disabledAPI(w http.ResponseWriter, r *http.Request) {
45+
mautrix.MUnknownToken.WithMessage("This API is disabled").Write(w)
5146
}
5247

53-
func (m *Meowlnir) AntispamAuth(next http.Handler) http.Handler {
54-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55-
authHash := util.SHA256String(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
56-
if !hmac.Equal(authHash[:], m.AntispamSecret[:]) {
57-
mautrix.MUnknown.WithMessage("Invalid antispam secret").Write(w)
58-
return
48+
func SecretAuth(secret *[32]byte) func(next http.Handler) http.Handler {
49+
return func(next http.Handler) http.Handler {
50+
if secret == nil {
51+
return http.HandlerFunc(disabledAPI)
5952
}
60-
next.ServeHTTP(w, r)
61-
})
53+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54+
authHash := util.SHA256String(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
55+
if !hmac.Equal(authHash[:], secret[:]) {
56+
mautrix.MUnknownToken.WithMessage("Invalid authorization token").Write(w)
57+
return
58+
}
59+
next.ServeHTTP(w, r)
60+
})
61+
}
6262
}
6363

6464
func (m *Meowlnir) GetBots(w http.ResponseWriter, r *http.Request) {
@@ -273,3 +273,37 @@ func (m *Meowlnir) PutManagementRoom(w http.ResponseWriter, r *http.Request) {
273273
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
274274
}
275275
}
276+
277+
func getPolicyListIDs(r *http.Request) []id.RoomID {
278+
listIDs := exslices.CastFuncFilter(r.URL.Query()["list_id"], func(s string) (id.RoomID, bool) {
279+
return id.RoomID(s), strings.HasPrefix(s, "!")
280+
})
281+
if len(listIDs) == 0 {
282+
listIDs = nil
283+
}
284+
return listIDs
285+
}
286+
287+
func (m *Meowlnir) MatchPolicy(w http.ResponseWriter, r *http.Request) {
288+
entityType := policylist.EntityType(r.PathValue("entityType"))
289+
entity := r.PathValue("entity")
290+
if !entityType.IsValid() {
291+
mautrix.MInvalidParam.WithMessage("Invalid entity type").Write(w)
292+
return
293+
}
294+
match := m.PolicyStore.Match(getPolicyListIDs(r), entityType, entity)
295+
exhttp.WriteJSONResponse(w, http.StatusOK, match)
296+
}
297+
298+
func (m *Meowlnir) ListPolicies(w http.ResponseWriter, r *http.Request) {
299+
entityType := policylist.EntityType(r.PathValue("entityType"))
300+
if !entityType.IsValid() {
301+
mautrix.MInvalidParam.WithMessage("Invalid entity type").Write(w)
302+
return
303+
} else if entityType != policylist.EntityTypeServer {
304+
mautrix.MInvalidParam.WithMessage("Only server policies can be listed").Write(w)
305+
return
306+
}
307+
rules := m.PolicyStore.ListServerRules(getPolicyListIDs(r))
308+
exhttp.WriteJSONResponse(w, http.StatusOK, rules)
309+
}

cmd/meowlnir/http.go

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package main
22

33
import (
44
"net/http"
5-
"slices"
65

76
"github.com/rs/zerolog/hlog"
87
"go.mau.fi/util/exhttp"
@@ -14,7 +13,8 @@ func (m *Meowlnir) AddHTTPEndpoints() {
1413
clientRouter.HandleFunc("POST /v3/rooms/{roomID}/report/{eventID}", m.PostReport)
1514
clientRouter.HandleFunc("POST /v3/rooms/{roomID}/report", m.PostReport)
1615
clientRouter.HandleFunc("POST /v3/users/{userID}/report", m.PostReport)
17-
m.AS.Router.PathPrefix("/_matrix/client").Handler(applyMiddleware(
16+
17+
m.AS.Router.Handle("/_matrix/client/", exhttp.ApplyMiddleware(
1818
http.StripPrefix("/_matrix/client", clientRouter),
1919
hlog.NewHandler(m.Log.With().Str("component", "reporting api").Logger()),
2020
hlog.RequestIDHandler("request_id", "X-Request-ID"),
@@ -26,7 +26,7 @@ func (m *Meowlnir) AddHTTPEndpoints() {
2626
policyServerRouter := http.NewServeMux()
2727
policyServerRouter.HandleFunc("POST /unstable/org.matrix.msc4284/event/{event_id}/check", m.PostMSC4284EventCheck)
2828

29-
m.AS.Router.PathPrefix("/_matrix/policy").Handler(applyMiddleware(
29+
m.AS.Router.Handle("/_matrix/policy/", exhttp.ApplyMiddleware(
3030
http.StripPrefix("/_matrix/policy", policyServerRouter),
3131
hlog.NewHandler(m.Log.With().Str("component", "policy server").Logger()),
3232
hlog.RequestIDHandler("request_id", "X-Request-ID"),
@@ -36,33 +36,37 @@ func (m *Meowlnir) AddHTTPEndpoints() {
3636

3737
antispamRouter := http.NewServeMux()
3838
antispamRouter.HandleFunc("POST /{policyListID}/{callback}", m.PostCallback)
39-
m.AS.Router.PathPrefix("/_meowlnir/antispam").Handler(applyMiddleware(
39+
m.AS.Router.Handle("/_meowlnir/antispam/", exhttp.ApplyMiddleware(
4040
http.StripPrefix("/_meowlnir/antispam", antispamRouter),
4141
hlog.NewHandler(m.Log.With().Str("component", "antispam api").Logger()),
4242
hlog.RequestIDHandler("request_id", "X-Request-ID"),
4343
requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}),
44-
m.AntispamAuth,
44+
SecretAuth(m.loadSecret(m.Config.Antispam.Secret)),
45+
))
46+
47+
dataRouter := http.NewServeMux()
48+
dataRouter.HandleFunc("GET /v1/match/{entityType}/{entity}", m.MatchPolicy)
49+
dataRouter.HandleFunc("GET /v1/list/{entityType}", m.ListPolicies)
50+
m.AS.Router.Handle("/_meowlnir/data/", exhttp.ApplyMiddleware(
51+
http.StripPrefix("/_meowlnir/data", dataRouter),
52+
hlog.NewHandler(m.Log.With().Str("component", "data api").Logger()),
53+
hlog.RequestIDHandler("request_id", "X-Request-ID"),
54+
exhttp.CORSMiddleware,
55+
requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}),
56+
SecretAuth(m.loadSecret(m.Config.Meowlnir.DataSecret)),
4557
))
4658

4759
managementRouter := http.NewServeMux()
4860
managementRouter.HandleFunc("GET /v1/bots", m.GetBots)
4961
managementRouter.HandleFunc("PUT /v1/bot/{username}", m.PutBot)
5062
managementRouter.HandleFunc("POST /v1/bot/{username}/verify", m.PostVerifyBot)
5163
managementRouter.HandleFunc("PUT /v1/management_room/{roomID}", m.PutManagementRoom)
52-
m.AS.Router.PathPrefix("/_meowlnir").Handler(applyMiddleware(
64+
m.AS.Router.Handle("/_meowlnir/", exhttp.ApplyMiddleware(
5365
http.StripPrefix("/_meowlnir", managementRouter),
5466
hlog.NewHandler(m.Log.With().Str("component", "management api").Logger()),
5567
hlog.RequestIDHandler("request_id", "X-Request-ID"),
5668
exhttp.CORSMiddleware,
5769
requestlog.AccessLogger(requestlog.Options{TrustXForwardedFor: true}),
58-
m.ManagementAuth,
70+
SecretAuth(m.loadSecret(m.Config.Meowlnir.ManagementSecret)),
5971
))
6072
}
61-
62-
func applyMiddleware(router http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
63-
slices.Reverse(middleware)
64-
for _, m := range middleware {
65-
router = m(router)
66-
}
67-
return router
68-
}

cmd/meowlnir/main.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,22 @@ type Meowlnir struct {
5858
EventProcessor *appservice.EventProcessor
5959
PolicyServer *policyeval.PolicyServer
6060

61-
ManagementSecret [32]byte
62-
AntispamSecret [32]byte
63-
6461
PolicyStore *policylist.Store
6562
MapLock sync.RWMutex
6663
Bots map[id.UserID]*bot.Bot
6764
EvaluatorByProtectedRoom map[id.RoomID]*policyeval.PolicyEvaluator
6865
EvaluatorByManagementRoom map[id.RoomID]*policyeval.PolicyEvaluator
6966
HackyAutoRedactPatterns []glob.Glob
7067

68+
appservicePingOnce sync.Once
69+
7170
RoomHashes *roomhash.Map
7271
}
7372

74-
func (m *Meowlnir) loadSecret(secret string) [32]byte {
73+
func (m *Meowlnir) loadSecret(secret string) *[32]byte {
74+
if len(secret) == 0 || (strings.Contains(secret, "disable") && len(secret) < 10) {
75+
return nil
76+
}
7577
if strings.HasPrefix(secret, "sha256:") {
7678
var decoded []byte
7779
var err error
@@ -83,9 +85,9 @@ func (m *Meowlnir) loadSecret(secret string) [32]byte {
8385
m.Log.WithLevel(zerolog.FatalLevel).Msg("Secret hash is not 32 bytes long")
8486
os.Exit(10)
8587
}
86-
return [32]byte(decoded)
88+
return (*[32]byte)(decoded)
8789
}
88-
return util.SHA256String(secret)
90+
return ptr.Ptr(util.SHA256String(secret))
8991
}
9092

9193
func (m *Meowlnir) Init(configPath string, noSaveConfig bool) {
@@ -110,9 +112,6 @@ func (m *Meowlnir) Init(configPath string, noSaveConfig bool) {
110112
Str("go_version", runtime.Version()).
111113
Msg("Initializing Meowlnir")
112114

113-
m.ManagementSecret = m.loadSecret(m.Config.Meowlnir.ManagementSecret)
114-
m.AntispamSecret = m.loadSecret(m.Config.Antispam.Secret)
115-
116115
var mainDB, synapseDB *dbutil.Database
117116
mainDB, err = dbutil.NewFromConfig("meowlnir", m.Config.Database, dbutil.ZeroLogger(m.Log.With().Str("db_section", "main").Logger()))
118117
if err != nil {
@@ -222,6 +221,9 @@ func (m *Meowlnir) initBot(ctx context.Context, db *database.Bot) *bot.Bot {
222221
if wrapped.CryptoHelper != nil {
223222
wrapped.CryptoHelper.CustomPostDecrypt = m.HandleMessage
224223
}
224+
m.appservicePingOnce.Do(func() {
225+
intent.EnsureAppserviceConnection(ctx)
226+
})
225227
m.Bots[wrapped.Client.UserID] = wrapped
226228

227229
managementRooms, err := m.DB.ManagementRoom.GetAll(ctx, db.Username)
@@ -249,6 +251,7 @@ func (m *Meowlnir) newPolicyEvaluator(bot *bot.Bot, roomID id.RoomID) *policyeva
249251
m.createPuppetClient,
250252
m.Config.Antispam.AutoRejectInvitesToken != "",
251253
m.Config.Antispam.FilterLocalInvites,
254+
m.Config.Antispam.NotifyManagementRoom,
252255
m.Config.Meowlnir.DryRun,
253256
m.HackyAutoRedactPatterns,
254257
m.PolicyServer,
@@ -303,6 +306,8 @@ func (m *Meowlnir) Run(ctx context.Context) {
303306
}
304307
}
305308

309+
go m.AS.Start()
310+
306311
bots, err := m.DB.Bot.GetAll(ctx)
307312
if err != nil {
308313
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get bot list")
@@ -313,7 +318,6 @@ func (m *Meowlnir) Run(ctx context.Context) {
313318
}
314319

315320
m.EventProcessor.Start(ctx)
316-
go m.AS.Start()
317321

318322
var wg sync.WaitGroup
319323
m.MapLock.Lock()

0 commit comments

Comments
 (0)