# rdpfido-cloud — хаб RDP FIDO Cloud

Хаб — это сервер-посредник для шлюзов RDP FIDO, у которых нет белого адреса
(домашний ПК за провайдерским NAT, ноутбук в чужой сети, офис без
администратора). Шлюз сам держит исходящее соединение к хабу (link), а
клиенты приходят на хаб по обычному адресу `<gate_id>.g.example.com:443` —
хаб по имени находит нужный link и пересылает байты. Облако — опция: шлюз без
раздела `cloud` в `gate.json` не делает ни одного исходящего соединения и
работает как раньше.

Контракт между хабом, шлюзом и клиентом — [`docs/CLOUD-PROTOCOL.md`](../../docs/CLOUD-PROTOCOL.md),
почему так — [`docs/CLOUD-PLAN.md`](../../docs/CLOUD-PLAN.md). Тот же бинарь
годится и для облака Imobile, и для своего сервера (self-hosted).

## Что хаб видит и чего не видит

Хаб слушает один TCP-порт (443) и различает соединения по первым байтам:

| Первые байты | Что делает хаб |
|---|---|
| TLS ClientHello | читает SNI **без расшифровки**, находит link шлюза по имени `<gate_id>.<суффикс>`, открывает поток `Https` и пересылает байты как есть. TLS-сессия и закрепление отпечатка сертификата заканчиваются на шлюзе, как без облака |
| `RFC1` | родной протокол: рукопожатие (HMAC общего секрета + эфемерный ECDH P-256, дальше AES-256-GCM), затем link шлюза, туннель RDP по билету или привязка кодом |
| что-то другое | закрыть |

Хаб **не** знает ключей TLS шлюза, **не** видит FIDO-проверку, **не** видит
содержимое RDP (внутри туннеля — свой TLS и NLA). Он видит адреса, объёмы,
время и имя шлюза в SNI. Проверка ключа и решение «пускать или нет» всегда за
шлюзом: хаб не может открыть доступ сам.

Что хаб хранит:

- `state.json` — привязанные шлюзы (id, секрет link-а, имя машины, платформа,
  версия, метка, когда привязан, когда виден) и не использованные коды
  привязки. Файл с правами 0600, пишется атомарно.
- в памяти — билеты туннелей (объявляет шлюз при выдаче доступа, живут до
  `expiresAt`, не дольше 10 минут, до 4 туннелей каждый), счётчики неудачных
  рукопожатий (8 за 5 минут на адрес или id — отказ), открытые потоки.

## Установка на VPS (Debian/Ubuntu)

Сборка — на машине с GCC ≥ 10 и статическим OpenSSL 3 (см. `src/linux/Makefile`):

```sh
make -C src/linux cloud                # build/linux/rdpfido-cloud
make -C src/relay                      # src/relay/rdpfido-relay (реле FIDO Stream, UDP)
```

На сервере:

```sh
sudo sh src/cloud/install.sh build/linux/rdpfido-cloud
#  создаёт пользователя rdpfido-cloud, /var/lib/rdpfido-cloud, /var/log/rdpfido-cloud,
#  ставит /usr/local/bin/rdpfido-cloud, юнит и веб-кабинет в /usr/share/rdpfido-cloud/web,
#  пишет /etc/rdpfido-cloud/cloud.json (если нет)
sudoedit /etc/rdpfido-cloud/cloud.json
sudo systemctl enable --now rdpfido-cloud
sudo ufw allow 443/tcp                 # и 7443/udp для реле
journalctl -u rdpfido-cloud -f
```

Реле ставится рядом по [`src/relay/README.md`](../relay/README.md)
(`make -C src/relay install`); его адрес прописывается в `relays` конфига,
и шлюзы получают его в `welcome` — свой `streamRelays` в `gate.json` им
больше не нужен.

DNS: `A`-запись на хаб для `*.g.example.com` (суффикс из `domainSuffix`) и
для `tunnelHost`. Сертификат хабу не нужен: TLS он не терминирует.

Юнит `rdpfido-cloud.service` запускает хаб от пользователя `rdpfido-cloud` с
`CAP_NET_BIND_SERVICE` (порт 443 без root) и песочницей systemd; ему нужны
только два своих каталога.

## Конфигурация `/etc/rdpfido-cloud/cloud.json`

```json
{
  "port": 443,
  "hub": "Imobile Cloud",
  "domainSuffix": "g.example.com",
  "publicPort": 443,
  "tunnelHost": "hub.example.com",
  "tunnelPort": 443,
  "relays": [{"address": "relay-eu.example.com:7443", "name": "eu-1"}],
  "maxGates": 1000,
  "maxStreamsPerGate": 32,
  "stateDir": "/var/lib/rdpfido-cloud",
  "logDir": "/var/log/rdpfido-cloud"
}
```

- `domainSuffix` — обязателен: публичный адрес шлюза = `<gate_id>.<domainSuffix>`.
- `publicPort`, `tunnelHost`, `tunnelPort` — то, что шлюз сообщит клиентам
  (порт хаба снаружи и адрес для туннелей RDP; обычно тот же хост и 443).
- `bind` (необязательно) — один IP-адрес вместо всех (по умолчанию `[::]`
  двойным стеком, при отключённом IPv6 — `0.0.0.0`).
- `maxStreamsPerGate` — не больше 64 (предел протокола).
- `logDir` — журнал `rdpfido-cloud.log` (4 МБ, одна ротация); если каталог
  недоступен на запись — пишется в `stateDir`. Журнал дублируется в stderr,
  то есть в `journalctl -u rdpfido-cloud`.

Пример стартового файла печатает `rdpfido-cloud default-config`. Другой путь
к конфигу — `--config PATH` перед командой.

## Команды

Один бинарь, команды через локальный сокет `<stateDir>/control.sock`
(права 0660 пользователю службы; root тоже может):

| Команда | Что делает |
|---|---|
| `rdpfido-cloud serve [--stats N] [--verbose] [--port P]` | сама служба (её запускает systemd). Сводка в журнал раз в `N` секунд (300 по умолчанию, 0 — без сводки), `--verbose` — отладочный уровень |
| `rdpfido-cloud code [--label X] [--org ID]` | новый код привязки `XXXX-XXXX-XXXX`: 15 минут, одноразовый; с `--org` — внутри организации (шлюз привяжется к ней). Если служба не запущена — пишет прямо в `state.json` (под той же блокировкой) |
| `rdpfido-cloud user add\|passwd\|rm\|list` | учётные записи кабинета в self-hosted режиме (`users.json`) |
| `rdpfido-cloud sign --key K manifest.json` | подпись манифеста ключом владельца (см. «Кабинет») |
| `rdpfido-cloud gates` | таблица шлюзов: id, машина, платформа, версия, привязан, виден, подключён ли сейчас и откуда |
| `rdpfido-cloud unlink <gate_id>` | убирает шлюз; если он подключён — сначала шлёт ему `{"t":"unlinked"}`, и шлюз выключает облачный режим у себя |
| `rdpfido-cloud status` | живые link-и, потоки, билеты, счётчики |
| `rdpfido-cloud selftest [--verbose] [--cabinet]` | сквозной тест в одном процессе (ниже); `--cabinet` — только шаги кабинета |

## Как привязать шлюз

1. На хабе: `rdpfido-cloud code --label kitchen-pc` → `ABCD-EFGH-JKLM`.
2. На хосте: `RdpFidoGate.exe cloud link hub.example.com:443 ABCD-EFGH-JKLM`
   (Linux: `rdpfido-gate cloud link ...`). Шлюз соединяется кодом
   (kind Claim), хаб отвечает только если код жив — подставному хабу код не
   достаётся, а шлюз ничего не отдаёт подставному хабу. В ответ шлюз получает
   секрет link-а и свой публичный адрес, сохраняет раздел `cloud` в
   `gate.json` и перезапускает службу.
3. Служба шлюза держит link (kind Link); `rdpfido-cloud gates` показывает
   `connected yes <адрес>`. Клиенты подключаются к
   `<gate_id>.g.example.com` — как к обычному адресу шлюза; RDP идёт через
   туннель хаба по билету, поток FIDO Stream — напрямую или через реле.

Отвязка с любой стороны: `RdpFidoGate.exe cloud unlink` на хосте или
`rdpfido-cloud unlink <id>` на хабе.

## Проверка

- `rdpfido-cloud selftest` поднимает хаб на случайном порту и фальшивый шлюз
  в потоке (link, эхо потоков, один билет) и проверяет: привязку кодом и
  повторный отказ по использованному коду; link с полученным секретом и
  `welcome`; маршрутизацию TLS ClientHello по SNI с эхом байтов; туннель по
  билету и отказ при втором использовании; закрытие по чужому суффиксу,
  неизвестному шлюзу и мусору; ping/pong в обе стороны; 5 МБ через поток
  под окном кредитов без нарушений; `unlink` оператора; лимит 8 неудач за
  5 минут. Код выхода 0/1, по строке `PASS`/`FAIL` на шаг.
- В журнале хаба: `link up: gate <id> '<машина>' (windows 1.22.0) from <адрес>`,
  затем `https from <клиент> -> gate <id>, stream 1` и
  `tunnel from <клиент> -> gate <id>, stream 3 (ticket ...)`.
- `rdpfido-cloud status` — счётчики; сводка в журнале раз в `--stats` секунд:
  `links 3, streams 1, tickets 0, connections 5 | accepted ...`.
- Без привязанного шлюза `curl https://<id>.g.example.com/v1/hello` обрывается
  сразу (в журнале `gate <id> is not linked now`) — так и должно быть: хаб
  не отвечает за шлюз.

## Пределы

64 КБ и 10 с до рукопожатия; 64 потока на link (`maxStreamsPerGate`, по
умолчанию 32); окно 256 КиБ на поток в каждую сторону; 30 с на дослать
хвост полуоткрытого потока; 4096 соединений на процесс (`LimitNOFILE` в
юните 8192); 25 с тишины — `ping`, 75 с — разрыв link-а. Один процесс, один
поток, `poll()`: расход — одна запись AES-GCM на 16 КиБ, десятки шлюзов и
потоков на самом дешёвом VPS.

## Self-hosted

Всё выше и есть self-hosted: свой VPS, свой домен, свой `cloud.json`. Кабинет
ниже — тоже в этом бинаре и работает без AccountServer (учётные записи в
`users.json`); для собственного сервера он не обязателен — шлюз, привязанный
кодом без организации, работает как раньше, права остаются на нём.

## Кабинет: организации, участники, права, манифесты

Контракт — [`docs/CLOUD-CABINET.md`](../../docs/CLOUD-CABINET.md). Кабинет
живёт в том же процессе: JSON-API и веб-интерфейс на `adminBind:adminPort`
(по умолчанию `127.0.0.1:8790`) **обычным HTTP** — снаружи ставится nginx
с TLS (`proxy_pass http://127.0.0.1:8790;`). Порт наружу не открывать.

### Конфигурация

```json
"adminPort": 8790,
"adminBind": "127.0.0.1",
"accountServer": "http://192.168.1.168:7500",
"adminToken": "<случайная строка>",
"webDir": "/usr/share/rdpfido-cloud/web"
```

- `adminPort` — `0` выключает кабинет. `adminBind` — только IP-литерал.
- `accountServer` — **облачный режим**: вход по e-mail/паролю уходит на
  `POST /api/account/login` этого AccountServer, сессия раз в 10 минут
  проверяется `POST /api/account/me`, оттуда же берутся подписки: любая
  действующая подписка продукта `rdpfido` у владельца организации даёт тариф
  `pro`, иначе `free`. Паролей хаб не хранит. Пусто — **self-hosted**
  режим: учётные записи в `<stateDir>/users.json`, тариф `self`.
- `adminToken` — bearer «владелец всего»: `Authorization: Bearer <token>`
  даёт роль owner в каждой организации, без CSRF; для CLI и автоматики.
  `default-config` печатает случайный. Пустой — выключен.
- `webDir` — откуда отдаются `/cabinet/index.html|app.js|app.css`
  (`install.sh` кладёт их в `/usr/share/rdpfido-cloud/web`).

### Файлы состояния

| Файл | Что |
|---|---|
| `state.json` | как раньше, плюс у шлюза `orgId`, `groups`, `thumbprint` (из `hello`), последний манифест (`manifestJson`, `manifestVersion`, `manifestSignature`, `manifestKid`, `manifestAckedVersion`); у кода — `orgId` |
| `orgs.json` | организации (`name`, `ownerAccount`, `plan`, `policy`, `manifestVersion`, `signingKey` с закрытым ключом PEM при `holder: cloud`), их `members` и `access`; `credentials` — ключи FIDO **по учётной записи** (ключ принадлежит человеку, а не членству, и попадает в манифесты всех шлюзов, куда у него есть право) |
| `users.json` | self-hosted логины: `passwordHash` (PBKDF2, `common/Crypto`), `name` |
| `events/YYYY-MM-DD.jsonl` | журнал, строка = событие `{ts, orgId, actor, type, gateId?, member?, detail}`; файлы старше 90 дней удаляются |

Все три JSON пишутся атомарно с правами 0600 под той же блокировкой
`state.lock`; `users.json` — под `users.lock`, потому что его правит CLI при
работающей службе (служба перечитывает файл при каждом входе).

### Учётные записи и роли

- Self-hosted: `rdpfido-cloud user add <login> [--name X]` (пароль спросит,
  или переменная `RDPFIDO_PASSWORD`), `user passwd <login>`, `user rm
  <login>`, `user list`. Приглашение участника из кабинета с полем
  `password` тоже создаёт логин.
- При первом входе создаётся личная организация (владелец — сам аккаунт,
  тариф из подписки или `self`), с ключом подписи.
- Роли в организации: `owner` > `admin` > `auditor` > `user`. Шлюзы, участники
  и права — `admin+`; журнал — `auditor+`; статус и плитки — `user+`;
  организация целиком (переименовать, тариф, политика, ключ, удалить) —
  `owner`.
- Тарифы: `free` — 1 шлюз, 3 участника, без реле в `welcome`; `pro` — 20 /
  50 / реле; `self` — без ограничений. В облачном режиме тариф меняет только
  подписка (и adminToken), в self-hosted — владелец.
- Сессия: `POST /api/v1/login` возвращает `session` и `csrf`; дальше либо
  cookie `rfc_session=<session>` **плюс** заголовок `X-Rfc-Csrf: <csrf>` на
  всё, кроме GET, либо `Authorization: Bearer <session>` (без CSRF). Сессия
  12 часов, в памяти; `Set-Cookie` сервер не шлёт — cookie ставит `app.js`.

### API (`/api/v1/…`, JSON, ответы `{ok:true,…}` / `{ok:false,error,message}`)

| Путь | Кто | Что |
|---|---|---|
| `GET config` | все | `{mode: cloud\|self, hub, domainSuffix, rpId}` |
| `POST login {email,password}` · `POST logout` · `GET me` | — | сессия; `me` = аккаунт и организации с ролями |
| `GET me/gates` | user+ | плитки для клиента `[{gateId,label,publicHost,publicPort,thumbprint,role,orgName,orgId,connected,conditions}]` — по правам участника |
| `GET me/credentials` · `DELETE me/credentials/{id}` · `POST me/credentials/begin` · `POST me/credentials/finish` | user+ | ключи FIDO аккаунта; `begin/finish` — тот же обмен, что `/v1/enroll/*` шлюза (`rpId = rdp-fido-gate.local`), зовёт **клиент**, не браузер |
| `POST orgs {name}` · `GET/PATCH/DELETE orgs/{id}` | owner | `PATCH {name, plan, policy:{managedOnly, leaderLeft}}`; adminToken создаёт с `ownerAccount` |
| `GET orgs/{id}/gates` · `POST …/gates/code {label}` · `PATCH …/gates/{gid} {label,groups}` · `DELETE …/gates/{gid}` | admin+ | шлюзы (с `live`: подключён, откуда, потоки) и выданные коды |
| `POST …/gates/{gid}/control {participantId}` | admin+ | `{"t":"control","id":N}` шлюзу по link-у (0 = хост); `gate_offline`, если link-а нет |
| `GET …/gates/{gid}/manifest` · `POST …/manifest/signature {version,signature}` | admin+ | текущий манифест (объект, каноническая строка, подписан ли) и загрузка подписи владельца |
| `GET/POST orgs/{id}/members` · `PATCH/DELETE …/members/{acct}` · `DELETE …/members/{acct}/credentials/{cid}` | admin+ (owner для роли owner) | `POST {email, role, label, password?}` |
| `GET/POST orgs/{id}/access` · `DELETE …/access/{aid}` | admin+ | `POST {memberAccount, target: gateId\|"group:<имя>", role: lead\|watch, conditions}`; повтор той же пары участник+цель заменяет правило |
| `GET orgs/{id}/events` · `POST …/events/query {from,to,type,limit}` · `GET …/events.csv` | auditor+ | журнал (новые первыми, до 1000) |
| `GET orgs/{id}/status` | user+ | подключённые шлюзы и их потоки |
| `GET orgs/{id}/key` · `POST …/key/export {password}` | admin+ / owner | ключ подписи; экспорт отдаёт закрытый ключ PEM (PKCS#8, AES-256) **один раз** и переводит `holder` в `owner` |

Условия доступа (`conditions`) проверяются по форме: `days` 1..7, `from`/`to`
`HH:MM`, `tz` — имя вида `Europe/Moscow`, `countries` — ISO alpha-2,
`until` — 0 или в будущем, `rdp`/`stream`/`files` — булевы. Их применяет
шлюз, хаб только хранит и доставляет.

### Манифесты

На каждый шлюз организации хаб держит манифест (`cloud/Manifest.{h,cpp}`,
общий код с шлюзом): версия организации, политика, список ключей участников
с правом на этот шлюз (прямое правило сильнее группового) с ролью и
условиями. Каноническая форма — `BuildManifestJson().dump(-1)`; подпись —
ECDSA P-256/SHA-256 ключом организации, base64url, DER. Хаб шлёт
`{"t":"manifest","manifest":{…},"signature":…,"kid":…}` при `hello` и при
каждом изменении (права, участник, ключ, группы шлюза, политика); шлюз
отвечает `manifestAck`/`manifestReject`, оба попадают в журнал. Открытый
ключ организации шлюз получает в `claimed` (`orgKey`).

Где закрытый ключ: `holder: cloud` (по умолчанию) — в `orgs.json`, хаб
подписывает сам; `holder: owner` — владелец забрал ключ (`key/export`),
после чего каждый новый манифест лежит неподписанным (`GET …/manifest`,
`signed:false`), шлюз работает по старому, а владелец подписывает:

```sh
rdpfido-cloud sign --key org-<id>-signing.key manifest.json   # → {"version","kid","publicKey","signature"}
```

и загружает `signature` в `POST …/manifest/signature` (или в кабинете,
раздел «Настройки → Неподписанные манифесты»).

### Проверка

`rdpfido-cloud selftest` после шагов хаба гоняет кабинет (`--cabinet` —
только его): self-hosted вход, личная организация, CSRF, тариф `free`, код
→ claim с `orgKey`, `welcome` без реле, лимит второго шлюза, приглашение
с паролем, регистрация ключа через `credentials/begin|finish` (синтетический
attestation, как у шлюза), право с условиями → манифест на link-е, проверка
подписи и подделки, `manifestAck`, плитки `me/gates`, `control`, отзыв →
манифест без ключа, журнал и CSV, adminToken, экспорт ключа и подпись
владельца, `manifestReject`, отвязка, выход; затем облачный режим против
макета AccountServer (вход, `pro` из подписки).
