Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install test dependencies
run: sudo apt-get update && sudo apt-get install --yes libmbedtls-dev libzstd-dev
run: sudo apt-get update && sudo apt-get install --yes libmbedtls-dev libzstd-dev libcurl4-openssl-dev libjansson-dev
- name: Configure tests
run: cmake -S tests -B build/tests
- name: Build tests
Expand Down Expand Up @@ -47,6 +47,34 @@ jobs:
- name: Build
run: npm run build

home-storage:
name: Home Storage build and tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test in the .NET 10 SDK container stage
run: docker build --target test --tag switch-drive-home-storage-tests home-storage
- name: Smoke test the runtime container
run: |
mkdir -p /tmp/home-storage-library
docker build --target final --tag switch-drive-home-storage home-storage
docker run --detach --name home-storage-smoke --publish 18080:8080 --env SETUP_TOKEN=ci-only-placeholder --volume /tmp/home-storage-library:/library:ro switch-drive-home-storage
trap 'docker rm --force home-storage-smoke >/dev/null 2>&1 || true' EXIT
for attempt in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:18080/health; then break; fi
if [ "$attempt" = 30 ]; then docker logs home-storage-smoke; exit 1; fi
sleep 1
done
docker rm --force home-storage-smoke
trap - EXIT
- name: Validate Compose
working-directory: home-storage
env:
HOST_LIBRARY_PATH: /tmp
SETUP_TOKEN: ci-only-placeholder
CLOUDFLARE_TUNNEL_TOKEN: ci-only-placeholder
run: docker compose config --quiet

switch-client:
name: Switch client build
runs-on: ubuntu-latest
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ Large-file support requires HOS 4.0.0 or later.
active account in the current MVP.
- Browse **My Drive** and **Shared with me**, including nested folders and items
exposed through shared drives.
- Download files directly from Google to
- Add one or more self-hosted **Home Storage** providers, discover them on the
local network, and browse a private PC folder through the same download and
installation workflow.
- Download files directly from the selected provider to
`sd:/switch-drive/downloads/<task-id>/`; file data does not pass through the
pairing service.
- Download files of 4 GiB or more as native HOS concatenated files, preserving
one logical filename on the Switch while avoiding FAT32's per-file limit.
- Resume an interrupted download only after validating its Drive revision, ETag,
- Resume an interrupted download only after validating its provider identity, revision, ETag,
HTTP range, expected size, and checksum metadata; invalid partial data can be
restarted without appending a full response to it.

Expand Down Expand Up @@ -69,8 +72,10 @@ immediately and is retained after relaunch.
- **L/R:** change the main section.
- **A:** activate the highlighted card, select, or open.
- **B:** go back; on the main screen, focus the section menu.
- **X:** download the selected Drive file.
- **Y:** download and install the selected Drive file.
- **X:** download the selected remote file.
- **Y:** download and install the selected remote file.
- **ZL in Home Storage:** hide the selected catalog entry when the device token
has catalog-management permission. This changes only SQLite on the PC.
- **Y in Library:** delete the downloaded package after confirmation, without
uninstalling the game or deleting saves. Downloads without a managed installation
are removed from the list; installed items retain their installation record.
Expand Down Expand Up @@ -241,10 +246,18 @@ home network to the Internet. After configuring a custom domain and registering
`https://<host>/oauth/google/callback` in Google Cloud, deploy it with Wrangler
and use that public HTTPS origin as `service_url`.

## Run Home Storage

[`home-storage/`](home-storage/README.md) is an independent .NET 10/Docker
storage provider that exposes a host library folder read-only. It supports LAN
discovery, optional credentials, an SQLite catalog, and resumable HTTP range
downloads. It does not replace or depend on the Google Drive pairing service.

## Security and data handling

The client never stores Google refresh tokens. It stores only a console session
credential and account IDs in `sd:/switch-drive/state.json`. The server encrypts
The client never stores Google refresh tokens or a Home Storage password. It
stores the console session credential, account IDs, and revocable Home Storage
bearer tokens in `sd:/switch-drive/state.json`. The pairing server encrypts
refresh tokens using `TOKEN_ENCRYPTION_KEY` before writing them to PostgreSQL.
Do not commit `.env`, console state, logs, or Google OAuth credentials.

Expand Down
4 changes: 4 additions & 0 deletions home-storage/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/bin
**/obj
.env
.git
11 changes: 11 additions & 0 deletions home-storage/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Windows example only; choose your own existing library folder.
HOST_LIBRARY_PATH=E:/SwitchDrive
LIBRARY_PATH=/library
HOME_STORAGE_PORT=8080
DISCOVERY_PORT=8080
INSTANCE_NAME=My Home Storage
SCAN_INTERVAL_MINUTES=5
MAX_CONCURRENT_DOWNLOADS=4
SETUP_TOKEN=replace-with-a-long-random-one-time-token
# Optional; needed only with the tunnel profile.
CLOUDFLARE_TUNNEL_TOKEN=replace-with-your-cloudflare-tunnel-token
6 changes: 6 additions & 0 deletions home-storage/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.env
**/bin/
**/obj/
*.db
*.db-shm
*.db-wal
23 changes: 23 additions & 0 deletions home-storage/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore
WORKDIR /src
COPY HomeStorage.slnx ./
COPY src/HomeStorage.Api/HomeStorage.Api.csproj src/HomeStorage.Api/
COPY tests/HomeStorage.Tests/HomeStorage.Tests.csproj tests/HomeStorage.Tests/
RUN dotnet restore HomeStorage.slnx

FROM restore AS test
COPY . .
RUN dotnet test HomeStorage.slnx --no-restore --configuration Release

FROM restore AS publish
COPY src/HomeStorage.Api src/HomeStorage.Api
RUN dotnet publish src/HomeStorage.Api/HomeStorage.Api.csproj --no-restore -c Release -o /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir -p /data && chown -R app:app /data
USER app
EXPOSE 8080/tcp 8080/udp
ENTRYPOINT ["dotnet", "HomeStorage.Api.dll"]
4 changes: 4 additions & 0 deletions home-storage/HomeStorage.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Solution>
<Project Path="src/HomeStorage.Api/HomeStorage.Api.csproj" />
<Project Path="tests/HomeStorage.Tests/HomeStorage.Tests.csproj" />
</Solution>
84 changes: 84 additions & 0 deletions home-storage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Switch Drive Home Storage

Home Storage exposes a read-only host folder as a private Switch Drive provider. It is an independent .NET 10 Minimal API: SQLite stores the catalog and credentials, while file bytes are streamed directly from the bind mount and never pass through the existing pairing service, Worker, or Neon/Postgres.

## Architecture

- The ASP.NET Core process hosts the public API, server-rendered administration panel and UDP discovery responder.
- A background indexer walks the configured container directory at startup, every `SCAN_INTERVAL_MINUTES`, or when requested in the panel.
- SQLite and ASP.NET Core data-protection keys live in the writable `/data` volume; the library mount remains read-only.
- Public clients receive opaque catalog IDs. Relative paths are retained only inside SQLite and are revalidated against the configured root whenever a file is opened.
- The metadata pipeline exposes `IFileMetadataExtractor` for later CNMT/NACP/icon extraction without coupling package parsing to scans or downloads.

## Start with Docker

Docker is the only host dependency. From this directory:

```powershell
Copy-Item .env.example .env
# Edit .env. HOST_LIBRARY_PATH must point at an existing folder.
docker compose up -d
docker compose logs -f home-storage
```

Open `http://localhost:8080/setup`, enter the `SETUP_TOKEN`, and choose the administrator and Switch-library credentials. There are no default credentials. The host path is controlled by `HOST_LIBRARY_PATH`; the panel may select only `/library` or a subdirectory already mounted into the container.

The library is mounted with `read_only: true`. Hiding an entry changes SQLite only and never deletes a host file. Back up the `home-storage-data` Docker volume to preserve IDs, settings and device registrations.

## Configuration

| Variable | Default | Purpose |
|---|---:|---|
| `HOST_LIBRARY_PATH` | required | Host folder bound read-only to `/library`; for example `E:/SwitchDrive`. |
| `LIBRARY_PATH` | `/library` | Initial path inside the container. The panel can later select a descendant. |
| `HOME_STORAGE_PORT` | `8080` | HTTP and advertised LAN port. |
| `DISCOVERY_PORT` | `8080` | UDP broadcast discovery port. |
| `INSTANCE_NAME` | `Home Storage` | Name shown on the Switch. |
| `SCAN_INTERVAL_MINUTES` | `5` | Periodic scan interval. |
| `MAX_CONCURRENT_DOWNLOADS` | `4` | Bound on simultaneously open download streams. |
| `SETUP_TOKEN` | required | One-time bootstrap secret; it is never logged. |
| `CLOUDFLARE_TUNNEL_TOKEN` | unset | Token used only by the optional tunnel profile. |

Supported extensions are `.nro`, `.nsp`, `.nsz`, `.xci`, and `.zip`. New or changed files are hashed with SHA-256 using bounded memory. Symlinks and reparse points are skipped. Missing files become inactive; suppressed files stay hidden until restored by the administrator.

## Authentication

Protected access is the default. The Switch exchanges the configured library username/password once at `POST /api/v1/auth/token` and stores only the revocable bearer token. The administrator password, library password and bearer tokens are never stored in plaintext by the service. Anonymous mode allows listing and downloads but not hiding catalog entries.

Plain HTTP should be used only on a trusted LAN. Always use HTTPS when accessing the service over the Internet.

## Test locally

```sh
curl http://localhost:8080/health
curl http://localhost:8080/drive-health
curl -u switch-user:password -X POST http://localhost:8080/api/v1/auth/token
curl -H "Authorization: Bearer TOKEN" "http://localhost:8080/api/v1/catalog?parentId=root"
curl -I -H "Authorization: Bearer TOKEN" http://localhost:8080/api/v1/files/FILE_ID/content
curl -H "Authorization: Bearer TOKEN" -H "Range: bytes=1048576-2097151" -o part.bin http://localhost:8080/api/v1/files/FILE_ID/content
```

A valid range returns `206 Partial Content`, `Content-Length`, `Content-Range`, `Accept-Ranges`, `ETag`, and `Last-Modified`. Invalid ranges return `416`. Do not modify a file while it is being downloaded; a detected size or timestamp change schedules reindexing and prevents use of stale metadata.

Run tests entirely in Docker:

```sh
docker build --target test -t switch-drive-home-storage-tests .
```

## Cloudflare Tunnel / CGNAT

Create a remotely managed HTTP tunnel in Cloudflare and route its public hostname to `http://home-storage:8080`, then set the token in `.env` and run:

```sh
docker compose --profile tunnel up -d
```

Cloudflare Tunnel is outbound-only, so it works behind CGNAT without exposing an inbound router port. Require Home Storage authentication for a public hostname. Do not place an interactive Cloudflare Access login page in front of the Switch API, and never commit the tunnel token.
If `HOME_STORAGE_PORT` is changed, use that same internal port in the tunnel route.

## Switch integration

The Switch can enter a LAN address or public HTTPS hostname manually. “Detect on network” broadcasts `SWITCHDRIVE_HOME_DISCOVER_V1` over UDP 8080 and validates replies with `/drive-health`. Discovery is LAN-only and can be blocked by client isolation; manual configuration remains available.

The public API is documented in [`openapi.yaml`](openapi.yaml). NSP/NSZ metadata columns and an extractor interface exist for later CNMT/NACP/icon parsing; this release indexes only file metadata.
44 changes: 44 additions & 0 deletions home-storage/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
services:
home-storage:
build:
context: .
target: final
env_file:
- path: .env
required: false
environment:
LIBRARY_MOUNT_ROOT: /library
DATA_PATH: /data
SETUP_TOKEN: ${SETUP_TOKEN:?set SETUP_TOKEN in .env}
ports:
- "${HOME_STORAGE_PORT:-8080}:${HOME_STORAGE_PORT:-8080}/tcp"
- "${DISCOVERY_PORT:-8080}:${DISCOVERY_PORT:-8080}/udp"
volumes:
- type: bind
source: ${HOST_LIBRARY_PATH:?set HOST_LIBRARY_PATH in .env}
target: /library
read_only: true
- type: volume
source: home-storage-data
target: /data
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:${HOME_STORAGE_PORT:-8080}/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
restart: unless-stopped

cloudflared:
image: cloudflare/cloudflared:latest
profiles: ["tunnel"]
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-}
depends_on:
home-storage:
condition: service_healthy
restart: unless-stopped

volumes:
home-storage-data:
41 changes: 41 additions & 0 deletions home-storage/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
openapi: 3.1.0
info: {title: Switch Drive Home Storage API, version: 1.0.0}
paths:
/health:
get: {summary: Container health, responses: {'200': {description: Healthy or degraded}}}
/drive-health:
get: {summary: Discovery metadata, responses: {'200': {description: Home Storage identity}}}
/api/v1/auth/token:
post:
summary: Exchange library Basic credentials for a revocable bearer token
security: [{basicAuth: []}]
responses: {'200': {description: Token issued}, '401': {description: Invalid credentials}, '429': {description: Rate limited}}
/api/v1/catalog:
get:
summary: List children of an opaque folder ID
security: [{bearerAuth: []}, {}]
parameters:
- {in: query, name: parentId, schema: {type: string, default: root}}
- {in: query, name: cursor, schema: {type: string}}
- {in: query, name: limit, schema: {type: integer, minimum: 1, maximum: 200}}
responses: {'200': {description: Catalog page}, '401': {description: Authentication required}}
/api/v1/files/{fileId}/content:
parameters: [{in: path, name: fileId, required: true, schema: {type: string}}]
get:
summary: Stream a file with byte-range and If-Range support
security: [{bearerAuth: []}, {}]
responses: {'200': {description: Full file}, '206': {description: Partial content}, '404': {description: Unavailable ID}, '409': {description: File changed}, '416': {description: Unsatisfiable range}, '429': {description: Concurrency limit}}
head:
summary: Return metadata without a response body
security: [{bearerAuth: []}, {}]
responses: {'200': {description: Metadata headers}}
/api/v1/catalog/{id}:
delete:
summary: Suppress an entry without deleting the host file
security: [{bearerAuth: []}]
parameters: [{in: path, name: id, required: true, schema: {type: string}}]
responses: {'204': {description: Suppressed}, '403': {description: Missing scope}}
components:
securitySchemes:
basicAuth: {type: http, scheme: basic}
bearerAuth: {type: http, scheme: bearer}
Loading
Loading