diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml new file mode 100644 index 0000000..aa2f362 --- /dev/null +++ b/.github/workflows/publish-server-image.yml @@ -0,0 +1,82 @@ +name: Publish server image + +# Builds server/Dockerfile and pushes it to GitHub's container registry. +# Packages pushed to ghcr.io start out private — the image stays private +# until its visibility is changed by hand under Packages -> Package settings. + +on: + push: + branches: + - main + tags: + - 'v*' + # No paths filter on purpose. It would apply to the tag push as well — + # there is only one push block, and GitHub evaluates the filter for every + # ref in it. Tagging a release on a commit that happens not to touch + # server/ (a docs or client commit, which is the common case) would then + # silently produce no image and no version tags at all. + # + # The cost of dropping it is a rebuild on commits that change nothing in + # the image. That takes well under a minute; a missing release does not + # announce itself. + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Check out + uses: actions/checkout@v4 + + # ghcr.io rejects upper case names, but repository owners may contain + # them (dmuiX). Normalise before it reaches any tag. + - name: Resolve image name + run: echo "IMAGE=ghcr.io/$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/unss-server" >> "$GITHUB_ENV" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + # Provided automatically, no secret needs to be configured. + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=tag + type=sha,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./server + # arm64 is included so the image also runs on a Raspberry Pi. + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + echo "### Pushed" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index e0bd9d6..5fccd06 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,19 @@ build/ *.nsp *.xci +# Switch homebrew (build leftovers) +*.map + # Server-side data savedata/ metadata.sqlite +# Runtime configuration — may contain the server URL including credentials +# (the client accepts https://user:pass@host). Never commit these. +config.ini + +# Logs +*.log + # Claude .claude/ \ No newline at end of file diff --git a/README.md b/README.md index 6e3b4dd..4fe3078 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,20 @@ To use remote server synchronization, you must configure settings first. and uNS [remote] enabled=1 serverUrl=http://your.hostname.com:8989 +; If the server sits behind basic auth, put the credentials in the URL — +; the client has no login prompt and passes this straight to libcurl, which +; sends them as an Authorization header: +; serverUrl=https://switch:@unss.example.com +; Use a long alphanumeric password — see "Server" below for why the +; punctuation that would need escaping is better avoided entirely. + +; The server certificate is verified by default. If it is signed by a root +; the console does not know (a private CA), put that root in +; sdmc:/uNSS/cacert.pem — a single PEM file is enough. +; 1 turns verification off. Last resort only: without it, anyone who can +; answer the handshake receives the password, which libcurl sends +; preemptively as an Authorization header. +insecureSkipVerify=0 [account] ; Nickname of the Switch user profile to operate on. @@ -47,6 +61,23 @@ restoreBy=all ; Exclude specific titles by name (separated by "||"). ; excludedTitleNames=uNSS||DBI + +[sync] +; 1: push automatically on launch, without pressing anything. +; Also read by the background service (see below). +autoPushOnLaunch=0 +; Minimum hours between two automatic backups — a brake, not a trigger. +; What actually decides whether anything is uploaded is whether the save +; data changed; unchanged titles are skipped either way. +; 0 (recommended): check on every launch and upload whatever changed. If +; nothing changed, nothing happens — for weeks, if that is +; how long it takes. +; >0 : additionally refuse to even look before that many hours +; have passed. Only useful to cap the checking itself. +autoPushIntervalHours=0 +; 1: back up every user profile registered on the console. +; 0: only the one named in defaultAccountName. +allAccounts=1 ``` #### `[account]` behavior matrix @@ -58,6 +89,321 @@ restoreBy=all If `defaultAccountName` is unset (or does not match any registered user) when the client needs it, uNSS prints an explanatory message and only the Exit option is available. +### Automatic backup + +With `autoPushOnLaunch=1` the client starts pushing as soon as it opens — no +menu interaction. Two rules keep that from being wasteful or unsafe: + +* **Only what changed.** The newest modification time inside each save is + compared against the last upload (`sdmc:/uNSS/saves/.syncstate`). Unchanged + titles are skipped before they are even archived. +* **Not while playing.** A running game keeps its save file open, so a backup + taken at that moment can be inconsistent. The automatic push waits; the + manual *Push to Server* button is never blocked. + +The timestamp for `autoPushIntervalHours` is only written after a successful +run, so a failed backup is retried on the next launch instead of being +counted as done. + +## Background service + +An NRO only runs while it is open — start a game and it is gone. For backups +that happen without you, uNSS ships a sysmodule that starts with the console +and stays resident. + +It works, on real hardware — firmware 22.5.0 with Atmosphere 1.11.2, backing up +three accounts unattended. It is also the part of uNSS that can take your +console down if the console has no room for it, so read +[Requirement: room in the system memory pool](#requirement-room-in-the-system-memory-pool) +before installing. Everything else here is only interesting once that holds. + +It wakes every few minutes and asks one question: has any save data changed +since the last upload? If not, it does nothing at all — not even opening a +socket — and goes back to sleep. That can go on for weeks. When a game ends +it checks immediately rather than waiting for the next interval, since the +moment right after someone saves and quits is the best time to copy a save. +While a game is running it never touches save data, because the game holds +those files open. + +Backing up only at boot, which is what earlier versions did, sounds +reasonable and works badly: a Switch is closed, not switched off, so reboots +are weeks apart. What triggers a backup is change, not the clock. + +Install it from inside the app: **Install background service**. It unpacks the +module to `atmosphere/contents/4200000000554E53/`, sets the boot2 flag and +takes effect after a **reboot**. **Remove background service** undoes it. +An already installed module is updated silently when the app carries a newer +one; only the first install is a deliberate button press, since it starts a +process at every boot. + +The service reads the same `config.ini` and needs `remote.enabled=1` and +`sync.autoPushOnLaunch=1`. It has no user interface, so it cannot show a +profile selector: with `allAccounts=0` it depends entirely on +`defaultAccountName`, which is compared **case-sensitively**. + +Each account only gets the saves that belong to it. The console hands back +every save on the system when asked for a list, without separating them by +user, so a save one player owns is not a failure for the other two — it is +simply not theirs, and is skipped rather than logged as an error. + +It writes to `sdmc:/uNSS/sysmodule.log`, and the app can read that file back: +**Service log** follows the end of it while new lines arrive, and lets go as +soon as you scroll up. Lines mentioning a failure are red. A sysmodule has no +screen of its own, so without this the only way to find out what it had been +doing was to pull the SD card or fetch the log over FTP. + +Restoring is deliberately not part of it — writing save data back should be a +decision you watch happen, in the app. + +### Requirement: room in the system memory pool + +**This is where the background service can take your console down.** Read it +before installing. + +Sysmodules do not get memory of their own. They all draw from one system pool +that the console's own processes are already living in, and it got tighter +with firmware 20.0.0. Measured on 22.5.0, that pool is **232 MB in total — +with about 210 MB of it already spoken for before any homebrew loads.** The +twenty-odd megabytes left over are what `sys-ftpd`, `sys-patch`, overlay +loaders like `nx-ovlloader`, Tesla, emuiibo and this module have to share. + +A sysmodule's heap is a static array, so its full size leaves that pool the +moment the module loads — whether it ever uses a byte of it or not. + +When it runs out, the kernel refuses the next allocation and whichever system +process asked for it dies — `hid` (no controller input) or `am` (the console +stops). The error is `2001-0132`, kernel `LimitReached`, and it names *the +victim*, not the cause. So the console blames a Nintendo process while the +module that exhausted the pool keeps running, looking innocent. + +**So: run as few other sysmodules as you can.** On a console loaded with +resident modules, uNSS may not fit — and the way you find out is a boot loop, +not an error message. + +The pool is one of two limits that behave like this. The other one is service +manager sessions, and it shows up as homebrew refusing to launch at all; see +[A second limit](#a-second-limit-service-manager-sessions) below. + +#### How much has to be free + +**There is no number here, and the measurements are the reason.** This section +exists to stop you trusting one. + +Ten module starts were logged on one console (22.5.0). Free system pool at +start ranged from 13.5 MB to 21.9 MB. Every single start shows the same +pattern: about **9 MB disappears within five seconds** of the module coming +up, and in most runs it stays gone. Then: + +| free at start | low point | outcome | +|---|---|---| +| 15076 KB | 4780 KB | log stops mid-startup — the console died | +| 21100 KB | 11568 KB | survived | +| 21868 KB | 12824 KB | survived | +| 20144 KB | 10612 KB | survived | +| 13496 KB | **3448–3960 KB** | survived — **five times** | + +Read the first and last rows again. The run that died had *more* memory left +at its low point than five runs that lived. Free memory does not separate +them, so no threshold can honestly be drawn from this data — not "20 MB at +start", not "keep 5 MB free". Anyone quoting such a number, including an +earlier version of this file, is interpolating. + +What the crashes actually had in common was fixed in code, not in free space: +a 6 MiB inner heap, then a 2 MiB one, and a 144 KB structure on a 16 KB stack. +Since those went, the module has survived every logged round — including the +five at 3.4 MB. + +For context, those figures were measured with **nine sysmodules starting at +boot** — sys-ftpd, sys-patch, SaltyNX, MissionControl, sys-clk, NxThemes, two +more and uNSS itself. Counting folders under `atmosphere/contents/` overstates +it: several are LayeredFS entries with no `flags/` directory, and one was +disabled by renaming its flag. Only a folder containing `flags/boot2.flag` +costs anything. + +So the advice stays qualitative, because that is all the evidence supports: +**every resident sysmodule you remove is headroom you get back, and the pool +is the thing that kills consoles.** Removing three overlay loaders moved this +console from 15 MB free to 21 MB. Note how small the modules are that buy that +back — emuiibo 262 KB, MissionControl 187 KB, sys-clk 174 KB, sys-ftpd 173 KB. +What costs the pool is rarely the file size, because a loader reserves room +for the largest thing it might load. + +Worth checking while you are in there: a module can start at boot, take its +share of the pool and do nothing useful. On this console sys-ftpd had a live +`boot2.flag` but served no FTP — it is built for firmware 19.0.0 and this is +22.5.0 — and left no crash report to hint at it. Anything that has not been +verified to work since the last firmware jump is worth a look. + +You do not have to guess on your own console: the module writes what it found +on every start, and **Service log** in the app shows it. + +``` +pool system at start: of KB used, KB free +pools +0s (KB free): app=... applet=... sys= sys-unsafe=... +pools +5s (KB free): ... sys= +pools +10s (KB free): ... sys=... +heap after round: KB in use, KB reached, 1024 KB total +``` + +The `sys=` column is the pool everything competes for. Watch how it moves +rather than what it reads once: a single value at startup misses the drop +entirely, which is why the module samples every five seconds through the whole +grace period. + +Use it to compare your console against itself — before and after removing a +module — not against the numbers above. Those came from one console on one +firmware, and they demonstrably fail to predict a crash. + +The sampling earns its keep in a different way. The crash it was built to +catch happens inside those thirty seconds, where nobody can see it: the +console simply dies, screen and all. The log closes the file after every +line, so whatever was written last survives. That is how the fatal run above +is identifiable at all — not by an error message, but by the log simply +stopping after `+10s`. + +The `heap` line is uNSS itself, so you can see how much of its 1 MiB is real +(measured: about 190–250 KB). + +If it does boot-loop, the console is not bricked: delete +`atmosphere/contents/4200000000554E53/flags/boot2.flag` from the SD card on a +PC and it comes up clean. + +`INNER_HEAP_SIZE` is **1 MiB**, and that number was earned the hard way: + +| size | outcome | +|---|---| +| 6 MiB | starved `hid` — boot loop | +| 2 MiB | killed `am` | +| 1 MiB | carries a full round end to end ✅ | +| 256 KB | too tight — see below | + +Do not raise it, and do not lower it either. 256 KB looked justified: a full +round across three accounts had peaked at 189 KB. At 256 KB the *same* round +peaked at 245 KB instead — a high water mark is not a property of the program +alone. A tight heap fragments and wastes what a roomy one reuses, so the +measurement was never a lower bound. Everything downstream failed at once: +compression (`ret=-3`), title lookup (the 144 KB control record no longer fit, +so every game logged as "Unknown"), and the upload. + +The same pool is why nothing heavy is opened during boot. Network services +wait for a 30 second grace period *and* for there to be something to upload; +`ns` and `account` open after the grace period, because the change check needs +them. When nothing changed, no socket is ever opened — which is the normal +case, and costs the pool nothing. + +#### A second limit: service manager sessions + +The pool is not the only thing you can run out of, and the other limit fails in +a way that points nowhere near the cause. + +On the console above, installing one more resident sysmodule — `ftpsrv` as a +sysmodule, alongside uNSS — made **every homebrew launch** take the console +down. Not eventually: opening Sphaira right after a reboot was enough, every +time. The screen showed + +``` +Atmosphere panic occurred! + +Title ID: 0100000000000034 +Error: std::abort (0xFFE) + +Report saved to atmosphere/fatal_errors/report_XXXXXXXX.bin +``` + +Two things about that screen are traps. + +`0100000000000034` is `fatal` — **Atmosphère's own crash reporter**. It is not +the program that failed. A process could not start, libnx called `fatalThrow`, +and `fatal` went to display it; to do that it needs `lbl`, the backlight +service, and it could not get that either. So it aborted, and the bare panic +screen is what is left when even the error handler dies. The original failure +is never named. + +And the report is in `atmosphere/fatal_errors/`, **not** `fatal_reports/` or +`crash_reports/` — those two stay empty, because `fatalThrow` is a deliberate +abort, not a CPU exception. Looking in the obvious place suggests nothing +happened at all. + +The `.bin` is small and readable without tools: + +``` +00000000: 4146 4532 fe0f 0000 3400 0000 0000 0001 AFE2....4....... +00000010: 1506 0000 ... .... +... +00000360: 5346 434f 0000 0000 1506 0000 0000 0000 SFCO............ +00000370: 6c62 6c00 lbl. +``` + +`AFE2` is the magic, `0x0FFE` the abort, then the program ID. The `SFCO` block +near the end is a captured IPC reply: result `0x615` on service `lbl`. Split +that the way Horizon does — module `0x615 & 0x1FF` = 21, description +`0x615 >> 9` = 3 — and it reads **`2021-0003`, `sm::ResultOutOfSessions`**. +The service manager was out of sessions. It has 88 of them, 87 for processes. + +Removing the extra sysmodule fixed it: reboot, launch homebrew, no panic. + +**What this does and does not establish.** It is one A/B on one console with one +module, so it does not show that `ftpsrv` is special — only that *one resident +sysmodule too many* was enough, and that the failure lands nowhere near +whatever pushed it over. Nor does it identify what holds the 87 sessions; +that cannot be read from outside. Four candidates inside uNSS were checked +against the source and cleared: network services are opened once behind a +guard, the save-data info reader is closed, save mounts are released by an RAII +guard on every error path, and nothing re-initialises per round. + +The practical rule is the same as for the pool, for a different reason: +**every resident sysmodule you remove is headroom.** If homebrew stopped +launching after you added one, take it out before looking anywhere else. + +### If it ever takes the console down again + +The module cannot be trusted to be correct — it has taken HID with it twice. +So it does not rely on being correct. + +Before doing anything risky it writes `sdmc:/uNSS/.running`, recording how +many crash reports existed at that moment and which phase it was in, and +deletes the file once it has survived the round. Finding that file at the +next start means the previous round did not finish, and comparing the counts +says whether anything actually died: + +* `atmosphere/crash_reports/` — a normal program died. The filename carries + the program id, so a report naming `4200000000554e53` is **us**, beyond + doubt. The `2168-0002` stack overflow was here. +* `atmosphere/fatal_reports/` — a *system* process died, and the console + went down with it. This one is not proof of guilt: any process crashing + inside our window lands here. + +Both directories have to be read; the two real incidents landed in different +ones. Filenames are lowercase, and a case-sensitive search for an uppercase +program id finds nothing — which reads like "no reports at all". + +What follows depends on **when** it happened, because the two cases are not +equally dangerous: + +| when | what died | response | +|---|---|---| +| during startup | anything | **disables itself** — a retry here is a boot loop | +| during a backup | only us | pause 5 min, then 20, then 80, capped at 2 h — then retry on its own | +| during a backup | the console (fatal) | pause the full 2 h at once; **disables itself** if it happens twice | +| — | nothing (power cut) | carries on | + +Disabling means renaming its own `boot2.flag` to `boot2.flag.crashed`, so the +next boot comes up without it. The app then offers **Re-enable background +service**. + +Pausing rather than switching off is the deliberate part. Nobody is watching a +console for notifications, so a module that turned itself off would simply stay +off forever — and the first strike is often not even ours, since someone else's +game crashing during our few minutes counts the same. But a fatal report means +the whole console stopped, which cannot be treated like one failed backup: +retrying every five minutes turns it into a device that dies every five +minutes. That happened (`am`, twice, 273 seconds apart), which is why fatals +back off all the way immediately. + +Counting reports rather than comparing timestamps is deliberate too: right +after boot the clock may not be set yet, so times are not trustworthy. A count +only ever grows. + ## Server ### Prerequisite Running server via Python interpreter requires some dependencies. Install dependencies first. @@ -66,11 +412,6 @@ pip install -r requirements.txt ``` ### Linux / macOS -Background mode -```bash -nohup run-linux.sh -``` - Foreground mode ```bash @@ -83,5 +424,212 @@ or python main.py --host 0.0.0.0 --port 8989 ``` +Background mode + +```bash +setsid nohup ./run-linux.sh > server.log 2>&1 < /dev/null & +``` + +`nohup` alone is not enough: it only shields against `SIGHUP`. Closing the +terminal or pressing Ctrl-C sends `SIGINT` to the whole foreground process +group, and uvicorn shuts down cleanly on that — the log then shows a tidy +shutdown rather than a crash, which is easy to misread. `setsid` puts the +server in its own session, out of reach of both. + +Run it from the `server/` directory: `metadata.sqlite` and `savedata/` are +resolved relative to the working directory. + +### Tests + +```bash +pip install -r requirements-dev.txt +pytest +``` + +Run them from the `server/` directory. Each test gets a fresh server in a +temporary directory, so they neither touch nor need your real database. + +They cover the two places where a mistake is expensive and silent: the wire +format the Switch client parses by hand, and the revision transaction +(`P` → `C`/`D`) that decides which backup counts as current. A revision that +never finished must not hide the last good one, and an upload must not be +able to overwrite a completed revision. + +The `tests.sh` script next to them is something else — a handful of `curl` +invocations for poking at a running server by hand. It checks nothing. + ### Windows Just used prebuilt binary by PyInstaller + +### Docker + +```bash +docker compose -f docker-compose.yaml up -d +``` + +`compose.traefik.yml` is an alternative for setups that already run Traefik: +the container publishes no port of its own and is reached inside the docker +network, with TLS terminated by the proxy. + +Access control there is basicAuth, not a login portal — the client does not +follow redirects, so anything redirect-based (authelia, OIDC) can never +complete. libcurl does send credentials taken straight from the URL, so +`serverUrl=https://user:password@host` works without any client change. + +You supply two values, both in plain text: + +``` +UNSS_USERNAME defaults to `switch` +UNSS_PASSWORD +``` + +The stack hashes the password itself, with bcrypt at cost 12. Doing it there +rather than by hand keeps it to **one** value: the same password has to reach +the client too, and a hash cannot be turned back into it — maintaining both +meant keeping them in sync by hand. To hash elsewhere anyway, set +`UNSS_BASICAUTH_USERS` to a full htpasswd line and the password is ignored. + +The explicit cost matters: `htpasswd` defaults to 5, far too low, and Traefik's +basicAuth supports only MD5, SHA1 and bcrypt (no argon2), so cost and password +length carry the security. + +**Which characters the password may contain is not a free choice.** The client +takes its credentials from the URL and passes that string to libcurl +unescaped, so `/`, `?`, `#`, `@`, `%`, `[` and `]` change how the URL is split +and break authentication in ways the error message does not point at. The INI +parser also strips `'` and `"` from both ends of a value. Use a long +alphanumeric password — length carries the entropy, not the variety of +characters: + +```bash +LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32; echo +``` + +Getting the resulting hash to Traefik is the one genuinely awkward part of this +setup, because **a container label cannot be filled from a secret store.** + +Traefik reads labels from the docker daemon — container metadata, fixed when +the container is created. It never looks inside the container, so an +environment variable a secret store populates at startup is invisible to it, +and compose has already written the label by then. The label simply stays +empty. That is not a harmless default either: Traefik discards a router whose +middleware is invalid, so the service answers **404** rather than asking for +credentials, which is easy to misread as a routing problem. + +Two ways around it. They differ in where the hash is *maintained* — not in +whether it lands in a file on disk, which it does either way. + +**From a secret store** (what `compose.traefik.yml` does). A small helper +container reads the credentials, hashes the password and writes Traefik's +middleware into its dynamic configuration; Traefik picks the file up on its +own. The store fills a container environment, which it can do, and the +container writes a file, which Traefik reads — that bridges the gap. Rotating +the password means changing it in one place. With OpenMediaVault's compose +plugin, locket can fetch it from OpenBao: + +```yaml +locket: + provider: + type: locket + options: + provider: bao + bao-url: http://127.0.0.1:8200 + bao-role-id: + bao-secret-id: file:/path/to/role-secret-id + raw: true + env: + - UNSS_USERNAME={{bao://secret/unss/UNSS_USERNAME}} + - UNSS_PASSWORD={{bao://secret/unss/UNSS_PASSWORD}} +``` + +Note that bcrypt salts randomly, so the generated line differs on every start +even though the password does not. Traefik re-reads the file and carries on — +but only if its file provider is actually watching, which is not the default: + +```yaml +providers: + file: + directory: /srv/traefik/dynamic + watch: true +``` + +Without `watch: true` Traefik reads the directory once at its own startup, so a +freshly written middleware is never picked up and the router referencing +`unss-auth@file` is dropped — the 404 again, not a 401. + +The router then references the middleware as `unss-auth@file`, not +`unss-auth` — it comes from the file provider, and without the suffix Traefik +looks for one the docker provider never defined. + +**From an `.env` file**, feeding the same writer. Fewer moving parts, but the +`.env` becomes the source of truth. Under OpenMediaVault the stack's +*Environment* field is that file. + +An `.env` on its own does **not** reach a container — it only fills `${...}` +in the compose file, and the credentials are deliberately not interpolated +there. So this route needs one more line on `unss-auth-writer`: + +```yaml +env_file: + - .env +``` + +``` +UNSS_PASSWORD=<32 alphanumeric characters> +``` + +A plain password needs no escaping. A ready-made hash still does, since the +doubled `$` is collapsed on the way in: + +``` +UNSS_BASICAUTH_USERS=switch:$$2y$$12$$.... +``` + +Note that `environment:` has none of these problems — it is read at runtime, +so a secret store works there without any of this. Labels are the exception. + +The client needs the **plaintext** password, since that is what it sends. The +user name in the URL is whatever `UNSS_USERNAME` was set to — `switch` unless +you changed it: + +```ini +serverUrl=https://switch:@unss.example.com +``` + +This is not the browser form of the URL — a browser prompts for credentials +in a dialog, and nothing is ever typed into the address bar. The client has +no dialog to prompt with, so it takes them from the URL; libcurl strips the +`user:pass@` part off and sends it as an `Authorization` header, exactly as a +browser would. The password never appears in the request path. + +It does, however, sit in plaintext in `config.ini` on the SD card, and there +is no way around that: Switch homebrew has no key store, so a client +certificate would be equally exposed. This is bearable because of what it +guards — anyone holding the SD card already has the save data the password +protects. What it really keeps out is the open internet. Use a random +password used nowhere else, and if the card is ever lost, change +`UNSS_PASSWORD` and recreate the stack; the old password is worthless from +that moment. + +Basic auth transmits that password on every request, protected only by TLS. +Over plain HTTP it is trivially readable — so use HTTPS, or no authentication +at all on a trusted network, but never basic auth over HTTP. + +The client verifies the server certificate by default, which is what makes +that password safe to send. Let's Encrypt needs nothing extra — the console +has trusted ISRG Root X1 since firmware 10.1.0. For a private CA, drop the +root into `sdmc:/uNSS/cacert.pem`; this curl uses the libnx SSL backend, which +passes `CAINFO` to `sslContextImportServerPki`, so one file is enough to make +the console trust a root the firmware never shipped. + +`remote.insecureSkipVerify=1` turns the check off. It is the last resort, not +the first thing to try, because its failure mode is misleading: a rejected +certificate looks like "cannot connect" rather than "certificate refused", so +the temptation is to disable verification and move on. With it off, anything +that can answer the handshake — hostile Wi-Fi, a spoofed DNS answer — +receives the plaintext password, which libcurl sends preemptively. The bcrypt +cost hardens the hash at rest; it does nothing for that path. + +Images are published to `ghcr.io//unss-server` by +`.github/workflows/publish-server-image.yml` on pushes to `main` and on `v*` +tags, for amd64 and arm64. diff --git a/build-all.sh b/build-all.sh new file mode 100755 index 0000000..903b5fc --- /dev/null +++ b/build-all.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# +# sysmodule 을 먼저 빌드해서 NRO 의 romfs 에 넣은 뒤 클라이언트를 빌드한다. +# +# 순서가 중요하다. romfs 안의 exefs.nsp 는 빌드 시점에 NRO 안으로 들어가므로, +# 모듈을 고치고 이 스크립트를 거치지 않으면 앱은 예전 모듈을 계속 설치한다. +# +# 저장소 전체를 마운트한다. client-sysmodule/source 의 공용 파일들이 +# ../../client/source 를 가리키는 심볼릭 링크라서, 하위 폴더만 마운트하면 +# 컨테이너 안에서 링크가 끊긴다. + +set -e + +ROOT=$(cd "$(dirname "$0")" && pwd) +IMAGE=unss-client-builder:latest + +run_make() +{ + subdir=$1 + shift + docker run --rm \ + -v "$ROOT":/uNSS -w "/uNSS/$subdir" \ + -u "$(id -u):$(id -g)" -e HOME=/tmp \ + "$IMAGE" make "$@" +} + +echo "==> sysmodule" +run_make client-sysmodule + +echo "==> romfs" +mkdir -p "$ROOT/client/romfs" +# Makefile 의 TARGET 은 디렉토리 이름에서 나온다 -> client-sysmodule.nsp +cp "$ROOT/client-sysmodule/client-sysmodule.nsp" "$ROOT/client/romfs/exefs.nsp" +ls -la "$ROOT/client/romfs/exefs.nsp" + +echo "==> client" +# NRO 규칙은 elf 와 nacp 에만 걸려 있고 romfs 안의 내용에는 걸려 있지 않다. +# 모듈만 고치고 클라이언트 소스를 건드리지 않으면 make 는 다시 링크할 이유를 +# 찾지 못하고, 예전 exefs.nsp 가 든 NRO 가 그대로 남는다 - 이 스크립트가 +# 막으려던 바로 그 일이다. 지워서 반드시 다시 만들게 한다. +rm -f "$ROOT/client/client.nro" +run_make client + +echo +echo "완료:" +ls -la "$ROOT/client/client.nro" diff --git a/client-sysmodule/Makefile b/client-sysmodule/Makefile new file mode 100644 index 0000000..5fc8c8f --- /dev/null +++ b/client-sysmodule/Makefile @@ -0,0 +1,222 @@ +#--------------------------------------------------------------------------------- +.SUFFIXES: +#--------------------------------------------------------------------------------- + +ifeq ($(strip $(DEVKITPRO)),) +$(error "Please set DEVKITPRO in your environment. export DEVKITPRO=/devkitpro") +endif + +TOPDIR ?= $(CURDIR) +include $(DEVKITPRO)/libnx/switch_rules + +#--------------------------------------------------------------------------------- +# TARGET is the name of the output +# BUILD is the directory where object files & intermediate files will be placed +# SOURCES is a list of directories containing source code +# DATA is a list of directories containing data files +# INCLUDES is a list of directories containing header files +# ROMFS is the directory containing data to be added to RomFS, relative to the Makefile (Optional) +# +# NO_ICON: if set to anything, do not use icon. +# NO_NACP: if set to anything, no .nacp file is generated. +# APP_TITLE is the name of the app stored in the .nacp file (Optional) +# APP_AUTHOR is the author of the app stored in the .nacp file (Optional) +# APP_VERSION is the version of the app stored in the .nacp file (Optional) +# APP_TITLEID is the titleID of the app stored in the .nacp file (Optional) +# ICON is the filename of the icon (.jpg), relative to the project folder. +# If not set, it attempts to use one of the following (in this order): +# - .jpg +# - icon.jpg +# - /default_icon.jpg +# +# CONFIG_JSON is the filename of the NPDM config file (.json), relative to the project folder. +# If not set, it attempts to use one of the following (in this order): +# - .json +# - config.json +# If a JSON file is provided or autodetected, an ExeFS PFS0 (.nsp) is built instead +# of a homebrew executable (.nro). This is intended to be used for sysmodules. +# NACP building is skipped as well. +#--------------------------------------------------------------------------------- +TARGET := $(notdir $(CURDIR)) +BUILD := build +SOURCES := source +DATA := data +INCLUDES := include +#ROMFS := romfs + +#--------------------------------------------------------------------------------- +# options for code generation +#--------------------------------------------------------------------------------- +ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE + +CFLAGS := -g -Wall -O2 -ffunction-sections \ + $(ARCH) $(DEFINES) + +CFLAGS += $(INCLUDE) -D__SWITCH__ `curl-config --cflags` + +CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions + +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) + +LIBS := `curl-config --libs` -lmbedtls -lmbedx509 -lmbedcrypto -lnx + +#--------------------------------------------------------------------------------- +# list of directories containing libraries, this must be the top level containing +# include and lib +#--------------------------------------------------------------------------------- +LIBDIRS := $(PORTLIBS) $(LIBNX) + + +#--------------------------------------------------------------------------------- +# no real need to edit anything past this point unless you need to add additional +# rules for different file extensions +#--------------------------------------------------------------------------------- +ifneq ($(BUILD),$(notdir $(CURDIR))) +#--------------------------------------------------------------------------------- + +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) + +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \ + $(foreach dir,$(DATA),$(CURDIR)/$(dir)) + +export DEPSDIR := $(CURDIR)/$(BUILD) + +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp))) +SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s))) +BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*))) + +#--------------------------------------------------------------------------------- +# use CXX for linking C++ projects, CC for standard C +#--------------------------------------------------------------------------------- +ifeq ($(strip $(CPPFILES)),) +#--------------------------------------------------------------------------------- + export LD := $(CC) +#--------------------------------------------------------------------------------- +else +#--------------------------------------------------------------------------------- + export LD := $(CXX) +#--------------------------------------------------------------------------------- +endif +#--------------------------------------------------------------------------------- + +export OFILES_BIN := $(addsuffix .o,$(BINFILES)) +export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o) +export OFILES := $(OFILES_BIN) $(OFILES_SRC) +export HFILES_BIN := $(addsuffix .h,$(subst .,_,$(BINFILES))) + +export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ + $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(CURDIR)/$(BUILD) + +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) + +ifeq ($(strip $(CONFIG_JSON)),) + jsons := $(wildcard *.json) + ifneq (,$(findstring $(TARGET).json,$(jsons))) + export APP_JSON := $(TOPDIR)/$(TARGET).json + else + ifneq (,$(findstring config.json,$(jsons))) + export APP_JSON := $(TOPDIR)/config.json + endif + endif +else + export APP_JSON := $(TOPDIR)/$(CONFIG_JSON) +endif + +ifeq ($(strip $(ICON)),) + icons := $(wildcard *.jpg) + ifneq (,$(findstring $(TARGET).jpg,$(icons))) + export APP_ICON := $(TOPDIR)/$(TARGET).jpg + else + ifneq (,$(findstring icon.jpg,$(icons))) + export APP_ICON := $(TOPDIR)/icon.jpg + endif + endif +else + export APP_ICON := $(TOPDIR)/$(ICON) +endif + +ifeq ($(strip $(NO_ICON)),) + export NROFLAGS += --icon=$(APP_ICON) +endif + +ifeq ($(strip $(NO_NACP)),) + export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp +endif + +ifneq ($(APP_TITLEID),) + export NACPFLAGS += --titleid=$(APP_TITLEID) +endif + +ifneq ($(ROMFS),) + export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS) +endif + +.PHONY: $(BUILD) clean all + +#--------------------------------------------------------------------------------- +all: $(BUILD) + +$(BUILD): + @[ -d $@ ] || mkdir -p $@ + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile + +#--------------------------------------------------------------------------------- +clean: + @echo clean ... +ifeq ($(strip $(APP_JSON)),) + @rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf +else + @rm -fr $(BUILD) $(TARGET).nsp $(TARGET).nso $(TARGET).npdm $(TARGET).elf +endif + + +#--------------------------------------------------------------------------------- +else +.PHONY: all + +DEPENDS := $(OFILES:.o=.d) + +#--------------------------------------------------------------------------------- +# main targets +#--------------------------------------------------------------------------------- +ifeq ($(strip $(APP_JSON)),) + +all : $(OUTPUT).nro + +ifeq ($(strip $(NO_NACP)),) +$(OUTPUT).nro : $(OUTPUT).elf $(OUTPUT).nacp +else +$(OUTPUT).nro : $(OUTPUT).elf +endif + +else + +all : $(OUTPUT).nsp + +$(OUTPUT).nsp : $(OUTPUT).nso $(OUTPUT).npdm + +$(OUTPUT).nso : $(OUTPUT).elf + +endif + +$(OUTPUT).elf : $(OFILES) + +$(OFILES_SRC) : $(HFILES_BIN) + +#--------------------------------------------------------------------------------- +# you need a rule like this for each extension you use as binary data +#--------------------------------------------------------------------------------- +%.bin.o %_bin.h : %.bin +#--------------------------------------------------------------------------------- + @echo $(notdir $<) + @$(bin2o) + +-include $(DEPENDS) + +#--------------------------------------------------------------------------------------- +endif +#--------------------------------------------------------------------------------------- diff --git a/client-sysmodule/README.md b/client-sysmodule/README.md new file mode 100644 index 0000000..9240df5 --- /dev/null +++ b/client-sysmodule/README.md @@ -0,0 +1,6 @@ +# sysmodule + +This template is for a sysmodule where the output .nsp is used by Atmosphère at: `sdmc:/atmosphere/contents//exefs.nsp`. To load the sysmodule at boot, the following file should exist: `sdmc:/atmosphere/contents//flags/boot2.flag`. + +Where titleid is whatever you specify. Update the `$(TARGET).json` file with the titleid and any other changes. + diff --git a/client-sysmodule/config.json b/client-sysmodule/config.json new file mode 100644 index 0000000..df5710e --- /dev/null +++ b/client-sysmodule/config.json @@ -0,0 +1,175 @@ +{ + "name": "uNSS-sysmodule", + "title_id": "0x4200000000554E53", + "program_id": "0x4200000000554E53", + "title_id_range_min": "0x4200000000554E53", + "program_id_range_min": "0x4200000000554E53", + "title_id_range_max": "0x4200000000554E53", + "program_id_range_max": "0x4200000000554E53", + "main_thread_stack_size": "0x00004000", + "main_thread_priority": 44, + "default_cpu_id": 3, + "process_category": 0, + "is_retail": true, + "pool_partition": 2, + "is_64_bit": true, + "address_space_type": 1, + "filesystem_access": { + "permissions": "0xffffffffffffffff" + }, + "service_access": [ + "*" + ], + "service_host": [ + "*" + ], + "kernel_capabilities": [ + { + "type": "kernel_flags", + "value": { + "highest_thread_priority": 63, + "lowest_thread_priority": 24, + "lowest_cpu_id": 3, + "highest_cpu_id": 3 + } + }, + { + "type": "syscalls", + "value": { + "svcUnknown": "0x6e", + "svcSetHeapSize": "0x01", + "svcSetMemoryPermission": "0x02", + "svcSetMemoryAttribute": "0x03", + "svcMapMemory": "0x04", + "svcUnmapMemory": "0x05", + "svcQueryMemory": "0x06", + "svcExitProcess": "0x07", + "svcCreateThread": "0x08", + "svcStartThread": "0x09", + "svcExitThread": "0x0a", + "svcSleepThread": "0x0b", + "svcGetThreadPriority": "0x0c", + "svcSetThreadPriority": "0x0d", + "svcGetThreadCoreMask": "0x0e", + "svcSetThreadCoreMask": "0x0f", + "svcGetCurrentProcessorNumber": "0x10", + "svcSignalEvent": "0x11", + "svcClearEvent": "0x12", + "svcMapSharedMemory": "0x13", + "svcUnmapSharedMemory": "0x14", + "svcCreateTransferMemory": "0x15", + "svcCloseHandle": "0x16", + "svcResetSignal": "0x17", + "svcWaitSynchronization": "0x18", + "svcCancelSynchronization": "0x19", + "svcArbitrateLock": "0x1a", + "svcArbitrateUnlock": "0x1b", + "svcWaitProcessWideKeyAtomic": "0x1c", + "svcSignalProcessWideKey": "0x1d", + "svcGetSystemTick": "0x1e", + "svcConnectToNamedPort": "0x1f", + "svcSendSyncRequestLight": "0x20", + "svcSendSyncRequest": "0x21", + "svcSendSyncRequestWithUserBuffer": "0x22", + "svcSendAsyncRequestWithUserBuffer": "0x23", + "svcGetProcessId": "0x24", + "svcGetThreadId": "0x25", + "svcBreak": "0x26", + "svcOutputDebugString": "0x27", + "svcReturnFromException": "0x28", + "svcGetInfo": "0x29", + "svcFlushEntireDataCache": "0x2a", + "svcFlushDataCache": "0x2b", + "svcMapPhysicalMemory": "0x2c", + "svcUnmapPhysicalMemory": "0x2d", + "svcGetFutureThreadInfo": "0x2e", + "svcGetLastThreadInfo": "0x2f", + "svcGetResourceLimitLimitValue": "0x30", + "svcGetResourceLimitCurrentValue": "0x31", + "svcSetThreadActivity": "0x32", + "svcGetThreadContext3": "0x33", + "svcWaitForAddress": "0x34", + "svcSignalToAddress": "0x35", + "svcDumpInfo": "0x3c", + "svcDumpInfoNew": "0x3d", + "svcCreateSession": "0x40", + "svcAcceptSession": "0x41", + "svcReplyAndReceiveLight": "0x42", + "svcReplyAndReceive": "0x43", + "svcReplyAndReceiveWithUserBuffer": "0x44", + "svcCreateEvent": "0x45", + "svcMapPhysicalMemoryUnsafe": "0x48", + "svcUnmapPhysicalMemoryUnsafe": "0x49", + "svcSetUnsafeLimit": "0x4a", + "svcCreateCodeMemory": "0x4b", + "svcControlCodeMemory": "0x4c", + "svcSleepSystem": "0x4d", + "svcReadWriteRegister": "0x4e", + "svcSetProcessActivity": "0x4f", + "svcCreateSharedMemory": "0x50", + "svcMapTransferMemory": "0x51", + "svcUnmapTransferMemory": "0x52", + "svcCreateInterruptEvent": "0x53", + "svcQueryPhysicalAddress": "0x54", + "svcQueryIoMapping": "0x55", + "svcCreateDeviceAddressSpace": "0x56", + "svcAttachDeviceAddressSpace": "0x57", + "svcDetachDeviceAddressSpace": "0x58", + "svcMapDeviceAddressSpaceByForce": "0x59", + "svcMapDeviceAddressSpaceAligned": "0x5a", + "svcMapDeviceAddressSpace": "0x5b", + "svcUnmapDeviceAddressSpace": "0x5c", + "svcInvalidateProcessDataCache": "0x5d", + "svcStoreProcessDataCache": "0x5e", + "svcFlushProcessDataCache": "0x5f", + "svcDebugActiveProcess": "0x60", + "svcBreakDebugProcess": "0x61", + "svcTerminateDebugProcess": "0x62", + "svcGetDebugEvent": "0x63", + "svcContinueDebugEvent": "0x64", + "svcGetProcessList": "0x65", + "svcGetThreadList": "0x66", + "svcGetDebugThreadContext": "0x67", + "svcSetDebugThreadContext": "0x68", + "svcQueryDebugProcessMemory": "0x69", + "svcReadDebugProcessMemory": "0x6a", + "svcWriteDebugProcessMemory": "0x6b", + "svcSetHardwareBreakPoint": "0x6c", + "svcGetDebugThreadParam": "0x6d", + "svcGetSystemInfo": "0x6f", + "svcCreatePort": "0x70", + "svcManageNamedPort": "0x71", + "svcConnectToPort": "0x72", + "svcSetProcessMemoryPermission": "0x73", + "svcMapProcessMemory": "0x74", + "svcUnmapProcessMemory": "0x75", + "svcQueryProcessMemory": "0x76", + "svcMapProcessCodeMemory": "0x77", + "svcUnmapProcessCodeMemory": "0x78", + "svcCreateProcess": "0x79", + "svcStartProcess": "0x7a", + "svcTerminateProcess": "0x7b", + "svcGetProcessInfo": "0x7c", + "svcCreateResourceLimit": "0x7d", + "svcSetResourceLimitLimitValue": "0x7e", + "svcCallSecureMonitor": "0x7f" + } + }, + { + "type": "min_kernel_version", + "value": "0x0030" + }, + { + "type": "handle_table_size", + "value": 64 + }, + { + "type": "debug_flags", + "value": { + "allow_debug": false, + "force_debug": true, + "force_debug_prod": false + } + } + ] +} \ No newline at end of file diff --git a/client-sysmodule/deploy.sh b/client-sysmodule/deploy.sh new file mode 100755 index 0000000..5a2fc30 --- /dev/null +++ b/client-sysmodule/deploy.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# +# 빌드한 sysmodule 을 스위치의 올바른 위치로 올린다. +# +# ./deploy.sh 192.168.1.40 +# ./deploy.sh 192.168.1.40:21 +# +# Atmosphere 는 atmosphere/contents// 만 쳐다본다. +# 폴더 이름이 곧 프로그램 ID 이므로 config.json 에서 직접 읽어온다. +# 손으로 옮기면 ID 가 어긋나기 쉬워서 스크립트로 고정한다. + +set -e + +HOSTPORT=$1 +if [ -z "$HOSTPORT" ]; then + echo "usage: $0 [:port]" >&2 + exit 1 +fi + +HOST=$(echo "$HOSTPORT" | cut -d':' -f1) +PORT=$(echo "$HOSTPORT" | cut -d':' -f2 -s) +PORT=${PORT:-21} + +# Makefile 의 TARGET 은 디렉토리 이름에서 나온다 -> client-sysmodule.nsp +NSP=client-sysmodule.nsp +if [ ! -f "$NSP" ]; then + echo "$NSP 이 없다. 먼저 make 를 실행할 것." >&2 + exit 1 +fi + +# "program_id": "0x0100000000554E53" -> 0100000000554E53 +PROGRAM_ID=$(sed -n 's/.*"program_id"[^"]*"0x\([0-9A-Fa-f]*\)".*/\1/p' config.json | head -n 1) +if [ -z "$PROGRAM_ID" ]; then + echo "config.json 에서 program_id 를 찾지 못했다." >&2 + exit 1 +fi + +BASE="ftp://$HOST:$PORT/ams_contents:/$PROGRAM_ID" +FTPOPTS="--connect-timeout 15 --user anonymous:anonymous --ftp-create-dirs -sS" + +echo "-> $HOST:$PORT (program id $PROGRAM_ID)" + +# exefs.nsp 라는 이름이어야 한다. client.nsp 그대로 올리면 인식하지 않는다. +curl $FTPOPTS -T "$NSP" "$BASE/exefs.nsp" +echo " exefs.nsp" + +# 빈 파일이면 된다. 이게 있어야 부팅 시 자동 실행된다. +TMPFLAG=$(mktemp) +curl $FTPOPTS -T "$TMPFLAG" "$BASE/flags/boot2.flag" +rm -f "$TMPFLAG" +echo " flags/boot2.flag" + +echo "-> 재부팅하면 적용된다." diff --git a/client-sysmodule/source/account.cpp b/client-sysmodule/source/account.cpp new file mode 120000 index 0000000..85ae7b7 --- /dev/null +++ b/client-sysmodule/source/account.cpp @@ -0,0 +1 @@ +../../client/source/account.cpp \ No newline at end of file diff --git a/client-sysmodule/source/account.hpp b/client-sysmodule/source/account.hpp new file mode 120000 index 0000000..f7591fb --- /dev/null +++ b/client-sysmodule/source/account.hpp @@ -0,0 +1 @@ +../../client/source/account.hpp \ No newline at end of file diff --git a/client-sysmodule/source/fileio.cpp b/client-sysmodule/source/fileio.cpp new file mode 120000 index 0000000..c0f0d2e --- /dev/null +++ b/client-sysmodule/source/fileio.cpp @@ -0,0 +1 @@ +../../client/source/fileio.cpp \ No newline at end of file diff --git a/client-sysmodule/source/fileio.hpp b/client-sysmodule/source/fileio.hpp new file mode 120000 index 0000000..4f3ecb1 --- /dev/null +++ b/client-sysmodule/source/fileio.hpp @@ -0,0 +1 @@ +../../client/source/fileio.hpp \ No newline at end of file diff --git a/client-sysmodule/source/http.cpp b/client-sysmodule/source/http.cpp new file mode 120000 index 0000000..f59b212 --- /dev/null +++ b/client-sysmodule/source/http.cpp @@ -0,0 +1 @@ +../../client/source/http.cpp \ No newline at end of file diff --git a/client-sysmodule/source/http.hpp b/client-sysmodule/source/http.hpp new file mode 120000 index 0000000..e01c144 --- /dev/null +++ b/client-sysmodule/source/http.hpp @@ -0,0 +1 @@ +../../client/source/http.hpp \ No newline at end of file diff --git a/client-sysmodule/source/ini.hpp b/client-sysmodule/source/ini.hpp new file mode 120000 index 0000000..3b4c188 --- /dev/null +++ b/client-sysmodule/source/ini.hpp @@ -0,0 +1 @@ +../../client/source/ini.hpp \ No newline at end of file diff --git a/client-sysmodule/source/main.cpp b/client-sysmodule/source/main.cpp new file mode 100644 index 0000000..2d71bbc --- /dev/null +++ b/client-sysmodule/source/main.cpp @@ -0,0 +1,1124 @@ +// uNSS 백그라운드 자동 백업 sysmodule. +// +// 부팅할 때 Atmosphere 가 띄우고, 그 뒤로는 계속 살아 있는다. 이따금 깨어나 +// 세이브가 바뀌었는지 보고, 바뀌었으면 서버로 올린다 - 특히 게임을 막 끝낸 +// 직후에. 아무것도 안 바뀌었으면 아무것도 하지 않는다. 몇 주가 걸리든. +// +// 부팅 때 한 번만 도는 설계였을 때는 사실상 돌지 않았다. 스위치는 끄는 +// 물건이 아니라 덮는 물건이라, 재부팅이 몇 주에 한 번이기 때문이다. +// +// 복원은 하지 않는다 - 그건 사용자가 GUI 에서 눈으로 보며 결정할 일이다. +// +// 측정 결과 (실기, 22.5.0): 이 프로세스가 쓸 수 있는 주소 공간은 약 14 MiB. +// 프로토타입이 2.3 MiB 를 썼고 mbedTLS 도 문제없이 올라갔다. + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "account.hpp" +#include "fileio.hpp" +#include "http.hpp" +#include "ini.hpp" +#include "sync.hpp" + + +// 절대 함부로 올리지 말 것. +// +// 6 MiB 로 잡았다가 부팅이 망가졌다 (2026-07-31). HID(0100000000000013) 가 +// 메모리를 못 받아 죽었고, 콘솔이 2001-0132 로 부팅 루프에 빠졌다. +// 20.0.0 이후 sysmodule 풀은 아주 빠듯하다. 이 프로세스가 크게 잡으면 +// 우리 모듈이 안 뜨는 정도가 아니라 시스템 모듈이 같이 죽는다. +// +// 그리고 2 MiB 도 컸다. 2026-08-01, am(0100000000000023) 이 같은 2001-0132 +// 로 세 번 죽었다 - 세 번 다 정확히 같은 자리에서 (PC-start = 0x390b4). +// 매번 우리 모듈이 뜨고 30 초 안이었다. 달라진 것은 하나다: 이날부터 +// sys-ftpd 가 같은 풀에서 함께 뜬다. 둘이 같이 들어가지 않는다. +// +// 우리: 0x200000 = 2048 KB +// sys-ftpd: 0x0A7000 = 668 KB (그쪽 HEAP_SIZE) +// +// 그래서 절반으로 줄인다. 이 값은 쓰는 만큼이 아니라 잡은 만큼 그대로 +// 풀에서 빠진다 - 정적 배열이기 때문이다. 한 바퀴에 실제로 얼마나 쓰는지는 +// 이제 로그에 남으므로 (logHeapUsage), 다음에는 재고 나서 정하면 된다. +// +// 모자라면 압축이나 업로드가 실패할 뿐 부팅은 멀쩡하다. 그쪽이 안전하다. +// +// 2026-08-01 에 재봤다. 1 MiB 로 한 바퀴 돌린 뒤 최고점이 189 KB 였다: +// +// heap after round: 179 KB in use, 189 KB reached, 1024 KB total +// +// 그래서 256 KB 로 줄였다가 되돌렸다. 그 크기에서는 이렇게 나왔다: +// +// heap after round: 201 KB in use, 245 KB reached, 256 KB total +// +// 같은 일을 하는데 최고점이 189 에서 245 로 올랐다. 최고 사용량은 고정된 +// 수치가 아니라 힙 크기에 딸려 움직인다 - 좁으면 조각이 나서 더 쓴다. +// 결과는 압축 실패 (ret=-3), 타이틀 이름 조회 실패 ("Unknown", 144 KB 짜리 +// 구조체를 못 잡는다), 업로드 실패 (http=-7) 였다. +// +// 재는 것은 옳았지만 그 값을 하한으로 쓴 것이 틀렸다. 실측치는 참고일 뿐이다. +// 한 바퀴가 끝까지 돌아간 것이 확인된 크기로 돌아간다. 풀에도 여유가 생겼다 - +// 오버레이 로더 세 개를 걷어내고 나서 시작 시점 여유가 15 MB 에서 21 MB 가 +// 되었다. +#define INNER_HEAP_SIZE 0x100000 + +static const char* LOG_PATH = "sdmc:/uNSS/sysmodule.log"; +static const char* CONFIG_PATH = "sdmc:/uNSS/config.ini"; +static const char* SAVE_DATA_PATH = "sdmc:/uNSS/saves"; + +// 우리 자신의 부팅 플래그. 스스로를 꺼야 할 때만 건드린다 (RunMarker 참고). +static const char* BOOT_FLAG_PATH = + "sdmc:/atmosphere/contents/4200000000554E53/flags/boot2.flag"; +static const char* BOOT_FLAG_DISABLED_PATH = + "sdmc:/atmosphere/contents/4200000000554E53/flags/boot2.flag.crashed"; + +// 실행 중임을 남기는 표시. 안에는 시작 시점의 fatal report 개수가 들어간다. +static const char* RUN_MARKER_PATH = "sdmc:/uNSS/.running"; + +// 연속으로 몇 번 죽었는지. 백업을 얼마나 쉬었다 다시 해볼지가 여기서 나온다. +// +// 스스로를 영영 끄지는 않는다. 끄는 설계는 "사람이 알아채고 앱을 열어 되살린다" +// 를 전제하는데, 이 콘솔에는 알림이라는 것이 없다. 알아챌 방법이 없는 상태를 +// 만들면 백업은 조용히 멈춘 채로 남는다 - 백업이 하지 말아야 할 단 하나다. +// +// 대신 물러섰다 다시 온다. 죽은 자리가 백업이라면 백업만 쉬면 되고, 모듈은 +// 계속 살아 있으니 다음 기회에 스스로 복구한다. 사람 손이 필요 없다. +// +// fatal report 개수는 콘솔 전체를 세므로 남의 게임이 죽어도 우리 탓으로 +// 보인다. 백업 한 바퀴는 몇 분씩 걸리니 (waitForNetwork 만 최대 180 초) +// 그 확률이 낮지 않다 - 그래서 첫 사고는 짧게만 쉰다. +static const char* CRASH_STRIKE_PATH = "sdmc:/uNSS/.crashes"; + +// 사고 뒤에 쉬는 바퀴 수. 한 바퀴는 5 분이다. +// +// 1 회 -> 1 바퀴 (5 분) +// 2 회 -> 4 바퀴 (20 분) +// 3 회 -> 16 바퀴 (1 시간 20 분) +// 그 뒤 -> 24 바퀴 (2 시간, 상한) +// +// 첫 번째를 짧게 두는 이유가 있다. fatal report 개수는 콘솔 전체를 세므로, +// 우리가 도는 몇 분 사이에 남의 게임이 죽어도 우리 탓으로 보인다. 그런 +// 우연 하나에 몇 시간을 쉬는 것은 과하다. 반대로 우리 버그라면 매번 같은 +// 자리에서 재현되므로 금세 위 칸으로 올라간다. +// +// 목표는 이 값을 쓸 일이 없는 것이다. 실제 원인은 고쳤다 - 16KB 스택에 +// 144KB 구조체를 올린 것이었고, fatal report 두 개가 같은 자리를 가리켰다. +// 다만 시스템 모듈이 죽으면 자기만 죽는 것이 아니라 콘솔이 같이 죽는다. +// 모르는 이유로 또 죽더라도 5 분마다 콘솔이 꺼지지는 않게 물러서 있을 뿐, +// 끄지는 않는다. +static const int CRASH_BACKOFF_MAX_ROUNDS = 24; + +int crashBackoffRounds(int strikes) +{ + if (strikes < 1) return 0; + + int rounds = 1; + for (int i = 1; i < strikes; ++i) + { + if (rounds >= CRASH_BACKOFF_MAX_ROUNDS / 4) return CRASH_BACKOFF_MAX_ROUNDS; + rounds *= 4; + } + + return rounds > CRASH_BACKOFF_MAX_ROUNDS ? CRASH_BACKOFF_MAX_ROUNDS : rounds; +} + +// Atmosphere 는 사고를 세 군데에 나눠 적는다. 우리가 세는 것은 앞의 두 +// 곳뿐이고 - 우리가 겪은 두 번의 사고가 각각 다른 쪽에 적혔다 - 세 번째는 +// 셀 수 없다. 그래서 "새 리포트 없음" 을 "아무 일 없었음" 으로 적으면 안 된다. +// +// crash_reports: 일반 프로그램이 죽으면 여기다. 파일 이름에 프로그램 ID 가 +// 들어가므로 (예: 01785498797_4200000000554e53.log) 우리 것만 골라 셀 수 +// 있다. 우리가 죽었다는 확실한 증거다. 2168-0002 가 여기 있었다. +// +// fatal_reports: 시스템 프로세스가 죽으면 여기다. 이름에는 죽은 쪽의 ID 가 +// 들어가므로 우리 것으로 보이지 않는다 - 우리가 남을 죽였을 때가 그렇다. +// 부팅 직후 리소스를 잡아 hid 를 죽였던 2001-0132 가 여기 있었고, 이름은 +// 0100000000000013 (hid) 이었다. 그래서 이쪽은 전체 개수로만 볼 수 있고, +// 남의 사고와 구별되지 않는다. +// +// fatal_errors: 세 번째 자리다. 세지 않는다. +// fatal 모듈 자신이 죽으면 (Title ID 0100000000000034) 리포트가 이쪽으로 +// report_XXXXXXXX.bin 이름으로 가고, 위의 두 디렉터리는 비어 있는 채로 +// 남는다. 콘솔이 멈췄는데도 우리 눈에는 아무 흔적이 없는 경우다. +// 실제로 그랬다: 상주 모듈을 하나 더 올렸더니 sm 세션이 동나서 +// (2021-0003, sm::ResultOutOfSessions) 홈브루를 띄울 때마다 콘솔이 +// 멈췄는데, 우리 로그는 "아무것도 안 죽었다" 고 적고 있었다 +// (2026-08-02). +// +// 여기를 세지 않는 이유는 우리 사고가 아니기 때문이다. fatal 이 죽은 것은 +// 우리가 죽은 것도, 우리가 남을 죽인 것도 아니고, 물러선다고 나아지지도 +// 않는다. 대신 위의 두 곳만 봤다는 사실을 로그에 그대로 적는다. +static const char* CRASH_REPORTS_DIR = "sdmc:/atmosphere/crash_reports"; +static const char* FATAL_REPORTS_DIR = "sdmc:/atmosphere/fatal_reports"; + +// crash_reports 파일 이름에서 찾을 우리 프로그램 ID. 소문자로 적힌다. +static const char* OWN_PROGRAM_ID_LOWER = "4200000000554e53"; + +// 시스템 모듈들이 다 뜰 때까지 비켜서 있는 시간. +// 부팅 직후에 리소스를 잡으면 HID 가 죽는다 (2001-0132). +static const u64 STARTUP_GRACE_SECONDS = 30; + +// 한 바퀴 돌고 다음까지 쉬는 시간. +// +// 스위치는 껐다 켜는 물건이 아니라 덮었다 여는 물건이다. 부팅 때 한 번만 +// 도는 설계로는 몇 주가 지나도 백업이 돌지 않는다. 그래서 계속 살아 있으면서 +// 이따금 들여다본다. +// +// 5 분마다 하는 일은 파일 시각 비교뿐이다. 올릴 것이 없으면 소켓도 열지 +// 않는다. 게임을 막 끝냈을 때는 이 간격을 기다리지 않고 바로 확인한다. +static const u64 POLL_SECONDS = 300; + +// 게임이 도는 동안에는 더 자주 본다. 끝나는 순간을 놓치면 다음 바퀴까지 +// 백업이 밀린다. 이 확인은 pm:dmnt 한 번 호출이라 값이 싸다. +static const u64 GAME_POLL_SECONDS = 60; + + +extern "C" { + +u32 __nx_applet_type = AppletType_None; +u32 __nx_fs_num_sessions = 1; + +extern void __libnx_init_time(void); + +static bool g_socketReady = false; +static bool g_nifmReady = false; +static bool g_lateServicesReady = false; +static bool g_pmdmntReady = false; + +void __libnx_initheap(void) +{ + static u8 inner_heap[INNER_HEAP_SIZE]; + extern void* fake_heap_start; + extern void* fake_heap_end; + + fake_heap_start = inner_heap; + fake_heap_end = inner_heap + sizeof(inner_heap); +} + +void __appInit(void) +{ + Result rc; + + rc = smInitialize(); + if (R_FAILED(rc)) + diagAbortWithResult(MAKERESULT(Module_Libnx, LibnxError_InitFail_SM)); + + rc = setsysInitialize(); + if (R_SUCCEEDED(rc)) + { + SetSysFirmwareVersion fw; + if (R_SUCCEEDED(setsysGetFirmwareVersion(&fw))) + hosversionSet(MAKEHOSVERSION(fw.major, fw.minor, fw.micro)); + setsysExit(); + } + + rc = timeInitialize(); + if (R_SUCCEEDED(rc)) + __libnx_init_time(); + + rc = fsInitialize(); + if (R_FAILED(rc)) + diagAbortWithResult(MAKERESULT(Module_Libnx, LibnxError_InitFail_FS)); + + fsdevMountSdmc(); + + // 여기서 끝낸다. ns / account / socket / nifm 은 열지 않는다. + // + // boot2 는 HID 같은 시스템 모듈과 같은 시점에 뜨고, 리소스 풀을 나눠 쓴다. + // 예전에는 이 자리에서 넷까지 다 열었고, 그 결과가 HID 의 2001-0132 + // (커널 LimitReached) 였다 - 콘솔이 부팅 루프에 빠졌다 (2026-07-31, + // fatal_reports 로 확인). 우리 로그의 시작 시각과 리포트 시각이 정확히 + // 겹친다. + // + // 무거운 것은 initLateServices() 에서 시스템이 다 뜬 뒤에 연다. + // smExit() 도 그래서 여기서 부르지 않는다 - 나중에 열려면 SM 이 필요하다. +} + + +// 타이틀 목록과 세이브 시각을 보는 데 필요한 것들. 네트워크는 아직 열지 +// 않는다 - 올릴 것이 있는지는 파일 시각만으로 알 수 있고, 없으면 소켓을 +// 여는 것 자체가 낭비다. +static bool initTitleServices(void) +{ + if (R_FAILED(nsInitialize())) + return false; + + if (R_FAILED(accountInitialize(AccountServiceType_System))) + { + nsExit(); + return false; + } + g_lateServicesReady = true; + + // isGameRunning() 이 쓰는 pm:dmnt 는 SM 이 살아 있을 때 열어야 한다. + // 그 함수는 자기가 열고 닫지만, smExit() 뒤에는 smGetService 가 실패하고 + // 실패하면 조용히 false - 즉 "게임 안 돌고 있음" - 를 준다. 그러면 게임이 + // 세이브를 붙잡고 있는 채로 백업이 돌아간다. + // + // libnx 는 세션을 세므로, 여기서 한 번 열어두면 그 뒤의 initialize / + // exit 쌍은 카운트만 오르내리고 세션은 살아 있다. + g_pmdmntReady = R_SUCCEEDED(pmdmntInitialize()); + + return true; +} + + +// 올릴 것이 정말 있을 때만 연다. 소켓 버퍼는 시스템 풀에서 나오고, +// 그 풀을 부팅 직후에 건드린 것이 2001-0132 의 원인이었다. +static bool initNetworkServices(void) +{ + // 프로토타입에서 실기 검증된 작은 값. 키우면 그만큼 풀을 먹는다. + static const SocketInitConfig sockConf = { + .tcp_tx_buf_size = 0x2000, + .tcp_rx_buf_size = 0x4000, + .tcp_tx_buf_max_size = 0x8000, + .tcp_rx_buf_max_size = 0x10000, + .udp_tx_buf_size = 0x800, + .udp_rx_buf_size = 0x1000, + .sb_efficiency = 1, + .num_bsd_sessions = 2, + .bsd_service_type = BsdServiceType_User, + }; + + g_socketReady = R_SUCCEEDED(socketInitialize(&sockConf)); + g_nifmReady = R_SUCCEEDED(nifmInitialize(NifmServiceType_User)); + + // 여기서 smExit() 를 부르면 안 된다. 네트워크는 소켓만으로 되지 않는다. + // + // 이름 풀이부터가 그렇다. sfdnsres 에는 초기화 함수 자체가 없고, 요청 + // 하나하나가 smGetServiceOriginal 로 직접 서비스를 연다 (libnx.a 의 + // sfdnsres.o - 정의된 것은 *Request 뿐이다). socketInitialize 는 bsd 만 + // 열 뿐 sfdnsres 는 건드리지 않는다. SM 을 닫으면 getaddrinfo 가 + // 그때부터 실패한다. TLS 도 마찬가지다 - libcurl 은 첫 https 연결에서야 + // ssl 을 연다. + // + // 증상이 고약했다. 소켓은 열려 있으니 "network ready" 가 찍히고, 그 + // 다음 push 만 조용히 떨어진다. 서버 로그에는 아무것도 남지 않는다 - + // 바이트가 나간 적이 없기 때문이다. 앱에서는 같은 코드가 잘 도는데, + // 앱은 SM 을 닫지 않아서다 (2026-08-01, http=-4 로 확인). + // + // 서비스를 하나씩 미리 열어두는 길도 있지만, 그것은 무엇이 게을리 + // 열리는지 전부 알아야만 맞는 방법이다. 세션 하나를 계속 쥐고 있는 + // 편이 싸고, 무엇보다 다음에 또 틀리지 않는다. + + return g_socketReady && g_nifmReady; +} + +void __appExit(void) +{ + // 늦게 연 것들은 열렸을 때만 닫는다. initLateServices() 까지 가지 못하고 + // 끝나는 경로가 여러 개 있다 (설정이 꺼져 있거나, 아직 백업할 때가 + // 아니거나). + if (g_pmdmntReady) pmdmntExit(); + if (g_nifmReady) nifmExit(); + if (g_socketReady) socketExit(); + if (g_lateServicesReady) + { + accountExit(); + nsExit(); + } + fsdevUnmountAll(); + timeExit(); + fsExit(); +} + +} // extern "C" + + +namespace +{ + +void writeLog(const std::string& line) +{ + FILE* fp = fopen(LOG_PATH, "a"); + if (!fp) return; + + const time_t now = time(NULL); + struct tm* tm = localtime(&now); + + if (tm) + { + fprintf(fp, "[%02d:%02d:%02d] %s\n", + tm->tm_hour, tm->tm_min, tm->tm_sec, line.c_str()); + } + else + { + fprintf(fp, "%s\n", line.c_str()); + } + + fclose(fp); +} + + +// 힙을 실제로 얼마나 썼는지 남긴다. +// +// INNER_HEAP_SIZE 는 지금까지 두 번 다 짐작으로 정했고, 두 번 다 너무 컸다: +// 6 MiB 는 hid 를, 2 MiB 는 am 을 죽였다. 둘 다 2001-0132 였고, 둘 다 +// "이 정도면 되겠지" 에서 나왔다. 재고 나서 정하면 그럴 일이 없다. +// +// uordblks 는 지금 잡혀 있는 양, arena 는 힙이 자라난 최고점이다. 후자가 +// 한 바퀴의 최대 사용량에 가깝다 - 그 값에 여유를 더한 것이 맞는 크기다. +void logHeapUsage(const char* when) +{ + const struct mallinfo mi = mallinfo(); + + writeLog(std::string("heap ") + when + ": " + + std::to_string(mi.uordblks / 1024) + " KB in use, " + + std::to_string(mi.arena / 1024) + " KB reached, " + + std::to_string(INNER_HEAP_SIZE / 1024) + " KB total"); +} + + +// 어느 통이 비었는지 커널에 직접 물어본다. +// +// 2001-0132 는 "한계에 닿았다" 는 뜻이고, 커널이 세는 한계는 다섯 가지뿐이다: +// 물리 메모리, 스레드, 이벤트, 전송 메모리, 세션. 지금까지 우리는 그중 +// 메모리라고 짐작하고 크기를 두 번 줄였다 (6 -> 2 -> 1 MiB). 두 번 다 am 은 +// 똑같이 죽었다. 짐작이 두 번 빗나갔으면 세 번째도 짐작할 일이 아니다. +// +// 값은 줄마다 파일을 닫는 로그로 나가므로, 바로 뒤에 콘솔이 죽어도 남는다. +void logResourceLimits(const char* when) +{ + static const char* POOL_NAMES[] = {"application", "applet", "system", "system-unsafe"}; + + for (u64 pool = 0; pool < 4; ++pool) + { + u64 total = 0; + u64 used = 0; + + const Result rcTotal = svcGetSystemInfo(&total, 0, INVALID_HANDLE, pool); + const Result rcUsed = svcGetSystemInfo(&used, 1, INVALID_HANDLE, pool); + + if (R_FAILED(rcTotal) || R_FAILED(rcUsed)) + { + writeLog(std::string("pool ") + POOL_NAMES[pool] + ": cannot read (rc=" + + std::to_string(R_FAILED(rcTotal) ? rcTotal : rcUsed) + ")"); + continue; + } + + writeLog(std::string("pool ") + POOL_NAMES[pool] + " " + when + ": " + + std::to_string(used / 1024) + " of " + + std::to_string(total / 1024) + " KB used, " + + std::to_string((total - used) / 1024) + " KB free"); + } + + // 우리 프로세스가 실제로 쥐고 있는 양. 풀에서 우리 몫이 얼마인지는 + // 이것으로만 알 수 있다 - mallinfo 는 malloc 한 것만 세므로 코드와 + // 스택, 그리고 정적 배열인 힙 자체가 빠져 있다. + u64 totalMem = 0; + u64 usedMem = 0; + + if (R_SUCCEEDED(svcGetInfo(&totalMem, InfoType_TotalMemorySize, CUR_PROCESS_HANDLE, 0)) + && R_SUCCEEDED(svcGetInfo(&usedMem, InfoType_UsedMemorySize, CUR_PROCESS_HANDLE, 0))) + { + writeLog(std::string("process memory ") + when + ": " + + std::to_string(usedMem / 1024) + " of " + + std::to_string(totalMem / 1024) + " KB used"); + } + + // 우리에게 걸린 한계. 시스템 모듈들은 이것을 나눠 쓴다 - 여기서 우리가 + // 축내면 남이 못 쓰고, 못 쓰는 쪽이 죽는다. am 이 그랬을 수 있다. + // + // 아래 다섯 가지 중 sessions 는 특히 값이 나간다. 세션이 동나면 + // 홈브루가 아예 뜨지 않고 콘솔이 멈추는데 (2021-0003), 밖에서는 그 + // 숫자를 볼 방법이 없다 - 이 줄이 유일한 창이다. + // + // 이 줄은 두 번 틀렸다. 둘 다 조용히 틀려서 오래 갔다. + // + // 첫째, InfoType_ResourceLimit 는 9 인데 5 를 넣고 있었다. 5 는 + // InfoType_HeapRegionSize 라서 호출 자체는 성공했고, 핸들 자리에는 + // 힙 영역 크기가 들어왔다. 그래서 아래 다섯 줄이 실기에서 매번 전부 + // "cannot read" 로 나왔다 - 그런데 rcInfo 는 0 이었으므로 왜 그런지는 + // 어디에도 적히지 않았다. 진단하려고 넣은 코드가 진단을 막고 있었다. + // + // 둘째, 핸들 자리다. 이 조회만은 CUR_PROCESS_HANDLE 이 아니라 + // INVALID_HANDLE 을 요구한다. 커널이 그렇게 검사한다: + // + // R_UNLESS(handle == ams::svc::InvalidHandle, svc::ResultInvalidHandle()); + // -- libmesosphere/source/svc/kern_svc_info.cpp + // + // InfoType 만 고쳤을 때 실기에서 2001-0114 가 났다 (rc=58369, 즉 + // 0xE401 -> 모듈 1, 설명 114 = svc::ResultInvalidHandle). 고친 줄이 + // 여전히 읽히지 않았고, 그때는 이유를 몰랐다 (2026-08-02). + u64 handleValue = 0; + const Result rcInfo = svcGetInfo(&handleValue, InfoType_ResourceLimit, + INVALID_HANDLE, 0); + + if (R_FAILED(rcInfo)) + { + writeLog("resource limit: cannot read (rc=" + std::to_string(rcInfo) + ")"); + return; + } + + const Handle reslimit = (Handle)handleValue; + + static const char* LIMIT_NAMES[] = + {"memory-KB", "threads", "events", "transfer-memory", "sessions"}; + + for (int which = 0; which < 5; ++which) + { + s64 limit = 0; + s64 current = 0; + + const Result rcLimit = + svcGetResourceLimitLimitValue(&limit, reslimit, (LimitableResource)which); + const Result rcCurrent = R_SUCCEEDED(rcLimit) + ? svcGetResourceLimitCurrentValue(¤t, reslimit, (LimitableResource)which) + : rcLimit; + + if (R_FAILED(rcCurrent)) + { + // rc 를 같이 적는다. 이것이 없어서 다섯 줄이 몇 주 동안 그냥 + // "cannot read" 였고, 원인이 위의 잘못된 InfoType 이라는 것을 + // 로그만 봐서는 알 수 없었다. + writeLog(std::string("limit ") + LIMIT_NAMES[which] + ": cannot read (rc=" + + std::to_string(rcCurrent) + ")"); + continue; + } + + // 메모리만 바이트로 나온다. 나머지는 개수다. + if (which == 0) + { + limit /= 1024; + current /= 1024; + } + + writeLog(std::string("limit ") + LIMIT_NAMES[which] + " " + when + ": " + + std::to_string(current) + " of " + std::to_string(limit)); + } + + svcCloseHandle(reslimit); +} + + +// 위와 같은 것을 한 줄로. 몇 초 간격으로 반복해서 남기기 위한 것이다. +// +// am 은 우리가 뜬 뒤 4-6 초에 죽는데 (2026-08-01, 네 번 모두), 우리는 그때 +// 30 초를 자고 있어서 그 순간의 값을 볼 방법이 없었다. 자는 동안에도 계속 +// 적으면 죽기 직전까지의 흐름이 남는다 - 어느 통이 어떻게 줄어드는지가 +// 한 번의 스냅샷보다 훨씬 많은 것을 말해준다. +void logPoolsBrief(const char* when) +{ + std::string line = std::string("pools ") + when + " (KB free):"; + + for (u64 pool = 0; pool < 4; ++pool) + { + static const char* SHORT_NAMES[] = {"app", "applet", "sys", "sys-unsafe"}; + + u64 total = 0; + u64 used = 0; + + if (R_FAILED(svcGetSystemInfo(&total, 0, INVALID_HANDLE, pool)) + || R_FAILED(svcGetSystemInfo(&used, 1, INVALID_HANDLE, pool))) + { + line += std::string(" ") + SHORT_NAMES[pool] + "=?"; + continue; + } + + line += std::string(" ") + SHORT_NAMES[pool] + "=" + + std::to_string((total - used) / 1024); + } + + writeLog(line); +} + + +// SD 가 올라올 때까지 기다린다. boot2 는 아주 이른 시점에 돌기 때문에 +// 한 번 실패했다고 끝내면 아무것도 못 한다. +bool waitForSdCard(int maxSeconds) +{ + for (int i = 0; i < maxSeconds; ++i) + { + FILE* fp = fopen(LOG_PATH, "a"); + if (fp) + { + fclose(fp); + return true; + } + svcSleepThread(1000000000ULL); + } + return false; +} + + +// 무선랜이 붙을 때까지 기다린다. 실측 16 초였다. +bool waitForNetwork(int maxSeconds) +{ + if (!g_nifmReady) return false; + + for (int i = 0; i < maxSeconds; ++i) + { + NifmInternetConnectionType type; + u32 strength = 0; + NifmInternetConnectionStatus status; + + if (R_SUCCEEDED(nifmGetInternetConnectionStatus(&type, &strength, &status)) + && status == NifmInternetConnectionStatus_Connected) + { + writeLog("network ready after " + std::to_string(i) + " s"); + return true; + } + + svcSleepThread(1000000000ULL); + } + + writeLog("no network after " + std::to_string(maxSeconds) + " s - giving up"); + return false; +} + + +// 이름 안에 우리 프로그램 ID 가 들어 있는지. 대소문자는 가리지 않는다 - +// Atmosphere 는 소문자로 적지만, 그것에 기대고 싶지 않다. +bool nameHasOwnProgramId(const char* name) +{ + const size_t idLen = strlen(OWN_PROGRAM_ID_LOWER); + const size_t nameLen = strlen(name); + if (nameLen < idLen) return false; + + for (size_t start = 0; start + idLen <= nameLen; ++start) + { + size_t i = 0; + for (; i < idLen; ++i) + { + if (tolower((unsigned char)name[start + i]) != OWN_PROGRAM_ID_LOWER[i]) + break; + } + if (i == idLen) return true; + } + + return false; +} + + +// 우리 이름이 붙은 crash report 의 개수. 이것이 늘었다면 우리가 죽은 것이다 - +// 추측이 아니라 Atmosphere 가 적어둔 사실이다. +int countOwnCrashReports() +{ + DIR* dir = opendir(CRASH_REPORTS_DIR); + if (!dir) return 0; + + int count = 0; + while (const struct dirent* entry = readdir(dir)) + { + if (entry->d_name[0] == '.') continue; + if (nameHasOwnProgramId(entry->d_name)) ++count; + } + + closedir(dir); + return count; +} + + +// Atmosphere 가 남긴 치명적 오류 보고서의 개수. +// +// 시각이 아니라 개수를 쓴다. 부팅 직후에는 RTC 가 아직 맞지 않을 수 있어서 +// 시각 비교는 믿을 것이 못 된다. 개수는 단조 증가한다. +// +// 이쪽은 시스템 프로세스가 죽은 기록이라 이름으로 우리를 가려낼 수 없다. +// 개수만 본다 - 우리가 남을 죽였을 때 (2001-0132, hid) 잡히는 유일한 길이다. +int countFatalReports() +{ + DIR* dir = opendir(FATAL_REPORTS_DIR); + if (!dir) return 0; + + int count = 0; + while (const struct dirent* entry = readdir(dir)) + { + if (entry->d_name[0] == '.') continue; + ++count; + } + + closedir(dir); + return count; +} + + +// 스스로 부팅 플래그를 치운다. 다음 부팅부터 이 모듈은 뜨지 않는다. +void disableSelf() +{ + remove(BOOT_FLAG_DISABLED_PATH); + rename(BOOT_FLAG_PATH, BOOT_FLAG_DISABLED_PATH); +} + + +// 실행 중임을 남겨두는 표시. 무사히 끝나면 소멸자가 지운다. 죽으면 남는다. +// +// 남아 있는 표시만으로는 무슨 일이 있었는지 알 수 없다. 우리가 시스템을 +// 죽인 것일 수도 있고, 백업 도중에 사용자가 콘솔을 끈 것일 수도 있다. +// 그래서 시작할 때의 fatal report 개수를 함께 적어둔다. 다음 실행에서 +// 그 수가 늘어 있으면 전자, 그대로면 후자다. +// +// 이 구분이 중요한 이유: 전자라면 다시 뜨는 것 자체가 위험하고, 후자라면 +// 아무 일도 없었으니 그냥 계속하면 된다. 사용자가 앱을 열어 뭔가 눌러야만 +// 복구되는 설계는 - 앱을 안 열면 - 영영 복구되지 않는다. +// 어느 구간이었는지도 함께 남긴다. 대응이 정반대이기 때문이다. +// +// 백업 중에 죽었다면 백업만 잠시 쉬면 된다 - 콘솔은 멀쩡히 쓸 수 있고, +// 모듈은 살아서 스스로 다시 해본다. +// +// 부팅 중에 죽었다면 다르다. 다시 떠서 또 죽으면 콘솔이 부팅 루프에 빠지고, +// 그때는 SD 카드를 빼는 것 말고 길이 없다. 그래서 그 경우에만 물러난다. +enum class RunPhase +{ + Boot = 0, + Backup = 1, +}; + +struct RunMarker +{ + static void clear() { remove(RUN_MARKER_PATH); } + + static void write(int fatalCount, int ownCrashCount, RunPhase phase) + { + FILE* fp = fopen(RUN_MARKER_PATH, "w"); + if (!fp) return; + fprintf(fp, "%d %d %d", fatalCount, (int)phase, ownCrashCount); + fclose(fp); + } + + // 지난번 표시가 남아 있으면 true, 그때 적어둔 값들을 out 에 넣는다. + // 필드가 모자란 예전 형식도 읽는다 - 없는 것은 보수적으로 채운다. + static bool read(int* outFatalCount, RunPhase* outPhase, int* outOwnCrashCount) + { + FILE* fp = fopen(RUN_MARKER_PATH, "r"); + if (!fp) return false; + + int fatalCount = 0; + int phase = (int)RunPhase::Boot; + int ownCrashCount = -1; + const int fields = fscanf(fp, "%d %d %d", &fatalCount, &phase, &ownCrashCount); + fclose(fp); + + if (fields < 1) return false; + + *outFatalCount = fatalCount; + *outPhase = (fields >= 2 && phase == (int)RunPhase::Backup) + ? RunPhase::Backup : RunPhase::Boot; + + // 예전 형식에는 이 값이 없다. -1 을 넣어두면 아래에서 "비교할 수 + // 없음" 으로 다뤄져 사고로 오인하지 않는다. + *outOwnCrashCount = (fields >= 3) ? ownCrashCount : -1; + return true; + } +}; + + +// 연속 사고 횟수. 한 바퀴를 무사히 끝내면 0 으로 되돌린다. +struct CrashStrikes +{ + static int read() + { + FILE* fp = fopen(CRASH_STRIKE_PATH, "r"); + if (!fp) return 0; + + int value = 0; + if (fscanf(fp, "%d", &value) != 1) value = 0; + fclose(fp); + + return value < 0 ? 0 : value; + } + + static void write(int value) + { + FILE* fp = fopen(CRASH_STRIKE_PATH, "w"); + if (!fp) return; + fprintf(fp, "%d", value); + fclose(fp); + } + + static void reset() { remove(CRASH_STRIKE_PATH); } +}; + + +// 백업할 계정을 정한다. sysmodule 에는 선택 화면이 없으므로 설정값으로만 +// 결정한다. +// +// allAccounts=1: 콘솔에 등록된 모든 사용자를 백업한다. 서버는 +// /users/<닉네임>/ 으로 사용자를 나누므로 서로 섞이지 않는다. +// allAccounts=0 또는 없음: defaultAccountName 하나만. +bool resolveTargets(Config& config, std::vector& targets) +{ + if ((bool)config["sync"]["allAccounts"]) + { + Account* list = NULL; + size_t count = 0; + + if (probeAccounts(&list, &count) != 0 || list == NULL) + { + writeLog("failed to list accounts"); + return false; + } + + for (size_t i = 0; i < count; ++i) + targets.push_back(list[i]); + + free(list); + return true; + } + + Account account{}; + AccountResolveOptions accountOptions; + accountOptions.defaultAccountName = config["account"]["defaultAccountName"].value; + accountOptions.useProfileSelector = false; + + if (accountOptions.defaultAccountName.empty()) + { + writeLog("defaultAccountName is empty - set it in config.ini"); + return false; + } + + if (getCurrentAccount(&account, accountOptions) != 0) + { + // 닉네임 비교는 대소문자를 구분한다. 오타보다 흔한 원인이라 같이 적어둔다. + writeLog("account not found (case-sensitive): " + accountOptions.defaultAccountName); + return false; + } + + targets.push_back(account); + return true; +} + + +SyncOptions makeOptions(Config& config, const Account& account) +{ + SyncOptions options; + options.uid = account.uid; + options.nickname = account.nickname; + options.saveDataPath = SAVE_DATA_PATH; + options.serverUrl = (std::string)config["remote"]["serverUrl"]; + options.remoteEnabled = true; + options.archiveBy = config["title"]["archiveBy"].value; + options.excludedTitleIds = config["title"]["excludedTitleIds"].value; + options.excludedTitleNames = config["title"]["excludedTitleNames"].value; + // 바뀐 것만 올린다. 매번 전부 올리면 SD 와 서버를 모두 낭비한다. + options.skipUnchanged = true; + return options; +} + + +// 네트워크는 처음 필요할 때 열고, 그 뒤로는 열어둔다. 여닫기를 반복하면 +// 그때마다 시스템 풀에서 버퍼를 다시 잡는다 - 굳이 그럴 이유가 없다. +bool ensureNetwork() +{ + if (!g_socketReady || !g_nifmReady) + { + if (!initNetworkServices()) + { + writeLog("failed to open network services"); + return false; + } + } + + return waitForNetwork(180); +} + + +// 한 바퀴. 올릴 것이 없으면 네트워크도 건드리지 않고 조용히 돌아간다. +void runBackupRound(Config& config, const std::vector& targets) +{ + int pending = 0; + for (const Account& account : targets) + { + const int changed = countChangedTitles(makeOptions(config, account)); + if (changed > 0) pending += changed; + } + + if (pending == 0) return; + + writeLog("titles to upload: " + std::to_string(pending)); + + if (!ensureNetwork()) + { + writeLog("no network - will try again later"); + return; + } + + bool allOk = true; + + for (const Account& account : targets) + { + writeLog(std::string("account: ") + account.nickname); + + const int ret = pushAllSaves(makeOptions(config, account), [](const std::string& line) + { + writeLog(" " + line); + }); + + if (ret != 0) + { + writeLog(" failed, ret=" + std::to_string(ret)); + allOk = false; + } + } + + // 하나라도 실패하면 시각을 남기지 않는다. 다음 바퀴에서 다시 시도한다. + if (allOk) + { + writeLastAutoSyncTime(SAVE_DATA_PATH, time(NULL)); + writeLog("backup finished"); + logHeapUsage("after round"); + } + else + { + writeLog("backup finished with errors - will retry"); + logHeapUsage("after round"); + } +} + +} // namespace + + +int main(int argc, char* argv[]) +{ + if (!waitForSdCard(60)) + return 0; + + writeLog("--- uNSS sysmodule started ---"); + + // 아직 아무것도 열지 않은 시점이다. 여기 값이 콘솔의 평소 상태에 가장 + // 가깝고, 아래의 두 번째 측정과 비교하면 우리가 얼마나 축내는지 나온다. + logResourceLimits("at start"); + + // 지난번에 끝까지 가지 못했다면, 우리 탓인지부터 가린다. + const int fatalNow = countFatalReports(); + const int ownCrashNow = countOwnCrashReports(); + + int fatalBefore = 0; + int ownCrashBefore = -1; + RunPhase lastPhase = RunPhase::Boot; + + // 0 보다 크면 그만큼의 바퀴 동안 백업을 건너뛴다. + int backoffRounds = 0; + + if (RunMarker::read(&fatalBefore, &lastPhase, &ownCrashBefore)) + { + // 우리 이름이 붙은 보고서가 늘었다면 확실히 우리다. + const bool weCrashed = (ownCrashBefore >= 0) && (ownCrashNow > ownCrashBefore); + // 시스템 쪽이 늘었다면 우리일 수도, 남일 수도 있다. + const bool systemCrashed = fatalNow > fatalBefore; + + if (weCrashed || systemCrashed) + { + const int strikes = CrashStrikes::read() + 1; + CrashStrikes::write(strikes); + + writeLog(weCrashed + ? ("previous run crashed - " + std::to_string(ownCrashNow - ownCrashBefore) + + " new crash report(s) with our program id, strike " + + std::to_string(strikes)) + : ("previous run ended in a system crash (" + + std::to_string(fatalNow - fatalBefore) + + " new fatal report(s), strike " + std::to_string(strikes) + + ") - could also have been another process")); + + if (lastPhase == RunPhase::Boot) + { + // 부팅 구간이다. 다시 떠서 또 죽으면 콘솔이 부팅 루프에 + // 빠지고, 그러면 SD 카드를 빼는 것 말고 길이 없다. + writeLog("it happened during startup - standing down so the console can boot"); + writeLog("re-enable from the app once the cause is fixed"); + + disableSelf(); + remove(RUN_MARKER_PATH); + return 0; + } + + // 백업 구간이다. 여기서 갈린다: 우리만 죽었나, 콘솔이 죽었나. + // + // 우리만 죽었다면 콘솔은 멀쩡히 쓸 수 있다. 잠시 쉬었다 스스로 + // 다시 해보면 된다 - 사람 손은 필요 없다. + // + // fatal report 는 다르다. 시스템 프로세스가 죽었다는 뜻이고, + // 그러면 콘솔 전체가 빨간 화면으로 멈춘다. "백업 한 번 실패" + // 와 같은 무게로 다룰 수 없다. 5 분 뒤에 다시 해보다가 또 + // 죽으면 5 분마다 콘솔이 멈추는 물건이 된다 - 실제로 그랬다 + // (2026-08-01, am 이 2001-0132 로 두 번, 273 초 간격). + // + // 그래서 fatal 은 처음부터 최대치로 물러선다. 그래도 또 나면 + // 우연이 아니므로 끈다. 백업이 멈추는 쪽이 콘솔이 멈추는 쪽보다 + // 낫고, 앱에서 한 번 눌러 되살릴 수 있다. + if (systemCrashed && strikes >= 2) + { + writeLog("the console itself went down twice - standing down"); + writeLog("re-enable from the app once the cause is fixed"); + + disableSelf(); + remove(RUN_MARKER_PATH); + return 0; + } + + backoffRounds = systemCrashed + ? CRASH_BACKOFF_MAX_ROUNDS + : crashBackoffRounds(strikes); + + writeLog(std::string("it happened during a backup - skipping the next ") + + std::to_string(backoffRounds) + " round(s) (" + + std::to_string(backoffRounds * (int)(POLL_SECONDS / 60)) + + " min), then trying again" + + (systemCrashed ? " - the whole console went down, so backing off all the way" : "")); + } + else + { + // 표시는 남았지만 우리가 보는 두 디렉터리에는 새 리포트가 없다. + // 대개는 백업 도중에 콘솔을 껐을 뿐이다. + // + // 다만 "새 리포트가 없다" 와 "아무 일도 없었다" 는 다르다. + // fatal 모듈 자신이 죽으면 (fatal_errors, 아래 설명) 두 곳 다 + // 비어 있는 채로 남는다. 그러니 아는 만큼만 적는다. + writeLog("previous run did not finish - no new reports in " + "crash_reports or fatal_reports, continuing"); + } + } + + // 위험한 구간에 들어가기 전에 표시를 남긴다. 한 바퀴를 무사히 넘기면 + // 지운다 - 부팅 직후가 위험한 구간이고, 그 뒤로는 아니다. + RunMarker::write(fatalNow, ownCrashNow, RunPhase::Boot); + + // 아예 꺼져 있으면 계속 살아 있을 이유가 없다. + { + Config config(CONFIG_PATH); + + if (!(bool)config["remote"]["enabled"]) + { + writeLog("remote disabled in config - nothing to do"); + RunMarker::clear(); + return 0; + } + if (!(bool)config["sync"]["autoPushOnLaunch"]) + { + writeLog("autoPushOnLaunch is off - nothing to do"); + RunMarker::clear(); + return 0; + } + + // 기본은 검증 켜짐. 자격증명이 URL 에 들어가는 이상, 검증을 끄면 + // 핸드셰이크에 응답하는 누구나 평문 비밀번호를 받는다. + const bool skipVerify = (bool)config["remote"]["insecureSkipVerify"]; + HTTPClient::setVerifyTls(!skipVerify); + if (skipVerify) + writeLog("WARNING: insecureSkipVerify=1 - the password is exposed to anyone answering the handshake"); + } + + // 여기까지는 파일만 읽었다. 이제부터 서비스를 연다 - 그 전에 시스템이 + // 자리를 잡을 시간을 준다. 백업은 몇 초 늦어도 상관없지만, 시스템 모듈과 + // 리소스를 다투면 콘솔이 부팅 루프에 빠진다. + // 자는 동안에도 5 초마다 값을 남긴다. 우리가 죽이고 있는 것이 무엇이든, + // 그 일은 바로 이 구간에서 벌어진다. + for (u64 elapsed = 0; elapsed < STARTUP_GRACE_SECONDS; elapsed += 5) + { + logPoolsBrief(("+" + std::to_string(elapsed) + "s").c_str()); + svcSleepThread(5 * 1000000000ULL); + } + + if (!initTitleServices()) + { + writeLog("failed to open system services - aborting"); + RunMarker::clear(); + return 0; + } + + recursiveMkdir(SAVE_DATA_PATH); + writeLog("watching for changes"); + + // 서비스를 다 연 뒤. 시작 시점과의 차이가 곧 우리 몫이다. + logResourceLimits("after services"); + + bool markerCleared = false; + bool gameWasRunning = false; + + // 여기서부터는 끝나지 않는다. + // + // 스위치는 끄는 물건이 아니라 덮는 물건이다. 부팅 때 한 번만 도는 설계는 + // 몇 주가 지나도 백업을 하지 못한다. 그래서 계속 살아 있으면서, 게임이 + // 끝난 뒤에 바뀐 세이브가 있는지 들여다본다. + while (true) + { + // 설정은 매 바퀴 새로 읽는다. 그래야 config.ini 를 고쳤을 때 + // 재부팅 없이 반영된다. + Config config(CONFIG_PATH); + + const bool enabled = (bool)config["remote"]["enabled"] + && (bool)config["sync"]["autoPushOnLaunch"]; + + bool gameRunning = false; + + if (enabled) + { + gameRunning = isGameRunning(); + + if (gameRunning) + { + // 게임이 세이브를 붙잡고 있다. 반쯤 쓰인 파일을 올릴 수는 없다. + if (!gameWasRunning) writeLog("game started - holding off"); + gameWasRunning = true; + } + else + { + // 게임을 막 끝냈다면 간격을 기다리지 않는다. 사람이 저장하고 + // 나온 직후가 백업하기 가장 좋은 때다. + const bool justFinished = gameWasRunning; + gameWasRunning = false; + + if (justFinished) writeLog("game ended - checking saves"); + + const int intervalHours = + atoi(config["sync"]["autoPushIntervalHours"].value.c_str()); + + if (backoffRounds > 0) + { + // 지난번에 백업 도중 죽었다. 끄지는 않되, 5 분마다 같은 + // 벽에 부딪히지도 않는다. 세어 내려가다 0 이 되면 다시 한다. + --backoffRounds; + if (backoffRounds == 0) + writeLog("backoff over - trying a backup again"); + } + else if (justFinished || isAutoSyncDue(SAVE_DATA_PATH, intervalHours)) + { + std::vector targets; + if (resolveTargets(config, targets) && !targets.empty()) + { + // 부팅 직후만 위험한 것이 아니다. 백업 한 바퀴가 이 + // 모듈이 하는 일의 전부이고, 죽는다면 십중팔구 그 + // 안에서 죽는다 - 실제로 그랬다 (2168-0002). + // + // 처음에는 첫 바퀴를 넘기면 표시를 지웠다. 그래서 + // 몇 시간 뒤 백업 도중에 죽었을 때 아무도 알아채지 + // 못했고, 다음 부팅에서 같은 자리에서 또 죽었다. + // 이제는 백업할 때마다, 구간까지 적어 남긴다. + RunMarker::write(countFatalReports(), countOwnCrashReports(), RunPhase::Backup); + runBackupRound(config, targets); + RunMarker::clear(); + + // 한 바퀴를 끝까지 돌았다. 지난번 사고가 무엇이었든 + // 이 자리에서 재현되지 않으므로 누적을 지운다. + CrashStrikes::reset(); + } + } + } + } + + // 부팅 표시를 지운다. 부팅 직후 구간은 넘겼다는 뜻이다. 백업 구간은 + // 위에서 따로 표시하고 지우므로, 여기서 지워도 보호는 남는다. + if (!markerCleared) + { + RunMarker::clear(); + markerCleared = true; + } + + // 게임 중에는 조금 더 자주 본다. 끝나는 순간을 놓치지 않으려는 것이고, + // isGameRunning() 자체는 값이 싸다. + const u64 sleepSeconds = gameRunning ? GAME_POLL_SECONDS : POLL_SECONDS; + svcSleepThread(sleepSeconds * 1000000000ULL); + } + + return 0; +} diff --git a/client-sysmodule/source/miniz.c b/client-sysmodule/source/miniz.c new file mode 120000 index 0000000..7a044c0 --- /dev/null +++ b/client-sysmodule/source/miniz.c @@ -0,0 +1 @@ +../../client/source/miniz.c \ No newline at end of file diff --git a/client-sysmodule/source/miniz.h b/client-sysmodule/source/miniz.h new file mode 120000 index 0000000..1039459 --- /dev/null +++ b/client-sysmodule/source/miniz.h @@ -0,0 +1 @@ +../../client/source/miniz.h \ No newline at end of file diff --git a/client-sysmodule/source/remote.cpp b/client-sysmodule/source/remote.cpp new file mode 120000 index 0000000..972d00c --- /dev/null +++ b/client-sysmodule/source/remote.cpp @@ -0,0 +1 @@ +../../client/source/remote.cpp \ No newline at end of file diff --git a/client-sysmodule/source/remote.hpp b/client-sysmodule/source/remote.hpp new file mode 120000 index 0000000..cfda131 --- /dev/null +++ b/client-sysmodule/source/remote.hpp @@ -0,0 +1 @@ +../../client/source/remote.hpp \ No newline at end of file diff --git a/client-sysmodule/source/savedata.cpp b/client-sysmodule/source/savedata.cpp new file mode 120000 index 0000000..8679be1 --- /dev/null +++ b/client-sysmodule/source/savedata.cpp @@ -0,0 +1 @@ +../../client/source/savedata.cpp \ No newline at end of file diff --git a/client-sysmodule/source/savedata.hpp b/client-sysmodule/source/savedata.hpp new file mode 120000 index 0000000..32944f7 --- /dev/null +++ b/client-sysmodule/source/savedata.hpp @@ -0,0 +1 @@ +../../client/source/savedata.hpp \ No newline at end of file diff --git a/client-sysmodule/source/sync.cpp b/client-sysmodule/source/sync.cpp new file mode 120000 index 0000000..978bb44 --- /dev/null +++ b/client-sysmodule/source/sync.cpp @@ -0,0 +1 @@ +../../client/source/sync.cpp \ No newline at end of file diff --git a/client-sysmodule/source/sync.hpp b/client-sysmodule/source/sync.hpp new file mode 120000 index 0000000..119fe14 --- /dev/null +++ b/client-sysmodule/source/sync.hpp @@ -0,0 +1 @@ +../../client/source/sync.hpp \ No newline at end of file diff --git a/client-sysmodule/source/title.cpp b/client-sysmodule/source/title.cpp new file mode 120000 index 0000000..2a3cad2 --- /dev/null +++ b/client-sysmodule/source/title.cpp @@ -0,0 +1 @@ +../../client/source/title.cpp \ No newline at end of file diff --git a/client-sysmodule/source/title.hpp b/client-sysmodule/source/title.hpp new file mode 120000 index 0000000..15cb07b --- /dev/null +++ b/client-sysmodule/source/title.hpp @@ -0,0 +1 @@ +../../client/source/title.hpp \ No newline at end of file diff --git a/client-sysmodule/source/utils.hpp b/client-sysmodule/source/utils.hpp new file mode 120000 index 0000000..1837b01 --- /dev/null +++ b/client-sysmodule/source/utils.hpp @@ -0,0 +1 @@ +../../client/source/utils.hpp \ No newline at end of file diff --git a/client-sysmodule/source/zipio.cpp b/client-sysmodule/source/zipio.cpp new file mode 120000 index 0000000..cb1cba1 --- /dev/null +++ b/client-sysmodule/source/zipio.cpp @@ -0,0 +1 @@ +../../client/source/zipio.cpp \ No newline at end of file diff --git a/client-sysmodule/source/zipio.hpp b/client-sysmodule/source/zipio.hpp new file mode 120000 index 0000000..bcda4ec --- /dev/null +++ b/client-sysmodule/source/zipio.hpp @@ -0,0 +1 @@ +../../client/source/zipio.hpp \ No newline at end of file diff --git a/client/Makefile b/client/Makefile index 6433d93..746ae76 100644 --- a/client/Makefile +++ b/client/Makefile @@ -20,7 +20,7 @@ BUILD := build SOURCES := source source/gui DATA := data INCLUDES := include -#ROMFS := romfs +ROMFS := romfs #--------------------------------------------------------------------------------- # options for code generation @@ -134,15 +134,49 @@ ifneq ($(ROMFS),) export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS) endif -.PHONY: $(BUILD) clean all +#--------------------------------------------------------------------------------- +# The app ships the sysmodule inside its romfs and installs it from there. That +# copy is a build artifact of ../client-sysmodule, not something to edit or +# commit by hand: leave it stale and the app happily installs a sysmodule that +# no longer matches this source tree — which is silent, because both files are +# valid and only their contents differ. +#--------------------------------------------------------------------------------- +SYSMODULE_NSP := $(CURDIR)/../client-sysmodule/client-sysmodule.nsp +ROMFS_NSP := $(CURDIR)/$(ROMFS)/exefs.nsp + +.PHONY: $(BUILD) clean all sysmodule #--------------------------------------------------------------------------------- all: $(BUILD) -$(BUILD): +$(BUILD): sysmodule @[ -d $@ ] || mkdir -p $@ @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile +# Build the sysmodule first, then refresh the embedded copy. +# +# Copying alone is not enough. The NRO rule depends on the elf and the nacp, +# never on what is inside romfs/, so a fresh exefs.nsp relinks nothing and the +# app goes on installing the previous module — exactly the silent staleness this +# exists to stop. Dropping the NRO is what forces the rebuild (build-all.sh does +# the same, for the same reason). +# +# Missing sources are not an error — a release tarball may ship only the .nsp — +# but skipping in silence is: the documented docker build mounts only client/ +# (docker-compose.yaml), so ../client-sysmodule is invisible there and the copy +# would be quietly left at whatever it happened to be. +sysmodule: +ifneq ($(wildcard $(CURDIR)/../client-sysmodule/Makefile),) + @$(MAKE) --no-print-directory -C $(CURDIR)/../client-sysmodule + @mkdir -p $(CURDIR)/$(ROMFS) + @cmp -s $(SYSMODULE_NSP) $(ROMFS_NSP) || \ + (echo "romfs ... exefs.nsp" && cp -f $(SYSMODULE_NSP) $(ROMFS_NSP) \ + && rm -f $(CURDIR)/$(TARGET).nro) +else + @[ -f $(ROMFS_NSP) ] || (echo "error: $(ROMFS)/exefs.nsp is missing and ../client-sysmodule is not visible - build the sysmodule first (see build-all.sh)" >&2; exit 1) + @echo "warning: ../client-sysmodule not visible - keeping the existing $(ROMFS)/exefs.nsp, which may be stale" +endif + #--------------------------------------------------------------------------------- clean: @echo clean ... diff --git a/client/source/fileio.cpp b/client/source/fileio.cpp index 2d36314..888b04f 100644 --- a/client/source/fileio.cpp +++ b/client/source/fileio.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -80,7 +82,14 @@ int recursiveMkdir(const std::string& path, mode_t mode) int createSaveData(const AccountUid accountUid, const u64 titleID) { - NsApplicationControlData controlData = {0x00,}; + // 0x24000 - 144KB 짜리다. 시스템 모듈 스택은 16KB 뿐이라 스택에 두면 + // 넘친다. 던지지 않는 new 를 쓰는 이유도 title.cpp 의 같은 구조체를 참고. + const std::unique_ptr controlDataHolder( + new (std::nothrow) NsApplicationControlData()); + if (!controlDataHolder) return -1; + + NsApplicationControlData& controlData = *controlDataHolder; + size_t actualSize; Result rc = nsInitialize(); @@ -143,6 +152,14 @@ int mountSaveData(const std::string& mountPoint, const AccountUid accountUid, co Result rc = fsdevMountSaveData(mountPoint.c_str(), titleID, accountUid); if (R_FAILED(rc)) { + // "그런 세이브는 없다" 와 "열지 못했다" 는 다른 일이다. 앞의 것은 + // 이 계정이 그 게임을 한 번도 저장하지 않았다는 뜻일 뿐이고, 흔하다. + // 둘을 뭉뚱그리면 정상적인 백업이 매번 오류로 끝난다. + if (R_MODULE(rc) == 2 && R_DESCRIPTION(rc) == 1002) // fs: TargetNotFound + { + return MOUNT_TARGET_NOT_FOUND; + } + return -1; } diff --git a/client/source/fileio.hpp b/client/source/fileio.hpp index 3dd450b..cbee8b4 100644 --- a/client/source/fileio.hpp +++ b/client/source/fileio.hpp @@ -11,7 +11,12 @@ int walk(const std::string& path, std::function +#include + + +namespace gui +{ + +namespace +{ + +// sysmodule 이 쓰는 곳과 같아야 한다 (client-sysmodule/source/main.cpp). +const char* SYSMODULE_LOG_PATH = "sdmc:/uNSS/sysmodule.log"; + +// 로그는 계속 자란다. 전부 들고 있을 이유가 없고, 앱의 힙도 무한하지 않다. +// 뒤쪽만 남긴다 - 알고 싶은 것은 언제나 마지막에 일어난 일이다. +constexpr size_t MAX_LINES = 500; + +// 대략 1 초. 화면은 60fps 로 돈다. +constexpr int RELOAD_INTERVAL_FRAMES = 60; + +} // namespace + + +LogScreen::LogScreen() +{ + reload(); +} + + +void LogScreen::reload() +{ + errorMessage.clear(); + + FILE* fp = fopen(SYSMODULE_LOG_PATH, "r"); + if (!fp) + { + lines.clear(); + lastSize = -1; + errorMessage = "No log yet - the background service has not run."; + return; + } + + // 파일 크기를 먼저 본다. 바뀌지 않았으면 읽을 필요가 없다. + fseek(fp, 0, SEEK_END); + const long size = ftell(fp); + if (size == lastSize) + { + fclose(fp); + return; + } + lastSize = size; + rewind(fp); + + std::vector fresh; + fresh.reserve(MAX_LINES); + + char buffer[512]; + while (fgets(buffer, sizeof(buffer), fp)) + { + size_t len = strlen(buffer); + while (len > 0 && (buffer[len - 1] == '\n' || buffer[len - 1] == '\r')) + buffer[--len] = '\0'; + + fresh.push_back(buffer); + + // 앞에서부터 버린다. 파일을 뒤에서부터 읽는 것보다 단순하고, 이 + // 크기에서는 차이가 느껴지지 않는다. + if (fresh.size() > MAX_LINES) + fresh.erase(fresh.begin()); + } + + fclose(fp); + lines.swap(fresh); + + if (lines.empty()) + errorMessage = "The log is empty."; +} + + +int LogScreen::visibleLineCount(const Renderer& r) const +{ + const int top = 60 + 50 + 15; + const int bottom = r.screenHeight() - 50 - 20; + const int count = (bottom - top) / 26; + return count < 1 ? 1 : count; +} + + +int LogScreen::maxScroll(int visible) const +{ + const int max = (int)lines.size() - visible; + return max < 0 ? 0 : max; +} + + +void LogScreen::update(u64 kDown) +{ + if (kDown & HidNpadButton_B) + { + App::instance().popScreen(); + return; + } + + // 화면이 만들어진 뒤에야 렌더러 크기를 물어볼 수 있으므로 여기서 센다. + const int visible = visibleLineCount(App::instance().getRenderer()); + + if (kDown & HidNpadButton_X) + { + lastSize = -1; // 강제로 다시 읽는다 + reload(); + } + + if (kDown & HidNpadButton_Y) + follow = !follow; + + if (kDown & HidNpadButton_AnyUp) + { + follow = false; + if (--scrollOffset < 0) scrollOffset = 0; + } + if (kDown & HidNpadButton_AnyDown) + { + if (++scrollOffset >= maxScroll(visible)) + { + scrollOffset = maxScroll(visible); + // 바닥에 닿으면 다시 따라간다. 따로 켤 필요가 없다. + follow = true; + } + } + if (kDown & HidNpadButton_L) + { + follow = false; + scrollOffset -= visible; + if (scrollOffset < 0) scrollOffset = 0; + } + if (kDown & HidNpadButton_R) + { + scrollOffset += visible; + if (scrollOffset >= maxScroll(visible)) + { + scrollOffset = maxScroll(visible); + follow = true; + } + } + + if (--framesUntilReload <= 0) + { + framesUntilReload = RELOAD_INTERVAL_FRAMES; + reload(); + } + + if (follow) + scrollOffset = maxScroll(visible); + else if (scrollOffset > maxScroll(visible)) + scrollOffset = maxScroll(visible); +} + + +void LogScreen::render(Renderer& r) +{ + int x = 80; + int y = 60; + + r.drawText("Background service log", x, y, 32, COLOR_ACCENT); + y += 50; + + r.drawRect(x, y, r.screenWidth() - x * 2, 2, COLOR_ACCENT); + y += 15; + + const int fy = r.screenHeight() - 50; + const int logAreaBottom = fy - 20; + const int lineHeight = 26; + const int visible = visibleLineCount(r); + + if (!errorMessage.empty() && lines.empty()) + { + r.drawText(errorMessage, x, y, 18, COLOR_DIM); + } + else + { + for (int i = scrollOffset; i < (int)lines.size() && i < scrollOffset + visible; i++) + { + // 실패한 줄은 눈에 띄어야 한다. 그것 하나 찾자고 여기에 온다. + const bool bad = + lines[i].find("Failed") != std::string::npos || + lines[i].find("WARNING") != std::string::npos || + lines[i].find("no network") != std::string::npos; + + r.drawText(lines[i], x, y, 18, bad ? COLOR_ERROR : COLOR_TEXT); + y += lineHeight; + } + } + + r.drawRect(0, logAreaBottom, r.screenWidth(), r.screenHeight() - logAreaBottom, COLOR_BACKGROUND); + r.drawRect(x, fy - 10, r.screenWidth() - x * 2, 2, {80, 80, 80, 255}); + + const std::string position = + lines.empty() + ? std::string("0 lines") + : std::to_string(scrollOffset + 1) + "-" + + std::to_string(scrollOffset + visible < (int)lines.size() + ? scrollOffset + visible + : (int)lines.size()) + + " / " + std::to_string(lines.size()); + + r.drawText(position + (follow ? " [following]" : "") + + " Up/Down, L/R: Scroll X: Reload Y: Follow B: Back", + x, fy, 18, follow ? COLOR_ACCENT : COLOR_DIM); +} + +} // namespace gui diff --git a/client/source/gui/LogScreen.hpp b/client/source/gui/LogScreen.hpp new file mode 100644 index 0000000..e618fa6 --- /dev/null +++ b/client/source/gui/LogScreen.hpp @@ -0,0 +1,44 @@ +#pragma once +#include "Gui.hpp" + +#include +#include + + +namespace gui +{ + +// 백그라운드 서비스가 무엇을 하고 있는지 보여준다. +// +// sysmodule 은 화면이 없다. 지금까지 그것이 무엇을 했는지 알아내려면 SD 를 +// 뽑거나 FTP 로 로그를 꺼내와야 했고, 실제로 그러느라 고장 하나를 며칠 +// 늦게 찾았다 (2026-07-31, TLS 가 열리지 않던 건). 로그는 이미 파일로 +// 남으므로, 여기서는 그것을 읽어 보여주기만 한다. +class LogScreen : public Screen +{ +public: + LogScreen(); + + void update(u64 kDown) override; + void render(Renderer& r) override; + +private: + std::vector lines; + int scrollOffset = 0; + + // 파일이 자라면 따라 내려간다. 사용자가 위로 올리면 놓아준다 - 읽는 + // 도중에 화면이 제멋대로 뛰면 읽을 수가 없다. + bool follow = true; + + // 새로 고칠지 판단하는 기준. 내용을 매번 다시 읽는 것보다 싸다. + long lastSize = -1; + int framesUntilReload = 0; + + std::string errorMessage; + + void reload(); + int visibleLineCount(const Renderer& r) const; + int maxScroll(int visible) const; +}; + +} // namespace gui diff --git a/client/source/gui/MainScreen.cpp b/client/source/gui/MainScreen.cpp index 71f95f5..5db2a7c 100644 --- a/client/source/gui/MainScreen.cpp +++ b/client/source/gui/MainScreen.cpp @@ -1,6 +1,7 @@ #include "MainScreen.hpp" #include "ProgressScreen.hpp" #include "AccountScreen.hpp" +#include "LogScreen.hpp" #include "../title.hpp" #include "../savedata.hpp" @@ -87,10 +88,80 @@ void MainScreen::rebuildMenu() menuItems.push_back({"Push to Server", [this]() { startPush(); }, remoteEnabled}); menuItems.push_back({"Pull from Server", [this]() { startPull(); }, true}); + + // 첫 설치만 사용자가 직접 고르게 한다. 부팅 때 도는 프로세스가 + // 생기는 일이라 몰래 해서는 안 된다. + const sysmodule::State state = sysmodule::getState(); + + if (state == sysmodule::State::NotInstalled) + menuItems.push_back({"Install background service", [this]() { installSysmodule(); }, true}); + else if (state == sysmodule::State::Interrupted) + { + // 지난 실행이 끝까지 가지 못해 스스로 꺼져 있다. 모듈이 죽었을 + // 수도, 백업 도중에 콘솔을 껐을 수도 있다. 알아서 되살리지 + // 않는다 - 전자라면 켜는 순간 같은 일이 반복된다. + menuItems.push_back({"Re-enable background service", [this]() { resumeSysmodule(); }, true}); + menuItems.push_back({"Remove background service", [this]() { uninstallSysmodule(); }, true}); + } + else + menuItems.push_back({"Remove background service", [this]() { uninstallSysmodule(); }, true}); + + // 설치되지 않았다면 보여줄 로그도 없다. + if (state != sysmodule::State::NotInstalled) + menuItems.push_back({"Show service log", []() + { + App::instance().pushScreen(new LogScreen()); + }, true}); } } +// 이미 설치돼 있는데 NRO 쪽이 더 새것이면 조용히 갱신한다. +// 쓰겠다는 결정은 이미 내려진 상태이고, 앱과 모듈이 어긋나면 곤란하다. +void MainScreen::updateSysmoduleIfOutdated() +{ + if (sysmodule::getState() != sysmodule::State::Outdated) return; + + if (sysmodule::install() == 0) + statusMessage = "Background service updated. Reboot to apply."; + else + statusMessage = "Failed to update background service."; +} + + +void MainScreen::installSysmodule() +{ + if (sysmodule::install() == 0) + statusMessage = "Installed. Reboot to activate."; + else + statusMessage = "Install failed. Is the SD card writable?"; + + rebuildMenu(); +} + + +void MainScreen::uninstallSysmodule() +{ + if (sysmodule::uninstall() == 0) + statusMessage = "Removed. Reboot to take effect."; + else + statusMessage = "Remove failed."; + + rebuildMenu(); +} + + +void MainScreen::resumeSysmodule() +{ + if (sysmodule::resume() == 0) + statusMessage = "Re-enabled. Reboot to activate."; + else + statusMessage = "Could not re-enable. Is the SD card writable?"; + + rebuildMenu(); +} + + void MainScreen::update(u64 kDown) { // 첫 프레임: 계정 해석 @@ -103,6 +174,16 @@ void MainScreen::update(u64 kDown) if (!accountResolved) return; + // 계정이 정해진 뒤 한 번만. 계정을 바꿨다고 다시 돌지 않는다. + if (!autoPushChecked) + { + autoPushChecked = true; + // 갱신이 먼저다. 아래에서 화면을 밀어버리면 돌아오지 않는다. + updateSysmoduleIfOutdated(); + startAutoPushIfDue(); + return; + } + if (kDown & HidNpadButton_AnyUp) { selectedIndex--; @@ -229,138 +310,88 @@ void MainScreen::render(Renderer& r) y += btnH + 10; } + if (!statusMessage.empty()) + { + y += 10; + r.drawText(statusMessage, x, y, 18, COLOR_ACCENT); + } + int fy = r.screenHeight() - 50; r.drawRect(x, fy - 10, r.screenWidth() - x * 2, 2, {80, 80, 80, 255}); r.drawText("A: Select -: Account +: Exit", x, fy, 18, COLOR_DIM); } +SyncOptions MainScreen::buildSyncOptions() const +{ + SyncOptions options; + options.uid = account.uid; + options.nickname = account.nickname; + options.saveDataPath = "sdmc:/uNSS/saves"; + options.serverUrl = (std::string)config["remote"]["serverUrl"]; + options.remoteEnabled = (bool)config["remote"]["enabled"]; + options.archiveBy = config["title"]["archiveBy"].value; + options.restoreBy = config["title"]["restoreBy"].value; + options.excludedTitleIds = config["title"]["excludedTitleIds"].value; + options.excludedTitleNames = config["title"]["excludedTitleNames"].value; + return options; +} + + void MainScreen::startPush() { - const std::string saveDataPath = "sdmc:/uNSS/saves"; - const std::string serverUrl = (std::string)config["remote"]["serverUrl"]; - const std::string archiveBy = config["title"]["archiveBy"].value; - const std::string excludedIds = config["title"]["excludedTitleIds"].value; - const std::string excludedNames = config["title"]["excludedTitleNames"].value; - const std::string nickname = account.nickname; - const AccountUid uid = account.uid; + const SyncOptions options = buildSyncOptions(); auto work = [=](std::function log) -> int { - HTTPRemoteStore remoteStore(serverUrl, saveDataPath); - recursiveMkdir(saveDataPath.c_str()); - - const ProbeTitlesFunc probeFunc = [&](const AccountUid probeUid, std::vector& titleIDs) -> int - { - int ret = archiveBy == "all" - ? probeAllTitles(probeUid, titleIDs) - : probeSaveDataCreatedTitles(probeUid, titleIDs); - if (ret == 0) - { - filterExcludedTitles(titleIDs, excludedIds, excludedNames); - } - return ret; - }; - - return archiveAllSaveData( - uid, - saveDataPath, - probeFunc, - [&log](int total, int current, u64 titleID) -> bool - { - std::string titleName; - if (getTitleName(titleID, titleName) != 0) - titleName = "Unknown"; - log("[" + padding(current, 3) + "/" + padding(total, 3) + "] " + titleName); - return true; - }, - [&log, &remoteStore, &nickname](int total, int current, int ret, u64 titleID) -> bool - { - if (ret != SAVEDATA_OK) - log("Failed to archive, ret=" + std::to_string(ret)); - else - { - int pushRet = remoteStore.push(nickname, titleID); - if (pushRet != 0) - log("Failed to push, ret=" + std::to_string(pushRet)); - } - return true; - } - ); + return pushAllSaves(options, log); }; App::instance().pushScreen(new ProgressScreen("Push to Server", std::move(work))); } -void MainScreen::startPull() +// 계정이 정해진 직후 호출된다. 자동 백업이 켜져 있고 마지막 실행에서 +// autoPushIntervalHours 가 지났으면 메뉴를 거치지 않고 바로 업로드한다. +void MainScreen::startAutoPushIfDue() { - const std::string saveDataPath = "sdmc:/uNSS/saves"; - const std::string serverUrl = (std::string)config["remote"]["serverUrl"]; - const std::string restoreBy = config["title"]["restoreBy"].value; - const std::string excludedIds = config["title"]["excludedTitleIds"].value; - const std::string excludedNames = config["title"]["excludedTitleNames"].value; - const std::string nickname = account.nickname; - const AccountUid uid = account.uid; - const bool remoteEnabled = (bool)config["remote"]["enabled"]; + if (!(bool)config["sync"]["autoPushOnLaunch"]) return; + if (!(bool)config["remote"]["enabled"]) return; - auto work = [=](std::function log) -> int + // 게임이 돌고 있으면 세이브가 열려 있을 수 있다. 그 상태로 뜬 백업은 + // 반쯤 쓰인 파일을 담을 수 있으므로 자동 백업은 미룬다. + // 수동 "Push to Server" 는 사용자가 알고 누르는 것이라 막지 않는다. + if (isGameRunning()) { - HTTPRemoteStore remoteStore(serverUrl, saveDataPath); - recursiveMkdir(saveDataPath.c_str()); + statusMessage = "Game is running - automatic backup postponed."; + return; + } - if (!remoteEnabled) - { - log("Remote is disabled, restoring from local..."); - return restoreAllSaveData( - uid, saveDataPath, - [&log](int total, int current, u64 titleID) -> bool - { - std::string titleName; - if (getTitleName(titleID, titleName) != 0) - titleName = "Unknown"; - log("[" + padding(current, 3) + "/" + padding(total, 3) + "] " + titleName); - return true; - }, - [&log](int total, int current, int ret, u64 titleID) -> bool - { - if (ret != SAVEDATA_OK) - log("Failed to restore, ret=" + std::to_string(ret)); - return true; - } - ); - } + const SyncOptions options = buildSyncOptions(); + const int intervalHours = atoi(config["sync"]["autoPushIntervalHours"].value.c_str()); - std::vector titleIDs; - int probeRet = restoreBy == "all" - ? probeAllTitles(uid, titleIDs) - : probeSaveDataCreatedTitles(uid, titleIDs); - if (probeRet == 0) - filterExcludedTitles(titleIDs, excludedIds, excludedNames); - if (probeRet != 0) - { - log("Failed to probe titles"); - return probeRet; - } + if (!isAutoSyncDue(options.saveDataPath, intervalHours)) return; - for (size_t i = 0; i < titleIDs.size(); ++i) - { - std::string titleName; - if (getTitleName(titleIDs[i], titleName) != 0) - titleName = "Unknown"; - log("[" + padding(i + 1, 3) + "/" + padding(titleIDs.size(), 3) + "] " + titleName); + auto work = [=](std::function log) -> int + { + int ret = pushAllSaves(options, log); + // 실패했다면 시각을 남기지 않는다. 다음 실행에서 다시 시도한다. + if (ret == 0) + writeLastAutoSyncTime(options.saveDataPath, time(NULL)); + return ret; + }; + + App::instance().pushScreen(new ProgressScreen("Auto Backup", std::move(work))); +} - if (remoteStore.pull(nickname, titleIDs[i]) != 0) - { - log("Failed to pull from server"); - } - else - { - restoreSaveData(uid, titleIDs[i], saveDataPath); - } - } - return 0; +void MainScreen::startPull() +{ + const SyncOptions options = buildSyncOptions(); + + auto work = [=](std::function log) -> int + { + return pullAllSaves(options, log); }; App::instance().pushScreen(new ProgressScreen("Pull from Server", std::move(work))); diff --git a/client/source/gui/MainScreen.hpp b/client/source/gui/MainScreen.hpp index f8ad3d8..38f4045 100644 --- a/client/source/gui/MainScreen.hpp +++ b/client/source/gui/MainScreen.hpp @@ -2,6 +2,8 @@ #include "Gui.hpp" #include "../account.hpp" #include "../ini.hpp" +#include "../sync.hpp" +#include "../sysmodule.hpp" #include @@ -23,6 +25,7 @@ class MainScreen : public Screen Account account{}; bool accountResolved = false; bool initialSelectDone = false; + bool autoPushChecked = false; std::vector menuItems; int selectedIndex = 0; @@ -33,6 +36,16 @@ class MainScreen : public Screen void startPull(); void switchAccount(); void rebuildMenu(); + + SyncOptions buildSyncOptions() const; + void startAutoPushIfDue(); + + std::string statusMessage; + + void updateSysmoduleIfOutdated(); + void installSysmodule(); + void uninstallSysmodule(); + void resumeSysmodule(); }; } // namespace gui diff --git a/client/source/http.cpp b/client/source/http.cpp index 1987bc9..8a3125b 100644 --- a/client/source/http.cpp +++ b/client/source/http.cpp @@ -1,5 +1,36 @@ #include "http.hpp" + #include +#include + + +namespace +{ + +bool g_verifyTls = true; + +// 있으면 쓰고, 없으면 콘솔에 내장된 목록만 쓴다. +// +// 이 curl 은 libnx SSL 백엔드라 CAINFO 를 sslContextImportServerPki 로 넘긴다. +// 즉 콘솔 목록에 없는 루트도 이 파일 하나로 신뢰시킬 수 있다. 보통은 필요 +// 없다 - Let's Encrypt 는 ISRG Root X1 로 교차서명되고 그것은 10.1.0 부터 +// 콘솔에 들어 있다 (SslCaCertificateId_ISRGRootX10). 사설 CA 를 쓰거나 훗날 +// 체인이 바뀌었을 때를 위한 길이다. +const char* CA_BUNDLE_PATH = "sdmc:/uNSS/cacert.pem"; + +bool fileExists(const char* path) +{ + struct stat st; + return stat(path, &st) == 0 && S_ISREG(st.st_mode); +} + +} // namespace + + +void HTTPClient::setVerifyTls(bool enabled) +{ + g_verifyTls = enabled; +} HTTPClient::HTTPClient() @@ -110,8 +141,17 @@ int HTTPClient::perform() curl_easy_setopt(curl, CURLOPT_READFUNCTION, readCallback); curl_easy_setopt(curl, CURLOPT_READDATA, this); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + // 자격증명은 URL 에 들어 있고 libcurl 이 그것을 Authorization 헤더로 + // 먼저 보낸다. 검증을 끄면 핸드셰이크에 응답하는 누구에게나 평문 비밀번호를 + // 건네주는 셈이다 - bcrypt 는 서버에 저장된 해시를 지킬 뿐, 이 구간은 + // 지키지 못한다. 그래서 기본은 켜짐이다. + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, g_verifyTls ? 1L : 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, g_verifyTls ? 2L : 0L); + + if (g_verifyTls && fileExists(CA_BUNDLE_PATH)) + { + curl_easy_setopt(curl, CURLOPT_CAINFO, CA_BUNDLE_PATH); + } // HTTP 메서드 설정 if (method == "GET") diff --git a/client/source/http.hpp b/client/source/http.hpp index a79411d..ebeb380 100644 --- a/client/source/http.hpp +++ b/client/source/http.hpp @@ -39,6 +39,20 @@ class HTTPClient HTTPClient(); ~HTTPClient(); +public: + // 인증서 검증을 켜고 끈다. 프로세스 전체에 적용되며 기본값은 켜짐. + // + // 자격증명은 URL 에 들어 있고 libcurl 이 그것을 Authorization 헤더로 먼저 + // 보낸다. 검증이 꺼져 있으면 핸드셰이크에 응답하는 누구나 평문 비밀번호를 + // 받는다 - bcrypt 는 서버에 저장된 해시를 지킬 뿐 이 구간과는 무관하다. + // + // 끄는 길을 남겨둔 이유는 하나다: 이 curl 은 libnx SSL 백엔드라 콘솔에 + // 내장된 인증서 목록으로 검증하고, 그 목록은 펌웨어와 함께만 갱신된다. + // 사설 CA 나 콘솔이 모르는 루트를 쓰면 서버가 멀쩡해도 거절당하고, 그 + // 실패는 "연결 안 됨" 처럼 보인다. 그때는 sdmc:/uNSS/cacert.pem 에 루트를 + // 두는 것이 먼저고, 이 스위치는 마지막 수단이다. + static void setVerifyTls(bool enabled); + public: HTTPClient& setUrl(const std::string& url); HTTPClient& setMethod(const std::string& method); diff --git a/client/source/main.cpp b/client/source/main.cpp index 5fe5648..2c310ba 100644 --- a/client/source/main.cpp +++ b/client/source/main.cpp @@ -10,6 +10,7 @@ #include #include "account.hpp" +#include "http.hpp" #include "ini.hpp" #include "fileio.hpp" #include "utils.hpp" @@ -27,12 +28,20 @@ void initConfig() { gl_Config["remote"]["enabled"].has(false); gl_Config["remote"]["serverUrl"].has("http://0.0.0.0:8989"); + // 자격증명은 URL 에 들어가고 libcurl 이 그것을 Authorization 헤더로 먼저 + // 보낸다. 검증을 끄면 핸드셰이크에 응답하는 누구나 평문 비밀번호를 받는다. + // 콘솔이 모르는 루트를 쓴다면 먼저 sdmc:/uNSS/cacert.pem 을 놓아볼 것. + gl_Config["remote"]["insecureSkipVerify"].has(false); gl_Config["account"]["defaultAccountName"].has(""); gl_Config["account"]["useProfileSelector"].has(true); gl_Config["title"]["archiveBy"].has("created"); gl_Config["title"]["restoreBy"].has("all"); gl_Config["title"]["excludedTitleIds"].has(""); gl_Config["title"]["excludedTitleNames"].has(""); + gl_Config["sync"]["autoPushOnLaunch"].has(false); + gl_Config["sync"]["autoPushIntervalHours"].has(24); + // 백업은 콘솔 전체가 대상인 편이 자연스럽다. sysmodule 이 이 값을 읽는다. + gl_Config["sync"]["allAccounts"].has(true); } @@ -50,6 +59,8 @@ int main(int argc, char** argv) initData(); initConfig(); + HTTPClient::setVerifyTls(!(bool)gl_Config["remote"]["insecureSkipVerify"]); + auto* mainScreen = new gui::MainScreen(gl_Config); gui::App::instance().run(mainScreen); diff --git a/client/source/remote.cpp b/client/source/remote.cpp index fea40f0..ead372f 100644 --- a/client/source/remote.cpp +++ b/client/source/remote.cpp @@ -37,6 +37,7 @@ int HTTPRemoteStore::_issueSaveDataRevisionId(const std::string userName, const }); int ret = client.perform(); + lastHttpResult = ret; if (ret < 0) { @@ -87,6 +88,7 @@ int HTTPRemoteStore::_uploadSaveDataRevision(const std::string userName, const u }); int ret = client.perform(); + lastHttpResult = ret; if (ret < 0) { @@ -119,6 +121,7 @@ int HTTPRemoteStore::_getLatestSaveDataRevision(const std::string userName, cons }); int ret = client.perform(); + lastHttpResult = ret; if (ret < 0) { @@ -168,6 +171,7 @@ int HTTPRemoteStore::_downloadSaveDataRevision(const std::string userName, const }); int ret = client.perform(); + lastHttpResult = ret; if (ret < 0) { diff --git a/client/source/remote.hpp b/client/source/remote.hpp index 381fe40..4e2ca24 100644 --- a/client/source/remote.hpp +++ b/client/source/remote.hpp @@ -31,10 +31,21 @@ class HTTPRemoteStore : public IRemoteStoreIO std::string serverUrl; std::string saveDataPath; + // 마지막으로 실패한 HTTP 호출이 돌려준 값 그대로. push / pull 은 실패를 + // 전부 -1 로 뭉개기 때문에, 그것만 보면 "서버가 400 을 줬다" 와 + // "연결조차 못 했다" 를 구별할 수 없다. 실제로 그 구별이 없어서 TLS 가 + // 열리지 않는 것을 한참 못 찾았다 (2026-07-31). 반환값 규약은 그대로 + // 두고, 원인만 여기에 남긴다. + int lastHttpResult = 0; + public: HTTPRemoteStore(const std::string& serverUrl, const std::string& saveDataPath); ~HTTPRemoteStore(); +public: + // 음수면 HTTPCLIENT_ERROR_* (연결 자체가 안 된 것), 양수면 HTTP 상태 코드. + int getLastHttpResult() const { return lastHttpResult; } + private: int _issueSaveDataRevisionId(const std::string userName, const u64 titleId, std::string& revisionId); int _uploadSaveDataRevision(const std::string userName, const u64 titleId, const std::string& revisionId); diff --git a/client/source/savedata.cpp b/client/source/savedata.cpp index 06a66c8..6af1729 100644 --- a/client/source/savedata.cpp +++ b/client/source/savedata.cpp @@ -24,7 +24,13 @@ int archiveSaveData(AccountUid uid, const u64 titleID, const std::string& output ); - if (mountSaveData("save", uid, titleID) != 0) + const int mountRet = mountSaveData("save", uid, titleID); + if (mountRet == MOUNT_TARGET_NOT_FOUND) + { + // 이 계정은 그 게임을 저장한 적이 없다. 올릴 것이 없을 뿐이다. + return SAVEDATA_NO_SAVE_DATA; + } + if (mountRet != 0) { return SAVEDATA_FAILED_TO_MOUNT; } diff --git a/client/source/savedata.hpp b/client/source/savedata.hpp index 2da3be0..71c2112 100644 --- a/client/source/savedata.hpp +++ b/client/source/savedata.hpp @@ -13,6 +13,12 @@ #define SAVEDATA_FAILED_TO_EXTRACT_FILE -4 #define SAVEDATA_FAILED_TO_PROBE_TITLES -5 +// 이 계정에 그 타이틀의 세이브가 없다. 고장이 아니라 흔한 일이다 - +// archiveBy=all 은 설치된 게임을 전부 훑으므로, 한 번도 켜보지 않은 게임이 +// 계정마다 수두룩하다. 실패로 세면 백업 한 바퀴가 늘 "오류로 끝남" 이 되고, +// 그러면 마지막 성공 시각이 기록되지 않아 다음 바퀴가 또 전부를 다시 한다. +#define SAVEDATA_NO_SAVE_DATA -6 + typedef std::function&)> ProbeTitlesFunc; diff --git a/client/source/sync.cpp b/client/source/sync.cpp new file mode 100644 index 0000000..900e5a7 --- /dev/null +++ b/client/source/sync.cpp @@ -0,0 +1,387 @@ +#include "sync.hpp" + +#include +#include +#include + +#include + +#include "fileio.hpp" +#include "remote.hpp" +#include "savedata.hpp" +#include "title.hpp" +#include "utils.hpp" + + +namespace +{ + +std::string lastSyncPath(const std::string& saveDataPath) +{ + return saveDataPath + "/.lastautosync"; +} + + +std::string titleNameOrUnknown(u64 titleID) +{ + std::string titleName; + if (getTitleName(titleID, titleName) != 0) + titleName = "Unknown"; + return titleName; +} + + +// 어떤 타이틀이 마지막으로 어떤 상태였는지 적어두는 파일. +// 한 줄에 "타이틀ID 시각" 형식. +std::string syncStatePath(const std::string& saveDataPath) +{ + return saveDataPath + "/.syncstate"; +} + + +std::string toHexId(u64 titleID) +{ + char buffer[17]; + snprintf(buffer, sizeof(buffer), "%016lX", titleID); + return std::string(buffer); +} + + +// 세이브 안에서 가장 최근 수정 시각을 찾는다. +// 마운트에 실패하면 0 을 돌려주고, 그 경우 호출한 쪽은 "바뀌었다" 로 본다. +u64 latestSaveDataTimestamp(const AccountUid uid, u64 titleID) +{ + const std::string mountPoint = "unsschk"; + + if (mountSaveData(mountPoint, uid, titleID) != 0) + return 0; + + u64 latest = 0; + walk(mountPoint + ":/", [&latest](const std::string& path, bool isDir) + { + if (isDir) return; + + struct stat st; + if (stat(path.c_str(), &st) == 0) + { + const u64 mtime = (u64)st.st_mtime; + if (mtime > latest) latest = mtime; + } + }); + + unmount(mountPoint); + return latest; +} + + +u64 readSyncedTimestamp(const std::string& saveDataPath, u64 titleID) +{ + FILE* fp = fopen(syncStatePath(saveDataPath).c_str(), "r"); + if (!fp) return 0; + + const std::string wanted = toHexId(titleID); + char idBuffer[32]; + unsigned long long stamp = 0; + u64 found = 0; + + while (fscanf(fp, "%31s %llu", idBuffer, &stamp) == 2) + { + if (wanted == idBuffer) + { + found = (u64)stamp; + break; + } + } + + fclose(fp); + return found; +} + + +void writeSyncedTimestamp(const std::string& saveDataPath, u64 titleID, u64 stamp) +{ + const std::string path = syncStatePath(saveDataPath); + const std::string wanted = toHexId(titleID); + + // 통째로 읽어서 해당 줄만 갈아끼운다. 항목이 수십 개라 이 정도면 충분하다. + std::string rebuilt; + FILE* fp = fopen(path.c_str(), "r"); + if (fp) + { + char idBuffer[32]; + unsigned long long existing = 0; + while (fscanf(fp, "%31s %llu", idBuffer, &existing) == 2) + { + if (wanted == idBuffer) continue; + rebuilt += std::string(idBuffer) + " " + std::to_string(existing) + "\n"; + } + fclose(fp); + } + + rebuilt += wanted + " " + std::to_string(stamp) + "\n"; + + FILE* out = fopen(path.c_str(), "w"); + if (!out) return; + fwrite(rebuilt.data(), 1, rebuilt.size(), out); + fclose(out); +} + +} // namespace + + +bool isGameRunning() +{ + if (R_FAILED(pmdmntInitialize())) + return false; + + u64 pid = 0; + const Result rc = pmdmntGetApplicationProcessId(&pid); + pmdmntExit(); + + // 실행 중인 애플리케이션이 없으면 실패를 돌려준다. + return R_SUCCEEDED(rc) && pid != 0; +} + + +bool hasSaveDataChanged(const SyncOptions& options, u64 titleID) +{ + const u64 current = latestSaveDataTimestamp(options.uid, titleID); + + // 시각을 못 읽었으면 판단할 근거가 없다. 안전한 쪽으로 (업로드). + if (current == 0) return true; + + return current != readSyncedTimestamp(options.saveDataPath, titleID); +} + + +void markSaveDataSynced(const SyncOptions& options, u64 titleID) +{ + const u64 current = latestSaveDataTimestamp(options.uid, titleID); + if (current == 0) return; + + writeSyncedTimestamp(options.saveDataPath, titleID, current); +} + + +namespace +{ + +// 백업 대상 타이틀 목록. pushAllSaves 와 countChangedTitles 가 같은 기준을 +// 써야 "바뀐 게 없다" 와 "올릴 게 없다" 가 어긋나지 않는다. +int collectTargetTitles(const SyncOptions& options, AccountUid uid, std::vector& titleIDs) +{ + const int ret = options.archiveBy == "all" + ? probeAllTitles(uid, titleIDs) + : probeSaveDataCreatedTitles(uid, titleIDs); + if (ret != 0) return ret; + + filterExcludedTitles(titleIDs, options.excludedTitleIds, options.excludedTitleNames); + return 0; +} + +} // namespace + + +int countChangedTitles(const SyncOptions& options) +{ + std::vector titleIDs; + if (collectTargetTitles(options, options.uid, titleIDs) != 0) return -1; + + if (!options.skipUnchanged) return (int)titleIDs.size(); + + int changed = 0; + for (const u64 titleID : titleIDs) + { + if (hasSaveDataChanged(options, titleID)) + ++changed; + } + + return changed; +} + + +int pushAllSaves(const SyncOptions& options, SyncLogFunc log) +{ + HTTPRemoteStore remoteStore(options.serverUrl, options.saveDataPath); + recursiveMkdir(options.saveDataPath.c_str()); + + const ProbeTitlesFunc probeFunc = [&](const AccountUid probeUid, std::vector& titleIDs) -> int + { + const int ret = collectTargetTitles(options, probeUid, titleIDs); + if (ret != 0) return ret; + + // 안 바뀐 타이틀은 압축조차 하지 않는다. 여기서 걸러야 의미가 있다. + if (options.skipUnchanged) + { + std::vector changed; + changed.reserve(titleIDs.size()); + + for (const u64 titleID : titleIDs) + { + if (hasSaveDataChanged(options, titleID)) + changed.push_back(titleID); + } + + const size_t skipped = titleIDs.size() - changed.size(); + if (skipped > 0) + log("Skipping " + std::to_string(skipped) + " unchanged title(s)"); + + titleIDs.swap(changed); + } + + return 0; + }; + + // 개별 타이틀의 실패는 콜백 안에서만 보인다. archiveAllSaveData 는 목록을 + // 훑는 데 성공하면 OK 를 주기 때문에, 세어두지 않으면 서버가 아예 죽어 + // 있어도 이 함수는 0 을 돌려준다. 그러면 호출하는 쪽이 백업을 마쳤다고 + // 믿고 마지막 시각을 남기고, 24 시간 동안 다시 시도하지 않는다. + int failures = 0; + + const int ret = archiveAllSaveData( + options.uid, + options.saveDataPath, + probeFunc, + [&log](int total, int current, u64 titleID) -> bool + { + log("[" + padding(current, 3) + "/" + padding(total, 3) + "] " + titleNameOrUnknown(titleID)); + return true; + }, + [&](int total, int current, int ret, u64 titleID) -> bool + { + if (ret == SAVEDATA_NO_SAVE_DATA) + { + // 이 계정은 그 게임을 저장한 적이 없다. 실패가 아니므로 세지 + // 않는다 - 세면 한 바퀴가 늘 "오류로 끝남" 이 되고, 그러면 + // 마지막 성공 시각이 남지 않아 다음 바퀴가 전부를 다시 한다. + log("No save data for this account - skipped"); + } + else if (ret != SAVEDATA_OK) + { + log("Failed to archive, ret=" + std::to_string(ret)); + ++failures; + } + else + { + int pushRet = remoteStore.push(options.nickname, titleID); + if (pushRet != 0) + { + // ret 은 늘 -1 이라 아무것도 말해주지 않는다. 뒤의 값이 + // 진짜 원인이다: 음수면 연결 자체가 안 된 것 + // (HTTPCLIENT_ERROR_*, 예: -5 = TLS), 양수면 서버가 + // 돌려준 상태 코드다. + log("Failed to push, ret=" + std::to_string(pushRet) + + " http=" + std::to_string(remoteStore.getLastHttpResult())); + ++failures; + } + else if (options.skipUnchanged) + { + // 성공한 것만 기록한다. 실패한 타이틀은 다음에 다시 올라간다. + markSaveDataSynced(options, titleID); + } + } + return true; + } + ); + + if (ret != 0) return ret; + + // 실패한 타이틀 수를 음수로 돌려준다. SAVEDATA_* 코드는 양수라 서로 + // 헷갈리지 않는다. 0 은 "하나도 빠짐없이 올라갔다" 는 뜻이고, + // 마지막 백업 시각은 그때만 남겨야 한다. + return failures > 0 ? -failures : 0; +} + + +int pullAllSaves(const SyncOptions& options, SyncLogFunc log) +{ + HTTPRemoteStore remoteStore(options.serverUrl, options.saveDataPath); + recursiveMkdir(options.saveDataPath.c_str()); + + if (!options.remoteEnabled) + { + log("Remote is disabled, restoring from local..."); + return restoreAllSaveData( + options.uid, options.saveDataPath, + [&log](int total, int current, u64 titleID) -> bool + { + log("[" + padding(current, 3) + "/" + padding(total, 3) + "] " + titleNameOrUnknown(titleID)); + return true; + }, + [&log](int total, int current, int ret, u64 titleID) -> bool + { + if (ret != SAVEDATA_OK) + log("Failed to restore, ret=" + std::to_string(ret)); + return true; + } + ); + } + + std::vector titleIDs; + int probeRet = options.restoreBy == "all" + ? probeAllTitles(options.uid, titleIDs) + : probeSaveDataCreatedTitles(options.uid, titleIDs); + if (probeRet != 0) + { + log("Failed to probe titles"); + return probeRet; + } + filterExcludedTitles(titleIDs, options.excludedTitleIds, options.excludedTitleNames); + + for (size_t i = 0; i < titleIDs.size(); ++i) + { + log("[" + padding(i + 1, 3) + "/" + padding(titleIDs.size(), 3) + "] " + titleNameOrUnknown(titleIDs[i])); + + if (remoteStore.pull(options.nickname, titleIDs[i]) != 0) + { + log("Failed to pull from server"); + } + else + { + restoreSaveData(options.uid, titleIDs[i], options.saveDataPath); + } + } + + return 0; +} + + +time_t readLastAutoSyncTime(const std::string& saveDataPath) +{ + FILE* fp = fopen(lastSyncPath(saveDataPath).c_str(), "r"); + if (!fp) return 0; + + long long value = 0; + if (fscanf(fp, "%lld", &value) != 1) + value = 0; + fclose(fp); + + return (time_t)value; +} + + +void writeLastAutoSyncTime(const std::string& saveDataPath, time_t when) +{ + recursiveMkdir(saveDataPath.c_str()); + + FILE* fp = fopen(lastSyncPath(saveDataPath).c_str(), "w"); + if (!fp) return; + + fprintf(fp, "%lld", (long long)when); + fclose(fp); +} + + +bool isAutoSyncDue(const std::string& saveDataPath, int intervalHours) +{ + if (intervalHours <= 0) return true; + + const time_t last = readLastAutoSyncTime(saveDataPath); + if (last == 0) return true; + + const time_t now = time(NULL); + // 시스템 시계가 뒤로 간 경우 (RTC 재설정 등) 그냥 실행한다. + if (now < last) return true; + + return (now - last) >= (time_t)intervalHours * 3600; +} diff --git a/client/source/sync.hpp b/client/source/sync.hpp new file mode 100644 index 0000000..c31f5cd --- /dev/null +++ b/client/source/sync.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +#include + + +// 동기화 진행 상황을 알리는 콜백. GUI 든 콘솔이든 동일하게 사용한다. +using SyncLogFunc = std::function; + + +struct SyncOptions +{ + AccountUid uid = {}; + std::string nickname; + std::string saveDataPath; + std::string serverUrl; + bool remoteEnabled = false; + + // "created" 또는 "all" + std::string archiveBy = "created"; + std::string restoreBy = "all"; + + std::string excludedTitleIds; + std::string excludedTitleNames; + + // 마지막 업로드 이후 바뀌지 않은 타이틀은 건너뛴다. + bool skipUnchanged = true; +}; + + +// 게임이 실행 중인가. 실행 중이면 세이브가 열려 있을 수 있어 +// 그 상태의 백업은 일관성을 보장하지 못한다. +bool isGameRunning(); + +// 세이브가 마지막 동기화 이후 바뀌었는지. 판단 근거는 세이브 안의 +// 가장 최근 수정 시각이다. 기록이 없으면 항상 true. +bool hasSaveDataChanged(const SyncOptions& options, u64 titleID); + +// 올릴 것이 있는 타이틀 수. 네트워크를 열기 전에 물어볼 수 있다 - +// 파일 시각만 보기 때문이다. 목록을 못 읽으면 -1. +// +// 0 이면 정말로 할 일이 없다는 뜻이고, 그러면 소켓도 무선랜도 건드릴 +// 이유가 없다. 시스템 모듈에서는 그 차이가 크다. +int countChangedTitles(const SyncOptions& options); + +// 업로드에 성공한 뒤 현재 상태를 기록해 둔다. +void markSaveDataSynced(const SyncOptions& options, u64 titleID); + + +// 모든 세이브를 아카이브한 뒤 서버로 업로드한다. +int pushAllSaves(const SyncOptions& options, SyncLogFunc log); + +// 서버에서 내려받아 복원한다. remoteEnabled 가 false 면 로컬 아카이브에서 복원한다. +int pullAllSaves(const SyncOptions& options, SyncLogFunc log); + + +// 마지막 자동 동기화 시각 (Unix time) 을 기록하는 파일. 없으면 0 을 반환한다. +time_t readLastAutoSyncTime(const std::string& saveDataPath); +void writeLastAutoSyncTime(const std::string& saveDataPath, time_t when); + +// intervalHours 가 지났는지 확인한다. intervalHours <= 0 이면 항상 true. +bool isAutoSyncDue(const std::string& saveDataPath, int intervalHours); diff --git a/client/source/sysmodule.cpp b/client/source/sysmodule.cpp new file mode 100644 index 0000000..2ebcccc --- /dev/null +++ b/client/source/sysmodule.cpp @@ -0,0 +1,219 @@ +#include "sysmodule.hpp" + +#include +#include +#include +#include + +#include + +#include "fileio.hpp" + + +namespace sysmodule +{ + +namespace +{ + +const char* ROMFS_MOUNT = "uNSSromfs"; + +std::string contentsDir() +{ + return std::string("sdmc:/atmosphere/contents/") + PROGRAM_ID; +} + +std::string exefsPath() +{ + return contentsDir() + "/exefs.nsp"; +} + +std::string flagPath() +{ + return contentsDir() + "/flags/boot2.flag"; +} + +// 모듈이 스스로를 껐을 때 부팅 플래그가 옮겨지는 자리. +// +// 모듈은 자기가 도는 동안 시스템이 죽었다는 것을 확인했을 때만 이렇게 한다 +// (client-sysmodule 의 RunMarker 참고). 여기 파일이 있다는 것은 "지난번에 +// 이 모듈이 콘솔을 망가뜨렸고, 그래서 스스로 물러났다" 는 뜻이다. +std::string disabledFlagPath() +{ + return contentsDir() + "/flags/boot2.flag.crashed"; +} + +// 설치된 모듈의 버전을 남겨두는 파일. Atmosphere 가 신경쓰지 않는 이름이라 +// 같은 폴더에 둬도 안전하다. +std::string versionPath() +{ + return contentsDir() + "/uNSS.version"; +} + + +bool fileExists(const std::string& path) +{ + struct stat st; + return stat(path.c_str(), &st) == 0; +} + + +int readInstalledVersion() +{ + FILE* fp = fopen(versionPath().c_str(), "r"); + if (!fp) return 0; + + int version = 0; + if (fscanf(fp, "%d", &version) != 1) + version = 0; + fclose(fp); + + return version; +} + + +int copyFile(const std::string& from, const std::string& to) +{ + FILE* src = fopen(from.c_str(), "rb"); + if (!src) return -1; + + FILE* dst = fopen(to.c_str(), "wb"); + if (!dst) + { + fclose(src); + return -2; + } + + // 모듈은 100 KiB 대라 버퍼를 크게 잡을 이유가 없다. + static u8 buffer[16 * 1024]; + int ret = 0; + + while (true) + { + const size_t got = fread(buffer, 1, sizeof(buffer), src); + if (got == 0) + { + if (ferror(src)) ret = -3; + break; + } + + if (fwrite(buffer, 1, got, dst) != got) + { + ret = -4; + break; + } + } + + fclose(dst); + fclose(src); + + // 반쯤 쓰다 만 파일을 남기면 부팅 때 그대로 로드된다. 반드시 지운다. + if (ret != 0) remove(to.c_str()); + + return ret; +} + +} // namespace + + +std::string installPath() +{ + return contentsDir(); +} + + +State getState() +{ + if (!fileExists(exefsPath())) + return State::NotInstalled; + + if (readInstalledVersion() < BUNDLED_VERSION) + return State::Outdated; + + // 설치는 돼 있는데 플래그가 치워진 자리에 남아 있다면, 지난 실행이 + // 끝까지 가지 못한 것이다. 모듈이 죽었거나 - 백업 도중에 콘솔을 껐거나. + // 어느 쪽인지는 여기서 알 수 없고, 그래서 되살리는 것은 사용자가 정한다. + // 이 상태를 알려주지 않으면 모듈은 조용히 다시 뜨지 않는다. + if (!fileExists(flagPath()) && fileExists(disabledFlagPath())) + return State::Interrupted; + + return State::UpToDate; +} + + +// 치워둔 플래그를 제자리로 돌린다. +int resume() +{ + if (!fileExists(disabledFlagPath())) return -1; + + remove(flagPath().c_str()); + return rename(disabledFlagPath().c_str(), flagPath().c_str()) == 0 ? 0 : -2; +} + + +int install() +{ + const Result rc = romfsMountSelf(ROMFS_MOUNT); + if (R_FAILED(rc)) return -1; + + int ret = 0; + + do + { + if (recursiveMkdir(contentsDir() + "/flags") != 0) + { + ret = -2; + break; + } + + const std::string source = std::string(ROMFS_MOUNT) + ":/exefs.nsp"; + if (copyFile(source, exefsPath()) != 0) + { + ret = -3; + break; + } + + // 내용은 없어도 된다. 존재 자체가 부팅 시 실행하라는 뜻이다. + FILE* flag = fopen(flagPath().c_str(), "wb"); + if (!flag) + { + ret = -4; + break; + } + fclose(flag); + + // 지난번에 치워둔 플래그가 남아 있으면 지운다. 방금 새로 만들었으니 + // 쓸모가 없고, 남겨두면 다음 판단이 헷갈린다. + remove(disabledFlagPath().c_str()); + + FILE* version = fopen(versionPath().c_str(), "w"); + if (version) + { + fprintf(version, "%d", BUNDLED_VERSION); + fclose(version); + } + } + while (false); + + romfsUnmount(ROMFS_MOUNT); + return ret; +} + + +int uninstall() +{ + remove(flagPath().c_str()); + // 치워둔 플래그도 같이 지운다. 남겨두면 flags/ 가 비지 않아 디렉토리가 + // 그대로 남는다. + remove(disabledFlagPath().c_str()); + remove(versionPath().c_str()); + remove(exefsPath().c_str()); + + // 빈 디렉토리만 지워진다. 남은 파일이 있으면 실패해도 그냥 둔다. + rmdir((contentsDir() + "/flags").c_str()); + rmdir(contentsDir().c_str()); + + return fileExists(exefsPath()) ? -1 : 0; +} + +} // namespace sysmodule diff --git a/client/source/sysmodule.hpp b/client/source/sysmodule.hpp new file mode 100644 index 0000000..866fd42 --- /dev/null +++ b/client/source/sysmodule.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include + + +// 백그라운드 자동 백업을 담당하는 sysmodule 의 설치를 관리한다. +// +// 모듈 자체는 NRO 의 romfs 에 들어있다. Atmosphere 는 +// atmosphere/contents// 만 인식하므로 그 위치로 풀어준다. +namespace sysmodule +{ + +// config.json 의 program_id 와 반드시 같아야 한다. +// +// 0x0100... 대역은 시스템 모듈용으로 0x...0FFF 정도까지만 쓰인다. +// 그보다 높은 값은 애플리케이션으로 취급돼 boot2 가 아예 띄우지 않는다. +// 그래서 sys-patch(420000000000000B), nx-ovlloader(420000000007E51A) 와 +// 같은 0x42 대역을 쓴다. 이 콘솔에서 실제로 도는 것이 확인된 대역이다. +constexpr const char* PROGRAM_ID = "4200000000554E53"; + +// romfs 에 들어있는 모듈의 버전. 모듈을 고칠 때마다 올린다. +// 3: 부팅 직후에 서비스를 열지 않도록 고쳤다. 2 는 HID 를 죽여서 콘솔을 +// 부팅 루프에 빠뜨렸다 (2001-0132, 커널 LimitReached). +// 4: smExit() 전에 pm:dmnt 를 열어둔다. 3 에서는 "게임 중에는 백업하지 +// 않는다" 는 보호가 아예 동작하지 않았다. +// 5: 올릴 것이 있는지 먼저 확인하고, 있을 때만 네트워크를 연다. +// 6: 한 번 돌고 끝나지 않고 계속 살아 있는다. 게임이 끝나면 백업한다. +// 5 까지는 부팅 때만 돌았고, 스위치는 재부팅을 거의 하지 않는다. +// 7: NsApplicationControlData 를 스택이 아니라 힙에 둔다. 144KB 짜리라 +// 16KB 스택에서는 함수 프롤로그에서 바로 넘쳤다. 6 은 백업을 시작하는 +// 순간 반드시 죽었다 (2168-0002 data abort, getTitleName+0x8). +// 자기 보호도 고쳤다: 6 까지는 첫 바퀴만 감시해서, 정작 위험한 백업 +// 도중의 죽음은 아무도 알아채지 못했다. +// 8: smExit() 앞에서 ssl 서비스를 연다. libcurl 은 첫 https 연결에서야 +// ssl 을 게을리 여는데, 그 시점에는 SM 세션이 이미 없어 실패했다. +// 7 은 죽지 않았지만 push 가 한 번도 성공하지 못했고, 서버 로그에는 +// 아무 흔적도 남지 않았다 - 바이트가 나간 적이 없기 때문이다. +// 실패 로그에 실제 원인 코드도 함께 남긴다 (ret=-1 만으로는 못 찾는다). +// 9: smExit() 를 아예 부르지 않는다. 8 은 ssl 만 미리 열었는데, 정작 +// 먼저 걸리는 것은 이름 풀이였다. sfdnsres 에는 초기화 함수가 없고 +// 요청마다 SM 으로 서비스를 여므로, SM 을 닫으면 getaddrinfo 가 +// 실패한다 (http=-4, CURLE_COULDNT_RESOLVE_HOST 로 확인). +// 미리 잡아두던 ssl 세션 두 개도 도로 뺐다 - 8 을 넣은 직후 am 이 +// 2001-0132 로 죽었다. 자기 보호도 조인다: 콘솔 전체가 내려앉는 +// 사고는 백업 실패와 같은 무게로 다루지 않는다. +// 10: 힙을 2 MiB 에서 1 MiB 로 줄인다. 2 MiB 도 컸다 - sys-ftpd 가 같은 +// 풀에서 함께 뜨기 시작한 날, am 이 2001-0132 로 세 번 죽었다 +// (세 번 다 PC-start = 0x390b4, 매번 우리가 뜬 뒤 30 초 안). +// 크기는 쓰는 만큼이 아니라 잡은 만큼 풀에서 빠진다. +// 한 바퀴의 실사용량을 이제 로그에 남긴다 - 다음 값은 재고 정한다. +// 11: 고치는 대신 잰다. 힙을 6 -> 2 -> 1 MiB 로 줄이는 동안 am 은 매번 +// 똑같이 죽었다 - 우리가 뜬 뒤 4-6 초에, 네 번 모두. 크기를 또 짐작할 +// 일이 아니다. 커널이 세는 다섯 가지 한계 (메모리, 스레드, 이벤트, +// 전송 메모리, 세션) 와 네 개의 메모리 풀을 시작할 때 적고, 그 뒤 +// 5 초마다 다시 적는다. 죽는 순간이 그 구간 안에 있으므로, 다음 사고 +// 때는 어느 통이 비었는지가 로그에 남는다. +// 12: 계정마다 자기 세이브만 본다. 세이브 목록은 콘솔 전체를 사용자 구분 +// 없이 돌려주는데 거르지 않았다 - 그래서 계정 셋이 똑같은 26 개를 받고, +// 남의 세이브를 열려다 스물세 번씩 실패했다. 고장이 아니라 "그 계정에는 +// 없다" 였다. 같은 게임이 로그에 세 번 찍히던 것도 같은 이유였다. +// "세이브 없음" 을 실패와 구별한다 - 실패로 세면 한 바퀴가 늘 오류로 +// 끝나고, 마지막 성공 시각이 남지 않아 다음 바퀴가 전부를 다시 한다. +// 힙은 1 MiB -> 256 KB. 실측 최고점이 189 KB 였다 (2026-08-01). +// 13: 힙을 1 MiB 로 되돌린다. 12 에서 256 KB 로 줄인 것은 실측 최고점 +// 189 KB 를 하한으로 읽은 탓인데, 그 값은 힙 크기에 따라 움직인다 - +// 256 KB 에서는 같은 일에 245 KB 를 썼고, 압축과 이름 조회와 업로드가 +// 한꺼번에 무너졌다. 계정 필터는 12 에서 확인됐으므로 그대로 둔다 +// (올릴 타이틀이 78 개에서 26 개로 줄었다). +constexpr int BUNDLED_VERSION = 13; + + +enum class State +{ + NotInstalled, // 설치된 적 없음 + Outdated, // 설치돼 있지만 NRO 가 들고 있는 것이 더 새것 + UpToDate, // 최신 + Interrupted, // 설치돼 있지만 지난 실행이 끝까지 가지 못해 꺼져 있음 +}; + + +State getState(); + +// 치워둔 boot2 플래그를 제자리로 돌린다 (State::Interrupted 일 때). +// +// 모듈은 위험한 일을 하기 전에 플래그를 치우고 무사히 끝나면 되돌린다. +// 그래서 모듈이 죽어도 부팅 루프가 생기지 않지만, 백업 도중에 콘솔을 끈 +// 경우에도 똑같이 치워진 채로 남는다. 둘을 구별할 방법이 없으므로 되살리는 +// 것은 사용자가 정한다. 성공하면 0. +int resume(); + +// romfs 의 모듈을 atmosphere/contents 로 복사하고 boot2 플래그를 만든다. +// 성공하면 0. +int install(); + +// 설치한 파일들을 지운다. 성공하면 0. +int uninstall(); + +// 사용자에게 보여줄 설치 경로. +std::string installPath(); + +} diff --git a/client/source/title.cpp b/client/source/title.cpp index 8dba710..17ea59c 100644 --- a/client/source/title.cpp +++ b/client/source/title.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include @@ -26,12 +28,29 @@ int getTitleName(const u64 titleID, std::string& titleName, int language) } ); - NsApplicationControlData controlData = {0x00,}; + // 스택이 아니라 힙이다. 이 구조체는 nacp 뒤에 0x20000 짜리 아이콘이 + // 붙어 있어 0x24000 - 144KB 다. 앱에서는 스택이 넉넉해 문제가 없지만 + // 시스템 모듈의 메인 스레드 스택은 16KB (config.json) 라, 스택에 두면 + // 함수 프롤로그에서 곧바로 넘친다. 실제로 그랬다: 2168-0002 data abort, + // getTitleName+0x8. + // + // make_unique 가 아니라 nothrow new 다. 시스템 모듈은 -fno-exceptions 로 + // 빌드되는데 (client-sysmodule/Makefile) 힙은 2MiB 뿐이다. 할당이 실패하면 + // operator new 가 던지는 bad_alloc 을 받을 곳이 없어 그대로 abort 로 간다 - + // 스택이 넘치던 것과 똑같이 모듈이 죽는다. 이 함수에는 이미 -1 로 물러나는 + // 길이 있으니 그쪽으로 보낸다. new(nothrow) T() 는 값 초기화라 0 으로 + // 채워져 나오므로 memset 은 따로 필요 없다. + const std::unique_ptr controlDataHolder( + new (std::nothrow) NsApplicationControlData()); + if (!controlDataHolder) return -1; + + NsApplicationControlData& controlData = *controlDataHolder; + size_t actualSize; Result rc = nsGetApplicationControlData(NsApplicationControlSource_Storage, titleID, &controlData, sizeof(controlData), &actualSize); - - if (R_SUCCEEDED(rc)) + + if (R_SUCCEEDED(rc)) { if (language == -1) { @@ -123,7 +142,20 @@ int probeSaveDataCreatedTitles(const AccountUid accountUid, std::vector& ti break; } - if (info.save_data_type == FsSaveDataType_Account) + // 이 계정의 것만 담는다. fsOpenSaveDataInfoReader 는 콘솔에 있는 모든 + // 세이브를 사용자 구분 없이 돌려주므로, 거르지 않으면 계정이 셋이든 + // 넷이든 모두 똑같은 목록을 받는다. + // + // 그 상태에서는 남의 세이브를 자기 것으로 열려다 실패하고, 그 실패가 + // "Failed to archive" 로 남았다 - 계정 하나당 스물세 번씩 + // (2026-08-01, Fränk 와 joseph 이 26 개 중 23 개에서 그랬다). + // 고장이 아니라 "이 계정에는 그 세이브가 없다" 였다. + // + // 중복도 같은 이유로 사라진다: 한 게임을 세 사람이 저장해두면 목록에 + // 세 번 들어와 있었고, 로그에도 같은 제목이 세 줄 찍혔다. + if (info.save_data_type == FsSaveDataType_Account + && info.uid.uid[0] == accountUid.uid[0] + && info.uid.uid[1] == accountUid.uid[1]) { outputTitleIDs.push_back(info.application_id); } diff --git a/server/Dockerfile b/server/Dockerfile index c48c0c8..383f99b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,8 +1,30 @@ FROM python:3.12-slim +# Which account the server runs as. Override at build time if the host uses +# different ids: docker build --build-arg UID=1001 --build-arg GID=1001 . +ARG UID=1000 +ARG GID=100 + WORKDIR /app + +# Opens nothing on the host — it only records the port in the image metadata. +# Reverse proxies read it from there: Traefik picks it up automatically as +# long as exactly one port is declared, so no port label is needed on the +# container. +EXPOSE 8989 + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . /app +# The database path is relative, so metadata.sqlite lives in /app — and SQLite +# writes its journal *next to* the database. Without write access to the +# directory itself (not just the file), every write fails with +# "unable to open database file", even when the file is mounted correctly. +RUN chown -R ${UID}:${GID} /app + +# Do not run as root. Bind-mounted metadata.sqlite and savedata/ must be owned +# by the same ids on the host, or the server cannot write to them. +USER ${UID}:${GID} + ENTRYPOINT ["python3", "main.py"] diff --git a/server/compose.traefik.yml b/server/compose.traefik.yml new file mode 100644 index 0000000..c1632f0 --- /dev/null +++ b/server/compose.traefik.yml @@ -0,0 +1,235 @@ +# uNSS server behind Traefik. +# +# Alternative to docker-compose.yaml for setups that already run Traefik as +# reverse proxy. The container publishes no port of its own — Traefik reaches +# it inside the docker network and terminates TLS on 443. +# +# Requires an external docker network named `traefik`, and the credentials: +# +# UNSS_USERNAME user name, defaults to `switch` +# UNSS_PASSWORD the password in plain text +# +# unss-auth-writer below hashes it with bcrypt at cost 12 and writes Traefik's +# middleware. Supplying the hash yourself still works — set UNSS_BASICAUTH_USERS +# to a full htpasswd line instead and the password is ignored. +# +# Hashing here rather than by hand exists so the store holds ONE value. The +# same password has to reach the client as well (see below), and a hash cannot +# be turned back into it — keeping both meant keeping them in sync by hand, +# which is exactly the kind of thing that drifts apart unnoticed. +# +# On the cost of 12: htpasswd defaults to 5, far too low today (measured: 19 ms +# per attempt versus 167 ms at 12). Traefik's basicAuth accepts only MD5, SHA1 +# or bcrypt — argon2 is not supported — so the cost factor and a long password +# are the only things carrying the security here. +# +# Which characters the password may contain is NOT a free choice. The Switch +# client takes its credentials from the URL in config.ini: +# +# serverUrl=https://switch:@unss.example.org +# +# and passes that string to libcurl unescaped. `/`, `?`, `#`, `@`, `%`, `[` +# and `]` change how a URL is split and will break authentication in ways the +# error message does not point at. Its INI parser also strips `'` and `"` from +# both ends of a value. A long alphanumeric password avoids all of it — +# length carries the entropy, not the variety of characters. +# +# Keep the credentials out of this file. Two ways to supply them: +# +# 1. From a secret store (what this file does). Rotating means changing the +# password there — nothing else moves. +# +# 2. From an .env file next to this one. Fewer moving parts, but the .env is +# then the source of truth. Under OpenMediaVault the stack's Environment +# field is that .env file. +# +# An .env feeds ${...} interpolation of THIS file and nothing else — it does +# not reach a container on its own, and none of the credentials below are +# interpolated. So this route needs one more line on unss-auth-writer: +# +# env_file: +# - .env +# +# A plain password needs no escaping there; a ready-made hash does, since +# the doubled `$` is still collapsed on the way in: +# +# UNSS_BASICAUTH_USERS=switch:$$2y$$12$$.... +# +# What does NOT work is filling a label from a secret store. Compose resolves +# ${...} while PARSING this file; a store injects values when the container +# STARTS, and labels are fixed at create time. The label stays empty, and an +# empty value is not a harmless default: Traefik drops a router whose +# middleware is invalid, so the service answers 404 instead of asking for +# credentials — which reads like a routing bug rather than an auth one. +# +# Why basicAuth and not a proper login: the Switch client does not follow +# redirects, so any redirect-based flow (authelia, OIDC) can never complete. +# libcurl does however send credentials taken straight from the URL, which +# means https://user:pass@host works without touching the client at all. +# +# The router rule is not set here on purpose: it comes from Traefik's +# defaultRule, which derives the host name from the compose service name. +# Renaming the service therefore changes the URL. + +services: + # Turns the credentials into a Traefik middleware. This exists for one + # reason: a label cannot be filled from a secret store, a file can. + # + # Traefik reads labels from the docker daemon — container metadata, fixed at + # create time. It never looks inside the container, so the environment a + # secret store populates is invisible to it. Writing the value to Traefik's + # dynamic configuration crosses that boundary: the store fills this + # container's environment (which it can), and this container writes the file + # (which Traefik reads). + # + # Neither variable is declared under `environment:`. A secret store injects + # them at container start; declaring them here would let compose overwrite + # them with an empty string at parse time. Without such a store, add the + # `env_file:` line described above — an .env alone never reaches a container, + # it only feeds ${...} in the compose file, and nothing here interpolates + # these two. A hash in that file needs the doubled `$`, a plain password + # does not. + # + # The image is httpd only because it ships `htpasswd`. Nothing is served + # from it. + unss-auth-writer: + image: httpd:2.4-alpine + restart: unless-stopped + volumes: + # Traefik's dynamic configuration directory, writable here and mounted + # read-only in Traefik itself. + - ${TRAEFIK_DYNAMIC_DIR:-/srv/traefik/dynamic}:/dynamic + command: + - sh + - -c + - | + set -e + NAME="$${UNSS_USERNAME:-switch}" + # htpasswd takes the name as a bare argument and the finished line goes + # into a double-quoted YAML scalar. A leading `-` is read as an option + # (htpasswd then exits 2 and this container restarts forever), a `:` + # splits the line in the wrong place, and a `"` or `\` writes a file + # Traefik cannot parse. None of those say what went wrong, so refuse. + case "$$NAME" in + ''|-*|*:*|*'"'*|*\\*) + echo "UNSS_USERNAME is empty, starts with '-', or contains : \" \\ - refusing" >&2 + exit 1 ;; + esac + if [ -n "$$UNSS_BASICAUTH_USERS" ]; then + # A ready-made htpasswd line — kept for setups that hash elsewhere. + # It wins over UNSS_PASSWORD, so remember which one was used: a + # rotation silently overridden by a leftover variable otherwise looks + # exactly like one that worked. + LINE="$$UNSS_BASICAUTH_USERS" + SOURCE="UNSS_BASICAUTH_USERS" + elif [ -n "$$UNSS_PASSWORD" ]; then + # Hash the plain password here, so the store holds one value a human + # can also type into the client instead of two that must be kept in + # sync. `-i` reads it from stdin: on the command line it would show + # up in the process list. + # + # bcrypt salts randomly, so this line differs on every start even + # though the password does not. Traefik reloads the file and carries + # on — the churn is cosmetic. + LINE="$$(printf '%s' "$$UNSS_PASSWORD" | htpasswd -niBC 12 "$$NAME")" + SOURCE="UNSS_PASSWORD (hashed here)" + else + echo "neither UNSS_PASSWORD nor UNSS_BASICAUTH_USERS is set - refusing to write an open middleware" >&2 + exit 1 + fi + # A colon alone is not enough of a check. `user:` with nothing behind + # it, and a hash that still carries the doubled dollars meant for + # compose interpolation, both keep the colon — and Traefik accepts each + # as a users entry that no password can ever satisfy. That is a total + # lockout which every other signal here reports as healthy. + case "$$LINE" in + *'"'*|*\\*) + echo "the htpasswd line contains a quote or backslash and would break the YAML - refusing" >&2 + exit 1 ;; + *'$$$$'*) + echo "the htpasswd line still has doubled dollar signs - pass it via env_file, which does the un-escaping" >&2 + exit 1 ;; + esac + case "$$LINE" in + ?*:?*) : ;; + *) echo "not an htpasswd line (expected user:hash) - refusing to write it" >&2; exit 1 ;; + esac + printf '%s\n' \ + '# Generated by unss-auth-writer. Do not edit; overwritten on every start.' \ + 'http:' \ + ' middlewares:' \ + ' unss-auth:' \ + ' basicAuth:' \ + ' users:' \ + " - \"$$LINE\"" \ + > /dynamic/unss-auth.yaml + # The user comes out of the line that was actually written, not out of + # UNSS_USERNAME: the two differ whenever UNSS_BASICAUTH_USERS won. + echo "wrote /dynamic/unss-auth.yaml for user $${LINE%%:*} (from $$SOURCE)" + # Stay alive so the healthcheck below can keep vouching for the file. + # Exiting would work too, but a stack that permanently shows one + # container as stopped trains you to ignore that display — and then + # you miss the time it means something. + exec sleep infinity + healthcheck: + # Not "did it run once" but "is the file there right now". + test: ["CMD-SHELL", "test -s /dynamic/unss-auth.yaml"] + interval: "60s" + timeout: "5s" + retries: 3 + start_period: "5s" + + unss: + image: ghcr.io/dmuix/unss-server:latest + container_name: unss + # The image already runs as 1000:100. Override only if the host uses + # different ids — the mounted metadata.sqlite and savedata/ must belong to + # whatever is set here, including the directory, since SQLite writes its + # journal next to the database. + user: "${PUID:-1000}:${PGID:-100}" + environment: + TZ: ${TZ} + volumes: + - ./savedata:/app/savedata + # Must already exist as a FILE. If it does not, docker creates a + # directory with that name and the server fails to start. + - ./metadata.sqlite:/app/metadata.sqlite + restart: unless-stopped + networks: + traefik: + labels: + traefik.enable: "true" + # No port label: the image declares EXPOSE 8989, and Traefik takes the + # port from there. Should the image ever expose a second port, Traefik + # can no longer choose and needs + # traefik.http.services.unss.loadbalancer.server.port: "8989" + # Without either, it builds no router at all and answers 404 — which + # reads like a routing problem rather than a missing port. + # + # `@file`, not `@docker`: the middleware comes from Traefik's dynamic + # configuration, written by unss-auth-writer above. Drop the suffix and + # Traefik looks for a middleware the docker provider never defined. + traefik.http.routers.unss.middlewares: "unss-auth@file" + depends_on: + # Start only once the middleware file exists — the writer's healthcheck + # is what asserts that. It refuses to run on an empty hash, so on a fresh + # volume a broken secret lookup keeps the server down rather than + # publishing it unauthenticated. + # + # It cannot do more than that: the file outlives the container, so once a + # run has succeeded, a later failed lookup leaves the PREVIOUS middleware + # in place. The server then stays reachable behind the old password + # instead of the rotated one. `docker logs unss-auth-writer` is the only + # thing that tells them apart. + unss-auth-writer: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request;urllib.request.urlopen('http://localhost:8989/users/healthcheck/saves')\" || exit 1"] + interval: "30s" + timeout: "5s" + retries: 3 + start_period: "10s" + +networks: + traefik: + external: true diff --git a/server/repository.py b/server/repository.py index c87e57a..2c06d37 100644 --- a/server/repository.py +++ b/server/repository.py @@ -96,11 +96,15 @@ def _init_schema(self): sqlite3.connect(self.db_path).executescript(script).close() async def get_latest_revision_by_title(self, user_name: str, title_id: str) -> str: + # created_at 은 CURRENT_TIMESTAMP 라 초 단위다. 같은 초에 두 번 올리면 + # 순서가 정해지지 않고, SQLite 는 먼저 찾은 것 - 대개 가장 오래된 것 - + # 을 준다. 복원할 때 지난 세이브를 받게 된다는 뜻이다. + # rowid 는 INSERT 순서대로 늘어나므로 이것으로 동점을 깬다. query = """ SELECT UPPER(revision_id) FROM savedata WHERE user_name = ? AND title_id = UPPER(?) AND status = 'C' - ORDER BY created_at DESC + ORDER BY created_at DESC, rowid DESC LIMIT 1 """ async with aiosqlite.connect(self.db_path) as conn: @@ -120,12 +124,24 @@ async def get_revision_status(self, revision_id: str) -> str: return result[0] if result else None async def query_all_latest_revision_by_user(self, user_name: str) -> Tuple[str, str]: + # GROUP BY ... HAVING MAX(created_at) 은 타이틀마다 한 줄로 줄여주긴 + # 하지만, 그 한 줄이 어느 리비전인지는 정하지 않는다. 같은 초에 두 번 + # 올리면 오래된 쪽이 나왔다 (실측). + # + # 타이틀마다 최신 한 줄을 명시적으로 고른다. 동점은 rowid 로 깬다 - + # get_latest_revision_by_title 과 같은 기준이어야 목록과 개별 조회가 + # 서로 다른 답을 내놓지 않는다. query = """ - SELECT UPPER(title_id), UPPER(revision_id) - FROM savedata - WHERE user_name = ? AND status = 'C' - GROUP BY title_id - HAVING MAX(created_at) + SELECT UPPER(s.title_id), UPPER(s.revision_id) + FROM savedata s + WHERE s.user_name = ? AND s.status = 'C' + AND s.rowid = ( + SELECT rowid + FROM savedata + WHERE user_name = s.user_name AND title_id = s.title_id AND status = 'C' + ORDER BY created_at DESC, rowid DESC + LIMIT 1 + ) """ async with aiosqlite.connect(self.db_path) as conn: async with conn.execute(query, (user_name,)) as cursor: diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt new file mode 100644 index 0000000..f186ae5 --- /dev/null +++ b/server/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt + +# 테스트에만 필요하다. 이미지에는 들어가지 않는다. +pytest +# TestClient 가 쓴다. +httpx diff --git a/server/requirements.txt b/server/requirements.txt index 2985e1a..721512c 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -3,4 +3,5 @@ pydantic aiosqlite uvicorn python-multipart -click \ No newline at end of file +click +uvloop \ No newline at end of file diff --git a/server/run-linux.sh b/server/run-linux.sh index 1613594..c7ea5b9 100755 --- a/server/run-linux.sh +++ b/server/run-linux.sh @@ -1,2 +1,2 @@ #!/bin/sh -uvicorn main:app --host 0.0.0.0 --port 8989 +exec uvicorn main:app --host 0.0.0.0 --port 8989 diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..2bf257d --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,43 @@ +""" +테스트마다 서버를 통째로 새로 세운다. + +repository 와 service 는 싱글턴이고, main 은 임포트되는 순간 모듈 수준에서 +"metadata.sqlite" 를 만든다. 즉 상태가 프로세스에 붙어 있다. 그래서 임시 +디렉토리로 옮겨 간 뒤 모듈을 다시 임포트한다 - 싱글턴이 클로저에 인스턴스를 +들고 있으므로, 모듈을 새로 읽으면 인스턴스도 새로 생긴다. + +이렇게 하지 않으면 테스트끼리 같은 DB 와 savedata/ 를 공유하고, 실행 순서에 +따라 결과가 달라진다. +""" + +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +SERVER_DIR = Path(__file__).resolve().parent.parent + +# 서버 모듈은 서로를 평범한 최상위 이름으로 임포트한다 (from repository import ...). +if str(SERVER_DIR) not in sys.path: + sys.path.insert(0, str(SERVER_DIR)) + + +@pytest.fixture() +def server(tmp_path, monkeypatch): + """(TestClient, 작업 디렉토리) 를 준다.""" + + monkeypatch.chdir(tmp_path) + + for name in ("main", "service", "repository", "singleton", "model"): + sys.modules.pop(name, None) + + import main # noqa: E402 - chdir 뒤에 임포트해야 DB 가 임시 디렉토리에 생긴다 + + with TestClient(main.app) as client: + yield client, tmp_path + + +@pytest.fixture() +def client(server): + return server[0] diff --git a/server/tests/test_protocol.py b/server/tests/test_protocol.py new file mode 100644 index 0000000..8295c2b --- /dev/null +++ b/server/tests/test_protocol.py @@ -0,0 +1,108 @@ +""" +클라이언트와 서버 사이의 약속을 지키는지 본다. + +응답은 JSON 이 아니라 plain text 다. C++ 클라이언트가 직접 파싱하기 때문에 +포맷이 조금만 달라져도 - 따옴표가 붙거나, 줄 구분이 바뀌거나, 대소문자가 +달라지거나 - 스위치 쪽에서 조용히 깨진다. 서버 테스트가 잡아줄 수 있는 것은 +여기까지이고, 그래서 여기를 잡는다. +""" + +UPLOAD = b"PK\x03\x04 pretend this is a save archive" + + +def upload(client, user, title, payload=UPLOAD): + """리비전 하나를 끝까지 올리고 revision_id 를 준다.""" + revision_id = client.post(f"/users/{user}/saves/{title}/revisions").text + client.post(f"/users/{user}/saves/{title}/revisions/{revision_id}", content=payload) + return revision_id + + +def test_empty_user_returns_empty_body_not_json(client): + response = client.get("/users/Nobody/saves") + + assert response.status_code == 200 + assert response.text == "" + # "[]" 나 "null" 이 오면 클라이언트는 그것을 타이틀 하나로 읽는다. + assert response.headers["content-type"].startswith("text/plain") + + +def test_save_list_is_pipe_separated_one_per_line(client): + first = upload(client, "Soad1337", "0100000000010000") + second = upload(client, "Soad1337", "010000000000100B") + + lines = client.get("/users/Soad1337/saves").text.splitlines() + + assert sorted(lines) == sorted([ + f"0100000000010000|{first}", + f"010000000000100B|{second}", + ]) + + +def test_ids_come_back_uppercase(client): + # 클라이언트는 타이틀 ID 를 소문자로 보낼 수 있다. 서버는 UPPER 로 + # 정규화해서 저장하고, 그대로 돌려줘야 비교가 어긋나지 않는다. + revision_id = upload(client, "Soad1337", "010000000000100b") + + body = client.get("/users/Soad1337/saves").text + + assert body == f"010000000000100B|{revision_id}" + assert revision_id == revision_id.upper() + + +def test_title_id_lookup_is_case_insensitive(client): + revision_id = upload(client, "Soad1337", "010000000000100b") + + lower = client.get("/users/Soad1337/saves/010000000000100b/revisions").text + upper = client.get("/users/Soad1337/saves/010000000000100B/revisions").text + + assert lower == upper == revision_id + + +def test_users_are_kept_apart(client): + mine = upload(client, "Soad1337", "0100000000010000") + yours = upload(client, "Someone", "0100000000010000") + + assert client.get("/users/Soad1337/saves").text == f"0100000000010000|{mine}" + assert client.get("/users/Someone/saves").text == f"0100000000010000|{yours}" + + +def test_upload_echoes_the_revision_id(client): + # 클라이언트는 이 응답으로 업로드가 받아들여졌는지 판단한다. + revision_id = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + response = client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{revision_id}", + content=UPLOAD, + ) + + assert response.status_code == 200 + assert response.text == revision_id + + +def test_download_returns_exactly_what_was_uploaded(client): + payload = bytes(range(256)) * 8 + revision_id = upload(client, "Soad1337", "0100000000010000", payload) + + response = client.get( + f"/users/Soad1337/saves/0100000000010000/revisions/{revision_id}/data" + ) + + assert response.status_code == 200 + assert response.content == payload + + +def test_download_latest_resolves_to_newest_revision(client): + upload(client, "Soad1337", "0100000000010000", b"old") + newest = upload(client, "Soad1337", "0100000000010000", b"new") + + response = client.get("/users/Soad1337/saves/0100000000010000/revisions/latest/data") + + assert response.content == b"new" + assert newest # 최신 리비전이 실제로 발급됐는지도 같이 본다 + + +def test_download_of_unknown_revision_is_404_not_a_crash(client): + response = client.get( + "/users/Soad1337/saves/0100000000010000/revisions/DOES-NOT-EXIST/data" + ) + + assert response.status_code == 404 diff --git a/server/tests/test_revisions.py b/server/tests/test_revisions.py new file mode 100644 index 0000000..e8f1d7e --- /dev/null +++ b/server/tests/test_revisions.py @@ -0,0 +1,159 @@ +""" +리비전 트랜잭션. 여기가 깨지면 반쯤 올라간 세이브가 최신인 척 남는다. + +상태는 셋이다: + P 발급됐고 아직 파일을 받는 중 + C 파일까지 다 받았다 - 이제서야 최신 리비전이 된다 + D 중간에 실패해서 버렸다 + +'P' 나 'D' 가 목록에 나타나면 클라이언트는 그것을 내려받으려 하고, 없거나 +잘린 파일을 세이브 데이터로 되돌려 쓰게 된다. +""" + +import sqlite3 + +import pytest + +UPLOAD = b"PK\x03\x04 pretend this is a save archive" + + +def status_of(work_dir, revision_id): + conn = sqlite3.connect(work_dir / "metadata.sqlite") + try: + row = conn.execute( + "SELECT status FROM savedata WHERE revision_id = ?", (revision_id,) + ).fetchone() + finally: + conn.close() + return row[0] if row else None + + +def test_issued_revision_is_pending_and_invisible(server): + client, work_dir = server + + revision_id = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + + assert status_of(work_dir, revision_id) == "P" + # 아직 파일이 없다. 목록에 나오면 클라이언트가 빈 것을 받아간다. + assert client.get("/users/Soad1337/saves").text == "" + + +def test_revision_becomes_current_only_after_the_file_arrives(server): + client, work_dir = server + + revision_id = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{revision_id}", + content=UPLOAD, + ) + + assert status_of(work_dir, revision_id) == "C" + assert client.get("/users/Soad1337/saves").text == f"0100000000010000|{revision_id}" + + +def test_uploading_twice_to_the_same_revision_is_refused(server): + client, work_dir = server + + revision_id = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{revision_id}", + content=b"the real save", + ) + + # 이미 끝난 트랜잭션이다. 다시 쓰게 두면 멀쩡한 백업이 덮인다. + with pytest.raises(ValueError): + client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{revision_id}", + content=b"garbage", + ) + + assert status_of(work_dir, revision_id) == "C" + response = client.get( + f"/users/Soad1337/saves/0100000000010000/revisions/{revision_id}/data" + ) + assert response.content == b"the real save" + + +def test_unknown_revision_cannot_be_uploaded_to(server): + client, _ = server + + with pytest.raises(ValueError): + client.post( + "/users/Soad1337/saves/0100000000010000/revisions/MADE-UP-ID", + content=UPLOAD, + ) + + +def test_newest_completed_revision_wins(server): + client, work_dir = server + + first = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{first}", content=b"old" + ) + + second = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{second}", content=b"new" + ) + + # CURRENT_TIMESTAMP 는 초 단위라 같은 초에 두 개가 들어갈 수 있다. + # 순서를 확실히 하려고 첫 번째를 과거로 밀어둔다. + conn = sqlite3.connect(work_dir / "metadata.sqlite") + try: + conn.execute( + "UPDATE savedata SET created_at = datetime('now', '-1 hour') WHERE revision_id = ?", + (first,), + ) + conn.commit() + finally: + conn.close() + + assert client.get("/users/Soad1337/saves/0100000000010000/revisions").text == second + assert client.get("/users/Soad1337/saves").text == f"0100000000010000|{second}" + + +def test_same_second_uploads_still_resolve_to_the_newest(server): + client, _ = server + + # created_at 은 초 단위다. 연달아 올리면 세 개가 같은 시각을 갖는다. + # 손대지 않은 그대로 - 실제로 일어나는 모양 그대로 - 확인한다. + last = None + for payload in (b"first", b"second", b"third"): + last = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{last}", content=payload + ) + + # 개별 조회와 목록이 같은 답을 내야 한다. 다르면 클라이언트가 목록을 보고 + # 고른 리비전과 실제로 받는 파일이 어긋난다. + assert client.get("/users/Soad1337/saves/0100000000010000/revisions").text == last + assert client.get("/users/Soad1337/saves").text == f"0100000000010000|{last}" + + response = client.get("/users/Soad1337/saves/0100000000010000/revisions/latest/data") + assert response.content == b"third" + + +def test_a_pending_revision_does_not_hide_the_last_good_one(server): + client, _ = server + + good = client.post("/users/Soad1337/saves/0100000000010000/revisions").text + client.post( + f"/users/Soad1337/saves/0100000000010000/revisions/{good}", content=b"good" + ) + + # 백업이 시작됐다가 (스위치가 꺼지거나 해서) 끝나지 않은 상황. + client.post("/users/Soad1337/saves/0100000000010000/revisions") + + # 끝난 적 없는 리비전 때문에 멀쩡한 백업이 가려지면 안 된다. + assert client.get("/users/Soad1337/saves/0100000000010000/revisions").text == good + assert client.get("/users/Soad1337/saves").text == f"0100000000010000|{good}" + + +def test_asking_for_a_title_without_any_completed_revision_fails(server): + client, _ = server + + client.post("/users/Soad1337/saves/0100000000010000/revisions") + + with pytest.raises(ValueError): + client.get("/users/Soad1337/saves/0100000000010000/revisions")