- Go 87.6%
- C 7.9%
- Shell 4.5%
| archive | ||
| cabi | ||
| examples | ||
| gateway | ||
| scripts | ||
| go.mod | ||
| README.md | ||
libagw (amnezia-gateway-sdk)
A Qt-free Go port of the amnezia-client GatewayController: the reusable
transport for talking to the Amnezia API gateway — request-envelope crypto,
HTTP, and the censorship-bypass (proxy failover) machinery — with a C ABI for
embedding into C++, Swift, Kotlin and Dart hosts.
The library is transport only. It does not parse business objects
(services, subscriptions, vpn:// keys); it hands the caller the decrypted
JSON response and an error code, and the host builds its typed models on top.
Layout
gateway/ pure-Go core (no cgo): Client, Config, envelope, bypass, failover
cabi/ C ABI (agw.h) as an importable package — the //export surface
archive/ main package that builds cabi standalone (c-archive / c-shared)
examples/ offline smoke tests: c_smoke, cpp_smoke (RAII wrapper), dart_smoke (FFI)
scripts/ build_archive.sh, smoke.sh, ios_clang.sh
gateway is an ordinary Go package — import it directly from Go hosts
(e.g. the b2b SDK / mobileproxy) with no cgo cost.
What it does
Client.Post sends one encrypted request. On a response that looks like proxy
interference (per the ported shouldBypassProxy heuristics) it resolves a
proxy pool from the S3 storage objects — falling back to the cached pool —
health-checks it, and retries through every proxy until one is accepted. The
working proxy and proxy lists are cached in memory and can be persisted by the
host via ExportState / ImportState. A saved working proxy is tried first;
when it fails, the direct path is retried before the proxy sweep, so a stale
cache from a previous run costs one attempt, not a failover.
c := gateway.New(gateway.Config{
GatewayEndpoint: "https://api.example.com",
PublicKeyPEM: pubKeyPEM,
S3PrimaryEndpoints: []string{"https://s3.example.com/bucket/"},
})
resp, err := c.Post(ctx, "v1/services", payloadJSON,
gateway.PostOptions{ServiceType: "amnezia-premium", UserCountryCode: "ru"})
Post blocks for up to the full failover sequence; run it off the UI thread
and cancel via ctx.
Parity with amnezia-client
The wire protocol and failover behaviour are ported faithfully from
gatewayController.cpp, including the quirks the gateway relies on: a 32-byte AES IV of which CBC uses the first 16,
an 8-byte salt that is generated and transmitted but unused, RSA PKCS#1 v1.5,
and the SHA-512(PEM) proxy-list key schedule.
The library stops at the transport: Post returns the decrypted body for any
gateway answer, including API errors (http_status/message in the body), and
an ErrorCode only when there is no answer to interpret — public key missing
or invalid, timeout, TLS error, gateway unreachable, undecryptable answer.
Mapping the body onto application errors (what apiUtils::checkNetworkReplyErrors
does in amnezia-client) stays with each application.
Two deliberate improvements over the Qt client:
- Full proxy sweep in one path — the client's async path tried a single proxy; here sync and async collapse into one function that walks the whole pool.
- Pull-model state — instead of writing an encrypted blob into app
settings, the library exposes the caches via
ExportState/ImportStateand the host owns storage. The blob holds bypass endpoints; keep it protected.
Diagnostics
There is no log callback. Every Post returns, next to the body or the
error code, a Trace: one line per network attempt, in order, saying what
was tried and what came back —
request 3f1c…-…
direct: tls: unknown certificate authority, suspicious
storage 1/4: http 403, 243 bytes
storage 2/4: http 200, 1812 bytes, 7 proxies
health 1/7: timeout
health 2/7: ok
proxy 2/7: http 200, 1456 bytes, api status 200, accepted
The host writes it into its own log at the level it chooses (for example,
debug on success, warning when the direct path was rejected, error when the
call failed) — nothing to wire at startup, no callback threading, and the
whole story of a failed call sits in one place. Lines never carry endpoint
addresses: failures are classified (tls: certificate hostname mismatch,
connect failed: connection refused, timeout, http 403), never quoted,
because these logs are exported and shared by users and the addresses are
censorship-bypass endpoints. In Go, Response.Trace and Error.Trace; over
the C ABI, agw_result.trace.
Build
Pure Go:
go test ./...
C ABI (see cabi/agw.h for the contract) — static archive for C/C++/Swift,
shared library for Dart FFI and JNI:
scripts/build_archive.sh # c-archive, host
AGW_BUILDMODE=c-shared scripts/build_archive.sh # .dylib/.so/.dll
GOOS=android GOARCH=arm64 scripts/build_archive.sh
On macOS/iOS link the archive with -framework CoreFoundation -framework Security (Go's crypto/x509 uses the system trust store). See
scripts/README.md for cross-compilation.
Offline smoke tests for every binding style (C, C++, Dart):
scripts/smoke.sh
Bindings
The C ABI is the single contract for all non-Go hosts:
- C++ — include
cabi/agw.h, link the archive.examples/cpp_smoke/agw.hppis a ready RAII wrapper. - Dart/Flutter —
dart:ffiover the shared library; generate bindings withffigenfromagw.h. Callagw_postfrom a helper isolate so the blocking call does not stall the UI isolate — a cancel handle is a plain integer, so it can be sent through aSendPort. Seeexamples/dart_smoke. - Swift — import
agw.hvia a module map / SPM binary target. - Kotlin/Android — a thin JNI wrapper over the
.so.
gomobile bind is intentionally not used to expose this library: C++ and Dart
need the C ABI anyway, and the JSON-first surface keeps the hand-written
bindings tiny.
Delivery: standalone vs merged
A Go runtime cannot be duplicated inside one process — two c-archives fail to
link outright (duplicate symbol _crosscall2), and two shared libraries, while
they do load, are an unsupported configuration. So how this library ships
depends on whether the host process already contains Go:
-
No Go in the process (e.g. the Qt client): build
./archiveas a c-archive and link it. This library owns the only runtime. -
Go already in the process (e.g. a Flutter app whose sing-box core is linked in): do not add a second artifact. Instead have that Go binary blank-import this package —
import _ "github.com/amnezia-vpn/libagw/cabi"cgo emits the
//exportsymbols for an imported package just as it does for main, so they land in the artifact that is already being produced (agomobile bindframework or AAR included). Hosts then reach them throughDynamicLibrary.process()on Apple orDynamicLibrary.open()on Android. One runtime, and the marginal size is the gateway code alone rather than another ~6 MB of runtime.
Both paths compile the same cabi package, so there is no second
implementation to keep in sync.