From 0029861c787217b3a27b49b46868ad4245722a88 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:06:25 +0300 Subject: [PATCH 01/29] docs(plans): add xray driver plan --- docs/plans/20261007-xray-driver.md | 526 +++++++++++++++++++++++++++++ 1 file changed, 526 insertions(+) create mode 100644 docs/plans/20261007-xray-driver.md diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md new file mode 100644 index 0000000..810dc59 --- /dev/null +++ b/docs/plans/20261007-xray-driver.md @@ -0,0 +1,526 @@ +# Xray driver + +## Overview + +- usher gains a second backend: Xray. A delivered Xray container exposes its own gRPC API on a + unix socket; usher reads the VLESS inbounds from it, keeps one UUID per user and inbound in + `users.json`, adds and removes users through `HandlerService`, and writes one `vless://` link + per user and inbound into `clients/`. +- usher still creates no interface and starts no process. Delivery prepares each VPN container; + usher only moves users into it. +- Backends are selected explicitly: `USHER_AWG_SOCKET` and `USHER_XRAY_SOCKET` name the socket + paths. A set variable enables the backend; at least one must be set. +- Breaking changes: an existing deployment must add `USHER_AWG_SOCKET` to its compose file, and + `users.json` moves to version 2, which the previous image refuses to load. The release is + marked with `!`. +- Scope of the Xray driver: VLESS over raw TCP without a header, with Reality security and + `decryption: none`. Any other security, transport, header, decryption or a port range fails + that inbound with an error that names the field. The driver is laid out so that more shapes + can be added later (Solution Overview, "Room for more inbound shapes"). + +## Context (from discovery) + +- `cmd/usher/main.go:22` hardcodes the awg socket; `main.go:57-63` dials it; `main_test.go:59-75` + (`TestServe`) calls `serve` with a `pass.Dial`. +- `cmd/usher/env.go:17-39` reads `USHER_HOST` and `USHER_DNS`, both required. +- `internal/pass/pass.go:25` types `Dial` on `awgv1.ManagementServiceClient`; `pass.go:42-119` is + the pass: `GetStatus`, prepare per interface, save state, apply per interface, mirror + `clients/`. A dial or `GetStatus` error returns before the mirror (`pass.go:52-65`), so + `clients/` stays untouched. `pass.go:136-138` fails an interface that is not present in the + kernel. `pass.go:211` logs "interface is not reported by awg-grpc". +- `internal/pass/enroll.go:23` enrolls AmneziaWG entries (key pair, PSK, address). +- `internal/clients/render.go:18` renders an AmneziaWG config from `awgv1.InterfaceStatus`; + `render.go:1-2` holds the package doc comment of `clients`; the golden file is + `internal/clients/testdata/phone.conf`. +- `internal/clients/mirror.go:22-24` hardcodes the `.conf` suffix in `Path`; `mirror.go:48` + keeps only `.conf` files in `Existing`, which preserves files of failed interfaces. +- `internal/state/state.go:53-57` is the AmneziaWG entry; `state.go:71` documents `EntryName` as + a key "in State.AWG"; `state.go:77-80` holds only the `awg` group; `state.go:107` rejects every + version other than 1. +- `internal/config/config.go:13-14`: user names `^[a-z0-9][a-z0-9_-]{0,31}$` (lowercase only), + interface names `^[A-Za-z0-9_][A-Za-z0-9_=+.-]{0,14}$`; `config.go:55-57` rejects a file + without any user and interface pair. +- `Dockerfile:8-9` copies only `cmd` and `internal` into the build stage; awg-grpc copies `gen` + as well (`awg-grpc/Dockerfile:23`). +- `deploy/compose.example.yml:39-41` and `deploy/compose.e2e.yml:35-37` set only `USHER_HOST` and + `USHER_DNS`; the awg e2e network is `172.30.99.0/24` (`compose.e2e.yml:58`). +- `test/e2e/e2e_test.go:69-107` is a package-wide `TestMain` that starts the awg compose project + and needs the `amneziawg` module; `e2e_test.go:24,406` uses `state.Entry`, `state.EntryName` + and `Entry.Route`. +- `.github/workflows/ci.yml:62` runs `go test -tags e2e -count=1 -v ./test/e2e/`. +- `docs/architecture/overview.md:62-76` holds the locked decisions; lines 64, 65, 70, 71, 74 and + 76 are worded for AmneziaWG only. +- `go.mod` already requires `google.golang.org/grpc v1.84.0`; Go is 1.27.1, whose standard + library has package `uuid` (`uuid.NewV4`, `uuid.Parse`, verified with `go doc uuid`). +- awg-grpc generates code with buf from a separate tools module: `awg-grpc/buf.gen.yaml:1-8`, + `awg-grpc/tools/go.mod:5-9`; its CI caches `tools/go.sum` (`awg-grpc/.github/workflows/ci.yml:33-35`); + its `scripts/format.sh:26` runs `buf lint`. + +Xray-core v26.9.9, read during planning: + +- `app/proxyman/command/command.proto:91,93,95`: `AlterInbound`, `ListInbounds`, + `GetInboundUsers`; `command.proto:13-19`: `AddUserOperation{User}`, + `RemoveUserOperation{email}`; `command.proto:41`: `ListInboundsRequest.isOnlyTags`. + `ListInbounds` with `isOnlyTags=false` returns the stored receiver and proxy configs + (`app/proxyman/command/command.go:113-118`, `app/proxyman/inbound/always.go:212-221`). +- `core/config.proto:35-42`: `InboundHandlerConfig{tag, receiver_settings, proxy_settings}`, both + settings as `TypedMessage` (`common/serial/typed_message.proto:10-14`: `type`, `value`). + `TypedMessage.type` is the message's full proto name without a URL prefix + (`common/serial/typed_message.go:22-24`). +- `app/proxyman/config.proto:37-42`: `ReceiverConfig{port_list, listen, stream_settings}`; + `common/net/port.proto:10-19`: `PortList` of `PortRange{From, To}`. +- `transport/internet/config.proto:44-57`: `StreamConfig{protocol_name, security_type, + security_settings}`. The config builder normalizes both `raw` and `tcp` to `tcp` + (`infra/conf/transport_internet.go:18-19`). With `security: none` the `security_type` is empty. +- Raw TCP has three header forms: no transport settings at all, `tcp.Config` without + `header_settings` (`transport/internet/tcp/config.proto:11-15`), and a `header_settings` from + `header: {type: "none"}`, which the builder loads as the no-op authenticator + (`infra/conf/transport_method.go:225-228,237-249`; package `xray.transport.internet.headers.noop`). +- `transport/internet/reality/config.proto:14-21`: `server_names`, `private_key`, `short_ids`, + `mldsa65_seed`. The builder rejects an empty `serverNames` and an empty `shortIds`, decodes the + private key with `base64.RawURLEncoding`, and stores every short ID hex-decoded into 8 bytes, so + `""` becomes 8 zero bytes and `"ab"` becomes `ab00000000000000` + (`infra/conf/transport_security.go:95-100,135-145`). `xray x25519` prints keys in the same + encoding by default (`main/commands/all/x25519.go:8,15`). +- `transport/internet/reality/reality.go:84-101`: the client authenticates the server by a + signature made with the Reality key, not by the target's certificate, and runs the ML-DSA-65 + check only when its `mldsa65Verify` is set. A link without `pqv` works against a server that has + a `mldsa65_seed`, and a self-signed target certificate works. +- `proxy/vless/inbound/config.proto:20-24`: `users`, `decryption`; the builder stores the literal + `none` (`infra/conf/vless.go:107,150`). The runtime config has no inbound-level `flow`: + `infra/conf/vless.go` writes the inbound `flow` into each client while it builds the config, + and only there. +- `proxy/vless/account.proto:16-22`: `Account{id, flow, encryption}`; + `common/protocol/user.proto:12-18`: `User{level, email, account}`. +- `proxy/vless/inbound/inbound.go:245-247` and `proxy/vless/validator.go:47`: `RemoveUser` removes + the user's reverse proxy and deletes it from the validator; live connections stay open. The + validator's errors quote the email, not the id (`validator.go:39,54`). +- `proxy/vless/inbound/inbound.go:589`: a user whose account `flow` differs from the client's + requested flow is rejected with "account ... is not able to use the flow". +- `proxy/vless/validator.go:21-25`: `ProcessUUID` zeroes bytes 6-7 of the UUID, so only a random + (v4) UUID keeps enough entropy. +- `proxy/proxy.go:306-307`: an empty `testseed` falls back to `{900, 500, 900, 256}`. +- `app/commander/commander.go:78-90`: `api.listen` accepts a unix path; + `transport/internet/system_listener.go:128-161`: a `path,0660` suffix sets the socket mode but + not its owner, so the socket gets the Xray process's uid and gid. +- `GetInboundUsers` first appears in v24.11.5 (absent in v24.10.31, present in v24.11.5, checked + per tag). +- The proto import closure of the messages above is 12 files, about 690 lines; + `transport/internet/tcp/config.proto` adds the raw-TCP header settings as a 13th. xray-core is + MPL-2.0. +- Xray's share-link format is a public proposal, XTLS/Xray-core discussion #716. + +## Development Approach + +- **testing approach**: Regular (code first, then tests) +- complete each task fully before moving to the next +- make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task + - tests are not optional - they are a required part of the checklist + - write unit tests for new functions/methods + - write unit tests for modified functions/methods + - add new test cases for new code paths + - update existing test cases if behavior changes + - tests cover both success and error scenarios +- **CRITICAL: all tests must pass before starting next task** - no exceptions +- **CRITICAL: update this plan file when scope changes during implementation** +- run tests after each change +- backward compatibility: kept for `usher.yml` and for reading `users.json` version 1; broken for + the environment and for rolling back to the previous image (see Overview) + +## Testing Strategy + +- **unit tests**: table-driven, beside the source; the gRPC APIs are faked by structs with func + fields, as `awgv1.ManagementServiceClient` already is (`AGENTS.md`, Test). +- **e2e tests**: tag `e2e`. The awg suite in `test/e2e` keeps running unchanged through every + task. The Xray suite is its own package, `test/e2e/xray`, with its own `TestMain`, compose + project, network and scratch root, and needs Docker only, no kernel module. +- **leak tests**: usher's own logs and errors never contain a Reality private key, a UUID or a + preshared key. Backend error text is logged as the backend returns it; Xray's API errors quote + emails, not ids (`validator.go:39,54`). + +## Acceptance Evidence + +There is no Xray support today: `usher.yml` naming an Xray inbound tag logs "interface is not +reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. + +1. Unit tests: `go test -race -count=1 ./...` passes. +2. Format chain, including the generated-code check: `scripts/format.sh` exits 0. +3. Xray e2e: `go test -tags e2e -count=1 -v ./test/e2e/xray/` passes with three assertions on a + config of two users: + - an HTTP request through a client built from a rendered link returns 200; + - after one user is removed from `usher.yml`, a new connection through that user's link fails + while the other user's link still works; + - after `docker compose restart xray`, a new connection succeeds again within 60 s (refill). +4. awg e2e: `go test -tags e2e -count=1 -v ./test/e2e/` still passes on a host with the + `amneziawg` module. +5. Manual smoke, once: import one rendered link into a phone client (v2rayNG or Streisand) and open + a page through it. + +## Progress Tracking + +- mark completed items with `[x]` immediately when done +- add newly discovered tasks with ➕ prefix +- document issues/blockers with ⚠️ prefix +- update plan if implementation deviates from original scope +- keep plan in sync with actual work done + +## Solution Overview + +- **Backend seam.** The pass gets a list of backends. Each pass opens one session per backend; + opening connects and lists the backend's interfaces. A session prepares one interface (enroll + entries, render client files) and applies one interface (send the desired users). The pass + keeps everything else: config load, state save before any apply, failure isolation per + interface, the `clients/` mirror. The awg driver forwards the desired list to `ApplyPeers` and + fails an interface that is not present in the kernel in its own prepare step; the Xray driver + computes the difference itself. `internal/xray` is the second implementation of the session + interface; between Task 2 and Task 6 the interface has one implementation, which the second one + completes in the same change set. +- **A backend that is down.** When opening a session fails, the pass logs it, applies the other + backends, keeps every client file with that backend's suffix, and skips the unknown-name log + for the pass, because the down backend's names are unknown. +- **Ownership.** usher owns every interface a backend reports, as today. For Xray that is every + VLESS inbound; other inbounds, such as the `api` tunnel, are not reported. An unreferenced VLESS + inbound gets an empty user list. +- **Name collisions.** A name reported by two backends fails in both, with an error that names + both backends. +- **Link parameters come from the running Xray.** `ListInbounds` returns the inbound's receiver + and Reality settings, including the private key. The driver derives the public key, uses it for + the link and keeps nothing. +- **Flow is usher's choice, not a read value.** The protocol entry decides it from the transport + and security keys and writes the same value into the account and into the link: + `xtls-rprx-vision` for VLESS with Reality over raw TCP. usher sends no `testseed`; Xray then uses + its default (`proxy/proxy.go:306-307`). +- **Switching off.** Removing a name removes the Xray user; new connections fail, live + connections stay until they close or reach the Xray `connIdle` timeout. The README states this + difference from AmneziaWG. +- **Room for more inbound shapes.** The Xray driver reads an inbound in three independent parts: + the protocol (proxy settings), the transport (stream `protocol_name` and its settings) and the + security (security settings). Each part is a table of decoder functions keyed by the proto type + name or the transport name, with one entry today: VLESS, raw TCP without a header, Reality. A + key without an entry fails the inbound with an error that names it. The protocol entry owns + everything protocol-specific: the flow, the account it builds from an entry, and the whole link, + so a protocol whose link is not scheme plus query (VMess) still fits. Reconcile compares + accounts as opaque messages and knows no protocol fields. Adding TLS or another transport adds + one table entry, its vendored proto and its tests. Adding a protocol adds the same, plus a state + field when its credential is not a UUID. +- **Vendored protos.** The 13 Xray proto files are copied at v26.9.9 and generated with buf into + `gen/xray`, so usher imports no xray-core Go code. + +## Technical Details + +- **Seam sketch** (`internal/pass`): + ```go + type Session interface { + Interfaces() []string + Prepare(st *state.State, name string, users []string) (files map[string][]byte, changed bool, err error) + Apply(ctx context.Context, st *state.State, name string, users []string) error + Close() + } + type Backend struct { + Name string // "awg-grpc", "xray"; used in logs + Suffix string // client file suffix: ".conf", ".txt" + Open func(ctx context.Context) (Session, error) + } + ``` + `cmd/usher` builds each backend's `Open` as a closure over its socket path and the server facts + it needs (`USHER_HOST` for both, `USHER_DNS` for awg). `pass.Settings` keeps the paths only. +- **Environment.** `USHER_AWG_SOCKET` and `USHER_XRAY_SOCKET` are absolute unix socket paths. At + least one must be set. `USHER_HOST` stays required; `USHER_DNS` is required only with + `USHER_AWG_SOCKET`. +- **State, version 2.** + ```json + { + "version": 2, + "awg": { "awg0/alice": { "address": "...", "private_key": "...", "preshared_key": "..." } }, + "xray": { "vless-reality/alice": { "id": "5f0c...-..." } } + } + ``` + Version 1 loads with an empty `xray` group; Save always writes 2. A zero UUID is invalid. usher + never re-keys an existing entry, so a seeded UUID survives every pass. +- **Xray user identity.** `email` is the entry name `tag/user`, `level` is 0, the account is what + the protocol entry builds (`vless.Account{id, flow}`). User names are lowercase by + `config.go:13`, which matches Xray's case-insensitive email handling. New ids come from + `uuid.NewV4()`. +- **Decoder tables** (`internal/xray`), plain function types, no interfaces: + ```go + type protocol struct { + account func(e state.XrayEntry) *serial.TypedMessage + link func(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string + } + + var protocols = map[string]func(proxy []byte, transport, security string) (protocol, error) // by proxy_settings.type + var transports = map[string]func(stream *internet.StreamConfig) (url.Values, error) // by protocol_name + var securities = map[string]func(settings []byte) (url.Values, error) // by security_type + ``` + An empty `security_type` is named `none` in errors. +- **Inbound reading.** `ListInbounds{isOnlyTags: false}`, then per inbound: + - `proxy_settings.type` must have an entry in `protocols`; an inbound without one is not an + usher interface and is skipped silently. The VLESS entry requires `decryption` `none` and sets + the flow. + - The receiver port list must hold one range with `From == To`. + - The `tcp` transport entry accepts all three header forms of raw TCP and fails any other + header. ASSUMPTION: the no-op header arrives as a message of package + `xray.transport.internet.headers.noop`; the entry accepts any message of that package. + - The Reality security entry gives `pbk`, the X25519 public key of `private_key` + (`crypto/ecdh`), unpadded URL-safe base64; `sid`, the lowercase hex of the first short ID with + trailing zero bytes trimmed, so an all-zero ID gives an empty `sid`; `sni`, the first non-empty + server name, failing the inbound when there is none. `mldsa65_seed` is ignored and the link + carries no `pqv`. +- **Reconcile.** `GetInboundUsers{tag}` gives the current users. A current email that is not + desired, or whose account differs from the desired one (same `TypedMessage.type` and + `proto.Equal` of the decoded messages), is removed; a desired email that is missing, or was + removed for a difference, is added. Each operation is one `AlterInbound`. An error fails the + inbound; the next pass converges a partial change. The log line is `users changed` with + `added`, `removed`, `updated` user names, like `peers changed` at `pass.go:179`. +- **Link file.** `clients//.txt`, mode 0600, one line built by the protocol entry: + `vless://@:?#`, where `params` merges the protocol's + `encryption=none` and `flow`, the transport's `type=tcp` and the security's `security=reality`, + `sni`, `fp=chrome`, `pbk`, `sid`, encoded by `url.Values.Encode`. + ASSUMPTION: these parameter names and values are the share-link format phone clients import; + Task 7 pins them against the public proposal. +- **Client file suffix per backend.** `clients.Path` and `clients.Existing` take the backend's + suffix. The pass calls `Existing` once per backend and merges the results: for a failed + interface it keeps that interface's files, for a backend that is down it keeps all of the + backend's files. +- **Delivery contract for the Xray container** (README): Xray v24.11.5 or later; `api.listen` on a + unix path with `,0660`; the Xray process gid equal to usher's group (`user: "0:1000"` in + compose, the counterpart of `AWG_GRPC_SOCKET_GID`); `api.services` limited to + `HandlerService`; VLESS inbounds start with `clients: []`; inbound tags match the usher + interface name pattern; the operator removes a stale socket file after a hard kill, because the + official image has no shell. + +## What Goes Where + +- **Implementation Steps** (`[ ]` checkboxes): code, tests, generated code, deploy examples and + documentation in this repository. +- **Post-Completion** (no checkboxes): the manual link check, the release. + +## Implementation Steps + +### Task 1: Read the awg socket path from the environment + +**Files:** +- Modify: `cmd/usher/env.go` +- Modify: `cmd/usher/main.go` +- Modify: `deploy/compose.example.yml` +- Modify: `deploy/compose.e2e.yml` +- Modify: `cmd/usher/env_test.go` +- Modify: `cmd/usher/main_test.go` + +- [ ] read `USHER_AWG_SOCKET` in `cmd/usher`, required and absolute +- [ ] dial that path instead of `socketTarget` (`main.go:22,57-63`) +- [ ] add `USHER_AWG_SOCKET: /run/awg-grpc/awg.sock` to `compose.example.yml:39-41` and + `compose.e2e.yml:35-37` +- [ ] write tests: variable set, unset (error), relative path (error) +- [ ] run `go test -race ./...` and the awg e2e - must pass before Task 2 + +### Task 2: Introduce the backend seam with the awg driver + +**Files:** +- Create: `internal/awg/` (driver session, enroll, render, apply), moved from `internal/pass` and + `internal/clients/render.go` +- Move: `internal/clients/testdata/phone.conf` with `render_test.go` +- Modify: `internal/pass/pass.go`, `internal/pass/settings.go` +- Modify: `internal/clients/mirror.go` (takes the package doc comment from `render.go:1-2`) +- Modify: `cmd/usher/main.go`, `cmd/usher/main_test.go` (`TestServe`, `main_test.go:59-75`) +- Modify: tests of the moved code (`internal/pass/enroll_test.go`, `internal/pass/fake_test.go`, + `internal/clients/render_test.go`, `internal/clients/mirror_test.go`) +- Modify: `internal/pass/pass_test.go` (cases for the seam) + +- [ ] add `Session` and `Backend` as in the seam sketch; the awg driver implements `Session` with + today's behavior from `pass.go:52-101`, `pass.go:136-138`, `enroll.go:23` and `render.go:18`, + and gets `USHER_HOST` and `USHER_DNS` from its constructor instead of `pass.Settings` +- [ ] make the pass open one session per backend, fail a name reported by two backends in both, + and handle a backend that is down as in Solution Overview +- [ ] give `clients.Path` and `clients.Existing` a suffix argument (`mirror.go:22,48`) and a way to + keep all files of one suffix +- [ ] reword `pass.go:211` to "interface is not reported by any backend" +- [ ] move the existing awg tests with the code; they pass unchanged in substance +- [ ] write tests for the seam with two fake backends: both apply; a name collision; a failed + interface of one backend leaving the other applied and keeping its files; one backend down + while the other applies and the first one's files stay; no unknown-name log while a backend + is down +- [ ] run `go build ./...`, `go vet ./...`, `go test -race ./...` and the awg e2e - must pass + before Task 3 + +### Task 3: Add the xray group to users.json + +**Files:** +- Modify: `internal/state/state.go` +- Modify: `internal/state/state_test.go` + +- [ ] add `Xray map[string]XrayEntry` with `json:"xray"`; `XrayEntry{ID uuid.UUID}` +- [ ] bump `version` to 2; `Load` accepts 1 and 2 (`state.go:107`), `Save` writes 2 +- [ ] validate xray entry names as `tag/user` and reject a zero UUID; errors never quote the UUID +- [ ] reword the doc comment at `state.go:71`; keep `Entry`, `EntryName` and `Entry.Route`, which + `test/e2e/e2e_test.go:24,406` uses +- [ ] write tests: load version 1 (empty `xray`), load version 2, reject version 3, reject a zero + UUID, save and load round trip, no UUID in any error text +- [ ] run `go test -race ./...` - must pass before Task 4 + +### Task 4: Vendor the Xray protos and generate code + +**Files:** +- Create: `proto/xray/` (13 files from Xray-core v26.9.9, original relative paths kept) +- Create: `proto/xray/README.md` (source tag, MPL-2.0 notice, refresh steps) +- Create: `buf.yaml`, `buf.gen.yaml`, `tools/go.mod`, `tools/go.sum` +- Create: `gen/xray/...` (generated, committed) +- Modify: `go.mod`, `go.sum` (`google.golang.org/protobuf` becomes a direct requirement) +- Modify: `Dockerfile` (`COPY gen ./gen` next to `Dockerfile:8-9`) +- Modify: `scripts/format.sh` +- Modify: `.github/workflows/ci.yml` (`tools/go.sum` in `cache-dependency-path`) + +- [ ] copy the closure of `command.proto`, `app/proxyman/config.proto`, + `proxy/vless/inbound/config.proto`, `proxy/vless/account.proto`, + `transport/internet/reality/config.proto`, `transport/internet/config.proto` and + `transport/internet/tcp/config.proto` +- [ ] `buf.yaml` with the module at `proto/xray`; `buf.gen.yaml` with `out: gen/xray`, + `paths=source_relative` and managed `go_package_prefix: github.com/mrcsin/usher/gen/xray`, + so every proto package keeps its own Go package +- [ ] add the tools module pinning buf, `protoc-gen-go`, `protoc-gen-go-grpc`, as in + `awg-grpc/tools/go.mod:5-9` +- [ ] extend `scripts/format.sh` to check that `gen/` equals `buf generate`; it runs no `buf lint`, + unlike `awg-grpc/scripts/format.sh:26`, because the vendored files are not usher's and fail + the default rules +- [ ] no unit test in this task: it adds generated code only; `scripts/format.sh`, `go build ./...` + and `docker build .` verify it, and Tasks 5 and 6 exercise the types +- [ ] run `scripts/format.sh`, `go test -race ./...` and `docker build .` - must pass before Task 5 + +### Task 5: Read inbounds through the decoder tables + +**Files:** +- Create: `internal/xray/inbound.go` +- Create: `internal/xray/protocol.go`, `internal/xray/transport.go`, `internal/xray/security.go` +- Create: `internal/xray/inbound_test.go`, `internal/xray/protocol_test.go`, + `internal/xray/transport_test.go`, `internal/xray/security_test.go` + +- [ ] add the three decoder tables of Technical Details with one entry each: VLESS, `tcp`, + Reality +- [ ] list inbounds, keep those whose proxy settings type has a protocol entry, decode the single + port, and run the transport and security entries; a missing key or an unsupported value + fails that inbound with an error naming it +- [ ] write tests for the VLESS entry: decryption `none` accepted, other decryption failing, flow + `xtls-rprx-vision` for `tcp` and Reality +- [ ] write tests for the `tcp` entry: no transport settings, `tcp.Config` without a header and a + no-op header accepted; an HTTP header failing +- [ ] write tests for the Reality entry: `pbk` from a fixed private key; `sid` from `ab00000000000000` + as `ab` and from 8 zero bytes as empty; the first server name empty and the second used; + all server names empty failing +- [ ] write tests for inbound reading: a valid inbound; a non-VLESS inbound skipped; a port range, + TLS security, empty security (named `none`) and a non-tcp transport each failing +- [ ] write a leak test: no error or log line contains the private key bytes in any encoding +- [ ] run `go test -race ./...` - must pass before Task 6 + +### Task 6: Reconcile Xray users + +**Files:** +- Create: `internal/xray/session.go` +- Create: `internal/xray/enroll.go` +- Create: `internal/xray/session_test.go` +- Create: `internal/xray/enroll_test.go` + +- [ ] implement `Session` for Xray: open lists the inbounds (Task 5) +- [ ] enroll: create an entry with `uuid.NewV4()` for every referenced user without one; never + change an existing entry +- [ ] apply: read `GetInboundUsers`, compare accounts as in Technical Details, remove extra and + changed users, add missing and changed users with `level` 0, `email` `tag/user` and the + account the protocol entry builds; log `users changed` +- [ ] treat an `AlterInbound` error as a failure of that inbound +- [ ] write tests with a fake `HandlerServiceClient`: add, remove, flow change, id change, + unreferenced inbound emptied, a seeded UUID kept, partial failure converging on the next + pass +- [ ] write a leak test: no usher log line or error contains a UUID +- [ ] run `go test -race ./...` - must pass before Task 7 + +### Task 7: Render VLESS links + +**Files:** +- Create: `internal/xray/link.go` +- Create: `internal/xray/link_test.go` +- Create: `internal/xray/testdata/` (golden link) + +- [ ] implement the VLESS entry's `link` as in Technical Details and write it into + `clients//.txt` in the session's prepare step +- [ ] read the share-link proposal (XTLS/Xray-core discussion #716) and pin parameter names, + values and encodings in a golden test; ASSUMPTION: the proposal covers the Reality + parameters `pbk`, `sid` and `spx` +- [ ] write tests: golden link; a link with an empty `sid`; the user name escaped in the fragment +- [ ] run `go test -race ./...` - must pass before Task 8 + +### Task 8: Wire the Xray backend and the deploy example + +**Files:** +- Modify: `cmd/usher/env.go` +- Modify: `cmd/usher/main.go` +- Modify: `deploy/compose.example.yml` +- Create: `deploy/xray/config.json` +- Modify: `cmd/usher/env_test.go` +- Modify: `cmd/usher/main_test.go` + +- [ ] read `USHER_XRAY_SOCKET`, absolute when set; make `USHER_AWG_SOCKET` optional, require at + least one of the two, and require `USHER_DNS` only with `USHER_AWG_SOCKET` +- [ ] build the Xray backend when `USHER_XRAY_SOCKET` is set, with the 10 s call deadline of + `pass.go:21` +- [ ] add an `xray` service to `compose.example.yml`: the official image, `user: "0:1000"`, the + example `config.json` (api on `/run/xray/api.sock,0660`, `HandlerService` only, one VLESS + Reality inbound with `clients: []`) and the shared socket volume +- [ ] write tests: only Xray, only awg, both, neither (usage error), `USHER_DNS` missing with and + without awg +- [ ] run `go test -race ./...` - must pass before Task 9 + +### Task 9: Xray end-to-end suite + +**Files:** +- Create: `deploy/compose.e2e-xray.yml` (own compose project `name:` and network + `172.30.98.0/24`, apart from the awg suite's `172.30.99.0/24`) +- Create: `deploy/e2e/xray/` (server config, nginx target config, self-signed certificate) +- Create: `test/e2e/xray/xray_test.go` (own package and `TestMain`, scratch root `.e2e-xray`) +- Modify: `.gitignore` (`.e2e-xray`) +- Modify: `.github/workflows/ci.yml` + +- [ ] compose project with Xray server (official image, pinned tag, `user: "0:${E2E_GID:-0}"`), + nginx with a self-signed certificate and `ssl_protocols TLSv1.3` as the Reality target, usher + built from the tree, and an Xray client profile +- [ ] the test config holds two users; the test builds each client JSON by copying `pbk`, `sid` + and `sni` from the rendered link unchanged into `publicKey`, `shortId` and `serverName` +- [ ] assert the three outcomes of Acceptance Evidence item 3 +- [ ] run the suite in the CI `e2e` job next to `ci.yml:62` +- [ ] run `go test -tags e2e -count=1 -v ./test/e2e/xray/` - must pass before Task 10 + +### Task 10: Verify acceptance criteria + +- [ ] verify all requirements from Overview are implemented +- [ ] verify edge cases are handled +- [ ] run full test suite: `go test -race -count=1 ./...` +- [ ] run `scripts/format.sh`, `shellcheck scripts/*.sh scripts/ci/*.sh scripts/git-hooks/pre-commit` + and `go run github.com/rhysd/actionlint/cmd/actionlint@latest` +- [ ] run e2e tests: `go test -tags e2e -count=1 -v ./test/e2e/...` on a host with the module +- [ ] verify test coverage meets project standard + +### Task 11: [Final] Update documentation + +- [ ] README: the two socket variables, the Xray delivery contract, link files, switching off in + Xray versus AmneziaWG +- [ ] `docs/architecture/overview.md`: the backend seam, a backend that is down, the decoder tables + and how a new shape is added, Xray keys and links, state version 2; generalize the locked + decisions at lines 64, 65, 70, 71, 74, 76 +- [ ] `AGENTS.md`: layout (`internal/awg`, `internal/xray`, `proto/xray`, `gen/`, `tools/`, + `test/e2e/xray`), generation command, Xray e2e, the UUID and the Reality private key in the + never-logged list; reword "usher saves it before `ApplyPeers`" for both backends +- [ ] move this plan to `docs/plans/completed/` + +## Post-Completion + +*Items requiring manual intervention or external systems - no checkboxes, informational only* + +**Manual verification** +- import one rendered link into a phone client and open a page through it; the link carries a + trimmed `sid`, which phone clients have not been checked against + +**Release** +- tag the release with a `!` header; the notes state that `USHER_AWG_SOCKET` is now required and + that the previous image cannot read `users.json` once this version has saved it + +**Follow-up** +- per-user metrics (issue #1) for both backends; `StatsService` joins the delivery contract then From bd02bac7f0163da825335f69a0e00a316bd8bfd8 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:10:38 +0300 Subject: [PATCH 02/29] feat(usher)!: read awg socket from environment --- cmd/usher/env.go | 17 +++++++++++ cmd/usher/env_test.go | 47 +++++++++++++++++++----------- cmd/usher/main.go | 17 ++++++----- cmd/usher/main_test.go | 6 ++-- deploy/compose.e2e.yml | 1 + deploy/compose.example.yml | 1 + docs/plans/20261007-xray-driver.md | 10 +++---- internal/pass/settings.go | 1 + 8 files changed, 67 insertions(+), 33 deletions(-) diff --git a/cmd/usher/env.go b/cmd/usher/env.go index a45adf3..af1277d 100644 --- a/cmd/usher/env.go +++ b/cmd/usher/env.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net/netip" + "path/filepath" "strings" "github.com/mrcsin/usher/internal/pass" @@ -20,6 +21,11 @@ func settingsFrom(getenv func(string) string) (pass.Settings, error) { return pass.Settings{}, err } + awgSocket, err := parseSocketPath("USHER_AWG_SOCKET", getenv("USHER_AWG_SOCKET")) + if err != nil { + return pass.Settings{}, err + } + var dns []netip.Addr for _, field := range strings.Split(getenv("USHER_DNS"), ",") { addr, err := parseIPv4("USHER_DNS", strings.TrimSpace(field)) @@ -32,6 +38,7 @@ func settingsFrom(getenv func(string) string) (pass.Settings, error) { return pass.Settings{ Host: host, DNS: dns, + AWGSocket: awgSocket, ConfigPath: configPath, ClientsDir: clientsPath, StatePath: statePath, @@ -48,3 +55,13 @@ func parseIPv4(name, value string) (netip.Addr, error) { } return addr, nil } + +func parseSocketPath(name, value string) (string, error) { + if value == "" { + return "", fmt.Errorf("%s is required", name) + } + if !filepath.IsAbs(value) { + return "", fmt.Errorf("%s: %q is not an absolute path", name, value) + } + return value, nil +} diff --git a/cmd/usher/env_test.go b/cmd/usher/env_test.go index 7e0207a..c4080d7 100644 --- a/cmd/usher/env_test.go +++ b/cmd/usher/env_test.go @@ -18,58 +18,70 @@ func TestSettingsFrom(t *testing.T) { }{ { name: "one DNS address", - env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1"}, + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, want: pass.Settings{ - Host: netip.MustParseAddr("203.0.113.10"), - DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1")}, + Host: netip.MustParseAddr("203.0.113.10"), + DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1")}, + AWGSocket: "/run/awg.sock", }, }, { name: "two DNS addresses", - env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1, 1.0.0.1"}, + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1, 1.0.0.1"}, want: pass.Settings{ - Host: netip.MustParseAddr("203.0.113.10"), - DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("1.0.0.1")}, + Host: netip.MustParseAddr("203.0.113.10"), + DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("1.0.0.1")}, + AWGSocket: "/run/awg.sock", }, }, { name: "missing host", - env: map[string]string{"USHER_DNS": "1.1.1.1"}, + env: map[string]string{"USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, wantErr: "USHER_HOST", }, { name: "host name", - env: map[string]string{"USHER_HOST": "vpn.example.com", "USHER_DNS": "1.1.1.1"}, + env: map[string]string{"USHER_HOST": "vpn.example.com", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, wantErr: "USHER_HOST", }, { name: "IPv6 host", - env: map[string]string{"USHER_HOST": "2001:db8::1", "USHER_DNS": "1.1.1.1"}, + env: map[string]string{"USHER_HOST": "2001:db8::1", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, wantErr: "USHER_HOST", }, { name: "host with port", - env: map[string]string{"USHER_HOST": "203.0.113.10:51820", "USHER_DNS": "1.1.1.1"}, + env: map[string]string{"USHER_HOST": "203.0.113.10:51820", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, wantErr: "USHER_HOST", }, + { + name: "missing awg socket", + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1"}, + wantErr: "USHER_AWG_SOCKET", + }, + { + name: "relative awg socket", + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "awg.sock", "USHER_DNS": "1.1.1.1"}, + wantErr: "USHER_AWG_SOCKET", + }, { name: "empty DNS", - env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": ""}, + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": ""}, wantErr: "USHER_DNS", }, { name: "IPv6 DNS", - env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "2606:4700:4700::1111"}, + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "2606:4700:4700::1111"}, wantErr: "USHER_DNS", }, { name: "DNS with port", - env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1:53"}, + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1:53"}, wantErr: "USHER_DNS", }, { name: "empty DNS element", - env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1,"}, + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1,"}, wantErr: "USHER_DNS", }, } @@ -85,15 +97,16 @@ func TestSettingsFrom(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if got.Host != tt.want.Host || !slices.Equal(got.DNS, tt.want.DNS) { - t.Errorf("host/dns = %v %v, want %v %v", got.Host, got.DNS, tt.want.Host, tt.want.DNS) + if got.Host != tt.want.Host || !slices.Equal(got.DNS, tt.want.DNS) || got.AWGSocket != tt.want.AWGSocket { + t.Errorf("host/dns/socket = %v %v %q, want %v %v %q", + got.Host, got.DNS, got.AWGSocket, tt.want.Host, tt.want.DNS, tt.want.AWGSocket) } }) } } func TestSettingsFromFixedPaths(t *testing.T) { - env := map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1"} + env := map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"} got, err := settingsFrom(func(key string) string { return env[key] }) if err != nil { t.Fatal(err) diff --git a/cmd/usher/main.go b/cmd/usher/main.go index 41f5fba..f2e4a1d 100644 --- a/cmd/usher/main.go +++ b/cmd/usher/main.go @@ -19,8 +19,6 @@ import ( "github.com/mrcsin/usher/internal/watch" ) -const socketTarget = "unix:///run/awg-grpc/awg.sock" - // version is set at build time with -X main.version. var version = "dev" @@ -41,7 +39,7 @@ func run(ctx context.Context, args []string, getenv func(string) string, stderr return 1 } - return serve(ctx, settings, dialSocket, stderr) + return serve(ctx, settings, dialSocket(settings.AWGSocket), stderr) } func serve(ctx context.Context, settings pass.Settings, dial pass.Dial, stderr io.Writer) int { @@ -54,10 +52,13 @@ func serve(ctx context.Context, settings pass.Settings, dial pass.Dial, stderr i return 0 } -func dialSocket() (awgv1.ManagementServiceClient, func(), error) { - conn, err := grpc.NewClient(socketTarget, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - return nil, nil, fmt.Errorf("opening %s: %w", socketTarget, err) +func dialSocket(path string) pass.Dial { + target := "unix://" + path + return func() (awgv1.ManagementServiceClient, func(), error) { + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, fmt.Errorf("opening %s: %w", target, err) + } + return awgv1.NewManagementServiceClient(conn), func() { _ = conn.Close() }, nil } - return awgv1.NewManagementServiceClient(conn), func() { _ = conn.Close() }, nil } diff --git a/cmd/usher/main_test.go b/cmd/usher/main_test.go index 25144b3..8b57bb0 100644 --- a/cmd/usher/main_test.go +++ b/cmd/usher/main_test.go @@ -16,7 +16,7 @@ import ( ) func TestRun(t *testing.T) { - validEnv := map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1"} + validEnv := map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"} tests := []struct { name string args []string @@ -27,8 +27,8 @@ func TestRun(t *testing.T) { {name: "no argument", args: nil, env: validEnv, wantCode: 2, wantStderr: "usage"}, {name: "unknown argument", args: []string{"serve"}, env: validEnv, wantCode: 2, wantStderr: "usage"}, {name: "extra argument", args: []string{"run", "x"}, env: validEnv, wantCode: 2, wantStderr: "usage"}, - {name: "missing host", args: []string{"run"}, env: map[string]string{"USHER_DNS": "1.1.1.1"}, wantCode: 1, wantStderr: "USHER_HOST"}, - {name: "bad dns", args: []string{"run"}, env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "dns.example"}, wantCode: 1, wantStderr: "USHER_DNS"}, + {name: "missing host", args: []string{"run"}, env: map[string]string{"USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, wantCode: 1, wantStderr: "USHER_HOST"}, + {name: "bad dns", args: []string{"run"}, env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "dns.example"}, wantCode: 1, wantStderr: "USHER_DNS"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/deploy/compose.e2e.yml b/deploy/compose.e2e.yml index efaf486..2e405c4 100644 --- a/deploy/compose.e2e.yml +++ b/deploy/compose.e2e.yml @@ -34,6 +34,7 @@ services: - awg-sock:/run/awg-grpc environment: USHER_HOST: 172.30.99.2 + USHER_AWG_SOCKET: /run/awg-grpc/awg.sock USHER_DNS: 1.1.1.1 # Handshake peer of the e2e test; test/e2e writes its config to ../.e2e/client. client: diff --git a/deploy/compose.example.yml b/deploy/compose.example.yml index a10b82f..891102c 100644 --- a/deploy/compose.example.yml +++ b/deploy/compose.example.yml @@ -38,6 +38,7 @@ services: - awg-sock:/run/awg-grpc environment: USHER_HOST: "203.0.113.10" + USHER_AWG_SOCKET: /run/awg-grpc/awg.sock USHER_DNS: "1.1.1.1,1.0.0.1" volumes: awg-sock: diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index 810dc59..fb0f2a3 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -308,12 +308,12 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Modify: `cmd/usher/env_test.go` - Modify: `cmd/usher/main_test.go` -- [ ] read `USHER_AWG_SOCKET` in `cmd/usher`, required and absolute -- [ ] dial that path instead of `socketTarget` (`main.go:22,57-63`) -- [ ] add `USHER_AWG_SOCKET: /run/awg-grpc/awg.sock` to `compose.example.yml:39-41` and +- [x] read `USHER_AWG_SOCKET` in `cmd/usher`, required and absolute +- [x] dial that path instead of `socketTarget` (`main.go:22,57-63`) +- [x] add `USHER_AWG_SOCKET: /run/awg-grpc/awg.sock` to `compose.example.yml:39-41` and `compose.e2e.yml:35-37` -- [ ] write tests: variable set, unset (error), relative path (error) -- [ ] run `go test -race ./...` and the awg e2e - must pass before Task 2 +- [x] write tests: variable set, unset (error), relative path (error) +- [x] run `go test -race ./...` and the awg e2e - must pass before Task 2 ### Task 2: Introduce the backend seam with the awg driver diff --git a/internal/pass/settings.go b/internal/pass/settings.go index 00c70b8..5783e7a 100644 --- a/internal/pass/settings.go +++ b/internal/pass/settings.go @@ -7,6 +7,7 @@ import "net/netip" type Settings struct { Host netip.Addr // USHER_HOST DNS []netip.Addr // USHER_DNS + AWGSocket string // USHER_AWG_SOCKET ConfigPath string ClientsDir string StatePath string From 00113e4b669591685d29ad3c2b84cebc560478fc Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:19:58 +0300 Subject: [PATCH 03/29] refactor(pass): add backend seam with awg driver --- cmd/usher/env.go | 28 +- cmd/usher/env_test.go | 16 +- cmd/usher/main.go | 29 +- cmd/usher/main_test.go | 15 +- docs/plans/20261007-xray-driver.md | 14 +- internal/{pass => awg}/enroll.go | 2 +- internal/{pass => awg}/enroll_test.go | 2 +- internal/{pass => awg}/fake_test.go | 2 +- internal/{clients => awg}/render.go | 3 +- internal/{clients => awg}/render_test.go | 2 +- internal/awg/session.go | 155 +++++++ internal/awg/session_test.go | 283 ++++++++++++ internal/{clients => awg}/testdata/phone.conf | 0 internal/clients/mirror.go | 29 +- internal/clients/mirror_test.go | 39 +- internal/pass/backend.go | 28 ++ internal/pass/pass.go | 233 +++++----- internal/pass/pass_test.go | 415 ++++++++++-------- internal/pass/settings.go | 10 +- 19 files changed, 910 insertions(+), 395 deletions(-) rename internal/{pass => awg}/enroll.go (99%) rename internal/{pass => awg}/enroll_test.go (99%) rename internal/{pass => awg}/fake_test.go (98%) rename internal/{clients => awg}/render.go (93%) rename internal/{clients => awg}/render_test.go (99%) create mode 100644 internal/awg/session.go create mode 100644 internal/awg/session_test.go rename internal/{clients => awg}/testdata/phone.conf (100%) create mode 100644 internal/pass/backend.go diff --git a/cmd/usher/env.go b/cmd/usher/env.go index af1277d..9b973bb 100644 --- a/cmd/usher/env.go +++ b/cmd/usher/env.go @@ -15,33 +15,39 @@ const ( statePath = "/srv/usher/state/users.json" ) -func settingsFrom(getenv func(string) string) (pass.Settings, error) { +// environment holds the server facts from the process environment and the fixed paths. +type environment struct { + pass.Settings + Host netip.Addr // USHER_HOST + DNS []netip.Addr // USHER_DNS + AWGSocket string // USHER_AWG_SOCKET +} + +func environmentFrom(getenv func(string) string) (environment, error) { host, err := parseIPv4("USHER_HOST", getenv("USHER_HOST")) if err != nil { - return pass.Settings{}, err + return environment{}, err } awgSocket, err := parseSocketPath("USHER_AWG_SOCKET", getenv("USHER_AWG_SOCKET")) if err != nil { - return pass.Settings{}, err + return environment{}, err } var dns []netip.Addr for _, field := range strings.Split(getenv("USHER_DNS"), ",") { addr, err := parseIPv4("USHER_DNS", strings.TrimSpace(field)) if err != nil { - return pass.Settings{}, err + return environment{}, err } dns = append(dns, addr) } - return pass.Settings{ - Host: host, - DNS: dns, - AWGSocket: awgSocket, - ConfigPath: configPath, - ClientsDir: clientsPath, - StatePath: statePath, + return environment{ + Settings: pass.Settings{ConfigPath: configPath, ClientsDir: clientsPath, StatePath: statePath}, + Host: host, + DNS: dns, + AWGSocket: awgSocket, }, nil } diff --git a/cmd/usher/env_test.go b/cmd/usher/env_test.go index c4080d7..08a38fa 100644 --- a/cmd/usher/env_test.go +++ b/cmd/usher/env_test.go @@ -5,21 +5,19 @@ import ( "slices" "strings" "testing" - - "github.com/mrcsin/usher/internal/pass" ) -func TestSettingsFrom(t *testing.T) { +func TestEnvironmentFrom(t *testing.T) { tests := []struct { name string env map[string]string - want pass.Settings + want environment wantErr string }{ { name: "one DNS address", env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, - want: pass.Settings{ + want: environment{ Host: netip.MustParseAddr("203.0.113.10"), DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1")}, AWGSocket: "/run/awg.sock", @@ -28,7 +26,7 @@ func TestSettingsFrom(t *testing.T) { { name: "two DNS addresses", env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1, 1.0.0.1"}, - want: pass.Settings{ + want: environment{ Host: netip.MustParseAddr("203.0.113.10"), DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("1.0.0.1")}, AWGSocket: "/run/awg.sock", @@ -87,7 +85,7 @@ func TestSettingsFrom(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := settingsFrom(func(key string) string { return tt.env[key] }) + got, err := environmentFrom(func(key string) string { return tt.env[key] }) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("err = %v, want it to name %s", err, tt.wantErr) @@ -105,9 +103,9 @@ func TestSettingsFrom(t *testing.T) { } } -func TestSettingsFromFixedPaths(t *testing.T) { +func TestEnvironmentFromFixedPaths(t *testing.T) { env := map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"} - got, err := settingsFrom(func(key string) string { return env[key] }) + got, err := environmentFrom(func(key string) string { return env[key] }) if err != nil { t.Fatal(err) } diff --git a/cmd/usher/main.go b/cmd/usher/main.go index f2e4a1d..2296521 100644 --- a/cmd/usher/main.go +++ b/cmd/usher/main.go @@ -15,6 +15,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/mrcsin/usher/internal/awg" "github.com/mrcsin/usher/internal/pass" "github.com/mrcsin/usher/internal/watch" ) @@ -33,26 +34,42 @@ func run(ctx context.Context, args []string, getenv func(string) string, stderr fmt.Fprintln(stderr, "usage: usher run") return 2 } - settings, err := settingsFrom(getenv) + env, err := environmentFrom(getenv) if err != nil { fmt.Fprintln(stderr, "usher:", err) return 1 } - return serve(ctx, settings, dialSocket(settings.AWGSocket), stderr) + log := slog.New(slog.NewTextHandler(stderr, nil)) + backends := []pass.Backend{awgBackend(env, log)} + return serve(ctx, env.Settings, backends, log) } -func serve(ctx context.Context, settings pass.Settings, dial pass.Dial, stderr io.Writer) int { - log := slog.New(slog.NewTextHandler(stderr, nil)) +func serve(ctx context.Context, settings pass.Settings, backends []pass.Backend, log *slog.Logger) int { log.Info("usher starting", "version", version) - p := pass.New(settings, dial, log) + p := pass.New(settings, backends, log) intervals := watch.Intervals{Poll: 3 * time.Second, Settle: 500 * time.Millisecond, Refill: 30 * time.Second} watch.New(settings.ConfigPath, intervals, p.Run).Run(ctx) log.Info("usher stopped") return 0 } -func dialSocket(path string) pass.Dial { +func awgBackend(env environment, log *slog.Logger) pass.Backend { + dial := dialSocket(env.AWGSocket) + return pass.Backend{ + Name: "awg-grpc", + Suffix: awg.Suffix, + Open: func(ctx context.Context) (pass.Session, error) { + session, err := awg.Open(ctx, dial, env.Host, env.DNS, log) + if err != nil { + return nil, err + } + return session, nil + }, + } +} + +func dialSocket(path string) awg.Dial { target := "unix://" + path return func() (awgv1.ManagementServiceClient, func(), error) { conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) diff --git a/cmd/usher/main_test.go b/cmd/usher/main_test.go index 8b57bb0..0769574 100644 --- a/cmd/usher/main_test.go +++ b/cmd/usher/main_test.go @@ -4,14 +4,12 @@ import ( "bytes" "context" "errors" - "net/netip" + "log/slog" "path/filepath" "strings" "testing" "time" - awgv1 "github.com/mrcsin/awg-grpc/gen/awg/v1" - "github.com/mrcsin/usher/internal/pass" ) @@ -59,20 +57,23 @@ func TestRun(t *testing.T) { func TestServe(t *testing.T) { root := t.TempDir() settings := pass.Settings{ - Host: netip.MustParseAddr("203.0.113.10"), - DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1")}, ConfigPath: filepath.Join(root, "usher.yml"), ClientsDir: filepath.Join(root, "clients"), StatePath: filepath.Join(root, "users.json"), } - dial := func() (awgv1.ManagementServiceClient, func(), error) { return nil, nil, errors.New("no socket") } + backends := []pass.Backend{{ + Name: "fake", + Suffix: ".conf", + Open: func(context.Context) (pass.Session, error) { return nil, errors.New("no socket") }, + }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() time.AfterFunc(200*time.Millisecond, cancel) var stderr bytes.Buffer + log := slog.New(slog.NewTextHandler(&stderr, nil)) done := make(chan int, 1) - go func() { done <- serve(ctx, settings, dial, &stderr) }() + go func() { done <- serve(ctx, settings, backends, log) }() select { case code := <-done: if code != 0 { diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index fb0f2a3..a39a70c 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -328,20 +328,20 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. `internal/clients/render_test.go`, `internal/clients/mirror_test.go`) - Modify: `internal/pass/pass_test.go` (cases for the seam) -- [ ] add `Session` and `Backend` as in the seam sketch; the awg driver implements `Session` with +- [x] add `Session` and `Backend` as in the seam sketch; the awg driver implements `Session` with today's behavior from `pass.go:52-101`, `pass.go:136-138`, `enroll.go:23` and `render.go:18`, and gets `USHER_HOST` and `USHER_DNS` from its constructor instead of `pass.Settings` -- [ ] make the pass open one session per backend, fail a name reported by two backends in both, +- [x] make the pass open one session per backend, fail a name reported by two backends in both, and handle a backend that is down as in Solution Overview -- [ ] give `clients.Path` and `clients.Existing` a suffix argument (`mirror.go:22,48`) and a way to +- [x] give `clients.Path` and `clients.Existing` a suffix argument (`mirror.go:22,48`) and a way to keep all files of one suffix -- [ ] reword `pass.go:211` to "interface is not reported by any backend" -- [ ] move the existing awg tests with the code; they pass unchanged in substance -- [ ] write tests for the seam with two fake backends: both apply; a name collision; a failed +- [x] reword `pass.go:211` to "interface is not reported by any backend" +- [x] move the existing awg tests with the code; they pass unchanged in substance +- [x] write tests for the seam with two fake backends: both apply; a name collision; a failed interface of one backend leaving the other applied and keeping its files; one backend down while the other applies and the first one's files stay; no unknown-name log while a backend is down -- [ ] run `go build ./...`, `go vet ./...`, `go test -race ./...` and the awg e2e - must pass +- [x] run `go build ./...`, `go vet ./...`, `go test -race ./...` and the awg e2e - must pass before Task 3 ### Task 3: Add the xray group to users.json diff --git a/internal/pass/enroll.go b/internal/awg/enroll.go similarity index 99% rename from internal/pass/enroll.go rename to internal/awg/enroll.go index b2f62e3..03173bc 100644 --- a/internal/pass/enroll.go +++ b/internal/awg/enroll.go @@ -1,4 +1,4 @@ -package pass +package awg import ( "crypto/ecdh" diff --git a/internal/pass/enroll_test.go b/internal/awg/enroll_test.go similarity index 99% rename from internal/pass/enroll_test.go rename to internal/awg/enroll_test.go index 6386016..f292d37 100644 --- a/internal/pass/enroll_test.go +++ b/internal/awg/enroll_test.go @@ -1,4 +1,4 @@ -package pass +package awg import ( "bytes" diff --git a/internal/pass/fake_test.go b/internal/awg/fake_test.go similarity index 98% rename from internal/pass/fake_test.go rename to internal/awg/fake_test.go index df011e4..e53eedc 100644 --- a/internal/pass/fake_test.go +++ b/internal/awg/fake_test.go @@ -1,4 +1,4 @@ -package pass +package awg import ( "context" diff --git a/internal/clients/render.go b/internal/awg/render.go similarity index 93% rename from internal/clients/render.go rename to internal/awg/render.go index 4eb2388..9d202d9 100644 --- a/internal/clients/render.go +++ b/internal/awg/render.go @@ -1,5 +1,4 @@ -// Package clients renders AmneziaWG client configs and mirrors them into the clients directory. -package clients +package awg import ( "encoding/base64" diff --git a/internal/clients/render_test.go b/internal/awg/render_test.go similarity index 99% rename from internal/clients/render_test.go rename to internal/awg/render_test.go index 3469e3c..bfe8907 100644 --- a/internal/clients/render_test.go +++ b/internal/awg/render_test.go @@ -1,4 +1,4 @@ -package clients +package awg import ( "bytes" diff --git a/internal/awg/session.go b/internal/awg/session.go new file mode 100644 index 0000000..8153d24 --- /dev/null +++ b/internal/awg/session.go @@ -0,0 +1,155 @@ +// Package awg is the AmneziaWG backend: it enrolls users, renders their client configs and +// applies their peers through the awg-grpc socket. +package awg + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "net/netip" + "slices" + "time" + + awgv1 "github.com/mrcsin/awg-grpc/gen/awg/v1" + + "github.com/mrcsin/usher/internal/clients" + "github.com/mrcsin/usher/internal/state" +) + +// Suffix is the file suffix of an AmneziaWG client config. +const Suffix = ".conf" + +// callTimeout bounds each call to awg-grpc. +const callTimeout = 10 * time.Second + +// Dial opens a connection to awg-grpc. It returns the client and the function that closes the +// connection. +type Dial func() (awgv1.ManagementServiceClient, func(), error) + +// Session is one pass's view of awg-grpc: the interfaces it reported when the session opened. +type Session struct { + client awgv1.ManagementServiceClient + closeConn func() + ifaces map[string]*awgv1.InterfaceStatus + names []string + host netip.Addr + dns []netip.Addr + log *slog.Logger +} + +// Open connects to awg-grpc and lists its interfaces. host and dns go into the client configs. +func Open(ctx context.Context, dial Dial, host netip.Addr, dns []netip.Addr, log *slog.Logger) (*Session, error) { + client, closeConn, err := dial() + if err != nil { + return nil, fmt.Errorf("connecting to awg-grpc: %w", err) + } + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + status, err := client.GetStatus(callCtx, &awgv1.GetStatusRequest{}) + if err != nil { + closeConn() + return nil, fmt.Errorf("getting status: %w", err) + } + s := &Session{ + client: client, + closeConn: closeConn, + ifaces: make(map[string]*awgv1.InterfaceStatus, len(status.GetInterfaces())), + host: host, + dns: dns, + log: log, + } + for _, iface := range status.GetInterfaces() { + s.ifaces[iface.GetName()] = iface + s.names = append(s.names, iface.GetName()) + } + return s, nil +} + +// Interfaces returns the names of the interfaces awg-grpc reported, in the order it reported them. +func (s *Session) Interfaces() []string { + return s.names +} + +// Prepare enrolls the users on the interface and renders their client configs, keyed by +// clients.Path. It reports whether it changed st. It fails an interface that is not present in +// the kernel. +func (s *Session) Prepare(st *state.State, name string, users []string) (map[string][]byte, bool, error) { + iface := s.ifaces[name] + if !iface.GetPresent() { + return nil, false, errors.New("interface is not present in the kernel") + } + changed, err := enroll(s.log, st, iface, users) + if err != nil { + return nil, false, err + } + files := make(map[string][]byte, len(users)) + for _, user := range users { + content, err := Render(st.AWG[state.EntryName(name, user)], iface, s.host, s.dns) + if err != nil { + return nil, false, fmt.Errorf("user %s: %w", user, err) + } + files[clients.Path(user, name, Suffix)] = content + } + return files, changed, nil +} + +// Apply sends the peers of the users to awg-grpc and logs what changed. +func (s *Session) Apply(ctx context.Context, st *state.State, name string, users []string) error { + peers := make([]*awgv1.Peer, 0, len(users)) + for _, user := range users { + e := st.AWG[state.EntryName(name, user)] + peers = append(peers, &awgv1.Peer{ + PublicKey: e.PublicKey(), + PresharedKey: e.PresharedKey[:], + AllowedIp: e.Route().String(), + }) + } + + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + resp, err := s.client.ApplyPeers(callCtx, &awgv1.ApplyPeersRequest{ + InterfaceName: name, + Peers: peers, + AllowEmpty: len(peers) == 0, + }) + if err != nil { + return fmt.Errorf("applying peers: %w", err) + } + + names := publicKeyNames(st, name) + added, removed, updated := nameKeys(names, resp.GetAdded()), nameKeys(names, resp.GetRemoved()), nameKeys(names, resp.GetUpdated()) + if len(added)+len(removed)+len(updated) > 0 { + s.log.Info("peers changed", "interface", name, "added", added, "removed", removed, "updated", updated) + } + return nil +} + +// Close closes the connection to awg-grpc. +func (s *Session) Close() { + s.closeConn() +} + +// publicKeyNames maps the public key of every entry of the interface, switched off or not, to +// its user name. +func publicKeyNames(st *state.State, ifaceName string) map[string]string { + names := make(map[string]string) + for user, e := range st.InterfaceEntries(ifaceName) { + names[string(e.PublicKey())] = user + } + return names +} + +func nameKeys(names map[string]string, keys [][]byte) []string { + out := make([]string, 0, len(keys)) + for _, k := range keys { + if user, ok := names[string(k)]; ok { + out = append(out, user) + } else { + out = append(out, base64.StdEncoding.EncodeToString(k)) + } + } + slices.Sort(out) + return out +} diff --git a/internal/awg/session_test.go b/internal/awg/session_test.go new file mode 100644 index 0000000..5e2bd42 --- /dev/null +++ b/internal/awg/session_test.go @@ -0,0 +1,283 @@ +package awg + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "log/slog" + "maps" + "net/netip" + "slices" + "strings" + "testing" + + awgv1 "github.com/mrcsin/awg-grpc/gen/awg/v1" + + "github.com/mrcsin/usher/internal/state" +) + +type sessionEnv struct { + t *testing.T + client *fakeClient + logs *bytes.Buffer + ifaces []*awgv1.InterfaceStatus + st *state.State +} + +func newSessionEnv(t *testing.T, ifaces ...*awgv1.InterfaceStatus) *sessionEnv { + t.Helper() + e := &sessionEnv{t: t, logs: &bytes.Buffer{}, ifaces: ifaces, st: &state.State{AWG: map[string]state.Entry{}}} + e.client = &fakeClient{status: func() (*awgv1.GetStatusResponse, error) { + return &awgv1.GetStatusResponse{Interfaces: e.ifaces}, nil + }} + return e +} + +func (e *sessionEnv) open() *Session { + e.t.Helper() + dial := func() (awgv1.ManagementServiceClient, func(), error) { return e.client, func() {}, nil } + s, err := Open(context.Background(), dial, netip.MustParseAddr("203.0.113.10"), + []netip.Addr{netip.MustParseAddr("1.1.1.1")}, slog.New(slog.NewTextHandler(e.logs, nil))) + if err != nil { + e.t.Fatal(err) + } + return s +} + +func named(name string, address string, params ...*awgv1.ConfigParam) *awgv1.InterfaceStatus { + return &awgv1.InterfaceStatus{ + Name: name, + Present: true, + PublicKey: bytes.Repeat([]byte{9}, 32), + ListenPort: 51820, + Addresses: []string{address}, + ClientParams: params, + } +} + +func TestOpen(t *testing.T) { + tests := []struct { + name string + dial Dial + wantErr string + }{ + {"dial error", func() (awgv1.ManagementServiceClient, func(), error) { return nil, nil, errors.New("no socket") }, + "connecting to awg-grpc"}, + {"get status error", func() (awgv1.ManagementServiceClient, func(), error) { + c := &fakeClient{status: func() (*awgv1.GetStatusResponse, error) { return nil, errors.New("down") }} + return c, func() {}, nil + }, "getting status"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Open(context.Background(), tt.dial, netip.MustParseAddr("203.0.113.10"), nil, slog.New(slog.DiscardHandler)) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want it to contain %q", err, tt.wantErr) + } + }) + } + t.Run("lists the interfaces in reported order", func(t *testing.T) { + e := newSessionEnv(t, named("awg1", "10.9.1.1/24"), named("awg0", "10.8.1.1/24")) + if got := e.open().Interfaces(); !slices.Equal(got, []string{"awg1", "awg0"}) { + t.Errorf("Interfaces = %v", got) + } + }) +} + +func TestSessionRequestShape(t *testing.T) { + e := newSessionEnv(t, named("awg0", "10.8.1.1/24"), named("awg1", "10.9.1.1/24")) + s := e.open() + users := map[string][]string{"awg0": {"laptop", "phone"}, "awg1": nil} + + for _, name := range s.Interfaces() { + if _, _, err := s.Prepare(e.st, name, users[name]); err != nil { + t.Fatal(err) + } + if err := s.Apply(context.Background(), e.st, name, users[name]); err != nil { + t.Fatal(err) + } + } + + if len(e.client.applied) != 2 { + t.Fatalf("requests = %d, want 2", len(e.client.applied)) + } + first, second := e.client.applied[0], e.client.applied[1] + if first.GetInterfaceName() != "awg0" || first.GetAllowEmpty() || len(first.GetPeers()) != 2 { + t.Fatalf("awg0 request: allow_empty=%v peers=%d", first.GetAllowEmpty(), len(first.GetPeers())) + } + for i, user := range users["awg0"] { + p := first.GetPeers()[i] + want := e.st.AWG["awg0/"+user] + if !bytes.Equal(p.GetPublicKey(), want.PublicKey()) || len(p.GetPublicKey()) != 32 { + t.Errorf("%s: public key mismatch", user) + } + if !bytes.Equal(p.GetPresharedKey(), want.PresharedKey[:]) { + t.Errorf("%s: preshared key mismatch", user) + } + if p.GetAllowedIp() != want.Route().String() { + t.Errorf("%s: allowed ip = %q", user, p.GetAllowedIp()) + } + } + if second.GetInterfaceName() != "awg1" || !second.GetAllowEmpty() || len(second.GetPeers()) != 0 { + t.Errorf("awg1 request: allow_empty=%v peers=%d", second.GetAllowEmpty(), len(second.GetPeers())) + } +} + +func TestPrepare(t *testing.T) { + t.Run("renders one file per user and reports new entries", func(t *testing.T) { + e := newSessionEnv(t, named("awg0", "10.8.1.1/24")) + s := e.open() + + files, changed, err := s.Prepare(e.st, "awg0", []string{"laptop", "phone"}) + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if got := slices.Sorted(maps.Keys(files)); !slices.Equal(got, []string{"laptop/awg0.conf", "phone/awg0.conf"}) { + t.Errorf("files = %v", got) + } + if !strings.Contains(string(files["phone/awg0.conf"]), "[Peer]") { + t.Errorf("phone config has no peer section") + } + + _, changed, err = s.Prepare(e.st, "awg0", []string{"laptop", "phone"}) + if err != nil || changed { + t.Errorf("second prepare: changed=%v err=%v", changed, err) + } + }) + missing := named("awg0", "10.8.1.1/24") + missing.Present = false + tests := []struct { + name string + iface *awgv1.InterfaceStatus + }{ + {"not present", missing}, + {"no free address", named("awg0", "10.8.1.1/30")}, + {"render error", named("awg0", "10.8.1.1/24", &awgv1.ConfigParam{Key: "S1", Value: "a\nb"})}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newSessionEnv(t, tt.iface) + if _, _, err := e.open().Prepare(e.st, "awg0", []string{"phone", "laptop"}); err == nil { + t.Fatal("want an error") + } + }) + } +} + +func TestApplyError(t *testing.T) { + e := newSessionEnv(t, named("awg0", "10.8.1.1/24")) + e.client.apply = func(*awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { + return nil, errors.New("rejected") + } + s := e.open() + if _, _, err := s.Prepare(e.st, "awg0", []string{"phone"}); err != nil { + t.Fatal(err) + } + if err := s.Apply(context.Background(), e.st, "awg0", []string{"phone"}); err == nil || !strings.Contains(err.Error(), "rejected") { + t.Fatalf("err = %v", err) + } +} + +func TestApplyLogsResult(t *testing.T) { + unknown := bytes.Repeat([]byte{7}, 32) + tests := []struct { + name string + response *awgv1.ApplyPeersResponse + want []string + silent bool + }{ + {"user names", nil, []string{"added=[phone]"}, false}, + {"key without entry", &awgv1.ApplyPeersResponse{Updated: [][]byte{unknown}}, + []string{base64.StdEncoding.EncodeToString(unknown)}, false}, + {"empty result", &awgv1.ApplyPeersResponse{}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newSessionEnv(t, named("awg0", "10.8.1.1/24")) + e.client.apply = func(r *awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { + if tt.response != nil { + return tt.response, nil + } + return &awgv1.ApplyPeersResponse{Added: [][]byte{r.GetPeers()[0].GetPublicKey()}}, nil + } + s := e.open() + if _, _, err := s.Prepare(e.st, "awg0", []string{"phone"}); err != nil { + t.Fatal(err) + } + + if err := s.Apply(context.Background(), e.st, "awg0", []string{"phone"}); err != nil { + t.Fatal(err) + } + + for _, w := range tt.want { + if !strings.Contains(e.logs.String(), w) { + t.Errorf("log lacks %q: %s", w, e.logs) + } + } + if tt.silent && strings.Contains(e.logs.String(), "peers changed") { + t.Errorf("empty result was logged: %s", e.logs) + } + }) + } +} + +func TestApplySwitchOff(t *testing.T) { + e := newSessionEnv(t, named("awg0", "10.8.1.1/24")) + s := e.open() + if _, _, err := s.Prepare(e.st, "awg0", []string{"laptop", "phone"}); err != nil { + t.Fatal(err) + } + laptop := e.st.AWG["awg0/laptop"] + e.client.apply = func(*awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { + return &awgv1.ApplyPeersResponse{Removed: [][]byte{laptop.PublicKey()}}, nil + } + + if err := s.Apply(context.Background(), e.st, "awg0", []string{"phone"}); err != nil { + t.Fatal(err) + } + + if got := e.client.applied[0]; len(got.GetPeers()) != 1 { + t.Fatalf("peers = %d, want 1", len(got.GetPeers())) + } + if !strings.Contains(e.logs.String(), "removed=[laptop]") { + t.Errorf("log does not name the removed user: %s", e.logs) + } +} + +func TestLogHasNoSecrets(t *testing.T) { + const headerKey = "HDRKEYSECRET" + const leak = "LEAKSECRET" + e := newSessionEnv(t, + named("awg0", "10.8.1.1/24", &awgv1.ConfigParam{Key: "HeaderProtectionKey", Value: headerKey}), + named("awg1", "10.9.1.1/24", &awgv1.ConfigParam{Key: "S1", Value: "x\n" + leak})) + e.client.apply = func(r *awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { + return &awgv1.ApplyPeersResponse{Added: [][]byte{r.GetPeers()[0].GetPublicKey()}}, nil + } + s := e.open() + + for _, name := range s.Interfaces() { + _, _, err := s.Prepare(e.st, name, []string{"phone"}) + if err != nil { + e.logs.WriteString(err.Error()) + continue + } + if err := s.Apply(context.Background(), e.st, name, []string{"phone"}); err != nil { + t.Fatal(err) + } + } + + logs := e.logs.String() + if logs == "" { + t.Fatal("no log output") + } + secrets := []string{headerKey, leak} + for _, entry := range e.st.AWG { + secrets = append(secrets, entry.PrivateKey.String(), entry.PresharedKey.String()) + } + for _, s := range secrets { + if strings.Contains(logs, s) { + t.Errorf("log contains a secret: %q", s) + } + } +} diff --git a/internal/clients/testdata/phone.conf b/internal/awg/testdata/phone.conf similarity index 100% rename from internal/clients/testdata/phone.conf rename to internal/awg/testdata/phone.conf diff --git a/internal/clients/mirror.go b/internal/clients/mirror.go index 8cebee6..c9308a7 100644 --- a/internal/clients/mirror.go +++ b/internal/clients/mirror.go @@ -1,3 +1,4 @@ +// Package clients mirrors client files into the clients directory. package clients import ( @@ -17,19 +18,17 @@ const ( clientDirMode = 0o755 ) -// Path returns the slash-separated path of a user's config on an interface, relative to the -// clients directory. -func Path(user, ifaceName string) string { - return user + "/" + ifaceName + ".conf" +// Path returns the slash-separated path of a user's client file on an interface, relative to the +// clients directory. suffix is the backend's file suffix, such as ".conf". +func Path(user, ifaceName, suffix string) string { + return user + "/" + ifaceName + suffix } -// Existing returns the current content of the configs in dir that belong to the interfaces in -// ifaces, keyed by Path. It reads as much as it can and returns the errors of the rest joined. -func Existing(dir string, ifaces map[string]bool) (map[string][]byte, error) { +// Existing returns the current content of the files in dir that end in suffix and belong to an +// interface for which keep returns true, keyed by Path. It reads as much as it can and returns +// the errors of the rest joined. +func Existing(dir, suffix string, keep func(ifaceName string) bool) (map[string][]byte, error) { files := make(map[string][]byte) - if len(ifaces) == 0 { - return files, nil - } userDirs, err := os.ReadDir(dir) if err != nil { return files, err @@ -39,14 +38,14 @@ func Existing(dir string, ifaces map[string]bool) (map[string][]byte, error) { if !d.IsDir() { continue } - confs, err := os.ReadDir(filepath.Join(dir, d.Name())) + entries, err := os.ReadDir(filepath.Join(dir, d.Name())) if err != nil { errs = append(errs, err) continue } - for _, f := range confs { - ifaceName, ok := strings.CutSuffix(f.Name(), ".conf") - if !ok || f.IsDir() || !ifaces[ifaceName] { + for _, f := range entries { + ifaceName, ok := strings.CutSuffix(f.Name(), suffix) + if !ok || f.IsDir() || !keep(ifaceName) { continue } content, err := os.ReadFile(filepath.Join(dir, d.Name(), f.Name())) @@ -54,7 +53,7 @@ func Existing(dir string, ifaces map[string]bool) (map[string][]byte, error) { errs = append(errs, err) continue } - files[Path(d.Name(), ifaceName)] = content + files[Path(d.Name(), ifaceName, suffix)] = content } } return files, errors.Join(errs...) diff --git a/internal/clients/mirror_test.go b/internal/clients/mirror_test.go index 89cef37..3aeac07 100644 --- a/internal/clients/mirror_test.go +++ b/internal/clients/mirror_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "testing" "time" ) @@ -149,23 +150,39 @@ func TestMirrorKeepsMtimeOfUnchangedFile(t *testing.T) { } func TestExisting(t *testing.T) { + only := func(names ...string) func(string) bool { + return func(name string) bool { return slices.Contains(names, name) } + } + all := func(string) bool { return true } + none := func(string) bool { return false } tests := []struct { name string existing map[string]string - ifaces map[string]bool + suffix string + keep func(string) bool want map[string][]byte }{ { - name: "returns only the configs of the named interfaces", + name: "returns only the files of the kept interfaces", existing: map[string]string{ "phone/awg0.conf": "a", "phone/awg1.conf": "b", "laptop/awg1.conf": "c", "phone/notes.txt": "d", }, - ifaces: map[string]bool{"awg1": true}, + suffix: ".conf", + keep: only("awg1"), want: map[string][]byte{"phone/awg1.conf": []byte("b"), "laptop/awg1.conf": []byte("c")}, }, { - name: "no interfaces reads nothing", + name: "keeping all returns every file of the suffix", + existing: map[string]string{"phone/awg0.conf": "a", "phone/vless.txt": "b", "laptop/vless.txt": "c"}, + suffix: ".txt", + keep: all, + want: map[string][]byte{"phone/vless.txt": []byte("b"), "laptop/vless.txt": []byte("c")}, + }, + { + name: "keeping none reads nothing", existing: map[string]string{"phone/awg0.conf": "a"}, + suffix: ".conf", + keep: none, want: map[string][]byte{}, }, } @@ -175,7 +192,7 @@ func TestExisting(t *testing.T) { for rel, content := range tt.existing { writeFile(t, filepath.Join(dir, filepath.FromSlash(rel)), content, 0o600) } - got, err := Existing(dir, tt.ifaces) + got, err := Existing(dir, tt.suffix, tt.keep) if err != nil { t.Fatal(err) } @@ -185,14 +202,20 @@ func TestExisting(t *testing.T) { }) } t.Run("missing directory is an error", func(t *testing.T) { - if _, err := Existing(filepath.Join(t.TempDir(), "gone"), map[string]bool{"awg0": true}); err == nil { + if _, err := Existing(filepath.Join(t.TempDir(), "gone"), ".conf", all); err == nil { t.Fatal("want an error") } }) } func TestPath(t *testing.T) { - if got := Path("phone", "awg0"); got != "phone/awg0.conf" { - t.Errorf("Path = %q, want phone/awg0.conf", got) + tests := []struct{ suffix, want string }{ + {".conf", "phone/awg0.conf"}, + {".txt", "phone/awg0.txt"}, + } + for _, tt := range tests { + if got := Path("phone", "awg0", tt.suffix); got != tt.want { + t.Errorf("Path(%q) = %q, want %q", tt.suffix, got, tt.want) + } } } diff --git a/internal/pass/backend.go b/internal/pass/backend.go new file mode 100644 index 0000000..bf89e54 --- /dev/null +++ b/internal/pass/backend.go @@ -0,0 +1,28 @@ +package pass + +import ( + "context" + + "github.com/mrcsin/usher/internal/state" +) + +// Session is one pass's connection to a backend. Open lists the backend's interfaces; the +// session prepares and applies one interface at a time. +type Session interface { + // Interfaces returns the names of the interfaces the backend reported. + Interfaces() []string + // Prepare enrolls the users on the interface in st and returns their client files, keyed by + // clients.Path, and whether it changed st. The pass saves st before any Apply. + Prepare(st *state.State, name string, users []string) (files map[string][]byte, changed bool, err error) + // Apply makes the interface hold exactly the users. + Apply(ctx context.Context, st *state.State, name string, users []string) error + // Close releases the connection. + Close() +} + +// Backend is a VPN server usher moves users into. +type Backend struct { + Name string // used in logs, such as "awg-grpc" + Suffix string // suffix of the client files the backend renders, such as ".conf" + Open func(ctx context.Context) (Session, error) +} diff --git a/internal/pass/pass.go b/internal/pass/pass.go index 01bebfb..f9d5731 100644 --- a/internal/pass/pass.go +++ b/internal/pass/pass.go @@ -2,43 +2,45 @@ package pass import ( "context" - "encoding/base64" - "errors" "fmt" "log/slog" "maps" "slices" - "time" - - awgv1 "github.com/mrcsin/awg-grpc/gen/awg/v1" + "strings" "github.com/mrcsin/usher/internal/clients" "github.com/mrcsin/usher/internal/config" "github.com/mrcsin/usher/internal/state" ) -// callTimeout bounds each call to awg-grpc. -const callTimeout = 10 * time.Second - -// Dial opens a connection to awg-grpc. It returns the client and the function that closes the -// connection. -type Dial func() (awgv1.ManagementServiceClient, func(), error) - // Pass runs reconcile passes. It keeps the last valid usher.yml it read. type Pass struct { settings Settings - dial Dial + backends []Backend log *slog.Logger last map[string][]string } -// New returns a Pass that works with the paths and server facts in settings. -func New(settings Settings, dial Dial, log *slog.Logger) *Pass { - return &Pass{settings: settings, dial: dial, log: log} +// New returns a Pass that works with the paths in settings and moves users into backends. +func New(settings Settings, backends []Backend, log *slog.Logger) *Pass { + return &Pass{settings: settings, backends: backends, log: log} +} + +// openBackend is a backend with its session for this pass. +type openBackend struct { + Backend + session Session +} + +// interfaceKey identifies an interface of one backend, by the backend's index in the open list. +type interfaceKey struct { + backend int + name string } // Run executes one pass. It logs every failure and never panics on one; a failure of one -// interface leaves the others to apply. +// interface leaves the others to apply, and a backend that is down leaves the other backends to +// apply. func (p *Pass) Run(ctx context.Context) { cfg, ok := p.loadConfig() if !ok { @@ -49,37 +51,36 @@ func (p *Pass) Run(ctx context.Context) { p.log.Error("loading state", "error", err) return } - client, closeConn, err := p.dial() - if err != nil { - p.log.Error("connecting to awg-grpc", "error", err) - return - } - defer closeConn() - - callCtx, cancel := context.WithTimeout(ctx, callTimeout) - status, err := client.GetStatus(callCtx, &awgv1.GetStatusRequest{}) - cancel() - if err != nil { - p.log.Error("getting status", "error", err) - return - } + open, down := p.openBackends(ctx) + defer func() { + for _, b := range open { + b.session.Close() + } + }() byInterface := usersByInterface(cfg) - reported := make(map[string]bool, len(status.GetInterfaces())) - rendered := make(map[string]map[string][]byte) - failed := make(map[string]bool) + owners := interfaceOwners(open) + failed := make(map[interfaceKey]bool) + rendered := make(map[interfaceKey]map[string][]byte) changed := false - for _, iface := range status.GetInterfaces() { - name := iface.GetName() - reported[name] = true - files, entriesChanged, err := p.prepare(st, iface, byInterface[name]) - if err != nil { - p.log.Error("interface failed", "interface", name, "error", err) - failed[name] = true - continue + for i, b := range open { + for _, name := range b.session.Interfaces() { + key := interfaceKey{i, name} + if len(owners[name]) > 1 { + err := fmt.Errorf("reported by %s", strings.Join(owners[name], " and ")) + p.log.Error("interface failed", "backend", b.Name, "interface", name, "error", err) + failed[key] = true + continue + } + files, entriesChanged, err := b.session.Prepare(st, name, byInterface[name]) + if err != nil { + p.log.Error("interface failed", "backend", b.Name, "interface", name, "error", err) + failed[key] = true + continue + } + rendered[key] = files + changed = changed || entriesChanged } - rendered[name] = files - changed = changed || entriesChanged } if changed { @@ -89,35 +90,67 @@ func (p *Pass) Run(ctx context.Context) { } } - for _, iface := range status.GetInterfaces() { - name := iface.GetName() - if failed[name] { - continue - } - if err := p.apply(ctx, client, st, name, byInterface[name]); err != nil { - p.log.Error("interface failed", "interface", name, "error", err) - failed[name] = true + for i, b := range open { + for _, name := range b.session.Interfaces() { + key := interfaceKey{i, name} + if failed[key] { + continue + } + if err := b.session.Apply(ctx, st, name, byInterface[name]); err != nil { + p.log.Error("interface failed", "backend", b.Name, "interface", name, "error", err) + failed[key] = true + } } } - p.logUnknown(cfg, reported) + // A backend that is down hides its interface names, so every unreferenced name could be one. + if len(down) == 0 { + p.logUnknown(cfg, owners) + } desired := make(map[string][]byte) - for name, files := range rendered { - if !failed[name] { + for key, files := range rendered { + if !failed[key] { maps.Copy(desired, files) } } - kept, err := clients.Existing(p.settings.ClientsDir, failed) - if err != nil { - p.log.Error("keeping client configs", "error", err) + for i, b := range open { + p.keepFiles(desired, b.Backend, func(name string) bool { return failed[interfaceKey{i, name}] }) + } + for _, b := range down { + p.keepFiles(desired, b, func(string) bool { return true }) } - maps.Copy(desired, kept) if err := clients.Mirror(p.settings.ClientsDir, desired); err != nil { p.log.Error("mirroring clients directory", "error", err) } } +// openBackends opens a session for every backend and returns the backends that failed to open. +func (p *Pass) openBackends(ctx context.Context) ([]openBackend, []Backend) { + var open []openBackend + var down []Backend + for _, b := range p.backends { + session, err := b.Open(ctx) + if err != nil { + p.log.Error("backend is down", "backend", b.Name, "error", err) + down = append(down, b) + continue + } + open = append(open, openBackend{b, session}) + } + return open, down +} + +// keepFiles copies the current client files of the backend's interfaces that keep selects into +// desired. +func (p *Pass) keepFiles(desired map[string][]byte, b Backend, keep func(ifaceName string) bool) { + kept, err := clients.Existing(p.settings.ClientsDir, b.Suffix, keep) + if err != nil { + p.log.Error("keeping client files", "backend", b.Name, "error", err) + } + maps.Copy(desired, kept) +} + func (p *Pass) loadConfig() (map[string][]string, bool) { cfg, err := config.Load(p.settings.ConfigPath) if err == nil { @@ -132,83 +165,23 @@ func (p *Pass) loadConfig() (map[string][]string, bool) { return p.last, true } -func (p *Pass) prepare(st *state.State, iface *awgv1.InterfaceStatus, users []string) (map[string][]byte, bool, error) { - if !iface.GetPresent() { - return nil, false, errors.New("interface is not present in the kernel") - } - changed, err := enroll(p.log, st, iface, users) - if err != nil { - return nil, false, err - } - files := make(map[string][]byte, len(users)) - for _, user := range users { - content, err := clients.Render(st.AWG[state.EntryName(iface.GetName(), user)], iface, p.settings.Host, p.settings.DNS) - if err != nil { - return nil, false, fmt.Errorf("user %s: %w", user, err) - } - files[clients.Path(user, iface.GetName())] = content - } - return files, changed, nil -} - -func (p *Pass) apply(ctx context.Context, client awgv1.ManagementServiceClient, st *state.State, ifaceName string, users []string) error { - peers := make([]*awgv1.Peer, 0, len(users)) - for _, user := range users { - e := st.AWG[state.EntryName(ifaceName, user)] - peers = append(peers, &awgv1.Peer{ - PublicKey: e.PublicKey(), - PresharedKey: e.PresharedKey[:], - AllowedIp: e.Route().String(), - }) - } - - callCtx, cancel := context.WithTimeout(ctx, callTimeout) - defer cancel() - resp, err := client.ApplyPeers(callCtx, &awgv1.ApplyPeersRequest{ - InterfaceName: ifaceName, - Peers: peers, - AllowEmpty: len(peers) == 0, - }) - if err != nil { - return fmt.Errorf("applying peers: %w", err) - } - - names := publicKeyNames(st, ifaceName) - added, removed, updated := nameKeys(names, resp.GetAdded()), nameKeys(names, resp.GetRemoved()), nameKeys(names, resp.GetUpdated()) - if len(added)+len(removed)+len(updated) > 0 { - p.log.Info("peers changed", "interface", ifaceName, "added", added, "removed", removed, "updated", updated) - } - return nil -} - -// publicKeyNames maps the public key of every entry of the interface, switched off or not, to -// its user name. -func publicKeyNames(st *state.State, ifaceName string) map[string]string { - names := make(map[string]string) - for user, e := range st.InterfaceEntries(ifaceName) { - names[string(e.PublicKey())] = user - } - return names -} - -func nameKeys(names map[string]string, keys [][]byte) []string { - out := make([]string, 0, len(keys)) - for _, k := range keys { - if user, ok := names[string(k)]; ok { - out = append(out, user) - } else { - out = append(out, base64.StdEncoding.EncodeToString(k)) +// interfaceOwners maps every reported interface name to the names of the backends that +// reported it. +func interfaceOwners(open []openBackend) map[string][]string { + owners := make(map[string][]string) + for _, b := range open { + for _, name := range b.session.Interfaces() { + owners[name] = append(owners[name], b.Name) } } - slices.Sort(out) - return out + return owners } -func (p *Pass) logUnknown(cfg map[string][]string, reported map[string]bool) { +func (p *Pass) logUnknown(cfg map[string][]string, owners map[string][]string) { for _, user := range slices.Sorted(maps.Keys(cfg)) { for _, ifaceName := range cfg[user] { - if !reported[ifaceName] { - p.log.Error("interface is not reported by awg-grpc", "interface", ifaceName, "user", user) + if _, ok := owners[ifaceName]; !ok { + p.log.Error("interface is not reported by any backend", "interface", ifaceName, "user", user) } } } diff --git a/internal/pass/pass_test.go b/internal/pass/pass_test.go index 3fc8154..ed4ec5a 100644 --- a/internal/pass/pass_test.go +++ b/internal/pass/pass_test.go @@ -3,8 +3,8 @@ package pass import ( "bytes" "context" - "encoding/base64" "errors" + "fmt" "log/slog" "maps" "net/netip" @@ -15,50 +15,107 @@ import ( "testing" "time" - awgv1 "github.com/mrcsin/awg-grpc/gen/awg/v1" - "github.com/mrcsin/usher/internal/state" ) +// fakeSession is a Session that renders "new " into one file per user and enrolls a +// state entry the first time an interface has users. +type fakeSession struct { + names []string + suffix string + + prepareErr map[string]error + applyErr map[string]error + onApply func(name string) + + applied map[string][]string + closed bool +} + +func (f *fakeSession) Interfaces() []string { return f.names } + +func (f *fakeSession) Prepare(st *state.State, name string, users []string) (map[string][]byte, bool, error) { + if err := f.prepareErr[name]; err != nil { + return nil, false, err + } + files := make(map[string][]byte, len(users)) + for _, user := range users { + files[user+"/"+name+f.suffix] = []byte("new " + name) + } + entryName := state.EntryName("fake", name) + _, enrolled := st.AWG[entryName] + changed := len(users) > 0 && !enrolled + if changed { + st.AWG[entryName] = state.Entry{ + Address: netip.MustParseAddr("10.0.0.2"), + PrivateKey: state.Key(bytes.Repeat([]byte{1}, state.KeyLength)), + PresharedKey: state.Key(bytes.Repeat([]byte{2}, state.KeyLength)), + } + } + return files, changed, nil +} + +func (f *fakeSession) Apply(_ context.Context, _ *state.State, name string, users []string) error { + if f.onApply != nil { + f.onApply(name) + } + if f.applied == nil { + f.applied = map[string][]string{} + } + f.applied[name] = users + return f.applyErr[name] +} + +func (f *fakeSession) Close() { f.closed = true } + +type fakeBackend struct { + session *fakeSession + openErr error + opens int +} + +func (f *fakeBackend) backend(name, suffix string) Backend { + return Backend{Name: name, Suffix: suffix, Open: func(context.Context) (Session, error) { + f.opens++ + if f.openErr != nil { + return nil, f.openErr + } + f.session.suffix = suffix + return f.session, nil + }} +} + type passEnv struct { t *testing.T settings Settings - client *fakeClient logs *bytes.Buffer pass *Pass - dial Dial - ifaces []*awgv1.InterfaceStatus + backends []Backend } -func newPassEnv(t *testing.T, yml string, ifaces ...*awgv1.InterfaceStatus) *passEnv { +func newPassEnv(t *testing.T, yml string, backends ...Backend) *passEnv { t.Helper() root := t.TempDir() e := &passEnv{ t: t, settings: Settings{ - Host: netip.MustParseAddr("203.0.113.10"), - DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1")}, ConfigPath: filepath.Join(root, "usher.yml"), ClientsDir: filepath.Join(root, "clients"), StatePath: filepath.Join(root, "users.json"), }, - logs: &bytes.Buffer{}, - ifaces: ifaces, + logs: &bytes.Buffer{}, + backends: backends, } - e.client = &fakeClient{status: func() (*awgv1.GetStatusResponse, error) { - return &awgv1.GetStatusResponse{Interfaces: e.ifaces}, nil - }} if err := os.MkdirAll(e.settings.ClientsDir, 0o755); err != nil { t.Fatal(err) } e.writeConfig(yml) - e.dial = func() (awgv1.ManagementServiceClient, func(), error) { return e.client, func() {}, nil } e.newPass() return e } func (e *passEnv) newPass() { - e.pass = New(e.settings, e.dial, slog.New(slog.NewTextHandler(e.logs, nil))) + e.pass = New(e.settings, e.backends, slog.New(slog.NewTextHandler(e.logs, nil))) } func (e *passEnv) writeConfig(yml string) { @@ -101,88 +158,53 @@ func (e *passEnv) clientFiles() map[string]string { return files } -func (e *passEnv) loadState() *state.State { - e.t.Helper() - st, err := state.Load(e.settings.StatePath) - if err != nil { - e.t.Fatal(err) - } - return st +func sortedKeys(files map[string]string) []string { + return slices.Sorted(maps.Keys(files)) } -func (e *passEnv) appliedInterfaces() []string { - var names []string - for _, r := range e.client.applied { - names = append(names, r.GetInterfaceName()) - } - return names -} - -func named(name string, address string, params ...*awgv1.ConfigParam) *awgv1.InterfaceStatus { - return &awgv1.InterfaceStatus{ - Name: name, - Present: true, - PublicKey: bytes.Repeat([]byte{9}, 32), - ListenPort: 51820, - Addresses: []string{address}, - ClientParams: params, - } -} - -func TestRunRequestShape(t *testing.T) { - e := newPassEnv(t, "phone: [awg0]\nlaptop: [awg0]\nguest: []\n", - named("awg0", "10.8.1.1/24"), named("awg1", "10.9.1.1/24")) +func TestRunOneBackend(t *testing.T) { + fb := &fakeBackend{session: &fakeSession{names: []string{"awg0", "awg1"}}} + e := newPassEnv(t, "phone: [awg0]\nlaptop: [awg0]\nguest: []\n", fb.backend("awg-grpc", ".conf")) e.run() - if got := e.appliedInterfaces(); !slices.Equal(got, []string{"awg0", "awg1"}) { - t.Fatalf("applied interfaces = %v", got) - } - first, second := e.client.applied[0], e.client.applied[1] - if first.GetAllowEmpty() || len(first.GetPeers()) != 2 { - t.Fatalf("awg0 request: allow_empty=%v peers=%d", first.GetAllowEmpty(), len(first.GetPeers())) + if got := fb.session.applied["awg0"]; !slices.Equal(got, []string{"laptop", "phone"}) { + t.Errorf("awg0 users = %v", got) } - st := e.loadState() - // peers are ordered by user name: laptop, phone - for i, user := range []string{"laptop", "phone"} { - p := first.GetPeers()[i] - want := st.AWG["awg0/"+user] - if !bytes.Equal(p.GetPublicKey(), want.PublicKey()) || len(p.GetPublicKey()) != 32 { - t.Errorf("%s: public key mismatch", user) - } - if !bytes.Equal(p.GetPresharedKey(), want.PresharedKey[:]) { - t.Errorf("%s: preshared key mismatch", user) - } - if p.GetAllowedIp() != want.Route().String() { - t.Errorf("%s: allowed ip = %q", user, p.GetAllowedIp()) - } + if users, ok := fb.session.applied["awg1"]; !ok || len(users) != 0 { + t.Errorf("awg1 applied = %v, %v", users, ok) } - if !second.GetAllowEmpty() || len(second.GetPeers()) != 0 { - t.Errorf("awg1 request: allow_empty=%v peers=%d", second.GetAllowEmpty(), len(second.GetPeers())) + if got := sortedKeys(e.clientFiles()); !slices.Equal(got, []string{"laptop/awg0.conf", "phone/awg0.conf"}) { + t.Errorf("client files = %v", got) } - files := e.clientFiles() - if len(files) != 2 || files["phone/awg0.conf"] == "" || files["laptop/awg0.conf"] == "" { - t.Errorf("clients files = %v", slices.Sorted(maps.Keys(files))) + if !fb.session.closed { + t.Error("session was not closed") } } func TestRunSavesStateBeforeApply(t *testing.T) { - e := newPassEnv(t, "phone: [awg0]\n", named("awg0", "10.8.1.1/24")) + fb := &fakeBackend{session: &fakeSession{names: []string{"awg0"}}} + e := newPassEnv(t, "phone: [awg0]\n", fb.backend("awg-grpc", ".conf")) var entriesAtApply int - e.client.apply = func(*awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { - entriesAtApply = len(e.loadState().AWG) - return &awgv1.ApplyPeersResponse{}, nil + fb.session.onApply = func(string) { + st, err := state.Load(e.settings.StatePath) + if err != nil { + t.Error(err) + return + } + entriesAtApply = len(st.AWG) } e.run() if entriesAtApply != 1 { - t.Fatalf("state held %d entries at the first ApplyPeers, want 1", entriesAtApply) + t.Fatalf("state held %d entries at the first Apply, want 1", entriesAtApply) } old := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) if err := os.Chtimes(e.settings.StatePath, old, old); err != nil { t.Fatal(err) } + e.writeConfig("phone: []\n") e.run() info, err := os.Stat(e.settings.StatePath) if err != nil { @@ -195,15 +217,16 @@ func TestRunSavesStateBeforeApply(t *testing.T) { func TestRunInvalidConfig(t *testing.T) { t.Run("last valid file is used", func(t *testing.T) { - e := newPassEnv(t, "phone: [awg0]\n", named("awg0", "10.8.1.1/24")) + fb := &fakeBackend{session: &fakeSession{names: []string{"awg0"}}} + e := newPassEnv(t, "phone: [awg0]\n", fb.backend("awg-grpc", ".conf")) e.run() e.writeConfig("phone: [awg0\n") - e.client.applied = nil + fb.session.applied = nil e.run() - if len(e.client.applied) != 1 || len(e.client.applied[0].GetPeers()) != 1 { - t.Fatalf("expected the last valid file to be applied, got %d requests", len(e.client.applied)) + if got := fb.session.applied["awg0"]; !slices.Equal(got, []string{"phone"}) { + t.Fatalf("expected the last valid file to be applied, got %v", got) } if !strings.Contains(e.logs.String(), "usher.yml") { t.Errorf("log does not name the file: %s", e.logs) @@ -213,13 +236,14 @@ func TestRunInvalidConfig(t *testing.T) { } }) t.Run("no earlier valid file", func(t *testing.T) { - e := newPassEnv(t, "phone: [awg0\n", named("awg0", "10.8.1.1/24")) + fb := &fakeBackend{session: &fakeSession{names: []string{"awg0"}}} + e := newPassEnv(t, "phone: [awg0\n", fb.backend("awg-grpc", ".conf")) e.writeClient("keep/awg0.conf", "keep") e.run() - if e.client.statusCalls != 0 || len(e.client.applied) != 0 { - t.Errorf("requests were sent: status=%d apply=%d", e.client.statusCalls, len(e.client.applied)) + if fb.opens != 0 { + t.Errorf("backend was opened") } if _, ok := e.clientFiles()["keep/awg0.conf"]; !ok { t.Errorf("clients directory changed") @@ -234,7 +258,7 @@ func TestRunStopsBeforeRequests(t *testing.T) { tests := []struct { name string setup func(e *passEnv) - wantStatus int + wantOpens int wantLogHas string stateBefore string }{ @@ -243,31 +267,25 @@ func TestRunStopsBeforeRequests(t *testing.T) { t.Fatal(err) } }, 0, "loading state", "{"}, - {"get status error", func(e *passEnv) { - e.client.status = func() (*awgv1.GetStatusResponse, error) { return nil, errors.New("down") } - }, 1, "getting status", ""}, {"state cannot be saved", func(e *passEnv) { e.settings.StatePath = filepath.Join(filepath.Dir(e.settings.StatePath), "missing", "users.json") e.newPass() }, 1, "saving state", ""}, - {"dial error", func(e *passEnv) { - e.dial = func() (awgv1.ManagementServiceClient, func(), error) { return nil, nil, errors.New("no socket") } - e.newPass() - }, 0, "connecting to awg-grpc", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - e := newPassEnv(t, "phone: [awg0]\n", named("awg0", "10.8.1.1/24")) + fb := &fakeBackend{session: &fakeSession{names: []string{"awg0"}}} + e := newPassEnv(t, "phone: [awg0]\n", fb.backend("awg-grpc", ".conf")) e.writeClient("stale/awg0.conf", "stale") tt.setup(e) e.run() - if len(e.client.applied) != 0 { - t.Errorf("ApplyPeers was called") + if len(fb.session.applied) != 0 { + t.Errorf("Apply was called") } - if e.client.statusCalls != tt.wantStatus { - t.Errorf("GetStatus calls = %d, want %d", e.client.statusCalls, tt.wantStatus) + if fb.opens != tt.wantOpens { + t.Errorf("opens = %d, want %d", fb.opens, tt.wantOpens) } if !strings.Contains(e.logs.String(), tt.wantLogHas) { t.Errorf("log lacks %q: %s", tt.wantLogHas, e.logs) @@ -288,155 +306,174 @@ func TestRunStopsBeforeRequests(t *testing.T) { } func TestRunInterfaceFailure(t *testing.T) { - missing := named("awg0", "10.8.1.1/24") - missing.Present = false tests := []struct { name string - failing *awgv1.InterfaceStatus - applyFails bool + session *fakeSession wantApplied []string }{ - {"not present", missing, false, []string{"awg1"}}, - {"no free address", named("awg0", "10.8.1.1/30"), false, []string{"awg1"}}, - {"render error", named("awg0", "10.8.1.1/24", &awgv1.ConfigParam{Key: "S1", Value: "a\nb"}), false, []string{"awg1"}}, - {"apply error", named("awg0", "10.8.1.1/24"), true, []string{"awg0", "awg1"}}, + {"prepare error", &fakeSession{prepareErr: map[string]error{"awg0": errors.New("not present")}}, []string{"awg1"}}, + {"apply error", &fakeSession{applyErr: map[string]error{"awg0": errors.New("rejected")}}, []string{"awg0", "awg1"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - e := newPassEnv(t, "phone: [awg0, awg1]\nlaptop: [awg0]\n", tt.failing, named("awg1", "10.9.1.1/24")) + tt.session.names = []string{"awg0", "awg1"} + fb := &fakeBackend{session: tt.session} + e := newPassEnv(t, "phone: [awg0, awg1]\nlaptop: [awg0]\n", fb.backend("awg-grpc", ".conf")) e.writeClient("phone/awg0.conf", "old phone") e.writeClient("laptop/awg0.conf", "old laptop") - e.client.apply = func(r *awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { - if tt.applyFails && r.GetInterfaceName() == "awg0" { - return nil, errors.New("rejected") - } - return &awgv1.ApplyPeersResponse{}, nil - } e.run() - if got := e.appliedInterfaces(); !slices.Equal(got, tt.wantApplied) { + if got := slices.Sorted(maps.Keys(tt.session.applied)); !slices.Equal(got, tt.wantApplied) { t.Errorf("applied = %v, want %v", got, tt.wantApplied) } files := e.clientFiles() if files["phone/awg0.conf"] != "old phone" || files["laptop/awg0.conf"] != "old laptop" { - t.Errorf("failed interface lost its files: %v", slices.Sorted(maps.Keys(files))) + t.Errorf("failed interface lost its files: %v", sortedKeys(files)) } - if !strings.Contains(files["phone/awg1.conf"], "[Peer]") { - t.Errorf("awg1 config missing") + if files["phone/awg1.conf"] != "new awg1" { + t.Errorf("awg1 file missing: %v", sortedKeys(files)) } }) } } func TestRunUnknownInterface(t *testing.T) { - e := newPassEnv(t, "phone: [awg0, awg9]\n", named("awg0", "10.8.1.1/24")) + fb := &fakeBackend{session: &fakeSession{names: []string{"awg0"}}} + e := newPassEnv(t, "phone: [awg0, awg9]\n", fb.backend("awg-grpc", ".conf")) e.writeClient("phone/awg9.conf", "orphan") e.run() - if got := e.appliedInterfaces(); !slices.Equal(got, []string{"awg0"}) { - t.Errorf("applied = %v", got) - } - if !strings.Contains(e.logs.String(), "awg9") { + if !strings.Contains(e.logs.String(), "not reported by any backend") || !strings.Contains(e.logs.String(), "awg9") { t.Errorf("log does not name awg9: %s", e.logs) } files := e.clientFiles() if len(files) != 1 || files["phone/awg0.conf"] == "" { - t.Errorf("clients files = %v", slices.Sorted(maps.Keys(files))) + t.Errorf("clients files = %v", sortedKeys(files)) } } -func TestRunSwitchOff(t *testing.T) { - e := newPassEnv(t, "phone: [awg0]\nlaptop: [awg0]\n", named("awg0", "10.8.1.1/24")) +func TestRunTwoBackends(t *testing.T) { + awg := &fakeBackend{session: &fakeSession{names: []string{"awg0"}}} + xray := &fakeBackend{session: &fakeSession{names: []string{"vless"}}} + e := newPassEnv(t, "phone: [awg0, vless]\n", awg.backend("awg-grpc", ".conf"), xray.backend("xray", ".txt")) + e.run() - laptop := e.loadState().AWG["awg0/laptop"] - laptopPub := laptop.PublicKey() - e.client.applied = nil - e.client.apply = func(*awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { - return &awgv1.ApplyPeersResponse{Removed: [][]byte{laptopPub}}, nil + + if got := awg.session.applied["awg0"]; !slices.Equal(got, []string{"phone"}) { + t.Errorf("awg applied = %v", awg.session.applied) + } + if got := xray.session.applied["vless"]; !slices.Equal(got, []string{"phone"}) { + t.Errorf("xray applied = %v", xray.session.applied) + } + want := []string{"phone/awg0.conf", "phone/vless.txt"} + if got := sortedKeys(e.clientFiles()); !slices.Equal(got, want) { + t.Errorf("client files = %v, want %v", got, want) + } + if strings.Contains(e.logs.String(), "not reported") { + t.Errorf("unexpected unknown-name log: %s", e.logs) } - e.logs.Reset() +} + +func TestRunNameCollision(t *testing.T) { + awg := &fakeBackend{session: &fakeSession{names: []string{"shared", "awg0"}}} + xray := &fakeBackend{session: &fakeSession{names: []string{"shared"}}} + e := newPassEnv(t, "phone: [shared, awg0]\n", awg.backend("awg-grpc", ".conf"), xray.backend("xray", ".txt")) + e.writeClient("phone/shared.conf", "old conf") + e.writeClient("phone/shared.txt", "old txt") - e.writeConfig("phone: [awg0]\nlaptop: []\n") e.run() - if len(e.client.applied) != 1 || len(e.client.applied[0].GetPeers()) != 1 { - t.Fatalf("expected one request with one peer") + if _, ok := awg.session.applied["shared"]; ok { + t.Error("awg applied the colliding name") } - if _, ok := e.clientFiles()["laptop/awg0.conf"]; ok { - t.Errorf("laptop file stayed") + if _, ok := xray.session.applied["shared"]; ok { + t.Error("xray applied the colliding name") } - if got := e.loadState().AWG["awg0/laptop"]; got != laptop { - t.Errorf("laptop entry changed: %+v", got) + if got := awg.session.applied["awg0"]; !slices.Equal(got, []string{"phone"}) { + t.Errorf("awg0 applied = %v", got) } - if !strings.Contains(e.logs.String(), "removed=[laptop]") { - t.Errorf("log does not name the removed user: %s", e.logs) + if !strings.Contains(e.logs.String(), "awg-grpc and xray") { + t.Errorf("log does not name both backends: %s", e.logs) + } + files := e.clientFiles() + if files["phone/shared.conf"] != "old conf" || files["phone/shared.txt"] != "old txt" { + t.Errorf("colliding interface lost its files: %v", sortedKeys(files)) } } -func TestRunLogsResult(t *testing.T) { - unknown := bytes.Repeat([]byte{7}, 32) - tests := []struct { - name string - response *awgv1.ApplyPeersResponse - want []string - silent bool - }{ - {"user names", nil, []string{"added=[phone]"}, false}, - {"key without entry", &awgv1.ApplyPeersResponse{Updated: [][]byte{unknown}}, - []string{base64.StdEncoding.EncodeToString(unknown)}, false}, - {"empty result", &awgv1.ApplyPeersResponse{}, nil, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - e := newPassEnv(t, "phone: [awg0]\n", named("awg0", "10.8.1.1/24")) - e.client.apply = func(r *awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { - if tt.response != nil { - return tt.response, nil - } - return &awgv1.ApplyPeersResponse{Added: [][]byte{r.GetPeers()[0].GetPublicKey()}}, nil - } +func TestRunFailedInterfaceKeepsOtherBackend(t *testing.T) { + awg := &fakeBackend{session: &fakeSession{names: []string{"awg0"}, applyErr: map[string]error{"awg0": errors.New("rejected")}}} + xray := &fakeBackend{session: &fakeSession{names: []string{"vless"}}} + e := newPassEnv(t, "phone: [awg0, vless]\n", awg.backend("awg-grpc", ".conf"), xray.backend("xray", ".txt")) + e.writeClient("phone/awg0.conf", "old conf") + e.writeClient("phone/vless.txt", "old txt") - e.run() + e.run() - for _, w := range tt.want { - if !strings.Contains(e.logs.String(), w) { - t.Errorf("log lacks %q: %s", w, e.logs) - } - } - if tt.silent && strings.Contains(e.logs.String(), "peers changed") { - t.Errorf("empty result was logged: %s", e.logs) - } - }) + if got := xray.session.applied["vless"]; !slices.Equal(got, []string{"phone"}) { + t.Errorf("xray applied = %v", xray.session.applied) + } + files := e.clientFiles() + if files["phone/awg0.conf"] != "old conf" { + t.Errorf("failed awg interface lost its file: %v", files) + } + if files["phone/vless.txt"] != "new vless" { + t.Errorf("xray file = %q", files["phone/vless.txt"]) } } -func TestRunLogHasNoSecrets(t *testing.T) { - const headerKey = "HDRKEYSECRET" - const leak = "LEAKSECRET" - e := newPassEnv(t, "phone: [awg0, awg1]\n", - named("awg0", "10.8.1.1/24", &awgv1.ConfigParam{Key: "HeaderProtectionKey", Value: headerKey}), - named("awg1", "10.9.1.1/24", &awgv1.ConfigParam{Key: "S1", Value: "x\n" + leak})) - e.client.apply = func(r *awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error) { - return &awgv1.ApplyPeersResponse{Added: [][]byte{r.GetPeers()[0].GetPublicKey()}}, nil - } +func TestRunBackendDown(t *testing.T) { + awg := &fakeBackend{openErr: errors.New("no socket"), session: &fakeSession{}} + xray := &fakeBackend{session: &fakeSession{names: []string{"vless"}}} + e := newPassEnv(t, "phone: [awg0, vless]\n", awg.backend("awg-grpc", ".conf"), xray.backend("xray", ".txt")) + e.writeClient("phone/awg0.conf", "old conf") + e.writeClient("laptop/awg1.conf", "old laptop conf") + e.writeClient("phone/vless.txt", "old txt") - e.run() - e.writeConfig("phone: [awg0\n") e.run() + if got := xray.session.applied["vless"]; !slices.Equal(got, []string{"phone"}) { + t.Errorf("xray applied = %v", xray.session.applied) + } + files := e.clientFiles() + want := map[string]string{ + "phone/awg0.conf": "old conf", + "laptop/awg1.conf": "old laptop conf", + "phone/vless.txt": "new vless", + } + if !maps.Equal(files, want) { + t.Errorf("client files = %v, want %v", files, want) + } logs := e.logs.String() - if logs == "" { - t.Fatal("no log output") + if !strings.Contains(logs, "backend is down") || !strings.Contains(logs, "no socket") { + t.Errorf("log does not report the down backend: %s", logs) } - secrets := []string{headerKey, leak} - for _, entry := range e.loadState().AWG { - secrets = append(secrets, entry.PrivateKey.String(), entry.PresharedKey.String()) + if strings.Contains(logs, "not reported by any backend") { + t.Errorf("unknown-name log while a backend is down: %s", logs) } - for _, s := range secrets { - if strings.Contains(logs, s) { - t.Errorf("log contains a secret: %q", s) - } +} + +func TestRunAllBackendsDownKeepsFiles(t *testing.T) { + fb := &fakeBackend{openErr: errors.New("no socket")} + e := newPassEnv(t, "phone: [awg0]\n", fb.backend("awg-grpc", ".conf")) + e.writeClient("phone/awg0.conf", "old") + + e.run() + + if files := e.clientFiles(); !maps.Equal(files, map[string]string{"phone/awg0.conf": "old"}) { + t.Errorf("client files = %v", files) + } +} + +func TestRunLogsBackendName(t *testing.T) { + fb := &fakeBackend{session: &fakeSession{names: []string{"awg0"}, prepareErr: map[string]error{"awg0": fmt.Errorf("boom")}}} + e := newPassEnv(t, "phone: [awg0]\n", fb.backend("awg-grpc", ".conf")) + + e.run() + + if !strings.Contains(e.logs.String(), "backend=awg-grpc") { + t.Errorf("log lacks the backend name: %s", e.logs) } } diff --git a/internal/pass/settings.go b/internal/pass/settings.go index 5783e7a..e593676 100644 --- a/internal/pass/settings.go +++ b/internal/pass/settings.go @@ -1,13 +1,9 @@ -// Package pass runs one reconcile pass: enrollment, rendering and the requests to awg-grpc. +// Package pass runs one reconcile pass over the backends: enrollment, the state file, the +// requests to each backend and the clients directory. package pass -import "net/netip" - -// Settings holds the server facts and the paths one pass works with. +// Settings holds the paths one pass works with. type Settings struct { - Host netip.Addr // USHER_HOST - DNS []netip.Addr // USHER_DNS - AWGSocket string // USHER_AWG_SOCKET ConfigPath string ClientsDir string StatePath string From 64dd6b05202f452da7da604af2a8721fe18abb4a Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:24:51 +0300 Subject: [PATCH 04/29] feat(state): add xray group, users.json version 2 --- docs/plans/20261007-xray-driver.md | 12 +++--- internal/state/state.go | 56 ++++++++++++++++++++++---- internal/state/state_test.go | 64 +++++++++++++++++++++++++----- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index a39a70c..92a9479 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -350,14 +350,14 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Modify: `internal/state/state.go` - Modify: `internal/state/state_test.go` -- [ ] add `Xray map[string]XrayEntry` with `json:"xray"`; `XrayEntry{ID uuid.UUID}` -- [ ] bump `version` to 2; `Load` accepts 1 and 2 (`state.go:107`), `Save` writes 2 -- [ ] validate xray entry names as `tag/user` and reject a zero UUID; errors never quote the UUID -- [ ] reword the doc comment at `state.go:71`; keep `Entry`, `EntryName` and `Entry.Route`, which +- [x] add `Xray map[string]XrayEntry` with `json:"xray"`; `XrayEntry{ID uuid.UUID}` +- [x] bump `version` to 2; `Load` accepts 1 and 2 (`state.go:107`), `Save` writes 2 +- [x] validate xray entry names as `tag/user` and reject a zero UUID; errors never quote the UUID +- [x] reword the doc comment at `state.go:71`; keep `Entry`, `EntryName` and `Entry.Route`, which `test/e2e/e2e_test.go:24,406` uses -- [ ] write tests: load version 1 (empty `xray`), load version 2, reject version 3, reject a zero +- [x] write tests: load version 1 (empty `xray`), load version 2, reject version 3, reject a zero UUID, save and load round trip, no UUID in any error text -- [ ] run `go test -race ./...` - must pass before Task 4 +- [x] run `go test -race ./...` - must pass before Task 4 ### Task 4: Vendor the Xray protos and generate code diff --git a/internal/state/state.go b/internal/state/state.go index cef19d3..2a2e3c5 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -12,11 +12,15 @@ import ( "net/netip" "os" "strings" + "uuid" "github.com/mrcsin/usher/internal/atomicfile" ) -const version = 1 +// version is the format that Save writes. Load also accepts firstVersion, which has no xray group. +const version = 2 + +const firstVersion = 1 const fileMode = 0o600 @@ -68,15 +72,23 @@ func (e Entry) Route() netip.Prefix { return netip.PrefixFrom(e.Address, hostBits) } -// EntryName returns the key of a user's entry on an interface in State.AWG. +// XrayEntry holds the credential of one user on one Xray inbound. +type XrayEntry struct { + ID uuid.UUID `json:"id"` +} + +// EntryName returns the key of a user's entry on an interface or inbound, in State.AWG or +// State.Xray. func EntryName(iface, user string) string { return iface + "/" + user } -// State is the content of users.json. AWG maps "interface/user" to its entry. +// State is the content of users.json. AWG and Xray map "interface/user" and "tag/user" to the +// entry of that user. type State struct { - Version int `json:"version"` - AWG map[string]Entry `json:"awg"` + Version int `json:"version"` + AWG map[string]Entry `json:"awg"` + Xray map[string]XrayEntry `json:"xray"` } // InterfaceEntries yields the user name and the entry of every entry on the interface. @@ -95,7 +107,7 @@ func (s *State) InterfaceEntries(iface string) iter.Seq2[string, Entry] { func Load(path string) (*State, error) { data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { - return &State{Version: version, AWG: map[string]Entry{}}, nil + return &State{Version: version, AWG: map[string]Entry{}, Xray: map[string]XrayEntry{}}, nil } if err != nil { return nil, fmt.Errorf("reading %s: %w", path, err) @@ -104,23 +116,34 @@ func Load(path string) (*State, error) { if err := json.Unmarshal(data, &s); err != nil { return nil, fmt.Errorf("parsing %s: %w", path, err) } - if s.Version != version { + if s.Version != version && s.Version != firstVersion { return nil, fmt.Errorf("%s: unsupported version %d", path, s.Version) } if s.AWG == nil { s.AWG = map[string]Entry{} } + if s.Xray == nil { + s.Xray = map[string]XrayEntry{} + } for name, e := range s.AWG { if err := validate(name, e); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } } + for name, e := range s.Xray { + if err := validateXray(name, e); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + } return &s, nil } // Save writes s to path atomically with mode 0600. func Save(path string, s *State) error { s.Version = version + if s.Xray == nil { + s.Xray = map[string]XrayEntry{} + } data, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("encoding state: %w", err) @@ -130,11 +153,28 @@ func Save(path string, s *State) error { return atomicfile.Write(path, data, fileMode) } -func validate(name string, e Entry) error { +func validateName(name string) error { iface, user, ok := strings.Cut(name, "/") if !ok || iface == "" || user == "" || strings.Contains(user, "/") { return fmt.Errorf("entry %q: name is not interface/user", name) } + return nil +} + +func validateXray(name string, e XrayEntry) error { + if err := validateName(name); err != nil { + return err + } + if e.ID == uuid.Nil() { + return fmt.Errorf("entry %q: id is missing", name) + } + return nil +} + +func validate(name string, e Entry) error { + if err := validateName(name); err != nil { + return err + } if !e.Address.Is4() { return fmt.Errorf("entry %q: address is not IPv4", name) } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index d56e6b0..64f3a1b 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -11,8 +11,11 @@ import ( "slices" "strings" "testing" + "uuid" ) +const phoneID = "5f0c1d2e-3a4b-4c5d-8e6f-708192a3b4c5" + func keyOf(b byte) Key { return Key(bytes.Repeat([]byte{b}, KeyLength)) } @@ -23,11 +26,14 @@ func testKey(b byte) string { func validState() *State { return &State{ - Version: 1, + Version: 2, AWG: map[string]Entry{ "awg0/phone": {Address: netip.MustParseAddr("10.8.1.2"), PrivateKey: keyOf(1), PresharedKey: keyOf(2)}, "awg1/phone": {Address: netip.MustParseAddr("10.9.1.2"), PrivateKey: keyOf(3), PresharedKey: keyOf(4)}, }, + Xray: map[string]XrayEntry{ + "vless/phone": {ID: uuid.MustParse(phoneID)}, + }, } } @@ -36,8 +42,8 @@ func TestLoadMissingFileIsEmpty(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if s.Version != 1 || len(s.AWG) != 0 { - t.Fatalf("want empty version 1 state, got %+v", s) + if s.Version != 2 || len(s.AWG) != 0 || len(s.Xray) != 0 { + t.Fatalf("want empty version 2 state, got %+v", s) } } @@ -59,9 +65,12 @@ func TestSaveLoadRoundTrip(t *testing.T) { // TestSaveFormat pins the users.json format: addresses are plain strings and keys are base64. func TestSaveFormat(t *testing.T) { path := filepath.Join(t.TempDir(), "users.json") - s := &State{AWG: map[string]Entry{ - "awg0/phone": {Address: netip.MustParseAddr("10.8.1.2"), PrivateKey: keyOf(1), PresharedKey: keyOf(2)}, - }} + s := &State{ + AWG: map[string]Entry{ + "awg0/phone": {Address: netip.MustParseAddr("10.8.1.2"), PrivateKey: keyOf(1), PresharedKey: keyOf(2)}, + }, + Xray: map[string]XrayEntry{"vless/phone": {ID: uuid.MustParse(phoneID)}}, + } if err := Save(path, s); err != nil { t.Fatalf("Save: %v", err) } @@ -70,13 +79,18 @@ func TestSaveFormat(t *testing.T) { t.Fatal(err) } want := `{ - "version": 1, + "version": 2, "awg": { "awg0/phone": { "address": "10.8.1.2", "private_key": "` + testKey(1) + `", "preshared_key": "` + testKey(2) + `" } + }, + "xray": { + "vless/phone": { + "id": "` + phoneID + `" + } } } ` @@ -85,6 +99,31 @@ func TestSaveFormat(t *testing.T) { } } +func TestLoadFirstVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.json") + data := `{"version":1,"awg":{"awg0/a":{"address":"10.8.1.2","private_key":"` + testKey(1) + `","preshared_key":"` + testKey(2) + `"}}}` + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + s, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(s.AWG) != 1 || s.Xray == nil || len(s.Xray) != 0 { + t.Fatalf("want one awg entry and an empty xray group, got %+v", s) + } + if err := Save(path, s); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `"version": 2`) { + t.Fatalf("Save kept the old version: %s", got) + } +} + func TestEntryDerived(t *testing.T) { e := Entry{Address: netip.MustParseAddr("10.8.1.2"), PrivateKey: keyOf(1), PresharedKey: keyOf(2)} if got := e.Route().String(); got != "10.8.1.2/32" { @@ -143,7 +182,14 @@ func TestLoadRejects(t *testing.T) { name string json string }{ - {"bad version", `{"version":2,"awg":{}}`}, + {"bad version", `{"version":3,"awg":{}}`}, + {"version zero", `{"version":0,"awg":{}}`}, + {"xray zero id", `{"version":2,"xray":{"vless/a":{"id":"00000000-0000-0000-0000-000000000000"}}}`}, + {"xray missing id", `{"version":2,"xray":{"vless/a":{}}}`}, + {"xray id not uuid", `{"version":2,"xray":{"vless/a":{"id":"` + secret + `"}}}`}, + {"xray name without slash", `{"version":2,"xray":{"vlessa":{"id":"` + phoneID + `"}}}`}, + {"xray name with two slashes", `{"version":2,"xray":{"vless/a/b":{"id":"` + phoneID + `"}}}`}, + {"xray name with empty tag", `{"version":2,"xray":{"/a":{"id":"` + phoneID + `"}}}`}, {"missing version", `{"awg":{}}`}, {"private key not base64", `{"version":1,"awg":{"awg0/a":{"address":"10.8.1.2","private_key":"` + secret + `","preshared_key":"` + testKey(2) + `"}}}`}, {"private key wrong length", `{"version":1,"awg":{"awg0/a":{"address":"10.8.1.2","private_key":"` + encodedSecret + `","preshared_key":"` + testKey(2) + `"}}}`}, @@ -170,7 +216,7 @@ func TestLoadRejects(t *testing.T) { if err == nil { t.Fatal("Load succeeded, want error") } - for _, stored := range []string{secret, encodedSecret, testKey(1), testKey(2)} { + for _, stored := range []string{secret, encodedSecret, testKey(1), testKey(2), phoneID} { if strings.Contains(err.Error(), stored) { t.Fatalf("error quotes a stored value %q: %v", stored, err) } From 18989c371b4231560ed101b8a11b58fe8846c67f Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:28:06 +0300 Subject: [PATCH 05/29] build(xray): vendor Xray protos and generate code --- .github/workflows/ci.yml | 8 +- Dockerfile | 1 + buf.gen.yaml | 13 + buf.yaml | 3 + docs/plans/20261007-xray-driver.md | 12 +- gen/xray/app/proxyman/command/command.pb.go | 1114 +++++++++++++++++ .../app/proxyman/command/command_grpc.pb.go | 463 +++++++ gen/xray/app/proxyman/config.pb.go | 586 +++++++++ gen/xray/common/geodata/geodat.pb.go | 965 ++++++++++++++ gen/xray/common/net/address.pb.go | 172 +++ gen/xray/common/net/port.pb.go | 184 +++ gen/xray/common/protocol/user.pb.go | 147 +++ gen/xray/common/serial/typed_message.pb.go | 135 ++ gen/xray/core/config.pb.go | 331 +++++ gen/xray/proxy/vless/account.pb.go | 259 ++++ gen/xray/proxy/vless/inbound/config.pb.go | 276 ++++ gen/xray/transport/internet/config.pb.go | 1098 ++++++++++++++++ .../transport/internet/reality/config.pb.go | 375 ++++++ gen/xray/transport/internet/tcp/config.pb.go | 135 ++ go.mod | 2 +- proto/xray/README.md | 16 + proto/xray/app/proxyman/command/command.proto | 108 ++ proto/xray/app/proxyman/config.proto | 75 ++ proto/xray/common/geodata/geodat.proto | 95 ++ proto/xray/common/net/address.proto | 19 + proto/xray/common/net/port.proto | 20 + proto/xray/common/protocol/user.proto | 19 + proto/xray/common/serial/typed_message.proto | 15 + proto/xray/core/config.proto | 57 + proto/xray/proxy/vless/account.proto | 31 + proto/xray/proxy/vless/inbound/config.proto | 29 + proto/xray/transport/internet/config.proto | 159 +++ .../transport/internet/reality/config.proto | 40 + .../xray/transport/internet/tcp/config.proto | 15 + scripts/format.sh | 9 + tools/go.mod | 103 ++ tools/go.sum | 231 ++++ 37 files changed, 7311 insertions(+), 9 deletions(-) create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 gen/xray/app/proxyman/command/command.pb.go create mode 100644 gen/xray/app/proxyman/command/command_grpc.pb.go create mode 100644 gen/xray/app/proxyman/config.pb.go create mode 100644 gen/xray/common/geodata/geodat.pb.go create mode 100644 gen/xray/common/net/address.pb.go create mode 100644 gen/xray/common/net/port.pb.go create mode 100644 gen/xray/common/protocol/user.pb.go create mode 100644 gen/xray/common/serial/typed_message.pb.go create mode 100644 gen/xray/core/config.pb.go create mode 100644 gen/xray/proxy/vless/account.pb.go create mode 100644 gen/xray/proxy/vless/inbound/config.pb.go create mode 100644 gen/xray/transport/internet/config.pb.go create mode 100644 gen/xray/transport/internet/reality/config.pb.go create mode 100644 gen/xray/transport/internet/tcp/config.pb.go create mode 100644 proto/xray/README.md create mode 100644 proto/xray/app/proxyman/command/command.proto create mode 100644 proto/xray/app/proxyman/config.proto create mode 100644 proto/xray/common/geodata/geodat.proto create mode 100644 proto/xray/common/net/address.proto create mode 100644 proto/xray/common/net/port.proto create mode 100644 proto/xray/common/protocol/user.proto create mode 100644 proto/xray/common/serial/typed_message.proto create mode 100644 proto/xray/core/config.proto create mode 100644 proto/xray/proxy/vless/account.proto create mode 100644 proto/xray/proxy/vless/inbound/config.proto create mode 100644 proto/xray/transport/internet/config.proto create mode 100644 proto/xray/transport/internet/reality/config.proto create mode 100644 proto/xray/transport/internet/tcp/config.proto create mode 100644 tools/go.mod create mode 100644 tools/go.sum diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 294e0d7..96f14dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,9 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - cache-dependency-path: go.sum + cache-dependency-path: | + go.sum + tools/go.sum - name: format chain run: scripts/format.sh @@ -53,7 +55,9 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - cache-dependency-path: go.sum + cache-dependency-path: | + go.sum + tools/go.sum - name: load module run: scripts/ci/module.sh diff --git a/Dockerfile b/Dockerfile index 2a291cc..ec0db67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY cmd ./cmd +COPY gen ./gen COPY internal ./internal ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..9c5b0d0 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/mrcsin/usher/gen/xray +plugins: + - local: ["go", "tool", "-modfile=tools/go.mod", "protoc-gen-go"] + out: gen/xray + opt: paths=source_relative + - local: ["go", "tool", "-modfile=tools/go.mod", "protoc-gen-go-grpc"] + out: gen/xray + opt: paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..c93e62f --- /dev/null +++ b/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: proto/xray diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index 92a9479..d3bb6d9 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -371,21 +371,21 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Modify: `scripts/format.sh` - Modify: `.github/workflows/ci.yml` (`tools/go.sum` in `cache-dependency-path`) -- [ ] copy the closure of `command.proto`, `app/proxyman/config.proto`, +- [x] copy the closure of `command.proto`, `app/proxyman/config.proto`, `proxy/vless/inbound/config.proto`, `proxy/vless/account.proto`, `transport/internet/reality/config.proto`, `transport/internet/config.proto` and `transport/internet/tcp/config.proto` -- [ ] `buf.yaml` with the module at `proto/xray`; `buf.gen.yaml` with `out: gen/xray`, +- [x] `buf.yaml` with the module at `proto/xray`; `buf.gen.yaml` with `out: gen/xray`, `paths=source_relative` and managed `go_package_prefix: github.com/mrcsin/usher/gen/xray`, so every proto package keeps its own Go package -- [ ] add the tools module pinning buf, `protoc-gen-go`, `protoc-gen-go-grpc`, as in +- [x] add the tools module pinning buf, `protoc-gen-go`, `protoc-gen-go-grpc`, as in `awg-grpc/tools/go.mod:5-9` -- [ ] extend `scripts/format.sh` to check that `gen/` equals `buf generate`; it runs no `buf lint`, +- [x] extend `scripts/format.sh` to check that `gen/` equals `buf generate`; it runs no `buf lint`, unlike `awg-grpc/scripts/format.sh:26`, because the vendored files are not usher's and fail the default rules -- [ ] no unit test in this task: it adds generated code only; `scripts/format.sh`, `go build ./...` +- [x] no unit test in this task: it adds generated code only; `scripts/format.sh`, `go build ./...` and `docker build .` verify it, and Tasks 5 and 6 exercise the types -- [ ] run `scripts/format.sh`, `go test -race ./...` and `docker build .` - must pass before Task 5 +- [x] run `scripts/format.sh`, `go test -race ./...` and `docker build .` - must pass before Task 5 ### Task 5: Read inbounds through the decoder tables diff --git a/gen/xray/app/proxyman/command/command.pb.go b/gen/xray/app/proxyman/command/command.pb.go new file mode 100644 index 0000000..7aa5a25 --- /dev/null +++ b/gen/xray/app/proxyman/command/command.pb.go @@ -0,0 +1,1114 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: app/proxyman/command/command.proto + +package command + +import ( + protocol "github.com/mrcsin/usher/gen/xray/common/protocol" + serial "github.com/mrcsin/usher/gen/xray/common/serial" + core "github.com/mrcsin/usher/gen/xray/core" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AddUserOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + User *protocol.User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddUserOperation) Reset() { + *x = AddUserOperation{} + mi := &file_app_proxyman_command_command_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddUserOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddUserOperation) ProtoMessage() {} + +func (x *AddUserOperation) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddUserOperation.ProtoReflect.Descriptor instead. +func (*AddUserOperation) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{0} +} + +func (x *AddUserOperation) GetUser() *protocol.User { + if x != nil { + return x.User + } + return nil +} + +type RemoveUserOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveUserOperation) Reset() { + *x = RemoveUserOperation{} + mi := &file_app_proxyman_command_command_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveUserOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveUserOperation) ProtoMessage() {} + +func (x *RemoveUserOperation) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveUserOperation.ProtoReflect.Descriptor instead. +func (*RemoveUserOperation) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{1} +} + +func (x *RemoveUserOperation) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +type AddInboundRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Inbound *core.InboundHandlerConfig `protobuf:"bytes,1,opt,name=inbound,proto3" json:"inbound,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddInboundRequest) Reset() { + *x = AddInboundRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddInboundRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddInboundRequest) ProtoMessage() {} + +func (x *AddInboundRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddInboundRequest.ProtoReflect.Descriptor instead. +func (*AddInboundRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{2} +} + +func (x *AddInboundRequest) GetInbound() *core.InboundHandlerConfig { + if x != nil { + return x.Inbound + } + return nil +} + +type AddInboundResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddInboundResponse) Reset() { + *x = AddInboundResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddInboundResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddInboundResponse) ProtoMessage() {} + +func (x *AddInboundResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddInboundResponse.ProtoReflect.Descriptor instead. +func (*AddInboundResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{3} +} + +type RemoveInboundRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveInboundRequest) Reset() { + *x = RemoveInboundRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveInboundRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveInboundRequest) ProtoMessage() {} + +func (x *RemoveInboundRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveInboundRequest.ProtoReflect.Descriptor instead. +func (*RemoveInboundRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{4} +} + +func (x *RemoveInboundRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +type RemoveInboundResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveInboundResponse) Reset() { + *x = RemoveInboundResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveInboundResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveInboundResponse) ProtoMessage() {} + +func (x *RemoveInboundResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveInboundResponse.ProtoReflect.Descriptor instead. +func (*RemoveInboundResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{5} +} + +type AlterInboundRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + Operation *serial.TypedMessage `protobuf:"bytes,2,opt,name=operation,proto3" json:"operation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AlterInboundRequest) Reset() { + *x = AlterInboundRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AlterInboundRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AlterInboundRequest) ProtoMessage() {} + +func (x *AlterInboundRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AlterInboundRequest.ProtoReflect.Descriptor instead. +func (*AlterInboundRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{6} +} + +func (x *AlterInboundRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *AlterInboundRequest) GetOperation() *serial.TypedMessage { + if x != nil { + return x.Operation + } + return nil +} + +type AlterInboundResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AlterInboundResponse) Reset() { + *x = AlterInboundResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AlterInboundResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AlterInboundResponse) ProtoMessage() {} + +func (x *AlterInboundResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AlterInboundResponse.ProtoReflect.Descriptor instead. +func (*AlterInboundResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{7} +} + +type ListInboundsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + IsOnlyTags bool `protobuf:"varint,1,opt,name=isOnlyTags,proto3" json:"isOnlyTags,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListInboundsRequest) Reset() { + *x = ListInboundsRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListInboundsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListInboundsRequest) ProtoMessage() {} + +func (x *ListInboundsRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListInboundsRequest.ProtoReflect.Descriptor instead. +func (*ListInboundsRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{8} +} + +func (x *ListInboundsRequest) GetIsOnlyTags() bool { + if x != nil { + return x.IsOnlyTags + } + return false +} + +type ListInboundsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Inbounds []*core.InboundHandlerConfig `protobuf:"bytes,1,rep,name=inbounds,proto3" json:"inbounds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListInboundsResponse) Reset() { + *x = ListInboundsResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListInboundsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListInboundsResponse) ProtoMessage() {} + +func (x *ListInboundsResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListInboundsResponse.ProtoReflect.Descriptor instead. +func (*ListInboundsResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{9} +} + +func (x *ListInboundsResponse) GetInbounds() []*core.InboundHandlerConfig { + if x != nil { + return x.Inbounds + } + return nil +} + +type GetInboundUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInboundUserRequest) Reset() { + *x = GetInboundUserRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInboundUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInboundUserRequest) ProtoMessage() {} + +func (x *GetInboundUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInboundUserRequest.ProtoReflect.Descriptor instead. +func (*GetInboundUserRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{10} +} + +func (x *GetInboundUserRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *GetInboundUserRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +type GetInboundUserResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Users []*protocol.User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInboundUserResponse) Reset() { + *x = GetInboundUserResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInboundUserResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInboundUserResponse) ProtoMessage() {} + +func (x *GetInboundUserResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInboundUserResponse.ProtoReflect.Descriptor instead. +func (*GetInboundUserResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{11} +} + +func (x *GetInboundUserResponse) GetUsers() []*protocol.User { + if x != nil { + return x.Users + } + return nil +} + +type GetInboundUsersCountResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInboundUsersCountResponse) Reset() { + *x = GetInboundUsersCountResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInboundUsersCountResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInboundUsersCountResponse) ProtoMessage() {} + +func (x *GetInboundUsersCountResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInboundUsersCountResponse.ProtoReflect.Descriptor instead. +func (*GetInboundUsersCountResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{12} +} + +func (x *GetInboundUsersCountResponse) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +type AddOutboundRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Outbound *core.OutboundHandlerConfig `protobuf:"bytes,1,opt,name=outbound,proto3" json:"outbound,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddOutboundRequest) Reset() { + *x = AddOutboundRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddOutboundRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddOutboundRequest) ProtoMessage() {} + +func (x *AddOutboundRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddOutboundRequest.ProtoReflect.Descriptor instead. +func (*AddOutboundRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{13} +} + +func (x *AddOutboundRequest) GetOutbound() *core.OutboundHandlerConfig { + if x != nil { + return x.Outbound + } + return nil +} + +type AddOutboundResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddOutboundResponse) Reset() { + *x = AddOutboundResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddOutboundResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddOutboundResponse) ProtoMessage() {} + +func (x *AddOutboundResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddOutboundResponse.ProtoReflect.Descriptor instead. +func (*AddOutboundResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{14} +} + +type RemoveOutboundRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveOutboundRequest) Reset() { + *x = RemoveOutboundRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveOutboundRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveOutboundRequest) ProtoMessage() {} + +func (x *RemoveOutboundRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveOutboundRequest.ProtoReflect.Descriptor instead. +func (*RemoveOutboundRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{15} +} + +func (x *RemoveOutboundRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +type RemoveOutboundResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveOutboundResponse) Reset() { + *x = RemoveOutboundResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveOutboundResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveOutboundResponse) ProtoMessage() {} + +func (x *RemoveOutboundResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveOutboundResponse.ProtoReflect.Descriptor instead. +func (*RemoveOutboundResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{16} +} + +type AlterOutboundRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + Operation *serial.TypedMessage `protobuf:"bytes,2,opt,name=operation,proto3" json:"operation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AlterOutboundRequest) Reset() { + *x = AlterOutboundRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AlterOutboundRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AlterOutboundRequest) ProtoMessage() {} + +func (x *AlterOutboundRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AlterOutboundRequest.ProtoReflect.Descriptor instead. +func (*AlterOutboundRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{17} +} + +func (x *AlterOutboundRequest) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *AlterOutboundRequest) GetOperation() *serial.TypedMessage { + if x != nil { + return x.Operation + } + return nil +} + +type AlterOutboundResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AlterOutboundResponse) Reset() { + *x = AlterOutboundResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AlterOutboundResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AlterOutboundResponse) ProtoMessage() {} + +func (x *AlterOutboundResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AlterOutboundResponse.ProtoReflect.Descriptor instead. +func (*AlterOutboundResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{18} +} + +type ListOutboundsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListOutboundsRequest) Reset() { + *x = ListOutboundsRequest{} + mi := &file_app_proxyman_command_command_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListOutboundsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListOutboundsRequest) ProtoMessage() {} + +func (x *ListOutboundsRequest) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListOutboundsRequest.ProtoReflect.Descriptor instead. +func (*ListOutboundsRequest) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{19} +} + +type ListOutboundsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Outbounds []*core.OutboundHandlerConfig `protobuf:"bytes,1,rep,name=outbounds,proto3" json:"outbounds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListOutboundsResponse) Reset() { + *x = ListOutboundsResponse{} + mi := &file_app_proxyman_command_command_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListOutboundsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListOutboundsResponse) ProtoMessage() {} + +func (x *ListOutboundsResponse) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListOutboundsResponse.ProtoReflect.Descriptor instead. +func (*ListOutboundsResponse) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{20} +} + +func (x *ListOutboundsResponse) GetOutbounds() []*core.OutboundHandlerConfig { + if x != nil { + return x.Outbounds + } + return nil +} + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_app_proxyman_command_command_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_command_command_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_app_proxyman_command_command_proto_rawDescGZIP(), []int{21} +} + +var File_app_proxyman_command_command_proto protoreflect.FileDescriptor + +const file_app_proxyman_command_command_proto_rawDesc = "" + + "\n" + + "\"app/proxyman/command/command.proto\x12\x19xray.app.proxyman.command\x1a\x1acommon/protocol/user.proto\x1a!common/serial/typed_message.proto\x1a\x11core/config.proto\"B\n" + + "\x10AddUserOperation\x12.\n" + + "\x04user\x18\x01 \x01(\v2\x1a.xray.common.protocol.UserR\x04user\"+\n" + + "\x13RemoveUserOperation\x12\x14\n" + + "\x05email\x18\x01 \x01(\tR\x05email\"N\n" + + "\x11AddInboundRequest\x129\n" + + "\ainbound\x18\x01 \x01(\v2\x1f.xray.core.InboundHandlerConfigR\ainbound\"\x14\n" + + "\x12AddInboundResponse\"(\n" + + "\x14RemoveInboundRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\"\x17\n" + + "\x15RemoveInboundResponse\"g\n" + + "\x13AlterInboundRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12>\n" + + "\toperation\x18\x02 \x01(\v2 .xray.common.serial.TypedMessageR\toperation\"\x16\n" + + "\x14AlterInboundResponse\"5\n" + + "\x13ListInboundsRequest\x12\x1e\n" + + "\n" + + "isOnlyTags\x18\x01 \x01(\bR\n" + + "isOnlyTags\"S\n" + + "\x14ListInboundsResponse\x12;\n" + + "\binbounds\x18\x01 \x03(\v2\x1f.xray.core.InboundHandlerConfigR\binbounds\"?\n" + + "\x15GetInboundUserRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12\x14\n" + + "\x05email\x18\x02 \x01(\tR\x05email\"J\n" + + "\x16GetInboundUserResponse\x120\n" + + "\x05users\x18\x01 \x03(\v2\x1a.xray.common.protocol.UserR\x05users\"4\n" + + "\x1cGetInboundUsersCountResponse\x12\x14\n" + + "\x05count\x18\x01 \x01(\x03R\x05count\"R\n" + + "\x12AddOutboundRequest\x12<\n" + + "\boutbound\x18\x01 \x01(\v2 .xray.core.OutboundHandlerConfigR\boutbound\"\x15\n" + + "\x13AddOutboundResponse\")\n" + + "\x15RemoveOutboundRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\"\x18\n" + + "\x16RemoveOutboundResponse\"h\n" + + "\x14AlterOutboundRequest\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12>\n" + + "\toperation\x18\x02 \x01(\v2 .xray.common.serial.TypedMessageR\toperation\"\x17\n" + + "\x15AlterOutboundResponse\"\x16\n" + + "\x14ListOutboundsRequest\"W\n" + + "\x15ListOutboundsResponse\x12>\n" + + "\toutbounds\x18\x01 \x03(\v2 .xray.core.OutboundHandlerConfigR\toutbounds\"\b\n" + + "\x06Config2\xae\t\n" + + "\x0eHandlerService\x12k\n" + + "\n" + + "AddInbound\x12,.xray.app.proxyman.command.AddInboundRequest\x1a-.xray.app.proxyman.command.AddInboundResponse\"\x00\x12t\n" + + "\rRemoveInbound\x12/.xray.app.proxyman.command.RemoveInboundRequest\x1a0.xray.app.proxyman.command.RemoveInboundResponse\"\x00\x12q\n" + + "\fAlterInbound\x12..xray.app.proxyman.command.AlterInboundRequest\x1a/.xray.app.proxyman.command.AlterInboundResponse\"\x00\x12q\n" + + "\fListInbounds\x12..xray.app.proxyman.command.ListInboundsRequest\x1a/.xray.app.proxyman.command.ListInboundsResponse\"\x00\x12x\n" + + "\x0fGetInboundUsers\x120.xray.app.proxyman.command.GetInboundUserRequest\x1a1.xray.app.proxyman.command.GetInboundUserResponse\"\x00\x12\x83\x01\n" + + "\x14GetInboundUsersCount\x120.xray.app.proxyman.command.GetInboundUserRequest\x1a7.xray.app.proxyman.command.GetInboundUsersCountResponse\"\x00\x12n\n" + + "\vAddOutbound\x12-.xray.app.proxyman.command.AddOutboundRequest\x1a..xray.app.proxyman.command.AddOutboundResponse\"\x00\x12w\n" + + "\x0eRemoveOutbound\x120.xray.app.proxyman.command.RemoveOutboundRequest\x1a1.xray.app.proxyman.command.RemoveOutboundResponse\"\x00\x12t\n" + + "\rAlterOutbound\x12/.xray.app.proxyman.command.AlterOutboundRequest\x1a0.xray.app.proxyman.command.AlterOutboundResponse\"\x00\x12t\n" + + "\rListOutbounds\x12/.xray.app.proxyman.command.ListOutboundsRequest\x1a0.xray.app.proxyman.command.ListOutboundsResponse\"\x00B\xec\x01\n" + + "\x1dcom.xray.app.proxyman.commandB\fCommandProtoP\x01Z5github.com/mrcsin/usher/gen/xray/app/proxyman/command\xa2\x02\x04XAPC\xaa\x02\x19Xray.App.Proxyman.Command\xca\x02\x19Xray\\App\\Proxyman\\Command\xe2\x02%Xray\\App\\Proxyman\\Command\\GPBMetadata\xea\x02\x1cXray::App::Proxyman::Commandb\x06proto3" + +var ( + file_app_proxyman_command_command_proto_rawDescOnce sync.Once + file_app_proxyman_command_command_proto_rawDescData []byte +) + +func file_app_proxyman_command_command_proto_rawDescGZIP() []byte { + file_app_proxyman_command_command_proto_rawDescOnce.Do(func() { + file_app_proxyman_command_command_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_app_proxyman_command_command_proto_rawDesc), len(file_app_proxyman_command_command_proto_rawDesc))) + }) + return file_app_proxyman_command_command_proto_rawDescData +} + +var file_app_proxyman_command_command_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_app_proxyman_command_command_proto_goTypes = []any{ + (*AddUserOperation)(nil), // 0: xray.app.proxyman.command.AddUserOperation + (*RemoveUserOperation)(nil), // 1: xray.app.proxyman.command.RemoveUserOperation + (*AddInboundRequest)(nil), // 2: xray.app.proxyman.command.AddInboundRequest + (*AddInboundResponse)(nil), // 3: xray.app.proxyman.command.AddInboundResponse + (*RemoveInboundRequest)(nil), // 4: xray.app.proxyman.command.RemoveInboundRequest + (*RemoveInboundResponse)(nil), // 5: xray.app.proxyman.command.RemoveInboundResponse + (*AlterInboundRequest)(nil), // 6: xray.app.proxyman.command.AlterInboundRequest + (*AlterInboundResponse)(nil), // 7: xray.app.proxyman.command.AlterInboundResponse + (*ListInboundsRequest)(nil), // 8: xray.app.proxyman.command.ListInboundsRequest + (*ListInboundsResponse)(nil), // 9: xray.app.proxyman.command.ListInboundsResponse + (*GetInboundUserRequest)(nil), // 10: xray.app.proxyman.command.GetInboundUserRequest + (*GetInboundUserResponse)(nil), // 11: xray.app.proxyman.command.GetInboundUserResponse + (*GetInboundUsersCountResponse)(nil), // 12: xray.app.proxyman.command.GetInboundUsersCountResponse + (*AddOutboundRequest)(nil), // 13: xray.app.proxyman.command.AddOutboundRequest + (*AddOutboundResponse)(nil), // 14: xray.app.proxyman.command.AddOutboundResponse + (*RemoveOutboundRequest)(nil), // 15: xray.app.proxyman.command.RemoveOutboundRequest + (*RemoveOutboundResponse)(nil), // 16: xray.app.proxyman.command.RemoveOutboundResponse + (*AlterOutboundRequest)(nil), // 17: xray.app.proxyman.command.AlterOutboundRequest + (*AlterOutboundResponse)(nil), // 18: xray.app.proxyman.command.AlterOutboundResponse + (*ListOutboundsRequest)(nil), // 19: xray.app.proxyman.command.ListOutboundsRequest + (*ListOutboundsResponse)(nil), // 20: xray.app.proxyman.command.ListOutboundsResponse + (*Config)(nil), // 21: xray.app.proxyman.command.Config + (*protocol.User)(nil), // 22: xray.common.protocol.User + (*core.InboundHandlerConfig)(nil), // 23: xray.core.InboundHandlerConfig + (*serial.TypedMessage)(nil), // 24: xray.common.serial.TypedMessage + (*core.OutboundHandlerConfig)(nil), // 25: xray.core.OutboundHandlerConfig +} +var file_app_proxyman_command_command_proto_depIdxs = []int32{ + 22, // 0: xray.app.proxyman.command.AddUserOperation.user:type_name -> xray.common.protocol.User + 23, // 1: xray.app.proxyman.command.AddInboundRequest.inbound:type_name -> xray.core.InboundHandlerConfig + 24, // 2: xray.app.proxyman.command.AlterInboundRequest.operation:type_name -> xray.common.serial.TypedMessage + 23, // 3: xray.app.proxyman.command.ListInboundsResponse.inbounds:type_name -> xray.core.InboundHandlerConfig + 22, // 4: xray.app.proxyman.command.GetInboundUserResponse.users:type_name -> xray.common.protocol.User + 25, // 5: xray.app.proxyman.command.AddOutboundRequest.outbound:type_name -> xray.core.OutboundHandlerConfig + 24, // 6: xray.app.proxyman.command.AlterOutboundRequest.operation:type_name -> xray.common.serial.TypedMessage + 25, // 7: xray.app.proxyman.command.ListOutboundsResponse.outbounds:type_name -> xray.core.OutboundHandlerConfig + 2, // 8: xray.app.proxyman.command.HandlerService.AddInbound:input_type -> xray.app.proxyman.command.AddInboundRequest + 4, // 9: xray.app.proxyman.command.HandlerService.RemoveInbound:input_type -> xray.app.proxyman.command.RemoveInboundRequest + 6, // 10: xray.app.proxyman.command.HandlerService.AlterInbound:input_type -> xray.app.proxyman.command.AlterInboundRequest + 8, // 11: xray.app.proxyman.command.HandlerService.ListInbounds:input_type -> xray.app.proxyman.command.ListInboundsRequest + 10, // 12: xray.app.proxyman.command.HandlerService.GetInboundUsers:input_type -> xray.app.proxyman.command.GetInboundUserRequest + 10, // 13: xray.app.proxyman.command.HandlerService.GetInboundUsersCount:input_type -> xray.app.proxyman.command.GetInboundUserRequest + 13, // 14: xray.app.proxyman.command.HandlerService.AddOutbound:input_type -> xray.app.proxyman.command.AddOutboundRequest + 15, // 15: xray.app.proxyman.command.HandlerService.RemoveOutbound:input_type -> xray.app.proxyman.command.RemoveOutboundRequest + 17, // 16: xray.app.proxyman.command.HandlerService.AlterOutbound:input_type -> xray.app.proxyman.command.AlterOutboundRequest + 19, // 17: xray.app.proxyman.command.HandlerService.ListOutbounds:input_type -> xray.app.proxyman.command.ListOutboundsRequest + 3, // 18: xray.app.proxyman.command.HandlerService.AddInbound:output_type -> xray.app.proxyman.command.AddInboundResponse + 5, // 19: xray.app.proxyman.command.HandlerService.RemoveInbound:output_type -> xray.app.proxyman.command.RemoveInboundResponse + 7, // 20: xray.app.proxyman.command.HandlerService.AlterInbound:output_type -> xray.app.proxyman.command.AlterInboundResponse + 9, // 21: xray.app.proxyman.command.HandlerService.ListInbounds:output_type -> xray.app.proxyman.command.ListInboundsResponse + 11, // 22: xray.app.proxyman.command.HandlerService.GetInboundUsers:output_type -> xray.app.proxyman.command.GetInboundUserResponse + 12, // 23: xray.app.proxyman.command.HandlerService.GetInboundUsersCount:output_type -> xray.app.proxyman.command.GetInboundUsersCountResponse + 14, // 24: xray.app.proxyman.command.HandlerService.AddOutbound:output_type -> xray.app.proxyman.command.AddOutboundResponse + 16, // 25: xray.app.proxyman.command.HandlerService.RemoveOutbound:output_type -> xray.app.proxyman.command.RemoveOutboundResponse + 18, // 26: xray.app.proxyman.command.HandlerService.AlterOutbound:output_type -> xray.app.proxyman.command.AlterOutboundResponse + 20, // 27: xray.app.proxyman.command.HandlerService.ListOutbounds:output_type -> xray.app.proxyman.command.ListOutboundsResponse + 18, // [18:28] is the sub-list for method output_type + 8, // [8:18] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_app_proxyman_command_command_proto_init() } +func file_app_proxyman_command_command_proto_init() { + if File_app_proxyman_command_command_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_app_proxyman_command_command_proto_rawDesc), len(file_app_proxyman_command_command_proto_rawDesc)), + NumEnums: 0, + NumMessages: 22, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_app_proxyman_command_command_proto_goTypes, + DependencyIndexes: file_app_proxyman_command_command_proto_depIdxs, + MessageInfos: file_app_proxyman_command_command_proto_msgTypes, + }.Build() + File_app_proxyman_command_command_proto = out.File + file_app_proxyman_command_command_proto_goTypes = nil + file_app_proxyman_command_command_proto_depIdxs = nil +} diff --git a/gen/xray/app/proxyman/command/command_grpc.pb.go b/gen/xray/app/proxyman/command/command_grpc.pb.go new file mode 100644 index 0000000..c5e7274 --- /dev/null +++ b/gen/xray/app/proxyman/command/command_grpc.pb.go @@ -0,0 +1,463 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: app/proxyman/command/command.proto + +package command + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + HandlerService_AddInbound_FullMethodName = "/xray.app.proxyman.command.HandlerService/AddInbound" + HandlerService_RemoveInbound_FullMethodName = "/xray.app.proxyman.command.HandlerService/RemoveInbound" + HandlerService_AlterInbound_FullMethodName = "/xray.app.proxyman.command.HandlerService/AlterInbound" + HandlerService_ListInbounds_FullMethodName = "/xray.app.proxyman.command.HandlerService/ListInbounds" + HandlerService_GetInboundUsers_FullMethodName = "/xray.app.proxyman.command.HandlerService/GetInboundUsers" + HandlerService_GetInboundUsersCount_FullMethodName = "/xray.app.proxyman.command.HandlerService/GetInboundUsersCount" + HandlerService_AddOutbound_FullMethodName = "/xray.app.proxyman.command.HandlerService/AddOutbound" + HandlerService_RemoveOutbound_FullMethodName = "/xray.app.proxyman.command.HandlerService/RemoveOutbound" + HandlerService_AlterOutbound_FullMethodName = "/xray.app.proxyman.command.HandlerService/AlterOutbound" + HandlerService_ListOutbounds_FullMethodName = "/xray.app.proxyman.command.HandlerService/ListOutbounds" +) + +// HandlerServiceClient is the client API for HandlerService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type HandlerServiceClient interface { + AddInbound(ctx context.Context, in *AddInboundRequest, opts ...grpc.CallOption) (*AddInboundResponse, error) + RemoveInbound(ctx context.Context, in *RemoveInboundRequest, opts ...grpc.CallOption) (*RemoveInboundResponse, error) + AlterInbound(ctx context.Context, in *AlterInboundRequest, opts ...grpc.CallOption) (*AlterInboundResponse, error) + ListInbounds(ctx context.Context, in *ListInboundsRequest, opts ...grpc.CallOption) (*ListInboundsResponse, error) + GetInboundUsers(ctx context.Context, in *GetInboundUserRequest, opts ...grpc.CallOption) (*GetInboundUserResponse, error) + GetInboundUsersCount(ctx context.Context, in *GetInboundUserRequest, opts ...grpc.CallOption) (*GetInboundUsersCountResponse, error) + AddOutbound(ctx context.Context, in *AddOutboundRequest, opts ...grpc.CallOption) (*AddOutboundResponse, error) + RemoveOutbound(ctx context.Context, in *RemoveOutboundRequest, opts ...grpc.CallOption) (*RemoveOutboundResponse, error) + AlterOutbound(ctx context.Context, in *AlterOutboundRequest, opts ...grpc.CallOption) (*AlterOutboundResponse, error) + ListOutbounds(ctx context.Context, in *ListOutboundsRequest, opts ...grpc.CallOption) (*ListOutboundsResponse, error) +} + +type handlerServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewHandlerServiceClient(cc grpc.ClientConnInterface) HandlerServiceClient { + return &handlerServiceClient{cc} +} + +func (c *handlerServiceClient) AddInbound(ctx context.Context, in *AddInboundRequest, opts ...grpc.CallOption) (*AddInboundResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AddInboundResponse) + err := c.cc.Invoke(ctx, HandlerService_AddInbound_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) RemoveInbound(ctx context.Context, in *RemoveInboundRequest, opts ...grpc.CallOption) (*RemoveInboundResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RemoveInboundResponse) + err := c.cc.Invoke(ctx, HandlerService_RemoveInbound_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) AlterInbound(ctx context.Context, in *AlterInboundRequest, opts ...grpc.CallOption) (*AlterInboundResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AlterInboundResponse) + err := c.cc.Invoke(ctx, HandlerService_AlterInbound_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) ListInbounds(ctx context.Context, in *ListInboundsRequest, opts ...grpc.CallOption) (*ListInboundsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListInboundsResponse) + err := c.cc.Invoke(ctx, HandlerService_ListInbounds_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) GetInboundUsers(ctx context.Context, in *GetInboundUserRequest, opts ...grpc.CallOption) (*GetInboundUserResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInboundUserResponse) + err := c.cc.Invoke(ctx, HandlerService_GetInboundUsers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) GetInboundUsersCount(ctx context.Context, in *GetInboundUserRequest, opts ...grpc.CallOption) (*GetInboundUsersCountResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInboundUsersCountResponse) + err := c.cc.Invoke(ctx, HandlerService_GetInboundUsersCount_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) AddOutbound(ctx context.Context, in *AddOutboundRequest, opts ...grpc.CallOption) (*AddOutboundResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AddOutboundResponse) + err := c.cc.Invoke(ctx, HandlerService_AddOutbound_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) RemoveOutbound(ctx context.Context, in *RemoveOutboundRequest, opts ...grpc.CallOption) (*RemoveOutboundResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RemoveOutboundResponse) + err := c.cc.Invoke(ctx, HandlerService_RemoveOutbound_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) AlterOutbound(ctx context.Context, in *AlterOutboundRequest, opts ...grpc.CallOption) (*AlterOutboundResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AlterOutboundResponse) + err := c.cc.Invoke(ctx, HandlerService_AlterOutbound_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *handlerServiceClient) ListOutbounds(ctx context.Context, in *ListOutboundsRequest, opts ...grpc.CallOption) (*ListOutboundsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListOutboundsResponse) + err := c.cc.Invoke(ctx, HandlerService_ListOutbounds_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// HandlerServiceServer is the server API for HandlerService service. +// All implementations must embed UnimplementedHandlerServiceServer +// for forward compatibility. +type HandlerServiceServer interface { + AddInbound(context.Context, *AddInboundRequest) (*AddInboundResponse, error) + RemoveInbound(context.Context, *RemoveInboundRequest) (*RemoveInboundResponse, error) + AlterInbound(context.Context, *AlterInboundRequest) (*AlterInboundResponse, error) + ListInbounds(context.Context, *ListInboundsRequest) (*ListInboundsResponse, error) + GetInboundUsers(context.Context, *GetInboundUserRequest) (*GetInboundUserResponse, error) + GetInboundUsersCount(context.Context, *GetInboundUserRequest) (*GetInboundUsersCountResponse, error) + AddOutbound(context.Context, *AddOutboundRequest) (*AddOutboundResponse, error) + RemoveOutbound(context.Context, *RemoveOutboundRequest) (*RemoveOutboundResponse, error) + AlterOutbound(context.Context, *AlterOutboundRequest) (*AlterOutboundResponse, error) + ListOutbounds(context.Context, *ListOutboundsRequest) (*ListOutboundsResponse, error) + mustEmbedUnimplementedHandlerServiceServer() +} + +// UnimplementedHandlerServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedHandlerServiceServer struct{} + +func (UnimplementedHandlerServiceServer) AddInbound(context.Context, *AddInboundRequest) (*AddInboundResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AddInbound not implemented") +} +func (UnimplementedHandlerServiceServer) RemoveInbound(context.Context, *RemoveInboundRequest) (*RemoveInboundResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveInbound not implemented") +} +func (UnimplementedHandlerServiceServer) AlterInbound(context.Context, *AlterInboundRequest) (*AlterInboundResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AlterInbound not implemented") +} +func (UnimplementedHandlerServiceServer) ListInbounds(context.Context, *ListInboundsRequest) (*ListInboundsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListInbounds not implemented") +} +func (UnimplementedHandlerServiceServer) GetInboundUsers(context.Context, *GetInboundUserRequest) (*GetInboundUserResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInboundUsers not implemented") +} +func (UnimplementedHandlerServiceServer) GetInboundUsersCount(context.Context, *GetInboundUserRequest) (*GetInboundUsersCountResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInboundUsersCount not implemented") +} +func (UnimplementedHandlerServiceServer) AddOutbound(context.Context, *AddOutboundRequest) (*AddOutboundResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AddOutbound not implemented") +} +func (UnimplementedHandlerServiceServer) RemoveOutbound(context.Context, *RemoveOutboundRequest) (*RemoveOutboundResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveOutbound not implemented") +} +func (UnimplementedHandlerServiceServer) AlterOutbound(context.Context, *AlterOutboundRequest) (*AlterOutboundResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AlterOutbound not implemented") +} +func (UnimplementedHandlerServiceServer) ListOutbounds(context.Context, *ListOutboundsRequest) (*ListOutboundsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListOutbounds not implemented") +} +func (UnimplementedHandlerServiceServer) mustEmbedUnimplementedHandlerServiceServer() {} +func (UnimplementedHandlerServiceServer) testEmbeddedByValue() {} + +// UnsafeHandlerServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to HandlerServiceServer will +// result in compilation errors. +type UnsafeHandlerServiceServer interface { + mustEmbedUnimplementedHandlerServiceServer() +} + +func RegisterHandlerServiceServer(s grpc.ServiceRegistrar, srv HandlerServiceServer) { + // If the following call panics, it indicates UnimplementedHandlerServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&HandlerService_ServiceDesc, srv) +} + +func _HandlerService_AddInbound_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddInboundRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).AddInbound(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_AddInbound_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).AddInbound(ctx, req.(*AddInboundRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_RemoveInbound_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RemoveInboundRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).RemoveInbound(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_RemoveInbound_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).RemoveInbound(ctx, req.(*RemoveInboundRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_AlterInbound_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AlterInboundRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).AlterInbound(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_AlterInbound_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).AlterInbound(ctx, req.(*AlterInboundRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_ListInbounds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListInboundsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).ListInbounds(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_ListInbounds_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).ListInbounds(ctx, req.(*ListInboundsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_GetInboundUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInboundUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).GetInboundUsers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_GetInboundUsers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).GetInboundUsers(ctx, req.(*GetInboundUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_GetInboundUsersCount_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInboundUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).GetInboundUsersCount(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_GetInboundUsersCount_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).GetInboundUsersCount(ctx, req.(*GetInboundUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_AddOutbound_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddOutboundRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).AddOutbound(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_AddOutbound_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).AddOutbound(ctx, req.(*AddOutboundRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_RemoveOutbound_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RemoveOutboundRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).RemoveOutbound(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_RemoveOutbound_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).RemoveOutbound(ctx, req.(*RemoveOutboundRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_AlterOutbound_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AlterOutboundRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).AlterOutbound(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_AlterOutbound_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).AlterOutbound(ctx, req.(*AlterOutboundRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HandlerService_ListOutbounds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListOutboundsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HandlerServiceServer).ListOutbounds(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HandlerService_ListOutbounds_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HandlerServiceServer).ListOutbounds(ctx, req.(*ListOutboundsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// HandlerService_ServiceDesc is the grpc.ServiceDesc for HandlerService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var HandlerService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "xray.app.proxyman.command.HandlerService", + HandlerType: (*HandlerServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "AddInbound", + Handler: _HandlerService_AddInbound_Handler, + }, + { + MethodName: "RemoveInbound", + Handler: _HandlerService_RemoveInbound_Handler, + }, + { + MethodName: "AlterInbound", + Handler: _HandlerService_AlterInbound_Handler, + }, + { + MethodName: "ListInbounds", + Handler: _HandlerService_ListInbounds_Handler, + }, + { + MethodName: "GetInboundUsers", + Handler: _HandlerService_GetInboundUsers_Handler, + }, + { + MethodName: "GetInboundUsersCount", + Handler: _HandlerService_GetInboundUsersCount_Handler, + }, + { + MethodName: "AddOutbound", + Handler: _HandlerService_AddOutbound_Handler, + }, + { + MethodName: "RemoveOutbound", + Handler: _HandlerService_RemoveOutbound_Handler, + }, + { + MethodName: "AlterOutbound", + Handler: _HandlerService_AlterOutbound_Handler, + }, + { + MethodName: "ListOutbounds", + Handler: _HandlerService_ListOutbounds_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "app/proxyman/command/command.proto", +} diff --git a/gen/xray/app/proxyman/config.pb.go b/gen/xray/app/proxyman/config.pb.go new file mode 100644 index 0000000..5bb0431 --- /dev/null +++ b/gen/xray/app/proxyman/config.pb.go @@ -0,0 +1,586 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: app/proxyman/config.proto + +package proxyman + +import ( + geodata "github.com/mrcsin/usher/gen/xray/common/geodata" + net "github.com/mrcsin/usher/gen/xray/common/net" + serial "github.com/mrcsin/usher/gen/xray/common/serial" + internet "github.com/mrcsin/usher/gen/xray/transport/internet" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type InboundConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InboundConfig) Reset() { + *x = InboundConfig{} + mi := &file_app_proxyman_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InboundConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InboundConfig) ProtoMessage() {} + +func (x *InboundConfig) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InboundConfig.ProtoReflect.Descriptor instead. +func (*InboundConfig) Descriptor() ([]byte, []int) { + return file_app_proxyman_config_proto_rawDescGZIP(), []int{0} +} + +type SniffingConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether or not to enable content sniffing on an inbound connection. + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + // Override target destination if sniff'ed protocol is in the given list. + // Supported values are "http", "tls", "fakedns". + DestinationOverride []string `protobuf:"bytes,2,rep,name=destination_override,json=destinationOverride,proto3" json:"destination_override,omitempty"` + DomainsExcluded []*geodata.DomainRule `protobuf:"bytes,3,rep,name=domains_excluded,json=domainsExcluded,proto3" json:"domains_excluded,omitempty"` + IpsExcluded []*geodata.IPRule `protobuf:"bytes,6,rep,name=ips_excluded,json=ipsExcluded,proto3" json:"ips_excluded,omitempty"` + // Whether should only try to sniff metadata without waiting for client input. + // Can be used to support SMTP like protocol where server send the first + // message. + MetadataOnly bool `protobuf:"varint,4,opt,name=metadata_only,json=metadataOnly,proto3" json:"metadata_only,omitempty"` + RouteOnly bool `protobuf:"varint,5,opt,name=route_only,json=routeOnly,proto3" json:"route_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SniffingConfig) Reset() { + *x = SniffingConfig{} + mi := &file_app_proxyman_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SniffingConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SniffingConfig) ProtoMessage() {} + +func (x *SniffingConfig) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SniffingConfig.ProtoReflect.Descriptor instead. +func (*SniffingConfig) Descriptor() ([]byte, []int) { + return file_app_proxyman_config_proto_rawDescGZIP(), []int{1} +} + +func (x *SniffingConfig) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *SniffingConfig) GetDestinationOverride() []string { + if x != nil { + return x.DestinationOverride + } + return nil +} + +func (x *SniffingConfig) GetDomainsExcluded() []*geodata.DomainRule { + if x != nil { + return x.DomainsExcluded + } + return nil +} + +func (x *SniffingConfig) GetIpsExcluded() []*geodata.IPRule { + if x != nil { + return x.IpsExcluded + } + return nil +} + +func (x *SniffingConfig) GetMetadataOnly() bool { + if x != nil { + return x.MetadataOnly + } + return false +} + +func (x *SniffingConfig) GetRouteOnly() bool { + if x != nil { + return x.RouteOnly + } + return false +} + +type ReceiverConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // PortList specifies the ports which the Receiver should listen on. + PortList *net.PortList `protobuf:"bytes,1,opt,name=port_list,json=portList,proto3" json:"port_list,omitempty"` + // Listen specifies the IP address that the Receiver should listen on. + Listen *net.IPOrDomain `protobuf:"bytes,2,opt,name=listen,proto3" json:"listen,omitempty"` + StreamSettings *internet.StreamConfig `protobuf:"bytes,3,opt,name=stream_settings,json=streamSettings,proto3" json:"stream_settings,omitempty"` + ReceiveOriginalDestination bool `protobuf:"varint,4,opt,name=receive_original_destination,json=receiveOriginalDestination,proto3" json:"receive_original_destination,omitempty"` + SniffingSettings *SniffingConfig `protobuf:"bytes,6,opt,name=sniffing_settings,json=sniffingSettings,proto3" json:"sniffing_settings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReceiverConfig) Reset() { + *x = ReceiverConfig{} + mi := &file_app_proxyman_config_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReceiverConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReceiverConfig) ProtoMessage() {} + +func (x *ReceiverConfig) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_config_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReceiverConfig.ProtoReflect.Descriptor instead. +func (*ReceiverConfig) Descriptor() ([]byte, []int) { + return file_app_proxyman_config_proto_rawDescGZIP(), []int{2} +} + +func (x *ReceiverConfig) GetPortList() *net.PortList { + if x != nil { + return x.PortList + } + return nil +} + +func (x *ReceiverConfig) GetListen() *net.IPOrDomain { + if x != nil { + return x.Listen + } + return nil +} + +func (x *ReceiverConfig) GetStreamSettings() *internet.StreamConfig { + if x != nil { + return x.StreamSettings + } + return nil +} + +func (x *ReceiverConfig) GetReceiveOriginalDestination() bool { + if x != nil { + return x.ReceiveOriginalDestination + } + return false +} + +func (x *ReceiverConfig) GetSniffingSettings() *SniffingConfig { + if x != nil { + return x.SniffingSettings + } + return nil +} + +type InboundHandlerConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + ReceiverSettings *serial.TypedMessage `protobuf:"bytes,2,opt,name=receiver_settings,json=receiverSettings,proto3" json:"receiver_settings,omitempty"` + ProxySettings *serial.TypedMessage `protobuf:"bytes,3,opt,name=proxy_settings,json=proxySettings,proto3" json:"proxy_settings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InboundHandlerConfig) Reset() { + *x = InboundHandlerConfig{} + mi := &file_app_proxyman_config_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InboundHandlerConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InboundHandlerConfig) ProtoMessage() {} + +func (x *InboundHandlerConfig) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_config_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InboundHandlerConfig.ProtoReflect.Descriptor instead. +func (*InboundHandlerConfig) Descriptor() ([]byte, []int) { + return file_app_proxyman_config_proto_rawDescGZIP(), []int{3} +} + +func (x *InboundHandlerConfig) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *InboundHandlerConfig) GetReceiverSettings() *serial.TypedMessage { + if x != nil { + return x.ReceiverSettings + } + return nil +} + +func (x *InboundHandlerConfig) GetProxySettings() *serial.TypedMessage { + if x != nil { + return x.ProxySettings + } + return nil +} + +type OutboundConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OutboundConfig) Reset() { + *x = OutboundConfig{} + mi := &file_app_proxyman_config_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OutboundConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OutboundConfig) ProtoMessage() {} + +func (x *OutboundConfig) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_config_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OutboundConfig.ProtoReflect.Descriptor instead. +func (*OutboundConfig) Descriptor() ([]byte, []int) { + return file_app_proxyman_config_proto_rawDescGZIP(), []int{4} +} + +type SenderConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Send traffic through the given IP. Only IP is allowed. + Via *net.IPOrDomain `protobuf:"bytes,1,opt,name=via,proto3" json:"via,omitempty"` + StreamSettings *internet.StreamConfig `protobuf:"bytes,2,opt,name=stream_settings,json=streamSettings,proto3" json:"stream_settings,omitempty"` + MultiplexSettings *MultiplexingConfig `protobuf:"bytes,4,opt,name=multiplex_settings,json=multiplexSettings,proto3" json:"multiplex_settings,omitempty"` + ViaCidr string `protobuf:"bytes,5,opt,name=via_cidr,json=viaCidr,proto3" json:"via_cidr,omitempty"` + TargetStrategy internet.DomainStrategy `protobuf:"varint,6,opt,name=target_strategy,json=targetStrategy,proto3,enum=xray.transport.internet.DomainStrategy" json:"target_strategy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SenderConfig) Reset() { + *x = SenderConfig{} + mi := &file_app_proxyman_config_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SenderConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SenderConfig) ProtoMessage() {} + +func (x *SenderConfig) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_config_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SenderConfig.ProtoReflect.Descriptor instead. +func (*SenderConfig) Descriptor() ([]byte, []int) { + return file_app_proxyman_config_proto_rawDescGZIP(), []int{5} +} + +func (x *SenderConfig) GetVia() *net.IPOrDomain { + if x != nil { + return x.Via + } + return nil +} + +func (x *SenderConfig) GetStreamSettings() *internet.StreamConfig { + if x != nil { + return x.StreamSettings + } + return nil +} + +func (x *SenderConfig) GetMultiplexSettings() *MultiplexingConfig { + if x != nil { + return x.MultiplexSettings + } + return nil +} + +func (x *SenderConfig) GetViaCidr() string { + if x != nil { + return x.ViaCidr + } + return "" +} + +func (x *SenderConfig) GetTargetStrategy() internet.DomainStrategy { + if x != nil { + return x.TargetStrategy + } + return internet.DomainStrategy(0) +} + +type MultiplexingConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether or not Mux is enabled. + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + // Max number of concurrent connections that one Mux connection can handle. + Concurrency int32 `protobuf:"varint,2,opt,name=concurrency,proto3" json:"concurrency,omitempty"` + // Transport XUDP in another Mux. + XudpConcurrency int32 `protobuf:"varint,3,opt,name=xudpConcurrency,proto3" json:"xudpConcurrency,omitempty"` + // "reject" (default), "allow" or "skip". + XudpProxyUDP443 string `protobuf:"bytes,4,opt,name=xudpProxyUDP443,proto3" json:"xudpProxyUDP443,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MultiplexingConfig) Reset() { + *x = MultiplexingConfig{} + mi := &file_app_proxyman_config_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MultiplexingConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MultiplexingConfig) ProtoMessage() {} + +func (x *MultiplexingConfig) ProtoReflect() protoreflect.Message { + mi := &file_app_proxyman_config_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MultiplexingConfig.ProtoReflect.Descriptor instead. +func (*MultiplexingConfig) Descriptor() ([]byte, []int) { + return file_app_proxyman_config_proto_rawDescGZIP(), []int{6} +} + +func (x *MultiplexingConfig) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *MultiplexingConfig) GetConcurrency() int32 { + if x != nil { + return x.Concurrency + } + return 0 +} + +func (x *MultiplexingConfig) GetXudpConcurrency() int32 { + if x != nil { + return x.XudpConcurrency + } + return 0 +} + +func (x *MultiplexingConfig) GetXudpProxyUDP443() string { + if x != nil { + return x.XudpProxyUDP443 + } + return "" +} + +var File_app_proxyman_config_proto protoreflect.FileDescriptor + +const file_app_proxyman_config_proto_rawDesc = "" + + "\n" + + "\x19app/proxyman/config.proto\x12\x11xray.app.proxyman\x1a\x18common/net/address.proto\x1a\x15common/net/port.proto\x1a\x1ftransport/internet/config.proto\x1a!common/serial/typed_message.proto\x1a\x1bcommon/geodata/geodat.proto\"\x0f\n" + + "\rInboundConfig\"\xad\x02\n" + + "\x0eSniffingConfig\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x121\n" + + "\x14destination_override\x18\x02 \x03(\tR\x13destinationOverride\x12J\n" + + "\x10domains_excluded\x18\x03 \x03(\v2\x1f.xray.common.geodata.DomainRuleR\x0fdomainsExcluded\x12>\n" + + "\fips_excluded\x18\x06 \x03(\v2\x1b.xray.common.geodata.IPRuleR\vipsExcluded\x12#\n" + + "\rmetadata_only\x18\x04 \x01(\bR\fmetadataOnly\x12\x1d\n" + + "\n" + + "route_only\x18\x05 \x01(\bR\trouteOnly\"\xe5\x02\n" + + "\x0eReceiverConfig\x126\n" + + "\tport_list\x18\x01 \x01(\v2\x19.xray.common.net.PortListR\bportList\x123\n" + + "\x06listen\x18\x02 \x01(\v2\x1b.xray.common.net.IPOrDomainR\x06listen\x12N\n" + + "\x0fstream_settings\x18\x03 \x01(\v2%.xray.transport.internet.StreamConfigR\x0estreamSettings\x12@\n" + + "\x1creceive_original_destination\x18\x04 \x01(\bR\x1areceiveOriginalDestination\x12N\n" + + "\x11sniffing_settings\x18\x06 \x01(\v2!.xray.app.proxyman.SniffingConfigR\x10sniffingSettingsJ\x04\b\x05\x10\x06\"\xc0\x01\n" + + "\x14InboundHandlerConfig\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12M\n" + + "\x11receiver_settings\x18\x02 \x01(\v2 .xray.common.serial.TypedMessageR\x10receiverSettings\x12G\n" + + "\x0eproxy_settings\x18\x03 \x01(\v2 .xray.common.serial.TypedMessageR\rproxySettings\"\x10\n" + + "\x0eOutboundConfig\"\xd6\x02\n" + + "\fSenderConfig\x12-\n" + + "\x03via\x18\x01 \x01(\v2\x1b.xray.common.net.IPOrDomainR\x03via\x12N\n" + + "\x0fstream_settings\x18\x02 \x01(\v2%.xray.transport.internet.StreamConfigR\x0estreamSettings\x12T\n" + + "\x12multiplex_settings\x18\x04 \x01(\v2%.xray.app.proxyman.MultiplexingConfigR\x11multiplexSettings\x12\x19\n" + + "\bvia_cidr\x18\x05 \x01(\tR\aviaCidr\x12P\n" + + "\x0ftarget_strategy\x18\x06 \x01(\x0e2'.xray.transport.internet.DomainStrategyR\x0etargetStrategyJ\x04\b\x03\x10\x04\"\xa4\x01\n" + + "\x12MultiplexingConfig\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12 \n" + + "\vconcurrency\x18\x02 \x01(\x05R\vconcurrency\x12(\n" + + "\x0fxudpConcurrency\x18\x03 \x01(\x05R\x0fxudpConcurrency\x12(\n" + + "\x0fxudpProxyUDP443\x18\x04 \x01(\tR\x0fxudpProxyUDP443B\xb9\x01\n" + + "\x15com.xray.app.proxymanB\vConfigProtoP\x01Z-github.com/mrcsin/usher/gen/xray/app/proxyman\xa2\x02\x03XAP\xaa\x02\x11Xray.App.Proxyman\xca\x02\x11Xray\\App\\Proxyman\xe2\x02\x1dXray\\App\\Proxyman\\GPBMetadata\xea\x02\x13Xray::App::Proxymanb\x06proto3" + +var ( + file_app_proxyman_config_proto_rawDescOnce sync.Once + file_app_proxyman_config_proto_rawDescData []byte +) + +func file_app_proxyman_config_proto_rawDescGZIP() []byte { + file_app_proxyman_config_proto_rawDescOnce.Do(func() { + file_app_proxyman_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_app_proxyman_config_proto_rawDesc), len(file_app_proxyman_config_proto_rawDesc))) + }) + return file_app_proxyman_config_proto_rawDescData +} + +var file_app_proxyman_config_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_app_proxyman_config_proto_goTypes = []any{ + (*InboundConfig)(nil), // 0: xray.app.proxyman.InboundConfig + (*SniffingConfig)(nil), // 1: xray.app.proxyman.SniffingConfig + (*ReceiverConfig)(nil), // 2: xray.app.proxyman.ReceiverConfig + (*InboundHandlerConfig)(nil), // 3: xray.app.proxyman.InboundHandlerConfig + (*OutboundConfig)(nil), // 4: xray.app.proxyman.OutboundConfig + (*SenderConfig)(nil), // 5: xray.app.proxyman.SenderConfig + (*MultiplexingConfig)(nil), // 6: xray.app.proxyman.MultiplexingConfig + (*geodata.DomainRule)(nil), // 7: xray.common.geodata.DomainRule + (*geodata.IPRule)(nil), // 8: xray.common.geodata.IPRule + (*net.PortList)(nil), // 9: xray.common.net.PortList + (*net.IPOrDomain)(nil), // 10: xray.common.net.IPOrDomain + (*internet.StreamConfig)(nil), // 11: xray.transport.internet.StreamConfig + (*serial.TypedMessage)(nil), // 12: xray.common.serial.TypedMessage + (internet.DomainStrategy)(0), // 13: xray.transport.internet.DomainStrategy +} +var file_app_proxyman_config_proto_depIdxs = []int32{ + 7, // 0: xray.app.proxyman.SniffingConfig.domains_excluded:type_name -> xray.common.geodata.DomainRule + 8, // 1: xray.app.proxyman.SniffingConfig.ips_excluded:type_name -> xray.common.geodata.IPRule + 9, // 2: xray.app.proxyman.ReceiverConfig.port_list:type_name -> xray.common.net.PortList + 10, // 3: xray.app.proxyman.ReceiverConfig.listen:type_name -> xray.common.net.IPOrDomain + 11, // 4: xray.app.proxyman.ReceiverConfig.stream_settings:type_name -> xray.transport.internet.StreamConfig + 1, // 5: xray.app.proxyman.ReceiverConfig.sniffing_settings:type_name -> xray.app.proxyman.SniffingConfig + 12, // 6: xray.app.proxyman.InboundHandlerConfig.receiver_settings:type_name -> xray.common.serial.TypedMessage + 12, // 7: xray.app.proxyman.InboundHandlerConfig.proxy_settings:type_name -> xray.common.serial.TypedMessage + 10, // 8: xray.app.proxyman.SenderConfig.via:type_name -> xray.common.net.IPOrDomain + 11, // 9: xray.app.proxyman.SenderConfig.stream_settings:type_name -> xray.transport.internet.StreamConfig + 6, // 10: xray.app.proxyman.SenderConfig.multiplex_settings:type_name -> xray.app.proxyman.MultiplexingConfig + 13, // 11: xray.app.proxyman.SenderConfig.target_strategy:type_name -> xray.transport.internet.DomainStrategy + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name +} + +func init() { file_app_proxyman_config_proto_init() } +func file_app_proxyman_config_proto_init() { + if File_app_proxyman_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_app_proxyman_config_proto_rawDesc), len(file_app_proxyman_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_app_proxyman_config_proto_goTypes, + DependencyIndexes: file_app_proxyman_config_proto_depIdxs, + MessageInfos: file_app_proxyman_config_proto_msgTypes, + }.Build() + File_app_proxyman_config_proto = out.File + file_app_proxyman_config_proto_goTypes = nil + file_app_proxyman_config_proto_depIdxs = nil +} diff --git a/gen/xray/common/geodata/geodat.pb.go b/gen/xray/common/geodata/geodat.pb.go new file mode 100644 index 0000000..a085bc9 --- /dev/null +++ b/gen/xray/common/geodata/geodat.pb.go @@ -0,0 +1,965 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: common/geodata/geodat.proto + +package geodata + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Type of domain value. +type Domain_Type int32 + +const ( + // The value is used as a sub string. + Domain_Substr Domain_Type = 0 + // The value is used as a regular expression. + Domain_Regex Domain_Type = 1 + // The value is a domain. + Domain_Domain Domain_Type = 2 + // The value is a full domain. + Domain_Full Domain_Type = 3 +) + +// Enum value maps for Domain_Type. +var ( + Domain_Type_name = map[int32]string{ + 0: "Substr", + 1: "Regex", + 2: "Domain", + 3: "Full", + } + Domain_Type_value = map[string]int32{ + "Substr": 0, + "Regex": 1, + "Domain": 2, + "Full": 3, + } +) + +func (x Domain_Type) Enum() *Domain_Type { + p := new(Domain_Type) + *p = x + return p +} + +func (x Domain_Type) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Domain_Type) Descriptor() protoreflect.EnumDescriptor { + return file_common_geodata_geodat_proto_enumTypes[0].Descriptor() +} + +func (Domain_Type) Type() protoreflect.EnumType { + return &file_common_geodata_geodat_proto_enumTypes[0] +} + +func (x Domain_Type) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Domain_Type.Descriptor instead. +func (Domain_Type) EnumDescriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{0, 0} +} + +type Domain struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Domain matching type. + Type Domain_Type `protobuf:"varint,1,opt,name=type,proto3,enum=xray.common.geodata.Domain_Type" json:"type,omitempty"` + // Domain value. + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + // Attributes of this domain. May be used for filtering. + Attribute []*Domain_Attribute `protobuf:"bytes,3,rep,name=attribute,proto3" json:"attribute,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Domain) Reset() { + *x = Domain{} + mi := &file_common_geodata_geodat_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Domain) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Domain) ProtoMessage() {} + +func (x *Domain) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Domain.ProtoReflect.Descriptor instead. +func (*Domain) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{0} +} + +func (x *Domain) GetType() Domain_Type { + if x != nil { + return x.Type + } + return Domain_Substr +} + +func (x *Domain) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *Domain) GetAttribute() []*Domain_Attribute { + if x != nil { + return x.Attribute + } + return nil +} + +type GeoSite struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Domain []*Domain `protobuf:"bytes,2,rep,name=domain,proto3" json:"domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeoSite) Reset() { + *x = GeoSite{} + mi := &file_common_geodata_geodat_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeoSite) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeoSite) ProtoMessage() {} + +func (x *GeoSite) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeoSite.ProtoReflect.Descriptor instead. +func (*GeoSite) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{1} +} + +func (x *GeoSite) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *GeoSite) GetDomain() []*Domain { + if x != nil { + return x.Domain + } + return nil +} + +type GeoSiteList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry []*GeoSite `protobuf:"bytes,1,rep,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeoSiteList) Reset() { + *x = GeoSiteList{} + mi := &file_common_geodata_geodat_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeoSiteList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeoSiteList) ProtoMessage() {} + +func (x *GeoSiteList) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeoSiteList.ProtoReflect.Descriptor instead. +func (*GeoSiteList) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{2} +} + +func (x *GeoSiteList) GetEntry() []*GeoSite { + if x != nil { + return x.Entry + } + return nil +} + +type GeoSiteRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + File string `protobuf:"bytes,1,opt,name=file,proto3" json:"file,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + Attrs string `protobuf:"bytes,3,opt,name=attrs,proto3" json:"attrs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeoSiteRule) Reset() { + *x = GeoSiteRule{} + mi := &file_common_geodata_geodat_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeoSiteRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeoSiteRule) ProtoMessage() {} + +func (x *GeoSiteRule) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeoSiteRule.ProtoReflect.Descriptor instead. +func (*GeoSiteRule) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{3} +} + +func (x *GeoSiteRule) GetFile() string { + if x != nil { + return x.File + } + return "" +} + +func (x *GeoSiteRule) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *GeoSiteRule) GetAttrs() string { + if x != nil { + return x.Attrs + } + return "" +} + +type DomainRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *DomainRule_Geosite + // *DomainRule_Custom + Value isDomainRule_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DomainRule) Reset() { + *x = DomainRule{} + mi := &file_common_geodata_geodat_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DomainRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DomainRule) ProtoMessage() {} + +func (x *DomainRule) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DomainRule.ProtoReflect.Descriptor instead. +func (*DomainRule) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{4} +} + +func (x *DomainRule) GetValue() isDomainRule_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *DomainRule) GetGeosite() *GeoSiteRule { + if x != nil { + if x, ok := x.Value.(*DomainRule_Geosite); ok { + return x.Geosite + } + } + return nil +} + +func (x *DomainRule) GetCustom() *Domain { + if x != nil { + if x, ok := x.Value.(*DomainRule_Custom); ok { + return x.Custom + } + } + return nil +} + +type isDomainRule_Value interface { + isDomainRule_Value() +} + +type DomainRule_Geosite struct { + Geosite *GeoSiteRule `protobuf:"bytes,1,opt,name=geosite,proto3,oneof"` +} + +type DomainRule_Custom struct { + Custom *Domain `protobuf:"bytes,2,opt,name=custom,proto3,oneof"` +} + +func (*DomainRule_Geosite) isDomainRule_Value() {} + +func (*DomainRule_Custom) isDomainRule_Value() {} + +type CIDR struct { + state protoimpl.MessageState `protogen:"open.v1"` + // IP address, should be either 4 or 16 bytes. + Ip []byte `protobuf:"bytes,1,opt,name=ip,proto3" json:"ip,omitempty"` + // Number of leading ones in the network mask. + Prefix uint32 `protobuf:"varint,2,opt,name=prefix,proto3" json:"prefix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CIDR) Reset() { + *x = CIDR{} + mi := &file_common_geodata_geodat_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CIDR) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CIDR) ProtoMessage() {} + +func (x *CIDR) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CIDR.ProtoReflect.Descriptor instead. +func (*CIDR) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{5} +} + +func (x *CIDR) GetIp() []byte { + if x != nil { + return x.Ip + } + return nil +} + +func (x *CIDR) GetPrefix() uint32 { + if x != nil { + return x.Prefix + } + return 0 +} + +type CIDRRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cidr *CIDR `protobuf:"bytes,1,opt,name=cidr,proto3" json:"cidr,omitempty"` + ReverseMatch bool `protobuf:"varint,2,opt,name=reverse_match,json=reverseMatch,proto3" json:"reverse_match,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CIDRRule) Reset() { + *x = CIDRRule{} + mi := &file_common_geodata_geodat_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CIDRRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CIDRRule) ProtoMessage() {} + +func (x *CIDRRule) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CIDRRule.ProtoReflect.Descriptor instead. +func (*CIDRRule) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{6} +} + +func (x *CIDRRule) GetCidr() *CIDR { + if x != nil { + return x.Cidr + } + return nil +} + +func (x *CIDRRule) GetReverseMatch() bool { + if x != nil { + return x.ReverseMatch + } + return false +} + +type GeoIP struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Cidr []*CIDR `protobuf:"bytes,2,rep,name=cidr,proto3" json:"cidr,omitempty"` + ReverseMatch bool `protobuf:"varint,3,opt,name=reverse_match,json=reverseMatch,proto3" json:"reverse_match,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeoIP) Reset() { + *x = GeoIP{} + mi := &file_common_geodata_geodat_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeoIP) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeoIP) ProtoMessage() {} + +func (x *GeoIP) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeoIP.ProtoReflect.Descriptor instead. +func (*GeoIP) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{7} +} + +func (x *GeoIP) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *GeoIP) GetCidr() []*CIDR { + if x != nil { + return x.Cidr + } + return nil +} + +func (x *GeoIP) GetReverseMatch() bool { + if x != nil { + return x.ReverseMatch + } + return false +} + +type GeoIPList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry []*GeoIP `protobuf:"bytes,1,rep,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeoIPList) Reset() { + *x = GeoIPList{} + mi := &file_common_geodata_geodat_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeoIPList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeoIPList) ProtoMessage() {} + +func (x *GeoIPList) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeoIPList.ProtoReflect.Descriptor instead. +func (*GeoIPList) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{8} +} + +func (x *GeoIPList) GetEntry() []*GeoIP { + if x != nil { + return x.Entry + } + return nil +} + +type GeoIPRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + File string `protobuf:"bytes,1,opt,name=file,proto3" json:"file,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + ReverseMatch bool `protobuf:"varint,3,opt,name=reverse_match,json=reverseMatch,proto3" json:"reverse_match,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeoIPRule) Reset() { + *x = GeoIPRule{} + mi := &file_common_geodata_geodat_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeoIPRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeoIPRule) ProtoMessage() {} + +func (x *GeoIPRule) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeoIPRule.ProtoReflect.Descriptor instead. +func (*GeoIPRule) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{9} +} + +func (x *GeoIPRule) GetFile() string { + if x != nil { + return x.File + } + return "" +} + +func (x *GeoIPRule) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *GeoIPRule) GetReverseMatch() bool { + if x != nil { + return x.ReverseMatch + } + return false +} + +type IPRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *IPRule_Geoip + // *IPRule_Custom + Value isIPRule_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IPRule) Reset() { + *x = IPRule{} + mi := &file_common_geodata_geodat_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IPRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IPRule) ProtoMessage() {} + +func (x *IPRule) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IPRule.ProtoReflect.Descriptor instead. +func (*IPRule) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{10} +} + +func (x *IPRule) GetValue() isIPRule_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *IPRule) GetGeoip() *GeoIPRule { + if x != nil { + if x, ok := x.Value.(*IPRule_Geoip); ok { + return x.Geoip + } + } + return nil +} + +func (x *IPRule) GetCustom() *CIDRRule { + if x != nil { + if x, ok := x.Value.(*IPRule_Custom); ok { + return x.Custom + } + } + return nil +} + +type isIPRule_Value interface { + isIPRule_Value() +} + +type IPRule_Geoip struct { + Geoip *GeoIPRule `protobuf:"bytes,1,opt,name=geoip,proto3,oneof"` +} + +type IPRule_Custom struct { + Custom *CIDRRule `protobuf:"bytes,2,opt,name=custom,proto3,oneof"` +} + +func (*IPRule_Geoip) isIPRule_Value() {} + +func (*IPRule_Custom) isIPRule_Value() {} + +type Domain_Attribute struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // Types that are valid to be assigned to TypedValue: + // + // *Domain_Attribute_BoolValue + // *Domain_Attribute_IntValue + TypedValue isDomain_Attribute_TypedValue `protobuf_oneof:"typed_value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Domain_Attribute) Reset() { + *x = Domain_Attribute{} + mi := &file_common_geodata_geodat_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Domain_Attribute) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Domain_Attribute) ProtoMessage() {} + +func (x *Domain_Attribute) ProtoReflect() protoreflect.Message { + mi := &file_common_geodata_geodat_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Domain_Attribute.ProtoReflect.Descriptor instead. +func (*Domain_Attribute) Descriptor() ([]byte, []int) { + return file_common_geodata_geodat_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *Domain_Attribute) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *Domain_Attribute) GetTypedValue() isDomain_Attribute_TypedValue { + if x != nil { + return x.TypedValue + } + return nil +} + +func (x *Domain_Attribute) GetBoolValue() bool { + if x != nil { + if x, ok := x.TypedValue.(*Domain_Attribute_BoolValue); ok { + return x.BoolValue + } + } + return false +} + +func (x *Domain_Attribute) GetIntValue() int64 { + if x != nil { + if x, ok := x.TypedValue.(*Domain_Attribute_IntValue); ok { + return x.IntValue + } + } + return 0 +} + +type isDomain_Attribute_TypedValue interface { + isDomain_Attribute_TypedValue() +} + +type Domain_Attribute_BoolValue struct { + BoolValue bool `protobuf:"varint,2,opt,name=bool_value,json=boolValue,proto3,oneof"` +} + +type Domain_Attribute_IntValue struct { + IntValue int64 `protobuf:"varint,3,opt,name=int_value,json=intValue,proto3,oneof"` +} + +func (*Domain_Attribute_BoolValue) isDomain_Attribute_TypedValue() {} + +func (*Domain_Attribute_IntValue) isDomain_Attribute_TypedValue() {} + +var File_common_geodata_geodat_proto protoreflect.FileDescriptor + +const file_common_geodata_geodat_proto_rawDesc = "" + + "\n" + + "\x1bcommon/geodata/geodat.proto\x12\x13xray.common.geodata\"\xbc\x02\n" + + "\x06Domain\x124\n" + + "\x04type\x18\x01 \x01(\x0e2 .xray.common.geodata.Domain.TypeR\x04type\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\x12C\n" + + "\tattribute\x18\x03 \x03(\v2%.xray.common.geodata.Domain.AttributeR\tattribute\x1al\n" + + "\tAttribute\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x1f\n" + + "\n" + + "bool_value\x18\x02 \x01(\bH\x00R\tboolValue\x12\x1d\n" + + "\tint_value\x18\x03 \x01(\x03H\x00R\bintValueB\r\n" + + "\vtyped_value\"3\n" + + "\x04Type\x12\n" + + "\n" + + "\x06Substr\x10\x00\x12\t\n" + + "\x05Regex\x10\x01\x12\n" + + "\n" + + "\x06Domain\x10\x02\x12\b\n" + + "\x04Full\x10\x03\"R\n" + + "\aGeoSite\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x123\n" + + "\x06domain\x18\x02 \x03(\v2\x1b.xray.common.geodata.DomainR\x06domain\"A\n" + + "\vGeoSiteList\x122\n" + + "\x05entry\x18\x01 \x03(\v2\x1c.xray.common.geodata.GeoSiteR\x05entry\"K\n" + + "\vGeoSiteRule\x12\x12\n" + + "\x04file\x18\x01 \x01(\tR\x04file\x12\x12\n" + + "\x04code\x18\x02 \x01(\tR\x04code\x12\x14\n" + + "\x05attrs\x18\x03 \x01(\tR\x05attrs\"\x8a\x01\n" + + "\n" + + "DomainRule\x12<\n" + + "\ageosite\x18\x01 \x01(\v2 .xray.common.geodata.GeoSiteRuleH\x00R\ageosite\x125\n" + + "\x06custom\x18\x02 \x01(\v2\x1b.xray.common.geodata.DomainH\x00R\x06customB\a\n" + + "\x05value\".\n" + + "\x04CIDR\x12\x0e\n" + + "\x02ip\x18\x01 \x01(\fR\x02ip\x12\x16\n" + + "\x06prefix\x18\x02 \x01(\rR\x06prefix\"^\n" + + "\bCIDRRule\x12-\n" + + "\x04cidr\x18\x01 \x01(\v2\x19.xray.common.geodata.CIDRR\x04cidr\x12#\n" + + "\rreverse_match\x18\x02 \x01(\bR\freverseMatch\"o\n" + + "\x05GeoIP\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12-\n" + + "\x04cidr\x18\x02 \x03(\v2\x19.xray.common.geodata.CIDRR\x04cidr\x12#\n" + + "\rreverse_match\x18\x03 \x01(\bR\freverseMatch\"=\n" + + "\tGeoIPList\x120\n" + + "\x05entry\x18\x01 \x03(\v2\x1a.xray.common.geodata.GeoIPR\x05entry\"X\n" + + "\tGeoIPRule\x12\x12\n" + + "\x04file\x18\x01 \x01(\tR\x04file\x12\x12\n" + + "\x04code\x18\x02 \x01(\tR\x04code\x12#\n" + + "\rreverse_match\x18\x03 \x01(\bR\freverseMatch\"\x82\x01\n" + + "\x06IPRule\x126\n" + + "\x05geoip\x18\x01 \x01(\v2\x1e.xray.common.geodata.GeoIPRuleH\x00R\x05geoip\x127\n" + + "\x06custom\x18\x02 \x01(\v2\x1d.xray.common.geodata.CIDRRuleH\x00R\x06customB\a\n" + + "\x05valueB\xc5\x01\n" + + "\x17com.xray.common.geodataB\vGeodatProtoP\x01Z/github.com/mrcsin/usher/gen/xray/common/geodata\xa2\x02\x03XCG\xaa\x02\x13Xray.Common.Geodata\xca\x02\x13Xray\\Common\\Geodata\xe2\x02\x1fXray\\Common\\Geodata\\GPBMetadata\xea\x02\x15Xray::Common::Geodatab\x06proto3" + +var ( + file_common_geodata_geodat_proto_rawDescOnce sync.Once + file_common_geodata_geodat_proto_rawDescData []byte +) + +func file_common_geodata_geodat_proto_rawDescGZIP() []byte { + file_common_geodata_geodat_proto_rawDescOnce.Do(func() { + file_common_geodata_geodat_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_geodata_geodat_proto_rawDesc), len(file_common_geodata_geodat_proto_rawDesc))) + }) + return file_common_geodata_geodat_proto_rawDescData +} + +var file_common_geodata_geodat_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_common_geodata_geodat_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_common_geodata_geodat_proto_goTypes = []any{ + (Domain_Type)(0), // 0: xray.common.geodata.Domain.Type + (*Domain)(nil), // 1: xray.common.geodata.Domain + (*GeoSite)(nil), // 2: xray.common.geodata.GeoSite + (*GeoSiteList)(nil), // 3: xray.common.geodata.GeoSiteList + (*GeoSiteRule)(nil), // 4: xray.common.geodata.GeoSiteRule + (*DomainRule)(nil), // 5: xray.common.geodata.DomainRule + (*CIDR)(nil), // 6: xray.common.geodata.CIDR + (*CIDRRule)(nil), // 7: xray.common.geodata.CIDRRule + (*GeoIP)(nil), // 8: xray.common.geodata.GeoIP + (*GeoIPList)(nil), // 9: xray.common.geodata.GeoIPList + (*GeoIPRule)(nil), // 10: xray.common.geodata.GeoIPRule + (*IPRule)(nil), // 11: xray.common.geodata.IPRule + (*Domain_Attribute)(nil), // 12: xray.common.geodata.Domain.Attribute +} +var file_common_geodata_geodat_proto_depIdxs = []int32{ + 0, // 0: xray.common.geodata.Domain.type:type_name -> xray.common.geodata.Domain.Type + 12, // 1: xray.common.geodata.Domain.attribute:type_name -> xray.common.geodata.Domain.Attribute + 1, // 2: xray.common.geodata.GeoSite.domain:type_name -> xray.common.geodata.Domain + 2, // 3: xray.common.geodata.GeoSiteList.entry:type_name -> xray.common.geodata.GeoSite + 4, // 4: xray.common.geodata.DomainRule.geosite:type_name -> xray.common.geodata.GeoSiteRule + 1, // 5: xray.common.geodata.DomainRule.custom:type_name -> xray.common.geodata.Domain + 6, // 6: xray.common.geodata.CIDRRule.cidr:type_name -> xray.common.geodata.CIDR + 6, // 7: xray.common.geodata.GeoIP.cidr:type_name -> xray.common.geodata.CIDR + 8, // 8: xray.common.geodata.GeoIPList.entry:type_name -> xray.common.geodata.GeoIP + 10, // 9: xray.common.geodata.IPRule.geoip:type_name -> xray.common.geodata.GeoIPRule + 7, // 10: xray.common.geodata.IPRule.custom:type_name -> xray.common.geodata.CIDRRule + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_common_geodata_geodat_proto_init() } +func file_common_geodata_geodat_proto_init() { + if File_common_geodata_geodat_proto != nil { + return + } + file_common_geodata_geodat_proto_msgTypes[4].OneofWrappers = []any{ + (*DomainRule_Geosite)(nil), + (*DomainRule_Custom)(nil), + } + file_common_geodata_geodat_proto_msgTypes[10].OneofWrappers = []any{ + (*IPRule_Geoip)(nil), + (*IPRule_Custom)(nil), + } + file_common_geodata_geodat_proto_msgTypes[11].OneofWrappers = []any{ + (*Domain_Attribute_BoolValue)(nil), + (*Domain_Attribute_IntValue)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_geodata_geodat_proto_rawDesc), len(file_common_geodata_geodat_proto_rawDesc)), + NumEnums: 1, + NumMessages: 12, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_geodata_geodat_proto_goTypes, + DependencyIndexes: file_common_geodata_geodat_proto_depIdxs, + EnumInfos: file_common_geodata_geodat_proto_enumTypes, + MessageInfos: file_common_geodata_geodat_proto_msgTypes, + }.Build() + File_common_geodata_geodat_proto = out.File + file_common_geodata_geodat_proto_goTypes = nil + file_common_geodata_geodat_proto_depIdxs = nil +} diff --git a/gen/xray/common/net/address.pb.go b/gen/xray/common/net/address.pb.go new file mode 100644 index 0000000..67b5221 --- /dev/null +++ b/gen/xray/common/net/address.pb.go @@ -0,0 +1,172 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: common/net/address.proto + +package net + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Address of a network host. It may be either an IP address or a domain +// address. +type IPOrDomain struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Address: + // + // *IPOrDomain_Ip + // *IPOrDomain_Domain + Address isIPOrDomain_Address `protobuf_oneof:"address"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IPOrDomain) Reset() { + *x = IPOrDomain{} + mi := &file_common_net_address_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IPOrDomain) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IPOrDomain) ProtoMessage() {} + +func (x *IPOrDomain) ProtoReflect() protoreflect.Message { + mi := &file_common_net_address_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IPOrDomain.ProtoReflect.Descriptor instead. +func (*IPOrDomain) Descriptor() ([]byte, []int) { + return file_common_net_address_proto_rawDescGZIP(), []int{0} +} + +func (x *IPOrDomain) GetAddress() isIPOrDomain_Address { + if x != nil { + return x.Address + } + return nil +} + +func (x *IPOrDomain) GetIp() []byte { + if x != nil { + if x, ok := x.Address.(*IPOrDomain_Ip); ok { + return x.Ip + } + } + return nil +} + +func (x *IPOrDomain) GetDomain() string { + if x != nil { + if x, ok := x.Address.(*IPOrDomain_Domain); ok { + return x.Domain + } + } + return "" +} + +type isIPOrDomain_Address interface { + isIPOrDomain_Address() +} + +type IPOrDomain_Ip struct { + // IP address. Must by either 4 or 16 bytes. + Ip []byte `protobuf:"bytes,1,opt,name=ip,proto3,oneof"` +} + +type IPOrDomain_Domain struct { + // Domain address. + Domain string `protobuf:"bytes,2,opt,name=domain,proto3,oneof"` +} + +func (*IPOrDomain_Ip) isIPOrDomain_Address() {} + +func (*IPOrDomain_Domain) isIPOrDomain_Address() {} + +var File_common_net_address_proto protoreflect.FileDescriptor + +const file_common_net_address_proto_rawDesc = "" + + "\n" + + "\x18common/net/address.proto\x12\x0fxray.common.net\"C\n" + + "\n" + + "IPOrDomain\x12\x10\n" + + "\x02ip\x18\x01 \x01(\fH\x00R\x02ip\x12\x18\n" + + "\x06domain\x18\x02 \x01(\tH\x00R\x06domainB\t\n" + + "\aaddressB\xae\x01\n" + + "\x13com.xray.common.netB\fAddressProtoP\x01Z+github.com/mrcsin/usher/gen/xray/common/net\xa2\x02\x03XCN\xaa\x02\x0fXray.Common.Net\xca\x02\x0fXray\\Common\\Net\xe2\x02\x1bXray\\Common\\Net\\GPBMetadata\xea\x02\x11Xray::Common::Netb\x06proto3" + +var ( + file_common_net_address_proto_rawDescOnce sync.Once + file_common_net_address_proto_rawDescData []byte +) + +func file_common_net_address_proto_rawDescGZIP() []byte { + file_common_net_address_proto_rawDescOnce.Do(func() { + file_common_net_address_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_net_address_proto_rawDesc), len(file_common_net_address_proto_rawDesc))) + }) + return file_common_net_address_proto_rawDescData +} + +var file_common_net_address_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_common_net_address_proto_goTypes = []any{ + (*IPOrDomain)(nil), // 0: xray.common.net.IPOrDomain +} +var file_common_net_address_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_common_net_address_proto_init() } +func file_common_net_address_proto_init() { + if File_common_net_address_proto != nil { + return + } + file_common_net_address_proto_msgTypes[0].OneofWrappers = []any{ + (*IPOrDomain_Ip)(nil), + (*IPOrDomain_Domain)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_net_address_proto_rawDesc), len(file_common_net_address_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_net_address_proto_goTypes, + DependencyIndexes: file_common_net_address_proto_depIdxs, + MessageInfos: file_common_net_address_proto_msgTypes, + }.Build() + File_common_net_address_proto = out.File + file_common_net_address_proto_goTypes = nil + file_common_net_address_proto_depIdxs = nil +} diff --git a/gen/xray/common/net/port.pb.go b/gen/xray/common/net/port.pb.go new file mode 100644 index 0000000..3e93767 --- /dev/null +++ b/gen/xray/common/net/port.pb.go @@ -0,0 +1,184 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: common/net/port.proto + +package net + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// PortRange represents a range of ports. +type PortRange struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The port that this range starts from. + From uint32 `protobuf:"varint,1,opt,name=From,proto3" json:"From,omitempty"` + // The port that this range ends with (inclusive). + To uint32 `protobuf:"varint,2,opt,name=To,proto3" json:"To,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PortRange) Reset() { + *x = PortRange{} + mi := &file_common_net_port_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PortRange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PortRange) ProtoMessage() {} + +func (x *PortRange) ProtoReflect() protoreflect.Message { + mi := &file_common_net_port_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PortRange.ProtoReflect.Descriptor instead. +func (*PortRange) Descriptor() ([]byte, []int) { + return file_common_net_port_proto_rawDescGZIP(), []int{0} +} + +func (x *PortRange) GetFrom() uint32 { + if x != nil { + return x.From + } + return 0 +} + +func (x *PortRange) GetTo() uint32 { + if x != nil { + return x.To + } + return 0 +} + +// PortList is a list of ports. +type PortList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Range []*PortRange `protobuf:"bytes,1,rep,name=range,proto3" json:"range,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PortList) Reset() { + *x = PortList{} + mi := &file_common_net_port_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PortList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PortList) ProtoMessage() {} + +func (x *PortList) ProtoReflect() protoreflect.Message { + mi := &file_common_net_port_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PortList.ProtoReflect.Descriptor instead. +func (*PortList) Descriptor() ([]byte, []int) { + return file_common_net_port_proto_rawDescGZIP(), []int{1} +} + +func (x *PortList) GetRange() []*PortRange { + if x != nil { + return x.Range + } + return nil +} + +var File_common_net_port_proto protoreflect.FileDescriptor + +const file_common_net_port_proto_rawDesc = "" + + "\n" + + "\x15common/net/port.proto\x12\x0fxray.common.net\"/\n" + + "\tPortRange\x12\x12\n" + + "\x04From\x18\x01 \x01(\rR\x04From\x12\x0e\n" + + "\x02To\x18\x02 \x01(\rR\x02To\"<\n" + + "\bPortList\x120\n" + + "\x05range\x18\x01 \x03(\v2\x1a.xray.common.net.PortRangeR\x05rangeB\xab\x01\n" + + "\x13com.xray.common.netB\tPortProtoP\x01Z+github.com/mrcsin/usher/gen/xray/common/net\xa2\x02\x03XCN\xaa\x02\x0fXray.Common.Net\xca\x02\x0fXray\\Common\\Net\xe2\x02\x1bXray\\Common\\Net\\GPBMetadata\xea\x02\x11Xray::Common::Netb\x06proto3" + +var ( + file_common_net_port_proto_rawDescOnce sync.Once + file_common_net_port_proto_rawDescData []byte +) + +func file_common_net_port_proto_rawDescGZIP() []byte { + file_common_net_port_proto_rawDescOnce.Do(func() { + file_common_net_port_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_net_port_proto_rawDesc), len(file_common_net_port_proto_rawDesc))) + }) + return file_common_net_port_proto_rawDescData +} + +var file_common_net_port_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_common_net_port_proto_goTypes = []any{ + (*PortRange)(nil), // 0: xray.common.net.PortRange + (*PortList)(nil), // 1: xray.common.net.PortList +} +var file_common_net_port_proto_depIdxs = []int32{ + 0, // 0: xray.common.net.PortList.range:type_name -> xray.common.net.PortRange + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_common_net_port_proto_init() } +func file_common_net_port_proto_init() { + if File_common_net_port_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_net_port_proto_rawDesc), len(file_common_net_port_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_net_port_proto_goTypes, + DependencyIndexes: file_common_net_port_proto_depIdxs, + MessageInfos: file_common_net_port_proto_msgTypes, + }.Build() + File_common_net_port_proto = out.File + file_common_net_port_proto_goTypes = nil + file_common_net_port_proto_depIdxs = nil +} diff --git a/gen/xray/common/protocol/user.pb.go b/gen/xray/common/protocol/user.pb.go new file mode 100644 index 0000000..b3082c9 --- /dev/null +++ b/gen/xray/common/protocol/user.pb.go @@ -0,0 +1,147 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: common/protocol/user.proto + +package protocol + +import ( + serial "github.com/mrcsin/usher/gen/xray/common/serial" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// User is a generic user for all protocols. +type User struct { + state protoimpl.MessageState `protogen:"open.v1"` + Level uint32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"` + Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"` + // Protocol specific account information. Must be the account proto in one of + // the proxies. + Account *serial.TypedMessage `protobuf:"bytes,3,opt,name=account,proto3" json:"account,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *User) Reset() { + *x = User{} + mi := &file_common_protocol_user_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_common_protocol_user_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_common_protocol_user_proto_rawDescGZIP(), []int{0} +} + +func (x *User) GetLevel() uint32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *User) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *User) GetAccount() *serial.TypedMessage { + if x != nil { + return x.Account + } + return nil +} + +var File_common_protocol_user_proto protoreflect.FileDescriptor + +const file_common_protocol_user_proto_rawDesc = "" + + "\n" + + "\x1acommon/protocol/user.proto\x12\x14xray.common.protocol\x1a!common/serial/typed_message.proto\"n\n" + + "\x04User\x12\x14\n" + + "\x05level\x18\x01 \x01(\rR\x05level\x12\x14\n" + + "\x05email\x18\x02 \x01(\tR\x05email\x12:\n" + + "\aaccount\x18\x03 \x01(\v2 .xray.common.serial.TypedMessageR\aaccountB\xc9\x01\n" + + "\x18com.xray.common.protocolB\tUserProtoP\x01Z0github.com/mrcsin/usher/gen/xray/common/protocol\xa2\x02\x03XCP\xaa\x02\x14Xray.Common.Protocol\xca\x02\x14Xray\\Common\\Protocol\xe2\x02 Xray\\Common\\Protocol\\GPBMetadata\xea\x02\x16Xray::Common::Protocolb\x06proto3" + +var ( + file_common_protocol_user_proto_rawDescOnce sync.Once + file_common_protocol_user_proto_rawDescData []byte +) + +func file_common_protocol_user_proto_rawDescGZIP() []byte { + file_common_protocol_user_proto_rawDescOnce.Do(func() { + file_common_protocol_user_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_protocol_user_proto_rawDesc), len(file_common_protocol_user_proto_rawDesc))) + }) + return file_common_protocol_user_proto_rawDescData +} + +var file_common_protocol_user_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_common_protocol_user_proto_goTypes = []any{ + (*User)(nil), // 0: xray.common.protocol.User + (*serial.TypedMessage)(nil), // 1: xray.common.serial.TypedMessage +} +var file_common_protocol_user_proto_depIdxs = []int32{ + 1, // 0: xray.common.protocol.User.account:type_name -> xray.common.serial.TypedMessage + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_common_protocol_user_proto_init() } +func file_common_protocol_user_proto_init() { + if File_common_protocol_user_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_protocol_user_proto_rawDesc), len(file_common_protocol_user_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_protocol_user_proto_goTypes, + DependencyIndexes: file_common_protocol_user_proto_depIdxs, + MessageInfos: file_common_protocol_user_proto_msgTypes, + }.Build() + File_common_protocol_user_proto = out.File + file_common_protocol_user_proto_goTypes = nil + file_common_protocol_user_proto_depIdxs = nil +} diff --git a/gen/xray/common/serial/typed_message.pb.go b/gen/xray/common/serial/typed_message.pb.go new file mode 100644 index 0000000..9d39e07 --- /dev/null +++ b/gen/xray/common/serial/typed_message.pb.go @@ -0,0 +1,135 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: common/serial/typed_message.proto + +package serial + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// TypedMessage is a serialized proto message along with its type name. +type TypedMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The name of the message type, retrieved from protobuf API. + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + // Serialized proto message. + Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TypedMessage) Reset() { + *x = TypedMessage{} + mi := &file_common_serial_typed_message_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TypedMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TypedMessage) ProtoMessage() {} + +func (x *TypedMessage) ProtoReflect() protoreflect.Message { + mi := &file_common_serial_typed_message_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TypedMessage.ProtoReflect.Descriptor instead. +func (*TypedMessage) Descriptor() ([]byte, []int) { + return file_common_serial_typed_message_proto_rawDescGZIP(), []int{0} +} + +func (x *TypedMessage) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *TypedMessage) GetValue() []byte { + if x != nil { + return x.Value + } + return nil +} + +var File_common_serial_typed_message_proto protoreflect.FileDescriptor + +const file_common_serial_typed_message_proto_rawDesc = "" + + "\n" + + "!common/serial/typed_message.proto\x12\x12xray.common.serial\"8\n" + + "\fTypedMessage\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x14\n" + + "\x05value\x18\x02 \x01(\fR\x05valueB\xc5\x01\n" + + "\x16com.xray.common.serialB\x11TypedMessageProtoP\x01Z.github.com/mrcsin/usher/gen/xray/common/serial\xa2\x02\x03XCS\xaa\x02\x12Xray.Common.Serial\xca\x02\x12Xray\\Common\\Serial\xe2\x02\x1eXray\\Common\\Serial\\GPBMetadata\xea\x02\x14Xray::Common::Serialb\x06proto3" + +var ( + file_common_serial_typed_message_proto_rawDescOnce sync.Once + file_common_serial_typed_message_proto_rawDescData []byte +) + +func file_common_serial_typed_message_proto_rawDescGZIP() []byte { + file_common_serial_typed_message_proto_rawDescOnce.Do(func() { + file_common_serial_typed_message_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_serial_typed_message_proto_rawDesc), len(file_common_serial_typed_message_proto_rawDesc))) + }) + return file_common_serial_typed_message_proto_rawDescData +} + +var file_common_serial_typed_message_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_common_serial_typed_message_proto_goTypes = []any{ + (*TypedMessage)(nil), // 0: xray.common.serial.TypedMessage +} +var file_common_serial_typed_message_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_common_serial_typed_message_proto_init() } +func file_common_serial_typed_message_proto_init() { + if File_common_serial_typed_message_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_serial_typed_message_proto_rawDesc), len(file_common_serial_typed_message_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_common_serial_typed_message_proto_goTypes, + DependencyIndexes: file_common_serial_typed_message_proto_depIdxs, + MessageInfos: file_common_serial_typed_message_proto_msgTypes, + }.Build() + File_common_serial_typed_message_proto = out.File + file_common_serial_typed_message_proto_goTypes = nil + file_common_serial_typed_message_proto_depIdxs = nil +} diff --git a/gen/xray/core/config.pb.go b/gen/xray/core/config.pb.go new file mode 100644 index 0000000..776a951 --- /dev/null +++ b/gen/xray/core/config.pb.go @@ -0,0 +1,331 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: core/config.proto + +package core + +import ( + serial "github.com/mrcsin/usher/gen/xray/common/serial" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Config is the master config of Xray. Xray takes this config as input and +// functions accordingly. +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Inbound handler configurations. Must have at least one item. + Inbound []*InboundHandlerConfig `protobuf:"bytes,1,rep,name=inbound,proto3" json:"inbound,omitempty"` + // Outbound handler configurations. Must have at least one item. The first + // item is used as default for routing. + Outbound []*OutboundHandlerConfig `protobuf:"bytes,2,rep,name=outbound,proto3" json:"outbound,omitempty"` + // App is for configurations of all features in Xray. A feature must + // implement the Feature interface, and its config type must be registered + // through common.RegisterConfig. + App []*serial.TypedMessage `protobuf:"bytes,4,rep,name=app,proto3" json:"app,omitempty"` + // Configuration for extensions. The config may not work if corresponding + // extension is not loaded into Xray. Xray will ignore such config during + // initialization. + Extension []*serial.TypedMessage `protobuf:"bytes,6,rep,name=extension,proto3" json:"extension,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_core_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_core_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_core_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetInbound() []*InboundHandlerConfig { + if x != nil { + return x.Inbound + } + return nil +} + +func (x *Config) GetOutbound() []*OutboundHandlerConfig { + if x != nil { + return x.Outbound + } + return nil +} + +func (x *Config) GetApp() []*serial.TypedMessage { + if x != nil { + return x.App + } + return nil +} + +func (x *Config) GetExtension() []*serial.TypedMessage { + if x != nil { + return x.Extension + } + return nil +} + +// InboundHandlerConfig is the configuration for inbound handler. +type InboundHandlerConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tag of the inbound handler. The tag must be unique among all inbound + // handlers + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + // Settings for how this inbound proxy is handled. + ReceiverSettings *serial.TypedMessage `protobuf:"bytes,2,opt,name=receiver_settings,json=receiverSettings,proto3" json:"receiver_settings,omitempty"` + // Settings for inbound proxy. Must be one of the inbound proxies. + ProxySettings *serial.TypedMessage `protobuf:"bytes,3,opt,name=proxy_settings,json=proxySettings,proto3" json:"proxy_settings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InboundHandlerConfig) Reset() { + *x = InboundHandlerConfig{} + mi := &file_core_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InboundHandlerConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InboundHandlerConfig) ProtoMessage() {} + +func (x *InboundHandlerConfig) ProtoReflect() protoreflect.Message { + mi := &file_core_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InboundHandlerConfig.ProtoReflect.Descriptor instead. +func (*InboundHandlerConfig) Descriptor() ([]byte, []int) { + return file_core_config_proto_rawDescGZIP(), []int{1} +} + +func (x *InboundHandlerConfig) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *InboundHandlerConfig) GetReceiverSettings() *serial.TypedMessage { + if x != nil { + return x.ReceiverSettings + } + return nil +} + +func (x *InboundHandlerConfig) GetProxySettings() *serial.TypedMessage { + if x != nil { + return x.ProxySettings + } + return nil +} + +// OutboundHandlerConfig is the configuration for outbound handler. +type OutboundHandlerConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tag of this outbound handler. + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + // Settings for how to dial connection for this outbound handler. + SenderSettings *serial.TypedMessage `protobuf:"bytes,2,opt,name=sender_settings,json=senderSettings,proto3" json:"sender_settings,omitempty"` + // Settings for this outbound proxy. Must be one of the outbound proxies. + ProxySettings *serial.TypedMessage `protobuf:"bytes,3,opt,name=proxy_settings,json=proxySettings,proto3" json:"proxy_settings,omitempty"` + // If not zero, this outbound will be expired in seconds. Not used for now. + Expire int64 `protobuf:"varint,4,opt,name=expire,proto3" json:"expire,omitempty"` + // Comment of this outbound handler. Not used for now. + Comment string `protobuf:"bytes,5,opt,name=comment,proto3" json:"comment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OutboundHandlerConfig) Reset() { + *x = OutboundHandlerConfig{} + mi := &file_core_config_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OutboundHandlerConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OutboundHandlerConfig) ProtoMessage() {} + +func (x *OutboundHandlerConfig) ProtoReflect() protoreflect.Message { + mi := &file_core_config_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OutboundHandlerConfig.ProtoReflect.Descriptor instead. +func (*OutboundHandlerConfig) Descriptor() ([]byte, []int) { + return file_core_config_proto_rawDescGZIP(), []int{2} +} + +func (x *OutboundHandlerConfig) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *OutboundHandlerConfig) GetSenderSettings() *serial.TypedMessage { + if x != nil { + return x.SenderSettings + } + return nil +} + +func (x *OutboundHandlerConfig) GetProxySettings() *serial.TypedMessage { + if x != nil { + return x.ProxySettings + } + return nil +} + +func (x *OutboundHandlerConfig) GetExpire() int64 { + if x != nil { + return x.Expire + } + return 0 +} + +func (x *OutboundHandlerConfig) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + +var File_core_config_proto protoreflect.FileDescriptor + +const file_core_config_proto_rawDesc = "" + + "\n" + + "\x11core/config.proto\x12\txray.core\x1a!common/serial/typed_message.proto\"\xfb\x01\n" + + "\x06Config\x129\n" + + "\ainbound\x18\x01 \x03(\v2\x1f.xray.core.InboundHandlerConfigR\ainbound\x12<\n" + + "\boutbound\x18\x02 \x03(\v2 .xray.core.OutboundHandlerConfigR\boutbound\x122\n" + + "\x03app\x18\x04 \x03(\v2 .xray.common.serial.TypedMessageR\x03app\x12>\n" + + "\textension\x18\x06 \x03(\v2 .xray.common.serial.TypedMessageR\textensionJ\x04\b\x03\x10\x04\"\xc0\x01\n" + + "\x14InboundHandlerConfig\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12M\n" + + "\x11receiver_settings\x18\x02 \x01(\v2 .xray.common.serial.TypedMessageR\x10receiverSettings\x12G\n" + + "\x0eproxy_settings\x18\x03 \x01(\v2 .xray.common.serial.TypedMessageR\rproxySettings\"\xef\x01\n" + + "\x15OutboundHandlerConfig\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12I\n" + + "\x0fsender_settings\x18\x02 \x01(\v2 .xray.common.serial.TypedMessageR\x0esenderSettings\x12G\n" + + "\x0eproxy_settings\x18\x03 \x01(\v2 .xray.common.serial.TypedMessageR\rproxySettings\x12\x16\n" + + "\x06expire\x18\x04 \x01(\x03R\x06expire\x12\x18\n" + + "\acomment\x18\x05 \x01(\tR\acommentB\x88\x01\n" + + "\rcom.xray.coreB\vConfigProtoP\x01Z%github.com/mrcsin/usher/gen/xray/core\xa2\x02\x03XCX\xaa\x02\tXray.Core\xca\x02\tXray\\Core\xe2\x02\x15Xray\\Core\\GPBMetadata\xea\x02\n" + + "Xray::Coreb\x06proto3" + +var ( + file_core_config_proto_rawDescOnce sync.Once + file_core_config_proto_rawDescData []byte +) + +func file_core_config_proto_rawDescGZIP() []byte { + file_core_config_proto_rawDescOnce.Do(func() { + file_core_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_core_config_proto_rawDesc), len(file_core_config_proto_rawDesc))) + }) + return file_core_config_proto_rawDescData +} + +var file_core_config_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_core_config_proto_goTypes = []any{ + (*Config)(nil), // 0: xray.core.Config + (*InboundHandlerConfig)(nil), // 1: xray.core.InboundHandlerConfig + (*OutboundHandlerConfig)(nil), // 2: xray.core.OutboundHandlerConfig + (*serial.TypedMessage)(nil), // 3: xray.common.serial.TypedMessage +} +var file_core_config_proto_depIdxs = []int32{ + 1, // 0: xray.core.Config.inbound:type_name -> xray.core.InboundHandlerConfig + 2, // 1: xray.core.Config.outbound:type_name -> xray.core.OutboundHandlerConfig + 3, // 2: xray.core.Config.app:type_name -> xray.common.serial.TypedMessage + 3, // 3: xray.core.Config.extension:type_name -> xray.common.serial.TypedMessage + 3, // 4: xray.core.InboundHandlerConfig.receiver_settings:type_name -> xray.common.serial.TypedMessage + 3, // 5: xray.core.InboundHandlerConfig.proxy_settings:type_name -> xray.common.serial.TypedMessage + 3, // 6: xray.core.OutboundHandlerConfig.sender_settings:type_name -> xray.common.serial.TypedMessage + 3, // 7: xray.core.OutboundHandlerConfig.proxy_settings:type_name -> xray.common.serial.TypedMessage + 8, // [8:8] is the sub-list for method output_type + 8, // [8:8] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_core_config_proto_init() } +func file_core_config_proto_init() { + if File_core_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_core_config_proto_rawDesc), len(file_core_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_core_config_proto_goTypes, + DependencyIndexes: file_core_config_proto_depIdxs, + MessageInfos: file_core_config_proto_msgTypes, + }.Build() + File_core_config_proto = out.File + file_core_config_proto_goTypes = nil + file_core_config_proto_depIdxs = nil +} diff --git a/gen/xray/proxy/vless/account.pb.go b/gen/xray/proxy/vless/account.pb.go new file mode 100644 index 0000000..7be6d9b --- /dev/null +++ b/gen/xray/proxy/vless/account.pb.go @@ -0,0 +1,259 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: proxy/vless/account.proto + +package vless + +import ( + proxyman "github.com/mrcsin/usher/gen/xray/app/proxyman" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Reverse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` + Sniffing *proxyman.SniffingConfig `protobuf:"bytes,2,opt,name=sniffing,proto3" json:"sniffing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Reverse) Reset() { + *x = Reverse{} + mi := &file_proxy_vless_account_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Reverse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Reverse) ProtoMessage() {} + +func (x *Reverse) ProtoReflect() protoreflect.Message { + mi := &file_proxy_vless_account_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Reverse.ProtoReflect.Descriptor instead. +func (*Reverse) Descriptor() ([]byte, []int) { + return file_proxy_vless_account_proto_rawDescGZIP(), []int{0} +} + +func (x *Reverse) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *Reverse) GetSniffing() *proxyman.SniffingConfig { + if x != nil { + return x.Sniffing + } + return nil +} + +type Account struct { + state protoimpl.MessageState `protogen:"open.v1"` + // ID of the account, in the form of a UUID, e.g., "66ad4540-b58c-4ad2-9926-ea63445a9b57". + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Flow settings. May be "xtls-rprx-vision". + Flow string `protobuf:"bytes,2,opt,name=flow,proto3" json:"flow,omitempty"` + Encryption string `protobuf:"bytes,3,opt,name=encryption,proto3" json:"encryption,omitempty"` + XorMode uint32 `protobuf:"varint,4,opt,name=xorMode,proto3" json:"xorMode,omitempty"` + Seconds uint32 `protobuf:"varint,5,opt,name=seconds,proto3" json:"seconds,omitempty"` + Padding string `protobuf:"bytes,6,opt,name=padding,proto3" json:"padding,omitempty"` + Reverse *Reverse `protobuf:"bytes,7,opt,name=reverse,proto3" json:"reverse,omitempty"` + Testpre uint32 `protobuf:"varint,8,opt,name=testpre,proto3" json:"testpre,omitempty"` + Testseed []uint32 `protobuf:"varint,9,rep,packed,name=testseed,proto3" json:"testseed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Account) Reset() { + *x = Account{} + mi := &file_proxy_vless_account_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Account) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Account) ProtoMessage() {} + +func (x *Account) ProtoReflect() protoreflect.Message { + mi := &file_proxy_vless_account_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Account.ProtoReflect.Descriptor instead. +func (*Account) Descriptor() ([]byte, []int) { + return file_proxy_vless_account_proto_rawDescGZIP(), []int{1} +} + +func (x *Account) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Account) GetFlow() string { + if x != nil { + return x.Flow + } + return "" +} + +func (x *Account) GetEncryption() string { + if x != nil { + return x.Encryption + } + return "" +} + +func (x *Account) GetXorMode() uint32 { + if x != nil { + return x.XorMode + } + return 0 +} + +func (x *Account) GetSeconds() uint32 { + if x != nil { + return x.Seconds + } + return 0 +} + +func (x *Account) GetPadding() string { + if x != nil { + return x.Padding + } + return "" +} + +func (x *Account) GetReverse() *Reverse { + if x != nil { + return x.Reverse + } + return nil +} + +func (x *Account) GetTestpre() uint32 { + if x != nil { + return x.Testpre + } + return 0 +} + +func (x *Account) GetTestseed() []uint32 { + if x != nil { + return x.Testseed + } + return nil +} + +var File_proxy_vless_account_proto protoreflect.FileDescriptor + +const file_proxy_vless_account_proto_rawDesc = "" + + "\n" + + "\x19proxy/vless/account.proto\x12\x10xray.proxy.vless\x1a\x19app/proxyman/config.proto\"Z\n" + + "\aReverse\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12=\n" + + "\bsniffing\x18\x02 \x01(\v2!.xray.app.proxyman.SniffingConfigR\bsniffing\"\x86\x02\n" + + "\aAccount\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04flow\x18\x02 \x01(\tR\x04flow\x12\x1e\n" + + "\n" + + "encryption\x18\x03 \x01(\tR\n" + + "encryption\x12\x18\n" + + "\axorMode\x18\x04 \x01(\rR\axorMode\x12\x18\n" + + "\aseconds\x18\x05 \x01(\rR\aseconds\x12\x18\n" + + "\apadding\x18\x06 \x01(\tR\apadding\x123\n" + + "\areverse\x18\a \x01(\v2\x19.xray.proxy.vless.ReverseR\areverse\x12\x18\n" + + "\atestpre\x18\b \x01(\rR\atestpre\x12\x1a\n" + + "\btestseed\x18\t \x03(\rR\btestseedB\xb4\x01\n" + + "\x14com.xray.proxy.vlessB\fAccountProtoP\x01Z,github.com/mrcsin/usher/gen/xray/proxy/vless\xa2\x02\x03XPV\xaa\x02\x10Xray.Proxy.Vless\xca\x02\x10Xray\\Proxy\\Vless\xe2\x02\x1cXray\\Proxy\\Vless\\GPBMetadata\xea\x02\x12Xray::Proxy::Vlessb\x06proto3" + +var ( + file_proxy_vless_account_proto_rawDescOnce sync.Once + file_proxy_vless_account_proto_rawDescData []byte +) + +func file_proxy_vless_account_proto_rawDescGZIP() []byte { + file_proxy_vless_account_proto_rawDescOnce.Do(func() { + file_proxy_vless_account_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proxy_vless_account_proto_rawDesc), len(file_proxy_vless_account_proto_rawDesc))) + }) + return file_proxy_vless_account_proto_rawDescData +} + +var file_proxy_vless_account_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_proxy_vless_account_proto_goTypes = []any{ + (*Reverse)(nil), // 0: xray.proxy.vless.Reverse + (*Account)(nil), // 1: xray.proxy.vless.Account + (*proxyman.SniffingConfig)(nil), // 2: xray.app.proxyman.SniffingConfig +} +var file_proxy_vless_account_proto_depIdxs = []int32{ + 2, // 0: xray.proxy.vless.Reverse.sniffing:type_name -> xray.app.proxyman.SniffingConfig + 0, // 1: xray.proxy.vless.Account.reverse:type_name -> xray.proxy.vless.Reverse + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_proxy_vless_account_proto_init() } +func file_proxy_vless_account_proto_init() { + if File_proxy_vless_account_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proxy_vless_account_proto_rawDesc), len(file_proxy_vless_account_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proxy_vless_account_proto_goTypes, + DependencyIndexes: file_proxy_vless_account_proto_depIdxs, + MessageInfos: file_proxy_vless_account_proto_msgTypes, + }.Build() + File_proxy_vless_account_proto = out.File + file_proxy_vless_account_proto_goTypes = nil + file_proxy_vless_account_proto_depIdxs = nil +} diff --git a/gen/xray/proxy/vless/inbound/config.pb.go b/gen/xray/proxy/vless/inbound/config.pb.go new file mode 100644 index 0000000..9eaa3c9 --- /dev/null +++ b/gen/xray/proxy/vless/inbound/config.pb.go @@ -0,0 +1,276 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: proxy/vless/inbound/config.proto + +package inbound + +import ( + protocol "github.com/mrcsin/usher/gen/xray/common/protocol" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Fallback struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Alpn string `protobuf:"bytes,2,opt,name=alpn,proto3" json:"alpn,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` + Dest string `protobuf:"bytes,5,opt,name=dest,proto3" json:"dest,omitempty"` + Xver uint64 `protobuf:"varint,6,opt,name=xver,proto3" json:"xver,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Fallback) Reset() { + *x = Fallback{} + mi := &file_proxy_vless_inbound_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Fallback) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Fallback) ProtoMessage() {} + +func (x *Fallback) ProtoReflect() protoreflect.Message { + mi := &file_proxy_vless_inbound_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Fallback.ProtoReflect.Descriptor instead. +func (*Fallback) Descriptor() ([]byte, []int) { + return file_proxy_vless_inbound_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Fallback) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Fallback) GetAlpn() string { + if x != nil { + return x.Alpn + } + return "" +} + +func (x *Fallback) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *Fallback) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Fallback) GetDest() string { + if x != nil { + return x.Dest + } + return "" +} + +func (x *Fallback) GetXver() uint64 { + if x != nil { + return x.Xver + } + return 0 +} + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + Users []*protocol.User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` + Fallbacks []*Fallback `protobuf:"bytes,2,rep,name=fallbacks,proto3" json:"fallbacks,omitempty"` + Decryption string `protobuf:"bytes,3,opt,name=decryption,proto3" json:"decryption,omitempty"` + XorMode uint32 `protobuf:"varint,4,opt,name=xorMode,proto3" json:"xorMode,omitempty"` + SecondsFrom int64 `protobuf:"varint,5,opt,name=seconds_from,json=secondsFrom,proto3" json:"seconds_from,omitempty"` + SecondsTo int64 `protobuf:"varint,6,opt,name=seconds_to,json=secondsTo,proto3" json:"seconds_to,omitempty"` + Padding string `protobuf:"bytes,7,opt,name=padding,proto3" json:"padding,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_proxy_vless_inbound_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_proxy_vless_inbound_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_proxy_vless_inbound_config_proto_rawDescGZIP(), []int{1} +} + +func (x *Config) GetUsers() []*protocol.User { + if x != nil { + return x.Users + } + return nil +} + +func (x *Config) GetFallbacks() []*Fallback { + if x != nil { + return x.Fallbacks + } + return nil +} + +func (x *Config) GetDecryption() string { + if x != nil { + return x.Decryption + } + return "" +} + +func (x *Config) GetXorMode() uint32 { + if x != nil { + return x.XorMode + } + return 0 +} + +func (x *Config) GetSecondsFrom() int64 { + if x != nil { + return x.SecondsFrom + } + return 0 +} + +func (x *Config) GetSecondsTo() int64 { + if x != nil { + return x.SecondsTo + } + return 0 +} + +func (x *Config) GetPadding() string { + if x != nil { + return x.Padding + } + return "" +} + +var File_proxy_vless_inbound_config_proto protoreflect.FileDescriptor + +const file_proxy_vless_inbound_config_proto_rawDesc = "" + + "\n" + + " proxy/vless/inbound/config.proto\x12\x18xray.proxy.vless.inbound\x1a\x1acommon/protocol/user.proto\"\x82\x01\n" + + "\bFallback\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04alpn\x18\x02 \x01(\tR\x04alpn\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n" + + "\x04type\x18\x04 \x01(\tR\x04type\x12\x12\n" + + "\x04dest\x18\x05 \x01(\tR\x04dest\x12\x12\n" + + "\x04xver\x18\x06 \x01(\x04R\x04xver\"\x92\x02\n" + + "\x06Config\x120\n" + + "\x05users\x18\x01 \x03(\v2\x1a.xray.common.protocol.UserR\x05users\x12@\n" + + "\tfallbacks\x18\x02 \x03(\v2\".xray.proxy.vless.inbound.FallbackR\tfallbacks\x12\x1e\n" + + "\n" + + "decryption\x18\x03 \x01(\tR\n" + + "decryption\x12\x18\n" + + "\axorMode\x18\x04 \x01(\rR\axorMode\x12!\n" + + "\fseconds_from\x18\x05 \x01(\x03R\vsecondsFrom\x12\x1d\n" + + "\n" + + "seconds_to\x18\x06 \x01(\x03R\tsecondsTo\x12\x18\n" + + "\apadding\x18\a \x01(\tR\apaddingB\xe5\x01\n" + + "\x1ccom.xray.proxy.vless.inboundB\vConfigProtoP\x01Z4github.com/mrcsin/usher/gen/xray/proxy/vless/inbound\xa2\x02\x04XPVI\xaa\x02\x18Xray.Proxy.Vless.Inbound\xca\x02\x18Xray\\Proxy\\Vless\\Inbound\xe2\x02$Xray\\Proxy\\Vless\\Inbound\\GPBMetadata\xea\x02\x1bXray::Proxy::Vless::Inboundb\x06proto3" + +var ( + file_proxy_vless_inbound_config_proto_rawDescOnce sync.Once + file_proxy_vless_inbound_config_proto_rawDescData []byte +) + +func file_proxy_vless_inbound_config_proto_rawDescGZIP() []byte { + file_proxy_vless_inbound_config_proto_rawDescOnce.Do(func() { + file_proxy_vless_inbound_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proxy_vless_inbound_config_proto_rawDesc), len(file_proxy_vless_inbound_config_proto_rawDesc))) + }) + return file_proxy_vless_inbound_config_proto_rawDescData +} + +var file_proxy_vless_inbound_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_proxy_vless_inbound_config_proto_goTypes = []any{ + (*Fallback)(nil), // 0: xray.proxy.vless.inbound.Fallback + (*Config)(nil), // 1: xray.proxy.vless.inbound.Config + (*protocol.User)(nil), // 2: xray.common.protocol.User +} +var file_proxy_vless_inbound_config_proto_depIdxs = []int32{ + 2, // 0: xray.proxy.vless.inbound.Config.users:type_name -> xray.common.protocol.User + 0, // 1: xray.proxy.vless.inbound.Config.fallbacks:type_name -> xray.proxy.vless.inbound.Fallback + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_proxy_vless_inbound_config_proto_init() } +func file_proxy_vless_inbound_config_proto_init() { + if File_proxy_vless_inbound_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proxy_vless_inbound_config_proto_rawDesc), len(file_proxy_vless_inbound_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proxy_vless_inbound_config_proto_goTypes, + DependencyIndexes: file_proxy_vless_inbound_config_proto_depIdxs, + MessageInfos: file_proxy_vless_inbound_config_proto_msgTypes, + }.Build() + File_proxy_vless_inbound_config_proto = out.File + file_proxy_vless_inbound_config_proto_goTypes = nil + file_proxy_vless_inbound_config_proto_depIdxs = nil +} diff --git a/gen/xray/transport/internet/config.pb.go b/gen/xray/transport/internet/config.pb.go new file mode 100644 index 0000000..cab3567 --- /dev/null +++ b/gen/xray/transport/internet/config.pb.go @@ -0,0 +1,1098 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: transport/internet/config.proto + +package internet + +import ( + net "github.com/mrcsin/usher/gen/xray/common/net" + serial "github.com/mrcsin/usher/gen/xray/common/serial" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DomainStrategy int32 + +const ( + DomainStrategy_AS_IS DomainStrategy = 0 + DomainStrategy_USE_IP DomainStrategy = 1 + DomainStrategy_USE_IP4 DomainStrategy = 2 + DomainStrategy_USE_IP6 DomainStrategy = 3 + DomainStrategy_USE_IP46 DomainStrategy = 4 + DomainStrategy_USE_IP64 DomainStrategy = 5 + DomainStrategy_FORCE_IP DomainStrategy = 6 + DomainStrategy_FORCE_IP4 DomainStrategy = 7 + DomainStrategy_FORCE_IP6 DomainStrategy = 8 + DomainStrategy_FORCE_IP46 DomainStrategy = 9 + DomainStrategy_FORCE_IP64 DomainStrategy = 10 +) + +// Enum value maps for DomainStrategy. +var ( + DomainStrategy_name = map[int32]string{ + 0: "AS_IS", + 1: "USE_IP", + 2: "USE_IP4", + 3: "USE_IP6", + 4: "USE_IP46", + 5: "USE_IP64", + 6: "FORCE_IP", + 7: "FORCE_IP4", + 8: "FORCE_IP6", + 9: "FORCE_IP46", + 10: "FORCE_IP64", + } + DomainStrategy_value = map[string]int32{ + "AS_IS": 0, + "USE_IP": 1, + "USE_IP4": 2, + "USE_IP6": 3, + "USE_IP46": 4, + "USE_IP64": 5, + "FORCE_IP": 6, + "FORCE_IP4": 7, + "FORCE_IP6": 8, + "FORCE_IP46": 9, + "FORCE_IP64": 10, + } +) + +func (x DomainStrategy) Enum() *DomainStrategy { + p := new(DomainStrategy) + *p = x + return p +} + +func (x DomainStrategy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DomainStrategy) Descriptor() protoreflect.EnumDescriptor { + return file_transport_internet_config_proto_enumTypes[0].Descriptor() +} + +func (DomainStrategy) Type() protoreflect.EnumType { + return &file_transport_internet_config_proto_enumTypes[0] +} + +func (x DomainStrategy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DomainStrategy.Descriptor instead. +func (DomainStrategy) EnumDescriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{0} +} + +type AddressPortStrategy int32 + +const ( + AddressPortStrategy_None AddressPortStrategy = 0 + AddressPortStrategy_SrvPortOnly AddressPortStrategy = 1 + AddressPortStrategy_SrvAddressOnly AddressPortStrategy = 2 + AddressPortStrategy_SrvPortAndAddress AddressPortStrategy = 3 + AddressPortStrategy_TxtPortOnly AddressPortStrategy = 4 + AddressPortStrategy_TxtAddressOnly AddressPortStrategy = 5 + AddressPortStrategy_TxtPortAndAddress AddressPortStrategy = 6 +) + +// Enum value maps for AddressPortStrategy. +var ( + AddressPortStrategy_name = map[int32]string{ + 0: "None", + 1: "SrvPortOnly", + 2: "SrvAddressOnly", + 3: "SrvPortAndAddress", + 4: "TxtPortOnly", + 5: "TxtAddressOnly", + 6: "TxtPortAndAddress", + } + AddressPortStrategy_value = map[string]int32{ + "None": 0, + "SrvPortOnly": 1, + "SrvAddressOnly": 2, + "SrvPortAndAddress": 3, + "TxtPortOnly": 4, + "TxtAddressOnly": 5, + "TxtPortAndAddress": 6, + } +) + +func (x AddressPortStrategy) Enum() *AddressPortStrategy { + p := new(AddressPortStrategy) + *p = x + return p +} + +func (x AddressPortStrategy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AddressPortStrategy) Descriptor() protoreflect.EnumDescriptor { + return file_transport_internet_config_proto_enumTypes[1].Descriptor() +} + +func (AddressPortStrategy) Type() protoreflect.EnumType { + return &file_transport_internet_config_proto_enumTypes[1] +} + +func (x AddressPortStrategy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AddressPortStrategy.Descriptor instead. +func (AddressPortStrategy) EnumDescriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{1} +} + +type SocketConfig_TProxyMode int32 + +const ( + // TProxy is off. + SocketConfig_Off SocketConfig_TProxyMode = 0 + // TProxy mode. + SocketConfig_TProxy SocketConfig_TProxyMode = 1 + // Redirect mode. + SocketConfig_Redirect SocketConfig_TProxyMode = 2 +) + +// Enum value maps for SocketConfig_TProxyMode. +var ( + SocketConfig_TProxyMode_name = map[int32]string{ + 0: "Off", + 1: "TProxy", + 2: "Redirect", + } + SocketConfig_TProxyMode_value = map[string]int32{ + "Off": 0, + "TProxy": 1, + "Redirect": 2, + } +) + +func (x SocketConfig_TProxyMode) Enum() *SocketConfig_TProxyMode { + p := new(SocketConfig_TProxyMode) + *p = x + return p +} + +func (x SocketConfig_TProxyMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SocketConfig_TProxyMode) Descriptor() protoreflect.EnumDescriptor { + return file_transport_internet_config_proto_enumTypes[2].Descriptor() +} + +func (SocketConfig_TProxyMode) Type() protoreflect.EnumType { + return &file_transport_internet_config_proto_enumTypes[2] +} + +func (x SocketConfig_TProxyMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SocketConfig_TProxyMode.Descriptor instead. +func (SocketConfig_TProxyMode) EnumDescriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{4, 0} +} + +type TransportConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Transport protocol name. + ProtocolName string `protobuf:"bytes,3,opt,name=protocol_name,json=protocolName,proto3" json:"protocol_name,omitempty"` + // Specific transport protocol settings. + Settings *serial.TypedMessage `protobuf:"bytes,2,opt,name=settings,proto3" json:"settings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransportConfig) Reset() { + *x = TransportConfig{} + mi := &file_transport_internet_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransportConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransportConfig) ProtoMessage() {} + +func (x *TransportConfig) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransportConfig.ProtoReflect.Descriptor instead. +func (*TransportConfig) Descriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{0} +} + +func (x *TransportConfig) GetProtocolName() string { + if x != nil { + return x.ProtocolName + } + return "" +} + +func (x *TransportConfig) GetSettings() *serial.TypedMessage { + if x != nil { + return x.Settings + } + return nil +} + +type StreamConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Address *net.IPOrDomain `protobuf:"bytes,8,opt,name=address,proto3" json:"address,omitempty"` + Port uint32 `protobuf:"varint,9,opt,name=port,proto3" json:"port,omitempty"` + // Effective network. + ProtocolName string `protobuf:"bytes,5,opt,name=protocol_name,json=protocolName,proto3" json:"protocol_name,omitempty"` + TransportSettings []*TransportConfig `protobuf:"bytes,2,rep,name=transport_settings,json=transportSettings,proto3" json:"transport_settings,omitempty"` + // Type of security. Must be a message name of the settings proto. + SecurityType string `protobuf:"bytes,3,opt,name=security_type,json=securityType,proto3" json:"security_type,omitempty"` + // Transport security settings. They can be either TLS or REALITY. + SecuritySettings []*serial.TypedMessage `protobuf:"bytes,4,rep,name=security_settings,json=securitySettings,proto3" json:"security_settings,omitempty"` + Udpmasks []*serial.TypedMessage `protobuf:"bytes,10,rep,name=udpmasks,proto3" json:"udpmasks,omitempty"` + Tcpmasks []*serial.TypedMessage `protobuf:"bytes,11,rep,name=tcpmasks,proto3" json:"tcpmasks,omitempty"` + QuicParams *QuicParams `protobuf:"bytes,12,opt,name=quic_params,json=quicParams,proto3" json:"quic_params,omitempty"` + SocketSettings *SocketConfig `protobuf:"bytes,6,opt,name=socket_settings,json=socketSettings,proto3" json:"socket_settings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamConfig) Reset() { + *x = StreamConfig{} + mi := &file_transport_internet_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamConfig) ProtoMessage() {} + +func (x *StreamConfig) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamConfig.ProtoReflect.Descriptor instead. +func (*StreamConfig) Descriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{1} +} + +func (x *StreamConfig) GetAddress() *net.IPOrDomain { + if x != nil { + return x.Address + } + return nil +} + +func (x *StreamConfig) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *StreamConfig) GetProtocolName() string { + if x != nil { + return x.ProtocolName + } + return "" +} + +func (x *StreamConfig) GetTransportSettings() []*TransportConfig { + if x != nil { + return x.TransportSettings + } + return nil +} + +func (x *StreamConfig) GetSecurityType() string { + if x != nil { + return x.SecurityType + } + return "" +} + +func (x *StreamConfig) GetSecuritySettings() []*serial.TypedMessage { + if x != nil { + return x.SecuritySettings + } + return nil +} + +func (x *StreamConfig) GetUdpmasks() []*serial.TypedMessage { + if x != nil { + return x.Udpmasks + } + return nil +} + +func (x *StreamConfig) GetTcpmasks() []*serial.TypedMessage { + if x != nil { + return x.Tcpmasks + } + return nil +} + +func (x *StreamConfig) GetQuicParams() *QuicParams { + if x != nil { + return x.QuicParams + } + return nil +} + +func (x *StreamConfig) GetSocketSettings() *SocketConfig { + if x != nil { + return x.SocketSettings + } + return nil +} + +type QuicParams struct { + state protoimpl.MessageState `protogen:"open.v1"` + Congestion string `protobuf:"bytes,1,opt,name=congestion,proto3" json:"congestion,omitempty"` + BbrProfile string `protobuf:"bytes,2,opt,name=bbr_profile,json=bbrProfile,proto3" json:"bbr_profile,omitempty"` + BrutalUp uint64 `protobuf:"varint,3,opt,name=brutal_up,json=brutalUp,proto3" json:"brutal_up,omitempty"` + BrutalDown uint64 `protobuf:"varint,4,opt,name=brutal_down,json=brutalDown,proto3" json:"brutal_down,omitempty"` + BrutalDisableLossCompensation bool `protobuf:"varint,5,opt,name=brutal_disable_loss_compensation,json=brutalDisableLossCompensation,proto3" json:"brutal_disable_loss_compensation,omitempty"` + InitStreamReceiveWindow uint64 `protobuf:"varint,6,opt,name=init_stream_receive_window,json=initStreamReceiveWindow,proto3" json:"init_stream_receive_window,omitempty"` + MaxStreamReceiveWindow uint64 `protobuf:"varint,7,opt,name=max_stream_receive_window,json=maxStreamReceiveWindow,proto3" json:"max_stream_receive_window,omitempty"` + InitConnReceiveWindow uint64 `protobuf:"varint,8,opt,name=init_conn_receive_window,json=initConnReceiveWindow,proto3" json:"init_conn_receive_window,omitempty"` + MaxConnReceiveWindow uint64 `protobuf:"varint,9,opt,name=max_conn_receive_window,json=maxConnReceiveWindow,proto3" json:"max_conn_receive_window,omitempty"` + MaxIdleTimeout int64 `protobuf:"varint,10,opt,name=max_idle_timeout,json=maxIdleTimeout,proto3" json:"max_idle_timeout,omitempty"` + KeepAlivePeriod int64 `protobuf:"varint,11,opt,name=keep_alive_period,json=keepAlivePeriod,proto3" json:"keep_alive_period,omitempty"` + DisablePathMtuDiscovery bool `protobuf:"varint,12,opt,name=disable_path_mtu_discovery,json=disablePathMtuDiscovery,proto3" json:"disable_path_mtu_discovery,omitempty"` + DisableChromeParrot bool `protobuf:"varint,13,opt,name=disable_chrome_parrot,json=disableChromeParrot,proto3" json:"disable_chrome_parrot,omitempty"` + DisableGSO bool `protobuf:"varint,14,opt,name=disableGSO,proto3" json:"disableGSO,omitempty"` + MaxIncomingStreams int64 `protobuf:"varint,15,opt,name=max_incoming_streams,json=maxIncomingStreams,proto3" json:"max_incoming_streams,omitempty"` + DisableStatelessReset bool `protobuf:"varint,16,opt,name=disable_stateless_reset,json=disableStatelessReset,proto3" json:"disable_stateless_reset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QuicParams) Reset() { + *x = QuicParams{} + mi := &file_transport_internet_config_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QuicParams) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuicParams) ProtoMessage() {} + +func (x *QuicParams) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_config_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuicParams.ProtoReflect.Descriptor instead. +func (*QuicParams) Descriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{2} +} + +func (x *QuicParams) GetCongestion() string { + if x != nil { + return x.Congestion + } + return "" +} + +func (x *QuicParams) GetBbrProfile() string { + if x != nil { + return x.BbrProfile + } + return "" +} + +func (x *QuicParams) GetBrutalUp() uint64 { + if x != nil { + return x.BrutalUp + } + return 0 +} + +func (x *QuicParams) GetBrutalDown() uint64 { + if x != nil { + return x.BrutalDown + } + return 0 +} + +func (x *QuicParams) GetBrutalDisableLossCompensation() bool { + if x != nil { + return x.BrutalDisableLossCompensation + } + return false +} + +func (x *QuicParams) GetInitStreamReceiveWindow() uint64 { + if x != nil { + return x.InitStreamReceiveWindow + } + return 0 +} + +func (x *QuicParams) GetMaxStreamReceiveWindow() uint64 { + if x != nil { + return x.MaxStreamReceiveWindow + } + return 0 +} + +func (x *QuicParams) GetInitConnReceiveWindow() uint64 { + if x != nil { + return x.InitConnReceiveWindow + } + return 0 +} + +func (x *QuicParams) GetMaxConnReceiveWindow() uint64 { + if x != nil { + return x.MaxConnReceiveWindow + } + return 0 +} + +func (x *QuicParams) GetMaxIdleTimeout() int64 { + if x != nil { + return x.MaxIdleTimeout + } + return 0 +} + +func (x *QuicParams) GetKeepAlivePeriod() int64 { + if x != nil { + return x.KeepAlivePeriod + } + return 0 +} + +func (x *QuicParams) GetDisablePathMtuDiscovery() bool { + if x != nil { + return x.DisablePathMtuDiscovery + } + return false +} + +func (x *QuicParams) GetDisableChromeParrot() bool { + if x != nil { + return x.DisableChromeParrot + } + return false +} + +func (x *QuicParams) GetDisableGSO() bool { + if x != nil { + return x.DisableGSO + } + return false +} + +func (x *QuicParams) GetMaxIncomingStreams() int64 { + if x != nil { + return x.MaxIncomingStreams + } + return 0 +} + +func (x *QuicParams) GetDisableStatelessReset() bool { + if x != nil { + return x.DisableStatelessReset + } + return false +} + +type CustomSockopt struct { + state protoimpl.MessageState `protogen:"open.v1"` + System string `protobuf:"bytes,1,opt,name=system,proto3" json:"system,omitempty"` + Network string `protobuf:"bytes,2,opt,name=network,proto3" json:"network,omitempty"` + Level string `protobuf:"bytes,3,opt,name=level,proto3" json:"level,omitempty"` + Opt string `protobuf:"bytes,4,opt,name=opt,proto3" json:"opt,omitempty"` + Value string `protobuf:"bytes,5,opt,name=value,proto3" json:"value,omitempty"` + Type string `protobuf:"bytes,6,opt,name=type,proto3" json:"type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CustomSockopt) Reset() { + *x = CustomSockopt{} + mi := &file_transport_internet_config_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CustomSockopt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CustomSockopt) ProtoMessage() {} + +func (x *CustomSockopt) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_config_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CustomSockopt.ProtoReflect.Descriptor instead. +func (*CustomSockopt) Descriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{3} +} + +func (x *CustomSockopt) GetSystem() string { + if x != nil { + return x.System + } + return "" +} + +func (x *CustomSockopt) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *CustomSockopt) GetLevel() string { + if x != nil { + return x.Level + } + return "" +} + +func (x *CustomSockopt) GetOpt() string { + if x != nil { + return x.Opt + } + return "" +} + +func (x *CustomSockopt) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *CustomSockopt) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +// SocketConfig is options to be applied on network sockets. +type SocketConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Mark of the connection. If non-zero, the value will be set to SO_MARK. + Mark int32 `protobuf:"varint,1,opt,name=mark,proto3" json:"mark,omitempty"` + // TFO is the state of TFO settings. + Tfo int32 `protobuf:"varint,2,opt,name=tfo,proto3" json:"tfo,omitempty"` + // TProxy is for enabling TProxy socket option. + Tproxy SocketConfig_TProxyMode `protobuf:"varint,3,opt,name=tproxy,proto3,enum=xray.transport.internet.SocketConfig_TProxyMode" json:"tproxy,omitempty"` + // ReceiveOriginalDestAddress is for enabling IP_RECVORIGDSTADDR socket + // option. This option is for UDP only. + ReceiveOriginalDestAddress bool `protobuf:"varint,4,opt,name=receive_original_dest_address,json=receiveOriginalDestAddress,proto3" json:"receive_original_dest_address,omitempty"` + AcceptProxyProtocol bool `protobuf:"varint,7,opt,name=accept_proxy_protocol,json=acceptProxyProtocol,proto3" json:"accept_proxy_protocol,omitempty"` + DomainStrategy DomainStrategy `protobuf:"varint,8,opt,name=domain_strategy,json=domainStrategy,proto3,enum=xray.transport.internet.DomainStrategy" json:"domain_strategy,omitempty"` + DialerProxy string `protobuf:"bytes,9,opt,name=dialer_proxy,json=dialerProxy,proto3" json:"dialer_proxy,omitempty"` + TcpKeepAliveInterval int32 `protobuf:"varint,10,opt,name=tcp_keep_alive_interval,json=tcpKeepAliveInterval,proto3" json:"tcp_keep_alive_interval,omitempty"` + TcpKeepAliveIdle int32 `protobuf:"varint,11,opt,name=tcp_keep_alive_idle,json=tcpKeepAliveIdle,proto3" json:"tcp_keep_alive_idle,omitempty"` + TcpCongestion string `protobuf:"bytes,12,opt,name=tcp_congestion,json=tcpCongestion,proto3" json:"tcp_congestion,omitempty"` + Interface string `protobuf:"bytes,13,opt,name=interface,proto3" json:"interface,omitempty"` + V6Only bool `protobuf:"varint,14,opt,name=v6only,proto3" json:"v6only,omitempty"` + TcpWindowClamp int32 `protobuf:"varint,15,opt,name=tcp_window_clamp,json=tcpWindowClamp,proto3" json:"tcp_window_clamp,omitempty"` + TcpUserTimeout int32 `protobuf:"varint,16,opt,name=tcp_user_timeout,json=tcpUserTimeout,proto3" json:"tcp_user_timeout,omitempty"` + TcpMaxSeg int32 `protobuf:"varint,17,opt,name=tcp_max_seg,json=tcpMaxSeg,proto3" json:"tcp_max_seg,omitempty"` + Penetrate bool `protobuf:"varint,18,opt,name=penetrate,proto3" json:"penetrate,omitempty"` + TcpMptcp bool `protobuf:"varint,19,opt,name=tcp_mptcp,json=tcpMptcp,proto3" json:"tcp_mptcp,omitempty"` + CustomSockopt []*CustomSockopt `protobuf:"bytes,20,rep,name=customSockopt,proto3" json:"customSockopt,omitempty"` + AddressPortStrategy AddressPortStrategy `protobuf:"varint,21,opt,name=address_port_strategy,json=addressPortStrategy,proto3,enum=xray.transport.internet.AddressPortStrategy" json:"address_port_strategy,omitempty"` + HappyEyeballs *HappyEyeballsConfig `protobuf:"bytes,22,opt,name=happy_eyeballs,json=happyEyeballs,proto3" json:"happy_eyeballs,omitempty"` + TrustedXForwardedFor []string `protobuf:"bytes,23,rep,name=trusted_x_forwarded_for,json=trustedXForwardedFor,proto3" json:"trusted_x_forwarded_for,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SocketConfig) Reset() { + *x = SocketConfig{} + mi := &file_transport_internet_config_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SocketConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SocketConfig) ProtoMessage() {} + +func (x *SocketConfig) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_config_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SocketConfig.ProtoReflect.Descriptor instead. +func (*SocketConfig) Descriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{4} +} + +func (x *SocketConfig) GetMark() int32 { + if x != nil { + return x.Mark + } + return 0 +} + +func (x *SocketConfig) GetTfo() int32 { + if x != nil { + return x.Tfo + } + return 0 +} + +func (x *SocketConfig) GetTproxy() SocketConfig_TProxyMode { + if x != nil { + return x.Tproxy + } + return SocketConfig_Off +} + +func (x *SocketConfig) GetReceiveOriginalDestAddress() bool { + if x != nil { + return x.ReceiveOriginalDestAddress + } + return false +} + +func (x *SocketConfig) GetAcceptProxyProtocol() bool { + if x != nil { + return x.AcceptProxyProtocol + } + return false +} + +func (x *SocketConfig) GetDomainStrategy() DomainStrategy { + if x != nil { + return x.DomainStrategy + } + return DomainStrategy_AS_IS +} + +func (x *SocketConfig) GetDialerProxy() string { + if x != nil { + return x.DialerProxy + } + return "" +} + +func (x *SocketConfig) GetTcpKeepAliveInterval() int32 { + if x != nil { + return x.TcpKeepAliveInterval + } + return 0 +} + +func (x *SocketConfig) GetTcpKeepAliveIdle() int32 { + if x != nil { + return x.TcpKeepAliveIdle + } + return 0 +} + +func (x *SocketConfig) GetTcpCongestion() string { + if x != nil { + return x.TcpCongestion + } + return "" +} + +func (x *SocketConfig) GetInterface() string { + if x != nil { + return x.Interface + } + return "" +} + +func (x *SocketConfig) GetV6Only() bool { + if x != nil { + return x.V6Only + } + return false +} + +func (x *SocketConfig) GetTcpWindowClamp() int32 { + if x != nil { + return x.TcpWindowClamp + } + return 0 +} + +func (x *SocketConfig) GetTcpUserTimeout() int32 { + if x != nil { + return x.TcpUserTimeout + } + return 0 +} + +func (x *SocketConfig) GetTcpMaxSeg() int32 { + if x != nil { + return x.TcpMaxSeg + } + return 0 +} + +func (x *SocketConfig) GetPenetrate() bool { + if x != nil { + return x.Penetrate + } + return false +} + +func (x *SocketConfig) GetTcpMptcp() bool { + if x != nil { + return x.TcpMptcp + } + return false +} + +func (x *SocketConfig) GetCustomSockopt() []*CustomSockopt { + if x != nil { + return x.CustomSockopt + } + return nil +} + +func (x *SocketConfig) GetAddressPortStrategy() AddressPortStrategy { + if x != nil { + return x.AddressPortStrategy + } + return AddressPortStrategy_None +} + +func (x *SocketConfig) GetHappyEyeballs() *HappyEyeballsConfig { + if x != nil { + return x.HappyEyeballs + } + return nil +} + +func (x *SocketConfig) GetTrustedXForwardedFor() []string { + if x != nil { + return x.TrustedXForwardedFor + } + return nil +} + +type HappyEyeballsConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + PrioritizeIpv6 bool `protobuf:"varint,1,opt,name=prioritize_ipv6,json=prioritizeIpv6,proto3" json:"prioritize_ipv6,omitempty"` + Interleave uint32 `protobuf:"varint,2,opt,name=interleave,proto3" json:"interleave,omitempty"` + TryDelayMs uint64 `protobuf:"varint,3,opt,name=try_delayMs,json=tryDelayMs,proto3" json:"try_delayMs,omitempty"` + MaxConcurrentTry uint32 `protobuf:"varint,4,opt,name=max_concurrent_try,json=maxConcurrentTry,proto3" json:"max_concurrent_try,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HappyEyeballsConfig) Reset() { + *x = HappyEyeballsConfig{} + mi := &file_transport_internet_config_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HappyEyeballsConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HappyEyeballsConfig) ProtoMessage() {} + +func (x *HappyEyeballsConfig) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_config_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HappyEyeballsConfig.ProtoReflect.Descriptor instead. +func (*HappyEyeballsConfig) Descriptor() ([]byte, []int) { + return file_transport_internet_config_proto_rawDescGZIP(), []int{5} +} + +func (x *HappyEyeballsConfig) GetPrioritizeIpv6() bool { + if x != nil { + return x.PrioritizeIpv6 + } + return false +} + +func (x *HappyEyeballsConfig) GetInterleave() uint32 { + if x != nil { + return x.Interleave + } + return 0 +} + +func (x *HappyEyeballsConfig) GetTryDelayMs() uint64 { + if x != nil { + return x.TryDelayMs + } + return 0 +} + +func (x *HappyEyeballsConfig) GetMaxConcurrentTry() uint32 { + if x != nil { + return x.MaxConcurrentTry + } + return 0 +} + +var File_transport_internet_config_proto protoreflect.FileDescriptor + +const file_transport_internet_config_proto_rawDesc = "" + + "\n" + + "\x1ftransport/internet/config.proto\x12\x17xray.transport.internet\x1a!common/serial/typed_message.proto\x1a\x18common/net/address.proto\"t\n" + + "\x0fTransportConfig\x12#\n" + + "\rprotocol_name\x18\x03 \x01(\tR\fprotocolName\x12<\n" + + "\bsettings\x18\x02 \x01(\v2 .xray.common.serial.TypedMessageR\bsettings\"\xdd\x04\n" + + "\fStreamConfig\x125\n" + + "\aaddress\x18\b \x01(\v2\x1b.xray.common.net.IPOrDomainR\aaddress\x12\x12\n" + + "\x04port\x18\t \x01(\rR\x04port\x12#\n" + + "\rprotocol_name\x18\x05 \x01(\tR\fprotocolName\x12W\n" + + "\x12transport_settings\x18\x02 \x03(\v2(.xray.transport.internet.TransportConfigR\x11transportSettings\x12#\n" + + "\rsecurity_type\x18\x03 \x01(\tR\fsecurityType\x12M\n" + + "\x11security_settings\x18\x04 \x03(\v2 .xray.common.serial.TypedMessageR\x10securitySettings\x12<\n" + + "\budpmasks\x18\n" + + " \x03(\v2 .xray.common.serial.TypedMessageR\budpmasks\x12<\n" + + "\btcpmasks\x18\v \x03(\v2 .xray.common.serial.TypedMessageR\btcpmasks\x12D\n" + + "\vquic_params\x18\f \x01(\v2#.xray.transport.internet.QuicParamsR\n" + + "quicParams\x12N\n" + + "\x0fsocket_settings\x18\x06 \x01(\v2%.xray.transport.internet.SocketConfigR\x0esocketSettings\"\x8d\x06\n" + + "\n" + + "QuicParams\x12\x1e\n" + + "\n" + + "congestion\x18\x01 \x01(\tR\n" + + "congestion\x12\x1f\n" + + "\vbbr_profile\x18\x02 \x01(\tR\n" + + "bbrProfile\x12\x1b\n" + + "\tbrutal_up\x18\x03 \x01(\x04R\bbrutalUp\x12\x1f\n" + + "\vbrutal_down\x18\x04 \x01(\x04R\n" + + "brutalDown\x12G\n" + + " brutal_disable_loss_compensation\x18\x05 \x01(\bR\x1dbrutalDisableLossCompensation\x12;\n" + + "\x1ainit_stream_receive_window\x18\x06 \x01(\x04R\x17initStreamReceiveWindow\x129\n" + + "\x19max_stream_receive_window\x18\a \x01(\x04R\x16maxStreamReceiveWindow\x127\n" + + "\x18init_conn_receive_window\x18\b \x01(\x04R\x15initConnReceiveWindow\x125\n" + + "\x17max_conn_receive_window\x18\t \x01(\x04R\x14maxConnReceiveWindow\x12(\n" + + "\x10max_idle_timeout\x18\n" + + " \x01(\x03R\x0emaxIdleTimeout\x12*\n" + + "\x11keep_alive_period\x18\v \x01(\x03R\x0fkeepAlivePeriod\x12;\n" + + "\x1adisable_path_mtu_discovery\x18\f \x01(\bR\x17disablePathMtuDiscovery\x122\n" + + "\x15disable_chrome_parrot\x18\r \x01(\bR\x13disableChromeParrot\x12\x1e\n" + + "\n" + + "disableGSO\x18\x0e \x01(\bR\n" + + "disableGSO\x120\n" + + "\x14max_incoming_streams\x18\x0f \x01(\x03R\x12maxIncomingStreams\x126\n" + + "\x17disable_stateless_reset\x18\x10 \x01(\bR\x15disableStatelessReset\"\x93\x01\n" + + "\rCustomSockopt\x12\x16\n" + + "\x06system\x18\x01 \x01(\tR\x06system\x12\x18\n" + + "\anetwork\x18\x02 \x01(\tR\anetwork\x12\x14\n" + + "\x05level\x18\x03 \x01(\tR\x05level\x12\x10\n" + + "\x03opt\x18\x04 \x01(\tR\x03opt\x12\x14\n" + + "\x05value\x18\x05 \x01(\tR\x05value\x12\x12\n" + + "\x04type\x18\x06 \x01(\tR\x04type\"\xc9\b\n" + + "\fSocketConfig\x12\x12\n" + + "\x04mark\x18\x01 \x01(\x05R\x04mark\x12\x10\n" + + "\x03tfo\x18\x02 \x01(\x05R\x03tfo\x12H\n" + + "\x06tproxy\x18\x03 \x01(\x0e20.xray.transport.internet.SocketConfig.TProxyModeR\x06tproxy\x12A\n" + + "\x1dreceive_original_dest_address\x18\x04 \x01(\bR\x1areceiveOriginalDestAddress\x122\n" + + "\x15accept_proxy_protocol\x18\a \x01(\bR\x13acceptProxyProtocol\x12P\n" + + "\x0fdomain_strategy\x18\b \x01(\x0e2'.xray.transport.internet.DomainStrategyR\x0edomainStrategy\x12!\n" + + "\fdialer_proxy\x18\t \x01(\tR\vdialerProxy\x125\n" + + "\x17tcp_keep_alive_interval\x18\n" + + " \x01(\x05R\x14tcpKeepAliveInterval\x12-\n" + + "\x13tcp_keep_alive_idle\x18\v \x01(\x05R\x10tcpKeepAliveIdle\x12%\n" + + "\x0etcp_congestion\x18\f \x01(\tR\rtcpCongestion\x12\x1c\n" + + "\tinterface\x18\r \x01(\tR\tinterface\x12\x16\n" + + "\x06v6only\x18\x0e \x01(\bR\x06v6only\x12(\n" + + "\x10tcp_window_clamp\x18\x0f \x01(\x05R\x0etcpWindowClamp\x12(\n" + + "\x10tcp_user_timeout\x18\x10 \x01(\x05R\x0etcpUserTimeout\x12\x1e\n" + + "\vtcp_max_seg\x18\x11 \x01(\x05R\ttcpMaxSeg\x12\x1c\n" + + "\tpenetrate\x18\x12 \x01(\bR\tpenetrate\x12\x1b\n" + + "\ttcp_mptcp\x18\x13 \x01(\bR\btcpMptcp\x12L\n" + + "\rcustomSockopt\x18\x14 \x03(\v2&.xray.transport.internet.CustomSockoptR\rcustomSockopt\x12`\n" + + "\x15address_port_strategy\x18\x15 \x01(\x0e2,.xray.transport.internet.AddressPortStrategyR\x13addressPortStrategy\x12S\n" + + "\x0ehappy_eyeballs\x18\x16 \x01(\v2,.xray.transport.internet.HappyEyeballsConfigR\rhappyEyeballs\x125\n" + + "\x17trusted_x_forwarded_for\x18\x17 \x03(\tR\x14trustedXForwardedFor\"/\n" + + "\n" + + "TProxyMode\x12\a\n" + + "\x03Off\x10\x00\x12\n" + + "\n" + + "\x06TProxy\x10\x01\x12\f\n" + + "\bRedirect\x10\x02\"\xad\x01\n" + + "\x13HappyEyeballsConfig\x12'\n" + + "\x0fprioritize_ipv6\x18\x01 \x01(\bR\x0eprioritizeIpv6\x12\x1e\n" + + "\n" + + "interleave\x18\x02 \x01(\rR\n" + + "interleave\x12\x1f\n" + + "\vtry_delayMs\x18\x03 \x01(\x04R\n" + + "tryDelayMs\x12,\n" + + "\x12max_concurrent_try\x18\x04 \x01(\rR\x10maxConcurrentTry*\xa9\x01\n" + + "\x0eDomainStrategy\x12\t\n" + + "\x05AS_IS\x10\x00\x12\n" + + "\n" + + "\x06USE_IP\x10\x01\x12\v\n" + + "\aUSE_IP4\x10\x02\x12\v\n" + + "\aUSE_IP6\x10\x03\x12\f\n" + + "\bUSE_IP46\x10\x04\x12\f\n" + + "\bUSE_IP64\x10\x05\x12\f\n" + + "\bFORCE_IP\x10\x06\x12\r\n" + + "\tFORCE_IP4\x10\a\x12\r\n" + + "\tFORCE_IP6\x10\b\x12\x0e\n" + + "\n" + + "FORCE_IP46\x10\t\x12\x0e\n" + + "\n" + + "FORCE_IP64\x10\n" + + "*\x97\x01\n" + + "\x13AddressPortStrategy\x12\b\n" + + "\x04None\x10\x00\x12\x0f\n" + + "\vSrvPortOnly\x10\x01\x12\x12\n" + + "\x0eSrvAddressOnly\x10\x02\x12\x15\n" + + "\x11SrvPortAndAddress\x10\x03\x12\x0f\n" + + "\vTxtPortOnly\x10\x04\x12\x12\n" + + "\x0eTxtAddressOnly\x10\x05\x12\x15\n" + + "\x11TxtPortAndAddress\x10\x06B\xdd\x01\n" + + "\x1bcom.xray.transport.internetB\vConfigProtoP\x01Z3github.com/mrcsin/usher/gen/xray/transport/internet\xa2\x02\x03XTI\xaa\x02\x17Xray.Transport.Internet\xca\x02\x17Xray\\Transport\\Internet\xe2\x02#Xray\\Transport\\Internet\\GPBMetadata\xea\x02\x19Xray::Transport::Internetb\x06proto3" + +var ( + file_transport_internet_config_proto_rawDescOnce sync.Once + file_transport_internet_config_proto_rawDescData []byte +) + +func file_transport_internet_config_proto_rawDescGZIP() []byte { + file_transport_internet_config_proto_rawDescOnce.Do(func() { + file_transport_internet_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_internet_config_proto_rawDesc), len(file_transport_internet_config_proto_rawDesc))) + }) + return file_transport_internet_config_proto_rawDescData +} + +var file_transport_internet_config_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_transport_internet_config_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_transport_internet_config_proto_goTypes = []any{ + (DomainStrategy)(0), // 0: xray.transport.internet.DomainStrategy + (AddressPortStrategy)(0), // 1: xray.transport.internet.AddressPortStrategy + (SocketConfig_TProxyMode)(0), // 2: xray.transport.internet.SocketConfig.TProxyMode + (*TransportConfig)(nil), // 3: xray.transport.internet.TransportConfig + (*StreamConfig)(nil), // 4: xray.transport.internet.StreamConfig + (*QuicParams)(nil), // 5: xray.transport.internet.QuicParams + (*CustomSockopt)(nil), // 6: xray.transport.internet.CustomSockopt + (*SocketConfig)(nil), // 7: xray.transport.internet.SocketConfig + (*HappyEyeballsConfig)(nil), // 8: xray.transport.internet.HappyEyeballsConfig + (*serial.TypedMessage)(nil), // 9: xray.common.serial.TypedMessage + (*net.IPOrDomain)(nil), // 10: xray.common.net.IPOrDomain +} +var file_transport_internet_config_proto_depIdxs = []int32{ + 9, // 0: xray.transport.internet.TransportConfig.settings:type_name -> xray.common.serial.TypedMessage + 10, // 1: xray.transport.internet.StreamConfig.address:type_name -> xray.common.net.IPOrDomain + 3, // 2: xray.transport.internet.StreamConfig.transport_settings:type_name -> xray.transport.internet.TransportConfig + 9, // 3: xray.transport.internet.StreamConfig.security_settings:type_name -> xray.common.serial.TypedMessage + 9, // 4: xray.transport.internet.StreamConfig.udpmasks:type_name -> xray.common.serial.TypedMessage + 9, // 5: xray.transport.internet.StreamConfig.tcpmasks:type_name -> xray.common.serial.TypedMessage + 5, // 6: xray.transport.internet.StreamConfig.quic_params:type_name -> xray.transport.internet.QuicParams + 7, // 7: xray.transport.internet.StreamConfig.socket_settings:type_name -> xray.transport.internet.SocketConfig + 2, // 8: xray.transport.internet.SocketConfig.tproxy:type_name -> xray.transport.internet.SocketConfig.TProxyMode + 0, // 9: xray.transport.internet.SocketConfig.domain_strategy:type_name -> xray.transport.internet.DomainStrategy + 6, // 10: xray.transport.internet.SocketConfig.customSockopt:type_name -> xray.transport.internet.CustomSockopt + 1, // 11: xray.transport.internet.SocketConfig.address_port_strategy:type_name -> xray.transport.internet.AddressPortStrategy + 8, // 12: xray.transport.internet.SocketConfig.happy_eyeballs:type_name -> xray.transport.internet.HappyEyeballsConfig + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name +} + +func init() { file_transport_internet_config_proto_init() } +func file_transport_internet_config_proto_init() { + if File_transport_internet_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_config_proto_rawDesc), len(file_transport_internet_config_proto_rawDesc)), + NumEnums: 3, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_transport_internet_config_proto_goTypes, + DependencyIndexes: file_transport_internet_config_proto_depIdxs, + EnumInfos: file_transport_internet_config_proto_enumTypes, + MessageInfos: file_transport_internet_config_proto_msgTypes, + }.Build() + File_transport_internet_config_proto = out.File + file_transport_internet_config_proto_goTypes = nil + file_transport_internet_config_proto_depIdxs = nil +} diff --git a/gen/xray/transport/internet/reality/config.pb.go b/gen/xray/transport/internet/reality/config.pb.go new file mode 100644 index 0000000..62d8e61 --- /dev/null +++ b/gen/xray/transport/internet/reality/config.pb.go @@ -0,0 +1,375 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: transport/internet/reality/config.proto + +package reality + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + Show bool `protobuf:"varint,1,opt,name=show,proto3" json:"show,omitempty"` + Dest string `protobuf:"bytes,2,opt,name=dest,proto3" json:"dest,omitempty"` + Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` + Xver uint64 `protobuf:"varint,4,opt,name=xver,proto3" json:"xver,omitempty"` + ServerNames []string `protobuf:"bytes,5,rep,name=server_names,json=serverNames,proto3" json:"server_names,omitempty"` + PrivateKey []byte `protobuf:"bytes,6,opt,name=private_key,json=privateKey,proto3" json:"private_key,omitempty"` + MinClientVer []byte `protobuf:"bytes,7,opt,name=min_client_ver,json=minClientVer,proto3" json:"min_client_ver,omitempty"` + MaxClientVer []byte `protobuf:"bytes,8,opt,name=max_client_ver,json=maxClientVer,proto3" json:"max_client_ver,omitempty"` + MaxTimeDiff uint64 `protobuf:"varint,9,opt,name=max_time_diff,json=maxTimeDiff,proto3" json:"max_time_diff,omitempty"` + ShortIds [][]byte `protobuf:"bytes,10,rep,name=short_ids,json=shortIds,proto3" json:"short_ids,omitempty"` + Mldsa65Seed []byte `protobuf:"bytes,11,opt,name=mldsa65_seed,json=mldsa65Seed,proto3" json:"mldsa65_seed,omitempty"` + LimitFallbackUpload *LimitFallback `protobuf:"bytes,12,opt,name=limit_fallback_upload,json=limitFallbackUpload,proto3" json:"limit_fallback_upload,omitempty"` + LimitFallbackDownload *LimitFallback `protobuf:"bytes,13,opt,name=limit_fallback_download,json=limitFallbackDownload,proto3" json:"limit_fallback_download,omitempty"` + Fingerprint string `protobuf:"bytes,21,opt,name=Fingerprint,proto3" json:"Fingerprint,omitempty"` + ServerName string `protobuf:"bytes,22,opt,name=server_name,json=serverName,proto3" json:"server_name,omitempty"` + PublicKey []byte `protobuf:"bytes,23,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + ShortId []byte `protobuf:"bytes,24,opt,name=short_id,json=shortId,proto3" json:"short_id,omitempty"` + Mldsa65Verify []byte `protobuf:"bytes,25,opt,name=mldsa65_verify,json=mldsa65Verify,proto3" json:"mldsa65_verify,omitempty"` + SpiderX string `protobuf:"bytes,26,opt,name=spider_x,json=spiderX,proto3" json:"spider_x,omitempty"` + SpiderY []int64 `protobuf:"varint,27,rep,packed,name=spider_y,json=spiderY,proto3" json:"spider_y,omitempty"` + MasterKeyLog string `protobuf:"bytes,31,opt,name=master_key_log,json=masterKeyLog,proto3" json:"master_key_log,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_transport_internet_reality_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_reality_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_transport_internet_reality_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetShow() bool { + if x != nil { + return x.Show + } + return false +} + +func (x *Config) GetDest() string { + if x != nil { + return x.Dest + } + return "" +} + +func (x *Config) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Config) GetXver() uint64 { + if x != nil { + return x.Xver + } + return 0 +} + +func (x *Config) GetServerNames() []string { + if x != nil { + return x.ServerNames + } + return nil +} + +func (x *Config) GetPrivateKey() []byte { + if x != nil { + return x.PrivateKey + } + return nil +} + +func (x *Config) GetMinClientVer() []byte { + if x != nil { + return x.MinClientVer + } + return nil +} + +func (x *Config) GetMaxClientVer() []byte { + if x != nil { + return x.MaxClientVer + } + return nil +} + +func (x *Config) GetMaxTimeDiff() uint64 { + if x != nil { + return x.MaxTimeDiff + } + return 0 +} + +func (x *Config) GetShortIds() [][]byte { + if x != nil { + return x.ShortIds + } + return nil +} + +func (x *Config) GetMldsa65Seed() []byte { + if x != nil { + return x.Mldsa65Seed + } + return nil +} + +func (x *Config) GetLimitFallbackUpload() *LimitFallback { + if x != nil { + return x.LimitFallbackUpload + } + return nil +} + +func (x *Config) GetLimitFallbackDownload() *LimitFallback { + if x != nil { + return x.LimitFallbackDownload + } + return nil +} + +func (x *Config) GetFingerprint() string { + if x != nil { + return x.Fingerprint + } + return "" +} + +func (x *Config) GetServerName() string { + if x != nil { + return x.ServerName + } + return "" +} + +func (x *Config) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +func (x *Config) GetShortId() []byte { + if x != nil { + return x.ShortId + } + return nil +} + +func (x *Config) GetMldsa65Verify() []byte { + if x != nil { + return x.Mldsa65Verify + } + return nil +} + +func (x *Config) GetSpiderX() string { + if x != nil { + return x.SpiderX + } + return "" +} + +func (x *Config) GetSpiderY() []int64 { + if x != nil { + return x.SpiderY + } + return nil +} + +func (x *Config) GetMasterKeyLog() string { + if x != nil { + return x.MasterKeyLog + } + return "" +} + +type LimitFallback struct { + state protoimpl.MessageState `protogen:"open.v1"` + AfterBytes uint64 `protobuf:"varint,1,opt,name=after_bytes,json=afterBytes,proto3" json:"after_bytes,omitempty"` + BytesPerSec uint64 `protobuf:"varint,2,opt,name=bytes_per_sec,json=bytesPerSec,proto3" json:"bytes_per_sec,omitempty"` + BurstBytesPerSec uint64 `protobuf:"varint,3,opt,name=burst_bytes_per_sec,json=burstBytesPerSec,proto3" json:"burst_bytes_per_sec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LimitFallback) Reset() { + *x = LimitFallback{} + mi := &file_transport_internet_reality_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LimitFallback) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LimitFallback) ProtoMessage() {} + +func (x *LimitFallback) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_reality_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LimitFallback.ProtoReflect.Descriptor instead. +func (*LimitFallback) Descriptor() ([]byte, []int) { + return file_transport_internet_reality_config_proto_rawDescGZIP(), []int{1} +} + +func (x *LimitFallback) GetAfterBytes() uint64 { + if x != nil { + return x.AfterBytes + } + return 0 +} + +func (x *LimitFallback) GetBytesPerSec() uint64 { + if x != nil { + return x.BytesPerSec + } + return 0 +} + +func (x *LimitFallback) GetBurstBytesPerSec() uint64 { + if x != nil { + return x.BurstBytesPerSec + } + return 0 +} + +var File_transport_internet_reality_config_proto protoreflect.FileDescriptor + +const file_transport_internet_reality_config_proto_rawDesc = "" + + "\n" + + "'transport/internet/reality/config.proto\x12\x1fxray.transport.internet.reality\"\x98\x06\n" + + "\x06Config\x12\x12\n" + + "\x04show\x18\x01 \x01(\bR\x04show\x12\x12\n" + + "\x04dest\x18\x02 \x01(\tR\x04dest\x12\x12\n" + + "\x04type\x18\x03 \x01(\tR\x04type\x12\x12\n" + + "\x04xver\x18\x04 \x01(\x04R\x04xver\x12!\n" + + "\fserver_names\x18\x05 \x03(\tR\vserverNames\x12\x1f\n" + + "\vprivate_key\x18\x06 \x01(\fR\n" + + "privateKey\x12$\n" + + "\x0emin_client_ver\x18\a \x01(\fR\fminClientVer\x12$\n" + + "\x0emax_client_ver\x18\b \x01(\fR\fmaxClientVer\x12\"\n" + + "\rmax_time_diff\x18\t \x01(\x04R\vmaxTimeDiff\x12\x1b\n" + + "\tshort_ids\x18\n" + + " \x03(\fR\bshortIds\x12!\n" + + "\fmldsa65_seed\x18\v \x01(\fR\vmldsa65Seed\x12b\n" + + "\x15limit_fallback_upload\x18\f \x01(\v2..xray.transport.internet.reality.LimitFallbackR\x13limitFallbackUpload\x12f\n" + + "\x17limit_fallback_download\x18\r \x01(\v2..xray.transport.internet.reality.LimitFallbackR\x15limitFallbackDownload\x12 \n" + + "\vFingerprint\x18\x15 \x01(\tR\vFingerprint\x12\x1f\n" + + "\vserver_name\x18\x16 \x01(\tR\n" + + "serverName\x12\x1d\n" + + "\n" + + "public_key\x18\x17 \x01(\fR\tpublicKey\x12\x19\n" + + "\bshort_id\x18\x18 \x01(\fR\ashortId\x12%\n" + + "\x0emldsa65_verify\x18\x19 \x01(\fR\rmldsa65Verify\x12\x19\n" + + "\bspider_x\x18\x1a \x01(\tR\aspiderX\x12\x19\n" + + "\bspider_y\x18\x1b \x03(\x03R\aspiderY\x12$\n" + + "\x0emaster_key_log\x18\x1f \x01(\tR\fmasterKeyLog\"\x83\x01\n" + + "\rLimitFallback\x12\x1f\n" + + "\vafter_bytes\x18\x01 \x01(\x04R\n" + + "afterBytes\x12\"\n" + + "\rbytes_per_sec\x18\x02 \x01(\x04R\vbytesPerSec\x12-\n" + + "\x13burst_bytes_per_sec\x18\x03 \x01(\x04R\x10burstBytesPerSecB\x8f\x02\n" + + "#com.xray.transport.internet.realityB\vConfigProtoP\x01Z;github.com/mrcsin/usher/gen/xray/transport/internet/reality\xa2\x02\x04XTIR\xaa\x02\x1fXray.Transport.Internet.Reality\xca\x02\x1fXray\\Transport\\Internet\\Reality\xe2\x02+Xray\\Transport\\Internet\\Reality\\GPBMetadata\xea\x02\"Xray::Transport::Internet::Realityb\x06proto3" + +var ( + file_transport_internet_reality_config_proto_rawDescOnce sync.Once + file_transport_internet_reality_config_proto_rawDescData []byte +) + +func file_transport_internet_reality_config_proto_rawDescGZIP() []byte { + file_transport_internet_reality_config_proto_rawDescOnce.Do(func() { + file_transport_internet_reality_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_internet_reality_config_proto_rawDesc), len(file_transport_internet_reality_config_proto_rawDesc))) + }) + return file_transport_internet_reality_config_proto_rawDescData +} + +var file_transport_internet_reality_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_transport_internet_reality_config_proto_goTypes = []any{ + (*Config)(nil), // 0: xray.transport.internet.reality.Config + (*LimitFallback)(nil), // 1: xray.transport.internet.reality.LimitFallback +} +var file_transport_internet_reality_config_proto_depIdxs = []int32{ + 1, // 0: xray.transport.internet.reality.Config.limit_fallback_upload:type_name -> xray.transport.internet.reality.LimitFallback + 1, // 1: xray.transport.internet.reality.Config.limit_fallback_download:type_name -> xray.transport.internet.reality.LimitFallback + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_transport_internet_reality_config_proto_init() } +func file_transport_internet_reality_config_proto_init() { + if File_transport_internet_reality_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_reality_config_proto_rawDesc), len(file_transport_internet_reality_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_transport_internet_reality_config_proto_goTypes, + DependencyIndexes: file_transport_internet_reality_config_proto_depIdxs, + MessageInfos: file_transport_internet_reality_config_proto_msgTypes, + }.Build() + File_transport_internet_reality_config_proto = out.File + file_transport_internet_reality_config_proto_goTypes = nil + file_transport_internet_reality_config_proto_depIdxs = nil +} diff --git a/gen/xray/transport/internet/tcp/config.pb.go b/gen/xray/transport/internet/tcp/config.pb.go new file mode 100644 index 0000000..55006b0 --- /dev/null +++ b/gen/xray/transport/internet/tcp/config.pb.go @@ -0,0 +1,135 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: transport/internet/tcp/config.proto + +package tcp + +import ( + serial "github.com/mrcsin/usher/gen/xray/common/serial" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + HeaderSettings *serial.TypedMessage `protobuf:"bytes,2,opt,name=header_settings,json=headerSettings,proto3" json:"header_settings,omitempty"` + AcceptProxyProtocol bool `protobuf:"varint,3,opt,name=accept_proxy_protocol,json=acceptProxyProtocol,proto3" json:"accept_proxy_protocol,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_transport_internet_tcp_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_tcp_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_transport_internet_tcp_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetHeaderSettings() *serial.TypedMessage { + if x != nil { + return x.HeaderSettings + } + return nil +} + +func (x *Config) GetAcceptProxyProtocol() bool { + if x != nil { + return x.AcceptProxyProtocol + } + return false +} + +var File_transport_internet_tcp_config_proto protoreflect.FileDescriptor + +const file_transport_internet_tcp_config_proto_rawDesc = "" + + "\n" + + "#transport/internet/tcp/config.proto\x12\x1bxray.transport.internet.tcp\x1a!common/serial/typed_message.proto\"\x8d\x01\n" + + "\x06Config\x12I\n" + + "\x0fheader_settings\x18\x02 \x01(\v2 .xray.common.serial.TypedMessageR\x0eheaderSettings\x122\n" + + "\x15accept_proxy_protocol\x18\x03 \x01(\bR\x13acceptProxyProtocolJ\x04\b\x01\x10\x02B\xf7\x01\n" + + "\x1fcom.xray.transport.internet.tcpB\vConfigProtoP\x01Z7github.com/mrcsin/usher/gen/xray/transport/internet/tcp\xa2\x02\x04XTIT\xaa\x02\x1bXray.Transport.Internet.Tcp\xca\x02\x1bXray\\Transport\\Internet\\Tcp\xe2\x02'Xray\\Transport\\Internet\\Tcp\\GPBMetadata\xea\x02\x1eXray::Transport::Internet::Tcpb\x06proto3" + +var ( + file_transport_internet_tcp_config_proto_rawDescOnce sync.Once + file_transport_internet_tcp_config_proto_rawDescData []byte +) + +func file_transport_internet_tcp_config_proto_rawDescGZIP() []byte { + file_transport_internet_tcp_config_proto_rawDescOnce.Do(func() { + file_transport_internet_tcp_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_internet_tcp_config_proto_rawDesc), len(file_transport_internet_tcp_config_proto_rawDesc))) + }) + return file_transport_internet_tcp_config_proto_rawDescData +} + +var file_transport_internet_tcp_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_transport_internet_tcp_config_proto_goTypes = []any{ + (*Config)(nil), // 0: xray.transport.internet.tcp.Config + (*serial.TypedMessage)(nil), // 1: xray.common.serial.TypedMessage +} +var file_transport_internet_tcp_config_proto_depIdxs = []int32{ + 1, // 0: xray.transport.internet.tcp.Config.header_settings:type_name -> xray.common.serial.TypedMessage + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_transport_internet_tcp_config_proto_init() } +func file_transport_internet_tcp_config_proto_init() { + if File_transport_internet_tcp_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_tcp_config_proto_rawDesc), len(file_transport_internet_tcp_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_transport_internet_tcp_config_proto_goTypes, + DependencyIndexes: file_transport_internet_tcp_config_proto_depIdxs, + MessageInfos: file_transport_internet_tcp_config_proto_msgTypes, + }.Build() + File_transport_internet_tcp_config_proto = out.File + file_transport_internet_tcp_config_proto_goTypes = nil + file_transport_internet_tcp_config_proto_depIdxs = nil +} diff --git a/go.mod b/go.mod index 1ba21b3..c9d8218 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.1 require ( github.com/mrcsin/awg-grpc v0.1.0 google.golang.org/grpc v1.84.0 + google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,5 +14,4 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/proto/xray/README.md b/proto/xray/README.md new file mode 100644 index 0000000..3d8c5ef --- /dev/null +++ b/proto/xray/README.md @@ -0,0 +1,16 @@ +# Vendored Xray protos + +These files are copied unchanged from [XTLS/Xray-core](https://github.com/XTLS/Xray-core) at tag +`v26.9.9`, with their original relative paths. They are licensed under MPL-2.0; the license text is +at . + +usher imports no xray-core Go code. `buf generate` turns these files into `gen/xray`, with the Go +import prefix `github.com/mrcsin/usher/gen/xray`. + +## Refresh + +1. Pick the new tag and set it in this file. +2. Fetch each file listed by `find proto/xray -name '*.proto'` from + `https://raw.githubusercontent.com/XTLS/Xray-core//`. Add any new `import` target + outside `google/`; drop files that no import reaches. +3. Run `go tool -modfile=tools/go.mod buf generate` and commit `gen/`. diff --git a/proto/xray/app/proxyman/command/command.proto b/proto/xray/app/proxyman/command/command.proto new file mode 100644 index 0000000..71f8f0d --- /dev/null +++ b/proto/xray/app/proxyman/command/command.proto @@ -0,0 +1,108 @@ +syntax = "proto3"; + +package xray.app.proxyman.command; +option csharp_namespace = "Xray.App.Proxyman.Command"; +option go_package = "github.com/xtls/xray-core/app/proxyman/command"; +option java_package = "com.xray.app.proxyman.command"; +option java_multiple_files = true; + +import "common/protocol/user.proto"; +import "common/serial/typed_message.proto"; +import "core/config.proto"; + +message AddUserOperation { + xray.common.protocol.User user = 1; +} + +message RemoveUserOperation { + string email = 1; +} + +message AddInboundRequest { + core.InboundHandlerConfig inbound = 1; +} + +message AddInboundResponse {} + +message RemoveInboundRequest { + string tag = 1; +} + +message RemoveInboundResponse {} + +message AlterInboundRequest { + string tag = 1; + xray.common.serial.TypedMessage operation = 2; +} + +message AlterInboundResponse {} + +message ListInboundsRequest { + bool isOnlyTags = 1; +} + +message ListInboundsResponse { + repeated core.InboundHandlerConfig inbounds = 1; +} + +message GetInboundUserRequest { + string tag = 1; + string email = 2; +} + +message GetInboundUserResponse { + repeated xray.common.protocol.User users = 1; +} + +message GetInboundUsersCountResponse { + int64 count = 1; +} + +message AddOutboundRequest { + core.OutboundHandlerConfig outbound = 1; +} + +message AddOutboundResponse {} + +message RemoveOutboundRequest { + string tag = 1; +} + +message RemoveOutboundResponse {} + +message AlterOutboundRequest { + string tag = 1; + xray.common.serial.TypedMessage operation = 2; +} + +message AlterOutboundResponse {} + +message ListOutboundsRequest {} + +message ListOutboundsResponse { + repeated core.OutboundHandlerConfig outbounds = 1; +} + +service HandlerService { + rpc AddInbound(AddInboundRequest) returns (AddInboundResponse) {} + + rpc RemoveInbound(RemoveInboundRequest) returns (RemoveInboundResponse) {} + + rpc AlterInbound(AlterInboundRequest) returns (AlterInboundResponse) {} + + rpc ListInbounds(ListInboundsRequest) returns (ListInboundsResponse) {} + + rpc GetInboundUsers(GetInboundUserRequest) returns (GetInboundUserResponse) {} + + rpc GetInboundUsersCount(GetInboundUserRequest) returns (GetInboundUsersCountResponse) {} + + rpc AddOutbound(AddOutboundRequest) returns (AddOutboundResponse) {} + + rpc RemoveOutbound(RemoveOutboundRequest) returns (RemoveOutboundResponse) {} + + rpc AlterOutbound(AlterOutboundRequest) returns (AlterOutboundResponse) {} + + rpc ListOutbounds(ListOutboundsRequest) returns (ListOutboundsResponse) {} +} + +message Config {} diff --git a/proto/xray/app/proxyman/config.proto b/proto/xray/app/proxyman/config.proto new file mode 100644 index 0000000..7a03871 --- /dev/null +++ b/proto/xray/app/proxyman/config.proto @@ -0,0 +1,75 @@ +syntax = "proto3"; + +package xray.app.proxyman; +option csharp_namespace = "Xray.App.Proxyman"; +option go_package = "github.com/xtls/xray-core/app/proxyman"; +option java_package = "com.xray.app.proxyman"; +option java_multiple_files = true; + +import "common/net/address.proto"; +import "common/net/port.proto"; +import "transport/internet/config.proto"; +import "common/serial/typed_message.proto"; +import "common/geodata/geodat.proto"; + +message InboundConfig {} + +message SniffingConfig { + // Whether or not to enable content sniffing on an inbound connection. + bool enabled = 1; + + // Override target destination if sniff'ed protocol is in the given list. + // Supported values are "http", "tls", "fakedns". + repeated string destination_override = 2; + + repeated xray.common.geodata.DomainRule domains_excluded = 3; + + repeated xray.common.geodata.IPRule ips_excluded = 6; + + // Whether should only try to sniff metadata without waiting for client input. + // Can be used to support SMTP like protocol where server send the first + // message. + bool metadata_only = 4; + + bool route_only = 5; +} + +message ReceiverConfig { + // PortList specifies the ports which the Receiver should listen on. + xray.common.net.PortList port_list = 1; + // Listen specifies the IP address that the Receiver should listen on. + xray.common.net.IPOrDomain listen = 2; + xray.transport.internet.StreamConfig stream_settings = 3; + bool receive_original_destination = 4; + reserved 5; + SniffingConfig sniffing_settings = 6; +} + +message InboundHandlerConfig { + string tag = 1; + xray.common.serial.TypedMessage receiver_settings = 2; + xray.common.serial.TypedMessage proxy_settings = 3; +} + +message OutboundConfig {} + +message SenderConfig { + // Send traffic through the given IP. Only IP is allowed. + xray.common.net.IPOrDomain via = 1; + xray.transport.internet.StreamConfig stream_settings = 2; + reserved 3; + MultiplexingConfig multiplex_settings = 4; + string via_cidr = 5; + xray.transport.internet.DomainStrategy target_strategy = 6; +} + +message MultiplexingConfig { + // Whether or not Mux is enabled. + bool enabled = 1; + // Max number of concurrent connections that one Mux connection can handle. + int32 concurrency = 2; + // Transport XUDP in another Mux. + int32 xudpConcurrency = 3; + // "reject" (default), "allow" or "skip". + string xudpProxyUDP443 = 4; +} diff --git a/proto/xray/common/geodata/geodat.proto b/proto/xray/common/geodata/geodat.proto new file mode 100644 index 0000000..be91853 --- /dev/null +++ b/proto/xray/common/geodata/geodat.proto @@ -0,0 +1,95 @@ +syntax = "proto3"; + +package xray.common.geodata; +option csharp_namespace = "Xray.Common.Geodata"; +option go_package = "github.com/xtls/xray-core/common/geodata"; +option java_package = "com.xray.common.geodata"; +option java_multiple_files = true; + +message Domain { + // Type of domain value. + enum Type { + // The value is used as a sub string. + Substr = 0; + // The value is used as a regular expression. + Regex = 1; + // The value is a domain. + Domain = 2; + // The value is a full domain. + Full = 3; + } + // Domain matching type. + Type type = 1; + + // Domain value. + string value = 2; + + message Attribute { + string key = 1; + + oneof typed_value { + bool bool_value = 2; + int64 int_value = 3; + } + } + // Attributes of this domain. May be used for filtering. + repeated Attribute attribute = 3; +} + +message GeoSite { + string code = 1; + repeated Domain domain = 2; +} + +message GeoSiteList { + repeated GeoSite entry = 1; +} + +message GeoSiteRule { + string file = 1; + string code = 2; + string attrs = 3; +} + +message DomainRule { + oneof value { + GeoSiteRule geosite = 1; + Domain custom = 2; + } +} + +message CIDR { + // IP address, should be either 4 or 16 bytes. + bytes ip = 1; + + // Number of leading ones in the network mask. + uint32 prefix = 2; +} + +message CIDRRule { + CIDR cidr = 1; + bool reverse_match = 2; +} + +message GeoIP { + string code = 1; + repeated CIDR cidr = 2; + bool reverse_match = 3; +} + +message GeoIPList { + repeated GeoIP entry = 1; +} + +message GeoIPRule { + string file = 1; + string code = 2; + bool reverse_match = 3; +} + +message IPRule { + oneof value { + GeoIPRule geoip = 1; + CIDRRule custom = 2; + } +} diff --git a/proto/xray/common/net/address.proto b/proto/xray/common/net/address.proto new file mode 100644 index 0000000..d83f0df --- /dev/null +++ b/proto/xray/common/net/address.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package xray.common.net; +option csharp_namespace = "Xray.Common.Net"; +option go_package = "github.com/xtls/xray-core/common/net"; +option java_package = "com.xray.common.net"; +option java_multiple_files = true; + +// Address of a network host. It may be either an IP address or a domain +// address. +message IPOrDomain { + oneof address { + // IP address. Must by either 4 or 16 bytes. + bytes ip = 1; + + // Domain address. + string domain = 2; + } +} diff --git a/proto/xray/common/net/port.proto b/proto/xray/common/net/port.proto new file mode 100644 index 0000000..0b626f9 --- /dev/null +++ b/proto/xray/common/net/port.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package xray.common.net; +option csharp_namespace = "Xray.Common.Net"; +option go_package = "github.com/xtls/xray-core/common/net"; +option java_package = "com.xray.common.net"; +option java_multiple_files = true; + +// PortRange represents a range of ports. +message PortRange { + // The port that this range starts from. + uint32 From = 1; + // The port that this range ends with (inclusive). + uint32 To = 2; +} + +// PortList is a list of ports. +message PortList { + repeated PortRange range = 1; +} diff --git a/proto/xray/common/protocol/user.proto b/proto/xray/common/protocol/user.proto new file mode 100644 index 0000000..14cf995 --- /dev/null +++ b/proto/xray/common/protocol/user.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package xray.common.protocol; +option csharp_namespace = "Xray.Common.Protocol"; +option go_package = "github.com/xtls/xray-core/common/protocol"; +option java_package = "com.xray.common.protocol"; +option java_multiple_files = true; + +import "common/serial/typed_message.proto"; + +// User is a generic user for all protocols. +message User { + uint32 level = 1; + string email = 2; + + // Protocol specific account information. Must be the account proto in one of + // the proxies. + xray.common.serial.TypedMessage account = 3; +} diff --git a/proto/xray/common/serial/typed_message.proto b/proto/xray/common/serial/typed_message.proto new file mode 100644 index 0000000..558de64 --- /dev/null +++ b/proto/xray/common/serial/typed_message.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package xray.common.serial; +option csharp_namespace = "Xray.Common.Serial"; +option go_package = "github.com/xtls/xray-core/common/serial"; +option java_package = "com.xray.common.serial"; +option java_multiple_files = true; + +// TypedMessage is a serialized proto message along with its type name. +message TypedMessage { + // The name of the message type, retrieved from protobuf API. + string type = 1; + // Serialized proto message. + bytes value = 2; +} diff --git a/proto/xray/core/config.proto b/proto/xray/core/config.proto new file mode 100644 index 0000000..32595d6 --- /dev/null +++ b/proto/xray/core/config.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package xray.core; +option csharp_namespace = "Xray.Core"; +option go_package = "github.com/xtls/xray-core/core"; +option java_package = "com.xray.core"; +option java_multiple_files = true; + +import "common/serial/typed_message.proto"; + +// Config is the master config of Xray. Xray takes this config as input and +// functions accordingly. +message Config { + // Inbound handler configurations. Must have at least one item. + repeated InboundHandlerConfig inbound = 1; + + // Outbound handler configurations. Must have at least one item. The first + // item is used as default for routing. + repeated OutboundHandlerConfig outbound = 2; + + reserved 3; + + // App is for configurations of all features in Xray. A feature must + // implement the Feature interface, and its config type must be registered + // through common.RegisterConfig. + repeated xray.common.serial.TypedMessage app = 4; + + // Configuration for extensions. The config may not work if corresponding + // extension is not loaded into Xray. Xray will ignore such config during + // initialization. + repeated xray.common.serial.TypedMessage extension = 6; +} + +// InboundHandlerConfig is the configuration for inbound handler. +message InboundHandlerConfig { + // Tag of the inbound handler. The tag must be unique among all inbound + // handlers + string tag = 1; + // Settings for how this inbound proxy is handled. + xray.common.serial.TypedMessage receiver_settings = 2; + // Settings for inbound proxy. Must be one of the inbound proxies. + xray.common.serial.TypedMessage proxy_settings = 3; +} + +// OutboundHandlerConfig is the configuration for outbound handler. +message OutboundHandlerConfig { + // Tag of this outbound handler. + string tag = 1; + // Settings for how to dial connection for this outbound handler. + xray.common.serial.TypedMessage sender_settings = 2; + // Settings for this outbound proxy. Must be one of the outbound proxies. + xray.common.serial.TypedMessage proxy_settings = 3; + // If not zero, this outbound will be expired in seconds. Not used for now. + int64 expire = 4; + // Comment of this outbound handler. Not used for now. + string comment = 5; +} diff --git a/proto/xray/proxy/vless/account.proto b/proto/xray/proxy/vless/account.proto new file mode 100644 index 0000000..296ce9b --- /dev/null +++ b/proto/xray/proxy/vless/account.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +package xray.proxy.vless; +option csharp_namespace = "Xray.Proxy.Vless"; +option go_package = "github.com/xtls/xray-core/proxy/vless"; +option java_package = "com.xray.proxy.vless"; +option java_multiple_files = true; + +import "app/proxyman/config.proto"; + +message Reverse { + string tag = 1; + xray.app.proxyman.SniffingConfig sniffing = 2; +} + +message Account { + // ID of the account, in the form of a UUID, e.g., "66ad4540-b58c-4ad2-9926-ea63445a9b57". + string id = 1; + // Flow settings. May be "xtls-rprx-vision". + string flow = 2; + + string encryption = 3; + uint32 xorMode = 4; + uint32 seconds = 5; + string padding = 6; + + Reverse reverse = 7; + + uint32 testpre = 8; + repeated uint32 testseed = 9; +} diff --git a/proto/xray/proxy/vless/inbound/config.proto b/proto/xray/proxy/vless/inbound/config.proto new file mode 100644 index 0000000..a0e72b5 --- /dev/null +++ b/proto/xray/proxy/vless/inbound/config.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package xray.proxy.vless.inbound; +option csharp_namespace = "Xray.Proxy.Vless.Inbound"; +option go_package = "github.com/xtls/xray-core/proxy/vless/inbound"; +option java_package = "com.xray.proxy.vless.inbound"; +option java_multiple_files = true; + +import "common/protocol/user.proto"; + +message Fallback { + string name = 1; + string alpn = 2; + string path = 3; + string type = 4; + string dest = 5; + uint64 xver = 6; +} + +message Config { + repeated xray.common.protocol.User users = 1; + repeated Fallback fallbacks = 2; + + string decryption = 3; + uint32 xorMode = 4; + int64 seconds_from = 5; + int64 seconds_to = 6; + string padding = 7; +} diff --git a/proto/xray/transport/internet/config.proto b/proto/xray/transport/internet/config.proto new file mode 100644 index 0000000..b459887 --- /dev/null +++ b/proto/xray/transport/internet/config.proto @@ -0,0 +1,159 @@ +syntax = "proto3"; + +package xray.transport.internet; +option csharp_namespace = "Xray.Transport.Internet"; +option go_package = "github.com/xtls/xray-core/transport/internet"; +option java_package = "com.xray.transport.internet"; +option java_multiple_files = true; + +import "common/serial/typed_message.proto"; +import "common/net/address.proto"; + +enum DomainStrategy { + AS_IS = 0; + USE_IP = 1; + USE_IP4 = 2; + USE_IP6 = 3; + USE_IP46 = 4; + USE_IP64 = 5; + FORCE_IP = 6; + FORCE_IP4 = 7; + FORCE_IP6 = 8; + FORCE_IP46 = 9; + FORCE_IP64 = 10; +} + +enum AddressPortStrategy { + None = 0; + SrvPortOnly = 1; + SrvAddressOnly = 2; + SrvPortAndAddress = 3; + TxtPortOnly = 4; + TxtAddressOnly = 5; + TxtPortAndAddress = 6; +} + +message TransportConfig { + // Transport protocol name. + string protocol_name = 3; + + // Specific transport protocol settings. + xray.common.serial.TypedMessage settings = 2; +} + +message StreamConfig { + xray.common.net.IPOrDomain address = 8; + uint32 port = 9; + + // Effective network. + string protocol_name = 5; + + repeated TransportConfig transport_settings = 2; + + // Type of security. Must be a message name of the settings proto. + string security_type = 3; + + // Transport security settings. They can be either TLS or REALITY. + repeated xray.common.serial.TypedMessage security_settings = 4; + + repeated xray.common.serial.TypedMessage udpmasks = 10; + repeated xray.common.serial.TypedMessage tcpmasks = 11; + + QuicParams quic_params = 12; + + SocketConfig socket_settings = 6; +} + +message QuicParams { + string congestion = 1; + string bbr_profile = 2; + uint64 brutal_up = 3; + uint64 brutal_down = 4; + bool brutal_disable_loss_compensation = 5; + uint64 init_stream_receive_window = 6; + uint64 max_stream_receive_window = 7; + uint64 init_conn_receive_window = 8; + uint64 max_conn_receive_window = 9; + int64 max_idle_timeout = 10; + int64 keep_alive_period = 11; + bool disable_path_mtu_discovery = 12; + bool disable_chrome_parrot = 13; + bool disableGSO = 14; + int64 max_incoming_streams = 15; + bool disable_stateless_reset = 16; +} + +message CustomSockopt { + string system = 1; + string network = 2; + string level = 3; + string opt = 4; + string value = 5; + string type = 6; +} + +// SocketConfig is options to be applied on network sockets. +message SocketConfig { + // Mark of the connection. If non-zero, the value will be set to SO_MARK. + int32 mark = 1; + + // TFO is the state of TFO settings. + int32 tfo = 2; + + enum TProxyMode { + // TProxy is off. + Off = 0; + // TProxy mode. + TProxy = 1; + // Redirect mode. + Redirect = 2; + } + + // TProxy is for enabling TProxy socket option. + TProxyMode tproxy = 3; + + // ReceiveOriginalDestAddress is for enabling IP_RECVORIGDSTADDR socket + // option. This option is for UDP only. + bool receive_original_dest_address = 4; + + bool accept_proxy_protocol = 7; + + DomainStrategy domain_strategy = 8; + + string dialer_proxy = 9; + + int32 tcp_keep_alive_interval = 10; + + int32 tcp_keep_alive_idle = 11; + + string tcp_congestion = 12; + + string interface = 13; + + bool v6only = 14; + + int32 tcp_window_clamp = 15; + + int32 tcp_user_timeout = 16; + + int32 tcp_max_seg = 17; + + bool penetrate = 18; + + bool tcp_mptcp = 19; + + repeated CustomSockopt customSockopt = 20; + + AddressPortStrategy address_port_strategy = 21; + + HappyEyeballsConfig happy_eyeballs = 22; + + repeated string trusted_x_forwarded_for = 23; +} + +message HappyEyeballsConfig { + bool prioritize_ipv6 = 1; + uint32 interleave = 2; + uint64 try_delayMs = 3; + uint32 max_concurrent_try = 4; +} diff --git a/proto/xray/transport/internet/reality/config.proto b/proto/xray/transport/internet/reality/config.proto new file mode 100644 index 0000000..7da4362 --- /dev/null +++ b/proto/xray/transport/internet/reality/config.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package xray.transport.internet.reality; +option csharp_namespace = "Xray.Transport.Internet.Reality"; +option go_package = "github.com/xtls/xray-core/transport/internet/reality"; +option java_package = "com.xray.transport.internet.reality"; +option java_multiple_files = true; + +message Config { + bool show = 1; + string dest = 2; + string type = 3; + uint64 xver = 4; + repeated string server_names = 5; + bytes private_key = 6; + bytes min_client_ver = 7; + bytes max_client_ver = 8; + uint64 max_time_diff = 9; + repeated bytes short_ids = 10; + + bytes mldsa65_seed = 11; + LimitFallback limit_fallback_upload = 12; + LimitFallback limit_fallback_download = 13; + + string Fingerprint = 21; + string server_name = 22; + bytes public_key = 23; + bytes short_id = 24; + bytes mldsa65_verify = 25; + string spider_x = 26; + repeated int64 spider_y = 27; + + string master_key_log = 31; +} + +message LimitFallback { + uint64 after_bytes = 1; + uint64 bytes_per_sec = 2; + uint64 burst_bytes_per_sec = 3; +} diff --git a/proto/xray/transport/internet/tcp/config.proto b/proto/xray/transport/internet/tcp/config.proto new file mode 100644 index 0000000..a12acdf --- /dev/null +++ b/proto/xray/transport/internet/tcp/config.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package xray.transport.internet.tcp; +option csharp_namespace = "Xray.Transport.Internet.Tcp"; +option go_package = "github.com/xtls/xray-core/transport/internet/tcp"; +option java_package = "com.xray.transport.internet.tcp"; +option java_multiple_files = true; + +import "common/serial/typed_message.proto"; + +message Config { + reserved 1; + xray.common.serial.TypedMessage header_settings = 2; + bool accept_proxy_protocol = 3; +} diff --git a/scripts/format.sh b/scripts/format.sh index f12b1df..3d507fb 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -16,3 +16,12 @@ fi for tags in '' e2e; do go vet -tags "$tags" ./... done + +# The vendored protos in proto/xray are not usher's own, so buf lint is not run on them. +generated=$(mktemp -d) +trap 'rm -rf "$generated"' EXIT +go tool -modfile=tools/go.mod buf generate --output "$generated" +if ! diff -r gen/xray "$generated/gen/xray"; then + echo "format.sh: gen/xray differs from buf generate" >&2 + exit 1 +fi diff --git a/tools/go.mod b/tools/go.mod new file mode 100644 index 0000000..2249d17 --- /dev/null +++ b/tools/go.mod @@ -0,0 +1,103 @@ +module github.com/mrcsin/usher/tools + +go 1.27.1 + +tool ( + github.com/bufbuild/buf/cmd/buf + google.golang.org/grpc/cmd/protoc-gen-go-grpc + google.golang.org/protobuf/cmd/protoc-gen-go +) + +require ( + buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 // indirect + buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260831211851-b4432e12a6e7.1 // indirect + buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 // indirect + buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect + buf.build/go/app v0.2.1-0.20260824172350-b0e76892c61a // indirect + buf.build/go/bufplugin v0.10.0 // indirect + buf.build/go/bufprivateusage v0.1.0 // indirect + buf.build/go/interrupt v1.1.0 // indirect + buf.build/go/protovalidate v1.4.0 // indirect + buf.build/go/protoyaml v0.7.0 // indirect + buf.build/go/spdx v0.2.0 // indirect + buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + connectrpc.com/connect v1.20.0 // indirect + connectrpc.com/otelconnect v0.9.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/bufbuild/buf v1.73.0 // indirect + github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect + github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli v29.8.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.9 // indirect + github.com/docker/go-connections v0.8.1 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.13.1 // indirect + github.com/google/go-containerregistry v0.22.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jdx/go-netrc v1.0.0 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.56.0 // indirect + github.com/moby/moby/client v0.6.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.62.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect + github.com/tidwall/btree v1.8.1 // indirect + go.lsp.dev/jsonrpc2 v0.10.0 // indirect + go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect + go.lsp.dev/protocol v0.12.0 // indirect + go.lsp.dev/uri v0.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + mvdan.cc/xurls/v2 v2.6.0 // indirect + pluginrpc.com/pluginrpc v0.5.0 // indirect +) diff --git a/tools/go.sum b/tools/go.sum new file mode 100644 index 0000000..7f8d848 --- /dev/null +++ b/tools/go.sum @@ -0,0 +1,231 @@ +buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 h1:z5+rVknD0AvorY/QV9cb3ohUXw4E7HvxU4+2JUH6EHU= +buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2/go.mod h1:Owcuj2phVyvThVYCD74iSV8ki0dAXw/uxkdcdZScdCc= +buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 h1:tE4A2RVukQOSd4K+6ndK+8s65FbjiBt2k5CiddXxCCo= +buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2/go.mod h1:Gjxz0o7qPHokNsmlX8D+/aKd29NRLRr9CCUkPeMAzJ0= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260831211851-b4432e12a6e7.1 h1:BpToyjv+oz0qldatN/ZFTL+vVtPVavFCY+g4rRu03bs= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260831211851-b4432e12a6e7.1/go.mod h1:hMqlU8iWXqCU7UOBs8PTDG/z7BFnhIknJZ4XMWcBx3E= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 h1:cik2XvKM1UV8HgLWD2vnBvV4GgT5NfpLJBb9UgLKzzg= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2/go.mod h1:91H8i3iR9GoomNkM9X13RTFL8cVNAHnS44BdgAnbqz4= +buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 h1:9xw8g8Q6zHLC7CO/jtpl2UK0kQticxIG+mI6N86f2/M= +buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2/go.mod h1:+0bw+k8FY5MU3tojFh33HeA5xMfGWjAUQpr+maW5Z3o= +buf.build/go/app v0.2.1-0.20260824172350-b0e76892c61a h1:m3hkeJOeLSq/1yTSMLyV5ipY6u3XrfS3gV8ZIqkUJJA= +buf.build/go/app v0.2.1-0.20260824172350-b0e76892c61a/go.mod h1:w19H+Lh2cAV/uL1FhcdDlyoxLTm8SkQAHW3CfmXZbFU= +buf.build/go/bufplugin v0.10.0 h1:vZBX0mq9as5UIBug8U+/DkGRaHNlM/HVOw59O8fvOIU= +buf.build/go/bufplugin v0.10.0/go.mod h1:ax7obVurKDH1I2nR4pFTS+TE6K3kZhTmwDCN2YgdV8I= +buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4= +buf.build/go/bufprivateusage v0.1.0/go.mod h1:GlCCJ3VVF7EqqU0CoRmo1FzAwwaKymEWSr+ty69xU5w= +buf.build/go/interrupt v1.1.0 h1:olBuhgv9Sav4/9pkSLoxgiOsZDgM5VhRhvRpn3DL0lE= +buf.build/go/interrupt v1.1.0/go.mod h1:ql56nXPG1oHlvZa6efNC7SKAQ/tUjS6z0mhJl0gyeRM= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= +buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= +buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= +buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= +buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= +buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 h1:njYKSWoLiq2i5O7y2bPPU2Yzp7iAU0Wk9KJ2OoAhNiU= +buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5/go.mod h1:DQmodNT9EHX94WzUaWiZK+/4EaFa/xZTc1gzfCxZVXU= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= +connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= +connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bufbuild/buf v1.73.0 h1:fDNKlxaf+PYi20dmVM7Yt3kOfXEhwrgKDLMNZFjztDs= +github.com/bufbuild/buf v1.73.0/go.mod h1:rfWpi8FmfTPW4CbLFIXZYAux2FWBiCMY6BUOlGqnR84= +github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 h1:jBaIQ1eixwqtjBKJk8gw+dKtmbkp3w8sLJtJ7kav1EE= +github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201/go.mod h1:bX3ObJfML+aki7PJevkWLSlOfEZ8EnNsfCz0hiYkhiI= +github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ= +github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.8.0+incompatible h1:ih0c2jq/nN7QfES8zIfwSzIhRScjs4ehER+kZ60aeSk= +github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.9 h1:BkydjIgZ46JnDbqyM2p2fc63KMw6y+KHL3Em/2AGJ7w= +github.com/docker/docker-credential-helpers v0.9.9/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= +github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84= +github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= +github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= +github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88= +github.com/jhump/protoreflect/v2 v2.0.0-beta.2/go.mod h1:4tnOYkB/mq7QTyS3YKtVtNrJv4Psqout8HA1U+hZtgM= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= +github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4= +github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9/go.mod h1:SKZx6stCn03JN3BOWTwvVIO2ajMkb/zQdTceXYhKw/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= +github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= +go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= +go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= +go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE= +go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2/go.mod h1:gtSHRuYfbCT0qnbLnovpie/WEmqyJ7T4n6VXiFMBtcw= +go.lsp.dev/protocol v0.12.0 h1:tNprUI9klQW5FAFVM4Sa+AbPFuVQByWhP1ttNUAjIWg= +go.lsp.dev/protocol v0.12.0/go.mod h1:Qb11/HgZQ72qQbeyPfJbu3hZBH23s1sr4st8czGeDMQ= +go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= +go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= +golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 h1:NCe/UiklGd/9xjT+ROBVhJ1kf6TRQaFedsR+z7u1gvo= +google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4/go.mod h1:fJ2lYaWjqNknJyQBOCd0fA3HnEElJqGplH71a2txi+g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= +mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo= +pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= From 48c47c324fea7984515d0025c58e49570c9ef23f Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:36:06 +0300 Subject: [PATCH 06/29] feat(xray): read VLESS inbounds via decoder tables --- docs/plans/20261007-xray-driver.md | 16 +-- internal/xray/inbound.go | 101 ++++++++++++++++ internal/xray/inbound_test.go | 186 +++++++++++++++++++++++++++++ internal/xray/protocol.go | 65 ++++++++++ internal/xray/protocol_test.go | 70 +++++++++++ internal/xray/security.go | 63 ++++++++++ internal/xray/security_test.go | 117 ++++++++++++++++++ internal/xray/transport.go | 45 +++++++ internal/xray/transport_test.go | 51 ++++++++ 9 files changed, 706 insertions(+), 8 deletions(-) create mode 100644 internal/xray/inbound.go create mode 100644 internal/xray/inbound_test.go create mode 100644 internal/xray/protocol.go create mode 100644 internal/xray/protocol_test.go create mode 100644 internal/xray/security.go create mode 100644 internal/xray/security_test.go create mode 100644 internal/xray/transport.go create mode 100644 internal/xray/transport_test.go diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index d3bb6d9..5f596c7 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -395,22 +395,22 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Create: `internal/xray/inbound_test.go`, `internal/xray/protocol_test.go`, `internal/xray/transport_test.go`, `internal/xray/security_test.go` -- [ ] add the three decoder tables of Technical Details with one entry each: VLESS, `tcp`, +- [x] add the three decoder tables of Technical Details with one entry each: VLESS, `tcp`, Reality -- [ ] list inbounds, keep those whose proxy settings type has a protocol entry, decode the single +- [x] list inbounds, keep those whose proxy settings type has a protocol entry, decode the single port, and run the transport and security entries; a missing key or an unsupported value fails that inbound with an error naming it -- [ ] write tests for the VLESS entry: decryption `none` accepted, other decryption failing, flow +- [x] write tests for the VLESS entry: decryption `none` accepted, other decryption failing, flow `xtls-rprx-vision` for `tcp` and Reality -- [ ] write tests for the `tcp` entry: no transport settings, `tcp.Config` without a header and a +- [x] write tests for the `tcp` entry: no transport settings, `tcp.Config` without a header and a no-op header accepted; an HTTP header failing -- [ ] write tests for the Reality entry: `pbk` from a fixed private key; `sid` from `ab00000000000000` +- [x] write tests for the Reality entry: `pbk` from a fixed private key; `sid` from `ab00000000000000` as `ab` and from 8 zero bytes as empty; the first server name empty and the second used; all server names empty failing -- [ ] write tests for inbound reading: a valid inbound; a non-VLESS inbound skipped; a port range, +- [x] write tests for inbound reading: a valid inbound; a non-VLESS inbound skipped; a port range, TLS security, empty security (named `none`) and a non-tcp transport each failing -- [ ] write a leak test: no error or log line contains the private key bytes in any encoding -- [ ] run `go test -race ./...` - must pass before Task 6 +- [x] write a leak test: no error or log line contains the private key bytes in any encoding +- [x] run `go test -race ./...` - must pass before Task 6 ### Task 6: Reconcile Xray users diff --git a/internal/xray/inbound.go b/internal/xray/inbound.go new file mode 100644 index 0000000..7373e6c --- /dev/null +++ b/internal/xray/inbound.go @@ -0,0 +1,101 @@ +package xray + +import ( + "context" + "errors" + "fmt" + "math" + "net/url" + + "google.golang.org/protobuf/proto" + + "github.com/mrcsin/usher/gen/xray/app/proxyman" + "github.com/mrcsin/usher/gen/xray/app/proxyman/command" + "github.com/mrcsin/usher/gen/xray/core" +) + +// inbound is a VLESS inbound as the running Xray holds it. A failed inbound has err set and no +// other field but tag. +type inbound struct { + tag string + port uint16 + protocol protocol + // params are the link parameters of the transport and the security. + params url.Values + err error +} + +// listInbounds returns every inbound whose proxy settings type has a protocol entry. Other +// inbounds, such as the api tunnel, are not usher interfaces and are left out. +func listInbounds(ctx context.Context, client command.HandlerServiceClient) ([]inbound, error) { + response, err := client.ListInbounds(ctx, &command.ListInboundsRequest{}) + if err != nil { + return nil, fmt.Errorf("listing inbounds: %w", err) + } + var inbounds []inbound + for _, config := range response.GetInbounds() { + decodeProtocol, ok := protocols[config.GetProxySettings().GetType()] + if !ok { + continue + } + in, err := decodeInbound(config, decodeProtocol) + if err != nil { + in = inbound{tag: config.GetTag(), err: err} + } + inbounds = append(inbounds, in) + } + return inbounds, nil +} + +func decodeInbound(config *core.InboundHandlerConfig, decodeProtocol func([]byte, string, string) (protocol, error)) (inbound, error) { + receiverSettings := config.GetReceiverSettings() + if receiverSettings.GetType() != typeName(&proxyman.ReceiverConfig{}) { + return inbound{}, fmt.Errorf("receiver settings have type %q", receiverSettings.GetType()) + } + var receiver proxyman.ReceiverConfig + if err := proto.Unmarshal(receiverSettings.GetValue(), &receiver); err != nil { + return inbound{}, fmt.Errorf("decoding receiver settings: %w", err) + } + port, err := singlePort(&receiver) + if err != nil { + return inbound{}, err + } + stream := receiver.GetStreamSettings() + transportName := stream.GetProtocolName() + decodeTransport, ok := transports[transportName] + if !ok { + return inbound{}, fmt.Errorf("transport %q is not supported", transportName) + } + params, err := decodeTransport(stream) + if err != nil { + return inbound{}, err + } + securityName := stream.GetSecurityType() + decodeSecurity, ok := securities[securityName] + if !ok { + if securityName == "" { + securityName = securityNone + } + return inbound{}, fmt.Errorf("security %q is not supported", securityName) + } + securityParams, err := decodeSecurity(stream.GetSecuritySettings()) + if err != nil { + return inbound{}, err + } + for key, values := range securityParams { + params[key] = values + } + decoded, err := decodeProtocol(config.GetProxySettings().GetValue(), transportName, securityName) + if err != nil { + return inbound{}, err + } + return inbound{tag: config.GetTag(), port: port, protocol: decoded, params: params}, nil +} + +func singlePort(receiver *proxyman.ReceiverConfig) (uint16, error) { + ranges := receiver.GetPortList().GetRange() + if len(ranges) != 1 || ranges[0].GetFrom() != ranges[0].GetTo() || ranges[0].GetFrom() == 0 || ranges[0].GetFrom() > math.MaxUint16 { + return 0, errors.New("port must be a single number") + } + return uint16(ranges[0].GetFrom()), nil +} diff --git a/internal/xray/inbound_test.go b/internal/xray/inbound_test.go new file mode 100644 index 0000000..4a67499 --- /dev/null +++ b/internal/xray/inbound_test.go @@ -0,0 +1,186 @@ +package xray + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "testing" + + "google.golang.org/grpc" + + "github.com/mrcsin/usher/gen/xray/app/proxyman" + "github.com/mrcsin/usher/gen/xray/app/proxyman/command" + "github.com/mrcsin/usher/gen/xray/common/net" + "github.com/mrcsin/usher/gen/xray/common/serial" + "github.com/mrcsin/usher/gen/xray/core" + vlessinbound "github.com/mrcsin/usher/gen/xray/proxy/vless/inbound" + "github.com/mrcsin/usher/gen/xray/transport/internet" +) + +// fakeHandlerService implements command.HandlerServiceClient with func fields. +type fakeHandlerService struct { + command.HandlerServiceClient + + inbounds func() (*command.ListInboundsResponse, error) +} + +func (f *fakeHandlerService) ListInbounds(context.Context, *command.ListInboundsRequest, ...grpc.CallOption) (*command.ListInboundsResponse, error) { + return f.inbounds() +} + +func portList(from, to uint32) *net.PortList { + return &net.PortList{Range: []*net.PortRange{{From: from, To: to}}} +} + +func realityStream(t *testing.T) *internet.StreamConfig { + t.Helper() + return &internet.StreamConfig{ + ProtocolName: "tcp", + SecurityType: "reality", + SecuritySettings: realitySettings(t, validReality(t)), + } +} + +func inboundConfig(tag string, ports *net.PortList, stream *internet.StreamConfig) *core.InboundHandlerConfig { + return &core.InboundHandlerConfig{ + Tag: tag, + ReceiverSettings: toTypedMessage(&proxyman.ReceiverConfig{PortList: ports, StreamSettings: stream}), + ProxySettings: toTypedMessage(&vlessinbound.Config{Decryption: "none"}), + } +} + +func listOf(configs ...*core.InboundHandlerConfig) command.HandlerServiceClient { + return &fakeHandlerService{inbounds: func() (*command.ListInboundsResponse, error) { + return &command.ListInboundsResponse{Inbounds: configs}, nil + }} +} + +func TestListInbounds(t *testing.T) { + t.Run("valid inbound", func(t *testing.T) { + client := listOf(inboundConfig("vless-reality", portList(443, 443), realityStream(t))) + got, err := listInbounds(t.Context(), client) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].err != nil { + t.Fatalf("inbounds = %+v", got) + } + in := got[0] + if in.tag != "vless-reality" || in.port != 443 { + t.Errorf("tag %q port %d", in.tag, in.port) + } + if in.params.Get("type") != "tcp" || in.params.Get("security") != "reality" || in.params.Get("pbk") == "" { + t.Errorf("params = %v", in.params) + } + if in.protocol.account == nil { + t.Error("protocol has no account builder") + } + }) + + t.Run("non-vless inbound is skipped", func(t *testing.T) { + api := &core.InboundHandlerConfig{ + Tag: "api", + ProxySettings: &serial.TypedMessage{Type: "xray.proxy.dokodemo.Config"}, + } + got, err := listInbounds(t.Context(), listOf(api)) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("inbounds = %+v, want none", got) + } + }) + + t.Run("list error", func(t *testing.T) { + client := &fakeHandlerService{inbounds: func() (*command.ListInboundsResponse, error) { + return nil, errors.New("unavailable") + }} + if _, err := listInbounds(t.Context(), client); err == nil || !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestListInboundsFailsUnsupportedShapes(t *testing.T) { + noSecurity := realityStream(t) + noSecurity.SecurityType = "" + noSecurity.SecuritySettings = nil + tls := realityStream(t) + tls.SecurityType = "tls" + websocket := realityStream(t) + websocket.ProtocolName = "websocket" + + tests := []struct { + name string + config *core.InboundHandlerConfig + wantErr string + }{ + {name: "port range", config: inboundConfig("a", portList(443, 450), realityStream(t)), wantErr: "port"}, + {name: "no port", config: inboundConfig("a", nil, realityStream(t)), wantErr: "port"}, + {name: "tls security", config: inboundConfig("a", portList(443, 443), tls), wantErr: `security "tls"`}, + {name: "empty security is named none", config: inboundConfig("a", portList(443, 443), noSecurity), wantErr: `security "none"`}, + {name: "websocket transport", config: inboundConfig("a", portList(443, 443), websocket), wantErr: `transport "websocket"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := listInbounds(t.Context(), listOf(tt.config)) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].tag != "a" || got[0].err == nil || !strings.Contains(got[0].err.Error(), tt.wantErr) { + t.Fatalf("inbounds = %+v, want one failed inbound containing %q", got, tt.wantErr) + } + }) + } +} + +func TestInboundErrorsNeverContainPrivateKey(t *testing.T) { + private, err := hex.DecodeString(privateKeyHex) + if err != nil { + t.Fatal(err) + } + encodings := []string{ + string(private), + privateKeyHex, + strings.ToUpper(privateKeyHex), + base64.StdEncoding.EncodeToString(private), + base64.RawStdEncoding.EncodeToString(private), + base64.URLEncoding.EncodeToString(private), + base64.RawURLEncoding.EncodeToString(private), + } + + bad := validReality(t) + bad.ServerNames = nil + shortKey := validReality(t) + shortKey.PrivateKey = append(bytes.Clone(shortKey.PrivateKey), 0) + garbled := realityStream(t) + garbled.SecuritySettings[0].Value = append(garbled.SecuritySettings[0].Value, 0xff) + tls := realityStream(t) + tls.SecurityType = "tls" + + streams := map[string]*internet.StreamConfig{ + "no server names": {ProtocolName: "tcp", SecurityType: "reality", SecuritySettings: realitySettings(t, bad)}, + "long key": {ProtocolName: "tcp", SecurityType: "reality", SecuritySettings: realitySettings(t, shortKey)}, + "garbled": garbled, + "tls with key": tls, + } + for name, stream := range streams { + t.Run(name, func(t *testing.T) { + got, err := listInbounds(t.Context(), listOf(inboundConfig("a", portList(443, 443), stream))) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].err == nil { + t.Fatalf("inbounds = %+v, want one failed inbound", got) + } + for _, encoding := range encodings { + if strings.Contains(got[0].err.Error(), encoding) { + t.Errorf("error %q contains the private key as %q", got[0].err, encoding) + } + } + }) + } +} diff --git a/internal/xray/protocol.go b/internal/xray/protocol.go new file mode 100644 index 0000000..2110ebd --- /dev/null +++ b/internal/xray/protocol.go @@ -0,0 +1,65 @@ +// Package xray is the Xray backend: it reads the VLESS inbounds of a running Xray through its +// gRPC API and moves usher's users into them. +package xray + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/mrcsin/usher/gen/xray/common/serial" + vlessaccount "github.com/mrcsin/usher/gen/xray/proxy/vless" + vlessinbound "github.com/mrcsin/usher/gen/xray/proxy/vless/inbound" + "github.com/mrcsin/usher/internal/state" +) + +const ( + transportTCP = "tcp" + securityReality = "reality" + + // flowVision is the flow of VLESS with Reality over raw TCP. + flowVision = "xtls-rprx-vision" + + decryptionNone = "none" +) + +// protocol is what a proxy-settings entry builds for an inbound. +type protocol struct { + // account returns the account Xray holds for an entry. + account func(e state.XrayEntry) *serial.TypedMessage +} + +// protocols maps the type of an inbound's proxy settings to its decoder. The decoder gets the +// settings and the names of the inbound's transport and security. +var protocols = map[string]func(settings []byte, transport, security string) (protocol, error){ + typeName(&vlessinbound.Config{}): decodeVLESS, +} + +func typeName(m proto.Message) string { + return string(m.ProtoReflect().Descriptor().FullName()) +} + +func toTypedMessage(m proto.Message) *serial.TypedMessage { + // Marshal fails only for a message with a missing required field, and proto3 has none. + value, _ := proto.Marshal(m) + return &serial.TypedMessage{Type: typeName(m), Value: value} +} + +func decodeVLESS(settings []byte, transport, security string) (protocol, error) { + var config vlessinbound.Config + if err := proto.Unmarshal(settings, &config); err != nil { + return protocol{}, fmt.Errorf("decoding vless settings: %w", err) + } + if config.GetDecryption() != decryptionNone { + return protocol{}, errors.New(`vless decryption is not "none"`) + } + if transport != transportTCP || security != securityReality { + return protocol{}, fmt.Errorf("vless over transport %q with security %q is not supported", transport, security) + } + return protocol{ + account: func(e state.XrayEntry) *serial.TypedMessage { + return toTypedMessage(&vlessaccount.Account{Id: e.ID.String(), Flow: flowVision}) + }, + }, nil +} diff --git a/internal/xray/protocol_test.go b/internal/xray/protocol_test.go new file mode 100644 index 0000000..3c0c159 --- /dev/null +++ b/internal/xray/protocol_test.go @@ -0,0 +1,70 @@ +package xray + +import ( + "strings" + "testing" + + "google.golang.org/protobuf/proto" + + vlessaccount "github.com/mrcsin/usher/gen/xray/proxy/vless" + vlessinbound "github.com/mrcsin/usher/gen/xray/proxy/vless/inbound" + "github.com/mrcsin/usher/internal/state" +) + +func vlessSettings(t *testing.T, decryption string) []byte { + t.Helper() + value, err := proto.Marshal(&vlessinbound.Config{Decryption: decryption}) + if err != nil { + t.Fatal(err) + } + return value +} + +func TestDecodeVLESS(t *testing.T) { + tests := []struct { + name string + decryption string + transport string + security string + wantErr string + }{ + {name: "tcp and reality", decryption: "none", transport: "tcp", security: "reality"}, + {name: "other decryption", decryption: "mlkem768x25519plus.native.0rtt", transport: "tcp", security: "reality", wantErr: "decryption"}, + {name: "empty decryption", decryption: "", transport: "tcp", security: "reality", wantErr: "decryption"}, + {name: "other transport", decryption: "none", transport: "ws", security: "reality", wantErr: `"ws"`}, + {name: "other security", decryption: "none", transport: "tcp", security: "tls", wantErr: `"tls"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := decodeVLESS(vlessSettings(t, tt.decryption), tt.transport, tt.security) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestVLESSAccountCarriesIDAndVisionFlow(t *testing.T) { + p, err := decodeVLESS(vlessSettings(t, "none"), "tcp", "reality") + if err != nil { + t.Fatal(err) + } + entry := state.XrayEntry{ID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}} + message := p.account(entry) + if message.GetType() != "xray.proxy.vless.Account" { + t.Fatalf("type = %q", message.GetType()) + } + var account vlessaccount.Account + if err := proto.Unmarshal(message.GetValue(), &account); err != nil { + t.Fatal(err) + } + if account.GetId() != entry.ID.String() || account.GetFlow() != "xtls-rprx-vision" { + t.Errorf("account = id %q flow %q", account.GetId(), account.GetFlow()) + } +} diff --git a/internal/xray/security.go b/internal/xray/security.go new file mode 100644 index 0000000..01f526b --- /dev/null +++ b/internal/xray/security.go @@ -0,0 +1,63 @@ +package xray + +import ( + "bytes" + "crypto/ecdh" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net/url" + + "google.golang.org/protobuf/proto" + + "github.com/mrcsin/usher/gen/xray/common/serial" + "github.com/mrcsin/usher/gen/xray/transport/internet/reality" +) + +// securityNone names an empty security type in errors. +const securityNone = "none" + +// securities maps a stream's security type to its decoder. The decoder returns the link +// parameters of the security. +var securities = map[string]func(settings []*serial.TypedMessage) (url.Values, error){ + securityReality: decodeReality, +} + +// decodeReality derives the link parameters from the server's Reality settings. The private key +// stays inside this function; only its public key leaves. +func decodeReality(settings []*serial.TypedMessage) (url.Values, error) { + if len(settings) != 1 || settings[0].GetType() != typeName(&reality.Config{}) { + return nil, errors.New("reality settings are missing") + } + var config reality.Config + if err := proto.Unmarshal(settings[0].GetValue(), &config); err != nil { + return nil, fmt.Errorf("decoding reality settings: %w", err) + } + private, err := ecdh.X25519().NewPrivateKey(config.GetPrivateKey()) + if err != nil { + return nil, errors.New("reality private_key is not an X25519 key") + } + serverName := firstNonEmpty(config.GetServerNames()) + if serverName == "" { + return nil, errors.New("reality server_names has no name") + } + if len(config.GetShortIds()) == 0 { + return nil, errors.New("reality short_ids is empty") + } + return url.Values{ + "security": {securityReality}, + "sni": {serverName}, + "pbk": {base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())}, + "sid": {hex.EncodeToString(bytes.TrimRight(config.GetShortIds()[0], "\x00"))}, + }, nil +} + +func firstNonEmpty(values []string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/xray/security_test.go b/internal/xray/security_test.go new file mode 100644 index 0000000..d3a6918 --- /dev/null +++ b/internal/xray/security_test.go @@ -0,0 +1,117 @@ +package xray + +import ( + "encoding/base64" + "encoding/hex" + "net/url" + "strings" + "testing" + + "github.com/mrcsin/usher/gen/xray/common/serial" + "github.com/mrcsin/usher/gen/xray/transport/internet/reality" +) + +// RFC 7748 section 6.1, Alice: a private key and its public key. +const ( + privateKeyHex = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a" + publicKeyHex = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a" +) + +func realitySettings(t *testing.T, config *reality.Config) []*serial.TypedMessage { + t.Helper() + return []*serial.TypedMessage{toTypedMessage(config)} +} + +func validReality(t *testing.T) *reality.Config { + t.Helper() + private, err := hex.DecodeString(privateKeyHex) + if err != nil { + t.Fatal(err) + } + return &reality.Config{ + PrivateKey: private, + ServerNames: []string{"example.com"}, + ShortIds: [][]byte{{0xab, 0, 0, 0, 0, 0, 0, 0}}, + } +} + +func TestDecodeReality(t *testing.T) { + public, err := hex.DecodeString(publicKeyHex) + if err != nil { + t.Fatal(err) + } + wantPBK := base64.RawURLEncoding.EncodeToString(public) + + tests := []struct { + name string + change func(*reality.Config) + want url.Values + wantErr string + }{ + { + name: "trimmed short id", + change: func(*reality.Config) {}, + want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "pbk": {wantPBK}, "sid": {"ab"}}, + }, + { + name: "all-zero short id is empty", + change: func(c *reality.Config) { c.ShortIds = [][]byte{make([]byte, 8)} }, + want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "pbk": {wantPBK}, "sid": {""}}, + }, + { + name: "first short id wins", + change: func(c *reality.Config) { c.ShortIds = [][]byte{{0x01, 0x02, 0, 0, 0, 0, 0, 0}, {0xff}} }, + want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "pbk": {wantPBK}, "sid": {"0102"}}, + }, + { + name: "first non-empty server name", + change: func(c *reality.Config) { c.ServerNames = []string{"", "second.example"} }, + want: url.Values{"security": {"reality"}, "sni": {"second.example"}, "pbk": {wantPBK}, "sid": {"ab"}}, + }, + { + name: "no usable server name", + change: func(c *reality.Config) { c.ServerNames = []string{"", ""} }, + wantErr: "server_names", + }, + { + name: "no short ids", + change: func(c *reality.Config) { c.ShortIds = nil }, + wantErr: "short_ids", + }, + { + name: "short private key", + change: func(c *reality.Config) { c.PrivateKey = c.PrivateKey[:31] }, + wantErr: "private_key", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := validReality(t) + tt.change(config) + got, err := decodeReality(realitySettings(t, config)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.Encode() != tt.want.Encode() { + t.Errorf("params = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDecodeRealityMissingSettings(t *testing.T) { + other := []*serial.TypedMessage{{Type: "xray.transport.internet.tls.Config"}} + for name, settings := range map[string][]*serial.TypedMessage{"none": nil, "other type": other} { + t.Run(name, func(t *testing.T) { + if _, err := decodeReality(settings); err == nil { + t.Fatal("want an error") + } + }) + } +} diff --git a/internal/xray/transport.go b/internal/xray/transport.go new file mode 100644 index 0000000..40d0c71 --- /dev/null +++ b/internal/xray/transport.go @@ -0,0 +1,45 @@ +package xray + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "google.golang.org/protobuf/proto" + + "github.com/mrcsin/usher/gen/xray/transport/internet" + "github.com/mrcsin/usher/gen/xray/transport/internet/tcp" +) + +// noopHeaderPackage is the proto package of the raw TCP header "none". +const noopHeaderPackage = "xray.transport.internet.headers.noop." + +// transports maps a stream's protocol name to its decoder. The decoder returns the link parameters +// of the transport. +var transports = map[string]func(stream *internet.StreamConfig) (url.Values, error){ + transportTCP: decodeTCP, +} + +// decodeTCP accepts raw TCP without a header: no transport settings, settings without a header +// and settings with the no-op header. +func decodeTCP(stream *internet.StreamConfig) (url.Values, error) { + for _, transport := range stream.GetTransportSettings() { + if transport.GetProtocolName() != transportTCP || transport.GetSettings() == nil { + continue + } + settings := transport.GetSettings() + if settings.GetType() != typeName(&tcp.Config{}) { + return nil, fmt.Errorf("tcp settings have type %q", settings.GetType()) + } + var config tcp.Config + if err := proto.Unmarshal(settings.GetValue(), &config); err != nil { + return nil, fmt.Errorf("decoding tcp settings: %w", err) + } + header := config.GetHeaderSettings() + if header != nil && !strings.HasPrefix(header.GetType(), noopHeaderPackage) { + return nil, errors.New(`tcp header is not "none"`) + } + } + return url.Values{"type": {transportTCP}}, nil +} diff --git a/internal/xray/transport_test.go b/internal/xray/transport_test.go new file mode 100644 index 0000000..abff89e --- /dev/null +++ b/internal/xray/transport_test.go @@ -0,0 +1,51 @@ +package xray + +import ( + "strings" + "testing" + + "github.com/mrcsin/usher/gen/xray/common/serial" + "github.com/mrcsin/usher/gen/xray/transport/internet" + "github.com/mrcsin/usher/gen/xray/transport/internet/tcp" +) + +func tcpStream(settings *serial.TypedMessage) *internet.StreamConfig { + return &internet.StreamConfig{ + ProtocolName: "tcp", + TransportSettings: []*internet.TransportConfig{{ProtocolName: "tcp", Settings: settings}}, + } +} + +func TestDecodeTCP(t *testing.T) { + noop := &serial.TypedMessage{Type: "xray.transport.internet.headers.noop.Config"} + http := &serial.TypedMessage{Type: "xray.transport.internet.headers.http.Config"} + tests := []struct { + name string + stream *internet.StreamConfig + wantErr string + }{ + {name: "no transport settings", stream: &internet.StreamConfig{ProtocolName: "tcp"}}, + {name: "settings without header", stream: tcpStream(toTypedMessage(&tcp.Config{}))}, + {name: "no-op header", stream: tcpStream(toTypedMessage(&tcp.Config{HeaderSettings: noop}))}, + {name: "http header", stream: tcpStream(toTypedMessage(&tcp.Config{HeaderSettings: http})), wantErr: "header"}, + {name: "foreign settings type", stream: tcpStream(&serial.TypedMessage{Type: "xray.other.Config"}), wantErr: "xray.other.Config"}, + {name: "garbled settings", stream: tcpStream(&serial.TypedMessage{Type: typeName(&tcp.Config{}), Value: []byte{0xff}}), wantErr: "decoding tcp settings"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := decodeTCP(tt.stream) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.Encode() != "type=tcp" { + t.Errorf("params = %q, want type=tcp", got.Encode()) + } + }) + } +} From bfbdeb93d86e91f90ff198f9b10b3b03b9946712 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:39:21 +0300 Subject: [PATCH 07/29] feat(xray): reconcile users through HandlerService --- docs/plans/20261007-xray-driver.md | 14 +- internal/xray/enroll.go | 22 ++ internal/xray/enroll_test.go | 40 ++++ internal/xray/session.go | 190 ++++++++++++++++ internal/xray/session_test.go | 352 +++++++++++++++++++++++++++++ 5 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 internal/xray/enroll.go create mode 100644 internal/xray/enroll_test.go create mode 100644 internal/xray/session.go create mode 100644 internal/xray/session_test.go diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index 5f596c7..90464ff 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -420,18 +420,18 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Create: `internal/xray/session_test.go` - Create: `internal/xray/enroll_test.go` -- [ ] implement `Session` for Xray: open lists the inbounds (Task 5) -- [ ] enroll: create an entry with `uuid.NewV4()` for every referenced user without one; never +- [x] implement `Session` for Xray: open lists the inbounds (Task 5) +- [x] enroll: create an entry with `uuid.NewV4()` for every referenced user without one; never change an existing entry -- [ ] apply: read `GetInboundUsers`, compare accounts as in Technical Details, remove extra and +- [x] apply: read `GetInboundUsers`, compare accounts as in Technical Details, remove extra and changed users, add missing and changed users with `level` 0, `email` `tag/user` and the account the protocol entry builds; log `users changed` -- [ ] treat an `AlterInbound` error as a failure of that inbound -- [ ] write tests with a fake `HandlerServiceClient`: add, remove, flow change, id change, +- [x] treat an `AlterInbound` error as a failure of that inbound +- [x] write tests with a fake `HandlerServiceClient`: add, remove, flow change, id change, unreferenced inbound emptied, a seeded UUID kept, partial failure converging on the next pass -- [ ] write a leak test: no usher log line or error contains a UUID -- [ ] run `go test -race ./...` - must pass before Task 7 +- [x] write a leak test: no usher log line or error contains a UUID +- [x] run `go test -race ./...` - must pass before Task 7 ### Task 7: Render VLESS links diff --git a/internal/xray/enroll.go b/internal/xray/enroll.go new file mode 100644 index 0000000..bb155e1 --- /dev/null +++ b/internal/xray/enroll.go @@ -0,0 +1,22 @@ +package xray + +import ( + "uuid" + + "github.com/mrcsin/usher/internal/state" +) + +// enroll makes st hold an entry for every user on the inbound. It creates a missing entry with a +// random UUID and never changes an existing one. It reports whether it created any. +func enroll(st *state.State, tag string, users []string) bool { + changed := false + for _, user := range users { + name := state.EntryName(tag, user) + if _, ok := st.Xray[name]; ok { + continue + } + st.Xray[name] = state.XrayEntry{ID: uuid.NewV4()} + changed = true + } + return changed +} diff --git a/internal/xray/enroll_test.go b/internal/xray/enroll_test.go new file mode 100644 index 0000000..c9fdf1c --- /dev/null +++ b/internal/xray/enroll_test.go @@ -0,0 +1,40 @@ +package xray + +import ( + "testing" + "uuid" + + "github.com/mrcsin/usher/internal/state" +) + +func TestEnroll(t *testing.T) { + seeded := uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d") + st := &state.State{Xray: map[string]state.XrayEntry{ + "vless/alice": {ID: seeded}, + "other/carol": {ID: uuid.NewV4()}, + }} + + if !enroll(st, "vless", []string{"alice", "bob"}) { + t.Fatal("enroll reported no change after adding bob") + } + if got := st.Xray["vless/alice"].ID; got != seeded { + t.Errorf("alice id changed to %v", got) + } + bob, ok := st.Xray["vless/bob"] + if !ok || bob.ID == uuid.Nil() { + t.Fatalf("bob entry = %+v, %v", bob, ok) + } + if bob.ID[6]>>4 != 4 { + t.Errorf("bob id %v is not version 4", bob.ID) + } + if _, ok := st.Xray["other/carol"]; !ok { + t.Error("entry of another inbound was dropped") + } + + if enroll(st, "vless", []string{"alice", "bob"}) { + t.Error("second enroll reported a change") + } + if got := st.Xray["vless/bob"].ID; got != bob.ID { + t.Errorf("bob id changed to %v", got) + } +} diff --git a/internal/xray/session.go b/internal/xray/session.go new file mode 100644 index 0000000..478a76f --- /dev/null +++ b/internal/xray/session.go @@ -0,0 +1,190 @@ +package xray + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strings" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + "github.com/mrcsin/usher/gen/xray/app/proxyman/command" + userproto "github.com/mrcsin/usher/gen/xray/common/protocol" + "github.com/mrcsin/usher/gen/xray/common/serial" + "github.com/mrcsin/usher/internal/state" +) + +// Suffix is the file suffix of an Xray client link file. +const Suffix = ".txt" + +// callTimeout bounds each call to Xray. +const callTimeout = 10 * time.Second + +// userLevel is the level of every user usher adds. +const userLevel = 0 + +// Dial opens a connection to the Xray API. It returns the client and the function that closes the +// connection. +type Dial func() (command.HandlerServiceClient, func(), error) + +// Session is one pass's view of Xray: the inbounds it reported when the session opened. +type Session struct { + client command.HandlerServiceClient + closeConn func() + inbounds map[string]inbound + names []string + log *slog.Logger +} + +// Open connects to Xray and lists its inbounds. +func Open(ctx context.Context, dial Dial, log *slog.Logger) (*Session, error) { + client, closeConn, err := dial() + if err != nil { + return nil, fmt.Errorf("connecting to xray: %w", err) + } + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + listed, err := listInbounds(callCtx, client) + if err != nil { + closeConn() + return nil, err + } + s := &Session{ + client: client, + closeConn: closeConn, + inbounds: make(map[string]inbound, len(listed)), + log: log, + } + for _, in := range listed { + s.inbounds[in.tag] = in + s.names = append(s.names, in.tag) + } + return s, nil +} + +// Interfaces returns the tags of the inbounds Xray reported, in the order it reported them. +func (s *Session) Interfaces() []string { + return s.names +} + +// Prepare enrolls the users on the inbound. It reports whether it changed st. It fails an inbound +// that could not be decoded. +func (s *Session) Prepare(st *state.State, name string, users []string) (map[string][]byte, bool, error) { + if err := s.inbounds[name].err; err != nil { + return nil, false, err + } + return nil, enroll(st, name, users), nil +} + +// Apply makes the inbound hold exactly the users: it removes the users that are not wanted or +// whose account differs, adds the missing ones and logs what changed. +func (s *Session) Apply(ctx context.Context, st *state.State, name string, users []string) error { + in := s.inbounds[name] + if in.err != nil { + return in.err + } + desired := make(map[string]*serial.TypedMessage, len(users)) + for _, user := range users { + desired[state.EntryName(name, user)] = in.protocol.account(st.Xray[state.EntryName(name, user)]) + } + + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + response, err := s.client.GetInboundUsers(callCtx, &command.GetInboundUserRequest{Tag: name}) + if err != nil { + return fmt.Errorf("getting users: %w", err) + } + current := make(map[string]*serial.TypedMessage, len(response.GetUsers())) + for _, u := range response.GetUsers() { + current[u.GetEmail()] = u.GetAccount() + } + + var removed, added, updated []string + for email, account := range current { + want, wanted := desired[email] + switch { + case !wanted: + removed = append(removed, email) + case !sameAccount(account, want): + updated = append(updated, email) + } + } + for email := range desired { + if _, ok := current[email]; !ok { + added = append(added, email) + } + } + slices.Sort(removed) + slices.Sort(added) + slices.Sort(updated) + + for _, email := range append(slices.Clone(removed), updated...) { + if err := s.alter(ctx, name, &command.RemoveUserOperation{Email: email}); err != nil { + return fmt.Errorf("removing user %s: %w", userOf(name, email), err) + } + } + for _, email := range append(slices.Clone(added), updated...) { + operation := &command.AddUserOperation{User: &userproto.User{ + Level: userLevel, Email: email, Account: desired[email], + }} + if err := s.alter(ctx, name, operation); err != nil { + return fmt.Errorf("adding user %s: %w", userOf(name, email), err) + } + } + + if len(removed)+len(added)+len(updated) > 0 { + s.log.Info("users changed", "interface", name, + "added", userNames(name, added), "removed", userNames(name, removed), "updated", userNames(name, updated)) + } + return nil +} + +// Close closes the connection to Xray. +func (s *Session) Close() { + s.closeConn() +} + +func (s *Session) alter(ctx context.Context, tag string, operation proto.Message) error { + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + _, err := s.client.AlterInbound(callCtx, &command.AlterInboundRequest{ + Tag: tag, + Operation: toTypedMessage(operation), + }) + return err +} + +// sameAccount compares two accounts as messages, so field order and default values do not +// matter. An account of a type this binary does not know differs from everything. +func sameAccount(a, b *serial.TypedMessage) bool { + if a.GetType() != b.GetType() { + return false + } + messageType, err := protoregistry.GlobalTypes.FindMessageByName(protoreflect.FullName(a.GetType())) + if err != nil { + return false + } + left, right := messageType.New().Interface(), messageType.New().Interface() + if proto.Unmarshal(a.GetValue(), left) != nil || proto.Unmarshal(b.GetValue(), right) != nil { + return false + } + return proto.Equal(left, right) +} + +// userOf returns the user name of an email "tag/user"; any other email is returned as it is. +func userOf(tag, email string) string { + user, _ := strings.CutPrefix(email, tag+"/") + return user +} + +func userNames(tag string, emails []string) []string { + names := make([]string, 0, len(emails)) + for _, email := range emails { + names = append(names, userOf(tag, email)) + } + return names +} diff --git a/internal/xray/session_test.go b/internal/xray/session_test.go new file mode 100644 index 0000000..8c64ef2 --- /dev/null +++ b/internal/xray/session_test.go @@ -0,0 +1,352 @@ +package xray + +import ( + "bytes" + "context" + "errors" + "log/slog" + "maps" + "slices" + "strings" + "testing" + "uuid" + + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" + + "github.com/mrcsin/usher/gen/xray/app/proxyman/command" + userproto "github.com/mrcsin/usher/gen/xray/common/protocol" + "github.com/mrcsin/usher/gen/xray/common/serial" + "github.com/mrcsin/usher/gen/xray/core" + vlessaccount "github.com/mrcsin/usher/gen/xray/proxy/vless" + "github.com/mrcsin/usher/internal/state" +) + +// fakeXray implements command.HandlerServiceClient over in-memory inbounds. It applies the +// operations it gets, so a second pass sees the result of the first. +type fakeXray struct { + command.HandlerServiceClient + + configs []*core.InboundHandlerConfig + users map[string]map[string]*serial.TypedMessage + // failAdd names an email whose AddUserOperation fails. + failAdd string + calls []string +} + +func newFakeXray(configs ...*core.InboundHandlerConfig) *fakeXray { + f := &fakeXray{configs: configs, users: map[string]map[string]*serial.TypedMessage{}} + for _, c := range configs { + f.users[c.GetTag()] = map[string]*serial.TypedMessage{} + } + return f +} + +func (f *fakeXray) ListInbounds(context.Context, *command.ListInboundsRequest, ...grpc.CallOption) (*command.ListInboundsResponse, error) { + return &command.ListInboundsResponse{Inbounds: f.configs}, nil +} + +func (f *fakeXray) GetInboundUsers(_ context.Context, in *command.GetInboundUserRequest, _ ...grpc.CallOption) (*command.GetInboundUserResponse, error) { + var response command.GetInboundUserResponse + for _, email := range slices.Sorted(maps.Keys(f.users[in.GetTag()])) { + response.Users = append(response.Users, &userproto.User{Email: email, Account: f.users[in.GetTag()][email]}) + } + return &response, nil +} + +func (f *fakeXray) AlterInbound(_ context.Context, in *command.AlterInboundRequest, _ ...grpc.CallOption) (*command.AlterInboundResponse, error) { + operation := in.GetOperation() + switch operation.GetType() { + case typeName(&command.AddUserOperation{}): + var add command.AddUserOperation + if err := proto.Unmarshal(operation.GetValue(), &add); err != nil { + return nil, err + } + email := add.GetUser().GetEmail() + f.calls = append(f.calls, "add "+email) + if email == f.failAdd { + return nil, errors.New("xray refused " + email) + } + f.users[in.GetTag()][email] = add.GetUser().GetAccount() + case typeName(&command.RemoveUserOperation{}): + var remove command.RemoveUserOperation + if err := proto.Unmarshal(operation.GetValue(), &remove); err != nil { + return nil, err + } + f.calls = append(f.calls, "remove "+remove.GetEmail()) + delete(f.users[in.GetTag()], remove.GetEmail()) + default: + return nil, errors.New("unexpected operation " + operation.GetType()) + } + return &command.AlterInboundResponse{}, nil +} + +type sessionEnv struct { + t *testing.T + xray *fakeXray + logs *bytes.Buffer + st *state.State +} + +func newSessionEnv(t *testing.T, xray *fakeXray) *sessionEnv { + t.Helper() + return &sessionEnv{t: t, xray: xray, logs: &bytes.Buffer{}, st: &state.State{Xray: map[string]state.XrayEntry{}}} +} + +func (e *sessionEnv) open() *Session { + e.t.Helper() + dial := func() (command.HandlerServiceClient, func(), error) { return e.xray, func() {}, nil } + s, err := Open(context.Background(), dial, slog.New(slog.NewTextHandler(e.logs, nil))) + if err != nil { + e.t.Fatal(err) + } + return s +} + +// pass runs Prepare and Apply for one inbound, as the pass does. +func (e *sessionEnv) pass(name string, users ...string) error { + e.t.Helper() + s := e.open() + defer s.Close() + if _, _, err := s.Prepare(e.st, name, users); err != nil { + return err + } + return s.Apply(context.Background(), e.st, name, users) +} + +func (e *sessionEnv) accountOf(tag, user string) *serial.TypedMessage { + return e.xray.users[tag][state.EntryName(tag, user)] +} + +func vlessAccount(id uuid.UUID, flow string) *serial.TypedMessage { + return toTypedMessage(&vlessaccount.Account{Id: id.String(), Flow: flow}) +} + +func twoInbounds(t *testing.T) *fakeXray { + t.Helper() + return newFakeXray( + inboundConfig("vless", portList(443, 443), realityStream(t)), + inboundConfig("spare", portList(8443, 8443), realityStream(t)), + ) +} + +func TestOpen(t *testing.T) { + t.Run("lists inbound tags in order", func(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + s := env.open() + if got := s.Interfaces(); !slices.Equal(got, []string{"vless", "spare"}) { + t.Errorf("Interfaces = %v", got) + } + }) + + t.Run("dial error", func(t *testing.T) { + dial := func() (command.HandlerServiceClient, func(), error) { return nil, nil, errors.New("no socket") } + if _, err := Open(context.Background(), dial, slog.Default()); err == nil || !strings.Contains(err.Error(), "no socket") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("list error closes the connection", func(t *testing.T) { + closed := false + dial := func() (command.HandlerServiceClient, func(), error) { + return &failingList{}, func() { closed = true }, nil + } + if _, err := Open(context.Background(), dial, slog.Default()); err == nil { + t.Fatal("no error") + } + if !closed { + t.Error("connection stays open") + } + }) +} + +type failingList struct{ command.HandlerServiceClient } + +func (*failingList) ListInbounds(context.Context, *command.ListInboundsRequest, ...grpc.CallOption) (*command.ListInboundsResponse, error) { + return nil, errors.New("unavailable") +} + +func TestApplyAddsAndRemoves(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + if err := env.pass("vless", "alice", "bob"); err != nil { + t.Fatal(err) + } + for _, user := range []string{"alice", "bob"} { + want := vlessAccount(env.st.Xray["vless/"+user].ID, flowVision) + if got := env.accountOf("vless", user); !sameAccount(got, want) { + t.Errorf("%s account = %v, want %v", user, got, want) + } + } + if !strings.Contains(env.logs.String(), "users changed") || !strings.Contains(env.logs.String(), "added=\"[alice bob]\"") { + t.Errorf("logs = %q", env.logs.String()) + } + + env.logs.Reset() + env.xray.calls = nil + if err := env.pass("vless", "alice"); err != nil { + t.Fatal(err) + } + if got := slices.Collect(maps.Keys(env.xray.users["vless"])); !slices.Equal(got, []string{"vless/alice"}) { + t.Errorf("users = %v", got) + } + if !slices.Equal(env.xray.calls, []string{"remove vless/bob"}) { + t.Errorf("calls = %v", env.xray.calls) + } + if !strings.Contains(env.logs.String(), "removed=[bob]") { + t.Errorf("logs = %q", env.logs.String()) + } + + env.logs.Reset() + env.xray.calls = nil + if err := env.pass("vless", "alice"); err != nil { + t.Fatal(err) + } + if len(env.xray.calls) != 0 || env.logs.Len() != 0 { + t.Errorf("an unchanged pass made calls %v and logged %q", env.xray.calls, env.logs.String()) + } +} + +func TestApplyEmptiesUnreferencedInbound(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + if err := env.pass("spare", "alice"); err != nil { + t.Fatal(err) + } + if err := env.pass("spare"); err != nil { + t.Fatal(err) + } + if len(env.xray.users["spare"]) != 0 { + t.Errorf("users = %v", env.xray.users["spare"]) + } +} + +func TestApplyKeepsSeededUUID(t *testing.T) { + seeded := uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d") + env := newSessionEnv(t, twoInbounds(t)) + env.st.Xray["vless/alice"] = state.XrayEntry{ID: seeded} + if err := env.pass("vless", "alice"); err != nil { + t.Fatal(err) + } + if got := env.st.Xray["vless/alice"].ID; got != seeded { + t.Errorf("id = %v", got) + } + if got := env.accountOf("vless", "alice"); !sameAccount(got, vlessAccount(seeded, flowVision)) { + t.Errorf("account = %v", got) + } +} + +func TestApplyReplacesChangedAccount(t *testing.T) { + tests := []struct { + name string + account *serial.TypedMessage + }{ + {name: "flow change", account: vlessAccount(uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d"), "")}, + {name: "id change", account: vlessAccount(uuid.NewV4(), flowVision)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + env.st.Xray["vless/alice"] = state.XrayEntry{ID: uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d")} + env.xray.users["vless"]["vless/alice"] = tt.account + + if err := env.pass("vless", "alice"); err != nil { + t.Fatal(err) + } + want := vlessAccount(env.st.Xray["vless/alice"].ID, flowVision) + if got := env.accountOf("vless", "alice"); !sameAccount(got, want) { + t.Errorf("account = %v, want %v", got, want) + } + if want := []string{"remove vless/alice", "add vless/alice"}; !slices.Equal(env.xray.calls, want) { + t.Errorf("calls = %v, want %v", env.xray.calls, want) + } + if !strings.Contains(env.logs.String(), "updated=[alice]") { + t.Errorf("logs = %q", env.logs.String()) + } + }) + } +} + +func TestSameAccount(t *testing.T) { + id := uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d") + // An explicit zero in field 5 (seconds) is the same message with a different encoding. + explicit := &serial.TypedMessage{ + Type: typeName(&vlessaccount.Account{}), + Value: append(vlessAccount(id, flowVision).GetValue(), 0x28, 0x00), + } + if !sameAccount(explicit, vlessAccount(id, flowVision)) { + t.Error("equal accounts differ") + } + if sameAccount(&serial.TypedMessage{Type: "unknown.Type"}, &serial.TypedMessage{Type: "unknown.Type"}) { + t.Error("an unknown type equals itself") + } +} + +func TestApplyConvergesAfterPartialFailure(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + env.xray.failAdd = "vless/bob" + + err := env.pass("vless", "alice", "bob") + if err == nil || !strings.Contains(err.Error(), "adding user bob") { + t.Fatalf("error = %v", err) + } + if env.accountOf("vless", "alice") == nil || env.accountOf("vless", "bob") != nil { + t.Fatalf("users = %v", slices.Collect(maps.Keys(env.xray.users["vless"]))) + } + + env.xray.failAdd = "" + if err := env.pass("vless", "alice", "bob"); err != nil { + t.Fatal(err) + } + if env.accountOf("vless", "bob") == nil { + t.Error("bob is missing after the next pass") + } +} + +func TestPrepareFailsUndecodableInbound(t *testing.T) { + websocket := realityStream(t) + websocket.ProtocolName = "websocket" + env := newSessionEnv(t, newFakeXray(inboundConfig("bad", portList(443, 443), websocket))) + s := env.open() + + _, changed, err := s.Prepare(env.st, "bad", []string{"alice"}) + if err == nil || !strings.Contains(err.Error(), `transport "websocket"`) || changed { + t.Fatalf("Prepare = %v, %v", changed, err) + } + if len(env.st.Xray) != 0 { + t.Errorf("state = %v", env.st.Xray) + } + if err := s.Apply(context.Background(), env.st, "bad", []string{"alice"}); err == nil { + t.Error("Apply succeeded on a failed inbound") + } +} + +func TestPrepareReportsEnrollment(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + s := env.open() + _, changed, err := s.Prepare(env.st, "vless", []string{"alice"}) + if err != nil || !changed { + t.Fatalf("first Prepare = %v, %v", changed, err) + } + _, changed, err = s.Prepare(env.st, "vless", []string{"alice"}) + if err != nil || changed { + t.Fatalf("second Prepare = %v, %v", changed, err) + } +} + +func TestSessionNeverLogsUUID(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + env.xray.failAdd = "vless/bob" + err := env.pass("vless", "alice", "bob") + if err == nil { + t.Fatal("no error") + } + env.xray.failAdd = "" + if err := env.pass("vless", "alice", "bob"); err != nil { + t.Fatal(err) + } + for _, id := range []uuid.UUID{env.st.Xray["vless/alice"].ID, env.st.Xray["vless/bob"].ID} { + text := id.String() + if strings.Contains(env.logs.String(), text) || strings.Contains(err.Error(), text) { + t.Errorf("a log line or an error contains uuid %s", text) + } + } +} From efb8919b43c8d8b7774d499a1bb2bd0da8037ade Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:41:31 +0300 Subject: [PATCH 08/29] feat(xray): render VLESS share links --- docs/plans/20261007-xray-driver.md | 8 +-- internal/xray/link.go | 32 +++++++++++ internal/xray/link_test.go | 86 ++++++++++++++++++++++++++++++ internal/xray/protocol.go | 6 +++ internal/xray/security.go | 4 ++ internal/xray/security_test.go | 8 +-- internal/xray/session.go | 25 ++++++--- internal/xray/session_test.go | 7 +-- internal/xray/testdata/vless.txt | 1 + 9 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 internal/xray/link.go create mode 100644 internal/xray/link_test.go create mode 100644 internal/xray/testdata/vless.txt diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index 90464ff..61e3c50 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -440,13 +440,13 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Create: `internal/xray/link_test.go` - Create: `internal/xray/testdata/` (golden link) -- [ ] implement the VLESS entry's `link` as in Technical Details and write it into +- [x] implement the VLESS entry's `link` as in Technical Details and write it into `clients//.txt` in the session's prepare step -- [ ] read the share-link proposal (XTLS/Xray-core discussion #716) and pin parameter names, +- [x] read the share-link proposal (XTLS/Xray-core discussion #716) and pin parameter names, values and encodings in a golden test; ASSUMPTION: the proposal covers the Reality parameters `pbk`, `sid` and `spx` -- [ ] write tests: golden link; a link with an empty `sid`; the user name escaped in the fragment -- [ ] run `go test -race ./...` - must pass before Task 8 +- [x] write tests: golden link; a link with an empty `sid`; the user name escaped in the fragment +- [x] run `go test -race ./...` - must pass before Task 8 ### Task 8: Wire the Xray backend and the deploy example diff --git a/internal/xray/link.go b/internal/xray/link.go new file mode 100644 index 0000000..ec36cdc --- /dev/null +++ b/internal/xray/link.go @@ -0,0 +1,32 @@ +package xray + +import ( + "net" + "net/netip" + "net/url" + "strconv" + "strings" + + "github.com/mrcsin/usher/internal/state" +) + +// vlessLink builds the share link of XTLS/Xray-core discussion #716 for one user of a VLESS +// inbound. params are the transport and security parameters of the inbound. +func vlessLink(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string { + query := url.Values{"encryption": {decryptionNone}, "flow": {flowVision}} + for key, values := range params { + query[key] = values + } + address := net.JoinHostPort(host.String(), strconv.Itoa(int(port))) + return "vless://" + e.ID.String() + "@" + address + "?" + encodeComponent(query.Encode()) + "#" + escapeComponent(user) +} + +// escapeComponent escapes s as JavaScript's encodeURIComponent does, which the proposal requires. +func escapeComponent(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} + +// encodeComponent turns the spaces that url.Values.Encode writes as "+" into "%20". +func encodeComponent(encoded string) string { + return strings.ReplaceAll(encoded, "+", "%20") +} diff --git a/internal/xray/link_test.go b/internal/xray/link_test.go new file mode 100644 index 0000000..93560b7 --- /dev/null +++ b/internal/xray/link_test.go @@ -0,0 +1,86 @@ +package xray + +import ( + "net/netip" + "net/url" + "os" + "strings" + "testing" + + "github.com/mrcsin/usher/internal/clients" + "github.com/mrcsin/usher/internal/state" +) + +var linkEntry = state.XrayEntry{ID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}} + +func linkParams(sid string) url.Values { + return url.Values{ + "type": {"tcp"}, + "security": {"reality"}, + "sni": {"example.com"}, + "fp": {"chrome"}, + "pbk": {"AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-AbCdE"}, + "sid": {sid}, + } +} + +func TestVLESSLinkGolden(t *testing.T) { + want, err := os.ReadFile("testdata/vless.txt") + if err != nil { + t.Fatal(err) + } + got := vlessLink(linkEntry, "alice", netip.MustParseAddr("203.0.113.7"), 443, linkParams("ab")) + "\n" + if got != string(want) { + t.Errorf("link differs from testdata/vless.txt:\n%s", got) + } +} + +func TestVLESSLinkParts(t *testing.T) { + tests := []struct { + name string + host string + sid string + user string + want []string + }{ + {name: "empty sid", host: "203.0.113.7", sid: "", user: "alice", want: []string{"&sid=&", "#alice"}}, + {name: "user name escaped", host: "203.0.113.7", sid: "ab", user: "a b#c/d", want: []string{"#a%20b%23c%2Fd"}}, + {name: "ipv6 host in brackets", host: "2001:db8::1", sid: "ab", user: "alice", want: []string{"@[2001:db8::1]:443?"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + link := vlessLink(linkEntry, tt.user, netip.MustParseAddr(tt.host), 443, linkParams(tt.sid)) + for _, part := range tt.want { + if !strings.Contains(link, part) { + t.Errorf("link %q does not contain %q", link, part) + } + } + parsed, err := url.Parse(link) + if err != nil { + t.Fatal(err) + } + if parsed.Query().Get("sid") != tt.sid || parsed.Fragment != tt.user { + t.Errorf("sid %q fragment %q, want %q and %q", parsed.Query().Get("sid"), parsed.Fragment, tt.sid, tt.user) + } + }) + } +} + +func TestPrepareRendersLinkFiles(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + s := env.open() + files, _, err := s.Prepare(env.st, "vless", []string{"alice", "bob"}) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("files = %d, want 2", len(files)) + } + for _, user := range []string{"alice", "bob"} { + content := string(files[clients.Path(user, "vless", Suffix)]) + wantPrefix := "vless://" + env.st.Xray[state.EntryName("vless", user)].ID.String() + "@203.0.113.7:443?" + if !strings.HasPrefix(content, wantPrefix) || !strings.HasSuffix(content, "#"+user+"\n") { + t.Errorf("%s link = %q", user, content) + } + } +} diff --git a/internal/xray/protocol.go b/internal/xray/protocol.go index 2110ebd..9537427 100644 --- a/internal/xray/protocol.go +++ b/internal/xray/protocol.go @@ -5,6 +5,8 @@ package xray import ( "errors" "fmt" + "net/netip" + "net/url" "google.golang.org/protobuf/proto" @@ -28,6 +30,9 @@ const ( type protocol struct { // account returns the account Xray holds for an entry. account func(e state.XrayEntry) *serial.TypedMessage + // link returns the client link of a user. params are the inbound's transport and security + // parameters. + link func(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string } // protocols maps the type of an inbound's proxy settings to its decoder. The decoder gets the @@ -61,5 +66,6 @@ func decodeVLESS(settings []byte, transport, security string) (protocol, error) account: func(e state.XrayEntry) *serial.TypedMessage { return toTypedMessage(&vlessaccount.Account{Id: e.ID.String(), Flow: flowVision}) }, + link: vlessLink, }, nil } diff --git a/internal/xray/security.go b/internal/xray/security.go index 01f526b..44de7de 100644 --- a/internal/xray/security.go +++ b/internal/xray/security.go @@ -18,6 +18,9 @@ import ( // securityNone names an empty security type in errors. const securityNone = "none" +// fingerprintChrome is the TLS client fingerprint of every link; Reality requires one. +const fingerprintChrome = "chrome" + // securities maps a stream's security type to its decoder. The decoder returns the link // parameters of the security. var securities = map[string]func(settings []*serial.TypedMessage) (url.Values, error){ @@ -48,6 +51,7 @@ func decodeReality(settings []*serial.TypedMessage) (url.Values, error) { return url.Values{ "security": {securityReality}, "sni": {serverName}, + "fp": {fingerprintChrome}, "pbk": {base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())}, "sid": {hex.EncodeToString(bytes.TrimRight(config.GetShortIds()[0], "\x00"))}, }, nil diff --git a/internal/xray/security_test.go b/internal/xray/security_test.go index d3a6918..2d94ae6 100644 --- a/internal/xray/security_test.go +++ b/internal/xray/security_test.go @@ -51,22 +51,22 @@ func TestDecodeReality(t *testing.T) { { name: "trimmed short id", change: func(*reality.Config) {}, - want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "pbk": {wantPBK}, "sid": {"ab"}}, + want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "fp": {"chrome"}, "pbk": {wantPBK}, "sid": {"ab"}}, }, { name: "all-zero short id is empty", change: func(c *reality.Config) { c.ShortIds = [][]byte{make([]byte, 8)} }, - want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "pbk": {wantPBK}, "sid": {""}}, + want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "fp": {"chrome"}, "pbk": {wantPBK}, "sid": {""}}, }, { name: "first short id wins", change: func(c *reality.Config) { c.ShortIds = [][]byte{{0x01, 0x02, 0, 0, 0, 0, 0, 0}, {0xff}} }, - want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "pbk": {wantPBK}, "sid": {"0102"}}, + want: url.Values{"security": {"reality"}, "sni": {"example.com"}, "fp": {"chrome"}, "pbk": {wantPBK}, "sid": {"0102"}}, }, { name: "first non-empty server name", change: func(c *reality.Config) { c.ServerNames = []string{"", "second.example"} }, - want: url.Values{"security": {"reality"}, "sni": {"second.example"}, "pbk": {wantPBK}, "sid": {"ab"}}, + want: url.Values{"security": {"reality"}, "sni": {"second.example"}, "fp": {"chrome"}, "pbk": {wantPBK}, "sid": {"ab"}}, }, { name: "no usable server name", diff --git a/internal/xray/session.go b/internal/xray/session.go index 478a76f..f4e2fae 100644 --- a/internal/xray/session.go +++ b/internal/xray/session.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "net/netip" "slices" "strings" "time" @@ -15,6 +16,7 @@ import ( "github.com/mrcsin/usher/gen/xray/app/proxyman/command" userproto "github.com/mrcsin/usher/gen/xray/common/protocol" "github.com/mrcsin/usher/gen/xray/common/serial" + "github.com/mrcsin/usher/internal/clients" "github.com/mrcsin/usher/internal/state" ) @@ -37,11 +39,12 @@ type Session struct { closeConn func() inbounds map[string]inbound names []string + host netip.Addr log *slog.Logger } -// Open connects to Xray and lists its inbounds. -func Open(ctx context.Context, dial Dial, log *slog.Logger) (*Session, error) { +// Open connects to Xray and lists its inbounds. host goes into the client links. +func Open(ctx context.Context, dial Dial, host netip.Addr, log *slog.Logger) (*Session, error) { client, closeConn, err := dial() if err != nil { return nil, fmt.Errorf("connecting to xray: %w", err) @@ -57,6 +60,7 @@ func Open(ctx context.Context, dial Dial, log *slog.Logger) (*Session, error) { client: client, closeConn: closeConn, inbounds: make(map[string]inbound, len(listed)), + host: host, log: log, } for _, in := range listed { @@ -71,13 +75,20 @@ func (s *Session) Interfaces() []string { return s.names } -// Prepare enrolls the users on the inbound. It reports whether it changed st. It fails an inbound -// that could not be decoded. +// Prepare enrolls the users on the inbound and renders their link files, keyed by clients.Path. +// It reports whether it changed st. It fails an inbound that could not be decoded. func (s *Session) Prepare(st *state.State, name string, users []string) (map[string][]byte, bool, error) { - if err := s.inbounds[name].err; err != nil { - return nil, false, err + in := s.inbounds[name] + if in.err != nil { + return nil, false, in.err + } + changed := enroll(st, name, users) + files := make(map[string][]byte, len(users)) + for _, user := range users { + link := in.protocol.link(st.Xray[state.EntryName(name, user)], user, s.host, in.port, in.params) + files[clients.Path(user, name, Suffix)] = []byte(link + "\n") } - return nil, enroll(st, name, users), nil + return files, changed, nil } // Apply makes the inbound hold exactly the users: it removes the users that are not wanted or diff --git a/internal/xray/session_test.go b/internal/xray/session_test.go index 8c64ef2..ad77da3 100644 --- a/internal/xray/session_test.go +++ b/internal/xray/session_test.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "maps" + "net/netip" "slices" "strings" "testing" @@ -96,7 +97,7 @@ func newSessionEnv(t *testing.T, xray *fakeXray) *sessionEnv { func (e *sessionEnv) open() *Session { e.t.Helper() dial := func() (command.HandlerServiceClient, func(), error) { return e.xray, func() {}, nil } - s, err := Open(context.Background(), dial, slog.New(slog.NewTextHandler(e.logs, nil))) + s, err := Open(context.Background(), dial, netip.MustParseAddr("203.0.113.7"), slog.New(slog.NewTextHandler(e.logs, nil))) if err != nil { e.t.Fatal(err) } @@ -141,7 +142,7 @@ func TestOpen(t *testing.T) { t.Run("dial error", func(t *testing.T) { dial := func() (command.HandlerServiceClient, func(), error) { return nil, nil, errors.New("no socket") } - if _, err := Open(context.Background(), dial, slog.Default()); err == nil || !strings.Contains(err.Error(), "no socket") { + if _, err := Open(context.Background(), dial, netip.MustParseAddr("203.0.113.7"), slog.Default()); err == nil || !strings.Contains(err.Error(), "no socket") { t.Fatalf("error = %v", err) } }) @@ -151,7 +152,7 @@ func TestOpen(t *testing.T) { dial := func() (command.HandlerServiceClient, func(), error) { return &failingList{}, func() { closed = true }, nil } - if _, err := Open(context.Background(), dial, slog.Default()); err == nil { + if _, err := Open(context.Background(), dial, netip.MustParseAddr("203.0.113.7"), slog.Default()); err == nil { t.Fatal("no error") } if !closed { diff --git a/internal/xray/testdata/vless.txt b/internal/xray/testdata/vless.txt new file mode 100644 index 0000000..2390a93 --- /dev/null +++ b/internal/xray/testdata/vless.txt @@ -0,0 +1 @@ +vless://01020304-0506-0708-090a-0b0c0d0e0f10@203.0.113.7:443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-AbCdE&security=reality&sid=ab&sni=example.com&type=tcp#alice From 415d10817e3ec191f2be5ef289d9b0e8593916f0 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:45:35 +0300 Subject: [PATCH 09/29] feat(cmd): wire the Xray backend --- cmd/usher/env.go | 44 +++++++++++++++++------ cmd/usher/env_test.go | 51 +++++++++++++++++++++++--- cmd/usher/main.go | 57 ++++++++++++++++++++++++++---- cmd/usher/main_test.go | 30 ++++++++++++++++ deploy/compose.example.yml | 17 +++++++++ deploy/xray/config.json | 33 +++++++++++++++++ docs/plans/20261007-xray-driver.md | 10 +++--- 7 files changed, 215 insertions(+), 27 deletions(-) create mode 100644 deploy/xray/config.json diff --git a/cmd/usher/env.go b/cmd/usher/env.go index 9b973bb..9667257 100644 --- a/cmd/usher/env.go +++ b/cmd/usher/env.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "net/netip" "path/filepath" @@ -18,9 +19,10 @@ const ( // environment holds the server facts from the process environment and the fixed paths. type environment struct { pass.Settings - Host netip.Addr // USHER_HOST - DNS []netip.Addr // USHER_DNS - AWGSocket string // USHER_AWG_SOCKET + Host netip.Addr // USHER_HOST + DNS []netip.Addr // USHER_DNS, read only with USHER_AWG_SOCKET + AWGSocket string // USHER_AWG_SOCKET, empty when the backend is off + XraySocket string // USHER_XRAY_SOCKET, empty when the backend is off } func environmentFrom(getenv func(string) string) (environment, error) { @@ -33,21 +35,28 @@ func environmentFrom(getenv func(string) string) (environment, error) { if err != nil { return environment{}, err } + xraySocket, err := parseSocketPath("USHER_XRAY_SOCKET", getenv("USHER_XRAY_SOCKET")) + if err != nil { + return environment{}, err + } + if awgSocket == "" && xraySocket == "" { + return environment{}, errors.New("set USHER_AWG_SOCKET, USHER_XRAY_SOCKET or both") + } var dns []netip.Addr - for _, field := range strings.Split(getenv("USHER_DNS"), ",") { - addr, err := parseIPv4("USHER_DNS", strings.TrimSpace(field)) + if awgSocket != "" { + dns, err = parseDNS(getenv("USHER_DNS")) if err != nil { return environment{}, err } - dns = append(dns, addr) } return environment{ - Settings: pass.Settings{ConfigPath: configPath, ClientsDir: clientsPath, StatePath: statePath}, - Host: host, - DNS: dns, - AWGSocket: awgSocket, + Settings: pass.Settings{ConfigPath: configPath, ClientsDir: clientsPath, StatePath: statePath}, + Host: host, + DNS: dns, + AWGSocket: awgSocket, + XraySocket: xraySocket, }, nil } @@ -62,9 +71,22 @@ func parseIPv4(name, value string) (netip.Addr, error) { return addr, nil } +func parseDNS(value string) ([]netip.Addr, error) { + var dns []netip.Addr + for _, field := range strings.Split(value, ",") { + addr, err := parseIPv4("USHER_DNS", strings.TrimSpace(field)) + if err != nil { + return nil, err + } + dns = append(dns, addr) + } + return dns, nil +} + +// parseSocketPath returns an empty path for an unset variable, which turns the backend off. func parseSocketPath(name, value string) (string, error) { if value == "" { - return "", fmt.Errorf("%s is required", name) + return "", nil } if !filepath.IsAbs(value) { return "", fmt.Errorf("%s: %q is not an absolute path", name, value) diff --git a/cmd/usher/env_test.go b/cmd/usher/env_test.go index 08a38fa..d87fd10 100644 --- a/cmd/usher/env_test.go +++ b/cmd/usher/env_test.go @@ -53,9 +53,48 @@ func TestEnvironmentFrom(t *testing.T) { wantErr: "USHER_HOST", }, { - name: "missing awg socket", + name: "only xray", + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_XRAY_SOCKET": "/run/xray.sock"}, + want: environment{ + Host: netip.MustParseAddr("203.0.113.10"), + XraySocket: "/run/xray.sock", + }, + }, + { + name: "only xray ignores a bad DNS", + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_XRAY_SOCKET": "/run/xray.sock", "USHER_DNS": "dns.example"}, + want: environment{ + Host: netip.MustParseAddr("203.0.113.10"), + XraySocket: "/run/xray.sock", + }, + }, + { + name: "both backends", + env: map[string]string{ + "USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", + "USHER_XRAY_SOCKET": "/run/xray.sock", "USHER_DNS": "1.1.1.1", + }, + want: environment{ + Host: netip.MustParseAddr("203.0.113.10"), + DNS: []netip.Addr{netip.MustParseAddr("1.1.1.1")}, + AWGSocket: "/run/awg.sock", + XraySocket: "/run/xray.sock", + }, + }, + { + name: "no backend", env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_DNS": "1.1.1.1"}, - wantErr: "USHER_AWG_SOCKET", + wantErr: "USHER_AWG_SOCKET, USHER_XRAY_SOCKET", + }, + { + name: "relative xray socket", + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_XRAY_SOCKET": "xray.sock"}, + wantErr: "USHER_XRAY_SOCKET", + }, + { + name: "awg without DNS", + env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_XRAY_SOCKET": "/run/xray.sock"}, + wantErr: "USHER_DNS", }, { name: "relative awg socket", @@ -95,9 +134,11 @@ func TestEnvironmentFrom(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if got.Host != tt.want.Host || !slices.Equal(got.DNS, tt.want.DNS) || got.AWGSocket != tt.want.AWGSocket { - t.Errorf("host/dns/socket = %v %v %q, want %v %v %q", - got.Host, got.DNS, got.AWGSocket, tt.want.Host, tt.want.DNS, tt.want.AWGSocket) + if got.Host != tt.want.Host || !slices.Equal(got.DNS, tt.want.DNS) || + got.AWGSocket != tt.want.AWGSocket || got.XraySocket != tt.want.XraySocket { + t.Errorf("host/dns/sockets = %v %v %q %q, want %v %v %q %q", + got.Host, got.DNS, got.AWGSocket, got.XraySocket, + tt.want.Host, tt.want.DNS, tt.want.AWGSocket, tt.want.XraySocket) } }) } diff --git a/cmd/usher/main.go b/cmd/usher/main.go index 2296521..58e35f3 100644 --- a/cmd/usher/main.go +++ b/cmd/usher/main.go @@ -1,4 +1,4 @@ -// Command usher keeps AmneziaWG peers and client configs in step with config/usher.yml. +// Command usher keeps AmneziaWG and Xray users and client configs in step with config/usher.yml. package main import ( @@ -15,9 +15,11 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/mrcsin/usher/gen/xray/app/proxyman/command" "github.com/mrcsin/usher/internal/awg" "github.com/mrcsin/usher/internal/pass" "github.com/mrcsin/usher/internal/watch" + "github.com/mrcsin/usher/internal/xray" ) // version is set at build time with -X main.version. @@ -41,8 +43,18 @@ func run(ctx context.Context, args []string, getenv func(string) string, stderr } log := slog.New(slog.NewTextHandler(stderr, nil)) - backends := []pass.Backend{awgBackend(env, log)} - return serve(ctx, env.Settings, backends, log) + return serve(ctx, env.Settings, backendsFor(env, log), log) +} + +func backendsFor(env environment, log *slog.Logger) []pass.Backend { + var backends []pass.Backend + if env.AWGSocket != "" { + backends = append(backends, awgBackend(env, log)) + } + if env.XraySocket != "" { + backends = append(backends, xrayBackend(env, log)) + } + return backends } func serve(ctx context.Context, settings pass.Settings, backends []pass.Backend, log *slog.Logger) int { @@ -69,13 +81,46 @@ func awgBackend(env environment, log *slog.Logger) pass.Backend { } } +func xrayBackend(env environment, log *slog.Logger) pass.Backend { + dial := dialXraySocket(env.XraySocket) + return pass.Backend{ + Name: "xray", + Suffix: xray.Suffix, + Open: func(ctx context.Context) (pass.Session, error) { + session, err := xray.Open(ctx, dial, env.Host, log) + if err != nil { + return nil, err + } + return session, nil + }, + } +} + func dialSocket(path string) awg.Dial { - target := "unix://" + path return func() (awgv1.ManagementServiceClient, func(), error) { - conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + conn, err := dialConn(path) if err != nil { - return nil, nil, fmt.Errorf("opening %s: %w", target, err) + return nil, nil, err } return awgv1.NewManagementServiceClient(conn), func() { _ = conn.Close() }, nil } } + +func dialXraySocket(path string) xray.Dial { + return func() (command.HandlerServiceClient, func(), error) { + conn, err := dialConn(path) + if err != nil { + return nil, nil, err + } + return command.NewHandlerServiceClient(conn), func() { _ = conn.Close() }, nil + } +} + +func dialConn(path string) (*grpc.ClientConn, error) { + target := "unix://" + path + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", target, err) + } + return conn, nil +} diff --git a/cmd/usher/main_test.go b/cmd/usher/main_test.go index 0769574..e1ed68e 100644 --- a/cmd/usher/main_test.go +++ b/cmd/usher/main_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "errors" + "io" "log/slog" "path/filepath" + "slices" "strings" "testing" "time" @@ -26,6 +28,7 @@ func TestRun(t *testing.T) { {name: "unknown argument", args: []string{"serve"}, env: validEnv, wantCode: 2, wantStderr: "usage"}, {name: "extra argument", args: []string{"run", "x"}, env: validEnv, wantCode: 2, wantStderr: "usage"}, {name: "missing host", args: []string{"run"}, env: map[string]string{"USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "1.1.1.1"}, wantCode: 1, wantStderr: "USHER_HOST"}, + {name: "no backend", args: []string{"run"}, env: map[string]string{"USHER_HOST": "203.0.113.10"}, wantCode: 1, wantStderr: "USHER_XRAY_SOCKET"}, {name: "bad dns", args: []string{"run"}, env: map[string]string{"USHER_HOST": "203.0.113.10", "USHER_AWG_SOCKET": "/run/awg.sock", "USHER_DNS": "dns.example"}, wantCode: 1, wantStderr: "USHER_DNS"}, } for _, tt := range tests { @@ -89,3 +92,30 @@ func TestServe(t *testing.T) { } } } + +func TestBackendsFor(t *testing.T) { + tests := []struct { + name string + env environment + want []string + }{ + {name: "only awg", env: environment{AWGSocket: "/run/awg.sock"}, want: []string{"awg-grpc"}}, + {name: "only xray", env: environment{XraySocket: "/run/xray.sock"}, want: []string{"xray"}}, + { + name: "both", + env: environment{AWGSocket: "/run/awg.sock", XraySocket: "/run/xray.sock"}, + want: []string{"awg-grpc", "xray"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []string + for _, b := range backendsFor(tt.env, slog.New(slog.NewTextHandler(io.Discard, nil))) { + got = append(got, b.Name) + } + if !slices.Equal(got, tt.want) { + t.Errorf("backends = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/deploy/compose.example.yml b/deploy/compose.example.yml index 891102c..9444747 100644 --- a/deploy/compose.example.yml +++ b/deploy/compose.example.yml @@ -21,6 +21,18 @@ services: environment: # Must equal usher's group. AWG_GRPC_SOCKET_GID: "1000" + xray: + image: ghcr.io/xtls/xray-core:26.9.9 + restart: unless-stopped + # Root uid for the port and the socket; the group must equal usher's group. + user: "0:1000" + command: ["run", "-config", "/etc/xray/config.json"] + ports: + - "443:443/tcp" + volumes: + # Replace the example Reality private key and short ID before use: `xray x25519`. + - ./xray/config.json:/etc/xray/config.json:ro + - xray-sock:/run/xray usher: image: ghcr.io/mrcsin/usher:v0.1.0 restart: unless-stopped @@ -30,16 +42,21 @@ services: depends_on: awg: condition: service_healthy + xray: + condition: service_started volumes: # The host directories belong to uid 1000. - ./usher/config:/srv/usher/config:ro - ./usher/clients:/srv/usher/clients - usher-state:/srv/usher/state - awg-sock:/run/awg-grpc + - xray-sock:/run/xray environment: USHER_HOST: "203.0.113.10" USHER_AWG_SOCKET: /run/awg-grpc/awg.sock + USHER_XRAY_SOCKET: /run/xray/api.sock USHER_DNS: "1.1.1.1,1.0.0.1" volumes: awg-sock: + xray-sock: usher-state: diff --git a/deploy/xray/config.json b/deploy/xray/config.json new file mode 100644 index 0000000..b7326f6 --- /dev/null +++ b/deploy/xray/config.json @@ -0,0 +1,33 @@ +{ + "log": { "loglevel": "warning" }, + "api": { + "tag": "api", + "listen": "/run/xray/api.sock,0660", + "services": ["HandlerService"] + }, + "inbounds": [ + { + "tag": "vless-reality", + "listen": "0.0.0.0", + "port": 443, + "protocol": "vless", + "settings": { + "clients": [], + "decryption": "none" + }, + "streamSettings": { + "network": "raw", + "security": "reality", + "realitySettings": { + "target": "example.com:443", + "serverNames": ["example.com"], + "privateKey": "EXAMPLE-REPLACE-WITH-XRAY-X25519-PRIVATE-00", + "shortIds": ["0123456789abcdef"] + } + } + } + ], + "outbounds": [ + { "tag": "direct", "protocol": "freedom" } + ] +} diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index 61e3c50..fc60ee2 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -458,16 +458,16 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Modify: `cmd/usher/env_test.go` - Modify: `cmd/usher/main_test.go` -- [ ] read `USHER_XRAY_SOCKET`, absolute when set; make `USHER_AWG_SOCKET` optional, require at +- [x] read `USHER_XRAY_SOCKET`, absolute when set; make `USHER_AWG_SOCKET` optional, require at least one of the two, and require `USHER_DNS` only with `USHER_AWG_SOCKET` -- [ ] build the Xray backend when `USHER_XRAY_SOCKET` is set, with the 10 s call deadline of +- [x] build the Xray backend when `USHER_XRAY_SOCKET` is set, with the 10 s call deadline of `pass.go:21` -- [ ] add an `xray` service to `compose.example.yml`: the official image, `user: "0:1000"`, the +- [x] add an `xray` service to `compose.example.yml`: the official image, `user: "0:1000"`, the example `config.json` (api on `/run/xray/api.sock,0660`, `HandlerService` only, one VLESS Reality inbound with `clients: []`) and the shared socket volume -- [ ] write tests: only Xray, only awg, both, neither (usage error), `USHER_DNS` missing with and +- [x] write tests: only Xray, only awg, both, neither (usage error), `USHER_DNS` missing with and without awg -- [ ] run `go test -race ./...` - must pass before Task 9 +- [x] run `go test -race ./...` - must pass before Task 9 ### Task 9: Xray end-to-end suite From c5a9f2c8bdd0b213b0c53e49137ae3ed94f73ce3 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:48:54 +0300 Subject: [PATCH 10/29] test(xray): add the Xray e2e suite --- .github/workflows/ci.yml | 3 + .gitignore | 1 + deploy/compose.e2e-xray.yml | 75 ++++++++ deploy/e2e/xray/nginx.conf | 22 +++ deploy/e2e/xray/server.json | 33 ++++ deploy/e2e/xray/target.crt | 10 ++ deploy/e2e/xray/target.key | 5 + test/e2e/xray/xray_test.go | 349 ++++++++++++++++++++++++++++++++++++ 8 files changed, 498 insertions(+) create mode 100644 deploy/compose.e2e-xray.yml create mode 100644 deploy/e2e/xray/nginx.conf create mode 100644 deploy/e2e/xray/server.json create mode 100644 deploy/e2e/xray/target.crt create mode 100644 deploy/e2e/xray/target.key create mode 100644 test/e2e/xray/xray_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96f14dd..eaf5e37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,3 +64,6 @@ jobs: - name: e2e test run: go test -tags e2e -count=1 -v ./test/e2e/ + + - name: xray e2e test + run: go test -tags e2e -count=1 -v ./test/e2e/xray/ diff --git a/.gitignore b/.gitignore index 776e188..bd6d940 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ Thumbs.db .idea/ .vs/ .e2e/ +.e2e-xray/ diff --git a/deploy/compose.e2e-xray.yml b/deploy/compose.e2e-xray.yml new file mode 100644 index 0000000..04d79c0 --- /dev/null +++ b/deploy/compose.e2e-xray.yml @@ -0,0 +1,75 @@ +name: ushere2exray +services: + # Reality target and plain HTTP page that the proxied requests fetch. + target: + image: nginx:1.27-alpine + networks: + e2e: + ipv4_address: 172.30.98.3 + volumes: + - ./e2e/xray/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./e2e/xray/target.crt:/etc/nginx/certs/target.crt:ro + - ./e2e/xray/target.key:/etc/nginx/certs/target.key:ro + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + interval: 1s + retries: 30 + xray: + image: ghcr.io/xtls/xray-core:26.9.9 + # Root uid for the port and the socket; the group equals usher's group. + user: "0:${E2E_GID:-0}" + command: ["run", "-config", "/etc/xray/config.json"] + depends_on: + target: + condition: service_healthy + networks: + e2e: + ipv4_address: 172.30.98.2 + volumes: + - ./e2e/xray/server.json:/etc/xray/config.json:ro + - ../.e2e-xray/sock:/run/xray + usher: + build: + context: .. + image: usher:e2e + user: ${E2E_UID:-0}:${E2E_GID:-0} + cap_drop: [ALL] + read_only: true + security_opt: [no-new-privileges:true] + depends_on: + xray: + condition: service_started + networks: [e2e] + volumes: + - ../.e2e-xray/config:/srv/usher/config:ro + - ../.e2e-xray/clients:/srv/usher/clients + - ../.e2e-xray/state:/srv/usher/state + - ../.e2e-xray/sock:/run/xray + environment: + USHER_HOST: 172.30.98.2 + USHER_XRAY_SOCKET: /run/xray/api.sock + # Client of one user; the test writes its config to ../.e2e-xray/client//config.json and + # reaches its HTTP proxy at the fixed address. + client-alice: + profiles: [client] + image: ghcr.io/xtls/xray-core:26.9.9 + command: ["run", "-config", "/etc/xray/config.json"] + networks: + e2e: + ipv4_address: 172.30.98.11 + volumes: + - ../.e2e-xray/client/alice/config.json:/etc/xray/config.json:ro + client-bob: + profiles: [client] + image: ghcr.io/xtls/xray-core:26.9.9 + command: ["run", "-config", "/etc/xray/config.json"] + networks: + e2e: + ipv4_address: 172.30.98.12 + volumes: + - ../.e2e-xray/client/bob/config.json:/etc/xray/config.json:ro +networks: + e2e: + ipam: + config: + - subnet: 172.30.98.0/24 diff --git a/deploy/e2e/xray/nginx.conf b/deploy/e2e/xray/nginx.conf new file mode 100644 index 0000000..c5c7293 --- /dev/null +++ b/deploy/e2e/xray/nginx.conf @@ -0,0 +1,22 @@ +# Test-only Reality target of the Xray e2e suite. The certificate and key in this directory are +# committed on purpose and protect nothing. +server { + listen 80; + default_type text/plain; + location / { + return 200 "usher-e2e-target\n"; + } +} + +server { + listen 443 ssl; + http2 on; + server_name target.e2e; + ssl_certificate /etc/nginx/certs/target.crt; + ssl_certificate_key /etc/nginx/certs/target.key; + ssl_protocols TLSv1.3; + default_type text/plain; + location / { + return 200 "usher-e2e-target\n"; + } +} diff --git a/deploy/e2e/xray/server.json b/deploy/e2e/xray/server.json new file mode 100644 index 0000000..627ba79 --- /dev/null +++ b/deploy/e2e/xray/server.json @@ -0,0 +1,33 @@ +{ + "log": { "loglevel": "warning" }, + "api": { + "tag": "api", + "listen": "/run/xray/api.sock,0660", + "services": ["HandlerService"] + }, + "inbounds": [ + { + "tag": "vless-reality", + "listen": "0.0.0.0", + "port": 443, + "protocol": "vless", + "settings": { + "clients": [], + "decryption": "none" + }, + "streamSettings": { + "network": "raw", + "security": "reality", + "realitySettings": { + "target": "target:443", + "serverNames": ["target.e2e"], + "privateKey": "vo6FtYE8B1Gdc2qDj35dZPqY2ciiIh3oITS0u0xUE9Q", + "shortIds": ["0123456789abcdef"] + } + } + } + ], + "outbounds": [ + { "tag": "direct", "protocol": "freedom" } + ] +} diff --git a/deploy/e2e/xray/target.crt b/deploy/e2e/xray/target.crt new file mode 100644 index 0000000..5585436 --- /dev/null +++ b/deploy/e2e/xray/target.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBeDCCAR2gAwIBAgIUTCI1efLW4GtS4+IlTcNaeybofbMwCgYIKoZIzj0EAwIw +FTETMBEGA1UEAwwKdGFyZ2V0LmUyZTAgFw0yNjEwMDkxMzQ4MDFaGA8yMTI2MDkx +NTEzNDgwMVowFTETMBEGA1UEAwwKdGFyZ2V0LmUyZTBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABOqvJ3ybmrhGOcyOiShkBnfxvBHCfknrSn/9hfS4U56aN0sNSyGJ +MdgAEgrX3mM1VgmpV3vvONLDs3JhfIuQYIujSTBHMB0GA1UdDgQWBBQa9oksEEtx +GIw26VZAP7MAgbjq2TAPBgNVHRMBAf8EBTADAQH/MBUGA1UdEQQOMAyCCnRhcmdl +dC5lMmUwCgYIKoZIzj0EAwIDSQAwRgIhAKEExRx9zApYyGWqprdrWgpcs9D7pAOi +dkP3F+n48w+yAiEA5SBpddEgzsv1L53iOIQZ3OiSxjAzfip+xjYmnYfz8qc= +-----END CERTIFICATE----- diff --git a/deploy/e2e/xray/target.key b/deploy/e2e/xray/target.key new file mode 100644 index 0000000..74d7ce4 --- /dev/null +++ b/deploy/e2e/xray/target.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIljW3PlDFVEPLt2i +ME1RXkTqfByoBrcJIHqAA7zolBWhRANCAATqryd8m5q4RjnMjokoZAZ38bwRwn5J +60p//YX0uFOemjdLDUshiTHYABIK195jNVYJqVd77zjSw7NyYXyLkGCL +-----END PRIVATE KEY----- diff --git a/test/e2e/xray/xray_test.go b/test/e2e/xray/xray_test.go new file mode 100644 index 0000000..71b8d04 --- /dev/null +++ b/test/e2e/xray/xray_test.go @@ -0,0 +1,349 @@ +//go:build e2e + +// Package xray drives usher against the official Xray image through deploy/compose.e2e-xray.yml. +// It needs Docker with compose and no kernel module; AGENTS.md, Test, runs it. +package xray + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +const ( + // repoDir is the repository root relative to this package; go test runs in the package dir. + repoDir = "../../.." + composeFile = "deploy/compose.e2e-xray.yml" + clientProfile = "client" + tag = "vless-reality" + targetURL = "http://target/" + targetBody = "usher-e2e-target" + proxyPort = "8080" + + // The deadlines follow the production constants: 3 s file poll, 500 ms settle, 30 s refill. + changeTimeout = 10 * time.Second + refillTimeout = 60 * time.Second + requestTimeout = 5 * time.Second +) + +// The bind-mount directories of the compose project, under scratchRoot. +const ( + scratchRoot = ".e2e-xray" + configDir = "config" + clientsDir = "clients" + stateDir = "state" + sockDir = "sock" + clientDir = "client" +) + +var scratchDirs = []string{configDir, clientsDir, stateDir, sockDir, clientDir} + +// proxies maps each user to the HTTP proxy of that user's client container. +var proxies = map[string]string{ + "alice": "172.30.98.11", + "bob": "172.30.98.12", +} + +func e2ePath(elem ...string) string { + return filepath.Join(append([]string{repoDir, scratchRoot}, elem...)...) +} + +func TestMain(m *testing.M) { + os.Exit(runMain(m)) +} + +// runMain creates the bind-mount directories as the test user, so dockerd does not create them +// as root and usher, which runs as the test user, can write them. A run that was killed leaves +// the project up, so runMain takes it down before it starts. +func runMain(m *testing.M) int { + flag.Parse() + if count := flag.Lookup("test.count").Value.String(); count != "1" { + fmt.Fprintf(os.Stderr, "-count=%s: TestXray restarts the xray service, so it runs once per process; use -count=1\n", count) + return 1 + } + down() + if err := os.RemoveAll(e2ePath()); err != nil { + fmt.Fprintf(os.Stderr, "removing %s: %v\n", scratchRoot, err) + return 1 + } + for _, dir := range scratchDirs { + if err := os.MkdirAll(e2ePath(dir), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "creating %s: %v\n", dir, err) + return 1 + } + } + for user := range proxies { + if err := os.MkdirAll(e2ePath(clientDir, user), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "creating client dir of %s: %v\n", user, err) + return 1 + } + } + defer down() + + out, err := composeCmd("up", "-d", "--build", "--wait", "usher").CombinedOutput() + fmt.Fprintf(os.Stderr, "docker compose up:\n%s\n", out) + if err != nil { + fmt.Fprintf(os.Stderr, "docker compose up: %v\n", err) + printLogs() + return 1 + } + code := m.Run() + if code != 0 { + printLogs() + } + return code +} + +func down() { + out, err := composeCmd("--profile", clientProfile, "down", "-v", "--remove-orphans", "--timeout", "10").CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "docker compose down: %v\n%s\n", err, out) + } +} + +func printLogs() { + out, err := composeCmd("--profile", clientProfile, "logs", "--no-color").CombinedOutput() + fmt.Fprintf(os.Stderr, "docker compose logs (err %v):\n%s\n", err, out) +} + +// composeCmd builds docker compose on the e2e project, run from the repository root with the +// uid and gid of the test user, so usher can write the bind mounts and open the socket. +func composeCmd(args ...string) *exec.Cmd { + cmd := exec.Command("docker", append([]string{"compose", "-f", composeFile}, args...)...) + cmd.Dir = repoDir + cmd.Env = append(os.Environ(), + "E2E_UID="+strconv.Itoa(os.Getuid()), + "E2E_GID="+strconv.Itoa(os.Getgid())) + return cmd +} + +func compose(t *testing.T, args ...string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := composeCmd(args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("docker compose %s: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout.String(), stderr.String()) + } +} + +// TestXray runs its subtests in order; each one starts from the state the previous one left. +func TestXray(t *testing.T) { + steps := []struct { + name string + run func(t *testing.T) + }{ + {"connect", testConnect}, + {"switch off", testSwitchOff}, + {"refill", testRefill}, + } + for _, step := range steps { + if !t.Run(step.name, step.run) { + t.FailNow() + } + } +} + +// testConnect writes a config of two users, builds a client from each rendered link and fetches +// the target page through it. +func testConnect(t *testing.T) { + waitFor(t, within(changeTimeout), "the API socket of xray", func() error { + _, err := os.Stat(e2ePath(sockDir, "api.sock")) + return err + }) + writeUsherConfig(t, usherYAML("alice", "bob")) + deadline := within(changeTimeout) + for user := range proxies { + var link string + waitFor(t, deadline, "link file of "+user, func() error { + var err error + link, err = readLink(user) + return err + }) + writeClientConfig(t, user, link) + } + compose(t, "--profile", clientProfile, "up", "-d", "client-alice", "client-bob") + for user, proxy := range proxies { + waitFor(t, within(changeTimeout), "a page through the link of "+user, func() error { + return fetch(proxy) + }) + } +} + +// testSwitchOff removes bob from usher.yml. A new connection through bob's link must fail while +// alice's link still works. +func testSwitchOff(t *testing.T) { + writeUsherConfig(t, usherYAML("alice")) + waitFor(t, within(changeTimeout), "a failing connection through the link of bob", func() error { + if err := fetch(proxies["bob"]); err == nil { + return errors.New("the link of bob still works") + } + return nil + }) + if err := fetch(proxies["alice"]); err != nil { + t.Fatalf("link of alice after bob was removed: %v", err) + } +} + +// testRefill restarts xray, which empties its user list. usher must put alice back on its refill +// ticker. +func testRefill(t *testing.T) { + compose(t, "restart", "xray") + waitFor(t, within(refillTimeout), "a page through the link of alice after the restart", func() error { + return fetch(proxies["alice"]) + }) +} + +func usherYAML(users ...string) string { + var b strings.Builder + for _, user := range users { + fmt.Fprintf(&b, "%s: [%s]\n", user, tag) + } + return b.String() +} + +func writeUsherConfig(t *testing.T, content string) { + t.Helper() + dir := e2ePath(configDir) + tmp := filepath.Join(dir, ".usher.yml.tmp") + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + t.Fatalf("writing usher.yml: %v", err) + } + if err := os.Rename(tmp, filepath.Join(dir, "usher.yml")); err != nil { + t.Fatalf("replacing usher.yml: %v", err) + } +} + +// readLink returns the rendered link of the user, or the reason it is not there as a mode 0600 +// file yet. +func readLink(user string) (string, error) { + path := e2ePath(clientsDir, user, tag+".txt") + info, err := os.Stat(path) + if err != nil { + return "", err + } + if info.Mode().Perm() != 0o600 { + return "", fmt.Errorf("mode of the link of %s = %v, want 0600", user, info.Mode().Perm()) + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// writeClientConfig builds the Xray client config of a user from the rendered link. pbk, sid and +// sni go into the config unchanged. +func writeClientConfig(t *testing.T, user, link string) { + t.Helper() + parsed, err := url.Parse(link) + if err != nil { + t.Fatalf("parsing the link of %s: %v", user, err) + } + host, portText, err := net.SplitHostPort(parsed.Host) + if err != nil { + t.Fatalf("splitting the host of the link of %s: %v", user, err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("port of the link of %s: %v", user, err) + } + query := parsed.Query() + config := map[string]any{ + "log": map[string]any{"loglevel": "warning"}, + "inbounds": []any{map[string]any{ + "listen": "0.0.0.0", + "port": 8080, + "protocol": "http", + }}, + "outbounds": []any{map[string]any{ + "protocol": "vless", + "settings": map[string]any{ + "address": host, + "port": port, + "id": parsed.User.Username(), + "flow": query.Get("flow"), + "encryption": query.Get("encryption"), + }, + "streamSettings": map[string]any{ + "network": "raw", + "security": query.Get("security"), + "realitySettings": map[string]any{ + "serverName": query.Get("sni"), + "fingerprint": query.Get("fp"), + "publicKey": query.Get("pbk"), + "shortId": query.Get("sid"), + }, + }, + }}, + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + t.Fatalf("encoding the client config of %s: %v", user, err) + } + if err := os.WriteFile(e2ePath(clientDir, user, "config.json"), data, 0o644); err != nil { + t.Fatalf("writing the client config of %s: %v", user, err) + } +} + +// fetch requests the target page through the HTTP proxy of one client container and reports why +// the request did not return the page with status 200. +func fetch(proxyHost string) error { + proxy, err := url.Parse("http://" + net.JoinHostPort(proxyHost, proxyPort)) + if err != nil { + return err + } + client := &http.Client{ + Timeout: requestTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxy), + DisableKeepAlives: true, + }, + } + resp, err := client.Get(targetURL) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d, want 200", resp.StatusCode) + } + if !strings.Contains(string(body), targetBody) { + return fmt.Errorf("body %q does not come from the target", body) + } + return nil +} + +func within(timeout time.Duration) time.Time { + return time.Now().Add(timeout) +} + +func waitFor(t *testing.T, deadline time.Time, what string, check func() error) { + t.Helper() + var err error + for time.Now().Before(deadline) { + if err = check(); err == nil { + return + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("no %s before the deadline: %v", what, err) +} From 837f3dee457b10e471ad134293ef17a9ccfb3bc0 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:53:37 +0300 Subject: [PATCH 11/29] fix(xray): key security decoders by proto type name --- internal/xray/inbound.go | 2 +- internal/xray/inbound_test.go | 7 ++++--- internal/xray/security.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/xray/inbound.go b/internal/xray/inbound.go index 7373e6c..6afecdc 100644 --- a/internal/xray/inbound.go +++ b/internal/xray/inbound.go @@ -85,7 +85,7 @@ func decodeInbound(config *core.InboundHandlerConfig, decodeProtocol func([]byte for key, values := range securityParams { params[key] = values } - decoded, err := decodeProtocol(config.GetProxySettings().GetValue(), transportName, securityName) + decoded, err := decodeProtocol(config.GetProxySettings().GetValue(), transportName, params.Get("security")) if err != nil { return inbound{}, err } diff --git a/internal/xray/inbound_test.go b/internal/xray/inbound_test.go index 4a67499..834ecec 100644 --- a/internal/xray/inbound_test.go +++ b/internal/xray/inbound_test.go @@ -18,6 +18,7 @@ import ( "github.com/mrcsin/usher/gen/xray/core" vlessinbound "github.com/mrcsin/usher/gen/xray/proxy/vless/inbound" "github.com/mrcsin/usher/gen/xray/transport/internet" + "github.com/mrcsin/usher/gen/xray/transport/internet/reality" ) // fakeHandlerService implements command.HandlerServiceClient with func fields. @@ -39,7 +40,7 @@ func realityStream(t *testing.T) *internet.StreamConfig { t.Helper() return &internet.StreamConfig{ ProtocolName: "tcp", - SecurityType: "reality", + SecurityType: typeName(&reality.Config{}), SecuritySettings: realitySettings(t, validReality(t)), } } @@ -162,8 +163,8 @@ func TestInboundErrorsNeverContainPrivateKey(t *testing.T) { tls.SecurityType = "tls" streams := map[string]*internet.StreamConfig{ - "no server names": {ProtocolName: "tcp", SecurityType: "reality", SecuritySettings: realitySettings(t, bad)}, - "long key": {ProtocolName: "tcp", SecurityType: "reality", SecuritySettings: realitySettings(t, shortKey)}, + "no server names": {ProtocolName: "tcp", SecurityType: typeName(&reality.Config{}), SecuritySettings: realitySettings(t, bad)}, + "long key": {ProtocolName: "tcp", SecurityType: typeName(&reality.Config{}), SecuritySettings: realitySettings(t, shortKey)}, "garbled": garbled, "tls with key": tls, } diff --git a/internal/xray/security.go b/internal/xray/security.go index 44de7de..693c865 100644 --- a/internal/xray/security.go +++ b/internal/xray/security.go @@ -21,10 +21,10 @@ const securityNone = "none" // fingerprintChrome is the TLS client fingerprint of every link; Reality requires one. const fingerprintChrome = "chrome" -// securities maps a stream's security type to its decoder. The decoder returns the link -// parameters of the security. +// securities maps a stream's security type, the full proto name of its settings message, to its +// decoder. The decoder returns the link parameters of the security. var securities = map[string]func(settings []*serial.TypedMessage) (url.Values, error){ - securityReality: decodeReality, + typeName(&reality.Config{}): decodeReality, } // decodeReality derives the link parameters from the server's Reality settings. The private key From 6631afe2a2ccd9bc8e803a2e9187194e9215e279 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:57:38 +0300 Subject: [PATCH 12/29] test(xray): debug logging (temporary) --- deploy/e2e/xray/server.json | 2 +- test/e2e/xray/xray_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/e2e/xray/server.json b/deploy/e2e/xray/server.json index 627ba79..45676b0 100644 --- a/deploy/e2e/xray/server.json +++ b/deploy/e2e/xray/server.json @@ -1,5 +1,5 @@ { - "log": { "loglevel": "warning" }, + "log": { "loglevel": "debug" }, "api": { "tag": "api", "listen": "/run/xray/api.sock,0660", diff --git a/test/e2e/xray/xray_test.go b/test/e2e/xray/xray_test.go index 71b8d04..5bc72cb 100644 --- a/test/e2e/xray/xray_test.go +++ b/test/e2e/xray/xray_test.go @@ -264,7 +264,7 @@ func writeClientConfig(t *testing.T, user, link string) { } query := parsed.Query() config := map[string]any{ - "log": map[string]any{"loglevel": "warning"}, + "log": map[string]any{"loglevel": "debug"}, "inbounds": []any{map[string]any{ "listen": "0.0.0.0", "port": 8080, From e47b4cc35f9707a495b0a2258f5a3f1f32c886cb Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:01:50 +0300 Subject: [PATCH 13/29] test(xray): allow the freedom outbound to reach target --- deploy/e2e/xray/server.json | 12 ++++++++++-- test/e2e/xray/xray_test.go | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/deploy/e2e/xray/server.json b/deploy/e2e/xray/server.json index 45676b0..62a07ed 100644 --- a/deploy/e2e/xray/server.json +++ b/deploy/e2e/xray/server.json @@ -1,5 +1,5 @@ { - "log": { "loglevel": "debug" }, + "log": { "loglevel": "warning" }, "api": { "tag": "api", "listen": "/run/xray/api.sock,0660", @@ -28,6 +28,14 @@ } ], "outbounds": [ - { "tag": "direct", "protocol": "freedom" } + { + "tag": "direct", + "protocol": "freedom", + "settings": { + "finalRules": [ + { "action": "allow", "ip": ["172.30.98.3/32"] } + ] + } + } ] } diff --git a/test/e2e/xray/xray_test.go b/test/e2e/xray/xray_test.go index 5bc72cb..71b8d04 100644 --- a/test/e2e/xray/xray_test.go +++ b/test/e2e/xray/xray_test.go @@ -264,7 +264,7 @@ func writeClientConfig(t *testing.T, user, link string) { } query := parsed.Query() config := map[string]any{ - "log": map[string]any{"loglevel": "debug"}, + "log": map[string]any{"loglevel": "warning"}, "inbounds": []any{map[string]any{ "listen": "0.0.0.0", "port": 8080, From 46ca9c061e6fbacf37bbaf699db7e492ca91a19a Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:05:19 +0300 Subject: [PATCH 14/29] docs(plans): mark the Xray e2e task done --- docs/plans/20261007-xray-driver.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index fc60ee2..a415d07 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -479,14 +479,14 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - Modify: `.gitignore` (`.e2e-xray`) - Modify: `.github/workflows/ci.yml` -- [ ] compose project with Xray server (official image, pinned tag, `user: "0:${E2E_GID:-0}"`), +- [x] compose project with Xray server (official image, pinned tag, `user: "0:${E2E_GID:-0}"`), nginx with a self-signed certificate and `ssl_protocols TLSv1.3` as the Reality target, usher built from the tree, and an Xray client profile -- [ ] the test config holds two users; the test builds each client JSON by copying `pbk`, `sid` +- [x] the test config holds two users; the test builds each client JSON by copying `pbk`, `sid` and `sni` from the rendered link unchanged into `publicKey`, `shortId` and `serverName` -- [ ] assert the three outcomes of Acceptance Evidence item 3 -- [ ] run the suite in the CI `e2e` job next to `ci.yml:62` -- [ ] run `go test -tags e2e -count=1 -v ./test/e2e/xray/` - must pass before Task 10 +- [x] assert the three outcomes of Acceptance Evidence item 3 +- [x] run the suite in the CI `e2e` job next to `ci.yml:62` +- [x] run `go test -tags e2e -count=1 -v ./test/e2e/xray/` - must pass before Task 10 (run in GitHub CI, not locally: no Docker on the dev host) ### Task 10: Verify acceptance criteria From 0ce0474f5da2d1a57805992b0f19a9ab11917550 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:12:21 +0300 Subject: [PATCH 15/29] test(xray): cover receiver and API error paths --- internal/xray/inbound_test.go | 10 +++++++++ internal/xray/session_test.go | 41 ++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/internal/xray/inbound_test.go b/internal/xray/inbound_test.go index 834ecec..f1eec30 100644 --- a/internal/xray/inbound_test.go +++ b/internal/xray/inbound_test.go @@ -114,11 +114,21 @@ func TestListInboundsFailsUnsupportedShapes(t *testing.T) { websocket := realityStream(t) websocket.ProtocolName = "websocket" + wrongReceiver := inboundConfig("a", portList(443, 443), realityStream(t)) + wrongReceiver.ReceiverSettings = toTypedMessage(&vlessinbound.Config{}) + garbledReceiver := inboundConfig("a", portList(443, 443), realityStream(t)) + garbledReceiver.ReceiverSettings = &serial.TypedMessage{Type: typeName(&proxyman.ReceiverConfig{}), Value: []byte{0xff}} + garbledProxy := inboundConfig("a", portList(443, 443), realityStream(t)) + garbledProxy.ProxySettings = &serial.TypedMessage{Type: typeName(&vlessinbound.Config{}), Value: []byte{0xff}} + tests := []struct { name string config *core.InboundHandlerConfig wantErr string }{ + {name: "receiver of another type", config: wrongReceiver, wantErr: "receiver settings have type"}, + {name: "undecodable receiver", config: garbledReceiver, wantErr: "decoding receiver settings"}, + {name: "undecodable vless settings", config: garbledProxy, wantErr: "decoding vless settings"}, {name: "port range", config: inboundConfig("a", portList(443, 450), realityStream(t)), wantErr: "port"}, {name: "no port", config: inboundConfig("a", nil, realityStream(t)), wantErr: "port"}, {name: "tls security", config: inboundConfig("a", portList(443, 443), tls), wantErr: `security "tls"`}, diff --git a/internal/xray/session_test.go b/internal/xray/session_test.go index ad77da3..60eede5 100644 --- a/internal/xray/session_test.go +++ b/internal/xray/session_test.go @@ -32,7 +32,11 @@ type fakeXray struct { users map[string]map[string]*serial.TypedMessage // failAdd names an email whose AddUserOperation fails. failAdd string - calls []string + // failRemove names an email whose RemoveUserOperation fails. + failRemove string + // failUsers makes GetInboundUsers fail. + failUsers bool + calls []string } func newFakeXray(configs ...*core.InboundHandlerConfig) *fakeXray { @@ -48,6 +52,9 @@ func (f *fakeXray) ListInbounds(context.Context, *command.ListInboundsRequest, . } func (f *fakeXray) GetInboundUsers(_ context.Context, in *command.GetInboundUserRequest, _ ...grpc.CallOption) (*command.GetInboundUserResponse, error) { + if f.failUsers { + return nil, errors.New("xray cannot list users") + } var response command.GetInboundUserResponse for _, email := range slices.Sorted(maps.Keys(f.users[in.GetTag()])) { response.Users = append(response.Users, &userproto.User{Email: email, Account: f.users[in.GetTag()][email]}) @@ -75,6 +82,9 @@ func (f *fakeXray) AlterInbound(_ context.Context, in *command.AlterInboundReque return nil, err } f.calls = append(f.calls, "remove "+remove.GetEmail()) + if remove.GetEmail() == f.failRemove { + return nil, errors.New("xray refused " + remove.GetEmail()) + } delete(f.users[in.GetTag()], remove.GetEmail()) default: return nil, errors.New("unexpected operation " + operation.GetType()) @@ -302,6 +312,35 @@ func TestApplyConvergesAfterPartialFailure(t *testing.T) { } } +func TestApplyFailsOnXrayErrors(t *testing.T) { + t.Run("listing users", func(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + env.xray.failUsers = true + err := env.pass("vless", "alice") + if err == nil || !strings.Contains(err.Error(), "getting users") { + t.Fatalf("error = %v", err) + } + }) + t.Run("removing a user", func(t *testing.T) { + env := newSessionEnv(t, twoInbounds(t)) + if err := env.pass("vless", "alice", "bob"); err != nil { + t.Fatal(err) + } + env.xray.failRemove = "vless/bob" + err := env.pass("vless", "alice") + if err == nil || !strings.Contains(err.Error(), "removing user bob") { + t.Fatalf("error = %v", err) + } + env.xray.failRemove = "" + if err := env.pass("vless", "alice"); err != nil { + t.Fatal(err) + } + if env.accountOf("vless", "bob") != nil { + t.Error("bob is still present after the next pass") + } + }) +} + func TestPrepareFailsUndecodableInbound(t *testing.T) { websocket := realityStream(t) websocket.ProtocolName = "websocket" From 6e3e853283fcaf6e66d6c2f36755ba957be66ed4 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:16:06 +0300 Subject: [PATCH 16/29] docs(plans): mark the acceptance task done --- docs/plans/20261007-xray-driver.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index a415d07..fae5ce3 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -490,13 +490,13 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. ### Task 10: Verify acceptance criteria -- [ ] verify all requirements from Overview are implemented -- [ ] verify edge cases are handled -- [ ] run full test suite: `go test -race -count=1 ./...` -- [ ] run `scripts/format.sh`, `shellcheck scripts/*.sh scripts/ci/*.sh scripts/git-hooks/pre-commit` +- [x] verify all requirements from Overview are implemented +- [x] verify edge cases are handled +- [x] run full test suite: `go test -race -count=1 ./...` +- [x] run `scripts/format.sh`, `shellcheck scripts/*.sh scripts/ci/*.sh scripts/git-hooks/pre-commit` and `go run github.com/rhysd/actionlint/cmd/actionlint@latest` -- [ ] run e2e tests: `go test -tags e2e -count=1 -v ./test/e2e/...` on a host with the module -- [ ] verify test coverage meets project standard +- [x] run e2e tests: `go test -tags e2e -count=1 -v ./test/e2e/...` on a host with the module (run in GitHub CI, run 37942456271 on 0ce0474: awg and Xray suites pass; no Docker or module on the dev host) +- [x] verify test coverage meets project standard ### Task 11: [Final] Update documentation From 8b8993116b12b71202110232421bfcd3be709cdf Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:19:58 +0300 Subject: [PATCH 17/29] docs(repo): document the Xray backend --- AGENTS.md | 75 ++++++++++---- README.md | 93 ++++++++++++++---- docs/architecture/overview.md | 152 +++++++++++++++++++++++------ docs/plans/20261007-xray-driver.md | 8 +- 4 files changed, 259 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6bbf520..3f85551 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ # 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 +peers of AmneziaWG interfaces, the users of Xray VLESS inbounds 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 @@ -24,7 +25,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 is faked the same way for +`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 @@ -36,7 +38,19 @@ GOOS=linux GOARCH=amd64 go test -c -tags e2e -o e2e.test ./test/e2e ./e2e.test -test.count=1 -test.v ``` -The suite needs `docker compose` without `sudo`. It wipes and recreates the bind-mount directories +The Xray suite in `test/e2e/xray` carries the same tag and needs Docker only, no kernel module. It +has its own `TestMain`, compose project +(`deploy/compose.e2e-xray.yml`, network `172.30.98.0/24`) and scratch root `.e2e-xray/` +(gitignored). It starts the official Xray image with a self-signed nginx as the Reality target, +builds usher from the tree and drives two users through client containers built from the rendered +links. The server config allows the target address in the freedom outbound `finalRules`, because +Xray 26.x blocks private destinations for VLESS by default. Run it with `-count=1`: + +```sh +go test -tags e2e -count=1 -v ./test/e2e/xray/ +``` + +Both suites need `docker compose` without `sudo`. It wipes and recreates the bind-mount directories under `.e2e/` (gitignored) at start, and passes `E2E_UID` and `E2E_GID` to compose so usher writes them as the test user. @@ -47,14 +61,25 @@ tar adds `._*` AppleDouble files. A run killed by a signal leaves containers up; docker compose -f deploy/compose.e2e.yml --profile handshake down -v --remove-orphans ``` +## Generate + +`gen/xray` comes from `proto/xray`; edit the protos only to refresh them (see +`proto/xray/README.md`). Regenerate with: + +```sh +go tool -modfile=tools/go.mod buf generate +``` + +`scripts/format.sh` fails when `gen/xray` differs from the output of that command. + ## Format ```sh scripts/format.sh ``` -Run before presenting changes. It checks gofmt and runs `go vet` without tags and with the `e2e` -tag. `gofmt -w .` fixes formatting. The pre-commit hook and the CI `check` job run the same script. +Run before presenting changes. It checks gofmt, runs `go vet` without tags and with the `e2e` +tag, and checks that `gen/xray` equals the output of `buf generate`. `gofmt -w .` fixes formatting. The pre-commit hook and the CI `check` job run the same script. After a change to a shell script or a workflow, also run: @@ -70,7 +95,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`, then runs the e2e suite and + the Xray e2e suite. The module pin is `AWG_MODULE_REF` in `ci.yml`. @@ -80,10 +106,12 @@ the notes of the annotated tag. ## Conventions -- 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. -- `state/users.json` is the only source of secrets. The kernel and `clients/` derive from it, and - usher saves it before `ApplyPeers`. +- A private key, a preshared key, a `client_params` value, an Xray UUID or a Reality private key is + never logged, and an error message never quotes one. A public key without an entry is logged as + base64. +- `state/users.json` is the only source of secrets. The kernel, Xray and `clients/` derive from it, + and usher saves it before the first `Apply` of any backend (`ApplyPeers` for awg, + `AlterInbound` for Xray). - 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. @@ -94,18 +122,31 @@ 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 Session and Backend seam, the reconcile pass +internal/awg/ AmneziaWG backend: enrollment, address allocation, config rendering, apply +internal/xray/ Xray backend: decoder tables, enrollment, user reconcile, vless:// links +internal/clients/ 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) -scripts/format.sh the Format chain (see Format) +proto/xray/ vendored Xray protos (see proto/xray/README.md) +gen/xray/ Go code generated from proto/xray, committed +tools/ separate Go module that pins buf and the protobuf plugins +test/e2e/ end-to-end test of AmneziaWG through docker compose (tag e2e) +test/e2e/xray/ end-to-end test of Xray through docker compose (tag e2e) +scripts/format.sh the Format chain (see Format), including the gen/xray check 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 .github/workflows/ ci.yml and release.yml Dockerfile Go build stage and the runtime image ``` +## Adding an Xray inbound shape + +Add one entry to `protocols`, `transports` or `securities` in `internal/xray`, vendor the proto +that describes its settings into `proto/xray`, run the generate command and write the entry's +tests. A protocol whose credential is not a UUID also needs a field in `state.XrayEntry` and its +own enrollment. `docs/architecture/overview.md`, "The Xray backend", has the details. + ## Docs Stable design is in `docs/architecture/`, starting at `overview.md`. Dated plans are in diff --git a/README.md b/README.md index 08e0fe4..1e200ad 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ 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. +in one file; within seconds usher creates their credentials, puts them into the running services +and writes a ready client config or link per user and interface. Addresses, keys and UUIDs stay +internal: you type names only. 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. +a crash or an upgrade of usher leaves every session running. usher supports two services: +AmneziaWG through [awg-grpc](https://github.com/mrcsin/awg-grpc), and Xray through the API of Xray +itself. ## Users @@ -27,27 +28,81 @@ alice-laptop: [awg0, awg1] bob: [] ``` -- One name per device: a WireGuard key cannot serve two devices at once. -- Removing a name, or setting it to `[]`, switches the user off. The keys stay, and putting the - name back restores the same config. +- An interface name is an AmneziaWG interface or the tag of an Xray inbound. A name that both + services report fails in both, so keep the names apart. +- One name per device on AmneziaWG: a WireGuard key cannot serve two devices at once. +- Removing a name, or setting it to `[]`, switches the user off. The credentials stay, and putting + the name back restores the same config or link. - 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. -Client configs appear in `clients//.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//.conf` for AmneziaWG and in +`clients//.txt` for Xray. An Xray file holds one `vless://` link. Secrets live in +`state/users.json`, the only copy of every key and UUID: keep the state volume in the backup set. +`users.json` has format version 2. Once this version has saved it, the previous usher image +cannot read it, so a rollback to that image needs a copy of `users.json` from before the upgrade. -## Running +### Switching off -[`deploy/compose.example.yml`](deploy/compose.example.yml) runs usher next to awg-grpc. Images are -`ghcr.io/mrcsin/usher:`, one per release. +On AmneziaWG, removing a user removes the peer from the interface. On Xray, removing a user +removes it from the inbound: new connections fail, but connections that are already open stay +until the client closes them or Xray ends them after its `connIdle` timeout. -| 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 | +## Running -Both are required. Publish each interface port 1:1, and give usher the socket group of the -service containers. +[`deploy/compose.example.yml`](deploy/compose.example.yml) runs usher next to awg-grpc and Xray. +Images are `ghcr.io/mrcsin/usher:`, one per release. + +| 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 AmneziaWG configs | +| `USHER_AWG_SOCKET` | `/run/awg-grpc/awg.sock` | absolute path of the awg-grpc socket | +| `USHER_XRAY_SOCKET` | `/run/xray/api.sock` | absolute path of the Xray API socket | + +`USHER_HOST` is required. Set `USHER_AWG_SOCKET`, `USHER_XRAY_SOCKET` or both; a variable that is +set turns its service on, and usher exits at start when neither is set. `USHER_DNS` is required +with `USHER_AWG_SOCKET` and is not read without it. A deployment from before the Xray driver must +add `USHER_AWG_SOCKET` to its compose file. + +Publish each interface port 1:1, and give usher the socket group of the service containers. + +If one service is down during a pass, usher logs it, applies the other service and keeps the +client files of the service that is down. + +### Xray + +usher creates no inbound and starts no Xray process. It reads the VLESS inbounds of a running +Xray and manages their users. Prepare the Xray container as follows: + +- Use Xray v24.11.5 or later, which has `GetInboundUsers`. +- Set `api.listen` to a unix socket path with the mode suffix, for example + `/run/xray/api.sock,0660`. Xray gives the socket its own uid and gid. +- Run Xray with the group of usher (`user: "0:1000"` in the example), so usher can open the + socket. +- List only `HandlerService` in `api.services`. +- Start every VLESS inbound with `"clients": []`. usher adds the users. +- Name each inbound tag so that it fits the interface name pattern: up to 15 characters from + letters, digits and `_=+.-`, and the first one a letter, a digit or `_`. +- After a hard kill, remove the stale socket file before you start Xray again. The official image + has no shell, so remove it through the socket volume. + +usher supports one inbound shape: VLESS with `decryption: none` over raw TCP without a header, +with Reality security. usher gives every user the flow `xtls-rprx-vision`. An inbound with another +security, transport, header or decryption, or with a port range, fails with an error that names +the field; the other inbounds keep working. An inbound with another protocol, such as the `api` +tunnel, is left alone. An unreferenced VLESS inbound gets an empty user list. + +[`deploy/xray/config.json`](deploy/xray/config.json) is an example inbound. Replace its Reality +private key and short ID before use; `xray x25519` prints a key pair. + +The link holds the public key derived from the inbound's private key, the first non-empty server +name, the first short ID with trailing zero bytes removed, and the fingerprint `chrome`. usher +sends no ML-DSA-65 verification key, so the link has no `pqv` parameter. + +Xray 26.x blocks private destinations for VLESS by default. If your clients must reach a private +address through the node, allow it in the `finalRules` of the freedom outbound, as the e2e server +config in `deploy/e2e/xray/server.json` does for its target. To issue new keys for a user, stop usher, delete its `/` entry from `users.json` and start it again. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a92157e..13d7437 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -2,41 +2,69 @@ ## Purpose -usher keeps AmneziaWG peers and client configs in step with `config/usher.yml`, a map of user name -to interface names. The operator types names only. Addresses, keys and the subnet are internal. +usher keeps AmneziaWG peers, Xray users and client files in step with `config/usher.yml`, a map of +user name to interface names. The operator types names only. Addresses, keys, UUIDs and the subnet +are internal. usher creates no interface and starts no process; delivery prepares each VPN +container and usher only moves users into it. ## Stack -Go, one binary: `usher run`. It talks gRPC to `awg-grpc` (`github.com/mrcsin/awg-grpc` v0.1.0) -over the unix socket `/run/awg-grpc/awg.sock`. It runs in a container with no capabilities, a -read-only root file system and uid 1000. Config parsing uses `gopkg.in/yaml.v3`. +Go, one binary: `usher run`. It talks gRPC over unix sockets to two backends, each enabled by its +own variable: `awg-grpc` (`github.com/mrcsin/awg-grpc` v0.1.0) at `USHER_AWG_SOCKET`, and the +`HandlerService` API of a running Xray at `USHER_XRAY_SOCKET`. At least one must be set. It runs +in a container with no capabilities, a read-only root file system and uid 1000. Config parsing +uses `gopkg.in/yaml.v3`. usher imports no xray-core Go code: the Xray protos are vendored in +`proto/xray` and generated into `gen/xray` with buf. ## Components | Package | Responsibility | |---|---| -| `cmd/usher` | Reads `USHER_HOST` and `USHER_DNS`, wires signals, the watcher and the pass. | +| `cmd/usher` | Reads the environment, builds one `pass.Backend` per set socket, wires signals, the watcher and the pass. | | `internal/config` | Loads and validates `usher.yml`. | | `internal/state` | Loads and saves `state/users.json`. | | `internal/atomicfile` | Replaces a file atomically for `state` and the `clients/` mirror. | -| `internal/pass` | Settings, enrollment, address allocation and the reconcile pass. | -| `internal/clients` | Renders client configs and mirrors the `clients/` directory. | +| `internal/pass` | Settings, the `Session` and `Backend` seam, and the reconcile pass. | +| `internal/awg` | The AmneziaWG backend: enrollment, address allocation, config rendering, `ApplyPeers`. | +| `internal/xray` | The Xray backend: inbound decoder tables, enrollment, user reconcile, `vless://` links. | +| `internal/clients` | Mirrors the `clients/` directory. | +| `proto/xray`, `gen/xray` | Vendored Xray protos and the generated Go code. | +| `tools` | Separate Go module that pins buf and the protobuf plugins. | | `internal/watch` | Polls the file, runs the refill ticker and serializes passes. | +## The backend seam + +`pass.Backend` has a name for logs, the suffix of its client files (`.conf`, `.txt`) and an `Open` +function. `Open` connects and lists the backend's interfaces, and returns a `pass.Session`, which +prepares one interface (enrolls entries, renders client files, reports whether it changed the +state) and applies one interface (sends the desired users). The pass owns the rest: config load, +the state save before any apply, failure isolation per interface and the `clients/` mirror. Each +backend sees only its own interfaces. Environment variables select backends; `cmd/usher` closes +over each socket path and the server facts the driver needs (`USHER_HOST` for both, `USHER_DNS` +for awg). + ## The pass 1. Load `usher.yml`. When it is invalid, use the last valid file of this process. With none, log and stop. Load `users.json`. On error, log and stop. -2. Call `GetStatus`. On error, log and stop. -3. For each reported interface: fail it when `present=false`. Otherwise enroll every user that - references it and render their configs. An enrollment or render error fails the interface. +2. Open a session per backend. A backend that fails to open is down: log `backend is down` and + go on with the others. +3. For each interface a session reports: fail it when two backends report the same name, with an + error that names both. Otherwise call `Prepare`: enroll every user that references it and + render their files. An enrollment or render error fails the interface. The awg driver also + fails an interface with `present=false`. 4. Save `users.json` when step 3 created or changed an entry. -5. For each interface that did not fail: call `ApplyPeers` with one peer per referencing user - (`address/32`), with `allow_empty` when no user references the interface. An error fails the - interface. -6. Log every user and interface pair whose interface `awg-grpc` does not report. +5. For each interface that did not fail: call `Apply` with the users that reference it, which may + be none. An error fails the interface. +6. Log every user and interface pair whose interface no backend reports. Skip this step while a + backend is down, because its names are unknown. 7. Make `clients/` equal to the rendered files of applied interfaces plus the current files of - failed ones. + failed ones, and of every interface of a down backend. A backend owns the files that end in + its suffix. + +The awg session calls `GetStatus` when it opens. Its `Apply` calls `ApplyPeers` with one peer per +referencing user (`address/32`), with `allow_empty` when no user references the interface. The +Xray session is described under "The Xray backend". Two triggers start a pass: a change of `usher.yml` and a 30 s refill ticker. One goroutine runs the watcher loop and the passes, with the poll ticker, the settle timer and the refill ticker as @@ -44,41 +72,107 @@ cases of one `select`. A tick that fires during a pass waits in its ticker, whic so each source yields at most one following pass. A refill tick and a file change during one pass give two following passes. File polling follows `reproxy` (`app/discovery/provider/file.go`): `os.Stat` every 3 s, and a new modification time counts once it has stayed unchanged for 500 ms. The connection is opened per -pass and every call has a 10 s deadline. +pass and every call has a 10 s deadline. A down backend fails to open on every pass, so the +refill ticker retries it. ## Keys and addresses -- `state/users.json` is the only source of secrets. Entries are keyed `interface/user` under the - `awg` group. The kernel and `clients/` derive from it. usher saves it (temp file, fsync, rename, - directory fsync, mode 0600) only when an entry changed, and always before `ApplyPeers`. -- A new entry gets an X25519 key pair (`crypto/ecdh`), 32 random PSK bytes and an address. +- `state/users.json` is the only source of secrets. It has `"version": 2` and two groups: `awg` + and `xray`, both keyed `interface/user` (`tag/user` for Xray). The kernel, Xray and `clients/` + derive from it. usher saves it (temp file, fsync, rename, directory fsync, mode 0600) only when + an entry changed, and always before the first `Apply`. Version 1 loads with an empty `xray` + group; `Save` always writes 2, so the previous image cannot read the file afterwards. +- A new awg entry gets an X25519 key pair (`crypto/ecdh`), 32 random PSK bytes and an address. - The address is the lowest host address of the interface's first IPv4 prefix that is not the network or broadcast address, not an interface address and not held by another entry of that interface. A stored address that breaks one of these rules, for example after the operator changes the interface address, is reassigned and logged, and the config is rewritten. -- Switching a user off removes the peer and the client file and keeps the entry, so the same - name gets the same key and address back. usher never deletes entries. +- A new Xray entry gets a random UUID (`uuid.NewV4`). usher never changes an existing entry, so a + UUID put into `users.json` by hand survives every pass. A zero UUID fails the load. +- Switching a user off removes the peer or the Xray user and the client file, and keeps the entry, + so the same name gets the same credentials back. usher never deletes entries. + +## The Xray backend + +usher reads an Xray inbound in three independent parts, and each part is a table of decoder +functions in `internal/xray`: + +| Table | File | Key | Entry today | +|---|---|---|---| +| `protocols` | `protocol.go` | type name of the inbound's proxy settings | VLESS | +| `transports` | `transport.go` | stream `protocol_name` | `tcp` | +| `securities` | `security.go` | stream `security_type` (the settings message type) | Reality | + +`listInbounds` calls `ListInbounds`, drops every inbound whose proxy settings type has no +`protocols` entry (such as the `api` tunnel), and decodes the rest. An inbound that fails to +decode becomes a failed interface with the error; the others are unaffected. A transport or +security without a table entry fails the inbound with an error that names it, and an empty +security type is named `none` in the error. + +- The receiver port list must hold one range with `From == To`. +- The VLESS entry requires `decryption` `none` and builds the account `{id, flow}` with the flow + `xtls-rprx-vision`, which it also puts into the link. It owns the whole link, so a protocol + whose link is not scheme plus query still fits. +- The `tcp` entry accepts raw TCP with no transport settings, with settings that have no header, + and with the no-op header (any message in the proto package + `xray.transport.internet.headers.noop`). Any other header fails the inbound. +- The Reality entry reads the server's settings and keeps the private key inside the function. It + returns `pbk`, the X25519 public key in unpadded URL-safe base64; `sid`, the lowercase hex of the + first short ID with trailing zero bytes removed, so an all-zero ID gives an empty `sid`; `sni`, + the first non-empty server name, failing the inbound when there is none; and `fp=chrome`. + `mldsa65_seed` is ignored and the link has no `pqv`. +- The supported shape is therefore VLESS + raw TCP without header + Reality + `decryption: none`. + +To add a shape, add one table entry, vendor the proto that describes its settings into +`proto/xray` and regenerate `gen/xray`, and write the entry's tests. A new protocol also needs a +field in `state` when its credential is not a UUID, plus an `enroll` for it. + +`Apply` reconciles one inbound. It calls `GetInboundUsers`, and compares the current users with the +desired ones by email, which is the entry name `tag/user`. A current email that is not desired is +removed. One whose account differs (same type name and `proto.Equal` of the decoded messages; +the code treats an unknown account type as different) is removed and added again. A desired email +that is missing is added. Each change is one `AlterInbound` with level 0. An error fails the +inbound, and the next pass converges a partial change. usher logs `users changed` with the +`added`, `removed` and `updated` user names. Reconcile knows no protocol fields: the protocol entry +builds the desired account. + +The link file is `clients//.txt`, one line, mode 0600: +`vless://@:?#`. The params are `encryption=none`, +`flow=xtls-rprx-vision`, `type=tcp`, `security=reality`, `sni`, `fp`, `pbk` and `sid`, encoded with +`url.Values.Encode` and `%20` for spaces. The format follows XTLS/Xray-core discussion #716. ## Locked decisions -- The scope is AmneziaWG over IPv4. The `users.json` grouping leaves room for Xray. -- usher owns every interface the wrapper reports, and an unreferenced interface gets an empty - list. +- The scope is AmneziaWG over IPv4 and Xray VLESS with Reality over raw TCP. The decoder tables + take more Xray shapes. +- usher owns every interface a backend reports, and an unreferenced interface gets an empty list. + For Xray that is every VLESS inbound. +- A backend that is down never blocks the others, and its client files stay. +- A name reported by two backends fails in both. - A broken `usher.yml` never changes state. The last valid file is kept in memory only. - A file without any user and interface pair is invalid, so a truncated file cannot wipe a node. - A failure of one interface never stops the daemon and never blocks the others. - An interface without an IPv4 prefix fails only when a user references it. -- `clients/` files of an interface that `awg-grpc` no longer reports are removed. +- `clients/` files of an interface that a live backend no longer reports are removed. - `USHER_HOST` is an IPv4 address, not a name: a client whose DNS points into the tunnel cannot resolve the name again to reconnect. -- No private key, PSK or `client_params` value is logged. A public key without an entry is logged - as base64. +- No private key, PSK, `client_params` value, Xray UUID or Reality private key is logged. A public + key without an entry is logged as base64. - No subcommands besides `usher run`, no central management, no migration of existing peers. ## Operations - `clients/` belongs to usher: anything not rendered from the applied state is removed. Files are mode `0600`, uid 1000. +- Xray delivery: Xray v24.11.5 or later; `api.listen` on a unix path with `,0660`; the Xray + process gid equal to usher's group (`user: "0:1000"`); `api.services` limited to + `HandlerService`; VLESS inbounds start with `clients: []`; inbound tags fit the interface name + pattern; after a hard kill the operator removes the stale socket file, because the official + image has no shell. +- Switching a user off in Xray stops new connections only. Live connections last until they close + or hit Xray's `connIdle`. +- Xray 26.x blocks private destinations for VLESS by default. The e2e server config allows its + target in the freedom outbound `finalRules`. - Changing an interface's address, key, port or obfuscation values rewrites every client config of that interface; those configs are handed out again. - Addresses are never freed: switched-off users keep theirs. A full subnet fails the whole diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index fae5ce3..091bbc7 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -500,15 +500,15 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. ### Task 11: [Final] Update documentation -- [ ] README: the two socket variables, the Xray delivery contract, link files, switching off in +- [x] README: the two socket variables, the Xray delivery contract, link files, switching off in Xray versus AmneziaWG -- [ ] `docs/architecture/overview.md`: the backend seam, a backend that is down, the decoder tables +- [x] `docs/architecture/overview.md`: the backend seam, a backend that is down, the decoder tables and how a new shape is added, Xray keys and links, state version 2; generalize the locked decisions at lines 64, 65, 70, 71, 74, 76 -- [ ] `AGENTS.md`: layout (`internal/awg`, `internal/xray`, `proto/xray`, `gen/`, `tools/`, +- [x] `AGENTS.md`: layout (`internal/awg`, `internal/xray`, `proto/xray`, `gen/`, `tools/`, `test/e2e/xray`), generation command, Xray e2e, the UUID and the Reality private key in the never-logged list; reword "usher saves it before `ApplyPeers`" for both backends -- [ ] move this plan to `docs/plans/completed/` +- [x] move this plan to `docs/plans/completed/` (skipped - archiving is left to the delivery step) ## Post-Completion From 69229101caf472429816e3a5aeb07926270c1e9c Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:33:26 +0300 Subject: [PATCH 18/29] fix(xray): reject finalmask and startup clients --- deploy/xray/config.json | 4 ++-- internal/xray/inbound.go | 6 ++++++ internal/xray/inbound_test.go | 32 +++++++++++++++++++++++++++++--- internal/xray/link.go | 18 +++++++----------- internal/xray/link_test.go | 4 ++-- internal/xray/protocol.go | 10 ++++++++-- internal/xray/protocol_test.go | 14 +++++++++++--- internal/xray/session.go | 11 +++++++++++ internal/xray/session_test.go | 10 ++++++++++ internal/xray/transport_test.go | 6 +++++- 10 files changed, 91 insertions(+), 24 deletions(-) diff --git a/deploy/xray/config.json b/deploy/xray/config.json index b7326f6..cb8037d 100644 --- a/deploy/xray/config.json +++ b/deploy/xray/config.json @@ -21,8 +21,8 @@ "realitySettings": { "target": "example.com:443", "serverNames": ["example.com"], - "privateKey": "EXAMPLE-REPLACE-WITH-XRAY-X25519-PRIVATE-00", - "shortIds": ["0123456789abcdef"] + "privateKey": "REPLACE-WITH-OUTPUT-OF-xray-x25519", + "shortIds": ["REPLACE-WITH-HEX-SHORT-ID"] } } } diff --git a/internal/xray/inbound.go b/internal/xray/inbound.go index 6afecdc..be265ba 100644 --- a/internal/xray/inbound.go +++ b/internal/xray/inbound.go @@ -38,6 +38,9 @@ func listInbounds(ctx context.Context, client command.HandlerServiceClient) ([]i if !ok { continue } + if config.GetTag() == "" { + continue + } in, err := decodeInbound(config, decodeProtocol) if err != nil { in = inbound{tag: config.GetTag(), err: err} @@ -70,6 +73,9 @@ func decodeInbound(config *core.InboundHandlerConfig, decodeProtocol func([]byte if err != nil { return inbound{}, err } + if len(stream.GetTcpmasks())+len(stream.GetUdpmasks()) > 0 { + return inbound{}, errors.New("finalmask is not supported") + } securityName := stream.GetSecurityType() decodeSecurity, ok := securities[securityName] if !ok { diff --git a/internal/xray/inbound_test.go b/internal/xray/inbound_test.go index f1eec30..55b01fe 100644 --- a/internal/xray/inbound_test.go +++ b/internal/xray/inbound_test.go @@ -19,6 +19,7 @@ import ( vlessinbound "github.com/mrcsin/usher/gen/xray/proxy/vless/inbound" "github.com/mrcsin/usher/gen/xray/transport/internet" "github.com/mrcsin/usher/gen/xray/transport/internet/reality" + "github.com/mrcsin/usher/gen/xray/transport/internet/tcp" ) // fakeHandlerService implements command.HandlerServiceClient with func fields. @@ -95,6 +96,16 @@ func TestListInbounds(t *testing.T) { } }) + t.Run("untagged inbound is skipped", func(t *testing.T) { + got, err := listInbounds(t.Context(), listOf(inboundConfig("", portList(443, 443), realityStream(t)))) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("inbounds = %+v, want none", got) + } + }) + t.Run("list error", func(t *testing.T) { client := &fakeHandlerService{inbounds: func() (*command.ListInboundsResponse, error) { return nil, errors.New("unavailable") @@ -110,10 +121,22 @@ func TestListInboundsFailsUnsupportedShapes(t *testing.T) { noSecurity.SecurityType = "" noSecurity.SecuritySettings = nil tls := realityStream(t) - tls.SecurityType = "tls" + tls.SecurityType = "xray.transport.internet.tls.Config" websocket := realityStream(t) websocket.ProtocolName = "websocket" + tcpMask := realityStream(t) + tcpMask.Tcpmasks = []*serial.TypedMessage{{Type: "xray.transport.internet.finalmask.header.Config"}} + udpMask := realityStream(t) + udpMask.Udpmasks = []*serial.TypedMessage{{Type: "xray.transport.internet.finalmask.header.Config"}} + httpHeader := realityStream(t) + httpHeader.TransportSettings = []*internet.TransportConfig{{ + ProtocolName: "tcp", + Settings: toTypedMessage(&tcp.Config{ + HeaderSettings: &serial.TypedMessage{Type: "xray.transport.internet.headers.http.Config"}, + }), + }} + wrongReceiver := inboundConfig("a", portList(443, 443), realityStream(t)) wrongReceiver.ReceiverSettings = toTypedMessage(&vlessinbound.Config{}) garbledReceiver := inboundConfig("a", portList(443, 443), realityStream(t)) @@ -131,8 +154,11 @@ func TestListInboundsFailsUnsupportedShapes(t *testing.T) { {name: "undecodable vless settings", config: garbledProxy, wantErr: "decoding vless settings"}, {name: "port range", config: inboundConfig("a", portList(443, 450), realityStream(t)), wantErr: "port"}, {name: "no port", config: inboundConfig("a", nil, realityStream(t)), wantErr: "port"}, - {name: "tls security", config: inboundConfig("a", portList(443, 443), tls), wantErr: `security "tls"`}, + {name: "tls security", config: inboundConfig("a", portList(443, 443), tls), wantErr: `security "xray.transport.internet.tls.Config"`}, {name: "empty security is named none", config: inboundConfig("a", portList(443, 443), noSecurity), wantErr: `security "none"`}, + {name: "tcp finalmask", config: inboundConfig("a", portList(443, 443), tcpMask), wantErr: "finalmask"}, + {name: "udp finalmask", config: inboundConfig("a", portList(443, 443), udpMask), wantErr: "finalmask"}, + {name: "http header", config: inboundConfig("a", portList(443, 443), httpHeader), wantErr: "header"}, {name: "websocket transport", config: inboundConfig("a", portList(443, 443), websocket), wantErr: `transport "websocket"`}, } for _, tt := range tests { @@ -170,7 +196,7 @@ func TestInboundErrorsNeverContainPrivateKey(t *testing.T) { garbled := realityStream(t) garbled.SecuritySettings[0].Value = append(garbled.SecuritySettings[0].Value, 0xff) tls := realityStream(t) - tls.SecurityType = "tls" + tls.SecurityType = "xray.transport.internet.tls.Config" streams := map[string]*internet.StreamConfig{ "no server names": {ProtocolName: "tcp", SecurityType: typeName(&reality.Config{}), SecuritySettings: realitySettings(t, bad)}, diff --git a/internal/xray/link.go b/internal/xray/link.go index ec36cdc..5935bda 100644 --- a/internal/xray/link.go +++ b/internal/xray/link.go @@ -12,21 +12,17 @@ import ( // vlessLink builds the share link of XTLS/Xray-core discussion #716 for one user of a VLESS // inbound. params are the transport and security parameters of the inbound. -func vlessLink(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string { - query := url.Values{"encryption": {decryptionNone}, "flow": {flowVision}} +func vlessLink(e state.XrayEntry, user, flow string, host netip.Addr, port uint16, params url.Values) string { + query := url.Values{"encryption": {decryptionNone}, "flow": {flow}} for key, values := range params { query[key] = values } address := net.JoinHostPort(host.String(), strconv.Itoa(int(port))) - return "vless://" + e.ID.String() + "@" + address + "?" + encodeComponent(query.Encode()) + "#" + escapeComponent(user) + return "vless://" + e.ID.String() + "@" + address + "?" + escapeSpaces(query.Encode()) + "#" + escapeSpaces(url.QueryEscape(user)) } -// escapeComponent escapes s as JavaScript's encodeURIComponent does, which the proposal requires. -func escapeComponent(s string) string { - return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") -} - -// encodeComponent turns the spaces that url.Values.Encode writes as "+" into "%20". -func encodeComponent(encoded string) string { - return strings.ReplaceAll(encoded, "+", "%20") +// escapeSpaces turns the "+" that url.QueryEscape and url.Values.Encode write for a space into +// "%20", as JavaScript's encodeURIComponent does and the proposal requires. +func escapeSpaces(s string) string { + return strings.ReplaceAll(s, "+", "%20") } diff --git a/internal/xray/link_test.go b/internal/xray/link_test.go index 93560b7..d544ebd 100644 --- a/internal/xray/link_test.go +++ b/internal/xray/link_test.go @@ -29,7 +29,7 @@ func TestVLESSLinkGolden(t *testing.T) { if err != nil { t.Fatal(err) } - got := vlessLink(linkEntry, "alice", netip.MustParseAddr("203.0.113.7"), 443, linkParams("ab")) + "\n" + got := vlessLink(linkEntry, "alice", flowVision, netip.MustParseAddr("203.0.113.7"), 443, linkParams("ab")) + "\n" if got != string(want) { t.Errorf("link differs from testdata/vless.txt:\n%s", got) } @@ -49,7 +49,7 @@ func TestVLESSLinkParts(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - link := vlessLink(linkEntry, tt.user, netip.MustParseAddr(tt.host), 443, linkParams(tt.sid)) + link := vlessLink(linkEntry, tt.user, flowVision, netip.MustParseAddr(tt.host), 443, linkParams(tt.sid)) for _, part := range tt.want { if !strings.Contains(link, part) { t.Errorf("link %q does not contain %q", link, part) diff --git a/internal/xray/protocol.go b/internal/xray/protocol.go index 9537427..464698e 100644 --- a/internal/xray/protocol.go +++ b/internal/xray/protocol.go @@ -56,16 +56,22 @@ func decodeVLESS(settings []byte, transport, security string) (protocol, error) if err := proto.Unmarshal(settings, &config); err != nil { return protocol{}, fmt.Errorf("decoding vless settings: %w", err) } + if len(config.GetUsers()) > 0 { + return protocol{}, errors.New("vless clients must be empty: usher owns the users") + } if config.GetDecryption() != decryptionNone { return protocol{}, errors.New(`vless decryption is not "none"`) } if transport != transportTCP || security != securityReality { return protocol{}, fmt.Errorf("vless over transport %q with security %q is not supported", transport, security) } + flow := flowVision return protocol{ account: func(e state.XrayEntry) *serial.TypedMessage { - return toTypedMessage(&vlessaccount.Account{Id: e.ID.String(), Flow: flowVision}) + return toTypedMessage(&vlessaccount.Account{Id: e.ID.String(), Flow: flow}) + }, + link: func(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string { + return vlessLink(e, user, flow, host, port, params) }, - link: vlessLink, }, nil } diff --git a/internal/xray/protocol_test.go b/internal/xray/protocol_test.go index 3c0c159..c57ea23 100644 --- a/internal/xray/protocol_test.go +++ b/internal/xray/protocol_test.go @@ -6,14 +6,15 @@ import ( "google.golang.org/protobuf/proto" + userproto "github.com/mrcsin/usher/gen/xray/common/protocol" vlessaccount "github.com/mrcsin/usher/gen/xray/proxy/vless" vlessinbound "github.com/mrcsin/usher/gen/xray/proxy/vless/inbound" "github.com/mrcsin/usher/internal/state" ) -func vlessSettings(t *testing.T, decryption string) []byte { +func vlessSettings(t *testing.T, decryption string, users ...*userproto.User) []byte { t.Helper() - value, err := proto.Marshal(&vlessinbound.Config{Decryption: decryption}) + value, err := proto.Marshal(&vlessinbound.Config{Decryption: decryption, Users: users}) if err != nil { t.Fatal(err) } @@ -31,7 +32,7 @@ func TestDecodeVLESS(t *testing.T) { {name: "tcp and reality", decryption: "none", transport: "tcp", security: "reality"}, {name: "other decryption", decryption: "mlkem768x25519plus.native.0rtt", transport: "tcp", security: "reality", wantErr: "decryption"}, {name: "empty decryption", decryption: "", transport: "tcp", security: "reality", wantErr: "decryption"}, - {name: "other transport", decryption: "none", transport: "ws", security: "reality", wantErr: `"ws"`}, + {name: "other transport", decryption: "none", transport: "websocket", security: "reality", wantErr: `"websocket"`}, {name: "other security", decryption: "none", transport: "tcp", security: "tls", wantErr: `"tls"`}, } for _, tt := range tests { @@ -50,6 +51,13 @@ func TestDecodeVLESS(t *testing.T) { } } +func TestDecodeVLESSRejectsStartupClients(t *testing.T) { + _, err := decodeVLESS(vlessSettings(t, "none", &userproto.User{Email: "startup"}), "tcp", "reality") + if err == nil || !strings.Contains(err.Error(), "clients") { + t.Fatalf("error = %v, want one naming clients", err) + } +} + func TestVLESSAccountCarriesIDAndVisionFlow(t *testing.T) { p, err := decodeVLESS(vlessSettings(t, "none"), "tcp", "reality") if err != nil { diff --git a/internal/xray/session.go b/internal/xray/session.go index f4e2fae..ba4e5c5 100644 --- a/internal/xray/session.go +++ b/internal/xray/session.go @@ -133,18 +133,29 @@ func (s *Session) Apply(ctx context.Context, st *state.State, name string, users slices.Sort(added) slices.Sort(updated) + var removedDone, addedDone []string + logPartial := func() { + if len(removedDone)+len(addedDone) > 0 { + s.log.Warn("users changed before a failure", "interface", name, + "removed", userNames(name, removedDone), "added", userNames(name, addedDone)) + } + } for _, email := range append(slices.Clone(removed), updated...) { if err := s.alter(ctx, name, &command.RemoveUserOperation{Email: email}); err != nil { + logPartial() return fmt.Errorf("removing user %s: %w", userOf(name, email), err) } + removedDone = append(removedDone, email) } for _, email := range append(slices.Clone(added), updated...) { operation := &command.AddUserOperation{User: &userproto.User{ Level: userLevel, Email: email, Account: desired[email], }} if err := s.alter(ctx, name, operation); err != nil { + logPartial() return fmt.Errorf("adding user %s: %w", userOf(name, email), err) } + addedDone = append(addedDone, email) } if len(removed)+len(added)+len(updated) > 0 { diff --git a/internal/xray/session_test.go b/internal/xray/session_test.go index 60eede5..51b3a7e 100644 --- a/internal/xray/session_test.go +++ b/internal/xray/session_test.go @@ -286,6 +286,13 @@ func TestSameAccount(t *testing.T) { if !sameAccount(explicit, vlessAccount(id, flowVision)) { t.Error("equal accounts differ") } + if sameAccount(vlessAccount(id, flowVision), &serial.TypedMessage{Type: "xray.proxy.trojan.Account"}) { + t.Error("accounts of different types are equal") + } + garbled := &serial.TypedMessage{Type: typeName(&vlessaccount.Account{}), Value: []byte{0xff}} + if sameAccount(garbled, vlessAccount(id, flowVision)) || sameAccount(vlessAccount(id, flowVision), garbled) { + t.Error("an undecodable account equals a valid one") + } if sameAccount(&serial.TypedMessage{Type: "unknown.Type"}, &serial.TypedMessage{Type: "unknown.Type"}) { t.Error("an unknown type equals itself") } @@ -302,6 +309,9 @@ func TestApplyConvergesAfterPartialFailure(t *testing.T) { if env.accountOf("vless", "alice") == nil || env.accountOf("vless", "bob") != nil { t.Fatalf("users = %v", slices.Collect(maps.Keys(env.xray.users["vless"]))) } + if !strings.Contains(env.logs.String(), "users changed before a failure") || !strings.Contains(env.logs.String(), "added=[alice]") { + t.Errorf("logs = %q", env.logs.String()) + } env.xray.failAdd = "" if err := env.pass("vless", "alice", "bob"); err != nil { diff --git a/internal/xray/transport_test.go b/internal/xray/transport_test.go index abff89e..59aceb0 100644 --- a/internal/xray/transport_test.go +++ b/internal/xray/transport_test.go @@ -17,7 +17,7 @@ func tcpStream(settings *serial.TypedMessage) *internet.StreamConfig { } func TestDecodeTCP(t *testing.T) { - noop := &serial.TypedMessage{Type: "xray.transport.internet.headers.noop.Config"} + noop := &serial.TypedMessage{Type: "xray.transport.internet.headers.noop.ConnectionConfig"} http := &serial.TypedMessage{Type: "xray.transport.internet.headers.http.Config"} tests := []struct { name string @@ -27,6 +27,10 @@ func TestDecodeTCP(t *testing.T) { {name: "no transport settings", stream: &internet.StreamConfig{ProtocolName: "tcp"}}, {name: "settings without header", stream: tcpStream(toTypedMessage(&tcp.Config{}))}, {name: "no-op header", stream: tcpStream(toTypedMessage(&tcp.Config{HeaderSettings: noop}))}, + {name: "settings of another transport", stream: &internet.StreamConfig{ + ProtocolName: "tcp", + TransportSettings: []*internet.TransportConfig{{ProtocolName: "websocket", Settings: &serial.TypedMessage{Type: "xray.other.Config"}}}, + }}, {name: "http header", stream: tcpStream(toTypedMessage(&tcp.Config{HeaderSettings: http})), wantErr: "header"}, {name: "foreign settings type", stream: tcpStream(&serial.TypedMessage{Type: "xray.other.Config"}), wantErr: "xray.other.Config"}, {name: "garbled settings", stream: tcpStream(&serial.TypedMessage{Type: typeName(&tcp.Config{}), Value: []byte{0xff}}), wantErr: "decoding tcp settings"}, From 6083d5d3a5e49f7ffb7bd2ef461bf70c3328821d Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:33:26 +0300 Subject: [PATCH 19/29] test(xray): make e2e switch-off checks strict --- deploy/e2e/xray/server.json | 2 +- internal/watch/watch_test.go | 2 +- test/e2e/xray/xray_test.go | 46 +++++++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/deploy/e2e/xray/server.json b/deploy/e2e/xray/server.json index 62a07ed..0612eb0 100644 --- a/deploy/e2e/xray/server.json +++ b/deploy/e2e/xray/server.json @@ -22,7 +22,7 @@ "target": "target:443", "serverNames": ["target.e2e"], "privateKey": "vo6FtYE8B1Gdc2qDj35dZPqY2ciiIh3oITS0u0xUE9Q", - "shortIds": ["0123456789abcdef"] + "shortIds": ["ab12"] } } } diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 7404ecc..aec4a30 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -11,7 +11,7 @@ import ( const ( testPoll = 5 * time.Millisecond - testSettle = 40 * time.Millisecond + testSettle = 200 * time.Millisecond idleRefill = time.Hour quiet = 200 * time.Millisecond deadline = 3 * time.Second diff --git a/test/e2e/xray/xray_test.go b/test/e2e/xray/xray_test.go index 71b8d04..d9a9ab0 100644 --- a/test/e2e/xray/xray_test.go +++ b/test/e2e/xray/xray_test.go @@ -37,6 +37,10 @@ const ( changeTimeout = 10 * time.Second refillTimeout = 60 * time.Second requestTimeout = 5 * time.Second + // refillTick is a little longer than the 30 s refill interval. + refillTick = 35 * time.Second + // failedFetches is the number of consecutive failed fetches that prove a user is off. + failedFetches = 3 ) // The bind-mount directories of the compose project, under scratchRoot. @@ -184,28 +188,58 @@ func testConnect(t *testing.T) { } } -// testSwitchOff removes bob from usher.yml. A new connection through bob's link must fail while -// alice's link still works. +// testSwitchOff removes bob from usher.yml. Once usher logs the removal, a new connection through +// bob's link must keep failing while alice's link still works. func testSwitchOff(t *testing.T) { writeUsherConfig(t, usherYAML("alice")) - waitFor(t, within(changeTimeout), "a failing connection through the link of bob", func() error { - if err := fetch(proxies["bob"]); err == nil { - return errors.New("the link of bob still works") + waitFor(t, within(changeTimeout), "the removal of bob in the usher log", func() error { + if !strings.Contains(usherLogs(t), "removed=[bob]") { + return errors.New("no removed=[bob] line yet") } return nil }) + assertFailsRepeatedly(t, "bob after the removal", proxies["bob"]) if err := fetch(proxies["alice"]); err != nil { t.Fatalf("link of alice after bob was removed: %v", err) } } // testRefill restarts xray, which empties its user list. usher must put alice back on its refill -// ticker. +// ticker and leave bob out. Once the list is full again, a refill tick must change nothing. func testRefill(t *testing.T) { compose(t, "restart", "xray") waitFor(t, within(refillTimeout), "a page through the link of alice after the restart", func() error { return fetch(proxies["alice"]) }) + assertFailsRepeatedly(t, "bob after the restart", proxies["bob"]) + + changes := strings.Count(usherLogs(t), "users changed") + time.Sleep(refillTick) + if got := strings.Count(usherLogs(t), "users changed"); got != changes { + t.Fatalf("usher changed users on a steady refill tick: %d lines before, %d after", changes, got) + } +} + +// assertFailsRepeatedly requires several consecutive failed fetches, so one transient error +// cannot stand for a removed user. +func assertFailsRepeatedly(t *testing.T, what, proxy string) { + t.Helper() + for range failedFetches { + if err := fetch(proxy); err == nil { + t.Fatalf("the link of %s still works", what) + } + time.Sleep(time.Second) + } +} + +// usherLogs returns the log of the usher container. +func usherLogs(t *testing.T) string { + t.Helper() + out, err := composeCmd("logs", "--no-color", "usher").CombinedOutput() + if err != nil { + t.Fatalf("docker compose logs usher: %v\n%s", err, out) + } + return string(out) } func usherYAML(users ...string) string { From 6b7467b08d6a010056ca8bbd8c2d76a1c56f7ef3 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:33:26 +0300 Subject: [PATCH 20/29] docs: sync README, AGENTS and plan with Xray --- AGENTS.md | 10 ++++++---- README.md | 17 +++++++++++------ deploy/compose.example.yml | 2 +- docs/architecture/overview.md | 2 +- docs/plans/20261007-xray-driver.md | 10 ++++++---- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f85551..774dc1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,15 +50,16 @@ Xray 26.x blocks private destinations for VLESS by default. Run it with `-count= go test -tags e2e -count=1 -v ./test/e2e/xray/ ``` -Both suites need `docker compose` without `sudo`. It wipes and recreates the bind-mount directories -under `.e2e/` (gitignored) at start, and passes `E2E_UID` and `E2E_GID` to compose so usher -writes them as the test user. +Both suites need `docker compose` without `sudo`. Each wipes and recreates its own bind-mount root +at start (`.e2e/`, `.e2e-xray/`, both gitignored) and passes `E2E_UID` and `E2E_GID` to compose so +usher writes them as the test user. 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 ``` ## Generate @@ -127,7 +128,8 @@ internal/awg/ AmneziaWG backend: enrollment, address allocation, config internal/xray/ Xray backend: decoder tables, enrollment, user reconcile, vless:// links internal/clients/ the clients/ mirror internal/watch/ file poll, refill ticker and the trigger loop -deploy/ example compose file, e2e compose file and interface config +deploy/ example compose file and Xray config (deploy/xray), e2e compose files + of both suites, e2e fixtures (deploy/e2e) proto/xray/ vendored Xray protos (see proto/xray/README.md) gen/xray/ Go code generated from proto/xray, committed tools/ separate Go module that pins buf and the protobuf plugins diff --git a/README.md b/README.md index 1e200ad..c84ca89 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ itself. `config/usher.yml`: ```yaml -alice-phone: [awg0] +alice-phone: [awg0, vless-reality] alice-laptop: [awg0, awg1] bob: [] ``` @@ -65,6 +65,9 @@ set turns its service on, and usher exits at start when neither is set. `USHER_D with `USHER_AWG_SOCKET` and is not read without it. A deployment from before the Xray driver must add `USHER_AWG_SOCKET` to its compose file. +Unsetting a variable turns its service off: usher removes that service's client files and logs its +interface names as unknown. The entries in `users.json` stay. + Publish each interface port 1:1, and give usher the socket group of the service containers. If one service is down during a pass, usher logs it, applies the other service and keeps the @@ -81,7 +84,8 @@ Xray and manages their users. Prepare the Xray container as follows: - Run Xray with the group of usher (`user: "0:1000"` in the example), so usher can open the socket. - List only `HandlerService` in `api.services`. -- Start every VLESS inbound with `"clients": []`. usher adds the users. +- Start every VLESS inbound with `"clients": []`; an inbound with startup clients fails. usher adds + the users. - Name each inbound tag so that it fits the interface name pattern: up to 15 characters from letters, digits and `_=+.-`, and the first one a letter, a digit or `_`. - After a hard kill, remove the stale socket file before you start Xray again. The official image @@ -89,9 +93,10 @@ Xray and manages their users. Prepare the Xray container as follows: usher supports one inbound shape: VLESS with `decryption: none` over raw TCP without a header, with Reality security. usher gives every user the flow `xtls-rprx-vision`. An inbound with another -security, transport, header or decryption, or with a port range, fails with an error that names -the field; the other inbounds keep working. An inbound with another protocol, such as the `api` -tunnel, is left alone. An unreferenced VLESS inbound gets an empty user list. +security, transport, header, finalmask or decryption, with startup clients, or with a port range, +fails with an error that names the field; the other inbounds keep working. An inbound with +another protocol, such as the `api` tunnel, or without a tag is left alone. An unreferenced VLESS +inbound gets an empty user list. [`deploy/xray/config.json`](deploy/xray/config.json) is an example inbound. Replace its Reality private key and short ID before use; `xray x25519` prints a key pair. @@ -105,7 +110,7 @@ address through the node, allow it in the `finalRules` of the freedom outbound, config in `deploy/e2e/xray/server.json` does for its target. To issue new keys for a user, stop usher, delete its `/` entry from -`users.json` and start it again. +the `awg` or `xray` group of `users.json` and start it again. ## License diff --git a/deploy/compose.example.yml b/deploy/compose.example.yml index 9444747..218953e 100644 --- a/deploy/compose.example.yml +++ b/deploy/compose.example.yml @@ -34,7 +34,7 @@ services: - ./xray/config.json:/etc/xray/config.json:ro - xray-sock:/run/xray usher: - image: ghcr.io/mrcsin/usher:v0.1.0 + image: ghcr.io/mrcsin/usher: restart: unless-stopped cap_drop: [ALL] read_only: true diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 13d7437..062c4d4 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -180,7 +180,7 @@ The link file is `clients//.txt`, one line, mode 0600: - `users.json` sits in the `usher-state` volume (mode `0600`, uid 1000). To re-key or delete a user: `docker compose stop usher`, then `docker run --rm -it -u 1000:1000 -v _usher-state:/state alpine vi /state/users.json`, - delete the `/` entry, `docker compose start usher`. A user still in `usher.yml` + delete the `/` entry from the `awg` or `xray` group, `docker compose start usher`. A user still in `usher.yml` gets new keys. - `USHER_HOST` is an address, not a name: a client that re-resolves a name while its DNS points into the tunnel cannot reconnect. diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index 091bbc7..021baaf 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -251,7 +251,7 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. var protocols = map[string]func(proxy []byte, transport, security string) (protocol, error) // by proxy_settings.type var transports = map[string]func(stream *internet.StreamConfig) (url.Values, error) // by protocol_name - var securities = map[string]func(settings []byte) (url.Values, error) // by security_type + var securities = map[string]func(settings []*serial.TypedMessage) (url.Values, error) // by security_type (full proto name) ``` An empty `security_type` is named `none` in errors. - **Inbound reading.** `ListInbounds{isOnlyTags: false}`, then per inbound: @@ -260,8 +260,9 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. the flow. - The receiver port list must hold one range with `From == To`. - The `tcp` transport entry accepts all three header forms of raw TCP and fails any other - header. ASSUMPTION: the no-op header arrives as a message of package - `xray.transport.internet.headers.noop`; the entry accepts any message of that package. + header. Verified against Xray v26.9.9: the no-op header arrives as + `xray.transport.internet.headers.noop.ConnectionConfig`; the entry accepts any message of the + package `xray.transport.internet.headers.noop`. - The Reality security entry gives `pbk`, the X25519 public key of `private_key` (`crypto/ecdh`), unpadded URL-safe base64; `sid`, the lowercase hex of the first short ID with trailing zero bytes trimmed, so an all-zero ID gives an empty `sid`; `sni`, the first non-empty @@ -508,7 +509,7 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - [x] `AGENTS.md`: layout (`internal/awg`, `internal/xray`, `proto/xray`, `gen/`, `tools/`, `test/e2e/xray`), generation command, Xray e2e, the UUID and the Reality private key in the never-logged list; reword "usher saves it before `ApplyPeers`" for both backends -- [x] move this plan to `docs/plans/completed/` (skipped - archiving is left to the delivery step) +- [ ] move this plan to `docs/plans/completed/` - ⚠️ not done: archiving is left to the delivery step ## Post-Completion @@ -519,6 +520,7 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. trimmed `sid`, which phone clients have not been checked against **Release** +- bump the image tag in `deploy/compose.example.yml` from the `` placeholder to the new release - tag the release with a `!` header; the notes state that `USHER_AWG_SOCKET` is now required and that the previous image cannot read `users.json` once this version has saved it From 30605f274ed2b53bcd01501ca6b8bb0952c0de0a Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:54:27 +0300 Subject: [PATCH 21/29] refactor(xray): name decoder types, extract diffUsers --- internal/xray/enroll_test.go | 5 ++-- internal/xray/inbound.go | 7 +++--- internal/xray/inbound_test.go | 24 +++---------------- internal/xray/protocol.go | 23 ++++++++++--------- internal/xray/security.go | 10 ++++++-- internal/xray/session.go | 43 +++++++++++++++++++---------------- internal/xray/session_test.go | 31 +++++++++++++------------ internal/xray/transport.go | 10 +++++--- 8 files changed, 76 insertions(+), 77 deletions(-) diff --git a/internal/xray/enroll_test.go b/internal/xray/enroll_test.go index c9fdf1c..d99b06b 100644 --- a/internal/xray/enroll_test.go +++ b/internal/xray/enroll_test.go @@ -8,16 +8,15 @@ import ( ) func TestEnroll(t *testing.T) { - seeded := uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d") st := &state.State{Xray: map[string]state.XrayEntry{ - "vless/alice": {ID: seeded}, + "vless/alice": {ID: seededID}, "other/carol": {ID: uuid.NewV4()}, }} if !enroll(st, "vless", []string{"alice", "bob"}) { t.Fatal("enroll reported no change after adding bob") } - if got := st.Xray["vless/alice"].ID; got != seeded { + if got := st.Xray["vless/alice"].ID; got != seededID { t.Errorf("alice id changed to %v", got) } bob, ok := st.Xray["vless/bob"] diff --git a/internal/xray/inbound.go b/internal/xray/inbound.go index be265ba..aeb9de4 100644 --- a/internal/xray/inbound.go +++ b/internal/xray/inbound.go @@ -25,8 +25,9 @@ type inbound struct { err error } -// listInbounds returns every inbound whose proxy settings type has a protocol entry. Other -// inbounds, such as the api tunnel, are not usher interfaces and are left out. +// listInbounds returns every tagged inbound whose proxy settings type has a protocol entry. Other +// inbounds, such as the api tunnel, and inbounds without a tag are not usher interfaces and are +// left out. func listInbounds(ctx context.Context, client command.HandlerServiceClient) ([]inbound, error) { response, err := client.ListInbounds(ctx, &command.ListInboundsRequest{}) if err != nil { @@ -50,7 +51,7 @@ func listInbounds(ctx context.Context, client command.HandlerServiceClient) ([]i return inbounds, nil } -func decodeInbound(config *core.InboundHandlerConfig, decodeProtocol func([]byte, string, string) (protocol, error)) (inbound, error) { +func decodeInbound(config *core.InboundHandlerConfig, decodeProtocol protocolDecoder) (inbound, error) { receiverSettings := config.GetReceiverSettings() if receiverSettings.GetType() != typeName(&proxyman.ReceiverConfig{}) { return inbound{}, fmt.Errorf("receiver settings have type %q", receiverSettings.GetType()) diff --git a/internal/xray/inbound_test.go b/internal/xray/inbound_test.go index 55b01fe..6606f8f 100644 --- a/internal/xray/inbound_test.go +++ b/internal/xray/inbound_test.go @@ -2,15 +2,11 @@ package xray import ( "bytes" - "context" "encoding/base64" "encoding/hex" - "errors" "strings" "testing" - "google.golang.org/grpc" - "github.com/mrcsin/usher/gen/xray/app/proxyman" "github.com/mrcsin/usher/gen/xray/app/proxyman/command" "github.com/mrcsin/usher/gen/xray/common/net" @@ -22,17 +18,6 @@ import ( "github.com/mrcsin/usher/gen/xray/transport/internet/tcp" ) -// fakeHandlerService implements command.HandlerServiceClient with func fields. -type fakeHandlerService struct { - command.HandlerServiceClient - - inbounds func() (*command.ListInboundsResponse, error) -} - -func (f *fakeHandlerService) ListInbounds(context.Context, *command.ListInboundsRequest, ...grpc.CallOption) (*command.ListInboundsResponse, error) { - return f.inbounds() -} - func portList(from, to uint32) *net.PortList { return &net.PortList{Range: []*net.PortRange{{From: from, To: to}}} } @@ -55,9 +40,7 @@ func inboundConfig(tag string, ports *net.PortList, stream *internet.StreamConfi } func listOf(configs ...*core.InboundHandlerConfig) command.HandlerServiceClient { - return &fakeHandlerService{inbounds: func() (*command.ListInboundsResponse, error) { - return &command.ListInboundsResponse{Inbounds: configs}, nil - }} + return newFakeXray(configs...) } func TestListInbounds(t *testing.T) { @@ -107,9 +90,8 @@ func TestListInbounds(t *testing.T) { }) t.Run("list error", func(t *testing.T) { - client := &fakeHandlerService{inbounds: func() (*command.ListInboundsResponse, error) { - return nil, errors.New("unavailable") - }} + client := newFakeXray() + client.failList = true if _, err := listInbounds(t.Context(), client); err == nil || !strings.Contains(err.Error(), "unavailable") { t.Fatalf("error = %v", err) } diff --git a/internal/xray/protocol.go b/internal/xray/protocol.go index 464698e..5d753ce 100644 --- a/internal/xray/protocol.go +++ b/internal/xray/protocol.go @@ -17,15 +17,16 @@ import ( ) const ( - transportTCP = "tcp" - securityReality = "reality" - // flowVision is the flow of VLESS with Reality over raw TCP. flowVision = "xtls-rprx-vision" decryptionNone = "none" ) +// protocolDecoder builds the protocol of an inbound from its proxy settings and the names of its +// transport and security. +type protocolDecoder func(settings []byte, transport, security string) (protocol, error) + // protocol is what a proxy-settings entry builds for an inbound. type protocol struct { // account returns the account Xray holds for an entry. @@ -35,9 +36,8 @@ type protocol struct { link func(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string } -// protocols maps the type of an inbound's proxy settings to its decoder. The decoder gets the -// settings and the names of the inbound's transport and security. -var protocols = map[string]func(settings []byte, transport, security string) (protocol, error){ +// protocols maps the type of an inbound's proxy settings to its decoder. +var protocols = map[string]protocolDecoder{ typeName(&vlessinbound.Config{}): decodeVLESS, } @@ -46,7 +46,8 @@ func typeName(m proto.Message) string { } func toTypedMessage(m proto.Message) *serial.TypedMessage { - // Marshal fails only for a message with a missing required field, and proto3 has none. + // Marshal fails on a proto3 string field with invalid UTF-8. The strings here are a tag Xray + // decoded and a user name that config validation accepted, so the error cannot happen. value, _ := proto.Marshal(m) return &serial.TypedMessage{Type: typeName(m), Value: value} } @@ -63,15 +64,15 @@ func decodeVLESS(settings []byte, transport, security string) (protocol, error) return protocol{}, errors.New(`vless decryption is not "none"`) } if transport != transportTCP || security != securityReality { - return protocol{}, fmt.Errorf("vless over transport %q with security %q is not supported", transport, security) + return protocol{}, fmt.Errorf("vless over transport %q with security %q is not supported", + transport, security) } - flow := flowVision return protocol{ account: func(e state.XrayEntry) *serial.TypedMessage { - return toTypedMessage(&vlessaccount.Account{Id: e.ID.String(), Flow: flow}) + return toTypedMessage(&vlessaccount.Account{Id: e.ID.String(), Flow: flowVision}) }, link: func(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string { - return vlessLink(e, user, flow, host, port, params) + return vlessLink(e, user, flowVision, host, port, params) }, }, nil } diff --git a/internal/xray/security.go b/internal/xray/security.go index 693c865..f0479ed 100644 --- a/internal/xray/security.go +++ b/internal/xray/security.go @@ -21,9 +21,15 @@ const securityNone = "none" // fingerprintChrome is the TLS client fingerprint of every link; Reality requires one. const fingerprintChrome = "chrome" +// securityReality is the security parameter of a Reality link. +const securityReality = "reality" + +// securityDecoder returns the link parameters of a stream's security. +type securityDecoder func(settings []*serial.TypedMessage) (url.Values, error) + // securities maps a stream's security type, the full proto name of its settings message, to its -// decoder. The decoder returns the link parameters of the security. -var securities = map[string]func(settings []*serial.TypedMessage) (url.Values, error){ +// decoder. +var securities = map[string]securityDecoder{ typeName(&reality.Config{}): decodeReality, } diff --git a/internal/xray/session.go b/internal/xray/session.go index ba4e5c5..eddb0d8 100644 --- a/internal/xray/session.go +++ b/internal/xray/session.go @@ -114,30 +114,13 @@ func (s *Session) Apply(ctx context.Context, st *state.State, name string, users current[u.GetEmail()] = u.GetAccount() } - var removed, added, updated []string - for email, account := range current { - want, wanted := desired[email] - switch { - case !wanted: - removed = append(removed, email) - case !sameAccount(account, want): - updated = append(updated, email) - } - } - for email := range desired { - if _, ok := current[email]; !ok { - added = append(added, email) - } - } - slices.Sort(removed) - slices.Sort(added) - slices.Sort(updated) + removed, added, updated := diffUsers(current, desired) var removedDone, addedDone []string logPartial := func() { if len(removedDone)+len(addedDone) > 0 { s.log.Warn("users changed before a failure", "interface", name, - "removed", userNames(name, removedDone), "added", userNames(name, addedDone)) + "added", userNames(name, addedDone), "removed", userNames(name, removedDone)) } } for _, email := range append(slices.Clone(removed), updated...) { @@ -165,6 +148,28 @@ func (s *Session) Apply(ctx context.Context, st *state.State, name string, users return nil } +// diffUsers returns the sorted emails to remove, add and replace so that current becomes desired. +func diffUsers(current, desired map[string]*serial.TypedMessage) (removed, added, updated []string) { + for email, account := range current { + want, wanted := desired[email] + switch { + case !wanted: + removed = append(removed, email) + case !sameAccount(account, want): + updated = append(updated, email) + } + } + for email := range desired { + if _, ok := current[email]; !ok { + added = append(added, email) + } + } + slices.Sort(removed) + slices.Sort(added) + slices.Sort(updated) + return removed, added, updated +} + // Close closes the connection to Xray. func (s *Session) Close() { s.closeConn() diff --git a/internal/xray/session_test.go b/internal/xray/session_test.go index 51b3a7e..02a2510 100644 --- a/internal/xray/session_test.go +++ b/internal/xray/session_test.go @@ -23,6 +23,9 @@ import ( "github.com/mrcsin/usher/internal/state" ) +// seededID is the UUID of an entry that state already holds. +var seededID = uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d") + // fakeXray implements command.HandlerServiceClient over in-memory inbounds. It applies the // operations it gets, so a second pass sees the result of the first. type fakeXray struct { @@ -36,7 +39,9 @@ type fakeXray struct { failRemove string // failUsers makes GetInboundUsers fail. failUsers bool - calls []string + // failList makes ListInbounds fail. + failList bool + calls []string } func newFakeXray(configs ...*core.InboundHandlerConfig) *fakeXray { @@ -48,6 +53,9 @@ func newFakeXray(configs ...*core.InboundHandlerConfig) *fakeXray { } func (f *fakeXray) ListInbounds(context.Context, *command.ListInboundsRequest, ...grpc.CallOption) (*command.ListInboundsResponse, error) { + if f.failList { + return nil, errors.New("unavailable") + } return &command.ListInboundsResponse{Inbounds: f.configs}, nil } @@ -160,7 +168,7 @@ func TestOpen(t *testing.T) { t.Run("list error closes the connection", func(t *testing.T) { closed := false dial := func() (command.HandlerServiceClient, func(), error) { - return &failingList{}, func() { closed = true }, nil + return &fakeXray{failList: true}, func() { closed = true }, nil } if _, err := Open(context.Background(), dial, netip.MustParseAddr("203.0.113.7"), slog.Default()); err == nil { t.Fatal("no error") @@ -171,12 +179,6 @@ func TestOpen(t *testing.T) { }) } -type failingList struct{ command.HandlerServiceClient } - -func (*failingList) ListInbounds(context.Context, *command.ListInboundsRequest, ...grpc.CallOption) (*command.ListInboundsResponse, error) { - return nil, errors.New("unavailable") -} - func TestApplyAddsAndRemoves(t *testing.T) { env := newSessionEnv(t, twoInbounds(t)) if err := env.pass("vless", "alice", "bob"); err != nil { @@ -231,16 +233,15 @@ func TestApplyEmptiesUnreferencedInbound(t *testing.T) { } func TestApplyKeepsSeededUUID(t *testing.T) { - seeded := uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d") env := newSessionEnv(t, twoInbounds(t)) - env.st.Xray["vless/alice"] = state.XrayEntry{ID: seeded} + env.st.Xray["vless/alice"] = state.XrayEntry{ID: seededID} if err := env.pass("vless", "alice"); err != nil { t.Fatal(err) } - if got := env.st.Xray["vless/alice"].ID; got != seeded { + if got := env.st.Xray["vless/alice"].ID; got != seededID { t.Errorf("id = %v", got) } - if got := env.accountOf("vless", "alice"); !sameAccount(got, vlessAccount(seeded, flowVision)) { + if got := env.accountOf("vless", "alice"); !sameAccount(got, vlessAccount(seededID, flowVision)) { t.Errorf("account = %v", got) } } @@ -250,13 +251,13 @@ func TestApplyReplacesChangedAccount(t *testing.T) { name string account *serial.TypedMessage }{ - {name: "flow change", account: vlessAccount(uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d"), "")}, + {name: "flow change", account: vlessAccount(seededID, "")}, {name: "id change", account: vlessAccount(uuid.NewV4(), flowVision)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { env := newSessionEnv(t, twoInbounds(t)) - env.st.Xray["vless/alice"] = state.XrayEntry{ID: uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d")} + env.st.Xray["vless/alice"] = state.XrayEntry{ID: seededID} env.xray.users["vless"]["vless/alice"] = tt.account if err := env.pass("vless", "alice"); err != nil { @@ -277,7 +278,7 @@ func TestApplyReplacesChangedAccount(t *testing.T) { } func TestSameAccount(t *testing.T) { - id := uuid.MustParse("5f0c3a52-4a6e-4d0b-8c1d-7e9f2b3a4c5d") + id := seededID // An explicit zero in field 5 (seconds) is the same message with a different encoding. explicit := &serial.TypedMessage{ Type: typeName(&vlessaccount.Account{}), diff --git a/internal/xray/transport.go b/internal/xray/transport.go index 40d0c71..9e9a2ff 100644 --- a/internal/xray/transport.go +++ b/internal/xray/transport.go @@ -15,9 +15,13 @@ import ( // noopHeaderPackage is the proto package of the raw TCP header "none". const noopHeaderPackage = "xray.transport.internet.headers.noop." -// transports maps a stream's protocol name to its decoder. The decoder returns the link parameters -// of the transport. -var transports = map[string]func(stream *internet.StreamConfig) (url.Values, error){ +const transportTCP = "tcp" + +// transportDecoder returns the link parameters of a stream's transport. +type transportDecoder func(stream *internet.StreamConfig) (url.Values, error) + +// transports maps a stream's protocol name to its decoder. +var transports = map[string]transportDecoder{ transportTCP: decodeTCP, } From 710013734c30756b6d65ce1e93c0b66672617399 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:55:16 +0300 Subject: [PATCH 22/29] refactor(awg): unexport render --- internal/awg/render.go | 4 ++-- internal/awg/render_test.go | 4 ++-- internal/awg/session.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/awg/render.go b/internal/awg/render.go index 9d202d9..f9b72e6 100644 --- a/internal/awg/render.go +++ b/internal/awg/render.go @@ -12,9 +12,9 @@ import ( "github.com/mrcsin/usher/internal/state" ) -// Render returns the client config of one user on one interface. The error never quotes a +// render returns the client config of one user on one interface. The error never quotes a // client_params value. -func Render(entry state.Entry, iface *awgv1.InterfaceStatus, host netip.Addr, dns []netip.Addr) ([]byte, error) { +func render(entry state.Entry, iface *awgv1.InterfaceStatus, host netip.Addr, dns []netip.Addr) ([]byte, error) { port := iface.GetListenPort() if port > math.MaxUint16 { return nil, fmt.Errorf("listen port %d is not a port number", port) diff --git a/internal/awg/render_test.go b/internal/awg/render_test.go index bfe8907..8e252ca 100644 --- a/internal/awg/render_test.go +++ b/internal/awg/render_test.go @@ -36,7 +36,7 @@ func testInterface() *awgv1.InterfaceStatus { } func TestRenderGolden(t *testing.T) { - got, err := Render(testEntry(), testInterface(), netip.MustParseAddr("203.0.113.10"), + got, err := render(testEntry(), testInterface(), netip.MustParseAddr("203.0.113.10"), []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("1.0.0.1")}) if err != nil { t.Fatal(err) @@ -124,7 +124,7 @@ func TestRender(t *testing.T) { if tt.mutate != nil { tt.mutate(iface) } - got, err := Render(testEntry(), iface, host, tt.dns) + got, err := render(testEntry(), iface, host, tt.dns) if tt.wantErr { if err == nil { t.Fatal("want an error") diff --git a/internal/awg/session.go b/internal/awg/session.go index 8153d24..9a28234 100644 --- a/internal/awg/session.go +++ b/internal/awg/session.go @@ -86,7 +86,7 @@ func (s *Session) Prepare(st *state.State, name string, users []string) (map[str } files := make(map[string][]byte, len(users)) for _, user := range users { - content, err := Render(st.AWG[state.EntryName(name, user)], iface, s.host, s.dns) + content, err := render(st.AWG[state.EntryName(name, user)], iface, s.host, s.dns) if err != nil { return nil, false, fmt.Errorf("user %s: %w", user, err) } From 69a3ba55cd51c2c9eaad8fd8afd26b276aeae164 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:55:16 +0300 Subject: [PATCH 23/29] refactor(state): rename validators, drop test guard --- internal/state/state.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index 2a2e3c5..df38c26 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -126,7 +126,7 @@ func Load(path string) (*State, error) { s.Xray = map[string]XrayEntry{} } for name, e := range s.AWG { - if err := validate(name, e); err != nil { + if err := validateAWG(name, e); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } } @@ -141,9 +141,6 @@ func Load(path string) (*State, error) { // Save writes s to path atomically with mode 0600. func Save(path string, s *State) error { s.Version = version - if s.Xray == nil { - s.Xray = map[string]XrayEntry{} - } data, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("encoding state: %w", err) @@ -156,7 +153,7 @@ func Save(path string, s *State) error { func validateName(name string) error { iface, user, ok := strings.Cut(name, "/") if !ok || iface == "" || user == "" || strings.Contains(user, "/") { - return fmt.Errorf("entry %q: name is not interface/user", name) + return fmt.Errorf("entry %q: name is not group/user", name) } return nil } @@ -171,7 +168,7 @@ func validateXray(name string, e XrayEntry) error { return nil } -func validate(name string, e Entry) error { +func validateAWG(name string, e Entry) error { if err := validateName(name); err != nil { return err } From f409cac2e6d72477deb94e330e12126e8afe3117 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:55:16 +0300 Subject: [PATCH 24/29] refactor(usher): share Open adapter, name AWG dialer --- cmd/usher/main.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/cmd/usher/main.go b/cmd/usher/main.go index 58e35f3..d0826f7 100644 --- a/cmd/usher/main.go +++ b/cmd/usher/main.go @@ -67,16 +67,12 @@ func serve(ctx context.Context, settings pass.Settings, backends []pass.Backend, } func awgBackend(env environment, log *slog.Logger) pass.Backend { - dial := dialSocket(env.AWGSocket) + dial := dialAWGSocket(env.AWGSocket) return pass.Backend{ Name: "awg-grpc", Suffix: awg.Suffix, Open: func(ctx context.Context) (pass.Session, error) { - session, err := awg.Open(ctx, dial, env.Host, env.DNS, log) - if err != nil { - return nil, err - } - return session, nil + return asSession(awg.Open(ctx, dial, env.Host, env.DNS, log)) }, } } @@ -87,16 +83,21 @@ func xrayBackend(env environment, log *slog.Logger) pass.Backend { Name: "xray", Suffix: xray.Suffix, Open: func(ctx context.Context) (pass.Session, error) { - session, err := xray.Open(ctx, dial, env.Host, log) - if err != nil { - return nil, err - } - return session, nil + return asSession(xray.Open(ctx, dial, env.Host, log)) }, } } -func dialSocket(path string) awg.Dial { +// asSession converts a backend session to pass.Session. On error it returns a nil interface, not +// an interface that holds a nil pointer. +func asSession[S pass.Session](session S, err error) (pass.Session, error) { + if err != nil { + return nil, err + } + return session, nil +} + +func dialAWGSocket(path string) awg.Dial { return func() (awgv1.ManagementServiceClient, func(), error) { conn, err := dialConn(path) if err != nil { From 0993e07020f2717607158faa4176f3135e5d6486 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:57:01 +0300 Subject: [PATCH 25/29] test(e2e): share suite helpers in e2eutil --- test/e2e/e2e_test.go | 173 +++++++++--------------------------- test/e2e/e2eutil/e2eutil.go | 148 ++++++++++++++++++++++++++++++ test/e2e/xray/xray_test.go | 159 ++++++--------------------------- 3 files changed, 217 insertions(+), 263 deletions(-) create mode 100644 test/e2e/e2eutil/e2eutil.go diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 60b97b0..8ef84bc 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -8,7 +8,6 @@ package e2e import ( "bytes" "encoding/base64" - "flag" "fmt" "maps" "net/netip" @@ -22,6 +21,7 @@ import ( "time" "github.com/mrcsin/usher/internal/state" + "github.com/mrcsin/usher/test/e2e/e2eutil" ) const ( @@ -47,14 +47,17 @@ const ( clientDir = "client" ) -var scratchDirs = []string{configDir, clientsDir, stateDir, clientDir} +var project = &e2eutil.Project{ + RepoDir: repoDir, + ComposeFile: composeFile, + ScratchRoot: scratchRoot, + Dirs: []string{configDir, clientsDir, stateDir, clientDir}, + Profile: clientProfile, + Services: []string{"awg", "usher"}, +} var bothUsers = usherYAML("phone", "laptop") -func e2ePath(elem ...string) string { - return filepath.Join(append([]string{repoDir, scratchRoot}, elem...)...) -} - func usherYAML(users ...string) string { var b strings.Builder for _, user := range users { @@ -67,66 +70,7 @@ func usherYAML(users ...string) string { type peerSet map[string]string func TestMain(m *testing.M) { - os.Exit(runMain(m)) -} - -// runMain creates the bind-mount directories as the test user, so dockerd does not create them -// as root and usher, which runs as the test user, can write them. A run that was killed leaves -// the project up, so runMain takes it down before it starts. -func runMain(m *testing.M) int { - flag.Parse() - if count := flag.Lookup("test.count").Value.String(); count != "1" { - fmt.Fprintf(os.Stderr, "-count=%s: TestE2E restarts the awg service, so it runs once per process; use -count=1\n", count) - return 1 - } - down() - if err := os.RemoveAll(e2ePath()); err != nil { - fmt.Fprintf(os.Stderr, "removing .e2e: %v\n", err) - return 1 - } - for _, dir := range scratchDirs { - if err := os.MkdirAll(e2ePath(dir), 0o755); err != nil { - fmt.Fprintf(os.Stderr, "creating %s: %v\n", dir, err) - return 1 - } - } - defer down() - - out, err := composeCmd("up", "-d", "--build", "--wait", "awg", "usher").CombinedOutput() - fmt.Fprintf(os.Stderr, "docker compose up:\n%s\n", out) - if err != nil { - fmt.Fprintf(os.Stderr, "docker compose up: %v\n", err) - printLogs() - return 1 - } - code := m.Run() - if code != 0 { - printLogs() - } - return code -} - -func down() { - out, err := composeCmd("--profile", clientProfile, "down", "-v", "--remove-orphans", "--timeout", "10").CombinedOutput() - if err != nil { - fmt.Fprintf(os.Stderr, "docker compose down: %v\n%s\n", err, out) - } -} - -func printLogs() { - out, err := composeCmd("--profile", clientProfile, "logs", "--no-color").CombinedOutput() - fmt.Fprintf(os.Stderr, "docker compose logs (err %v):\n%s\n", err, out) -} - -// composeCmd builds docker compose on the e2e project, run from the repository root with the -// uid and gid of the test user, so usher can write the bind mounts and open the socket. -func composeCmd(args ...string) *exec.Cmd { - cmd := exec.Command("docker", append([]string{"compose", "-f", composeFile}, args...)...) - cmd.Dir = repoDir - cmd.Env = append(os.Environ(), - "E2E_UID="+strconv.Itoa(os.Getuid()), - "E2E_GID="+strconv.Itoa(os.Getgid())) - return cmd + os.Exit(project.Main(m)) } func run(cmd *exec.Cmd) (stdout, stderr string, err error) { @@ -137,15 +81,6 @@ func run(cmd *exec.Cmd) (stdout, stderr string, err error) { return outBuf.String(), errBuf.String(), err } -func compose(t *testing.T, args ...string) string { - t.Helper() - stdout, stderr, err := run(composeCmd(args...)) - if err != nil { - t.Fatalf("docker compose %s: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout, stderr) - } - return stdout -} - // TestE2E runs its subtests in order; each one starts from the state the previous one left. func TestE2E(t *testing.T) { steps := []struct { @@ -165,10 +100,10 @@ func TestE2E(t *testing.T) { } func testEnroll(t *testing.T) { - writeUsherConfig(t, bothUsers) - deadline := within(changeTimeout) + project.WriteConfig(t, bothUsers) + deadline := e2eutil.Within(changeTimeout) for _, user := range []string{"phone", "laptop"} { - waitFor(t, deadline, "client config of "+user, func() error { + e2eutil.WaitFor(t, deadline, "client config of "+user, func() error { return checkClientFile(user) }) } @@ -198,25 +133,25 @@ func testEnroll(t *testing.T) { // awg-grpc's smoke test: the image has no resolvconf for DNS, and a full-tunnel AllowedIPs needs // sysctl writes a non-privileged container cannot make. func testHandshake(t *testing.T) { - original, err := os.ReadFile(e2ePath(clientsDir, "phone", iface+".conf")) + original, err := os.ReadFile(project.Path(clientsDir, "phone", iface+".conf")) if err != nil { t.Fatalf("reading the phone config: %v", err) } conf := clientConfigCopy(t, string(original)) - path := e2ePath(clientDir, "awgc0.conf") + path := project.Path(clientDir, "awgc0.conf") if err := os.WriteFile(path, []byte(conf), 0o600); err != nil { t.Fatalf("writing client config: %v", err) } - compose(t, "--profile", clientProfile, "up", "-d", "--wait", "client") + project.Compose(t, "--profile", clientProfile, "up", "-d", "--wait", "client") defer removeClient(t) // The first packet into the tunnel starts the handshake; its reply may be lost to it. - stdout, stderr, err := run(composeCmd("exec", "-T", "client", "ping", "-c", "1", "-W", "5", interfaceAddr)) + stdout, stderr, err := run(project.Cmd("exec", "-T", "client", "ping", "-c", "1", "-W", "5", interfaceAddr)) t.Logf("first ping %s (err %v):\n%s%s", interfaceAddr, err, stdout, stderr) phoneKey := publicKey(loadEntries(t)[state.EntryName(iface, "phone")]) - waitFor(t, within(changeTimeout), "handshake of phone", func() error { + e2eutil.WaitFor(t, e2eutil.Within(changeTimeout), "handshake of phone", func() error { peers, err := dump() if err != nil { return err @@ -251,10 +186,10 @@ func clientConfigCopy(t *testing.T, original string) string { func removeClient(t *testing.T) { t.Helper() if t.Failed() { - out, err := composeCmd("--profile", clientProfile, "logs", "--no-color", "client").CombinedOutput() + out, err := project.Cmd("--profile", clientProfile, "logs", "--no-color", "client").CombinedOutput() t.Logf("docker compose logs client (err %v):\n%s", err, out) } - if out, err := composeCmd("--profile", clientProfile, "rm", "-sf", "client").CombinedOutput(); err != nil { + if out, err := project.Cmd("--profile", clientProfile, "rm", "-sf", "client").CombinedOutput(); err != nil { t.Errorf("removing client: %v\n%s", err, out) } } @@ -269,11 +204,11 @@ func testSwitchOff(t *testing.T) { publicKey(laptopBefore): laptopBefore.Route().String(), } - writeUsherConfig(t, usherYAML("phone")) - deadline := within(changeTimeout) + project.WriteConfig(t, usherYAML("phone")) + deadline := e2eutil.Within(changeTimeout) waitPeers(t, deadline, peerSet{phoneKey: both[phoneKey]}) - waitFor(t, deadline, "removal of clients/laptop", func() error { - if _, err := os.Stat(e2ePath(clientsDir, "laptop")); err == nil { + e2eutil.WaitFor(t, deadline, "removal of clients/laptop", func() error { + if _, err := os.Stat(project.Path(clientsDir, "laptop")); err == nil { return fmt.Errorf("clients/laptop still exists") } return nil @@ -282,13 +217,13 @@ func testSwitchOff(t *testing.T) { t.Fatalf("users.json entry of laptop changed while the user was off") } - writeUsherConfig(t, bothUsers) - deadline = within(changeTimeout) + project.WriteConfig(t, bothUsers) + deadline = e2eutil.Within(changeTimeout) waitPeers(t, deadline, both) if got := loadEntries(t)[state.EntryName(iface, "laptop")]; got != laptopBefore { t.Fatalf("users.json entry of laptop changed after the user came back") } - waitFor(t, deadline, "client config of laptop", func() error { + e2eutil.WaitFor(t, deadline, "client config of laptop", func() error { return checkClientFile("laptop") }) } @@ -298,7 +233,7 @@ func testSwitchOff(t *testing.T) { // last valid file on its refill ticker and keep clients/ as it was. The log line of the refill // pass proves the restart emptied the kernel: its added list names both users. func testBrokenFileAndRefill(t *testing.T) { - defer writeUsherConfig(t, bothUsers) + defer project.WriteConfig(t, bothUsers) before, err := dump() if err != nil { @@ -309,18 +244,18 @@ func testBrokenFileAndRefill(t *testing.T) { } clientsBefore := readClients(t) - writeUsherConfig(t, fmt.Sprintf("phone: [%s\nlaptop: [%s]\n", iface, iface)) + project.WriteConfig(t, fmt.Sprintf("phone: [%s\nlaptop: [%s]\n", iface, iface)) errorLine := regexp.MustCompile(`usher\.yml.*line \d+`) - waitFor(t, within(changeTimeout), "usher.yml error in the usher log", func() error { + e2eutil.WaitFor(t, e2eutil.Within(changeTimeout), "usher.yml error in the usher log", func() error { return checkLog(errorLine, "") }) // Docker takes --since at second precision, so the stamp may include the second before it. restartedAt := time.Now().UTC().Format(time.RFC3339) - compose(t, "restart", "awg") + project.Compose(t, "restart", "awg") - deadline := within(refillTimeout) - waitFor(t, deadline, "refill of the peer set", func() error { + deadline := e2eutil.Within(refillTimeout) + e2eutil.WaitFor(t, deadline, "refill of the peer set", func() error { got, err := dump() if err != nil { return err @@ -331,7 +266,7 @@ func testBrokenFileAndRefill(t *testing.T) { return nil }) refilled := regexp.MustCompile(`peers changed.*added="?\[laptop phone\]`) - waitFor(t, deadline, "refill line in the usher log", func() error { + e2eutil.WaitFor(t, deadline, "refill line in the usher log", func() error { return checkLog(refilled, restartedAt) }) @@ -345,7 +280,7 @@ func checkLog(re *regexp.Regexp, since string) error { if since != "" { args = append(args, "--since", since) } - stdout, stderr, err := run(composeCmd(append(args, "usher")...)) + stdout, stderr, err := run(project.Cmd(append(args, "usher")...)) if err != nil { return fmt.Errorf("docker compose logs: %w: %s", err, stderr) } @@ -355,21 +290,9 @@ func checkLog(re *regexp.Regexp, since string) error { return nil } -func writeUsherConfig(t *testing.T, content string) { - t.Helper() - dir := e2ePath(configDir) - tmp := filepath.Join(dir, ".usher.yml.tmp") - if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { - t.Fatalf("writing usher.yml: %v", err) - } - if err := os.Rename(tmp, filepath.Join(dir, "usher.yml")); err != nil { - t.Fatalf("replacing usher.yml: %v", err) - } -} - // checkClientFile reports why clients//.conf is not a mode 0600 file yet. func checkClientFile(user string) error { - info, err := os.Stat(e2ePath(clientsDir, user, iface+".conf")) + info, err := os.Stat(project.Path(clientsDir, user, iface+".conf")) if err != nil { return err } @@ -381,7 +304,7 @@ func checkClientFile(user string) error { func readClients(t *testing.T) map[string]string { t.Helper() - root := e2ePath(clientsDir) + root := project.Path(clientsDir) files := map[string]string{} err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { @@ -403,7 +326,7 @@ func readClients(t *testing.T) map[string]string { func loadEntries(t *testing.T) map[string]state.Entry { t.Helper() - s, err := state.Load(e2ePath(stateDir, "users.json")) + s, err := state.Load(project.Path(stateDir, "users.json")) if err != nil { t.Fatalf("loading users.json: %v", err) } @@ -433,7 +356,7 @@ func (d dumpSet) allowed() peerSet { // dump reads the peers of the interface from the wrapper container. The preshared key column is // never kept. func dump() (dumpSet, error) { - stdout, stderr, err := run(composeCmd("exec", "-T", "awg", "awg", "show", iface, "dump")) + stdout, stderr, err := run(project.Cmd("exec", "-T", "awg", "awg", "show", iface, "dump")) if err != nil { return nil, fmt.Errorf("awg show dump: %w: %s", err, stderr) } @@ -456,7 +379,7 @@ func dump() (dumpSet, error) { func waitPeers(t *testing.T, deadline time.Time, want peerSet) { t.Helper() - waitFor(t, deadline, fmt.Sprintf("a peer set of %d peers", len(want)), func() error { + e2eutil.WaitFor(t, deadline, fmt.Sprintf("a peer set of %d peers", len(want)), func() error { got, err := dump() if err != nil { return err @@ -467,19 +390,3 @@ func waitPeers(t *testing.T, deadline time.Time, want peerSet) { return nil }) } - -func within(timeout time.Duration) time.Time { - return time.Now().Add(timeout) -} - -func waitFor(t *testing.T, deadline time.Time, what string, check func() error) { - t.Helper() - var err error - for time.Now().Before(deadline) { - if err = check(); err == nil { - return - } - time.Sleep(500 * time.Millisecond) - } - t.Fatalf("no %s before the deadline: %v", what, err) -} diff --git a/test/e2e/e2eutil/e2eutil.go b/test/e2e/e2eutil/e2eutil.go new file mode 100644 index 0000000..5c5e458 --- /dev/null +++ b/test/e2e/e2eutil/e2eutil.go @@ -0,0 +1,148 @@ +//go:build e2e + +// Package e2eutil holds what the e2e suites share: the docker compose project lifecycle, the +// scratch directories, the usher.yml writer and the polling helpers. +package e2eutil + +import ( + "bytes" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// pollInterval is the wait between two runs of a WaitFor check. +const pollInterval = 500 * time.Millisecond + +// Project describes the compose project of one suite. +type Project struct { + // RepoDir is the repository root relative to the suite's package directory. + RepoDir string + // ComposeFile is the compose file, relative to RepoDir. + ComposeFile string + // ScratchRoot is the directory under RepoDir that holds the bind mounts. + ScratchRoot string + // Dirs are the bind-mount directories under ScratchRoot. + Dirs []string + // Profile is the compose profile of the client containers. + Profile string + // Services are the services that Main starts and waits for. + Services []string +} + +// Path returns a path under the scratch root. +func (p *Project) Path(elem ...string) string { + return filepath.Join(append([]string{p.RepoDir, p.ScratchRoot}, elem...)...) +} + +// Cmd builds docker compose on the project, run from the repository root with the uid and gid of +// the test user, so usher can write the bind mounts and open the socket. +func (p *Project) Cmd(args ...string) *exec.Cmd { + cmd := exec.Command("docker", append([]string{"compose", "-f", p.ComposeFile}, args...)...) + cmd.Dir = p.RepoDir + cmd.Env = append(os.Environ(), + "E2E_UID="+strconv.Itoa(os.Getuid()), + "E2E_GID="+strconv.Itoa(os.Getgid())) + return cmd +} + +// Compose runs docker compose on the project and returns its stdout; a failure ends the test. +func (p *Project) Compose(t *testing.T, args ...string) string { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := p.Cmd(args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("docker compose %s: %v\nstdout:\n%s\nstderr:\n%s", + strings.Join(args, " "), err, stdout.String(), stderr.String()) + } + return stdout.String() +} + +// Main runs the suite and returns its exit code. It creates the bind-mount directories as the +// test user, so dockerd does not create them as root and usher, which runs as the test user, can +// write them. A run that was killed leaves the project up, so Main takes it down before it starts. +func (p *Project) Main(m *testing.M) int { + flag.Parse() + if count := flag.Lookup("test.count").Value.String(); count != "1" { + fmt.Fprintf(os.Stderr, "-count=%s: the suite restarts a service, so it runs once per process; use -count=1\n", count) + return 1 + } + p.down() + if err := os.RemoveAll(p.Path()); err != nil { + fmt.Fprintf(os.Stderr, "removing %s: %v\n", p.ScratchRoot, err) + return 1 + } + for _, dir := range p.Dirs { + if err := os.MkdirAll(p.Path(dir), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "creating %s: %v\n", dir, err) + return 1 + } + } + defer p.down() + + up := append([]string{"up", "-d", "--build", "--wait"}, p.Services...) + out, err := p.Cmd(up...).CombinedOutput() + fmt.Fprintf(os.Stderr, "docker compose up:\n%s\n", out) + if err != nil { + fmt.Fprintf(os.Stderr, "docker compose up: %v\n", err) + p.printLogs() + return 1 + } + code := m.Run() + if code != 0 { + p.printLogs() + } + return code +} + +func (p *Project) down() { + out, err := p.Cmd("--profile", p.Profile, "down", "-v", "--remove-orphans", "--timeout", "10").CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "docker compose down: %v\n%s\n", err, out) + } +} + +func (p *Project) printLogs() { + out, err := p.Cmd("--profile", p.Profile, "logs", "--no-color").CombinedOutput() + fmt.Fprintf(os.Stderr, "docker compose logs (err %v):\n%s\n", err, out) +} + +// WriteConfig replaces config/usher.yml atomically with content. +func (p *Project) WriteConfig(t *testing.T, content string) { + t.Helper() + dir := p.Path("config") + tmp := filepath.Join(dir, ".usher.yml.tmp") + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + t.Fatalf("writing usher.yml: %v", err) + } + if err := os.Rename(tmp, filepath.Join(dir, "usher.yml")); err != nil { + t.Fatalf("replacing usher.yml: %v", err) + } +} + +// Within returns the deadline that is timeout from now. +func Within(timeout time.Duration) time.Time { + return time.Now().Add(timeout) +} + +// WaitFor runs check until it returns nil or the deadline passes; then it ends the test with the +// last error. +func WaitFor(t *testing.T, deadline time.Time, what string, check func() error) { + t.Helper() + var err error + for time.Now().Before(deadline) { + if err = check(); err == nil { + return + } + time.Sleep(pollInterval) + } + t.Fatalf("no %s before the deadline: %v", what, err) +} diff --git a/test/e2e/xray/xray_test.go b/test/e2e/xray/xray_test.go index d9a9ab0..edf31de 100644 --- a/test/e2e/xray/xray_test.go +++ b/test/e2e/xray/xray_test.go @@ -5,22 +5,21 @@ package xray import ( - "bytes" "encoding/json" "errors" - "flag" "fmt" "io" "net" "net/http" "net/url" "os" - "os/exec" "path/filepath" "strconv" "strings" "testing" "time" + + "github.com/mrcsin/usher/test/e2e/e2eutil" ) const ( @@ -31,7 +30,7 @@ const ( tag = "vless-reality" targetURL = "http://target/" targetBody = "usher-e2e-target" - proxyPort = "8080" + proxyPort = 8080 // The deadlines follow the production constants: 3 s file poll, 500 ms settle, 30 s refill. changeTimeout = 10 * time.Second @@ -53,7 +52,15 @@ const ( clientDir = "client" ) -var scratchDirs = []string{configDir, clientsDir, stateDir, sockDir, clientDir} +var project = &e2eutil.Project{ + RepoDir: repoDir, + ComposeFile: composeFile, + ScratchRoot: scratchRoot, + Dirs: []string{configDir, clientsDir, stateDir, sockDir, + filepath.Join(clientDir, "alice"), filepath.Join(clientDir, "bob")}, + Profile: clientProfile, + Services: []string{"usher"}, +} // proxies maps each user to the HTTP proxy of that user's client container. var proxies = map[string]string{ @@ -61,88 +68,8 @@ var proxies = map[string]string{ "bob": "172.30.98.12", } -func e2ePath(elem ...string) string { - return filepath.Join(append([]string{repoDir, scratchRoot}, elem...)...) -} - func TestMain(m *testing.M) { - os.Exit(runMain(m)) -} - -// runMain creates the bind-mount directories as the test user, so dockerd does not create them -// as root and usher, which runs as the test user, can write them. A run that was killed leaves -// the project up, so runMain takes it down before it starts. -func runMain(m *testing.M) int { - flag.Parse() - if count := flag.Lookup("test.count").Value.String(); count != "1" { - fmt.Fprintf(os.Stderr, "-count=%s: TestXray restarts the xray service, so it runs once per process; use -count=1\n", count) - return 1 - } - down() - if err := os.RemoveAll(e2ePath()); err != nil { - fmt.Fprintf(os.Stderr, "removing %s: %v\n", scratchRoot, err) - return 1 - } - for _, dir := range scratchDirs { - if err := os.MkdirAll(e2ePath(dir), 0o755); err != nil { - fmt.Fprintf(os.Stderr, "creating %s: %v\n", dir, err) - return 1 - } - } - for user := range proxies { - if err := os.MkdirAll(e2ePath(clientDir, user), 0o755); err != nil { - fmt.Fprintf(os.Stderr, "creating client dir of %s: %v\n", user, err) - return 1 - } - } - defer down() - - out, err := composeCmd("up", "-d", "--build", "--wait", "usher").CombinedOutput() - fmt.Fprintf(os.Stderr, "docker compose up:\n%s\n", out) - if err != nil { - fmt.Fprintf(os.Stderr, "docker compose up: %v\n", err) - printLogs() - return 1 - } - code := m.Run() - if code != 0 { - printLogs() - } - return code -} - -func down() { - out, err := composeCmd("--profile", clientProfile, "down", "-v", "--remove-orphans", "--timeout", "10").CombinedOutput() - if err != nil { - fmt.Fprintf(os.Stderr, "docker compose down: %v\n%s\n", err, out) - } -} - -func printLogs() { - out, err := composeCmd("--profile", clientProfile, "logs", "--no-color").CombinedOutput() - fmt.Fprintf(os.Stderr, "docker compose logs (err %v):\n%s\n", err, out) -} - -// composeCmd builds docker compose on the e2e project, run from the repository root with the -// uid and gid of the test user, so usher can write the bind mounts and open the socket. -func composeCmd(args ...string) *exec.Cmd { - cmd := exec.Command("docker", append([]string{"compose", "-f", composeFile}, args...)...) - cmd.Dir = repoDir - cmd.Env = append(os.Environ(), - "E2E_UID="+strconv.Itoa(os.Getuid()), - "E2E_GID="+strconv.Itoa(os.Getgid())) - return cmd -} - -func compose(t *testing.T, args ...string) { - t.Helper() - var stdout, stderr bytes.Buffer - cmd := composeCmd(args...) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("docker compose %s: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout.String(), stderr.String()) - } + os.Exit(project.Main(m)) } // TestXray runs its subtests in order; each one starts from the state the previous one left. @@ -165,24 +92,24 @@ func TestXray(t *testing.T) { // testConnect writes a config of two users, builds a client from each rendered link and fetches // the target page through it. func testConnect(t *testing.T) { - waitFor(t, within(changeTimeout), "the API socket of xray", func() error { - _, err := os.Stat(e2ePath(sockDir, "api.sock")) + e2eutil.WaitFor(t, e2eutil.Within(changeTimeout), "the API socket of xray", func() error { + _, err := os.Stat(project.Path(sockDir, "api.sock")) return err }) - writeUsherConfig(t, usherYAML("alice", "bob")) - deadline := within(changeTimeout) + project.WriteConfig(t, usherYAML("alice", "bob")) + deadline := e2eutil.Within(changeTimeout) for user := range proxies { var link string - waitFor(t, deadline, "link file of "+user, func() error { + e2eutil.WaitFor(t, deadline, "link file of "+user, func() error { var err error link, err = readLink(user) return err }) writeClientConfig(t, user, link) } - compose(t, "--profile", clientProfile, "up", "-d", "client-alice", "client-bob") + project.Compose(t, "--profile", clientProfile, "up", "-d", "client-alice", "client-bob") for user, proxy := range proxies { - waitFor(t, within(changeTimeout), "a page through the link of "+user, func() error { + e2eutil.WaitFor(t, e2eutil.Within(changeTimeout), "a page through the link of "+user, func() error { return fetch(proxy) }) } @@ -191,8 +118,8 @@ func testConnect(t *testing.T) { // testSwitchOff removes bob from usher.yml. Once usher logs the removal, a new connection through // bob's link must keep failing while alice's link still works. func testSwitchOff(t *testing.T) { - writeUsherConfig(t, usherYAML("alice")) - waitFor(t, within(changeTimeout), "the removal of bob in the usher log", func() error { + project.WriteConfig(t, usherYAML("alice")) + e2eutil.WaitFor(t, e2eutil.Within(changeTimeout), "the removal of bob in the usher log", func() error { if !strings.Contains(usherLogs(t), "removed=[bob]") { return errors.New("no removed=[bob] line yet") } @@ -207,8 +134,8 @@ func testSwitchOff(t *testing.T) { // testRefill restarts xray, which empties its user list. usher must put alice back on its refill // ticker and leave bob out. Once the list is full again, a refill tick must change nothing. func testRefill(t *testing.T) { - compose(t, "restart", "xray") - waitFor(t, within(refillTimeout), "a page through the link of alice after the restart", func() error { + project.Compose(t, "restart", "xray") + e2eutil.WaitFor(t, e2eutil.Within(refillTimeout), "a page through the link of alice after the restart", func() error { return fetch(proxies["alice"]) }) assertFailsRepeatedly(t, "bob after the restart", proxies["bob"]) @@ -235,7 +162,7 @@ func assertFailsRepeatedly(t *testing.T, what, proxy string) { // usherLogs returns the log of the usher container. func usherLogs(t *testing.T) string { t.Helper() - out, err := composeCmd("logs", "--no-color", "usher").CombinedOutput() + out, err := project.Cmd("logs", "--no-color", "usher").CombinedOutput() if err != nil { t.Fatalf("docker compose logs usher: %v\n%s", err, out) } @@ -250,22 +177,10 @@ func usherYAML(users ...string) string { return b.String() } -func writeUsherConfig(t *testing.T, content string) { - t.Helper() - dir := e2ePath(configDir) - tmp := filepath.Join(dir, ".usher.yml.tmp") - if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { - t.Fatalf("writing usher.yml: %v", err) - } - if err := os.Rename(tmp, filepath.Join(dir, "usher.yml")); err != nil { - t.Fatalf("replacing usher.yml: %v", err) - } -} - // readLink returns the rendered link of the user, or the reason it is not there as a mode 0600 // file yet. func readLink(user string) (string, error) { - path := e2ePath(clientsDir, user, tag+".txt") + path := project.Path(clientsDir, user, tag+".txt") info, err := os.Stat(path) if err != nil { return "", err @@ -301,7 +216,7 @@ func writeClientConfig(t *testing.T, user, link string) { "log": map[string]any{"loglevel": "warning"}, "inbounds": []any{map[string]any{ "listen": "0.0.0.0", - "port": 8080, + "port": proxyPort, "protocol": "http", }}, "outbounds": []any{map[string]any{ @@ -329,7 +244,7 @@ func writeClientConfig(t *testing.T, user, link string) { if err != nil { t.Fatalf("encoding the client config of %s: %v", user, err) } - if err := os.WriteFile(e2ePath(clientDir, user, "config.json"), data, 0o644); err != nil { + if err := os.WriteFile(project.Path(clientDir, user, "config.json"), data, 0o644); err != nil { t.Fatalf("writing the client config of %s: %v", user, err) } } @@ -337,7 +252,7 @@ func writeClientConfig(t *testing.T, user, link string) { // fetch requests the target page through the HTTP proxy of one client container and reports why // the request did not return the page with status 200. func fetch(proxyHost string) error { - proxy, err := url.Parse("http://" + net.JoinHostPort(proxyHost, proxyPort)) + proxy, err := url.Parse("http://" + net.JoinHostPort(proxyHost, strconv.Itoa(proxyPort))) if err != nil { return err } @@ -365,19 +280,3 @@ func fetch(proxyHost string) error { } return nil } - -func within(timeout time.Duration) time.Time { - return time.Now().Add(timeout) -} - -func waitFor(t *testing.T, deadline time.Time, what string, check func() error) { - t.Helper() - var err error - for time.Now().Before(deadline) { - if err = check(); err == nil { - return - } - time.Sleep(500 * time.Millisecond) - } - t.Fatalf("no %s before the deadline: %v", what, err) -} From d2489bf2602d854d1336a94500185378ac1192b3 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:57:01 +0300 Subject: [PATCH 26/29] docs: fix fake description, rewrap long lines --- AGENTS.md | 11 +++++++---- docs/architecture/overview.md | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 774dc1d..66c09ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,9 @@ 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`; the Xray API is faked the same way for -`command.HandlerServiceClient`. +`awgv1.ManagementServiceClient`. The Xray API is faked by `fakeXray`, an in-memory +`command.HandlerServiceClient` that applies the user operations it gets and fails on flags such +as `failAdd`; it embeds the nil interface, so a call it does not implement panics. 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 @@ -41,7 +42,8 @@ GOOS=linux GOARCH=amd64 go test -c -tags e2e -o e2e.test ./test/e2e The Xray suite in `test/e2e/xray` carries the same tag and needs Docker only, no kernel module. It has its own `TestMain`, compose project (`deploy/compose.e2e-xray.yml`, network `172.30.98.0/24`) and scratch root `.e2e-xray/` -(gitignored). It starts the official Xray image with a self-signed nginx as the Reality target, +(gitignored). Both suites take the compose lifecycle and the polling helpers from +`test/e2e/e2eutil`. It starts the official Xray image with a self-signed nginx as the Reality target, builds usher from the tree and drives two users through client containers built from the rendered links. The server config allows the target address in the freedom outbound `finalRules`, because Xray 26.x blocks private destinations for VLESS by default. Run it with `-count=1`: @@ -80,7 +82,8 @@ scripts/format.sh ``` Run before presenting changes. It checks gofmt, runs `go vet` without tags and with the `e2e` -tag, and checks that `gen/xray` equals the output of `buf generate`. `gofmt -w .` fixes formatting. The pre-commit hook and the CI `check` job run the same script. +tag, and runs the `gen/xray` check (see Generate). `gofmt -w .` fixes formatting. The pre-commit +hook and the CI `check` job run the same script. After a change to a shell script or a workflow, also run: diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 062c4d4..5b64e20 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -104,7 +104,8 @@ functions in `internal/xray`: | `securities` | `security.go` | stream `security_type` (the settings message type) | Reality | `listInbounds` calls `ListInbounds`, drops every inbound whose proxy settings type has no -`protocols` entry (such as the `api` tunnel), and decodes the rest. An inbound that fails to +`protocols` entry (such as the `api` tunnel) and every inbound without a tag, and decodes the +rest. An inbound that fails to decode becomes a failed interface with the error; the others are unaffected. A transport or security without a table entry fails the inbound with an error that names it, and an empty security type is named `none` in the error. @@ -180,8 +181,8 @@ The link file is `clients//.txt`, one line, mode 0600: - `users.json` sits in the `usher-state` volume (mode `0600`, uid 1000). To re-key or delete a user: `docker compose stop usher`, then `docker run --rm -it -u 1000:1000 -v _usher-state:/state alpine vi /state/users.json`, - delete the `/` entry from the `awg` or `xray` group, `docker compose start usher`. A user still in `usher.yml` - gets new keys. + delete the `/` entry from the `awg` or `xray` group, + `docker compose start usher`. A user still in `usher.yml` gets new keys. - `USHER_HOST` is an address, not a name: a client that re-resolves a name while its DNS points into the tunnel cannot reconnect. - Client isolation and DNS redirection belong to the service config, not to usher. For AmneziaWG: From 775889335aaf2db69aa7e97b07189c9fb1bdbbd8 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:06:38 +0300 Subject: [PATCH 27/29] refactor(repo): drop restating comments --- internal/awg/session.go | 1 - internal/pass/pass.go | 3 --- internal/xray/protocol.go | 6 +----- internal/xray/security.go | 1 - internal/xray/session.go | 2 -- internal/xray/session_test.go | 8 +++----- test/e2e/xray/xray_test.go | 6 +----- 7 files changed, 5 insertions(+), 22 deletions(-) diff --git a/internal/awg/session.go b/internal/awg/session.go index 9a28234..ef397eb 100644 --- a/internal/awg/session.go +++ b/internal/awg/session.go @@ -21,7 +21,6 @@ import ( // Suffix is the file suffix of an AmneziaWG client config. const Suffix = ".conf" -// callTimeout bounds each call to awg-grpc. const callTimeout = 10 * time.Second // Dial opens a connection to awg-grpc. It returns the client and the function that closes the diff --git a/internal/pass/pass.go b/internal/pass/pass.go index f9d5731..d383d5b 100644 --- a/internal/pass/pass.go +++ b/internal/pass/pass.go @@ -26,7 +26,6 @@ func New(settings Settings, backends []Backend, log *slog.Logger) *Pass { return &Pass{settings: settings, backends: backends, log: log} } -// openBackend is a backend with its session for this pass. type openBackend struct { Backend session Session @@ -165,8 +164,6 @@ func (p *Pass) loadConfig() (map[string][]string, bool) { return p.last, true } -// interfaceOwners maps every reported interface name to the names of the backends that -// reported it. func interfaceOwners(open []openBackend) map[string][]string { owners := make(map[string][]string) for _, b := range open { diff --git a/internal/xray/protocol.go b/internal/xray/protocol.go index 5d753ce..db52fac 100644 --- a/internal/xray/protocol.go +++ b/internal/xray/protocol.go @@ -23,17 +23,13 @@ const ( decryptionNone = "none" ) -// protocolDecoder builds the protocol of an inbound from its proxy settings and the names of its -// transport and security. type protocolDecoder func(settings []byte, transport, security string) (protocol, error) // protocol is what a proxy-settings entry builds for an inbound. type protocol struct { // account returns the account Xray holds for an entry. account func(e state.XrayEntry) *serial.TypedMessage - // link returns the client link of a user. params are the inbound's transport and security - // parameters. - link func(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string + link func(e state.XrayEntry, user string, host netip.Addr, port uint16, params url.Values) string } // protocols maps the type of an inbound's proxy settings to its decoder. diff --git a/internal/xray/security.go b/internal/xray/security.go index f0479ed..c943a48 100644 --- a/internal/xray/security.go +++ b/internal/xray/security.go @@ -21,7 +21,6 @@ const securityNone = "none" // fingerprintChrome is the TLS client fingerprint of every link; Reality requires one. const fingerprintChrome = "chrome" -// securityReality is the security parameter of a Reality link. const securityReality = "reality" // securityDecoder returns the link parameters of a stream's security. diff --git a/internal/xray/session.go b/internal/xray/session.go index eddb0d8..0af3427 100644 --- a/internal/xray/session.go +++ b/internal/xray/session.go @@ -23,10 +23,8 @@ import ( // Suffix is the file suffix of an Xray client link file. const Suffix = ".txt" -// callTimeout bounds each call to Xray. const callTimeout = 10 * time.Second -// userLevel is the level of every user usher adds. const userLevel = 0 // Dial opens a connection to the Xray API. It returns the client and the function that closes the diff --git a/internal/xray/session_test.go b/internal/xray/session_test.go index 02a2510..afa45de 100644 --- a/internal/xray/session_test.go +++ b/internal/xray/session_test.go @@ -37,11 +37,9 @@ type fakeXray struct { failAdd string // failRemove names an email whose RemoveUserOperation fails. failRemove string - // failUsers makes GetInboundUsers fail. - failUsers bool - // failList makes ListInbounds fail. - failList bool - calls []string + failUsers bool + failList bool + calls []string } func newFakeXray(configs ...*core.InboundHandlerConfig) *fakeXray { diff --git a/test/e2e/xray/xray_test.go b/test/e2e/xray/xray_test.go index edf31de..72eb1cd 100644 --- a/test/e2e/xray/xray_test.go +++ b/test/e2e/xray/xray_test.go @@ -32,7 +32,6 @@ const ( targetBody = "usher-e2e-target" proxyPort = 8080 - // The deadlines follow the production constants: 3 s file poll, 500 ms settle, 30 s refill. changeTimeout = 10 * time.Second refillTimeout = 60 * time.Second requestTimeout = 5 * time.Second @@ -42,7 +41,6 @@ const ( failedFetches = 3 ) -// The bind-mount directories of the compose project, under scratchRoot. const ( scratchRoot = ".e2e-xray" configDir = "config" @@ -159,7 +157,6 @@ func assertFailsRepeatedly(t *testing.T, what, proxy string) { } } -// usherLogs returns the log of the usher container. func usherLogs(t *testing.T) string { t.Helper() out, err := project.Cmd("logs", "--no-color", "usher").CombinedOutput() @@ -195,8 +192,7 @@ func readLink(user string) (string, error) { return strings.TrimSpace(string(data)), nil } -// writeClientConfig builds the Xray client config of a user from the rendered link. pbk, sid and -// sni go into the config unchanged. +// writeClientConfig builds the Xray client config of a user from the rendered link. func writeClientConfig(t *testing.T, user, link string) { t.Helper() parsed, err := url.Parse(link) From 037513d59c2ef0b16eb5bd3a590e09b0393fcde1 Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:16:15 +0300 Subject: [PATCH 28/29] docs(plans): record the xray driver run --- docs/plans/20261007-xray-driver.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index 021baaf..a2f0f53 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -526,3 +526,25 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. **Follow-up** - per-user metrics (issue #1) for both backends; `StatsService` joins the delivery contract then + +**Executed by exec:** +- branch: xray-driver + +## Verify it yourself + +1. Unit tests and the format chain, on any machine with Go 1.27.1: + `go test -race -count=1 ./...` and `scripts/format.sh` both exit 0 on `7758893`. +2. Both e2e suites, on Linux with Docker and the `amneziawg` module, or in CI: + `go test -tags e2e -count=1 -v ./test/e2e/...`. CI run 37949266510 on `7758893` shows + `TestE2E` (enroll, handshake, switch_off, broken_file_and_refill) and `TestXray` (connect, + switch_off, refill) as PASS. `master` (`876b208`) has no `test/e2e/xray` package, so there is no + failing pre-change run to compare; before this branch an Xray tag in `usher.yml` only logged + "interface is not reported by awg-grpc" (`pass.go:211` on `master`). +3. Xray behaviour in the e2e, step by step: two users get a link each and an HTTP request through + each link returns 200; after `bob` leaves `usher.yml`, usher logs `removed=[bob]` and three + fetches through his link fail while `alice` still works; after `docker compose restart xray`, + `alice` works again within 60 s, `bob` stays off, and one more refill tick logs no + `users changed`. +4. By hand, once: import one rendered `clients//.txt` link into v2rayNG or Streisand and + open a page. No automated check covers how phone clients parse the trimmed `sid` and the `%20` + escaping. From c3381e5d76e5904e4b2bbc853746fbff4156c91c Mon Sep 17 00:00:00 2001 From: EvaTheSalmon <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:18:30 +0300 Subject: [PATCH 29/29] docs(plans): sync xray plan with review fixes --- docs/plans/20261007-xray-driver.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/plans/20261007-xray-driver.md b/docs/plans/20261007-xray-driver.md index a2f0f53..f308168 100644 --- a/docs/plans/20261007-xray-driver.md +++ b/docs/plans/20261007-xray-driver.md @@ -14,8 +14,9 @@ `users.json` moves to version 2, which the previous image refuses to load. The release is marked with `!`. - Scope of the Xray driver: VLESS over raw TCP without a header, with Reality security and - `decryption: none`. Any other security, transport, header, decryption or a port range fails - that inbound with an error that names the field. The driver is laid out so that more shapes + `decryption: none`. Any other security, transport, header, decryption, a port range, a + `finalmask` or startup `clients` fails that inbound with an error that names the field; an + inbound without a tag is skipped. The driver is laid out so that more shapes can be added later (Solution Overview, "Room for more inbound shapes"). ## Context (from discovery) @@ -255,10 +256,11 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. ``` An empty `security_type` is named `none` in errors. - **Inbound reading.** `ListInbounds{isOnlyTags: false}`, then per inbound: - - `proxy_settings.type` must have an entry in `protocols`; an inbound without one is not an - usher interface and is skipped silently. The VLESS entry requires `decryption` `none` and sets - the flow. + - `proxy_settings.type` must have an entry in `protocols`; an inbound without one, or without a + tag, is not an usher interface and is skipped silently. The VLESS entry requires `decryption` + `none` and no startup `clients`, and sets the flow. - The receiver port list must hold one range with `From == To`. + - The stream settings must carry no `finalmask` (`tcpmasks`, `udpmasks`). - The `tcp` transport entry accepts all three header forms of raw TCP and fails any other header. Verified against Xray v26.9.9: the no-op header arrives as `xray.transport.internet.headers.noop.ConnectionConfig`; the entry accepts any message of the @@ -277,7 +279,7 @@ reported by awg-grpc" (`pass.go:211`) and nothing reaches Xray. - **Link file.** `clients//.txt`, mode 0600, one line built by the protocol entry: `vless://@:?#`, where `params` merges the protocol's `encryption=none` and `flow`, the transport's `type=tcp` and the security's `security=reality`, - `sni`, `fp=chrome`, `pbk`, `sid`, encoded by `url.Values.Encode`. + `sni`, `fp=chrome`, `pbk`, `sid`, encoded by `url.Values.Encode` with spaces written as `%20`. ASSUMPTION: these parameter names and values are the share-link format phone clients import; Task 7 pins them against the public proposal. - **Client file suffix per backend.** `clients.Path` and `clients.Existing` take the backend's