SimpleTwo · Control Plane

First-run setup

Paste the one-time bootstrap token from the server logs (or /data/bootstrap-token), then choose an admin password.

Sign in

Gateways
healthy / total
Active connections
client sessions
GEO zones
configured
TLS
Host load
1 min avg

Fleet

Every host and what is wrong with it. Counts of things that exist answer a question nobody has at 3am; this answers the one they do.
HostAddressRoleVersionsStatusReportedNote

Gateways

IDGEOGroupAddressLoadStatus

Click a gateway for load & connection history.

Create GEO

Server provisioning (gateways, messaging, Postgres) now lives on the Servers & deploys tab.

Gateway groups

Egress classes for gateways. Mark those that reach the corporate network as corporate; then simply don't add them to guest/partner profiles' allowed gateways. A corporate group with a served whitelist below becomes a DMZ — its gateways forward ONLY these internal destinations and drop the rest (ADR-0006). Leave blank for an open corporate egress.
GroupTypeDescription

GEOs

CodeNameCountryGroup

Gateways

IPv6 marks a gateway whose host can egress IPv6. Clients capture IPv6 into the tunnel either way — leaving it outside was a leak — and drop it unless a gateway they selected is marked here, so a v6-only destination fails at once instead of stalling.
IDGEOGroupAddressLoadIPv6StatusActions

Rule lists

IDKindEntriesVer

New list

Profile

nameonactionmatch byvalue→ GEO

Preview routing

Version history

VersionDefaultRules

Groups / roles → profile

Maps a user role/group to a routing profile and the gateway groups it may use. Corporate egress is separated simply by NOT ticking corporate gateway groups for guest/partner.
Group / rolePriorityProfileAllowed gateway groups

Installed servers

What is actually running, one row per host, grouped by role — click a role to fold it. Hosts install their own software and update themselves; Settings changes what a host should be and reaches it on its next poll, and the menu holds its log, the configuration it wrote, and decommissioning.
Host nameAddressGEOLoad / RAM / diskTLSStatusActions

Add a host

How to lay this out

Two private networks, and the split matters. Network B holds the database: only postgres and messaging are on it. Network A holds the SFU pool: redis and the livekit nodes. Keeping them apart is what makes “an SFU cannot reach the database” a fact about routing rather than a firewall rule somebody has to keep true — an SFU is a large third-party binary listening on the public internet, and it has no business sharing a network with the database. The coordinator has a leg in each; nothing else spans both.

HostPublic PrivateWhy
postgres B only messaging connects; nothing reaches it from outside
messagingyes B clients talk to it, it talks to the database
redis A the SFU pool’s coordination bus; no client ever touches it
livekit ×2yes A media and signalling are public; the bus is reached privately
turnyes a relay talks to clients and to nothing of ours
gatewayyes DMZ only for corp egress see ADR-0006; a geo gateway needs no private leg
support DMZ, beside the coordinator the support bot's daemon (ADR-0031): inbound only from the messaging host (signed webhooks on :8087), egress to messaging and to GitLab; no client and nothing public ever reaches it, and it holds no model-provider key — model calls go to messaging's AI proxy inside the perimeter

The one rule that silently breaks calls. A private address must never be advertised to clients. “Private address” goes in the host’s private address field; the address clients use goes in node IP for an SFU. Put a private address in the second one and every call connects, carries no media and times out with nothing in any log.

Order. 1) postgres → 2) messaging (then redeploy postgres once: the access rule is narrowed to the messaging host’s /32 and cannot be written before that host exists) → 3) redis → 4) livekit node A → 5) livekit node B → 6) turn. A second SFU node without redis is refused: two nodes without the bus each keep their own room table, so two people “in the same room” on different nodes cannot see each other and nothing reports a problem.

Sizing to start. postgres 2 vCPU / 4 GB / 40 GB SSD · messaging 2 vCPU / 4 GB (attachments live on its disk until S3 exists) · redis 1 vCPU / 1 GB (it holds room routing, not data) · each livekit 4 vCPU / 8 GB and real bandwidth — an SFU forwards every stream to every participant, so egress is the limit long before CPU · turn 2 vCPU / 2 GB, bandwidth-bound for the same reason · support 1 vCPU / 1 GB / 10 GB — two small Go daemons and a JSON state file; the thinking happens on the model provider's side, so it never grows with load the way media hosts do. Deploy it last: it needs messaging live and the Support provisioning (Settings) run, in either order.

Ports the deploy opens for you (host firewall, when ufw or firewalld is present): livekit 7880/tcp, 7881/tcp, 50000-60000/udp · turn 3478/udp+tcp and 49152-65535/udp · messaging its listen port, plus 443 when it terminates its own TLS. Redis is the exception: 6379 is opened only to the SFU nodes’ addresses, never to the world, and on a host with no firewall tooling the deploy fails rather than leave a database reachable.

Certificates. messaging obtains its own (autocert) for the hostname you set under Settings. An SFU needs TLS in front of 7880 for iOS to connect at all, and an enrolled host no longer needs you to arrange that: the coordinator runs the ACME order and the host serves the one challenge file, because the name points at the host while the account key stays here. The edge front then terminates 443 and proxies every path to 7880 — every path, since moderation arrives on the same host and port as the client’s WebSocket, and a front that forwards only the socket breaks mute and remove while calls still look fine.

Add a host

The host joins by running one command on itself (ADR-0025) — no SSH key here, no inbound reachability needed, so a machine in a private network works the same as a public one. The invitation is single-use and expires in an hour; the host exchanges it for its own credential and then pulls what its role needs.
SSH key (only needed for "run it for me")
Used once and forgotten — it is never stored. Leave empty to use the coordinator's own key.
The token is not stored — only its hash — so this command cannot be shown again. Generate another if it is lost.

Provisioning log

One row per host, newest attempt first; a +N marks how many earlier attempts that host had, whose history is in this row’s log. Hosts that pull their state show Settings instead of a redeploy, because there is nothing to push to them.
RoleIDHostGEOVersionStatusAgent seen

Active sessions

UserDeviceGatewayExpires
CPU load
Memory
Disk
Uptime

Host logs

Asks a host for the last lines of one of the units it installed. Pulled on request rather than streamed, so an idle fleet costs nothing; the host caps the size and removes secrets before sending. The answer arrives on its next poll — usually within a minute.
pick a host and a unit

Coordinator logs

Client sessions

Per-client monitoring — SW version, live per-location quality (traffic light), error count and online status. Aggregated from client telemetry + active sessions.
UserDeviceVersionQuality (per location)ErrorsLast seen

Client diagnostics

Recent client-reported events (connect lifecycle, errors, connection metrics). Newest first.
TimeUserLvlEventMessageGEO/GWApp/OS

Extended client debug

Remotely turn on verbose xray logging and log streaming for a specific device or employee — no app update needed. Takes effect on the client's next connect. Use the device id / username shown in Client diagnostics above.
Global (all clients): ·

Audit log

TimeActorActionDetail

Users

UsernameNameEmailSourceRolesGroupsInvited byExpiresStatusActions

Bots

Non-human identities (ADR-0014/0021), authenticating to messaging with a scoped bot token. A service bot belongs to the tenant. A personal bot belongs to one user and is one of two things: an assistant — a session of that person, which reads what they read and writes in their name — or a worker, which acts under its own name and sees only the conversations it was added to. The owner is shown beside every personal bot, which is what makes free naming safe. Bots hold no admin role — a bot cannot sign in, so a role would be enforced nowhere; scopes and the conversation allowlist are what grant one anything. Rotating a token keeps the owner, the entity and the fence. Whether an assistant may act for its owner at all is a tenant-wide switch, in Settings → Acting on behalf of a user; with it off the tenant has no assistants, only workers.
UsernameNameKindGrantsWebhookStatusActivityLast seenCreatedActions

Automations

Server-side rules (ADR-0014): a trigger (bot mention / keyword / schedule / webhook) runs linear steps; a step with an approve gate pauses until an allowed human reacts ✅ in the chat.
NameTriggerStepsEnabledActions

Recent runs

AutomationStateStepUpdatedError

Agent activity

Everything non-humans did — bot mutations, automation runs, approvals — merged from the coordinator and messaging audit trails.
TimeActorActionDetail

Roles

RolePermissionsDirectory

Single sign-on (OIDC)

Loading…

Groups

Directory groups grant roles to their members (user → groups → roles → permissions). Assign members here or on the user; map a group to a routing profile in Routing → Groups.
GroupDescriptionRolesActions

Client apps

What the Dashboard and the sign-in screen offer people as a way to install SimpleTwo. Store links go to TestFlight / Google Play; the rest are builds hosted here, in the coordinator's data volume. Both the list and the downloads are public — the person who needs a client cannot sign in from one yet.
PlatformPublished
The mac-v* and android-v* pipelines POST the build they just signed to <public-url>/v1/clients/<platform> with this token. It authorises that one thing and nothing else — not an admin session, which is what a pipeline holding admin credentials would be. Rotating stops the pipelines publishing until the CI variable is updated.

Product options

VPN is the base — always on. Messaging and Conferencing are options: switch them per tenant and choose HOW they are reached — directly over TLS (works without the VPN) or through the tunnel only. Clients hide disabled modules.
ModuleEnabledAccess
VPNbasetunnel
Messaging
Conferencing

Acting on behalf of a user

ADR-0021 §2. An assistant is a virtual session of one person — it reads everything they can read and posts in their name. This switch decides whether this organization may have them at all. The messaging service checks it on every request, not only when an assistant is created, so turning it off stops the ones already running rather than waiting for their tokens to expire. Worker bots — which act under their own names and see only the conversations they were added to — are not governed by it.
Loading…

General

Camouflage each gateway under a country-plausible host (RU→yandex.ru, NL→a .nl site). Verify on the gateway with xray tls ping <host> — pick one showing Post-Quantum: false (X25519). Per-gateway blank = inherit global. Applies after the gateway's next Update.

Client tunnel profile

Remotely tune client transport knobs — fingerprint, block_quic, dns (mode/servers), fragment, mtu, mux, kill_switch (true/false — enforce blocking all traffic when the tunnel is down; omit to let users decide), reconnect (true/false — enforce auto-reconnect; omit for user choice) — without an app rebuild. JSON is overlaid global → group → user → device (most specific key wins); the client applies keys it understands and ignores the rest. Applies on the client's next connect.

Calls & conferences (SFU)

Self-hosted LiveKit SFU for A/V calls, conferences, screen share. The coordinator mints room tokens (RBAC: calls:join / calls:host); media flows client↔SFU. Deploy the livekit and turn server roles, then enter the client URL + API key/secret here.

Push notifications (APNs)

One key, both kinds of push. This is not calls-only: the same APNs key rings incoming calls and delivers every message notification. Leave it blank and users get no message banners at all — the messaging service builds no APNs client and every notification silently becomes a no-op. Saving is enough: the key is part of the messaging host’s desired state, so its agent picks the change up on its next poll (about a minute) and restarts the service. Nothing to redeploy — a host that pulls has no redeploy.
Leave OFF for TestFlight and App Store builds — they are signed aps-environment: production, and the sandbox host rejects their tokens as BadDeviceToken. Only a Xcode-built debug install needs this.

Push notifications (Android / FCM)

The Android half of the same story: without it Android devices get notifications only while the app is open. Same rule as APNs — saving is enough, the messaging host’s agent applies it within a minute and restarts the service. Check push_android in the messaging /healthz to see it land.
Firebase console → Project settings → Service accounts → Generate new private key. This is not google-services.json: that one is the client config, it belongs in the Android build (CI variable ANDROID_GOOGLE_SERVICES_JSON), and it is rejected here.

AI lane (model provider)

The tenant's model-provider key behind the /ai/v1/* proxy (ADR-0030 §2). Bots never see it — each holds its own ai_… token, issued per bot in the Bots table with a model allowlist and a daily budget. Same rule as push keys: saving is enough, the messaging host's agent applies it within a minute. Check ai in the messaging /healthz.

Support case (@librarian)

One button stands the whole ADR-0031 case up, idempotently: the bot (system badge, @librarian), its AI token, the «SimpleTwo Support» channel and «SimpleTwo Dev» room, the gated automation and the webhook secrets. Re-running converges rooms/automation and rotates both bot tokens — the support node picks them up on its next convergence. Then: enroll a support role host (Servers), add people to «SimpleTwo Dev», grant them the dev role. Requires the AI lane above.
Names are per-install; blank keeps the current ones (defaults on first run). Rooms are tracked by id once created — room titles here only matter at first creation, and renaming a room later in the client is free.

Gateway DNS (VLESS / Reality egress)

Resolvers gateways use for tunneled traffic. Without this, a gateway resolves via its host /etc/resolv.conf (often a broken VPS stub) — sites don't load though the tunnel is up. Applies on each gateway's next config pull; redeploy to apply now.

Directory photos (AD / LDAP)

Employee avatars are pulled from AD (thumbnailPhoto) after each SSO login and pushed to messaging. Guests/external accounts upload their own photo in the app.

Call recording (S3)

Hosts can record calls; the SFU egress uploads MP4 to this S3 bucket. Requires the egress + Redis server role.

TLS / Certificate

Let's Encrypt (auto)

Point these hostnames (DNS) at this coordinator — a Let's Encrypt cert is issued automatically on first connect. The mobile app discovers the coordinator via https://<host>/.well-known/simpletwo.json.
How auto-discovery works & setup
The mobile app asks users only for email + password and finds this coordinator from the email domain. This coordinator: . It tries, in order:
  1. Subdomain (recommended) — create a DNS record s2.<yourdomain> pointing here (CNAME to this coordinator's host, or an A record to its IP), then add s2.<yourdomain> to Tenant domains above. The coordinator serves the discovery document and auto-issues a Let's Encrypt certificate for that name on first connect. Nothing else to host.
  2. Root file — publish https://<yourdomain>/.well-known/simpletwo.json with { "coordinator_url": "…", "name": "…", "allow_insecure": false }.
  3. DNS TXT — add _simpletwo.<yourdomain> TXT = simpletwo=https://<coordinator> (used only if 1–2 fail; may be blocked on some networks).
Example for user@itglobal.com: DNS s2.itglobal.com → this coordinator, add s2.itglobal.com to Tenant domains. The app then discovers it via https://s2.itglobal.com/.well-known/simpletwo.json. Verify anytime by opening that URL in a browser.

Obtain from provider


Manual import (paste PEM from any provider)