# Self-hosting your own hub

This guide is for someone who wants to run their **own** Windrose Hub instance — replacing the one at `windrose.certifriedmultitool.com` — so their own users (or just themselves) can manage Windrose dedicated servers from a cloud dashboard.

If you only want to use the hub I run, see [USER_SETUP.md](USER_SETUP.md) — you don't need this guide.

---

## What is the hub?

A small ASP.NET Core 8 web app. Two responsibilities:

1. **A multi-tenant dashboard** at the root URL — sign-up / sign-in (email/password or Discord OAuth), per-user list of agents (each agent = one Windrose dedicated server), per-agent event log, RCON console, restart buttons.
2. **An agent endpoint** at `/api/agent` — outbound WebSocket connection from each Windrose Server Manager. Bidirectional: agents push events upward, hub pushes commands downward (restart, RCON exec, start/stop).

Agents authenticate with bearer tokens (SHA-256 hashed in the DB; plaintext shown to operator once on creation). Users authenticate with cookies (PBKDF2 password hashes, 30-day sliding sessions). Discord OAuth is optional.

State lives in a single SQLite file (`hub.db`) — Users, Agents, EventLog tables. Easy to back up (just copy the file).

Source: `WindroseServerManager.HubServer` project in this repo (under [Windrose-ServerManager-Hub](#)).

---

## What you'll need

- A Linux box with:
  - .NET 8 runtime (or self-contained build — your pick)
  - A reverse proxy that handles TLS + WebSocket upgrade (nginx, Caddy, OpenLiteSpeed, Traefik)
  - A domain name (and ability to point it at the box)
  - About 100 MB disk for the published binaries + DB
- (Optional) A Discord application for OAuth — register at https://discord.com/developers/applications

Memory footprint: ~40–60 MB resident under normal load.

---

## Reference deployment (what I run)

My production hub runs on:

- **OS**: Ubuntu 24.04 LTS
- **Runtime**: .NET 8 (apt-installed, framework-dependent)
- **Reverse proxy**: OpenLiteSpeed 1.8.5 (via Cyberpanel)
- **Cert**: Let's Encrypt (Cyberpanel-managed renewal)
- **Service manager**: systemd
- **Hub bound to**: `127.0.0.1:9090` (loopback only, OLS proxies to it)

Your setup will likely differ. The shape of the work is the same regardless:

```
┌──────────────────┐           ┌──────────────────┐         ┌──────────────────┐
│  Internet        │ ── 443 ── │  Reverse proxy   │ ── ─ ── │  Hub :9090       │
│  (browsers,      │           │  (TLS termination│         │  ASP.NET Core 8  │
│   manager agents)│           │   + WebSocket    │         │  SQLite hub.db   │
└──────────────────┘           │   upgrade)       │         └──────────────────┘
                               └──────────────────┘
```

---

## Step-by-step

### 1. Build and copy the hub

On your dev machine:

```bash
git clone <hub-repo>          # Windrose-ServerManager-Hub
cd Windrose-ServerManager-Hub
dotnet publish WindroseServerManager.HubServer \
  -c Release -r linux-x64 --no-self-contained \
  -o ./publish-linux

# Strip dev configs that aren't supposed to leave your machine
rm -f publish-linux/appsettings.Local.json publish-linux/appsettings.Development.json

# Tar + scp
tar czf hub-publish.tar.gz -C publish-linux .
scp hub-publish.tar.gz user@your-server:/tmp/
```

On the server:

```bash
sudo mkdir -p /opt/wsm-hub/app /opt/wsm-hub/data
sudo tar xzf /tmp/hub-publish.tar.gz -C /opt/wsm-hub/app
sudo chown -R wsm:wsm /opt/wsm-hub      # if you created a dedicated user
```

### 2. Production config

Create `/opt/wsm-hub/app/appsettings.Production.json` (mode `600`, owned by the service user):

```json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Urls": "http://127.0.0.1:9090",
  "ConnectionStrings": {
    "Hub": "Data Source=/opt/wsm-hub/data/hub.db"
  },
  "Discord": {
    "ClientId": "OPTIONAL — your Discord app's client ID",
    "ClientSecret": "OPTIONAL — your Discord app's client secret"
  }
}
```

Notes:
- `Urls` — bind to loopback only. The reverse proxy will be the only thing reaching it.
- `ConnectionStrings.Hub` — SQLite path. The hub creates the DB on first run; just make sure the directory exists and is writable by the service user.
- `Discord` — leave both fields empty / remove the section to disable Discord OAuth (email/password sign-up still works).

### 3. systemd service

`/etc/systemd/system/wsm-hub.service`:

```ini
[Unit]
Description=Windrose Server Manager Hub
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=wsm
Group=wsm
WorkingDirectory=/opt/wsm-hub/app
ExecStart=/usr/bin/dotnet /opt/wsm-hub/app/WindroseServerManager.HubServer.dll
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
Restart=on-failure
RestartSec=5
KillSignal=SIGINT
SyslogIdentifier=wsm-hub
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/wsm-hub/data /opt/wsm-hub/app
ProtectHome=read-only
PrivateTmp=true

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now wsm-hub.service
sudo systemctl status wsm-hub.service
journalctl -u wsm-hub.service -f
```

You should see "WSM Hub listening — UI at root, agent ws at /api/agent" and "Now listening on: http://127.0.0.1:9090".

### 4. Reverse proxy

Two paths must be proxied:

1. `/` → HTTP proxy to `127.0.0.1:9090` (the dashboard, login pages, OAuth callback, etc.)
2. `/api/agent` → **WebSocket** proxy to `127.0.0.1:9090` (the agent endpoint)

The proxy must:
- Terminate TLS (Let's Encrypt or any cert)
- Forward `X-Forwarded-Proto: https` so the hub knows the public URL is HTTPS — critical for OAuth redirect URI generation
- Honor the WebSocket Upgrade handshake (HTTP 101 Switching Protocols)

#### Example: nginx

```nginx
server {
    listen 443 ssl http2;
    server_name hub.example.com;

    ssl_certificate     /etc/letsencrypt/live/hub.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/hub.example.com/privkey.pem;

    # WebSocket upgrade for the agent endpoint
    location /api/agent {
        proxy_pass http://127.0.0.1:9090;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 1d;     # WebSockets are long-lived
        proxy_send_timeout 1d;
    }

    # Everything else: regular HTTP proxy
    location / {
        proxy_pass http://127.0.0.1:9090;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

#### Example: Caddy

```
hub.example.com {
    reverse_proxy 127.0.0.1:9090
}
```

(Caddy auto-handles WebSocket upgrade and HTTPS.)

#### Example: OpenLiteSpeed (Cyberpanel)

See `deploy/vhost-windrose.conf` in the [hub repo](#) for the full vhost.conf I use. Two key blocks (in addition to standard Cyberpanel boilerplate):

```
extprocessor wsmhubbackend {
  type                    proxy
  address                 127.0.0.1:9090
  maxConns                100
  initTimeout             60
  retryTimeout            0
  respBuffer              0
}

context / {
  type                    proxy
  handler                 wsmhubbackend
  addDefaultCharset       off
}

websocket {
  uri                     /api/agent
  address                 127.0.0.1:9090
}
```

The `websocket` block must be **at vhost level** (not inside the listener) for OpenLiteSpeed 1.8+. The `context /` catch-all and the `websocket` block coexist — OLS routes `/api/agent` through the WebSocket pipeline and everything else through the HTTP proxy.

After editing, reload OLS: `sudo /usr/local/lsws/bin/lswsctrl restart`.

### 5. Discord OAuth (optional)

If you want users to sign in with Discord:

1. Go to https://discord.com/developers/applications → New Application
2. Pick a name (shown to users on the OAuth consent screen)
3. Under **OAuth2 → General**:
   - Note the **Client ID** and **Client Secret** — these go in `appsettings.Production.json`
   - Under **Redirects** add: `https://hub.example.com/auth/discord/callback` (use your domain)
4. Restart the hub: `sudo systemctl restart wsm-hub`

The login + signup pages will now show a "Sign in with Discord" button. The Account page (signed-in only) lets users link/unlink Discord and merge duplicate accounts.

### 6. DNS and certificate

Point a DNS A record for your hub's hostname (e.g. `hub.example.com`) at the server's public IP. For Let's Encrypt HTTP-01 validation the record needs to be **DNS only** (not Cloudflare-proxied) at the moment of issuance — you can flip to proxied after the cert is issued.

If using Cyberpanel: `cyberpanel issueSSL --domainName hub.example.com`. For other setups: `certbot certonly --webroot -w /var/www/html -d hub.example.com` (or DNS-01 if HTTP isn't reachable yet).

### 7. Verify

- Visit `https://hub.example.com/` — landing page renders
- Click Sign up, create an account, click "Add server", note the token
- On the manager box, paste that token into `appsettings.Local.json`'s `Hub.AgentToken` and set `Hub.Url` to `wss://hub.example.com/api/agent`, restart the manager
- Refresh the hub dashboard — your server should appear with "online" status
- Click into the agent → click "Send RCON" with `info` — within ~1 second the response shows up in the recent events table

If the manager log shows `The 'Connection' header value 'Keep-Alive' is invalid` — your reverse proxy isn't doing the WebSocket upgrade. Re-check the WebSocket section of the proxy config.

---

## Operations

| Task                            | How                                                                              |
|---------------------------------|----------------------------------------------------------------------------------|
| View logs                       | `journalctl -u wsm-hub -f`                                                       |
| Restart                         | `sudo systemctl restart wsm-hub`                                                 |
| Backup the DB                   | `cp /opt/wsm-hub/data/hub.db /opt/wsm-hub/data/hub-$(date +%F).bak`              |
| List users (sqlite shell)       | `sqlite3 /opt/wsm-hub/data/hub.db "SELECT Id,Email,DiscordId FROM Users;"`       |
| Delete a user                   | `sqlite3 /opt/wsm-hub/data/hub.db "DELETE FROM Users WHERE Id=N;"` (cascades)    |
| Delete an agent                 | Use the dashboard's Delete button, or `DELETE FROM Agents WHERE Id=N;`           |
| Update                          | rebuild + scp + extract over `app/` + `systemctl restart wsm-hub`                |

The DB schema auto-creates on first run (EF Core `EnsureCreated`). Schema migrations between hub versions are not yet automated — for now back up `hub.db` before upgrading.

---

## Hardening notes

- The systemd unit above runs the hub as a non-root user, with `ProtectSystem=strict` (read-only `/etc`, `/usr`, etc.) and `ReadWritePaths` whitelisting only the data dir + app dir. This means even an exploited hub can't write outside its own directories.
- Agent tokens are SHA-256 hashed in the DB. The plaintext token is shown to the operator on agent creation and never persisted in plaintext.
- User passwords are PBKDF2-HMAC-SHA256 with 100k iterations + 16-byte salt. `CryptographicOperations.FixedTimeEquals` for the comparison.
- WebSocket auth uses `Authorization: Bearer <token>` on the upgrade request — a browser can't initiate this (browsers can't set custom headers on `new WebSocket()`), so the WS endpoint is effectively only usable by the .NET manager agent.
- The agent endpoint hard-fails on missing/invalid tokens before accepting the WebSocket — no half-connected sockets.

If you expose the hub at a high-value URL or run it for many users, consider adding:
- `fail2ban` rules for failed sign-in attempts
- Rate limiting on the auth endpoints (nginx `limit_req` or similar)
- A separate read-only DB replica for analytics if you want them
- DataProtection key encryption (the hub currently stores cookie-signing keys unencrypted on disk; fine for single-tenant, worth hardening for multi-admin setups — see [ASP.NET Core Data Protection docs](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview))

---

## Source

The hub is a separate repo: `Windrose-ServerManager-Hub`. The wire protocol between hub and agent is documented in `WindroseServerManager.Hub/HubProtocol.cs` (in this repo, since the agent ships in the manager). Both sides versioned via `HubProtocol.Version` (currently `1`) — handshake fails on mismatch.
