internal/remnawave, and that adapter is the only code in the
repository that knows a Remnawave-shaped type exists.
That is not a stylistic choice. Two systems that both believe they own VPN state produce a class of
bug nobody can reconcile: a customer whose subscription is active in one place and disabled in the
other, and no way to say which is right. Keeping the authority split makes the question answerable —
Remnawave’s answer is the truth about access, Omniflow’s answer is the truth about money and intent —
and it lets you upgrade Remnawave without Omniflow holding an opinion about its schema.
See data ownership for the conceptual version of the boundary and
fulfillment for what happens after a payment settles.
Which Remnawave version
The adapter targets Remnawave 3.2.2 and the paths it calls are hard-coded. There is no version negotiation, no capability discovery, and no second adapter: one client, one set of routes. What pins the version in practice is a contract test,internal/remnawave/contract_test.go, which
asserts the exact method and path of every call, the {"response": …} envelope Omniflow expects
back, and how each failure class is mapped. A Remnawave upgrade that moves a route fails there
rather than surfacing later as a provisioning error nobody can classify. If you are planning a panel
upgrade, running go test ./internal/remnawave/... against the new surface is the cheapest possible
check.
There is no compatibility matrix page. The version is asserted in three places only: the README
badge,
AGENTS.md, and the contract test.Credentials and connection
Both processes that provision and both that read need the same two values.
Obtain the token from your Remnawave installation’s own API-token facility. It needs to be able to
read and modify users, read subscriptions, and manage HWID devices — that is the whole surface
Omniflow touches, and nothing wider is used.
1
Create a token in Remnawave
Issue an API token in the Remnawave panel. Give it to Omniflow alone; the token is used from
three processes but it is one credential, and rotating it means changing one value.
2
Put both values in .env
.env in the shipped Compose file, which
is what stops the three from disagreeing about which panel they are talking to.3
Restart and check readiness
curl -s localhost:8080/readyz should report a remnawave entry with "healthy": true. A
wrong token shows as an unhealthy dependency rather than as a healthy panel — see
Health and readiness.APP_OPERATOR_TOKEN, a 32-byte APP_DATA_ENCRYPTION_KEY, and
APP_VALKEY_URL. Full list in configuration.
There is also no “test connection” button for Remnawave. POST /v1/panel/settings/providers/{provider}/test
probes payment adapters only and answers 422 provider_not_loaded for any other name. The readiness
probe is the connection test.
Every call Omniflow makes
Fifteen operations. This is the complete list — nothing else in Remnawave’s API is reachable from Omniflow, because nothing else is implemented.
Three narrow Go interfaces divide that set by who is allowed to hold it:
Service is the read set
for customer surfaces, Provisioner is the write set the fulfillment worker holds, and Importer is
the single paging read the customer import uses. A read-only surface cannot call a write because it
was never handed one.
What the transport itself prevents
Four failure modes are designed out below the level of any feature, and each has a test:- Token leakage on a redirect.
CheckRedirectreturnshttp.ErrUseLastResponse, so a302is never followed with theAuthorizationheader attached. A misconfigured proxy in front of Remnawave cannot walk away with the credential. - Memory exhaustion. Every response body is read through a 1 MiB limit reader, so a broken or hostile panel cannot exhaust worker memory.
- An expired token reading as “this customer has nothing”. A non-2xx becomes an API error
carrying the status code, and a
404becomes a distinct not-found error. Neither can be mistaken for an empty success — which is the difference between “your subscription is gone” and “the panel said no”. - A hung panel. A fixed 10-second client timeout on every call.
Writes always go through a fulfillment operation
Nothing in Omniflow calls Remnawave to change state directly — not a paid order, not a panel click, not a bulk action. Every change is first written to PostgreSQL as afulfillment_operations row
carrying an idempotency key and a desired state, and then applied by a durable job.
The reason is stated in the code on the operator route: a panel click that wrote to Remnawave itself
would be the one change reconciliation could not explain. It would have no idempotency key, no retry
policy, no drift record, and no history. Committing the operation in the same transaction as the
state change that requires it also means a crash between “paid” and “provisioned” leaves work to do
rather than money to reconcile.
The supported operations are create, reconcile, extend, set_limits, set_squads, enable,
disable, and reset_traffic.
An operator queues one from
/admin/customers/{customerId} → the subscription card → the actions
panel. A reason is mandatory; without it the buttons stay disabled. The panel reports queued,
not done, because the worker is what applies it. The wire call is
POST /v1/panel/customers/{customerID}/subscriptions/{subscriptionID}/operations with
subscriptions.write, a CSRF token, and X-Operator-Reason; it answers 202 with
{operationId, operation, status}.
Refusals worth knowing:
- A subscription slot with no entitlement answers
409 not_provisioned— there is nothing to extend, disable, or reset. createandreconcileare deliberately absent from the operator allowlist and answer422 unsupported_operation. Offering them would give an operator a way to mint an entitlement with no order behind it.- The idempotency key is derived server-side as
panel:<subscriptionID>:<operation>:<requestID>, so a double-submitted form reuses the first operation rather than queueing a second.
Deterministic usernames and remote-user recovery
Each subscription provisions a Remnawave user namedomniflow_ plus the first 16 hex characters of
the subscription UUID. Installations upgraded from v0.4 keep a customer-derived legacy name for the
first slot: omniflow_ plus the first 16 hex characters of the customer UUID.
Two failures this prevents. A retried create after a lost response must not mint a second
Remnawave user, so the worker looks the username up before creating one. And an upgraded
installation must adopt the Remnawave user it already has rather than duplicating it, so the first
slot accepts both names as candidates. Deriving the name from the subscription rather than the
customer is exactly what lets one customer own several independent Remnawave users.
A PATCH that returns 404 — the panel user was deleted out from under Omniflow — falls back to
the create-or-recover path and recreates it. A non-create operation with no known remote ID fails
with remote_user_not_found and retries: you cannot enable something that was never provisioned.
Drift
Areconcile operation reads the Remnawave user and compares it against what Omniflow intends. Each
mismatch is written as an entitlement_drifts row with expected and observed JSON.
The point is that “somebody edited Remnawave by hand” becomes a reviewable record instead of a
support ticket. Drift is always evaluated against the Remnawave user that subscription owns, so a
customer’s second subscription can never be judged against the first one’s remote state.
Read it at
/admin → System → Drift (GET /v1/panel/system/drift, system.read), or against the
token surface at GET /v1/admin/fulfillment/drifts?limit=<1..500> (default 100).
An empty drift list is the normal state, not proof that nothing ever drifted. In a successful
reconcile pass the drift rows are opened and closed inside the same transaction as the corrective
PATCH, so a row stays visible only while corrective work is failing or Remnawave is unreachable.
That is the intended signal; it is not a general “what changed in Remnawave” log.external_edit appears in the database constraint and in the OpenAPI enum but no code path produces
it. What an externally modified user actually records is the specific field drift above. Only one
open drift exists per entitlement and kind — a repeat refreshes the row rather than piling up — and
resolved rows are deleted after APP_RETENTION_DRIFT_DAYS (default 90). Open rows are never swept.
Reconciliation runs in the worker process every 15 minutes for up to 500 entitlements whose
reconciled_at is null or older than 15 minutes. Both numbers are hard-coded. An installation
running no worker gets no reconciliation and no provisioning at all.
Health and readiness
The API registersremnawave in the same health registry that answers GET /readyz, feeds the
omniflow_dependency_up{dependency="remnawave"} gauge, and drives automatic maintenance mode. One
registry means a load balancer, an operator’s dashboard, and the maintenance controller cannot
disagree about whether the panel is up.
The probe is ListUsers(0, 1) — an authenticated read — rather than a request to an unauthenticated
page, so a revoked token reads as an outage instead of as health.
timeout, unavailable) and never a driver message, so
the response cannot leak a URL or a credential. Probe results are cached for 2 seconds and each
probe is bounded at 3 seconds, so a burst of readiness checks cannot become a burst of Remnawave
calls. Operators see the same probe at GET /v1/panel/overview/health with system.read.
Automatic maintenance watches postgres and remnawave. After APP_MAINTENANCE_FAILURE_STREAK
consecutive unhealthy samples (default 3, sampled every APP_MAINTENANCE_PROBE_SECONDS, default 30)
purchases pause and outbound provisioning is held rather than failed; the worker snoozes the job and
the operation stays pending. See maintenance mode.
omniflow_remnawave_requests_total is declared in the metrics registry but no code path ever
increments it, and a Prometheus counter emits no series until first use. The only Remnawave signal
actually on /metrics is omniflow_dependency_up{dependency="remnawave"}. Adapter calls are
traced as child spans through otelhttp, but no tracer provider is installed anywhere in
Omniflow and no OTEL_* variable is read, so those spans go nowhere unless you add an exporter
yourself. Observability covers what exists.Importing the customers you already have
An operator adopting Omniflow already has users in Remnawave. The import walksGET /api/users/?start=&size=, stages every candidate with a fingerprint, classifies it as valid,
conflicting, or invalid, and only then applies. Conflicts are computed against existing Remnawave
mappings, existing Telegram identity subjects, and Telegram IDs already staged by other pages of the
same run — that last check is what stops two remote users sharing one Telegram ID from both being
imported.
A Remnawave outage during a preview does not abort the run: it is marked failed with
remnawave_unavailable in the error summary and can be resumed from its cursor. The full procedure
is in customers and imports.
What Omniflow will never do to your panel
- It never touches Remnawave’s database. Every interaction is one of the fourteen HTTP calls above.
- It never manages Xray, nodes, hosts, or squad definitions. Squads are referenced by UUID; they are created and configured in Remnawave.
- It never stores an access link. There is no subscription-URL column anywhere in the schema;
GET /api/subscriptions/by-id/{id}is called live per request and the response carriesCache-Control: no-storeandReferrer-Policy: no-referrer. A link is a bearer credential and Omniflow treats it as one. - It never exposes a hardware identifier. Device lists are addressed by a digest — a truncated SHA-256 in the operator panel, an opaque handle in the customer panel — and removal resolves the digest back server-side. A value that never reaches a browser cannot leak from one.
- It never creates a second Remnawave user for a subscription that already has one; it recovers by username first.
- It never silently recreates state it did not expect to be missing: a deleted remote user is
recorded as a
missing_remotedrift before the next pass recreates it. - It never proxies Remnawave. No Omniflow endpoint forwards a request to the panel; every Remnawave call originates from a named feature.
When Remnawave is unreachable
The two different codes for one condition are a real inconsistency in the API: a client library or a
runbook that treats “Remnawave is down” as a single case has to know both.
The operator notice carries identifiers and a classified error code only — never a Remnawave
response body. Same for fulfillment history, which records an end date, whether a traffic and device
limit were configured, and a squad count, but never squad identifiers or links.