A chat application, written to show the SCION SDK. 1 server and 3 clients:
- a terminal client
- an Android app
- an iOS app
Every request between a client and the server is HTTP/3 over SCION.
1 file for each role:
| role | file |
|---|---|
| a client, in Rust | transport/scion.rs |
| a client, on Android | ScionTransport.kt |
| a client, on iOS | ScionTransport.swift |
| the server | scion.rs |
| a SCION network on this machine | topology.rs |
| all of the above, in 1 test | tests/scion.rs |
The 3 clients are structurally identical. Only the language changes.
2 terminals:
- terminal 1 starts a SCION network with the chat server in it
- terminal 2 runs a terminal client that talks to the server over SCION
terminal 1 terminal 2
+----------------------+ +----------------------+
| cargo run -p | | cargo run -p |
| chat-dev | <------- | chat-ui-ratatui |
| | SCION | |
| SCION network + | | terminal client |
| chat-server | | |
+----------------------+ +----------------------+
cargo run -p chat-devThis command starts:
- the SCION network
- the chat server
- a control API that describes both
Ctrl+C stops all of it.
chat-dev serves its description on a fixed port, 8099. The values in it change with each run:
- the endhost API takes a free port
- the SNAP token is new for each run
- the certificate goes in a new directory
Read them into the environment, then start the client:
eval "$(curl -s http://127.0.0.1:8099/info | jq -r '
"export CHAT_CLIENT_SERVER_URL=\(.base_url | @sh)",
"export CHAT_CLIENT_ENDHOST_API=\(.endhost_api_url | @sh)",
"export CHAT_CLIENT_TARGET=\(.target | @sh)",
"export CHAT_CLIENT_CERT_PATH=\(.ca_path | @sh)",
"export CHAT_CLIENT_SNAP_TOKEN=\(.auth_token | @sh)"
')"
cargo run -p chat-ui-ratatuiFor another user, run the same 2 commands in another terminal. Each read of /info makes a new
token, and each client needs its own.
The Android app runs on an emulator on this machine:
cd android && ./gradlew :app:installDebugThe app is pre-filled with 10.0.2.2:8099, which is the address the emulator reaches this host's
loopback at. The first build downloads the SDK into android/libs/maven. See
android/README.md.
The iOS app runs on a simulator on this machine:
cd ios && ./fetch-sdk.sh && xcodegen generate && open ChatApp.xcodeprojThe app is pre-filled with 127.0.0.1:8099. The simulator shares the Mac's network, so that is the
same address outside it. fetch-sdk.sh downloads the SDK into ios/libs. See
ios/README.md.
Every client shares the rooms. A message from the terminal client appears on the phone.
--bind-ip moves every part of the network to 1 address that another machine reaches. Give a real
address on this machine:
cargo run -p chat-dev -- --bind-ip 192.168.1.20Every client then uses that address, this machine included, so you give up the name localhost.
See the topology for which AS each client attaches to.
+------------------+ +-------------------+ +-------------+
| chat-ui-ratatui | --> | chat-client-core | --> | |
| terminal UI | | transports | | chat-core |
+------------------+ +-------------------+ | API types |
| |
+------------------+ +-------------------+ | |
| chat-dev | --> | chat-server | --> | |
| network + server | | the API | +-------------+
+------------------+ +-------------------+
An arrow means "depends on". cargo doc_dx --open renders every crate.
android/ and ios/ are separate builds. Each one reaches the same server over the SCION SDK for
its platform, and each one has a README that lays out its code.
The API's request and response types. Both the server and the client depend on it, so a change reaches both.
The server. cargo run -p chat-server -- --help lists every flag with its default and its CHAT_*
environment fallback.
scion.rs puts it on SCION in 3 steps:
ScionStackBuilderbuilds a stackstack.bind()opens a socket on itScionH3AxumServer::serve_with_graceful_shutdownserves the Axum router on that socket
Every route sits under /api/v1. Every route needs an authorization: Bearer <token> header, with
the token from login, except:
/healthz/server/register/login
The server describes itself at /.well-known/openapi.json. The repository also holds the same
document as YAML at crates/chat-server/openapi.yaml, because
YAML gives a better diff. You can therefore read and review the API surface without a running
server. A test compares the 2 documents, and it rewrites the file when you run:
CHAT_UPDATE_OPENAPI=1 cargo test -p chat-serverThe typed API, the session, and the transport under them:
ScionTransportcarries HTTP/3 over SCION, against the server's--transport scionmodeTcpTransportspeaks plain HTTP to the server's--transport tcpmodeMockTransportanswers from a script, so a test can produce a reply that no real server produces
tests/scion.rs starts a network of 2 ASes, puts the server in 1 and a client in the other, and
sends a message between them.
3 screens over chat-client-core:
- connect
- sign in
- chat
The screens draw and read keys. app.rs holds every call to the client.
Every field of the connect screen also has a flag, so a launch can arrive with the form answered:
| flag | environment | what it is |
|---|---|---|
--transport |
CHAT_CLIENT_TRANSPORT |
scion or tcp. Defaults to scion |
--server-url |
CHAT_CLIENT_SERVER_URL |
where the server is |
--endhost-api |
CHAT_CLIENT_ENDHOST_API |
how the client finds SCION. Required by --transport scion |
--target |
CHAT_CLIENT_TARGET |
the server's SCION address, for a host with no TSAR record |
--cert-path |
CHAT_CLIENT_CERT_PATH |
a certificate to trust instead of the system roots |
--insecure |
CHAT_CLIENT_INSECURE |
accept any certificate. See Trust |
--auth-api-key |
CHAT_CLIENT_AUTH_API_KEY |
a key the authority mints tokens from. See Credentials |
--snap-token |
CHAT_CLIENT_SNAP_TOKEN |
1 token, already minted, as chat-dev hands out |
--aa-url |
CHAT_CLIENT_AA_URL |
the authority. Defaults to https://auth.scion.anapaya.net |
Each transport uses 1 URL scheme:
scionuseshttpstcpuseshttp
The client checks the URL against the transport.
The SNAP asks every client to prove it may use the network. There are 2 ways to answer, and the clients offer the choice the same way they offer Trust:
| choice | flag | what it is |
|---|---|---|
| API key | --auth-api-key |
the long-lived secret. The authority mints tokens from it |
| SNAP token | --snap-token |
1 token, already minted |
A token lasts a day at most, so a client given only a token stops working when it expires. A client
given a key mints its own and keeps minting them, which is what a deployment wants. chat-dev is
the other case: it has no authority, and mints a token of its own for every read of /info, so the
quickstart above passes a token.
An endhost API on an appliance asks for neither, so both flags may be left out. Giving both is refused, because they name different credentials.
The key is the thing worth guarding: a token expires, a key does not until it is revoked.
Every client accepts the server's certificate in 1 of 3 ways. The terminal client chooses with the flags above, and the 2 apps with a control on the configuration screen:
| choice | flag | what it accepts |
|---|---|---|
| system roots | neither flag | the anchors the machine ships |
| pinned | --cert-path |
1 certificate, in place of those anchors |
| no check | --insecure |
any certificate at all |
The default is the strict one, so a blank --cert-path means the system roots and a self-signed
server is refused. --insecure says so on purpose. It exists to reach a server you control before
you have copied its certificate, and while it is on, anyone on the path can answer as that server.
Giving both flags is refused, because they name different trusts.
The client reads CHAT_CLIENT_* and the server reads CHAT_*. Keep them apart. The server sits in
1 AS and the client attaches to another, so a shared CHAT_ENDHOST_API would point the client at
the wrong endhost API.
A development helper. It starts a SCION network on this machine and puts the chat server in it. The network runs for as long as the process runs. The underlay is SNAP, which addresses an endpoint at the address its tunnel observed. A client behind a translation needs that.
The network holds 3 autonomous systems. The server's AS is the middle one.
1-ff00:0:132 (every client, at the bound address)
| iface 1 to 3
2-ff00:0:212 (the chat server)
| iface 4 to 2
2-ff00:0:222 (an Android emulator on this machine, at 10.0.2.2)
| AS | who attaches to it | published at |
|---|---|---|
1-ff00:0:132 |
every client, and the default | the bound address |
2-ff00:0:212 |
the chat server | the bound address |
2-ff00:0:222 |
an Android emulator on this machine | 10.0.2.2 |
1-ff00:0:132 is the default AS. It publishes the address that --bind-ip binds, and every client
that reaches that address uses it:
- a terminal client on this machine
- a client on another machine, after you give
--bind-ipan address that machine reaches
An Android emulator on this machine is the only client that cannot use the default. The emulator
reaches the host at 10.0.2.2, and inside the emulator 127.0.0.1 means the emulator itself. The
network publishes 1 address for each AS, and 1 AS holds 1 address, so the emulator gets
2-ff00:0:222 as a fallback. --bind-ip makes the fallback unnecessary: with a LAN address bound,
the emulator reaches that address like any other client.
2 files build this:
topology.rsmakes the 3 ASes, the 2 links, and the endhost API and SNAP endpoint in eachlib.rssets the address each AS publishes, and chooses the description thatGET /infoanswers with
chat-dev serves its description at GET /info on --control-port (8099), over plain TCP. A
client that failed to connect over SCION can still read it. The same document goes to standard
output at startup.
curl -s http://127.0.0.1:8099/info | jq{
"control_url": "http://127.0.0.1:8099",
"endhost_api_url": "http://127.0.0.1:65263/",
"client_isd_as": "1-ff00:0:132",
"auth_token": "eyJ0eXAiOiJKV1Qi...",
"base_url": "https://localhost:8443",
"target": "2-ff00:0:212,127.0.0.1",
"ca_path": "/tmp/.tmpTFIcwA/cert.pem",
"chat_server_args": ["--transport", "scion", "--listen", "127.0.0.1:8443", "..."]
}This sample shows the fields a client reads most. The document holds more. Every field carries a
doc comment in info.rs, which cargo doc_dx renders.
GET /info answers with the description that matches the address the client asked at:
- a request to
10.0.2.2gets the emulator's AS - every other request gets the default AS
Both descriptions name the same server, certificate and SCION address.
--no-server starts the network without the chat server:
cargo run -p chat-dev -- --no-servercargo run -p chat-server then starts the server as its own process against the running network.
You can stop it, rebuild it and start it again while the network stays up. The chat_server_args
field holds the command that joins it:
cargo run -p chat-server -- $(curl -s http://127.0.0.1:8099/info | jq -r '.chat_server_args | join(" ")')Give the server the same data directory, so it presents the certificate the description names.
The pinned toolchain in rust-toolchain.toml is picked up automatically by rustup. CI runs the
checks the cargo aliases in .cargo/config.toml define, on Linux and Windows:
cargo clippy_ci
cargo test_ci
cargo doc_ciFormatting runs on a pinned nightly, because rustfmt.toml uses nightly-only options:
cargo +nightly-2026-03-12 fmt --allThe SDK leaves the choice of a rustls provider to the application. Every binary here calls
scion_sdk_utils::rustls::select_ring_crypto_provider() before its first TLS handshake.
SQLite is compiled into the binary, so there is no database service to install, start, or connect to. The database is 1 file. The store creates the file and the directory that holds it. Delete the file to start over.
The compiler checks the server's SQL against the schema, so it needs to know the schema.
crates/chat-server/.sqlx/ holds that knowledge, as 1 JSON file for each query.
The repository commits this folder, so a build needs no database and no tooling. Every cargo
command works on a fresh clone without DATABASE_URL or sqlx-cli. The crate fails to compile
without the folder.
You need sqlx-cli to add or change a query. Regenerate the metadata afterwards:
cargo install --version 0.9.0 sqlx-cli --no-default-features --features sqlite # match Cargo.toml
cd crates/chat-server
DB=$(mktemp -d)/prepare.db # a fresh file: CREATE TABLE IF NOT EXISTS would keep a stale one
sqlite3 "$DB" < src/store/sqlite/schema.sql
DATABASE_URL="sqlite://$DB" cargo sqlx prepare -- --all-targetsA forgotten regeneration is a compile error. Metadata that has drifted from the schema is worse,
because it still compiles, so CI runs the same commands with --check.
Licensed under the Apache License, Version 2.0.