From 7c4016c7e1bd6d0a496e75bcbd15e08828274c6a Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 31 Jul 2026 21:59:26 +0800 Subject: [PATCH 1/3] Refactor Xray JSON invoke APIs --- AGENTS.md | 205 ++++++++++++++++++++++++++++++++++++++++ README.md | 63 +++++++----- download_geo/main.go | 2 +- invoke.go | 38 +------- invoke_model.go | 23 ++--- invoke_test.go | 121 +++++++++++++----------- readme/README.zh_CN.md | 54 +++++++---- xray/ping.go | 30 ------ xray/ping_batch.go | 20 ++-- xray/ping_batch_test.go | 46 +++------ xray/validation.go | 6 +- xray/xray.go | 46 ++------- xray/xray_test.go | 18 ++-- 13 files changed, 393 insertions(+), 279 deletions(-) create mode 100644 AGENTS.md delete mode 100644 xray/ping.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..de3cdbaa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,205 @@ +# Project Overview + +libXray is a Go wrapper around Xray-core for mobile and desktop applications. +It exposes one structured JSON entrypoint, provides share-link and GeoData +utilities, and builds native artifacts for Android, Apple platforms, Linux, and +Windows. + +The public Invoke contract is intentionally small. Platform applications should +construct typed request models, serialize them at the native boundary, and call +`Invoke` or `CGoInvoke`. Do not add platform-specific application behavior to +the generic API. + +# Repository Layout + +| Path | Purpose | +| --- | --- | +| `invoke.go` | Invoke request validation, method dispatch, and response encoding. | +| `invoke_model.go` | Public method enum and typed request/response models. | +| `xray/` | Xray instance lifecycle, configuration validation, and batch latency testing. | +| `share/` | Share-link parsing, validation, and generation. | +| `geo/` | GeoData inspection helpers. | +| `controller/` | Android socket protection and process lookup integration. | +| `dns/` | Android VPN-aware process DNS resolver. | +| `memory/` | Platform-specific memory-pressure handling. | +| `nodep/` | Small utilities that do not depend on the managed Xray instance. | +| `cgo_bridge/` | C ABI exports used by Apple, Linux, Windows, and Dart FFI. | +| `android_wrapper.go` | Android-only gomobile interfaces and controller registration. | +| `build/` | Cross-platform build scripts and artifact assembly. | +| `.github/workflows/` | CI builds and release artifact publication. | +| `README.md` | English integration documentation. | +| `readme/README.zh_CN.md` | Chinese integration documentation. | + +# Invoke API Contract + +The current API version is `2`. Requests using an omitted or different +`apiVersion` are rejected. + +```json +{ + "apiVersion": 2, + "method": "runXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +Every response uses the same envelope: + +```json +{ + "success": true, + "data": {}, + "error": "" +} +``` + +`data` must be a JSON object for successful methods that return data, `{}` for +successful methods without data, or `null` for failures without structured +failure data. Do not return scalar values directly from `data`. + +Supported methods: + +- `getFreePorts` +- `convertShareLinksToXrayJson` +- `convertXrayJsonToShareLinks` +- `countGeoData` +- `pingBatch` +- `testXray` +- `runXray` +- `stopXray` +- `xrayVersion` +- `getXrayState` + +`pingBatch`, `testXray`, and `runXray` receive serialized Xray configuration +text through `xrayJson`. They must not accept or read an application-provided +configuration file path. `countGeoData` is the exception because it operates on +GeoData files directly and receives `datDir` in its payload. + +The complete UTF-8 Invoke request and response envelopes are limited to 16 MiB. +`pingBatch` accepts at most five configurations. It parses only `outbounds`, +ignores other root fields, and includes outbound dependencies referenced by +`streamSettings.sockopt.dialerProxy` or `proxySettings.tag`. + +# Runtime Semantics + +`runXray` manages one package-level Xray instance. A second `runXray` call fails +until `stopXray` closes the current instance. + +`testXray` and `pingBatch` create temporary Xray instances. Xray-core contains +process-wide state, including the system dialer DNS client and outbound manager. +Running temporary instances while another Xray instance is active may replace +that state, and closing a temporary instance does not restore it. libXray does +not serialize or isolate these calls. Integrators that require independent +concurrent instances must place them in separate processes. + +Xray runtime environment values belong in the root `env` object of `xrayJson`. +A top-level `env` field on the Invoke request is ignored. Missing root env fields +are governed by Xray-core behavior. + +# Platform Integration + +## C ABI + +`cgo_bridge/main.go` exports: + +```c +char* CGoInvoke(char* requestJSON); +void CGoFree(char* value); +``` + +`CGoInvoke` returns C-allocated memory. Every non-null response must be released +exactly once with `CGoFree`. Do not release it with a platform allocator or from +Go directly. + +## Android + +Android uses gomobile and produces `libXray.aar` plus +`libXray-sources.jar`. Android-only APIs include socket protection, process +lookup registration, `SetDNS`, and `ResetDNS`. + +`SetDNS` changes Go's process-wide resolver and requires a protected IP endpoint +such as `8.8.8.8:53`. Call `ResetDNS` after the managed Xray instance stops. +Keep Android-only code behind the `android` build tag. + +## Apple Platforms + +The CGo build produces `LibXray.xcframework` for iOS, iOS Simulator, macOS, +tvOS, and tvOS Simulator. Swift callers use `CGoInvoke` and `CGoFree`; the Xray +configuration and runtime TUN fd are supplied by the application through the +typed JSON contract. + +## Linux and Windows + +Linux produces `linux_so/libXray.so`; Windows produces +`windows_dll/libXray.dll`. Both artifacts expose the C ABI. libXray does not +provide or manage a desktop executable wrapper. + +# Building + +Build scripts use the Xray-core version pinned by `go.mod` by default: + +```shell +python3 build/main.py android +python3 build/main.py apple go +python3 build/main.py linux +python build/main.py windows +``` + +Apple also has a gomobile build path: + +```shell +python3 build/main.py apple gomobile +``` + +To test an adjacent Xray-core checkout, place it at `../Xray-core` and append +`local`: + +```shell +python3 build/main.py android local +python3 build/main.py apple go local +``` + +The build scripts temporarily adjust the Go module graph and restore `go.mod` +and `go.sum` when the build finishes. Generated native artifacts, downloaded +GeoData, and intermediate build directories are ignored by Git. Do not edit +generated headers, archives, frameworks, AARs, JARs, DLLs, or shared libraries +manually. + +# Development Rules + +1. Keep `Invoke` as the single cross-platform API entrypoint. Platform-only + controller APIs must remain isolated by build tags. +2. Define request and response fields as typed Go models in `invoke_model.go`. + Do not pass unstructured maps into package business logic. +3. Treat method names, JSON keys, response shapes, and `apiVersion` as a public + wire contract. Breaking changes require an API version increment and + synchronized integration documentation. +4. Xray configuration APIs accept `xrayJson` text, not file paths. File access + remains limited to APIs whose purpose is operating on files. +5. Keep the English and Chinese README API sections synchronized. +6. Preserve per-item ordering in `pingBatch`; one invalid configuration should + produce an item failure without discarding other accepted items. +7. Close every temporary Xray instance on success and error paths. Do not add + hidden serialization or state restoration that changes existing runtime + semantics. +8. Do not modify the adjacent Xray-core checkout as part of a libXray change + unless the task explicitly requires it. +9. Use `gofmt` for Go source and keep changes narrowly scoped to the owning + package. + +# Validation + +Run the checks appropriate to the change scope: + +```shell +gofmt -w +go test ./... -count=1 +git diff --check +``` + +Changes to build scripts or platform bridges should also build the affected +artifact. Changes to the Invoke wire contract must include dispatch/model tests, +unknown or removed method tests where relevant, response-shape tests, and +synchronized consumer model updates in downstream applications. diff --git a/README.md b/README.md index 8b57b6d2..f3d6c536 100644 --- a/README.md +++ b/README.md @@ -128,10 +128,10 @@ The request is a JSON object: ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "runXray", "payload": { - "configPath": "/path/to/config.json" + "xrayJson": "{\"outbounds\":[...]}" } } ``` @@ -148,23 +148,26 @@ The response is a JSON object: Design notes: -1. A top-level `env` field is ignored and has no effect. Xray-core runtime +1. Invoke currently accepts only `apiVersion: 2`. Xray configurations are + passed as UTF-8 JSON text in `xrayJson`; libXray does not read configuration + file paths. +2. A top-level `env` field is ignored and has no effect. Xray-core runtime environment options belong in the root `env` object of the Xray config. -2. `SetTunFd` has been removed. When the fd is only known at runtime, write +3. `SetTunFd` has been removed. When the fd is only known at runtime, write `xray.tun.fd` into the Xray config root `env` object before calling `runXray`. -3. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in +4. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in the method payload. -4. The complete UTF-8 encoded Invoke request and response JSON envelopes are +5. The complete UTF-8 encoded Invoke request and response JSON envelopes are limited to 16 MiB. If either limit is exceeded, Invoke returns a failure response with `success: false`, `data: null`, and a size-limit error. -5. `convertShareLinksToXrayJson` validates each parsed outbound with the current +6. `convertShareLinksToXrayJson` validates each parsed outbound with the current Xray-core config builder. Invalid outbounds are omitted, and the method fails if none remain. Validation does not create or start an Xray instance. -6. Xray-core keeps its system dialer DNS client and outbound manager in - process-wide state. Creating another Xray instance through `ping`, - `pingBatch`, `testXray`, or the exported Go APIs while `runXray` or - `runXrayFromJson` is active may replace that state and affect the running +7. Xray-core keeps its system dialer DNS client and outbound manager in + process-wide state. Creating another Xray instance through `pingBatch`, + `testXray`, or the exported Go APIs while `runXray` is active may replace + that state and affect the running instance. Closing the temporary instance does not restore the previous state. libXray does not serialize, isolate, or restore concurrent instances; callers that require overlapping instances must place them in separate @@ -177,11 +180,9 @@ getFreePorts convertShareLinksToXrayJson convertXrayJsonToShareLinks countGeoData -ping pingBatch testXray runXray -runXrayFromJson stopXray xrayVersion getXrayState @@ -287,28 +288,24 @@ Some tools used to parse shared links. ## xray -### ping - -Latency testing. - ### pingBatch Tests multiple outbound configurations concurrently in one temporary Xray -instance. Each config file is parsed only for its `outbounds`; all other root -fields are ignored. The target outbound is selected by `outboundTag`, then by -the `proxy` tag, and finally by the first outbound. +instance. Each `xrayJson` string is parsed only for its `outbounds`; all other +root fields are ignored. The target outbound is selected by `outboundTag`, then +by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "pingBatch", "payload": { "configs": [ { - "configPath": "/path/to/node-1.json" + "xrayJson": "{\"outbounds\":[...]}" }, { - "configPath": "/path/to/full-config.json", + "xrayJson": "{\"outbounds\":[...]}", "outboundTag": "media" } ], @@ -329,6 +326,26 @@ Outbound dependencies referenced by `streamSettings.sockopt.dialerProxy` or `proxySettings.tag` are included automatically. +### testXray + +Validates an Xray configuration from the supplied JSON text without reading a +configuration file: + +```json +{ + "apiVersion": 2, + "method": "testXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +### runXray + +Starts the managed Xray instance from the supplied JSON text. Use `stopXray` +to stop that instance. `runXrayFromJson` is no longer a separate method. + ### metrics Refer to the following configuration: diff --git a/download_geo/main.go b/download_geo/main.go index d625ecb3..07ee3a5d 100644 --- a/download_geo/main.go +++ b/download_geo/main.go @@ -72,7 +72,7 @@ func makeLoadGeoDataRequest(datDir string, name string, geoType string) (string, return "", err } request := libXray.LibXrayInvokeRequest{ - APIVersion: 1, + APIVersion: libXray.LibXrayAPIVersion, Method: libXray.LibXrayMethodCountGeoData, Payload: payload, } diff --git a/invoke.go b/invoke.go index 9e1ec1a8..90078b6e 100644 --- a/invoke.go +++ b/invoke.go @@ -43,16 +43,12 @@ func Invoke(requestJSON string) string { return invokeConvertXrayJsonToShareLinks(request.Payload) case LibXrayMethodCountGeoData: return invokeCountGeoData(request.Payload) - case LibXrayMethodPing: - return invokePing(request.Payload) case LibXrayMethodPingBatch: return invokePingBatch(request.Payload) case LibXrayMethodTestXray: return invokeTestXray(request.Payload) case LibXrayMethodRunXray: return invokeRunXray(request.Payload) - case LibXrayMethodRunXrayFromJson: - return invokeRunXrayFromJSON(request.Payload) case LibXrayMethodStopXray: return encodeInvokeNoDataResponse(xray.StopXray()) case LibXrayMethodXrayVersion: @@ -64,7 +60,7 @@ func Invoke(requestJSON string) string { } } func validateAPIVersion(version int) error { - if version == 0 || version == 1 { + if version == LibXrayAPIVersion { return nil } return errors.New("unsupported apiVersion") @@ -165,21 +161,6 @@ func invokeCountGeoData(payload json.RawMessage) string { return encodeInvokeNoDataResponse(err) } -func invokePing(payload json.RawMessage) string { - request, err := decodePayload[PingRequest](payload) - if err != nil { - return encodeInvokeResponse(nil, err) - } - delay, err := xray.Ping(request.ConfigPath, request.Timeout, request.URL, request.Proxy) - if err != nil { - if delay == nodep.PingDelayError || delay == nodep.PingDelayTimeout { - return encodeInvokeResponse(&PingResponse{Delay: delay}, err) - } - return encodeInvokeResponse(nil, err) - } - return encodeInvokeResponse(&PingResponse{Delay: delay}, nil) -} - func invokePingBatch(payload json.RawMessage) string { request, err := decodePayload[PingBatchRequest](payload) if err != nil { @@ -189,7 +170,7 @@ func invokePingBatch(payload json.RawMessage) string { configs := make([]xray.PingBatchItem, len(request.Configs)) for i, config := range request.Configs { configs[i] = xray.PingBatchItem{ - ConfigPath: config.ConfigPath, + XrayJSON: config.XrayJson, OutboundTag: config.OutboundTag, } } @@ -215,11 +196,11 @@ func invokePingBatch(payload json.RawMessage) string { } func invokeTestXray(payload json.RawMessage) string { - request, err := decodePayload[RunXrayRequest](payload) + request, err := decodePayload[TestXrayRequest](payload) if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.TestXray(request.ConfigPath) + err = xray.TestXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } @@ -228,15 +209,6 @@ func invokeRunXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.RunXray(request.ConfigPath) - return encodeInvokeNoDataResponse(err) -} - -func invokeRunXrayFromJSON(payload json.RawMessage) string { - request, err := decodePayload[RunXrayFromJSONRequest](payload) - if err != nil { - return encodeInvokeNoDataResponse(err) - } - err = xray.RunXrayFromJSON(request.ConfigJSON) + err = xray.RunXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } diff --git a/invoke_model.go b/invoke_model.go index a0cd3168..96db5320 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -5,16 +5,16 @@ import "encoding/json" type LibXrayMethod string +const LibXrayAPIVersion = 2 + const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" LibXrayMethodConvertShareLinksToXrayJson LibXrayMethod = "convertShareLinksToXrayJson" LibXrayMethodConvertXrayJsonToShareLinks LibXrayMethod = "convertXrayJsonToShareLinks" LibXrayMethodCountGeoData LibXrayMethod = "countGeoData" - LibXrayMethodPing LibXrayMethod = "ping" LibXrayMethodPingBatch LibXrayMethod = "pingBatch" LibXrayMethodTestXray LibXrayMethod = "testXray" LibXrayMethodRunXray LibXrayMethod = "runXray" - LibXrayMethodRunXrayFromJson LibXrayMethod = "runXrayFromJson" LibXrayMethodStopXray LibXrayMethod = "stopXray" LibXrayMethodXrayVersion LibXrayMethod = "xrayVersion" LibXrayMethodGetXrayState LibXrayMethod = "getXrayState" @@ -52,17 +52,6 @@ type CountGeoDataRequest struct { DatDir string `json:"datDir,omitempty"` } -type PingRequest struct { - ConfigPath string `json:"configPath,omitempty"` - Timeout int `json:"timeout,omitempty"` - URL string `json:"url,omitempty"` - Proxy string `json:"proxy,omitempty"` -} - -type PingResponse struct { - Delay int64 `json:"delay,omitempty"` -} - type PingBatchRequest struct { Configs []PingBatchItemRequest `json:"configs,omitempty"` Timeout int `json:"timeout,omitempty"` @@ -70,7 +59,7 @@ type PingBatchRequest struct { } type PingBatchItemRequest struct { - ConfigPath string `json:"configPath,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` OutboundTag string `json:"outboundTag,omitempty"` } @@ -85,11 +74,11 @@ type PingBatchItemResponse struct { } type RunXrayRequest struct { - ConfigPath string `json:"configPath,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` } -type RunXrayFromJSONRequest struct { - ConfigJSON string `json:"configJSON,omitempty"` +type TestXrayRequest struct { + XrayJson string `json:"xrayJson,omitempty"` } type XrayVersionResponse struct { diff --git a/invoke_test.go b/invoke_test.go index edc35f24..933180bf 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -27,7 +27,7 @@ func invokeForTest(t *testing.T, method LibXrayMethod, payload any) testResponse t.Fatal(err) } rawRequest, err := json.Marshal(&LibXrayInvokeRequest{ - APIVersion: 1, + APIVersion: LibXrayAPIVersion, Method: method, Payload: rawPayload, }) @@ -73,21 +73,6 @@ func decodeDataObject[T any](t *testing.T, response testResponse) T { return value } -func writeConfigToFile(t *testing.T, config any, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { - t.Fatal(err) - } - file, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - defer file.Close() - if err := json.NewEncoder(file).Encode(config); err != nil { - t.Fatal(err) - } -} - func writeGeoSiteDatForTest(t *testing.T, path string) { t.Helper() data, err := proto.Marshal(&geodata.GeoSiteList{ @@ -201,14 +186,15 @@ func testXrayConfig(t *testing.T) any { } func TestInvokeTestXray(t *testing.T) { - projectRoot, _ := filepath.Abs(".") - configPath := filepath.Join(projectRoot, "config", "xray_config_test.json") - writeConfigToFile(t, testXrayConfig(t), configPath) + xrayJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodTestXray, - RunXrayRequest{ConfigPath: configPath}, + TestXrayRequest{XrayJson: string(xrayJSON)}, ) if !response.Success { t.Fatalf("TestXray failed: %s", response.Err) @@ -216,15 +202,36 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeTestXrayDoesNotReadConfigPath(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "xray.json") + configJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, configJSON, 0o600); err != nil { + t.Fatal(err) + } + + response := invokeForTest( + t, + LibXrayMethodTestXray, + TestXrayRequest{XrayJson: configPath}, + ) + if response.Success { + t.Fatal("testXray should parse xrayJson instead of reading a path") + } +} + func TestInvokeRunXray(t *testing.T) { - projectRoot, _ := filepath.Abs(".") - configPath := filepath.Join(projectRoot, "config", "xray_config_run.json") - writeConfigToFile(t, testXrayConfig(t), configPath) + xrayJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodRunXray, - RunXrayRequest{ConfigPath: configPath}, + RunXrayRequest{XrayJson: string(xrayJSON)}, ) defer xrayStopForTest(t) if !response.Success { @@ -247,12 +254,14 @@ func TestInvokeRunXrayAppliesConfigEnv(t *testing.T) { } config["env"] = map[string]string{key: "configured"} - configPath := filepath.Join(t.TempDir(), "xray.json") - writeConfigToFile(t, config, configPath) + xrayJSON, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodRunXray, - RunXrayRequest{ConfigPath: configPath}, + RunXrayRequest{XrayJson: string(xrayJSON)}, ) defer xrayStopForTest(t) if !response.Success { @@ -349,28 +358,6 @@ func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing. } } -func TestInvokePingReturnsDelaySentinelOnXrayError(t *testing.T) { - response := invokeForTest( - t, - LibXrayMethodPing, - PingRequest{ - ConfigPath: filepath.Join(t.TempDir(), "missing.json"), - Timeout: 1, - URL: "https://example.com", - }, - ) - if response.Success { - t.Fatal("Ping should keep failure success state on Xray error") - } - if response.Err == "" { - t.Fatal("Ping failure should keep error text") - } - ping := decodeDataObject[PingResponse](t, response) - if ping.Delay != nodep.PingDelayError { - t.Fatalf("delay = %d, want %d", ping.Delay, nodep.PingDelayError) - } -} - func TestInvokePingBatchReturnsPerItemFailures(t *testing.T) { response := invokeForTest( t, @@ -378,7 +365,7 @@ func TestInvokePingBatchReturnsPerItemFailures(t *testing.T) { PingBatchRequest{ Configs: []PingBatchItemRequest{ { - ConfigPath: filepath.Join(t.TempDir(), "missing.json"), + XrayJson: "not JSON", }, }, Timeout: 1, @@ -428,7 +415,7 @@ func TestInvokePingBatchRejectsMoreThanFiveConfigs(t *testing.T) { configs := make([]PingBatchItemRequest, 6) for i := range configs { configs[i] = PingBatchItemRequest{ - ConfigPath: "unused.json", + XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`, } } response := invokeForTest( @@ -494,6 +481,21 @@ func TestInvokeUnknownMethod(t *testing.T) { } } +func TestInvokeRemovedMethods(t *testing.T) { + for _, method := range []string{"ping", "runXrayFromJson"} { + response := invokeRawForTest( + t, + `{"apiVersion":2,"method":"`+method+`","payload":{}}`, + ) + if response.Success { + t.Fatalf("removed method %q should fail", method) + } + if response.Err != "unknown method" { + t.Fatalf("method %q error = %q, want unknown method", method, response.Err) + } + } +} + func TestInvokeRejectsOversizedRequest(t *testing.T) { response := invokeRawForTest(t, strings.Repeat(" ", maxInvokeJSONBytes+1)) if response.Success { @@ -531,17 +533,22 @@ func TestInvokeRejectsOversizedResponse(t *testing.T) { func TestInvokeAPIVersion(t *testing.T) { response := invokeRawForTest(t, `{"method":"xrayVersion"}`) - if !response.Success { - t.Fatalf("omitted apiVersion should default to v1: %s", response.Err) + if response.Success { + t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion","env":{"xray.location.asset":"updated-asset"}}`) + response = invokeRawForTest(t, `{"apiVersion":1,"method":"xrayVersion"}`) if response.Success { - t.Fatal("unsupported apiVersion should fail") + t.Fatal("v1 apiVersion should fail") } if got := string(response.Data); got != "null" { t.Fatalf("data = %s, want null", got) } + + response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion"}`) + if !response.Success { + t.Fatalf("v2 apiVersion should succeed: %s", response.Err) + } } func TestInvokeNoDataResponseShape(t *testing.T) { @@ -551,7 +558,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":1,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":2,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -564,7 +571,7 @@ func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) - requestJSON := `{"apiVersion":1,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` + requestJSON := `{"apiVersion":2,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` var response testResponse if err := json.Unmarshal([]byte(Invoke(requestJSON)), &response); err != nil { t.Fatal(err) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index d21bdb78..9f925797 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -96,10 +96,10 @@ void CGoFree(char* value); ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "runXray", "payload": { - "configPath": "/path/to/config.json" + "xrayJson": "{\"outbounds\":[...]}" } } ``` @@ -116,12 +116,13 @@ void CGoFree(char* value); 设计决定: -1. 顶层 `env` 字段会被忽略且不会生效。Xray-core 运行时环境项应写入 Xray 配置根 `env` 对象。 -2. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 -3. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 -4. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 -5. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。 -6. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 或 `runXrayFromJson` 正在运行时,通过 `ping`、`pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 +1. Invoke 当前只接受 `apiVersion: 2`。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 +2. 顶层 `env` 字段会被忽略且不会生效。Xray-core 运行时环境项应写入 Xray 配置根 `env` 对象。 +3. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 +4. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 +5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 +6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。 +7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 正在运行时,通过 `pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 支持的 method: @@ -130,11 +131,9 @@ getFreePorts convertShareLinksToXrayJson convertXrayJsonToShareLinks countGeoData -ping pingBatch testXray runXray -runXrayFromJson stopXray xrayVersion getXrayState @@ -219,27 +218,23 @@ libXray 使用 `sendThrough` 来存储节点名称。 ## xray -### ping - -延迟测试。 - ### pingBatch -在一个临时 Xray instance 内并发测试多份 outbound 配置。每份配置文件只解析 -`outbounds`,其他根字段全部忽略。目标 outbound 依次按 `outboundTag`、`proxy` -tag、首个 outbound 选择。 +在一个临时 Xray instance 内并发测试多份 outbound 配置。每个 `xrayJson` 文本只 +解析 `outbounds`,其他根字段全部忽略。目标 outbound 依次按 `outboundTag`、 +`proxy` tag、首个 outbound 选择。 ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "pingBatch", "payload": { "configs": [ { - "configPath": "/path/to/node-1.json" + "xrayJson": "{\"outbounds\":[...]}" }, { - "configPath": "/path/to/full-config.json", + "xrayJson": "{\"outbounds\":[...]}", "outboundTag": "media" } ], @@ -258,6 +253,25 @@ tag、首个 outbound 选择。 通过 `streamSettings.sockopt.dialerProxy` 或 `proxySettings.tag` 引用的 outbound 依赖会被自动包含。 +### testXray + +直接校验传入的 Xray JSON 文本,不读取配置文件: + +```json +{ + "apiVersion": 2, + "method": "testXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +### runXray + +使用传入的 Xray JSON 文本启动由 libXray 管理的 Xray instance,并通过 +`stopXray` 停止。`runXrayFromJson` 不再作为独立 method 存在。 + ### metrics 统计。 diff --git a/xray/ping.go b/xray/ping.go deleted file mode 100644 index f667b80d..00000000 --- a/xray/ping.go +++ /dev/null @@ -1,30 +0,0 @@ -package xray - -import ( - "github.com/xtls/libxray/nodep" -) - -// Ping Xray config and find the delay and country code of its outbound. -// configPath means the config.json file path. -// timeout means how long the http request will be cancelled if no response, in units of seconds. -// url means the website we use to test speed. "https://www.google.com" is a good choice for most cases. -// proxy means the local http/socks5 proxy, like "socks5://[::1]:1080". -func Ping(configPath string, timeout int, url string, proxy string) (int64, error) { - server, err := StartXray(configPath) - if err != nil { - return nodep.PingDelayError, err - } - - if err := server.Start(); err != nil { - _ = server.Close() - return nodep.PingDelayError, err - } - defer server.Close() - - delay, err := nodep.MeasureDelay(timeout, url, proxy) - if err != nil { - return delay, err - } - - return delay, nil -} diff --git a/xray/ping_batch.go b/xray/ping_batch.go index 947498d7..f40f24d9 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -9,7 +9,7 @@ import ( "net" "net/http" "net/url" - "os" + "strings" "sync" "time" @@ -26,7 +26,7 @@ const ( ) type PingBatchItem struct { - ConfigPath string + XrayJSON string OutboundTag string } @@ -59,7 +59,7 @@ func PingBatch( mergedOutbounds := make([]conf.OutboundDetourConfig, 0, len(items)) for index, item := range items { - outbounds, err := readPingOutbounds(item.ConfigPath) + outbounds, err := readPingOutbounds(item.XrayJSON) if err != nil { results[index] = failedPingBatchResult(nodep.PingDelayError, err) continue @@ -155,19 +155,13 @@ func validatePingBatchRequest( return nil } -func readPingOutbounds(path string) ([]conf.OutboundDetourConfig, error) { - if path == "" { - return nil, errors.New("ping config path is empty") +func readPingOutbounds(xrayJSON string) ([]conf.OutboundDetourConfig, error) { + if xrayJSON == "" { + return nil, errors.New("ping Xray JSON is empty") } - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - var config pingOutboundConfig - decoder := json.NewDecoder(&confJSON.Reader{Reader: file}) + decoder := json.NewDecoder(&confJSON.Reader{Reader: strings.NewReader(xrayJSON)}) if err := decoder.Decode(&config); err != nil { return nil, err } diff --git a/xray/ping_batch_test.go b/xray/ping_batch_test.go index 933ae8f9..ece744e8 100644 --- a/xray/ping_batch_test.go +++ b/xray/ping_batch_test.go @@ -4,8 +4,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" @@ -13,17 +11,8 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -func writePingBatchConfig(t *testing.T, name string, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - return path -} - func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { - path := writePingBatchConfig(t, "config.json", `{ + xrayJSON := `{ // These fields are deliberately invalid for the full Xray schema. "inbounds": "ignored", "routing": 42, @@ -31,9 +20,9 @@ func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { "outbounds": [ {"protocol": "freedom", "tag": "proxy"} ] - }`) + }` - outbounds, err := readPingOutbounds(path) + outbounds, err := readPingOutbounds(xrayJSON) if err != nil { t.Fatal(err) } @@ -46,7 +35,7 @@ func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { } func TestPreparePingOutboundsPreservesRealityClientConfig(t *testing.T) { - path := writePingBatchConfig(t, "reality.json", `{ + xrayJSON := `{ "outbounds": [{ "tag": "proxy", "protocol": "vless", @@ -71,9 +60,9 @@ func TestPreparePingOutboundsPreservesRealityClientConfig(t *testing.T) { } } }] - }`) + }` - outbounds, err := readPingOutbounds(path) + outbounds, err := readPingOutbounds(xrayJSON) if err != nil { t.Fatal(err) } @@ -198,9 +187,6 @@ func TestPingBatchRunsRequestsConcurrently(t *testing.T) { defer server.Close() config := `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}` - firstPath := writePingBatchConfig(t, "first.json", config) - secondPath := writePingBatchConfig(t, "second.json", config) - type batchResult struct { results []PingBatchResult err error @@ -209,8 +195,8 @@ func TestPingBatchRunsRequestsConcurrently(t *testing.T) { go func() { results, err := PingBatch( []PingBatchItem{ - {ConfigPath: firstPath}, - {ConfigPath: secondPath}, + {XrayJSON: config}, + {XrayJSON: config}, }, 2, server.URL, @@ -248,14 +234,8 @@ func TestPingBatchRunsRequestsConcurrently(t *testing.T) { func TestPingBatchKeepsPerItemConfigErrorsInInputOrder(t *testing.T) { results, err := PingBatch( []PingBatchItem{ - {ConfigPath: filepath.Join(t.TempDir(), "missing.json")}, - { - ConfigPath: writePingBatchConfig( - t, - "invalid.json", - `{"outbounds":[]}`, - ), - }, + {XrayJSON: "not JSON"}, + {XrayJSON: `{"outbounds":[]}`}, }, 1, "https://example.com", @@ -277,8 +257,8 @@ func TestPingBatchKeepsPerItemConfigErrorsInInputOrder(t *testing.T) { t.Fatalf("result %d has no error", index) } } - if !strings.Contains(results[0].Error, "missing.json") { - t.Fatalf("first result error = %q, want missing config error", results[0].Error) + if results[0].Error == "" { + t.Fatal("first result should contain a JSON parsing error") } if results[1].Error != "ping config has no outbounds" { t.Fatalf("second result error = %q, want empty outbounds error", results[1].Error) @@ -355,7 +335,7 @@ func TestValidatePingBatchRequestAcceptsFiveConfigs(t *testing.T) { func ExamplePingBatch() { results, _ := PingBatch( - []PingBatchItem{{ConfigPath: "config.json"}}, + []PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}}, 5, "https://cp.cloudflare.com/", ) diff --git a/xray/validation.go b/xray/validation.go index f7e2c1e1..3d0a442f 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -1,9 +1,9 @@ package xray // Test Xray Config. -// configPath means the config.json file path. -func TestXray(configPath string) error { - server, err := StartXray(configPath) +// xrayJSON is the serialized Xray JSON configuration. +func TestXray(xrayJSON string) error { + server, err := newXrayInstance(xrayJSON) if err != nil { return err } diff --git a/xray/xray.go b/xray/xray.go index a684bffb..ec3f2145 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -3,10 +3,10 @@ package xray import ( "errors" "runtime/debug" + "strings" "sync" "github.com/xtls/libxray/memory" - "github.com/xtls/xray-core/common/cmdarg" "github.com/xtls/xray-core/core" _ "github.com/xtls/xray-core/main/distro/all" ) @@ -18,9 +18,8 @@ var ( var ErrAlreadyRunning = errors.New("xray is already running") -func StartXray(configPath string) (*core.Instance, error) { - file := cmdarg.Arg{configPath} - config, err := core.LoadConfig("json", file) +func newXrayInstance(xrayJSON string) (*core.Instance, error) { + config, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) if err != nil { return nil, err } @@ -33,22 +32,9 @@ func StartXray(configPath string) (*core.Instance, error) { return server, nil } -func StartXrayFromJSON(configJSON string) (*core.Instance, error) { - // Convert JSON string to bytes - configBytes := []byte(configJSON) - - // Use core.StartInstance which can load configuration directly from bytes - server, err := core.StartInstance("json", configBytes) - if err != nil { - return nil, err - } - - return server, nil -} - // Run Xray instance. -// configPath means the config.json file path. -func RunXray(configPath string) (err error) { +// xrayJSON is the serialized Xray JSON configuration. +func RunXray(xrayJSON string) (err error) { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { @@ -56,7 +42,7 @@ func RunXray(configPath string) (err error) { } memory.InitForceFree() - server, err := StartXray(configPath) + server, err := newXrayInstance(xrayJSON) if err != nil { return } @@ -71,26 +57,6 @@ func RunXray(configPath string) (err error) { return nil } -// Run Xray instance with JSON configuration string. -// configJSON means the JSON configuration string. -func RunXrayFromJSON(configJSON string) (err error) { - coreServerMu.Lock() - defer coreServerMu.Unlock() - if coreServer != nil { - return ErrAlreadyRunning - } - - memory.InitForceFree() - server, err := StartXrayFromJSON(configJSON) - if err != nil { - return - } - coreServer = server - - debug.FreeOSMemory() - return nil -} - // Get Xray State func GetXrayState() bool { coreServerMu.Lock() diff --git a/xray/xray_test.go b/xray/xray_test.go index a0b7f3bc..192f825e 100644 --- a/xray/xray_test.go +++ b/xray/xray_test.go @@ -12,7 +12,7 @@ const minimalConfig = `{ "outbounds": [{"protocol": "freedom", "tag": "direct"}] }` -func TestRunXrayFromJSONRejectsDuplicateStart(t *testing.T) { +func TestRunXrayRejectsDuplicateStart(t *testing.T) { t.Cleanup(func() { if err := StopXray(); err != nil { t.Errorf("stop xray: %v", err) @@ -22,13 +22,13 @@ func TestRunXrayFromJSONRejectsDuplicateStart(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } - if err := RunXrayFromJSON(minimalConfig); err != nil { + if err := RunXray(minimalConfig); err != nil { t.Fatalf("start xray: %v", err) } if !GetXrayState() { t.Fatal("xray should be running") } - if err := RunXrayFromJSON(minimalConfig); !errors.Is(err, ErrAlreadyRunning) { + if err := RunXray(minimalConfig); !errors.Is(err, ErrAlreadyRunning) { t.Fatalf("duplicate start error = %v, want %v", err, ErrAlreadyRunning) } } @@ -37,7 +37,7 @@ func TestXrayLifecycleConcurrentStateReads(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } - if err := RunXrayFromJSON(minimalConfig); err != nil { + if err := RunXray(minimalConfig); err != nil { t.Fatalf("start xray: %v", err) } @@ -59,17 +59,17 @@ func TestXrayLifecycleConcurrentStateReads(t *testing.T) { } } -func TestRunXrayFromJSONFailureDoesNotPublishInstance(t *testing.T) { +func TestRunXrayFailureDoesNotPublishInstance(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } - if err := RunXrayFromJSON(`{"outbounds":[`); err == nil { + if err := RunXray(`{"outbounds":[`); err == nil { t.Fatal("invalid config should fail") } if GetXrayState() { t.Fatal("failed start must not publish an instance") } - if err := RunXrayFromJSON(minimalConfig); err != nil { + if err := RunXray(minimalConfig); err != nil { t.Fatalf("start after failure: %v", err) } if err := StopXray(); err != nil { @@ -77,7 +77,7 @@ func TestRunXrayFromJSONFailureDoesNotPublishInstance(t *testing.T) { } } -func TestRunXrayFromJSONSerializesConcurrentStarts(t *testing.T) { +func TestRunXraySerializesConcurrentStarts(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } @@ -94,7 +94,7 @@ func TestRunXrayFromJSONSerializesConcurrentStarts(t *testing.T) { starters.Add(1) go func() { defer starters.Done() - errorsByStart <- RunXrayFromJSON(minimalConfig) + errorsByStart <- RunXray(minimalConfig) }() } starters.Wait() From a83a21ee56a280648780087cfa7a893e26b2b65d Mon Sep 17 00:00:00 2001 From: yiguo Date: Sun, 2 Aug 2026 18:34:48 +0800 Subject: [PATCH 2/3] Add age-encrypted subscription support --- .github/workflows/build.yml | 1 - AGENTS.md | 7 ++ README.md | 43 +++++++++ go.mod | 4 + go.sum | 8 ++ invoke.go | 23 ++++- invoke_model.go | 24 ++++- invoke_test.go | 87 +++++++++++++++++- readme/README.zh_CN.md | 43 ++++++++- share/age.go | 122 ++++++++++++++++++++++++ share/age_test.go | 179 ++++++++++++++++++++++++++++++++++++ 11 files changed, 536 insertions(+), 5 deletions(-) create mode 100644 share/age.go create mode 100644 share/age_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fb4ec44f..49c04183 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -241,7 +241,6 @@ jobs: cp -R LibXray.xcframework "dist/${{ matrix.artifact }}/" ;; esac - # ========================= # Upload artifacts # ========================= diff --git a/AGENTS.md b/AGENTS.md index de3cdbaa..a996452a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ Supported methods: - `getFreePorts` - `convertShareLinksToXrayJson` - `convertXrayJsonToShareLinks` +- `generateAgeKeyPair` - `countGeoData` - `pingBatch` - `testXray` @@ -72,6 +73,12 @@ Supported methods: - `xrayVersion` - `getXrayState` +Age-encrypted subscription support is part of the share boundary. libXray owns +native key generation, in-memory armor decryption, and parsing. Integrating +applications own HTTP headers, persistence of both generated keys, and refresh +behavior. Never log age secret keys, decrypted subscription text, or complete +Invoke requests containing those values. + `pingBatch`, `testXray`, and `runXray` receive serialized Xray configuration text through `xrayJson`. They must not accept or read an application-provided configuration file path. `countGeoData` is the exception because it operates on diff --git a/README.md b/README.md index f3d6c536..8c51d038 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,8 @@ Design notes: 6. `convertShareLinksToXrayJson` validates each parsed outbound with the current Xray-core config builder. Invalid outbounds are omitted, and the method fails if none remain. Validation does not create or start an Xray instance. + Its optional `age.secretKey` decrypts official age ASCII armor in memory + before the existing parser runs. Plaintext input remains unchanged. 7. Xray-core keeps its system dialer DNS client and outbound manager in process-wide state. Creating another Xray instance through `pingBatch`, `testXray`, or the exported Go APIs while `runXray` is active may replace @@ -179,6 +181,7 @@ Supported methods: getFreePorts convertShareLinksToXrayJson convertXrayJsonToShareLinks +generateAgeKeyPair countGeoData pingBatch testXray @@ -278,6 +281,44 @@ convert VMessAEAD/VLESS sharing protocol to Xray Json. convert VMessQRCode to Xray Json. +### age-encrypted subscriptions + +`convertShareLinksToXrayJson` accepts an optional native age secret key. Only +X25519 (`AGE-SECRET-KEY-1...`) and ML-KEM-768 + X25519 hybrid +(`AGE-SECRET-KEY-PQ-1...`) identities are accepted. Recognized age armor is +decrypted in memory and limited to 16 MiB of plaintext. + +```json +{ + "apiVersion": 2, + "method": "convertShareLinksToXrayJson", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted + +```json +{ + "apiVersion": 2, + "method": "generateAgeKeyPair", + "payload": { + "keyType": "x25519" + } +} +``` + +The response contains both `secretKey` and `publicKey`. The integrating +application must persist the pair and send only `publicKey` as +`X-Age-Public-Key`. libXray does not perform the subscription HTTP request, +persist keys, or add headers. Applications must never send the secret key over +HTTP or write decrypted subscription text to disk. + ### vmess convert VMessQRCode to Xray Json. @@ -396,6 +437,8 @@ Start and stop Xray instances. [FreePort](https://github.com/phayes/freeport) +[MetaCubeX age](https://github.com/MetaCubeX/age) (BSD 3-Clause) + # License This repository is based on the MIT License. diff --git a/go.mod b/go.mod index ef69942a..3cb04fc5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/xtls/libxray go 1.26.3 require ( + github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 github.com/stretchr/testify v1.11.1 github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc google.golang.org/protobuf v1.36.11 @@ -21,6 +22,9 @@ require ( github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/metacubex/hkdf v0.1.0 // indirect + github.com/metacubex/hpke v0.1.0 // indirect + github.com/metacubex/mlkem v0.1.0 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pion/dtls/v3 v3.1.4 // indirect diff --git a/go.sum b/go.sum index 6c9ce215..98dee9c7 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,14 @@ 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/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48= +github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c= +github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= +github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4= +github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= +github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= +github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I= +github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= diff --git a/invoke.go b/invoke.go index 90078b6e..4abcf6cc 100644 --- a/invoke.go +++ b/invoke.go @@ -41,6 +41,8 @@ func Invoke(requestJSON string) string { return invokeConvertShareLinksToXrayJson(request.Payload) case LibXrayMethodConvertXrayJsonToShareLinks: return invokeConvertXrayJsonToShareLinks(request.Payload) + case LibXrayMethodGenerateAgeKeyPair: + return invokeGenerateAgeKeyPair(request.Payload) case LibXrayMethodCountGeoData: return invokeCountGeoData(request.Payload) case LibXrayMethodPingBatch: @@ -133,10 +135,29 @@ func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { if err != nil { return encodeInvokeResponse(nil, err) } - xrayJson, err := share.ConvertShareLinksToXrayJson(request.Text) + secretKey := "" + if request.Age != nil { + secretKey = request.Age.SecretKey + } + xrayJson, err := share.ConvertShareLinksToXrayJsonWithAge(request.Text, secretKey) return encodeInvokeResponse(xrayJson, err) } +func invokeGenerateAgeKeyPair(payload json.RawMessage) string { + request, err := decodePayload[GenerateAgeKeyPairRequest](payload) + if err != nil { + return encodeInvokeResponse(nil, err) + } + pair, err := share.GenerateAgeKeyPair(share.AgeKeyType(request.KeyType)) + if err != nil { + return encodeInvokeResponse(nil, err) + } + return encodeInvokeResponse(&GenerateAgeKeyPairResponse{ + SecretKey: pair.SecretKey, + PublicKey: pair.PublicKey, + }, nil) +} + func invokeConvertXrayJsonToShareLinks(payload json.RawMessage) string { request, err := decodePayload[ConvertXrayJsonToShareLinksRequest](payload) if err != nil { diff --git a/invoke_model.go b/invoke_model.go index 96db5320..f317f0ea 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -11,6 +11,7 @@ const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" LibXrayMethodConvertShareLinksToXrayJson LibXrayMethod = "convertShareLinksToXrayJson" LibXrayMethodConvertXrayJsonToShareLinks LibXrayMethod = "convertXrayJsonToShareLinks" + LibXrayMethodGenerateAgeKeyPair LibXrayMethod = "generateAgeKeyPair" LibXrayMethodCountGeoData LibXrayMethod = "countGeoData" LibXrayMethodPingBatch LibXrayMethod = "pingBatch" LibXrayMethodTestXray LibXrayMethod = "testXray" @@ -34,8 +35,29 @@ type GetFreePortsResponse struct { Ports []int `json:"ports,omitempty"` } +type AgeDecryptConfig struct { + SecretKey string `json:"secretKey,omitempty"` +} + type ConvertShareLinksToXrayJsonRequest struct { - Text string `json:"text,omitempty"` + Text string `json:"text,omitempty"` + Age *AgeDecryptConfig `json:"age,omitempty"` +} + +type AgeKeyType string + +const ( + AgeKeyTypeX25519 AgeKeyType = "x25519" + AgeKeyTypeHybrid AgeKeyType = "hybrid" +) + +type GenerateAgeKeyPairRequest struct { + KeyType AgeKeyType `json:"keyType,omitempty"` +} + +type GenerateAgeKeyPairResponse struct { + SecretKey string `json:"secretKey,omitempty"` + PublicKey string `json:"publicKey,omitempty"` } type ConvertXrayJsonToShareLinksRequest struct { diff --git a/invoke_test.go b/invoke_test.go index 933180bf..45e10f41 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -1,13 +1,17 @@ package libXray import ( + "bytes" "encoding/base64" "encoding/json" + "io" "os" "path/filepath" "strings" "testing" + "github.com/metacubex/age" + "github.com/metacubex/age/armor" "github.com/xtls/libxray/nodep" "github.com/xtls/xray-core/common/geodata" "github.com/xtls/xray-core/infra/conf" @@ -339,6 +343,87 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { } } +func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { + generated := invokeForTest( + t, + LibXrayMethodGenerateAgeKeyPair, + GenerateAgeKeyPairRequest{KeyType: AgeKeyTypeX25519}, + ) + if !generated.Success { + t.Fatalf("GenerateAgeKeyPair failed: %s", generated.Err) + } + pair := decodeDataObject[GenerateAgeKeyPairResponse](t, generated) + if pair.SecretKey == "" || pair.PublicKey == "" { + t.Fatalf("generated pair is incomplete: %+v", pair) + } + + recipient, err := age.ParseX25519Recipient(pair.PublicKey) + if err != nil { + t.Fatal(err) + } + var encrypted bytes.Buffer + armored := armor.NewWriter(&encrypted) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + const link = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com#AgeInvoke" + if _, err := io.WriteString(writer, link); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + + converted := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{ + Text: encrypted.String(), + Age: &AgeDecryptConfig{SecretKey: pair.SecretKey}, + }, + ) + if !converted.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", converted.Err) + } + config := decodeDataObject[conf.Config](t, converted) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } +} + +func TestInvokeAgeFailuresHaveNullData(t *testing.T) { + for _, test := range []struct { + method LibXrayMethod + payload any + }{ + { + method: LibXrayMethodGenerateAgeKeyPair, + payload: GenerateAgeKeyPairRequest{KeyType: AgeKeyType("invalid")}, + }, + { + method: LibXrayMethodConvertShareLinksToXrayJson, + payload: ConvertShareLinksToXrayJsonRequest{ + Text: "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid", + }, + }, + } { + response := invokeForTest(t, test.method, test.payload) + if response.Success { + t.Fatalf("method %q unexpectedly succeeded", test.method) + } + if got := string(response.Data); got != "null" { + t.Fatalf("method %q data = %s, want null", test.method, got) + } + if response.Err == "" { + t.Fatalf("method %q returned no error", test.method) + } + } +} + func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing.T) { response := invokeForTest( t, @@ -482,7 +567,7 @@ func TestInvokeUnknownMethod(t *testing.T) { } func TestInvokeRemovedMethods(t *testing.T) { - for _, method := range []string{"ping", "runXrayFromJson"} { + for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey"} { response := invokeRawForTest( t, `{"apiVersion":2,"method":"`+method+`","payload":{}}`, diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 9f925797..180d2d7c 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -121,7 +121,7 @@ void CGoFree(char* value); 3. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 4. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 -6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。 +6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 正在运行时,通过 `pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 支持的 method: @@ -130,6 +130,7 @@ void CGoFree(char* value); getFreePorts convertShareLinksToXrayJson convertXrayJsonToShareLinks +generateAgeKeyPair countGeoData pingBatch testXray @@ -208,6 +209,44 @@ libXray 使用 `sendThrough` 来存储节点名称。 转换 VMessQRCode 为 Xray Json。 +### age 加密订阅 + +`convertShareLinksToXrayJson` 接受可选的 age 原生私钥。仅支持 X25519 +(`AGE-SECRET-KEY-1...`)和 ML-KEM-768 + X25519 hybrid +(`AGE-SECRET-KEY-PQ-1...`)identity。识别到 age armor 后会在内存中完成 +解密,解密后明文上限为 16 MiB。 + +```json +{ + "apiVersion": 2, + "method": "convertShareLinksToXrayJson", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +`generateAgeKeyPair` 可生成新密钥对,`keyType` 支持 `x25519` 或 +`hybrid`;省略时默认为 `x25519`: + +```json +{ + "apiVersion": 2, + "method": "generateAgeKeyPair", + "payload": { + "keyType": "x25519" + } +} +``` + +响应同时包含 `secretKey` 和 `publicKey`。接入 App 必须持久化该密钥对,并且 +只将 `publicKey` 作为 `X-Age-Public-Key` 发送。libXray 不负责订阅 HTTP 请求、 +密钥持久化或请求 Header;严禁通过 HTTP 发送私钥,也不能把解密后的订阅文本 +写入磁盘。 + ### vmess 转换 VMessQRCode 为 Xray Json。 @@ -323,6 +362,8 @@ http://localhost:49227/debug/vars [FreePort](https://github.com/phayes/freeport) +[MetaCubeX age](https://github.com/MetaCubeX/age)(BSD 3-Clause) + # License 本仓库基于 MIT License 。 diff --git a/share/age.go b/share/age.go new file mode 100644 index 00000000..1057911f --- /dev/null +++ b/share/age.go @@ -0,0 +1,122 @@ +package share + +import ( + "errors" + "io" + "strings" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" + "github.com/xtls/xray-core/infra/conf" +) + +const ( + ageArmorHeader = "-----BEGIN AGE ENCRYPTED FILE-----" + maxAgePlaintextBytes = 16 * 1024 * 1024 +) + +var ( + ErrAgeSecretKeyMissing = errors.New("missing age secret key") + ErrAgeSecretKeyInvalid = errors.New("invalid or unsupported age secret key") + ErrAgeDecryptFailed = errors.New("unable to decrypt age subscription") + ErrAgeArmorMalformed = errors.New("malformed age armor") + ErrAgePlaintextTooLarge = errors.New("decrypted subscription exceeds the 16 MiB size limit") + ErrAgePlaintextUnsupported = errors.New("decrypted subscription is unsupported") + ErrAgeKeyTypeUnsupported = errors.New("unsupported age key type") +) + +type AgeKeyType string + +const ( + AgeKeyTypeX25519 AgeKeyType = "x25519" + AgeKeyTypeHybrid AgeKeyType = "hybrid" +) + +type AgeKeyPair struct { + SecretKey string + PublicKey string +} + +func GenerateAgeKeyPair(keyType AgeKeyType) (*AgeKeyPair, error) { + switch keyType { + case "", AgeKeyTypeX25519: + identity, err := age.GenerateX25519Identity() + if err != nil { + return nil, errors.New("failed to generate age keypair") + } + return &AgeKeyPair{ + SecretKey: identity.String(), + PublicKey: identity.Recipient().String(), + }, nil + case AgeKeyTypeHybrid: + identity, err := age.GenerateHybridIdentity() + if err != nil { + return nil, errors.New("failed to generate age keypair") + } + return &AgeKeyPair{ + SecretKey: identity.String(), + PublicKey: identity.Recipient().String(), + }, nil + default: + return nil, ErrAgeKeyTypeUnsupported + } +} + +func ConvertShareLinksToXrayJsonWithAge(links, secretKey string) (*conf.Config, error) { + text := strings.TrimSpace(FixWindowsReturn(links)) + if !strings.HasPrefix(text, ageArmorHeader) { + return ConvertShareLinksToXrayJson(links) + } + if strings.TrimSpace(secretKey) == "" { + return nil, ErrAgeSecretKeyMissing + } + + identity, _, err := parseNativeAgeIdentity(secretKey) + if err != nil { + return nil, err + } + reader, err := age.Decrypt(armor.NewReader(strings.NewReader(text)), identity) + if err != nil { + var noMatch *age.NoIdentityMatchError + if errors.As(err, &noMatch) { + return nil, ErrAgeDecryptFailed + } + return nil, ErrAgeArmorMalformed + } + + plaintext, err := io.ReadAll(io.LimitReader(reader, maxAgePlaintextBytes+1)) + if err != nil { + return nil, ErrAgeArmorMalformed + } + if len(plaintext) > maxAgePlaintextBytes { + return nil, ErrAgePlaintextTooLarge + } + config, err := ConvertShareLinksToXrayJson(string(plaintext)) + if err != nil { + return nil, ErrAgePlaintextUnsupported + } + return config, nil +} + +func parseNativeAgeIdentity(secretKey string) (age.Identity, age.Recipient, error) { + key := strings.TrimSpace(secretKey) + if key == "" || strings.ContainsAny(key, "\r\n") { + return nil, nil, ErrAgeSecretKeyInvalid + } + + if strings.HasPrefix(key, "AGE-SECRET-KEY-1") { + identity, err := age.ParseX25519Identity(key) + if err != nil { + return nil, nil, ErrAgeSecretKeyInvalid + } + return identity, identity.Recipient(), nil + } + if strings.HasPrefix(key, "AGE-SECRET-KEY-PQ-1") { + identity, err := age.ParseHybridIdentity(key) + if err != nil { + return nil, nil, ErrAgeSecretKeyInvalid + } + return identity, identity.Recipient(), nil + } + return nil, nil, ErrAgeSecretKeyInvalid +} diff --git a/share/age_test.go b/share/age_test.go new file mode 100644 index 00000000..9b14a701 --- /dev/null +++ b/share/age_test.go @@ -0,0 +1,179 @@ +package share + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" +) + +const ageTestShareLink = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com#AgeTest" + +func TestGenerateAgeKeyPair(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + first, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + second, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + if first.SecretKey == second.SecretKey || first.PublicKey == second.PublicKey { + t.Fatal("generated age keypairs should be unique") + } + assertAgeRoundTrip(t, first, ageTestShareLink) + }) + } +} + +func TestGenerateAgeKeyPairRejectsUnsupportedType(t *testing.T) { + _, err := GenerateAgeKeyPair(AgeKeyType("unsupported")) + if !errors.Is(err, ErrAgeKeyTypeUnsupported) { + t.Fatalf("error = %v, want %v", err, ErrAgeKeyTypeUnsupported) + } +} + +func TestConvertShareLinksToXrayJsonWithAgePlaintext(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + config, err := ConvertShareLinksToXrayJsonWithAge(ageTestShareLink, pair.SecretKey) + if err != nil { + t.Fatal(err) + } + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + pair, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, ageTestShareLink) + config, err := ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + if err != nil { + t.Fatal(err) + } + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + }) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, ageTestShareLink) + + _, err = ConvertShareLinksToXrayJsonWithAge(armored, "") + if !errors.Is(err, ErrAgeSecretKeyMissing) { + t.Fatalf("missing key error = %v", err) + } + + wrongPair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + _, err = ConvertShareLinksToXrayJsonWithAge(armored, wrongPair.SecretKey) + if !errors.Is(err, ErrAgeDecryptFailed) { + t.Fatalf("wrong key error = %v", err) + } + if strings.Contains(err.Error(), wrongPair.SecretKey) { + t.Fatal("decryption error contains the secret key") + } + + _, err = ConvertShareLinksToXrayJsonWithAge(ageArmorHeader+"\ninvalid", pair.SecretKey) + if !errors.Is(err, ErrAgeArmorMalformed) { + t.Fatalf("malformed armor error = %v", err) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeRejectsLargePlaintext(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest( + t, + pair, + strings.Repeat("x", maxAgePlaintextBytes+1), + ) + _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + if !errors.Is(err, ErrAgePlaintextTooLarge) { + t.Fatalf("large plaintext error = %v", err) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeSanitizesParserErrors(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + sensitivePlaintext := "unsupported://user:password@example.com" + armored := encryptAgeForTest(t, pair, sensitivePlaintext) + _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + if !errors.Is(err, ErrAgePlaintextUnsupported) { + t.Fatalf("unsupported plaintext error = %v", err) + } + if strings.Contains(err.Error(), sensitivePlaintext) || strings.Contains(err.Error(), pair.SecretKey) { + t.Fatal("age parser error contains sensitive input") + } +} + +func assertAgeRoundTrip(t *testing.T, pair *AgeKeyPair, plaintext string) { + t.Helper() + armored := encryptAgeForTest(t, pair, plaintext) + identity, _, err := parseNativeAgeIdentity(pair.SecretKey) + if err != nil { + t.Fatal(err) + } + reader, err := age.Decrypt(armor.NewReader(strings.NewReader(armored)), identity) + if err != nil { + t.Fatal(err) + } + decrypted, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if string(decrypted) != plaintext { + t.Fatalf("decrypted = %q, want %q", decrypted, plaintext) + } +} + +func encryptAgeForTest(t *testing.T, pair *AgeKeyPair, plaintext string) string { + t.Helper() + _, recipient, err := parseNativeAgeIdentity(pair.SecretKey) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + armored := armor.NewWriter(&output) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(writer, plaintext); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + return output.String() +} From 56ac8fd038fe9eedb41a8100c06c173612a42602 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sun, 2 Aug 2026 21:04:16 +0800 Subject: [PATCH 3/3] Document hybrid age key generation --- README.md | 3 +++ readme/README.zh_CN.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c51d038..29b38fb1 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,9 @@ decrypted in memory and limited to 16 MiB of plaintext. ``` Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted +`keyType` defaults to `x25519`. The `hybrid` option matches Mihomo +`age keygen-pq` and produces an `AGE-SECRET-KEY-PQ-1...` identity with an +`age1pq1...` recipient. ```json { diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 180d2d7c..a859496e 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -230,7 +230,9 @@ libXray 使用 `sendThrough` 来存储节点名称。 ``` `generateAgeKeyPair` 可生成新密钥对,`keyType` 支持 `x25519` 或 -`hybrid`;省略时默认为 `x25519`: +`hybrid`;省略时默认为 `x25519`。`hybrid` 对应 Mihomo 的 +`age keygen-pq`,生成 `AGE-SECRET-KEY-PQ-1...` identity 和 +`age1pq1...` recipient: ```json {