From f78358f255ef012e9f9108c7a0c244cf9ff124c5 Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:50:06 +0200 Subject: [PATCH 1/8] - refactor: Extract sync logic from MainScreen into a sync module - feat: Skip titles whose save data has not changed since the last upload - feat: Give each account only the saves that belong to it The push / pull routines were lambdas inside MainScreen, tied to the GUI only by their log callback. Moving them into sync.{hpp,cpp} lets a headless caller reuse the exact same code path, so an app and a background service cannot drift apart. No behavioural change on its own. Change detection compares the newest mtime inside each save against saves/.syncstate and filters during the probe, so unchanged titles are not even archived. countChangedTitles() answers the same question without touching the network, which lets a caller decide whether there is anything to do before opening a socket. collectTargetTitles() is shared so that "nothing changed" and "nothing to upload" cannot disagree - they were separate copies of the same filter before. The console returns every save on the system when asked for a list, without separating them by user, and that was never filtered. So every account received every title and spent the round trying to open saves belonging to someone else. Mounting a save that does not exist also returned the same value as failing to mount one that does, so a healthy round always ended "with errors" - and a round that ends with errors never records the time it succeeded, which meant the next round packed and uploaded everything again from scratch. archiveAllSaveData() also could not report failures: it returned OK once it had walked the list, and per-title errors only reached a callback that always answered true. A server that was down still produced a return value of 0, which both callers read as success. Failures are counted and returned negatively; SAVEDATA_* codes are positive, so the two cannot be confused. --- client/source/fileio.cpp | 19 +- client/source/fileio.hpp | 5 + client/source/remote.cpp | 4 + client/source/remote.hpp | 11 ++ client/source/savedata.cpp | 8 +- client/source/savedata.hpp | 6 + client/source/sync.cpp | 387 +++++++++++++++++++++++++++++++++++++ client/source/sync.hpp | 64 ++++++ client/source/title.cpp | 40 +++- 9 files changed, 538 insertions(+), 6 deletions(-) create mode 100644 client/source/sync.cpp create mode 100644 client/source/sync.hpp 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&)> 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/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); } From fdcbb813b59881226a3fc4f2a3e95942fd1f42b9 Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:50:21 +0200 Subject: [PATCH 2/8] - feat: Verify the server certificate by default The credentials sit in the URL and libcurl offers them as an Authorization header on the first request. Without verification, anyone who answers the handshake receives the password in the clear. bcrypt protects the hash at rest on the server; it does nothing for this leg. Let's Encrypt validates against the console's built-in list - ISRG Root X1 has been there since 10.1.0 - so the common case needs nothing extra. For a private CA there is sdmc:/uNSS/cacert.pem: this curl uses the libnx SSL backend, which passes CAINFO to sslContextImportServerPki, so a single file can make the console trust a root the firmware never shipped. That is the first thing to try; turning verification off is the last resort, because its failure mode looks like "cannot connect" rather than "certificate refused". Failures now carry the underlying result alongside the -1. That -1 covered "server said 400" and "never connected" with one value, which is what made a DNS problem take a day to find. --- client/source/http.cpp | 44 ++++++++++++++++++++++++++++++++++++++++-- client/source/http.hpp | 14 ++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) 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); From 9685526bdb6fbb6b08296a44d2b7dc838f1bd6ea Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:51:41 +0200 Subject: [PATCH 3/8] - feat: Add background sysmodule for unattended save backup An NRO only runs while it is open - start a game and it is gone - so unattended backups need a sysmodule. This one boots with the console and stays resident, waking every five minutes to ask whether any save data changed since the last upload. If nothing changed it does nothing at all, not even opening a socket, which is the normal case and costs a few file reads. When a game ends it checks straight away rather than waiting out the interval, since the moment after someone saves and quits is the best time to copy a save. While a game runs it stays off the save files entirely. Backing up at boot instead, which the first version did, sounds reasonable and works badly: a Switch is closed, not switched off, so reboots are weeks apart and the module would finish before the first game even started. Config is re-read every round, so editing config.ini takes effect without a reboot. Shared sources are symlinked from client/source rather than copied, so both binaries always build from the same code. build-all.sh builds the module first and refreshes the copy the app embeds; it also drops the NRO, because the NRO rule depends on the elf and the nacp but never on romfs contents - without that, fixing the module and running make produced an app that still installed the previous one. Notes from bringing this up on hardware (22.5.0 / Atmosphere 1.11.2): - The program id must sit in the custom range. 0x0100000000554E53 was never loaded by boot2 at all; 0x4200000000554E53 works. - Services must be opened in __appInit before smExit(). But opening network, ns and account there runs alongside HID, which then failed to get resources and died with 2001-0132, boot-looping the console. They move to initLateServices() after a 30 second grace period, and the session is kept rather than released - sfdnsres has no initialize function and opens itself per request, so closing the session disabled getaddrinfo while the log still said "network ready". - INNER_HEAP_SIZE is 1 MiB, measured rather than guessed. Sysmodules share one small pool and a static heap leaves it the moment the module loads. 6 MiB starved hid, 2 MiB killed am. 256 KB is too tight in the other direction: a round peaking at 189 KB with a roomy heap peaked at 245 KB with a tight one, and compression, title lookup and upload failed at once. - NsApplicationControlData is 0x24000 bytes and the main thread stack is 16 KB, so asking for a title name overflowed it in the function prologue. Since the module can take the console down, it does not rely on being correct. It parks its own boot2.flag before anything risky and restores it only after a clean round, so a crash costs one error screen rather than a boot loop. Crashes are attributed by program id - ours land in crash_reports, a dying system process in fatal_reports, and the two real incidents landed in different directories. A crash during a backup pauses in growing steps (5, 20, 80 min, capped at two hours) and then retries on its own, because nobody watches a console for notifications and a module that switched itself off would stay off. Only a crash during startup, where retrying means a boot loop, still stands down. --- .gitignore | 10 + build-all.sh | 46 ++ client-sysmodule/Makefile | 222 +++++ client-sysmodule/README.md | 6 + client-sysmodule/config.json | 175 ++++ client-sysmodule/deploy.sh | 53 ++ client-sysmodule/source/account.cpp | 1 + client-sysmodule/source/account.hpp | 1 + client-sysmodule/source/fileio.cpp | 1 + client-sysmodule/source/fileio.hpp | 1 + client-sysmodule/source/http.cpp | 1 + client-sysmodule/source/http.hpp | 1 + client-sysmodule/source/ini.hpp | 1 + client-sysmodule/source/main.cpp | 1124 ++++++++++++++++++++++++++ client-sysmodule/source/miniz.c | 1 + client-sysmodule/source/miniz.h | 1 + client-sysmodule/source/remote.cpp | 1 + client-sysmodule/source/remote.hpp | 1 + client-sysmodule/source/savedata.cpp | 1 + client-sysmodule/source/savedata.hpp | 1 + client-sysmodule/source/sync.cpp | 1 + client-sysmodule/source/sync.hpp | 1 + client-sysmodule/source/title.cpp | 1 + client-sysmodule/source/title.hpp | 1 + client-sysmodule/source/utils.hpp | 1 + client-sysmodule/source/zipio.cpp | 1 + client-sysmodule/source/zipio.hpp | 1 + 27 files changed, 1656 insertions(+) create mode 100755 build-all.sh create mode 100644 client-sysmodule/Makefile create mode 100644 client-sysmodule/README.md create mode 100644 client-sysmodule/config.json create mode 100755 client-sysmodule/deploy.sh create mode 120000 client-sysmodule/source/account.cpp create mode 120000 client-sysmodule/source/account.hpp create mode 120000 client-sysmodule/source/fileio.cpp create mode 120000 client-sysmodule/source/fileio.hpp create mode 120000 client-sysmodule/source/http.cpp create mode 120000 client-sysmodule/source/http.hpp create mode 120000 client-sysmodule/source/ini.hpp create mode 100644 client-sysmodule/source/main.cpp create mode 120000 client-sysmodule/source/miniz.c create mode 120000 client-sysmodule/source/miniz.h create mode 120000 client-sysmodule/source/remote.cpp create mode 120000 client-sysmodule/source/remote.hpp create mode 120000 client-sysmodule/source/savedata.cpp create mode 120000 client-sysmodule/source/savedata.hpp create mode 120000 client-sysmodule/source/sync.cpp create mode 120000 client-sysmodule/source/sync.hpp create mode 120000 client-sysmodule/source/title.cpp create mode 120000 client-sysmodule/source/title.hpp create mode 120000 client-sysmodule/source/utils.hpp create mode 120000 client-sysmodule/source/zipio.cpp create mode 120000 client-sysmodule/source/zipio.hpp 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/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 From 533142b572e3a1bfd81447130fd2763e02f37e7e Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:51:57 +0200 Subject: [PATCH 4/8] - feat: Install and manage the background service from the app - feat: Show the service log inside the app - feat: Automatic backup on launch, throttled by change rather than time Installing a sysmodule by hand means copying two files into a hex-named folder under atmosphere/contents - easy to get wrong. The module is embedded in the NRO's romfs and the app writes it out itself: "Install background service" copies exefs.nsp, creates the boot2 flag and records the bundled version; "Remove background service" deletes them again. An already installed but outdated module is updated silently on launch, while the first install stays a deliberate button press, since it starts a process at every boot. When the module has stood itself down after a crash, the app offers to re-enable it rather than leaving it stuck. The sysmodule has no screen, so finding out what it had been doing meant pulling the SD card or fetching the log over FTP - which is exactly what once delayed a diagnosis by a day, with the module failing every upload for hours and the only witness a file nobody could see. "Service log" follows the end of that file while new lines arrive and lets go as soon as you scroll up, because a view that jumps around cannot be read. Lines mentioning a failure are red. Only the last 500 lines are held in memory. With sync.autoPushOnLaunch the client starts pushing as soon as it opens. autoPushIntervalHours is a brake rather than a trigger and defaults to 0: what decides whether anything is uploaded is whether the save data changed, so backups happen several times a day while playing, or not for weeks. A fixed interval could only ever be wrong in one of the two directions. The timestamp is written only after a successful run, so a failed backup is retried on the next launch instead of counting as done. A running game keeps its save open, so an automatic push waits for it to end (pm:dmnt); the manual Push button stays unrestricted. pm:dmnt is opened before smExit, because the lazy open inside isGameRunning() failed afterwards and the fail-open path then reported "no game running" - which would have let a backup archive a save file the game still held open. remote.insecureSkipVerify is read here and passed to the HTTP layer. --- client/Makefile | 40 ++++- client/source/gui/LogScreen.cpp | 213 +++++++++++++++++++++++++++ client/source/gui/LogScreen.hpp | 44 ++++++ client/source/gui/MainScreen.cpp | 245 +++++++++++++++++-------------- client/source/gui/MainScreen.hpp | 13 ++ client/source/main.cpp | 11 ++ client/source/sysmodule.cpp | 219 +++++++++++++++++++++++++++ client/source/sysmodule.hpp | 101 +++++++++++++ 8 files changed, 776 insertions(+), 110 deletions(-) create mode 100644 client/source/gui/LogScreen.cpp create mode 100644 client/source/gui/LogScreen.hpp create mode 100644 client/source/sysmodule.cpp create mode 100644 client/source/sysmodule.hpp 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/gui/LogScreen.cpp b/client/source/gui/LogScreen.cpp new file mode 100644 index 0000000..d2da96e --- /dev/null +++ b/client/source/gui/LogScreen.cpp @@ -0,0 +1,213 @@ +#include "LogScreen.hpp" + +#include +#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/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/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(); + +} From 31c7edcad0dc242b4598c7be46ed2b2435b5be99 Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:52:10 +0200 Subject: [PATCH 5/8] - fix: Return the newest revision when timestamps collide - chore: Add tests for the revision transaction and the wire format created_at is CURRENT_TIMESTAMP and only has second resolution, so two uploads of the same title within one second get identical timestamps. ORDER BY created_at DESC then has no defined order, and SQLite was measured returning the oldest row - so restoring could hand back a previous save, and the save list could advertise a revision that is not the current one. Both queries were affected. GROUP BY title_id HAVING MAX(created_at) reduces each title to one row but never says which row, and returned the oldest for the same reason. Both now select the newest row explicitly and break ties on rowid, which grows with insertion order. Using one rule in both places matters: a disagreement between the list and the per-title lookup would mean the client downloads a different revision than the one it chose from the list. The tests cover this and the rest of the revision transaction - a pending revision must not hide the last completed one, a completed revision must not be writable again - plus the plain text wire format the C++ client parses by hand, where an empty list has to be an empty body rather than "[]". Both new revision tests fail against the previous queries, which is the point of them. Each test gets a fresh server in a temporary directory, so they neither touch nor need a real database. --- server/repository.py | 28 ++++-- server/requirements-dev.txt | 6 ++ server/tests/conftest.py | 43 +++++++++ server/tests/test_protocol.py | 108 ++++++++++++++++++++++ server/tests/test_revisions.py | 159 +++++++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 server/requirements-dev.txt create mode 100644 server/tests/conftest.py create mode 100644 server/tests/test_protocol.py create mode 100644 server/tests/test_revisions.py 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/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") From 863dfdaad27b2cba2a3272bc6d2a802b68cd0767 Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:52:20 +0200 Subject: [PATCH 6/8] - fix: Add missing uvloop dependency - fix: Run the server as a non-root user - feat: Declare the server port in the image - chore: exec uvicorn so signals reach the server main.py requests loop="uvloop" but the package was not listed in requirements.txt, so the Docker container crashed on startup. run-linux.sh now execs uvicorn, so signals reach the server rather than the wrapping shell - killing that shell used to leave uvicorn orphaned. Switching the container off root is not just a USER line: 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 - not merely the file - every write fails with "unable to open database file" even when the bind mount is correct. So /app is chowned at build time and the image drops to that account. UID and GID are build args (default 1000:100) and can also be overridden per deployment with the compose user: key; the mounted database and savedata directory have to belong to whichever is used. EXPOSE opens nothing on the host; it records the port in the image metadata, where reverse proxies read it. Traefik picks it up on its own as long as exactly one port is declared, so a deployment no longer needs a port label to keep in sync. Without either, Traefik builds no router and answers 404, which reads like a routing problem rather than a missing port. --- server/Dockerfile | 22 ++++++++++++++++++++++ server/requirements.txt | 3 ++- server/run-linux.sh | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) 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/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 From e8e5904dc24628d4cadddb0c4108004c096f5bb8 Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:52:29 +0200 Subject: [PATCH 7/8] - ci: Publish the server image to ghcr.io Builds server/Dockerfile on pushes to main and on version tags, for amd64 and arm64. Uses the automatically provided GITHUB_TOKEN, so no secret has to be configured. Images pushed to ghcr.io are private until their visibility is changed by hand. The owner name is lowercased before it reaches a tag - ghcr.io rejects upper case, and account names can contain it. There is deliberately no paths filter. 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 leaving it out is a rebuild on commits that change nothing in the image, which takes well under a minute; a missing release does not announce itself. --- .github/workflows/publish-server-image.yml | 82 ++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/publish-server-image.yml 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" From 8f0977cb6d35d3a8e8da6e988d9cc4178ff843f7 Mon Sep 17 00:00:00 2001 From: dmuiX <19862760+dmuiX@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:53:03 +0200 Subject: [PATCH 8/8] - docs: Document automatic backup, the background service and deployment - docs: Add compose file for running the server behind Traefik Covers the new [sync] keys, what the automatic push does and the two rules that bound it, and a section on the background service: how to install it from the app, what it reads, where it logs, and why restoring is deliberately not part of it. The memory section deliberately states no threshold. Ten module starts were logged on hardware: the run that took the console down had 4780 KB free in the system pool at its low point, while five runs that survived a full round reached 3448-3960 KB. Free memory does not separate them, so no figure can honestly be drawn from the data, and the text says that rather than quoting one that would look authoritative and be wrong. What the crashes had in common was fixed in code, not in free space. The advice that remains is qualitative - fewer resident sysmodules is more headroom - plus how to read the module's own pool samples to compare a console against itself before and after a change. compose.traefik.yml is for setups that already terminate TLS in a proxy: it joins an external network and publishes no port of its own. Access control is basicAuth rather than a login portal because the client cannot follow redirects, so anything redirect-based can never complete, while libcurl does send credentials taken straight from the URL. Getting the hash to Traefik is the awkward part, because a container label cannot be filled from a secret store - Compose resolves ${...} while parsing, long before a store injects anything, and Traefik discards a router whose middleware is invalid, so the service answers 404 rather than prompting. A helper container bridges that: it reads the credentials from its environment, hashes the password with bcrypt cost 12 and writes the middleware into Traefik's dynamic configuration, which Traefik reads on its own. It stays resident with a healthcheck that tests the file, so the server can depend on service_healthy rather than on it having run once, and it refuses to produce a middleware that lets everyone in. Also records which characters the password may contain, since it travels inside a URL in config.ini where @ splits the host off and # truncates the rest, and the ini parser strips quotes from both ends of a value. --- README.md | 558 ++++++++++++++++++++++++++++++++++++- server/compose.traefik.yml | 235 ++++++++++++++++ 2 files changed, 788 insertions(+), 5 deletions(-) create mode 100644 server/compose.traefik.yml 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/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