Skip to content

Commit e9d3823

Browse files
add anticheat system (#250)
1 parent 961349b commit e9d3823

19 files changed

Lines changed: 1809 additions & 101 deletions

anticheat/anticheat.go

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
package anticheat
2+
3+
import (
4+
"database/sql"
5+
"fmt"
6+
"log"
7+
"time"
8+
)
9+
10+
type AntiCheat struct {
11+
violations map[string][]time.Time
12+
failedAuth map[string][]time.Time
13+
db *sql.DB
14+
dispatch chan func()
15+
onBan func(accountID int32)
16+
}
17+
18+
func New(db *sql.DB, dispatch chan func()) *AntiCheat {
19+
return &AntiCheat{
20+
violations: make(map[string][]time.Time),
21+
failedAuth: make(map[string][]time.Time),
22+
db: db,
23+
dispatch: dispatch,
24+
}
25+
}
26+
27+
func (ac *AntiCheat) SetOnBan(fn func(accountID int32)) {
28+
ac.onBan = fn
29+
}
30+
31+
// post dispatches function to server's main loop
32+
func (ac *AntiCheat) post(fn func()) {
33+
if ac.dispatch != nil {
34+
select {
35+
case ac.dispatch <- fn:
36+
return
37+
default:
38+
fn()
39+
return
40+
}
41+
}
42+
fn()
43+
}
44+
45+
// StartCleanup starts periodic cleanup of old violations/auth entries
46+
func (ac *AntiCheat) StartCleanup() {
47+
ticker := time.NewTicker(5 * time.Minute)
48+
go func() {
49+
for range ticker.C {
50+
ac.post(func() {
51+
cutoff := time.Now().Add(-1 * time.Hour)
52+
53+
for k, timestamps := range ac.violations {
54+
var keep []time.Time
55+
for _, t := range timestamps {
56+
if t.After(cutoff) {
57+
keep = append(keep, t)
58+
}
59+
}
60+
if len(keep) > 0 {
61+
ac.violations[k] = keep
62+
} else {
63+
delete(ac.violations, k)
64+
}
65+
}
66+
67+
for k, timestamps := range ac.failedAuth {
68+
var keep []time.Time
69+
for _, t := range timestamps {
70+
if t.After(cutoff) {
71+
keep = append(keep, t)
72+
}
73+
}
74+
if len(keep) > 0 {
75+
ac.failedAuth[k] = keep
76+
} else {
77+
delete(ac.failedAuth, k)
78+
}
79+
}
80+
})
81+
}
82+
}()
83+
}
84+
85+
// Track a violation - returns true if threshold exceeded and player should be banned
86+
func (ac *AntiCheat) Track(accountID int32, violationType string, threshold int, window time.Duration) bool {
87+
key := fmt.Sprintf("%d:%s", accountID, violationType)
88+
exceeded := false
89+
90+
ac.post(func() {
91+
now := time.Now()
92+
cutoff := now.Add(-window)
93+
94+
timestamps := []time.Time{now}
95+
for _, t := range ac.violations[key] {
96+
if t.After(cutoff) {
97+
timestamps = append(timestamps, t)
98+
}
99+
}
100+
ac.violations[key] = timestamps
101+
exceeded = len(timestamps) >= threshold
102+
})
103+
104+
return exceeded
105+
}
106+
107+
// Track failed auth attempt - returns true if should ban
108+
func (ac *AntiCheat) TrackFailedAuth(identifier string) bool {
109+
shouldBan := false
110+
111+
ac.post(func() {
112+
now := time.Now()
113+
cutoff := now.Add(-30 * time.Minute)
114+
115+
timestamps := []time.Time{now}
116+
for _, t := range ac.failedAuth[identifier] {
117+
if t.After(cutoff) {
118+
timestamps = append(timestamps, t)
119+
}
120+
}
121+
ac.failedAuth[identifier] = timestamps
122+
shouldBan = len(timestamps) >= 10
123+
})
124+
125+
return shouldBan
126+
}
127+
128+
// Clear auth attempts on successful login
129+
func (ac *AntiCheat) ClearAuth(identifiers ...string) {
130+
ac.post(func() {
131+
for _, id := range identifiers {
132+
delete(ac.failedAuth, id)
133+
}
134+
})
135+
}
136+
137+
// IssueBan creates a temporary ban (hours=0 means permanent)
138+
func (ac *AntiCheat) IssueBan(accountID int32, hours int, reason string, ip, hwid string) error {
139+
var banEnd time.Time
140+
if hours > 0 {
141+
banEnd = time.Now().Add(time.Duration(hours) * time.Hour)
142+
}
143+
144+
var accountParam any
145+
if accountID == 0 {
146+
accountParam = nil
147+
} else {
148+
accountParam = accountID
149+
}
150+
151+
_, err := ac.db.Exec(`INSERT INTO bans (accountID, reason, banEnd, ip, hwid) VALUES (?, ?, ?, ?, ?)`,
152+
accountParam, reason, banEnd, ip, hwid)
153+
if err != nil {
154+
return err
155+
}
156+
157+
if accountID > 0 {
158+
_, err = ac.db.Exec(`UPDATE accounts SET isBanned = 1 WHERE accountID = ?`, accountID)
159+
160+
if ac.onBan != nil {
161+
ac.post(func() {
162+
if ac.onBan != nil {
163+
ac.onBan(accountID)
164+
}
165+
})
166+
}
167+
168+
if hours > 0 {
169+
count, _ := ac.incrementTempBans(accountID)
170+
if count >= 3 {
171+
err := ac.IssueBan(accountID, 0, "Escalated: 3+ temporary bans", ip, hwid)
172+
if err != nil {
173+
return err
174+
}
175+
}
176+
}
177+
}
178+
return err
179+
}
180+
181+
func (ac *AntiCheat) LockAccount(accountID int32) error {
182+
_, err := ac.db.Exec(`UPDATE accounts SET isLocked = 1 WHERE accountID = ?`, accountID)
183+
return err
184+
}
185+
186+
func (ac *AntiCheat) IsBanned(accountID int32, ip, hwid string) (bool, string, int64, error) {
187+
var reason string
188+
var banEndRaw []byte
189+
190+
err := ac.db.QueryRow(`
191+
SELECT reason, banEnd FROM bans
192+
WHERE (accountID = ? OR ip = ? OR (hwid = ? AND hwid != ''))
193+
AND (banEnd IS NULL OR banEnd > NOW())
194+
ORDER BY createdAt DESC
195+
LIMIT 1`, accountID, ip, hwid).Scan(&reason, &banEndRaw)
196+
197+
if err == sql.ErrNoRows {
198+
return false, "", 0, nil
199+
}
200+
201+
if err != nil {
202+
log.Println(err)
203+
return false, "", 0, err
204+
}
205+
206+
if banEndRaw == nil {
207+
return true, reason, 0, nil
208+
}
209+
210+
s := string(banEndRaw)
211+
layouts := []string{
212+
"2006-01-02 15:04:05",
213+
"2006-01-02 15:04:05.999999",
214+
"2006-01-02 15:04:05.999999999",
215+
}
216+
217+
var parsed time.Time
218+
var parseErr error
219+
for _, layout := range layouts {
220+
parsed, parseErr = time.ParseInLocation(layout, s, time.Local)
221+
if parseErr == nil {
222+
return true, reason, parsed.UnixMilli()*10000 + 116444592000000000, nil
223+
}
224+
}
225+
226+
return true, reason, 0, fmt.Errorf("failed to parse banEnd %q: %w", s, parseErr)
227+
}
228+
229+
func (ac *AntiCheat) accountIDByPlayerName(name string) (int32, error) {
230+
var accountID int32
231+
err := ac.db.QueryRow(
232+
`SELECT accountID FROM characters WHERE name = ? LIMIT 1`,
233+
name,
234+
).Scan(&accountID)
235+
if err != nil {
236+
if err == sql.ErrNoRows {
237+
return 0, fmt.Errorf("player %q not found", name)
238+
}
239+
return 0, err
240+
}
241+
return accountID, nil
242+
}
243+
244+
// Unban removes all bans for an account (resolved by player name)
245+
func (ac *AntiCheat) Unban(name string) error {
246+
accountID, err := ac.accountIDByPlayerName(name)
247+
if err != nil {
248+
return err
249+
}
250+
251+
if _, err := ac.db.Exec(`DELETE FROM bans WHERE accountID = ?`, accountID); err != nil {
252+
return err
253+
}
254+
255+
_, err = ac.db.Exec(`UPDATE accounts SET isBanned = 0 WHERE accountID = ?`, accountID)
256+
return err
257+
}
258+
259+
// GetBanHistory returns recent ban records (resolved by player name)
260+
func (ac *AntiCheat) GetBanHistory(name string, limit int) ([]string, error) {
261+
accountID, err := ac.accountIDByPlayerName(name)
262+
if err != nil {
263+
return nil, err
264+
}
265+
266+
rows, err := ac.db.Query(`
267+
SELECT reason, banEnd, createdAt FROM bans
268+
WHERE accountID = ?
269+
ORDER BY createdAt DESC
270+
LIMIT ?`, accountID, limit)
271+
if err != nil {
272+
return nil, err
273+
}
274+
defer rows.Close()
275+
276+
var history []string
277+
for rows.Next() {
278+
var reason string
279+
var banEnd sql.NullTime
280+
var createdAt time.Time
281+
if err := rows.Scan(&reason, &banEnd, &createdAt); err != nil {
282+
continue
283+
}
284+
285+
durStr := "permanent"
286+
if banEnd.Valid {
287+
durStr = banEnd.Time.Format("2006-01-02 15:04")
288+
}
289+
history = append(history, fmt.Sprintf("%s: %s (until %s)",
290+
createdAt.Format("2006-01-02 15:04"), reason, durStr))
291+
}
292+
return history, rows.Err()
293+
}
294+
295+
// Internal: track temp ban count for escalation
296+
func (ac *AntiCheat) incrementTempBans(accountID int32) (int, error) {
297+
_, err := ac.db.Exec(`
298+
INSERT INTO ban_escalation (accountID, count) VALUES (?, 1)
299+
ON DUPLICATE KEY UPDATE count = count + 1`, accountID)
300+
if err != nil {
301+
return 0, err
302+
}
303+
304+
var count int
305+
err = ac.db.QueryRow(`SELECT count FROM ban_escalation WHERE accountID = ?`, accountID).Scan(&count)
306+
return count, err
307+
}
308+
309+
// Detection helpers - track violations and auto-ban on threshold
310+
func (ac *AntiCheat) LogDamageViolation(accountID int32, damage, maxDamage int32) {
311+
if damage > maxDamage*2 {
312+
if ac.Track(accountID, "damage", 5, 5*time.Minute) {
313+
ac.IssueBan(accountID, 168, fmt.Sprintf("Excessive damage: %d > %d", damage, maxDamage), "", "")
314+
}
315+
}
316+
}
317+
318+
func (ac *AntiCheat) LogAttackSpeedViolation(accountID int32) bool {
319+
return ac.Track(accountID, "attack_speed", 120, 1*time.Minute)
320+
}
321+
322+
func (ac *AntiCheat) LogMovementViolation(accountID int32, distance int16, moveType byte) {
323+
if distance > 1000 {
324+
log.Println("Teleport hack detected - movement type:", moveType, fmt.Sprintf("Suspicious movement: %d pixels", distance), "accountID:", accountID)
325+
if ac.Track(accountID, "teleport", 3, 5*time.Minute) {
326+
ac.IssueBan(accountID, 168, fmt.Sprintf("Teleport hack: %d pixels", distance), "", "")
327+
}
328+
}
329+
}
330+
331+
func (ac *AntiCheat) LogInvalidItemViolation(accountID int32) {
332+
if ac.Track(accountID, "invalid_item", 5, 5*time.Minute) {
333+
ac.IssueBan(accountID, 168, "Using items not in inventory", "", "")
334+
}
335+
}
336+
337+
func (ac *AntiCheat) LogInvalidTradeViolation(accountID int32, reason string) {
338+
if ac.Track(accountID, "invalid_trade", 5, 5*time.Minute) {
339+
ac.IssueBan(accountID, 168, "Invalid trade: "+reason, "", "")
340+
}
341+
}
342+
343+
func (ac *AntiCheat) LogSkillAbuseViolation(accountID int32, skillID int32) {
344+
if ac.Track(accountID, "skill_abuse", 5, 5*time.Minute) {
345+
ac.IssueBan(accountID, 168, fmt.Sprintf("Skill abuse: ID %d", skillID), "", "")
346+
}
347+
}

0 commit comments

Comments
 (0)