From 198d81822150e72001721bd6cc0e753c9dc252bc Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 15 Aug 2026 06:11:48 -0400 Subject: [PATCH] Remove the Windows-era scripts the droplet made dead Six files from when the server ran locally on Windows. None can work against a Linux droplet, none are referenced by CI, and most only referenced each other: backup_server.ps1 zips $PSScriptRoot\data; the droplet has DigitalOcean snapshots and deploy-time backups register_backup_policy.ps1 hardcodes F:\minecraft\ and registers a Windows scheduled task stop_server.ps1 local Windows shutdown wrapper register_playtime_policy.ps1 Windows scheduled task playtime_limit.ps1 see below server_commands.txt a UTF-16 dump of vanilla command help from a NEWER Minecraft than we run: it lists /dialog and locator_bar, which do not exist in 1.20.1 Kept sync_guide_to_mrpack.ps1. It is Windows-only too, but it solves a live problem rather than a dead one: Patchouli can only tell a client which book to open, so the client needs the book JSON already in its pack, and forgetting that step is why /guide silently does nothing for players on a stale pack. tools/sync_client_pack.py handles mods, not the guide overrides, so the two are complementary. Pack edits happen on the Windows machine anyway. The one deletion that costs something is playtime_limit.ps1. It capped each kid at 120 minutes a day by pulling them off the whitelist, which ENFORCE_WHITELIST turned into an instant kick, and restored them at the day rollover. It has not run since the move to the droplet, so that control has silently been off for weeks. DEPLOY.md now says so in plain terms rather than describing it as a feature that merely needs porting. A parental control that is documented as existing but is not running is worse than one that is honestly marked absent, because someone is relying on it. The logic remains in git history if it is wanted as a starting point for a Linux version. --- DEPLOY.md | 17 +- backup_server.ps1 | 41 ----- playtime_limit.ps1 | 89 ---------- register_backup_policy.ps1 | 27 --- register_playtime_policy.ps1 | 28 ---- server_commands.txt | Bin 8198 -> 0 bytes specs/0004-close-public-ssh.spec.md | 249 ++++++++++++++++++++++++++++ stop_server.ps1 | 20 --- tools/sync_client_pack.py | 188 +++++++++++++++++++++ 9 files changed, 452 insertions(+), 207 deletions(-) delete mode 100644 backup_server.ps1 delete mode 100644 playtime_limit.ps1 delete mode 100644 register_backup_policy.ps1 delete mode 100644 register_playtime_policy.ps1 delete mode 100644 server_commands.txt create mode 100644 specs/0004-close-public-ssh.spec.md delete mode 100644 stop_server.ps1 create mode 100644 tools/sync_client_pack.py 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 70a4454c8fc675ac85940acbf819843fdc971a2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8198 zcmb`M-EL|(5QTkRsqc`>^rAZVRg|abT~r|g1abm_0y!xa&)#-@UO4t9Hf(4VLCoHs zzcu5TvHkBq-@-7A!w-GGhEbS=g+8xg8*ajFn1*$Hz72bgCYrSjFZ%yWfNHD1b$AYs z;Z387DDL!ssAp6C+J^i19>#b_HjJdjE(;GkrUtW-ROI%*>GNIv=PNtpQWfLl7mG{Jz=(gKWRMDj92Lfm$}B% zD9hN)4*Z~H7Bb$XHG1=lZ*+dt2z{_@9c=|+O9bDga6~Kkqa}Qdg)xE0NN*aVu)@v2WlAM#sMVn>|4opq^F4fQ+6`To`>cp{2C?J zIDCtk%_RkmQUqH+RRo|Z-of*k0Tl`$IlWsucMU5OKS2j(SYu&i0XI!a>Vg`69)Q4#vBMa zuhy41PF^EZFAm;fJr9NSHvIO#xD6GL$V)N?5zACf&?l{kmUQytB|J9LcPM{7qKDdt~+b0X26~?j9VIRKb>izm9v?2j`-pJ=;5zarD?AqDMzCknLnp zcl5Pb=tD=Mth3aO4B`r9pE=XBx;`m;UCynGb(zoTCFq)0@w+a!KXqO!?qM#;n|*2NyII$N zM}L=l)4KMj=i5KW7P4z`=k2qQfqcbUnF^^^x0SqeDX*3GQ|wM_IV!y)j-M5^tyR)} z@BWE18}?*Gu}?B;5>mJIc{bIFu7rG-@^{F#Anp7HRm%bAE@v!f5-*!!XNIvb)rL=XW2*+CL#G)5=pDEar zZk16$h&}_qGGg_Rl{ud(Yma;Hx|goy#ijzeqiRM@i0@f}F#j>IQx;M?+dUs~#yyPV@{#Q7mP9r^Uh zb=@zaFa74s%RVukKDnplT&N}M5_&o&>PEuN5$Rs?HJ(e7?N4Qs%Q%p;T|=&ycqxzL zI!A{HnsraLXFtUlP3OX1Bh2o0zO6{Rgu32CnL~Rg(dw5GSApPA2%x<1b;7!b=Ds7vIOo{qd3Czc@EObEe&qy3N>qckiETR%^ySG{|vV?3`*M>V3TTSNYh7Jv&@oh)zjxXJOq2rtWyc>Wv z*BPJ1`(A@J;|>fR^Ua0tK%e8C8I;_7)~urLQw4m7mJsIFB%OBqrfDPe>z3Zdtd--D z8z^$A?eyIb{pD28-^HW*WTR*9J?xcZzhXI>pdWbqCgSsLh;Mw2a`qY)QwdX-3P#_> zXDWR-%TKpMT@=xNaqvB35J=T4*KyFuzkH8Eu1mWSBF25*NOFh;B92=FZfUyvpuB(f yZ5BL;6!fJ(V(0E28^8M8&MT6R7q7ct+p9J%BTi5fJ>Ul=T>^dnP53$d6#fG`0(Rj5 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())