Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3212a86
docs(plans): add xray config backend plan
mrcsin Oct 14, 2026
99a3a13
feat(usher): read awg socket from environment
mrcsin Oct 9, 2026
4b016ef
refactor(pass): add backend seam with awg driver
mrcsin Oct 9, 2026
af549ec
refactor(usher): share Open adapter, name AWG dialer
mrcsin Oct 9, 2026
b255b81
refactor(awg): unexport render
mrcsin Oct 9, 2026
8acbeab
refactor(pass): keep every suffix of a down backend
mrcsin Oct 9, 2026
16fc05f
feat(state): add xray credentials, users.json v2
mrcsin Oct 14, 2026
b3de8b8
feat(xray): read the operator's Xray server config
mrcsin Oct 14, 2026
cb50c99
feat(xray): detect drift against the running Xray
mrcsin Oct 14, 2026
ae429c4
feat(xray): manage users through HandlerService
mrcsin Oct 14, 2026
060a444
feat(xray): recover a stale API socket
mrcsin Oct 14, 2026
f02b182
feat(xray): mirror inbounds into client outbounds
mrcsin Oct 14, 2026
3cc7c06
feat(xray): render client json and share links
mrcsin Oct 14, 2026
398be30
feat(usher): wire the Xray backend
mrcsin Oct 14, 2026
e06d0f2
fix(xray): open the socket lock read-only
mrcsin Oct 14, 2026
f06d531
test(xray): add the Xray e2e suite
mrcsin Oct 14, 2026
0155ff4
docs(plans): verify xray backend acceptance
mrcsin Oct 14, 2026
0ccfeee
docs(xray): document the Xray backend
mrcsin Oct 14, 2026
f19ddea
fix(xray): hide secrets and guard unusable drift
mrcsin Oct 14, 2026
fb1c089
fix(usher): reject stray env and hide state ids
mrcsin Oct 14, 2026
8f334c4
test(e2e): prove xray passes ran and clients up
mrcsin Oct 14, 2026
311a8a3
docs(xray): correct plan, README and architecture
mrcsin Oct 14, 2026
945767c
fix(xray): leave untrusted inbounds untouched
mrcsin Oct 14, 2026
45aab5c
fix(xray): drift-check inbounds with hint errors
mrcsin Oct 14, 2026
c4e0025
docs: rewrap lines past 100 columns
mrcsin Oct 14, 2026
11726d1
refactor(pass): split Run into three steps
mrcsin Oct 14, 2026
6acacc2
refactor(usher): share the unix socket dial
mrcsin Oct 14, 2026
9f77178
style(state): separate import groups
mrcsin Oct 14, 2026
8e9dc49
refactor(xray): unexport reader, share decode, tidy
mrcsin Oct 14, 2026
80211c8
docs(repo): fix comment audit findings
mrcsin Oct 14, 2026
7561eb7
docs(plans): record the xray exec run
mrcsin Oct 14, 2026
e0f24fb
docs(plans): complete the xray config backend plan
mrcsin Oct 14, 2026
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ jobs:

- name: e2e test
run: go test -tags e2e -count=1 -v ./test/e2e/

# Needs no kernel module; runs even when the awg e2e step failed.
- name: xray e2e test
if: ${{ !cancelled() }}
run: go test -tags e2e -count=1 -v ./test/e2e/xray/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Thumbs.db
.idea/
.vs/
.e2e/
.e2e-xray/
46 changes: 35 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# usher

Container that reads `config/usher.yml`, a map of user name to AmneziaWG interface names, and keeps
the peers of those interfaces and the client configs in `clients/` in step with it through the
`awg-grpc` unix socket; `README.md` describes it. Go module `github.com/mrcsin/usher`; entry point
`cmd/usher`. All commands run from the repository root.
Container that reads `config/usher.yml`, a map of user name to interface names, and keeps the
AmneziaWG peers, the Xray users and the client files in `clients/` in step with it through the
`awg-grpc` and Xray API unix sockets; `README.md` describes it. Go module `github.com/mrcsin/usher`;
entry point `cmd/usher`. All commands run from the repository root.

## Build

Expand All @@ -24,7 +24,8 @@ go test -race ./...

Table-driven tests sit beside the source as `*_test.go`; filesystem tests use `t.TempDir()`. The
`awg-grpc` API is faked by a struct with func fields that implements
`awgv1.ManagementServiceClient`.
`awgv1.ManagementServiceClient`; the Xray API by a struct that implements
`command.HandlerServiceClient`.

The e2e suite in `test/e2e` carries `//go:build e2e`. It needs Docker and a host with the
`amneziawg` module loaded, so it runs on a Linux host, not on macOS. It uses the published
Expand All @@ -40,13 +41,27 @@ The suite needs `docker compose` without `sudo`. It wipes and recreates the bind
under `.e2e/` (gitignored) at start, and passes `E2E_UID` and `E2E_GID` to compose so usher
writes them as the test user.

The Xray suite in `test/e2e/xray` (`deploy/compose.e2e-xray.yml`) needs only Docker and no kernel
module. The test generates a CA, the server certificates and the Reality key, and starts the
official Xray image. It also kills Xray's process from a helper container in its PID namespace,
because `docker kill` bypasses the restart policy:

```sh
go test -tags e2e -count=1 -v ./test/e2e/xray/
```

It uses `.e2e-xray/` (gitignored) for its bind mounts. CI runs it in the `e2e` job.

To copy the tree from macOS to a Linux host, use `COPYFILE_DISABLE=1 tar --no-xattrs`: plain macOS
tar adds `._*` AppleDouble files. A run killed by a signal leaves containers up; remove them with:

```sh
docker compose -f deploy/compose.e2e.yml --profile handshake down -v --remove-orphans
docker compose -f deploy/compose.e2e-xray.yml --profile client down -v --remove-orphans
```

The Xray suite also takes its project down at start.

## Format

```sh
Expand All @@ -70,7 +85,8 @@ Enable the pre-commit hook per clone: `git config core.hooksPath scripts/git-hoo
`.github/workflows/ci.yml` runs on every pull request and every push to `master`.

- `check`: `scripts/format.sh`, `go test -race`, shellcheck.
- `e2e`: loads the pinned kernel module with `scripts/ci/module.sh`, then runs the e2e suite.
- `e2e`: loads the pinned kernel module with `scripts/ci/module.sh`, runs `./test/e2e/` (awg), then
`./test/e2e/xray/`. The Xray step needs no module and runs even when the awg step failed.

The module pin is `AWG_MODULE_REF` in `ci.yml`.

Expand All @@ -82,8 +98,13 @@ the notes of the annotated tag.

- A private key, a preshared key or a `client_params` value is never logged, and an error message
never quotes one. A public key without an entry is logged as base64.
- A Reality `privateKey` or `mldsa65Seed`, a `certificateFile` or `keyFile`, a PEM key, `sockopt`,
a server `fallbacks` entry and a drift value never reach `clients/`, a log line or an error. A
drift reason names the differing part only.
- usher never writes the Xray server config. It owns the users of the managed inbounds and
`clients/`.
- `state/users.json` is the only source of secrets. The kernel and `clients/` derive from it, and
usher saves it before `ApplyPeers`.
usher saves it before any backend's `Apply`.
- A failure of one interface never stops the daemon; the pass logs it and goes on.
- No interface without a second implementation, no exported surface only for tests.

Expand All @@ -94,11 +115,14 @@ cmd/usher/ entry point: environment, signals, run loop wiring
internal/config/ usher.yml loader and validation
internal/state/ users.json load and save
internal/atomicfile/ atomic file replace shared by state and the clients mirror
internal/pass/ settings, enrollment, address allocation, the reconcile pass
internal/clients/ client config rendering and the clients/ mirror
internal/pass/ settings, the Backend/Session seam, the reconcile pass
internal/awg/ awg backend: enrollment, address allocation, .conf rendering
internal/xray/ Xray backend: config reader, drift guard, users, mirror, rendering, stale socket
internal/clients/ client file paths and the clients/ mirror
internal/watch/ file poll, refill ticker and the trigger loop
deploy/ example compose file, e2e compose file and interface config
test/e2e/ end-to-end test through docker compose (tag e2e)
deploy/ example compose file, example Xray config, e2e compose files
test/e2e/ awg end-to-end test through docker compose (tag e2e)
test/e2e/xray/ Xray end-to-end test (tag e2e)
scripts/format.sh the Format chain (see Format)
scripts/git-hooks/ pre-commit hook: branch guard, then scripts/format.sh
scripts/ci/module.sh builds and loads the pinned kernel module in the CI e2e job
Expand Down
165 changes: 151 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
User management for the VPN services of one node. You list users and the interfaces each may use
in one file; within seconds usher creates their keys, puts them into the running services and
writes a ready client config per user and interface. Addresses and keys stay internal: you type
names only.
names only. An interface is an AmneziaWG interface or an inbound of your Xray config.

usher holds no capabilities and is never in the packet path. Every VPN service runs in its own
container with a small management API on a unix socket; usher calls that API and nothing else, so
a crash or an upgrade of usher leaves every session running. AmneziaWG is supported through
[awg-grpc](https://github.com/mrcsin/awg-grpc); Xray is next.
[awg-grpc](https://github.com/mrcsin/awg-grpc), Xray through its own gRPC API.

## Users

Expand All @@ -31,26 +31,163 @@ bob: []
- Removing a name, or setting it to `[]`, switches the user off. The keys stay, and putting the
name back restores the same config.
- A broken file changes nothing: usher keeps the last valid one and logs the error with its line.
- A service that restarts empty is refilled within 30 s without any change to the file.
- A service that restarts empty is refilled at the next refill tick (every 30 s; Xray measured
28 to 32 s, bounded at 45 s in the e2e) without any change to the file.

Client configs appear in `clients/<user>/<interface>.conf`. Secrets live in `state/users.json`,
the only copy of every key: keep the state volume in the backup set.
Client files appear in `clients/<user>/<interface>`: `.conf` for AmneziaWG, `.json` and `.txt`
for Xray. Secrets live in `state/users.json`, the only copy of every key: keep the state volume in
the backup set.

## Running

[`deploy/compose.example.yml`](deploy/compose.example.yml) runs usher next to awg-grpc. Images are
`ghcr.io/mrcsin/usher:<tag>`, one per release.
[`deploy/compose.example.yml`](deploy/compose.example.yml) runs usher next to awg-grpc and Xray.
Images are `ghcr.io/mrcsin/usher:<tag>`, one per release. Drop the service you do not use.

| Variable | Example | Meaning |
| ------------ | ----------------- | ----------------------------------------------- |
| `USHER_HOST` | `203.0.113.10` | IPv4 address clients connect to; no host names |
| `USHER_DNS` | `1.1.1.1,1.0.0.1` | DNS servers written into client configs |
| Variable | Example | Meaning |
| -------------------- | ------------------------ | ---------------------------------------------------- |
| `USHER_HOST` | `203.0.113.10` | IPv4 address clients connect to; no host names |
| `USHER_AWG_SOCKET` | `/run/awg-grpc/awg.sock` | awg-grpc socket; enables the AmneziaWG backend |
| `USHER_DNS` | `1.1.1.1,1.0.0.1` | DNS servers in AmneziaWG configs; needs the awg socket |
| `USHER_XRAY_SOCKET` | `/run/xray/api.sock` | Xray API socket; enables the Xray backend |
| `USHER_XRAY_CONFIG` | `/etc/xray/config.json` | Xray server config, read-only; needs the Xray socket |

Both are required. Publish each interface port 1:1, and give usher the socket group of the
service containers.
`USHER_HOST` is always required. Set at least one socket. Publish each interface port 1:1, and give
usher the socket group of the service containers.

To issue new keys for a user, stop usher, delete its `<interface>/<user>` entry from
`users.json` and start it again.
`users.json` and start it again. The Xray entries are under `xray` and named `<tag>/<user>`. If you
change an inbound between a protocol with a UUID (VLESS, VMess) and one with a password (Trojan,
Hysteria2), the stored credentials no longer fit: usher reports "stored credential does not fit"
and keeps the old client files until you stop usher, delete the `<tag>/<user>` entries of that tag
and start it again.

### Upgrading

- `USHER_AWG_SOCKET` is required for the AmneziaWG backend; earlier releases hardcoded the path.
Set it to the awg-grpc socket (`/run/awg-grpc/awg.sock` in the example) before you upgrade.
- The first save writes `users.json` as version 2 and copies the old file to `users.json.v1`. To
roll back to an earlier image, stop usher and rename `users.json.v1` back to `users.json`.

## Xray

You write the Xray server config and usher fills its inbounds with users. An inbound tag is the
interface name in `usher.yml`, up to 32 characters. usher reads the config file and puts users
into the running inbounds through Xray's `HandlerService`. It never writes the config. Xray keeps
users in memory only, so usher refills them at the next refill tick after a restart.

Per user and inbound usher writes `clients/<user>/<tag>.json`, a full client config with a local
socks proxy on 127.0.0.1:10808 and an http proxy on 127.0.0.1:10809, and `clients/<user>/<tag>.txt`,
a share link.

### Server config

- One JSON file; comments are allowed. No `-confdir`.
- Mount the directory that holds the file into both containers, not the file. usher then sees a
replaced file at its next refill tick.
- `api.services` is `["HandlerService"]` and `api.listen` is a unix path with `,0660`, such as
`/run/xray/api.sock,0660`. The Xray process group must equal usher's group.
- The socket directory is a bind mount that usher can write. After a hard kill Xray leaves a stale
socket and fails to start on it. usher unlinks the socket at its next tick, and Xray's restart
policy (`restart: unless-stopped`) starts it again. usher unlinks a socket only when no process
holds the `<socket>.lock` file that Xray keeps beside it, or, when that file is gone (a failed
start removes it), when a connection to the socket is refused. A live Xray keeps its socket.
- Keep certificates and key files outside the directory that usher mounts. usher does not read
them, and Xray reloads them itself. The example mounts `./xray-certs` into the Xray container
only.
- The Xray image follows `xray-core` in `go.mod` (now `ghcr.io/xtls/xray-core:26.10.10`). Another
version can change how Xray builds an inbound and put every inbound in drift.
- Every managed inbound has a tag, a single integer `port`, no unix `listen` and an empty
`clients` list. A Hysteria2 inbound also has an empty `hysteriaSettings.auth`; usher gives each
user an auth. VLESS `decryption` is `none`.

[`deploy/xray/config.json`](deploy/xray/config.json) is an example with placeholders that Xray
refuses to start with.

### Supported shapes

| Part | Values |
| --------- | --------------------------------------------------------------- |
| Protocol | VLESS, Hysteria2, VMess, Trojan |
| Transport | raw (header none), ws, grpc, httpupgrade, xhttp, hysteria |
| Security | none, tls, reality |
| Mask | salamander (Hysteria2, without `packetSize`), `quicParams` |

Shadowsocks and WireGuard inbounds are ignored. VMess and Trojan are covered by unit tests only.
An inbound outside this table fails; the log names the field. Its existing client files stay.

### The x-usher block

Add `x-usher` to an inbound to tell usher what clients need. Xray ignores the key. Every field is
optional, and an unknown key fails the inbound.

```json
"x-usher": {
"address": "203.0.113.10",
"port": 443,
"sni": "example.com",
"fingerprint": "chrome",
"trust": "ca",
"pin_sha256": "…",
"hop_ports": "20000-30000"
}
```

| Field | Default | Meaning |
| ------------- | ------------------------------------ | ---------------------------------------------------- |
| `address` | `USHER_HOST` | address clients dial |
| `port` | the inbound `port` | port clients dial |
| `sni` | `tlsSettings.serverName`, or the first Reality `serverNames` entry without `*` | name clients send; on Reality one of the `serverNames` entries |
| `fingerprint` | `chrome` | uTLS fingerprint; Reality allows `chrome`, `firefox`, `safari` |
| `trust` | `ca` | `ca` or `pin`, see below |
| `pin_sha256` | hash of the inline certificate | hex SHA-256 of the leaf; only with `trust: pin` |
| `hop_ports` | none | Hysteria2 port range `from-to`, from < to |

A TLS inbound with neither `sni` nor `tlsSettings.serverName` fails. Reality matches the name by
exact lookup in `serverNames`, so on Reality an `sni` that is not literally one of its entries
fails the inbound, and so does a config with only wildcard names. The privateKey and the
`mldsa65Seed` must be base64url keys of the right size, and the seed must differ from the key.

### TLS trust

- `ca`: the client verifies the server certificate against its CA roots and gets only the
server name, the ALPN list and the fingerprint. Use a certificate that a public CA issued for
your domain.
- `pin`: the client also gets `pinnedPeerCertSha256`, from `pin_sha256` or from an inline
`certificates[].certificate`. An inbound with neither fails. A Hysteria2 pin inbound gets no
`.txt`, because v2rayNG and Hiddify do not read the pin from a `hysteria2://` link.

Reality needs no certificate. usher derives the public key from `privateKey`, takes `shortIds[0]`
and sets `spiderX` to `/`. With `mldsa65Seed` it adds `mldsa65Verify`. Private keys, seeds, key
files and fallbacks never reach `clients/` or the log.

### Client compatibility

Measured on 2026-10-14 against Xray 26.10.10.

- Reality links work in Xray-core clients only. sing-box 1.14.3 fails the server's
`X25519MLKEM768` requirement. Only `chrome`, `firefox` and `safari` carry it.
- xhttp is not in upstream sing-box.
- VLESS ws, Trojan, VMess and Hysteria2 with salamander work in sing-box.
- A user in `usher.yml` that loses an interface loses new connections at once. An open Hysteria2
QUIC connection stays up until the client reconnects.
- `hop_ports` needs a DNAT on the host from the range to the inbound port. usher does not set it.

### Config edits

usher compares each inbound with the running Xray at every pass. After you edit a managed inbound
in the file, restart Xray. Until then usher logs a drift reason that names the part (port, proxy
settings, stream settings), keeps the old client files and still removes users who left
`usher.yml`. Certificate files that the config names are reloaded by Xray itself and cause no
drift. When the running inbound is missing, runs another protocol or cannot be compared with the
file, usher changes nothing on it, because an account of the wrong type crashes Xray; restart Xray.

An inbound with an `x-usher` error, or one whose credential or client file cannot be built, keeps
its old client files, and usher still removes the users who left `usher.yml` from it, once the
drift check has compared the inbound with the running Xray and found it usable. It adds no
user and changes no credential until you fix the file. An inbound that fails any other check (port,
`listen`, non-empty `clients`, transport, Reality keys, a tag that is not a valid interface name
or that the file defines twice) is left alone: usher cannot trust the file entry, so it adds and
removes no user on that tag and keeps its old client files.

## License

Expand Down
Loading
Loading