Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,9 @@ jobs:
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH"

- name: Enable jsonv2
run: export GOEXPERIMENT=jsonv2
if: matrix.go-version == '1.25'

- name: Run pre-commit
uses: pre-commit/action@v3.0.1
1 change: 1 addition & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ include:

variables:
BINARY_NAME: meowlnir
GOEXPERIMENT: jsonv2
67 changes: 58 additions & 9 deletions cmd/meowlnir/policyserver.go
Original file line number Diff line number Diff line change
@@ -1,43 +1,92 @@
//go:build goexperiment.jsonv2

package main

import (
"encoding/json"
"encoding/json/v2"
"io"
"net/http"
"regexp"

"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exhttp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/federation"
"maunium.net/go/mautrix/federation/pdu"
"maunium.net/go/mautrix/id"

"go.mau.fi/meowlnir/policyeval"
)

var eventIDRegex = regexp.MustCompile(`^\$[A-Za-z0-9_-]{43}$`)

const pduSizeLimit = 64 * 1024 // 64 KiB
const bodySizeLimit = pduSizeLimit * 1.5 // Leave a bit of room for whitespace

func (m *Meowlnir) PostMSC4284EventCheck(w http.ResponseWriter, r *http.Request) {
eventID := id.EventID(r.PathValue("event_id"))
var req event.Event
err := json.NewDecoder(r.Body).Decode(&req)
// Only room v4+ is supported
if !eventIDRegex.MatchString(string(eventID)) {
mautrix.MInvalidParam.WithMessage("Invalid event ID format").Write(w)
return
} else if r.ContentLength > bodySizeLimit {
mautrix.MTooLarge.WithMessage("PDUs must be less than 64 KiB").Write(w)
return
}
if r.ContentLength >= 0 && r.ContentLength <= 2 {
resp := m.PolicyServer.HandleCachedCheck(eventID)
if resp == nil {
mautrix.MNotFound.WithMessage("Event not found in cache, please provide it in the request").Write(w)
} else if resp.Recommendation == "spam" && m.Config.Meowlnir.DryRun {
exhttp.WriteJSONResponse(w, http.StatusOK, &policyeval.PolicyServerResponse{Recommendation: "ok"})
} else {
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
}
return
}
var parsedPDU *pdu.PDU
err := json.UnmarshalRead(io.LimitReader(r.Body, bodySizeLimit), &parsedPDU)
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to parse request body")
mautrix.MNotJSON.WithMessage("Request body is not valid JSON").Write(w)
return
}

var ok bool
m.MapLock.RLock()
eval, ok := m.EvaluatorByProtectedRoom[req.RoomID]
eval, ok := m.EvaluatorByProtectedRoom[parsedPDU.RoomID]
m.MapLock.RUnlock()
if !ok {
mautrix.MNotFound.WithMessage("Policy server error: room is not protected").Write(w)
return
}
createEvt := eval.GetProtectedRoomCreateEvent(parsedPDU.RoomID)
if createEvt == nil {
mautrix.MNotFound.WithMessage("Policy server error: room create event not found").Write(w)
return
}
expectedEventID, err := parsedPDU.CalculateEventID(createEvt.RoomVersion)
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to calculate event ID from PDU")
mautrix.MUnknown.WithMessage("Failed to calculate event ID from PDU").Write(w)
return
} else if expectedEventID != eventID {
mautrix.MInvalidParam.WithMessage("Event ID does not match hash of request body").Write(w)
return
}
clientEvent, err := parsedPDU.ToClientEvent(createEvt.RoomVersion)
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to convert PDU to client event")
mautrix.MUnknown.WithMessage("Failed to convert PDU to client event").Write(w)
return
}

resp, err := m.PolicyServer.HandleCheck(
r.Context(),
eventID,
&req,
clientEvent,
eval,
m.Config.PolicyServer.AlwaysRedact && !m.Config.Meowlnir.DryRun,
federation.OriginServerNameFromRequest(r))
federation.OriginServerNameFromRequest(r),
)
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to handle check")
mautrix.MUnknown.WithMessage("Policy server error: internal server error").Write(w)
Expand Down
13 changes: 13 additions & 0 deletions cmd/meowlnir/policyserver_disabled.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !goexperiment.jsonv2

package main

import (
"net/http"

"maunium.net/go/mautrix"
)

func (m *Meowlnir) PostMSC4284EventCheck(w http.ResponseWriter, r *http.Request) {
mautrix.MUnrecognized.WithMessage("This Meowlnir wasn't compiled with jsonv2 for policy server support").Write(w)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ require (
golang.org/x/sync v0.16.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.25.0
maunium.net/go/mautrix v0.25.1-0.20250817104534-cc80be150059
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,5 @@ 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/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.25.0 h1:dhYoXIXSxI9A+kEPwBceuRP0wcpho15dVLucUF8k2eE=
maunium.net/go/mautrix v0.25.0/go.mod h1:pDd6Ppg+1PbWrw/rg4ZQQfVYZICRGzH+DcliZ/BODvU=
maunium.net/go/mautrix v0.25.1-0.20250817104534-cc80be150059 h1:ldFZJ2MKcRLje69uIs3RmxKZSW2TQVRjj1aL4Di0saY=
maunium.net/go/mautrix v0.25.1-0.20250817104534-cc80be150059/go.mod h1:pDd6Ppg+1PbWrw/rg4ZQQfVYZICRGzH+DcliZ/BODvU=
1 change: 1 addition & 0 deletions policyeval/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
type protectedRoomMeta struct {
Name string
ACL *event.ServerACLEventContent
Create *event.CreateEventContent
ApplyACL bool
}

Expand Down
20 changes: 18 additions & 2 deletions policyeval/policyserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func (ps *PolicyServer) getCache(evtID id.EventID, pdu *event.Event) *psCacheEnt
defer ps.cacheLock.Unlock()
entry, ok := ps.eventCache[evtID]
if !ok {
if pdu == nil {
return nil
}
ps.unlockedClearCacheIfNeeded()
entry = &psCacheEntry{LastAccessed: time.Now(), PDU: pdu}
ps.eventCache[evtID] = entry
Expand Down Expand Up @@ -110,6 +113,20 @@ func (ps *PolicyServer) getRecommendation(pdu *event.Event, evaluator *PolicyEva
return PSRecommendationOk, nil
}

func (ps *PolicyServer) HandleCachedCheck(evtID id.EventID) *PolicyServerResponse {
r := ps.getCache(evtID, nil)
if r == nil {
return nil
}
r.Lock.Lock()
defer r.Lock.Unlock()
if r.Recommendation == "" {
return nil
}
r.LastAccessed = time.Now()
return &PolicyServerResponse{Recommendation: r.Recommendation}
}

func (ps *PolicyServer) HandleCheck(
ctx context.Context,
evtID id.EventID,
Expand All @@ -121,13 +138,12 @@ func (ps *PolicyServer) HandleCheck(
log := zerolog.Ctx(ctx).With().
Stringer("room_id", pdu.RoomID).
Stringer("event_id", evtID).
Str("caller", caller).
Logger()
r := ps.getCache(evtID, pdu)
finalRec := r.Recommendation
r.Lock.Lock()
defer func() {
defer r.Lock.Unlock()
r.Lock.Unlock()
if caller != pdu.Sender.Homeserver() && finalRec == PSRecommendationSpam && redact {
go func() {
if _, err = evaluator.Bot.RedactEvent(context.WithoutCancel(ctx), pdu.RoomID, evtID); err != nil {
Expand Down
43 changes: 36 additions & 7 deletions policyeval/protectedrooms.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,27 @@ func (pe *PolicyEvaluator) IsProtectedRoom(roomID id.RoomID) bool {
return protected
}

func (pe *PolicyEvaluator) GetProtectedRoomCreateEvent(roomID id.RoomID) *event.CreateEventContent {
pe.protectedRoomsLock.RLock()
meta, ok := pe.protectedRooms[roomID]
pe.protectedRoomsLock.RUnlock()
if !ok {
return nil
}
return meta.Create
}

func (pe *PolicyEvaluator) HandleProtectedRoomMeta(ctx context.Context, evt *event.Event) {
switch evt.Type {
case event.StatePowerLevels:
pe.handleProtectedRoomPowerLevels(ctx, evt)
case event.StateCreate:
pe.protectedRoomsLock.Lock()
meta, ok := pe.protectedRooms[evt.RoomID]
if ok {
meta.Create = evt.Content.AsCreate()
}
pe.protectedRoomsLock.Unlock()
case event.StateRoomName:
pe.protectedRoomsLock.Lock()
meta, ok := pe.protectedRooms[evt.RoomID]
Expand Down Expand Up @@ -142,15 +159,16 @@ func (pe *PolicyEvaluator) tryProtectingRoom(ctx context.Context, joinedRooms *m
return nil, fmt.Sprintf("* Bot is not in protected room [%s](%s) and joining failed: %v", roomID, roomID.URI().MatrixToURL(), err)
}
}
createEvt, err := pe.Bot.FullStateEvent(ctx, roomID, event.StateCreate, "")
if err != nil {
return nil, fmt.Sprintf("* Failed to get create event for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err)
}
var powerLevels event.PowerLevelsEventContent
err = pe.Bot.StateEvent(ctx, roomID, event.StatePowerLevels, "", &powerLevels)
if err != nil {
return nil, fmt.Sprintf("* Failed to get power levels for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err)
}
powerLevels.CreateEvent, err = pe.Bot.FullStateEvent(ctx, roomID, event.StateCreate, "")
if err != nil {
return nil, fmt.Sprintf("* Failed to get creation content for [%s](%s): %v", roomID, roomID.URI().MatrixToURL(), err)
}
powerLevels.CreateEvent = createEvt
ownLevel := powerLevels.GetUserLevel(pe.Bot.UserID)
minLevel := max(powerLevels.Ban(), powerLevels.Redact())
if ownLevel < minLevel && !pe.DryRun {
Expand All @@ -171,7 +189,7 @@ func (pe *PolicyEvaluator) tryProtectingRoom(ctx context.Context, joinedRooms *m
zerolog.Ctx(ctx).Warn().Err(err).Stringer("room_id", roomID).Msg("Failed to get server ACL")
}
slices.Sort(acl.Deny)
pe.markAsProtectedRoom(roomID, name.Name, &acl, members.Chunk)
pe.markAsProtectedRoom(roomID, name.Name, &acl, createEvt.Content.AsCreate(), members.Chunk)
if doReeval {
memberIDs := make([]id.UserID, len(members.Chunk))
for i, member := range members.Chunk {
Expand Down Expand Up @@ -247,10 +265,21 @@ func (pe *PolicyEvaluator) markAsWantToProtect(roomID id.RoomID) {
pe.wantToProtect[roomID] = struct{}{}
}

func (pe *PolicyEvaluator) markAsProtectedRoom(roomID id.RoomID, name string, acl *event.ServerACLEventContent, evts []*event.Event) {
func (pe *PolicyEvaluator) markAsProtectedRoom(
roomID id.RoomID,
name string,
acl *event.ServerACLEventContent,
create *event.CreateEventContent,
evts []*event.Event,
) {
pe.protectedRoomsLock.Lock()
defer pe.protectedRoomsLock.Unlock()
pe.protectedRooms[roomID] = &protectedRoomMeta{Name: name, ACL: acl, ApplyACL: !slices.Contains(pe.skipACLForRooms, roomID)}
pe.protectedRooms[roomID] = &protectedRoomMeta{
Name: name,
ACL: acl,
Create: create,
ApplyACL: !slices.Contains(pe.skipACLForRooms, roomID),
}
delete(pe.wantToProtect, roomID)
for _, evt := range evts {
pe.unlockedUpdateUser(id.UserID(evt.GetStateKey()), evt.RoomID, evt.Content.AsMember().Membership)
Expand Down