diff --git a/DEPLOY.md b/DEPLOY.md index 81b6bbb..d138068 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -100,8 +100,21 @@ with a cron job instead: tar czf ~/mc-backup-$(date +\%F).tgz -C ~/minecraft/data world ops.json whitelist.json server.properties ``` -> The daily **playtime limiter** (`playtime_limit.ps1`) is Windows-only too. -> A Linux (cron + rcon) version isn't written yet — ask for it when you move here. +> ### Daily playtime limits are NOT running +> +> There used to be a Windows script (`playtime_limit.ps1`) that read each kid's +> `play_time` from the server stats, and once they passed a daily cap (default +> 120 minutes) removed them from the whitelist, which `ENFORCE_WHITELIST` turns +> into an instant kick. It put them back at the next day rollover. +> +> It only ever worked against a local Windows host and has not run since the move +> to the droplet. It was deleted rather than left in the repo pretending to be a +> control that was actually switched off. +> +> **So there is currently no time limit of any kind.** If you want one back, it +> needs writing for Linux: a cron job every few minutes calling +> `docker exec minecraft-java rcon-cli` to read stats and adjust the whitelist. +> The original logic is in git history if it is ever wanted as a starting point. ## Later: add a domain diff --git a/backup_server.ps1 b/backup_server.ps1 deleted file mode 100644 index 3f5934b..0000000 --- a/backup_server.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -# Minecraft Server Backup Script -# Saves a timestamped zip of /data to /backup - -$BackupDir = "$PSScriptRoot\backup" -$DataDir = "$PSScriptRoot\data" -$Timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" -$ZipName = "minecraft-backup_$Timestamp.zip" -$ZipPath = Join-Path $BackupDir $ZipName - -# Create backup folder if it doesn't exist -if (-not (Test-Path $BackupDir)) { - New-Item -ItemType Directory -Path $BackupDir | Out-Null -} - -# Tell the server to save before backing up -Write-Host "Saving server data..." -docker exec minecraft-java rcon-cli save-all -Start-Sleep -Seconds 3 - -# Collect important files/folders (skip locked JARs and cache) -$Include = @("world", "ops.json", "whitelist.json", "server.properties", - "banned-players.json", "banned-ips.json", "usercache.json", "config", "mods") - -$TempZipDir = Join-Path $env:TEMP "mc_backup_$Timestamp" -New-Item -ItemType Directory -Path $TempZipDir | Out-Null - -foreach ($item in $Include) { - $src = Join-Path $DataDir $item - if (Test-Path $src) { - Copy-Item -Path $src -Destination $TempZipDir -Recurse -Force - } -} - -# Compress the staging directory -Write-Host "Creating backup: $ZipName" -Compress-Archive -Path "$TempZipDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal - -# Clean up temp staging -Remove-Item -Recurse -Force $TempZipDir - -Write-Host "Backup complete: $ZipPath" diff --git a/playtime_limit.ps1 b/playtime_limit.ps1 deleted file mode 100644 index 2f54d47..0000000 --- a/playtime_limit.ps1 +++ /dev/null @@ -1,89 +0,0 @@ -# Daily playtime limiter for kids. -# Reads each whitelisted player's total play_time from the server stats, -# tracks how much they've played *today*, and when they hit the daily cap it -# removes them from the whitelist (ENFORCE_WHITELIST kicks them instantly). -# At the next day rollover it puts them back automatically. -# -# Run this every few minutes via Task Scheduler (see register_playtime_policy.ps1). -# Test safely first: .\playtime_limit.ps1 -DryRun - -param( - [int] $LimitMinutes = 120, # daily cap per kid, in minutes - [string] $Container = "minecraft-java", - [switch] $DryRun # print actions instead of running them -) - -$DataDir = "$PSScriptRoot\data" -$StatsDir = "$PSScriptRoot\data\world\stats" -$Whitelist = "$PSScriptRoot\data\whitelist.json" -$StateFile = "$PSScriptRoot\playtime_state.json" -# ponytail: "today" uses host local time. Host TZ should match the server's TZ -# (America/New_York) or the reset moment will drift from the kids' midnight. -$Today = (Get-Date).ToString('yyyy-MM-dd') -$LimitTicks = $LimitMinutes * 60 * 20 # play_time is stored in game ticks (20/sec) - -function Invoke-Rcon([string[]]$CmdArgs) { - if ($DryRun) { Write-Host "[dry-run] rcon: $($CmdArgs -join ' ')"; return } - docker exec $Container rcon-cli @CmdArgs -} - -if (-not (Test-Path $Whitelist)) { Write-Host "No whitelist.json yet — nothing to do."; return } -$roster = Get-Content $Whitelist -Raw | ConvertFrom-Json -if (-not $roster) { Write-Host "Whitelist empty — nothing to do."; return } - -# Flush stats to disk so play_time is current, then read. -Invoke-Rcon @("save-all") -Start-Sleep -Seconds 2 - -# Load previous state into a hashtable keyed by uuid. -$state = @{} -if (Test-Path $StateFile) { - (Get-Content $StateFile -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { - $state[$_.Name] = $_.Value - } -} - -foreach ($p in $roster) { - $uuid = $p.uuid - $name = $p.name - - # Current TOTAL play_time for this player (ticks). Missing file/stat = 0. - $ticks = 0 - $sf = Join-Path $StatsDir "$uuid.json" - if (Test-Path $sf) { - $s = Get-Content $sf -Raw | ConvertFrom-Json - $pt = $s.stats.'minecraft:custom'.'minecraft:play_time' - if ($pt) { $ticks = [long]$pt } - } - - $e = $state[$uuid] - - # New day (or first time seen): reset baseline, and un-lock if we locked them. - if (-not $e -or $e.date -ne $Today) { - if ($e -and $e.lockedOut) { - Write-Host "$name : new day — restoring whitelist access." - Invoke-Rcon @("whitelist", "add", $name) - } - $e = [pscustomobject]@{ date = $Today; baseline = $ticks; lockedOut = $false } - } - - $usedMin = [math]::Round(($ticks - $e.baseline) / 20 / 60, 1) - - if (($ticks - $e.baseline) -ge $LimitTicks -and -not $e.lockedOut) { - Write-Host "$name : hit daily limit ($usedMin/$LimitMinutes min) — locking out." - Invoke-Rcon @("kick", $name, "Daily playtime is up! Come back tomorrow.") - Invoke-Rcon @("whitelist", "remove", $name) - $e.lockedOut = $true - } else { - Write-Host "$name : $usedMin/$LimitMinutes min today$(if($e.lockedOut){' (locked)'})" - } - - $state[$uuid] = $e -} - -# Persist state. -if ($DryRun) { - Write-Host "[dry-run] state not saved." -} else { - $state | ConvertTo-Json -Depth 5 | Set-Content -Path $StateFile -Encoding utf8 -} diff --git a/register_backup_policy.ps1 b/register_backup_policy.ps1 deleted file mode 100644 index 681cbbb..0000000 --- a/register_backup_policy.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -# Run this script once as Administrator to register the shutdown backup policy. -# It creates a scheduled task that auto-backs up the Minecraft server at logoff/shutdown. - -#Requires -RunAsAdministrator - -$BackupScript = "F:\minecraft\backup_server.ps1" - -$action = New-ScheduledTaskAction -Execute "powershell.exe" ` - -Argument "-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `"$BackupScript`"" - -$trigger = New-ScheduledTaskTrigger -AtLogOff - -$settings = New-ScheduledTaskSettingsSet ` - -ExecutionTimeLimit (New-TimeSpan -Minutes 5) ` - -MultipleInstances IgnoreNew ` - -StartWhenAvailable - -Register-ScheduledTask ` - -TaskName "MinecraftBackupOnShutdown" ` - -Action $action ` - -Trigger $trigger ` - -Settings $settings ` - -RunLevel Highest ` - -Description "Auto-backup Minecraft server before system logoff/shutdown" ` - -Force - -Write-Host "Policy registered. Minecraft will auto-backup on every shutdown/logoff." diff --git a/register_playtime_policy.ps1 b/register_playtime_policy.ps1 deleted file mode 100644 index 35bc7cc..0000000 --- a/register_playtime_policy.ps1 +++ /dev/null @@ -1,28 +0,0 @@ -# Run once as Administrator to enforce daily playtime limits. -# Registers a scheduled task that runs playtime_limit.ps1 every 5 minutes. -#Requires -RunAsAdministrator - -$Script = Join-Path $PSScriptRoot "playtime_limit.ps1" - -$action = New-ScheduledTaskAction -Execute "powershell.exe" ` - -Argument "-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `"$Script`"" - -# Every 5 minutes, indefinitely. -$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) ` - -RepetitionInterval (New-TimeSpan -Minutes 5) - -$settings = New-ScheduledTaskSettingsSet ` - -ExecutionTimeLimit (New-TimeSpan -Minutes 4) ` - -MultipleInstances IgnoreNew ` - -StartWhenAvailable - -Register-ScheduledTask ` - -TaskName "MinecraftPlaytimeLimit" ` - -Action $action ` - -Trigger $trigger ` - -Settings $settings ` - -RunLevel Highest ` - -Description "Enforce daily kid playtime limit on the Minecraft server" ` - -Force - -Write-Host "Playtime limiter registered — runs every 5 minutes." diff --git a/server_commands.txt b/server_commands.txt deleted file mode 100644 index 70a4454..0000000 Binary files a/server_commands.txt and /dev/null differ diff --git a/specs/0004-close-public-ssh.spec.md b/specs/0004-close-public-ssh.spec.md new file mode 100644 index 0000000..bd3866f --- /dev/null +++ b/specs/0004-close-public-ssh.spec.md @@ -0,0 +1,249 @@ +--- +id: 0004 +title: Close public SSH; DigitalOcean console becomes the admin path +project: minecraft +status: draft +owner: tbgorrie +created: 2026-08-02 +--- + +# 0004 - Close public SSH; DigitalOcean console becomes the admin path + +## Problem / user story + +As the server owner, I want no SSH port exposed to the internet, so that the entire +brute-force surface disappears and the only ways in are ones an attacker cannot reach. + +Port 22 currently absorbs continuous attack: 94 failed logins and 13 bans were recorded in +the first audit, and one source alone made 742 attempts in two hours. The port is already +restricted to six source addresses, but that list has broken admin access three times +(a VPN exit rotated `.203` to `.213`, and a residential ISP will do the same to the +collaborator in Baku). Maintaining an address allowlist is both the weakest control here +and the most operationally fragile. + +## Goal (the increment) + +Remove every **human** source address from the inbound port 22 rule, leaving only +DigitalOcean's console ranges. No person can SSH in from the internet; the web console +still can. + +**Corrected 2026-08-02 after pre-flight.** The first draft said to remove port 22 entirely. +That was wrong and would have been a serious mistake: the DigitalOcean **web console +connects inbound over SSH** from `162.243.x` / `198.211.x`. This was proven earlier the +same day, when the console failed with +`User root from 162.243.188.66 not allowed because not listed in AllowUsers`. Deleting the +rule would have taken the console down with it and left the Recovery Console as the only +way in: a screen-scraped TTY with no paste and no file transfer, gated on a 10-character +root password. + +So the rule stays, and only its source list changes: + +| Source | Today | After | +|---|---|---| +| `75.57.37.137` (home) | allowed | **removed** | +| `45.134.142.203`, `.213` (VPN exits) | allowed | **removed** | +| `185.146.112.221` (collaborator) | allowed | **removed** | +| `162.243.128.0/17` (DO console) | allowed | kept | +| `198.211.96.0/19` (DO console) | allowed | kept | + +Deliberately NOT stopping or disabling `sshd`: a firewall rule is reversible in seconds +through the API, whereas re-enabling a stopped daemon requires console access that may +itself be the thing that is broken. + +**Residual risk to accept:** DigitalOcean's console ranges are large (a /17 and a /19), so +any DO customer's droplet inside them can still reach port 22. That is the price of keeping +the console. It is mitigated by root being key-only (`PermitRootLogin prohibit-password`), +`AllowUsers` naming only the admin account, `MaxAuthTries 3`, and fail2ban's 3-strike +24-hour ban. Narrowing to the observed /24s would be tighter but risks breaking the console +whenever DigitalOcean uses a different host, and this environment has already punished that +kind of guess three times. + +## Why this is possible now + +It would not have been a month ago. Deploys are pull-based (`gitops/deploy.sh`): the droplet +polls GitHub, verifies CI, and rebuilds itself. Nothing external needs to reach in. If +deploys were push-based over SSH, closing 22 would break them. + +## Acceptance criteria + +- [ ] The DigitalOcean **web console** opens and gives a root shell, verified immediately + before the change. +- [ ] The **Recovery Console** opens and accepts the root password, verified before the + change. This is the path that survives a broken droplet-agent. +- [ ] Root password confirmed working and stored (`MC_SERVER` in `.env`); it is the only + credential the Recovery Console accepts. +- [ ] Inbound TCP 22 lists only the two DigitalOcean console ranges; every human address + is gone. Verify with `doctl compute firewall get`. +- [ ] SSH from the admin machine now times out (proves user access is gone). +- [ ] The web console still opens **after** the change (proves the console ranges were the + right ones to keep). This is the check that would have caught the original error. +- [ ] Minecraft still reachable on 25565 from an external network. +- [ ] A deploy completes end to end after the change (proves GitOps is unaffected). +- [ ] `server-audit` still runs from the console. +- [ ] Break-glass procedure below executed once successfully, then closed again. + +## Security requirements + +- Trust boundaries: the internet-facing surface reduces to 25565 (game, gated by whitelist + and Mojang auth) and ICMP. The admin plane moves inside DigitalOcean's authenticated + control panel, so its security becomes the security of the DO account. +- STRIDE deltas: + - **Spoofing:** SSH brute force becomes impossible rather than merely rate-limited. The + DO account becomes the single identity to protect, so **2FA on DigitalOcean is now + mandatory, not advisory**. Without it this change moves risk rather than removing it. + - **Repudiation:** attribution weakens. Today `edueq9r3eiky` and `wali` are distinct + accounts and auditd records who did what. The console logs in as **root**, so every + console action attributes to root and the DO panel's own activity log becomes the only + record of which human it was. Accept knowingly. + - **Denial of service:** removing 22 eliminates the fail2ban churn and the associated + risk of an admin being banned by a shared-NAT neighbour. + - **Elevation:** unchanged on the host; the container hardening is untouched. +- Data sensitivity: unchanged. No player data is involved. +- Secrets: the root password becomes load-bearing for Recovery Console access. It is + currently 10 characters. **Rotate it to something long before relying on it as the last + way in.** +- Dependencies: none added. +- Security gate: must pass `security-gate`. + +## Design / approach + +**Change:** drop one inbound rule. Inbound becomes 25565 from anywhere plus ICMP. Outbound +is unchanged (443, DNS, NTP, ICMP). + +**Break-glass (reopen 22 for a task, then close it).** + +Tested 2026-08-02 and rewritten after the first version failed. Two findings from the dry +run, both of which would have caused a real lockout: + +1. **`add-rules` / `remove-rules` do not do what they appear to.** `add-rules` created a + *second* port-22 rule rather than extending the existing one, leaving two overlapping + rules; the subsequent `remove-rules` then produced a state where the intended address + was no longer reachable. Recovery needed a full `update`. **Always rewrite the entire + rule set with `update`**, which replaces rather than merges. +2. **Propagation takes about 45 seconds, not instantly.** The first test declared "still + reachable" after 10s and was simply too early. Any verification must poll for at least + 90 seconds before concluding anything. + +```powershell +$env:DIGITALOCEAN_ACCESS_TOKEN = [regex]::Match((Get-Content "F:\minecraft\.env" -Raw), 'dop_v1_[A-Za-z0-9]+').Value +$fw = "f6563e1d-a9f6-477c-9e66-39156c9f382e" +$any = "address:0.0.0.0/0,address:::/0" +$me = (Invoke-RestMethod "https://api.ipify.org?format=json").ip + +# OPEN: full rule set, console ranges plus this one address. +doctl compute firewall update $fw --name minecraft-fw --droplet-ids 585063347 ` + --inbound-rules "protocol:tcp,ports:25565,$any protocol:tcp,ports:22,address:$me/32,address:162.243.128.0/17,address:198.211.96.0/19 protocol:icmp,$any" ` + --outbound-rules "protocol:tcp,ports:443,$any protocol:tcp,ports:53,$any protocol:udp,ports:53,$any protocol:udp,ports:123,$any protocol:icmp,$any" +Start-Sleep -Seconds 50 # propagation + +# ... do the work ... + +# CLOSE: identical command with $me dropped from the port-22 sources. +doctl compute firewall update $fw --name minecraft-fw --droplet-ids 585063347 ` + --inbound-rules "protocol:tcp,ports:25565,$any protocol:tcp,ports:22,address:162.243.128.0/17,address:198.211.96.0/19 protocol:icmp,$any" ` + --outbound-rules "protocol:tcp,ports:443,$any protocol:tcp,ports:53,$any protocol:udp,ports:53,$any protocol:udp,ports:123,$any protocol:icmp,$any" +``` + +Better than a standing allowlist: access is scoped to one address, exists only while +needed, and every open and close lands in the DigitalOcean activity log. + +**Verified during the dry run:** the game port stayed reachable throughout every firewall +change. Players are never affected by admin-plane work. + +**Routine work after the change:** + +| Task | How | +|---|---| +| Deploy code | Automatic. Push to main; the droplet pulls within 5 minutes. | +| Watch a deploy | Console: `journalctl -u minecraft-deploy.service -f` | +| Add a player | Console: `docker exec minecraft-java rcon-cli whitelist add NAME` | +| Read the audit | Console: `server-audit 24h` | +| Server logs | Console: `docker logs -f minecraft-java` | +| Anything needing file transfer | Break-glass, or commit it to the repo and let the deploy carry it | + +## Files to change + +- No repository files. This is a cloud firewall change. +- `specs/0002-hardening.spec.md` - mark H2's endpoint reached. +- `README.md` / `DEPLOY.md` - document console-first administration and break-glass. + +## Out of scope + +- Stopping or disabling `sshd` (kept running deliberately; see Goal). +- Tailscale. It remains the better long-term answer because it restores real SSH with no + public port and keeps per-person attribution. This spec is the cheaper interim step and + does not preclude it. +- Any change to game access. 25565 stays open to every address. + +## Forbidden systems + +- Do not touch the container, the world data, or the whitelist as part of this. + +## Test plan + +Before (all must pass, or stop): +1. Open the DO web console; confirm a root prompt. +2. Open the Recovery Console; confirm the root password works. +3. `doctl compute firewall get ` recorded as the rollback reference. + +After: +4. `ssh mcserver` times out. +5. `Test-NetConnection 159.65.25.218 -Port 25565` succeeds. +6. Console: `docker exec minecraft-java mc-monitor status --host localhost` answers. +7. Push a trivial commit; confirm `DEPLOY OK` in the journal via console. +8. Run break-glass open, `ssh mcserver`, then close, and confirm the timeout returns. + +## Definition of Done + +- [ ] Acceptance criteria met with evidence. +- [ ] 2FA enabled on the DigitalOcean account (hard prerequisite). +- [ ] Root password rotated to a long value and stored in `.env`. +- [ ] Wali told his SSH access is going away and shown the console path. +- [ ] `security-gate` passed. +- [ ] Change summary written, including the attribution tradeoff. + +## Blocker found in pre-flight: the collaborator has no console access + +`doctl account get` shows exactly one member on the DigitalOcean team +(`c-Tburns@thesummitgrp.com`). Wali is not on it, so he cannot open the web console. His +GitHub MFA is irrelevant to this: it protects his GitHub, not a DigitalOcean panel he has +no account on. + +Closing user SSH therefore **removes his access entirely** rather than relocating it. Pick +one before proceeding: + +| Option | Effect | Cost | +|---|---|---| +| **Invite him to the DO team** | He gets the console, same as the owner | He also gets the whole DO account: droplets, billing, the ability to destroy things. There is no "this droplet only" role. | +| **Route him through the owner** | Owner runs anything he needs | Fine if he rarely administers; a bottleneck if he does | +| **Break-glass on request** | Owner opens 22 for his address, he works, owner closes it | Works, but his residential IP rotates, so the address must be fetched fresh each time | +| **Do Tailscale first** | He keeps real SSH under his own name, no public port | More setup now, but it is the only option that keeps per-person attribution | + +Recommendation: if Wali administers the server with any regularity, **do Tailscale instead +of this spec**. It achieves the same goal (no public SSH port), keeps named accounts and +their audit trail, and does not force a choice between giving a collaborator full billing +access and giving him nothing. + +## Also unverified: GitHub two-factor + +DigitalOcean login is via GitHub OAuth, so the GitHub account is the root of trust for the +whole admin plane after this change. The API token in use lacks the scope to read +`two_factor_authentication`, so this must be confirmed by hand at +`github.com/settings/security` before proceeding. If it is off, this change concentrates +all server access behind a single password. + +## Risks + +| Risk | Mitigation | +|---|---| +| Web console broken when needed | Verified before the change; Recovery Console is the second path; break-glass is the third | +| DO account compromised = total control | 2FA mandatory before this change | +| Console is awkward: no paste, no file transfer | Break-glass for real work; ship files through the repo | +| Loss of per-person attribution | Accepted and recorded; Tailscale restores it later | +| Root password is the last resort and is short | Rotate before relying on it | + +## Future work + +- Tailscale on the droplet: restores named-account SSH with no public port, and ends the + IP-rotation problem for both admins. +- Then retire the break-glass procedure entirely. diff --git a/stop_server.ps1 b/stop_server.ps1 deleted file mode 100644 index 143e76d..0000000 --- a/stop_server.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -# Minecraft Server Stop Script -# Always runs a backup before stopping the container — this is the shutdown policy. - -$ServerDir = $PSScriptRoot - -Write-Host "=== Minecraft Shutdown Policy: Backup before stop ===" - -# Run backup first -powershell -ExecutionPolicy Bypass -File "$ServerDir\backup_server.ps1" - -if ($LASTEXITCODE -ne 0) { - Write-Warning "Backup reported errors. Stopping anyway..." -} - -Write-Host "Stopping Minecraft server..." -Push-Location $ServerDir -docker-compose down -Pop-Location - -Write-Host "Server stopped." diff --git a/tools/sync_client_pack.py b/tools/sync_client_pack.py new file mode 100644 index 0000000..54529cb --- /dev/null +++ b/tools/sync_client_pack.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Keep data/EduCraftClient.mrpack in sync with the server's MODS list. + + python tools/sync_client_pack.py --check # CI gate: fail if out of sync + python tools/sync_client_pack.py --write # regenerate the pack + +Why this exists: the server mod list in docker-compose.yml and the client pack +were kept in step by hand, in the same commit, by remembering to. That held +exactly as long as someone remembered. When Ad Astra went on the server without +every family re-importing, nobody could join, and the failure showed up as +children unable to play rather than as a broken build. + +The rule this enforces: every mod the server runs that a client also needs must +be in the pack, at the identical version, with hashes Modrinth itself vouches +for. Server-only mods stay out. Client-only mods stay in. + +Hashes and file sizes come from the Modrinth API rather than being computed +locally, so the pack records what the CDN will actually serve. A mismatch means +the URL now points at different bytes than we reviewed, which is a supply-chain +signal worth failing on. +""" +import argparse +import json +import os +import re +import shutil +import sys +import urllib.parse +import urllib.request +import zipfile + +COMPOSE = "docker-compose.yml" +PACK = "data/EduCraftClient.mrpack" +API = "https://api.modrinth.com/v2" +HDR = {"User-Agent": "educraft-pack-sync/1.0 (+github.com/OpenSource-For-Freedom/minecraft)"} + +# Mods deliberately installed on the SERVER ONLY. Clients never need these, so +# they must not enter the pack. Keep in step with tests/test_client_server_mods.py. +SERVER_ONLY = { + "luckperms", "profanityguard", "bluemap", "prismprotect", + "playtimestatistics", "terralith", "dungeonsarise", +} + + +def norm(name): + """Jar filename -> comparable mod key. Strips version and loader token.""" + name = name.replace("%20", " ").replace("%2B", "+") + stem = re.split(r"[-_]\d", name.lower())[0] + stem = re.sub(r"[-_ ]?(forge|fabric|neoforge|quilt|mc)$", "", stem) + return stem.replace("-", "").replace("_", "").replace(" ", "") + + +def get_json(url): + with urllib.request.urlopen(urllib.request.Request(url, headers=HDR), timeout=45) as r: + return json.loads(r.read().decode()) + + +def server_mod_urls(): + src = open(COMPOSE, encoding="utf-8").read() + m = re.search(r'MODS:\s*"([^"]+)"', src, re.S) + if not m: + raise SystemExit("FAIL: no MODS list in docker-compose.yml") + return [u.strip() for u in m.group(1).split(",") if u.strip()] + + +def version_from_url(url): + """Resolve a pinned Modrinth CDN url to its version record. + + The url embeds the version id: /data//versions//.jar + Querying by id is exact; searching by filename would not be. + """ + m = re.search(r"/versions/([^/]+)/", url) + if not m: + raise SystemExit(f"FAIL: url is not version-pinned: {url}") + return get_json(f"{API}/version/{m.group(1)}") + + +def build_entries(): + """Pack entries for every server mod a client also needs.""" + entries, skipped = [], [] + for url in server_mod_urls(): + jar = os.path.basename(url) + if norm(jar) in SERVER_ONLY: + skipped.append(jar) + continue + v = version_from_url(url) + f = next((x for x in v["files"] if x.get("primary")), v["files"][0]) + if f["url"] != url: + print(f" WARN: compose url and Modrinth primary file differ for {jar}") + entries.append({ + "path": f"mods/{f['filename']}", + "hashes": {"sha1": f["hashes"]["sha1"], "sha512": f["hashes"]["sha512"]}, + "env": {"client": "required", "server": "required"}, + "downloads": [f["url"]], + "fileSize": f["size"], + }) + return entries, skipped + + +def current_pack(): + with zipfile.ZipFile(PACK) as z: + return json.loads(z.read("modrinth.index.json")), z.namelist() + + +def desired_index(idx, entries): + """Server-derived entries, plus client-only mods already in the pack.""" + server_keys = {norm(os.path.basename(e["path"])) for e in entries} + client_only = [f for f in idx["files"] + if norm(os.path.basename(f["path"])) not in server_keys + and norm(os.path.basename(f["path"])) not in SERVER_ONLY] + new = dict(idx) + new["files"] = sorted(entries + client_only, key=lambda f: f["path"].lower()) + return new, client_only + + +def bump(v): + parts = (v or "1.0.0").split(".") + while len(parts) < 3: + parts.append("0") + try: + parts[1] = str(int(parts[1]) + 1) + parts[2] = "0" + except ValueError: + return "1.1.0" + return ".".join(parts) + + +def main(): + ap = argparse.ArgumentParser() + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--check", action="store_true", help="fail if the pack is out of sync") + g.add_argument("--write", action="store_true", help="regenerate the pack") + args = ap.parse_args() + + idx, names = current_pack() + entries, skipped = build_entries() + want, client_only = desired_index(idx, entries) + + have = {f["path"]: f for f in idx["files"]} + need = {f["path"]: f for f in want["files"]} + + missing = sorted(set(need) - set(have)) + extra = sorted(set(have) - set(need)) + changed = sorted(p for p in set(have) & set(need) + if have[p]["hashes"]["sha512"] != need[p]["hashes"]["sha512"]) + + print(f" server mods : {len(server_mod_urls())}") + print(f" server-only : {len(skipped)} (excluded from the pack)") + print(f" client-only kept : {len(client_only)}") + print(f" pack should hold : {len(want['files'])} currently holds: {len(idx['files'])}") + + if not (missing or extra or changed): + print("\n IN SYNC: every client-facing server mod is in the pack at the same version.") + return 0 + + print("\n OUT OF SYNC") + for p in missing: + print(f" MISSING from pack : {os.path.basename(p)}") + for p in extra: + print(f" STALE in pack : {os.path.basename(p)}") + for p in changed: + print(f" VERSION/HASH DIFF : {os.path.basename(p)}") + + if args.check: + print("\n Players on the published pack would be REFUSED at the FML handshake.") + print(" Fix: python tools/sync_client_pack.py --write, then commit the pack.") + return 1 + + want["versionId"] = bump(idx.get("versionId")) + shutil.copy(PACK, PACK + ".bak") + tmp = PACK + ".tmp" + with zipfile.ZipFile(PACK) as zin, zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + if item.filename == "modrinth.index.json": + continue + zout.writestr(item, zin.read(item.filename)) + zout.writestr("modrinth.index.json", json.dumps(want, indent=2)) + os.replace(tmp, PACK) + print(f"\n WROTE {PACK}: {len(want['files'])} mods, versionId " + f"{idx.get('versionId')} -> {want['versionId']}") + print(" Backup at " + PACK + ".bak") + print(" REMEMBER: families must re-import. The Modrinth App creates a NEW") + print(" instance on import rather than updating the existing one.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())