Skip to content

Commit accbce7

Browse files
committed
WIP: Use hll-geofence for enforce cap fight
This is a work-in-progress to demonstrate the usage of hll-geofence for the enforce cap fight seeding automod. It uses the rconv2 protocol and is therefore much more responsive in detecting players out of bounds. Usually a player is notified about being out of the play-able area within 1 second or much less. This commit introduces a new component that is entirely written in golang but also requires access to the configuration data in the db. Therefore there is an added way of an internal API user, which can authenticate to the CRCon api and use any available api without nay explicit grants of permissions. This is a WIP, as it will not take into account saving the seeding automod config, yet (e.g., by restarting the enforce_cap_fight program in supervisor). Hence, when changing the config right now, one would need to restart the program manually. It does also not yet remove the "offensive-point-tracking" version of the enforce cap fight component in the python seeding automod.
1 parent ba6c651 commit accbce7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+15074
-3
lines changed

Dockerfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
FROM golang:1.24-bookworm AS gobuild
2+
3+
WORKDIR /code
4+
5+
COPY seeding/. .
6+
RUN go build -o app automod.go
7+
18
FROM python:3.12-slim
29

310
WORKDIR /code
@@ -13,5 +20,6 @@ RUN chmod +x entrypoint.sh
1320
RUN chmod +x manage.py
1421
RUN chmod +x rconweb/manage.py
1522
ENV LOGGING_FILENAME=startup.log
23+
COPY --from=gobuild /code/app /code/seeding/
1624

1725
ENTRYPOINT [ "/code/entrypoint.sh" ]

config/supervisord.conf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ startretries=5
103103
autostart=true
104104
autorestart=unexpected
105105

106+
[program:enforce_cap_fight]
107+
command=/code/seeding/app
108+
startretries=5
109+
autostart=true
110+
autorestart=unexpected
111+
stderror_logfile=/logs/enforce_cap_fight.log
112+
stdout_logfile=/logs/enforce_cap_fight.log
113+
106114
[program:cron]
107115
environment=LOGGING_FILENAME=cron_%(ENV_SERVER_NUMBER)s.log
108116
command=/bin/bash -c "/usr/bin/crontab /config/crontab && /usr/sbin/cron -f"

rconweb/api/auth.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import csv
22
import datetime
3+
import hashlib
4+
import hmac
35
import json
46
import logging
7+
import os
58
from dataclasses import asdict, dataclass
69
from functools import wraps
710
from typing import Any, Sequence
@@ -18,14 +21,13 @@
1821
from django.db.models.signals import post_delete, post_save
1922
from django.http import HttpResponse, QueryDict, parse_cookie
2023
from django.views.decorators.csrf import csrf_exempt
24+
from rconweb.settings import SECRET_KEY, TAG_VERSION
2125

2226
from rcon.audit import heartbeat, set_registered_mods
2327
from rcon.cache_utils import ttl_cache, invalidates
28+
from rcon.settings import SERVER_INFO
2429
from rcon.types import DjangoGroup, DjangoPermission, DjangoUserPermissions
2530
from rcon.user_config.rcon_server_settings import RconServerSettingsUserConfig
26-
from rconweb.settings import SECRET_KEY, TAG_VERSION
27-
28-
2931
from .decorators import require_content_type, require_http_methods
3032
from .models import DjangoAPIKey, SteamPlayer
3133

@@ -289,6 +291,50 @@ def check_api_key(request):
289291
pass
290292

291293

294+
class InternalUser(User):
295+
"""
296+
Represents a system-user, which uses the CRCon API internally.
297+
This should only ever be used for components that cannot directly invoke the corresponding python APIs directly.
298+
"""
299+
300+
def has_perms(self, perm_list, obj=None) -> bool:
301+
return True
302+
303+
304+
def check_internal_api(request):
305+
"""
306+
Verifies if a request is coming from an internal user, such as the seeding automod in golang.
307+
The user does not have password, and is not included in the standard Django API key or User list.
308+
Everyone with this API key will automatically have every permission available.
309+
310+
The API key is automatically generated from the provided HLL server password and the backend API secret key and
311+
can therefore be automatically created on the user side as well, whenever needed, in a container that shares the same
312+
environment variables.
313+
"""
314+
logger.info("Checking internal")
315+
try:
316+
logger.info(SECRET_KEY)
317+
logger.info(SERVER_INFO.get("password"))
318+
header_name, raw_api_key = request.META[HTTP_AUTHORIZATION_HEADER].split(
319+
maxsplit=1
320+
)
321+
logger.info(header_name)
322+
logger.info(raw_api_key)
323+
if not header_name.upper().strip() in BEARER:
324+
return
325+
api_key = hmac.new(
326+
SECRET_KEY.encode(),
327+
msg=SERVER_INFO.get("password").encode(),
328+
digestmod=hashlib.sha256
329+
).hexdigest().upper()
330+
if raw_api_key == api_key:
331+
request.user = InternalUser(username="system")
332+
333+
except (KeyError, ValueError) as e:
334+
logger.error("", e)
335+
pass
336+
337+
292338
def login_required():
293339
"""Flag this endpoint as one that requires the user
294340
to be logged in.
@@ -297,6 +343,7 @@ def login_required():
297343
def decorator(func):
298344
@wraps(func)
299345
def wrapper(request, *args, **kwargs):
346+
check_internal_api(request)
300347
# Check if API-Key is used
301348
check_api_key(request)
302349

seeding/automod.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"github.com/floriansw/go-hll-rcon/rconv2"
6+
"github.com/floriansw/hll-geofences/data"
7+
"github.com/floriansw/hll-geofences/worker"
8+
"log/slog"
9+
"os"
10+
"os/signal"
11+
"seeding/internal"
12+
"seeding/internal/crcon"
13+
"strconv"
14+
"syscall"
15+
)
16+
17+
var (
18+
hllPassword = os.Getenv("HLL_PASSWORD")
19+
hllHost = os.Getenv("HLL_HOST")
20+
hllPort = os.Getenv("HLL_PORT")
21+
rconWebApiSecret = os.Getenv("RCONWEB_API_SECRET")
22+
rconBackendUrl = os.Getenv("RCONWEB_BACKEND_URL")
23+
24+
punishAfterSeconds = 10
25+
26+
// designates the map orientation from left to right
27+
alliedToAxisHorizontalMaps = []string{"CARENTAN", "HILL 400", "HÜRTGEN FOREST", "MORTAIN"}
28+
axisToAlliedHorizontalMaps = []string{"EL ALAMEIN", "OMAHA BEACH", "SAINTE-MÈRE-ÉGLISE", "STALINGRAD", "TOBRUK", "UTAH BEACH"}
29+
// designates the map orientation from top to bottom
30+
alliedToAxisVerticalMaps = []string{"ELSENBORN RIDGE", "KHARKOV", "KURSK", "PURPLE HEART LANE", "ST MARIE DU MONT"}
31+
axisToAlliedVerticalMaps = []string{"DRIEL", "FOY", "REMAGEN"}
32+
33+
horizontalCaps = []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"}
34+
)
35+
36+
func main() {
37+
level := slog.LevelInfo
38+
if _, ok := os.LookupEnv("DEBUG"); ok {
39+
level = slog.LevelDebug
40+
}
41+
42+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
43+
44+
if rconBackendUrl == "" {
45+
rconBackendUrl = "http://backend:8000"
46+
}
47+
48+
ctx := context.Background()
49+
a := internal.NewAuthentication(hllPassword, rconWebApiSecret)
50+
ac, err := crcon.NewClient(rconBackendUrl, a).GetAutoModSeedingConfig(ctx)
51+
if err != nil {
52+
logger.Error("find-seeding-automod-config", "error", err)
53+
return
54+
}
55+
56+
// Prefilling fences for first-spawners on the server. Whenever a player joins a server, they are randomly assigned
57+
// a spawn on either side (even before side selection). To not give them a warning that they are outside of the spawns
58+
// during seeding, the HQ lines for each team opponent are allowlisted as well.
59+
alliedFences := []data.Fence{
60+
{
61+
X: &horizontalCaps[0],
62+
Condition: &data.Condition{
63+
Equals: map[string][]string{
64+
"map_name": axisToAlliedHorizontalMaps,
65+
},
66+
},
67+
},
68+
{
69+
X: &horizontalCaps[9],
70+
Condition: &data.Condition{
71+
Equals: map[string][]string{
72+
"map_name": alliedToAxisHorizontalMaps,
73+
},
74+
},
75+
},
76+
{
77+
Y: internal.Pointer(1),
78+
Condition: &data.Condition{
79+
Equals: map[string][]string{
80+
"map_name": axisToAlliedVerticalMaps,
81+
},
82+
},
83+
},
84+
{
85+
Y: internal.Pointer(10),
86+
Condition: &data.Condition{
87+
Equals: map[string][]string{
88+
"map_name": alliedToAxisVerticalMaps,
89+
},
90+
},
91+
},
92+
}
93+
axisFences := []data.Fence{
94+
{
95+
X: &horizontalCaps[9],
96+
Condition: &data.Condition{
97+
Equals: map[string][]string{
98+
"map_name": axisToAlliedHorizontalMaps,
99+
},
100+
},
101+
},
102+
{
103+
X: &horizontalCaps[0],
104+
Condition: &data.Condition{
105+
Equals: map[string][]string{
106+
"map_name": alliedToAxisHorizontalMaps,
107+
},
108+
},
109+
},
110+
{
111+
Y: internal.Pointer(10),
112+
Condition: &data.Condition{
113+
Equals: map[string][]string{
114+
"map_name": axisToAlliedVerticalMaps,
115+
},
116+
},
117+
},
118+
{
119+
Y: internal.Pointer(1),
120+
Condition: &data.Condition{
121+
Equals: map[string][]string{
122+
"map_name": alliedToAxisVerticalMaps,
123+
},
124+
},
125+
},
126+
}
127+
128+
// fill the remaining caps as configured by the user.
129+
for c := range ac.EnforceCapFight.MaxCaps * 2 {
130+
alliedFences = append(alliedFences, data.Fence{
131+
X: &horizontalCaps[9-c],
132+
Condition: &data.Condition{
133+
Equals: map[string][]string{
134+
"map_name": axisToAlliedHorizontalMaps,
135+
},
136+
},
137+
})
138+
alliedFences = append(alliedFences, data.Fence{
139+
X: &horizontalCaps[c],
140+
Condition: &data.Condition{
141+
Equals: map[string][]string{
142+
"map_name": alliedToAxisHorizontalMaps,
143+
},
144+
},
145+
})
146+
147+
axisFences = append(axisFences, data.Fence{
148+
X: &horizontalCaps[c],
149+
Condition: &data.Condition{
150+
Equals: map[string][]string{
151+
"map_name": axisToAlliedHorizontalMaps,
152+
},
153+
},
154+
})
155+
axisFences = append(axisFences, data.Fence{
156+
X: &horizontalCaps[9-c],
157+
Condition: &data.Condition{
158+
Equals: map[string][]string{
159+
"map_name": alliedToAxisHorizontalMaps,
160+
},
161+
},
162+
})
163+
164+
alliedFences = append(alliedFences, data.Fence{
165+
Y: internal.Pointer(10 - c),
166+
Condition: &data.Condition{
167+
Equals: map[string][]string{
168+
"map_name": axisToAlliedVerticalMaps,
169+
},
170+
},
171+
})
172+
alliedFences = append(alliedFences, data.Fence{
173+
Y: internal.Pointer(c + 1),
174+
Condition: &data.Condition{
175+
Equals: map[string][]string{
176+
"map_name": alliedToAxisVerticalMaps,
177+
},
178+
},
179+
})
180+
axisFences = append(axisFences, data.Fence{
181+
Y: internal.Pointer(c + 1),
182+
Condition: &data.Condition{
183+
Equals: map[string][]string{
184+
"map_name": axisToAlliedVerticalMaps,
185+
},
186+
},
187+
})
188+
axisFences = append(axisFences, data.Fence{
189+
Y: internal.Pointer(10 - c),
190+
Condition: &data.Condition{
191+
Equals: map[string][]string{
192+
"map_name": alliedToAxisVerticalMaps,
193+
},
194+
},
195+
})
196+
}
197+
198+
// player count condition is always the same, same as game mode, hence adding it once only.
199+
for _, fence := range axisFences {
200+
fence.Condition.GreaterThan = map[string]int{
201+
"player_count": ac.EnforceCapFight.MinPlayers,
202+
}
203+
fence.Condition.LessThan = map[string]int{
204+
"player_count": ac.EnforceCapFight.MaxPlayers,
205+
}
206+
fence.Condition.Equals["game_mode"] = []string{"Warfare"}
207+
}
208+
209+
if ac.Enabled == false || ac.DryRun == true {
210+
logger.Info("enforce-cap-fight-disabled")
211+
} else {
212+
port, err := strconv.Atoi(hllPort)
213+
if err != nil {
214+
logger.Error("parse-port", "error", err)
215+
return
216+
}
217+
pool, err := rconv2.NewConnectionPool(rconv2.ConnectionPoolOptions{
218+
Logger: logger,
219+
Hostname: hllHost,
220+
Port: port,
221+
Password: hllPassword,
222+
MaxIdleConnections: internal.Pointer(2),
223+
MaxOpenConnections: internal.Pointer(5),
224+
})
225+
if err != nil {
226+
logger.Error("create-connection-pool", "server", hllHost, "error", err)
227+
return
228+
}
229+
w := worker.NewWorker(logger, pool, data.Server{
230+
Host: hllHost,
231+
Port: port,
232+
Password: hllPassword,
233+
PunishAfterSeconds: &punishAfterSeconds,
234+
AxisFence: axisFences,
235+
AlliesFence: alliedFences,
236+
Messages: &data.Messages{
237+
Warning: nil,
238+
Punish: &ac.EnforceCapFight.ViolationMessage,
239+
},
240+
})
241+
go w.Run(ctx)
242+
logger.Info("started-worker")
243+
}
244+
245+
stop := make(chan os.Signal, 1)
246+
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
247+
<-stop
248+
249+
logger.Info("graceful-shutdown")
250+
}

seeding/go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module seeding
2+
3+
go 1.24.1
4+
5+
require (
6+
github.com/floriansw/go-hll-rcon v0.0.0-20250421114312-973708b529c4
7+
github.com/floriansw/hll-geofences v0.0.0-20250424221049-a62713ee5a0c
8+
)
9+
10+
require gopkg.in/yaml.v3 v3.0.1 // indirect

0 commit comments

Comments
 (0)