Proxy Control product naming does not rename deployed runtime contracts.
The following remain supported unchanged: /opt/mtproxy-shared443, .mtproxy-owned, marker mtproxy-shared443, /etc/nginx/mtproxy-stream, .mtproxy-backup, Compose project mtproxy, volume mtproxy_panel-data, /var/lib/mtproxy-panel, /opt/mtproxy-panel, /etc/mtproxy-agent, /var/lib/mtproxy-agent, unit filenames mtproxy-agent.service and mtproxy-fleet-ingress.service, fleet URI urn:mtproxy-panel:node:, and installed scripts/mtproxy-deploy.
Protocol names (mtproxy, protocols.mtproxy, MTPROXY_*) remain protocol-specific and are not deprecated. Historical changelog entries are not rewritten.
A future migration must provide discovery, read-only planning, backups, ownership checks, explicit cutover, rollback, and tests from an actual prior installation. Never silently move databases, volumes, certificates, units, routes, markers, or fleet identities.
Two panels of different releases talk to each other only through the identifiers below; they are frozen the same way the runtime paths above are, and a change to any of them is a new api_version / schema_version, never an edit in place.
panel_guid— one uuid4 per panel database, minted inpanel_settingson the first start and never regenerated. It is the node'snode_idon every central that links it, theexpected_guidof every push, thenode_guidof every generation document and the target of the audit actionfleet.unlink. A restored database keeps its GUID; a new database is a new panel.fleet_master_guidin the same table names the one central allowed to push (one master per node); onlyunlinkclears it./api/fleet/v2/*—GET identity,GET status,GET inventory,PUT generation,GET observed,POST credentials/capture,POST versions/update,POST unlink;identity.api_version = 2; every route accepts only anode-syncoradminAPI key. Conflict answers are 409 with{detail, code}and the codesguid_mismatch,foreign_master,stale_generation,digest_conflict,secret_store_disabledare part of the contract (digest_invalidis reserved). A push answers200 {observed, credentials}or202 {observed}; the body limit is 64 KiB. The legacy/api/fleet/nodes*and/agent/v1/*paths of Fleet v1 stay frozen alongside.- API key format
pc_<prefix>_<secret>— the literal prefixpc_, an 8-character hexadecimal lookup prefix, an underscore, then a URL-safe secret; the database stores the prefix and the SHA-256 hex digest of the whole key. Scopes are exactlyadmin,monitor,node-sync, and their mapping to roles (owner,viewer, fleet-only) does not change. A key is presented asAuthorization: Bearer <key>. - Wire fields of
GenerationDocument(schema_version: 1) —node_guid,master_guid,generation(≥ 1),previous_generation(≥ 0),created_at,created_by,resources[]; eachResourcehasref(grant:<id>),protocol(mtproxy | naive | mieru),runtime_username,desired_state(enabled | disabled | deleted),credential_ref(<secret_id>:<version>),credential_origin(caller | manager),origin(provisioned | imported, defaultprovisioned),options,valid_from,valid_until. Unknown fields are refused (extra = forbid), so a field can only be added together with a newschema_version. The digest is the SHA-256 of the canonical JSON (sorted keys, resources sorted byref, compact separators, ASCII). ObservedGeneration—applied_generation,digest,reconcile_state(idle | applying | converged | failed),resources[]of{ref, protocol, runtime_username, state: enabled | disabled | missing | failed | drifted, error, revision, learned},reported_at.learned({}when absent) is a bounded map of link facts the runtime taught the node —host/portfor Telemt,share_templatefor mita — never a credential. Observed reports (ObservedGeneration,ObservedResource, the push response) may gain fields; a central ignores unknown ones, whereas what the node validates (GenerationDocument,Resource,PushRequest) still refuses them with 422.- Secret purposes in
secret_versionsthat the two sides rely on:node-api-key(the node's key, on the central),fleet-managed(pushed credentials, on the node),grant.credential(the central's own escrow).
The identifiers above do not change; schema_version stays 1 and api_version stays 2 because everything routing adds is optional on the wire and ignored by a reader that does not know it:
- Capability
egress.v1inidentity.capabilities, next togeneration.v1,credentials.capture,versions.update,unlink; with itidentity.protocols[naive|mieru].egress = {backend: naive_native | mieru_native, capabilities[], providers: {warp: {reachable}}, revision, mode: direct | proxy | custom, restart_required, applied_digest, warnings[]}(nullfor MTProxy, a disabled service or a manager that does not answer). The provider's endpoint is never part of it. GenerationDocument.egress— optional,{naive | mieru: EgressDocument{backend, policy_id, policy_revision, document, digest}}withdigest= SHA-256 of the canonical JSON ofdocument(sorted keys, compact, ASCII) anddocument≤ 16 KiB. The central sends it only to a node withegress.v1; left out of the wire form and of the digest when absent, so a document without egress has the same digest for a node with or withoutegress.v1. Present, it is part ofcanonical_digestand ofcontent_digest.- Compiled documents (what the managers validate):
naive_native—{"schema": 1, "upstream": {"provider": "warp"} | null, "acl": [{"deny": [subject…]}, …]};mieru_native—{"schema": 1, "proxies": [{"name": "warp", "provider": "warp"}], "rules": [{"domains": [], "cidrs": [], "action": "DIRECT" | "PROXY" | "REJECT", "proxy": "warp" | null}, …]}.schema: 1and the provider namewarpare frozen; a new provider or field isschema: 2. ObservedGeneration.egress—{protocol: {state: converged | failed | unsupported, revision, digest, error}},{}when the node has nothing to report; an older central ignores it (observed reports may gain fields).- Manager egress API on the existing UDS with the same token:
GET /v1/egress,POST /v1/egress/plan | apply | rollbackwithexpected_revision,document,operation_id; refusal codesegress_conflict,egress_invalid,egress_unreachable,egress_readback_mismatch,manual_intervention_required,egress_no_previous,operation_conflict. The naive-manager's markers# BEGIN NAIVE-MANAGER EGRESS/# END NAIVE-MANAGER EGRESSand the state keyegressinusers.json; the mieru-manager's journal operationsegress.apply/egress.rollback. - Environment
NAIVE_EGRESS_WARP/MIERU_EGRESS_WARP(socks5://host:port, empty = no provider) and the installer section[egress](warp,warp_port,naive,mieru) with[three_xui].warp/warp_portstill read as a fallback. - Routing API and audit
/api/routing/*with the codespolicy_not_found,policy_conflict,policy_applied,unsupported,protocol_out_of_scope,node_not_found,backend_mismatch,managed_by_central,node_not_local,manager_unavailableand the compiler's reasons (backend_capability_missing,rule_kind_unsupported,private_destination,provider_unavailable,provider_unreachable,protocol_disabled_on_node,node_lacks_egress_v1,document_too_large); audit actionsrouting.policy.update | apply | rollback | delete; migration 14 (routing_policies,routing_rules,routing_applies,managed_egress) is additive.
schema_version and api_version do not change: everything the router adds is optional on the wire, omitted when absent, and ignored by a reader without egress.router.v1 (panel/tests/test_routing_fleet_router.py and test_routing_fleet.py prove both directions).
- Capability
egress.router.v1inidentity.capabilities; with itidentity.router = {available: true, xray_version, capabilities[], providers: {warp: {reachable}}, services: {naive | mieru: {revision, applied_digest}}}or{available: false, reason}, andidentity.protocols[naive|mieru].egress.router_attached(boolean). A central without the capability in its vocabulary ignores both. EgressDocument.backendgains the valuexray_router;EgressDocument.companion(optional: the document the other manager applies right after — the native attach document beside a router pass-through, or the router pass-through beside a native direct) andEgressDocument.passthrough(optional boolean) are left out of the wire form and of the digest when absent/false, so a generation without a router section keeps its digest and a node withoutegress.router.v1never sees them: the central sends a router section only to a node withegress.router.v1(node_lacks_routerotherwise).- The router intent (what the router validates):
{"schema": 1, "default": {"action": "direct" | "egress", "egress": "warp" | null}, "rules": [{"domains": [], "geosites": [], "cidrs": [], "geoips": [], "ports": [], "action": "direct" | "block" | "egress", "egress": "warp" | null}, …]}, ≤ 128 rules, ≤ 64 selectors of each kind, ≤ 32 ports, ≤ 16 KiB; the pass-through is{"schema": 1, "default": {"action": "direct", "egress": null}, "rules": []}. Attach documents:naive_native{"schema": 1, "upstream": {"provider": "router"}, "acl": []},mieru_native{"schema": 1, "proxies": [{"name": "router", "provider": "router"}], "rules": [{"domains": ["*"], "cidrs": ["*"], "action": "PROXY", "proxy": "router"}]}— the provider namerouterjoinswarpunderschema: 1. ObservedEgress.router—{revision, digest}of the router section the node applied beside the native one; an older central ignores it.RoutingPolicy.backendgainsxray_router;Compiled.compiler_versionis"2";RuleMatchgainsgeosites[]/geoips[](codes^[a-z0-9][a-z0-9@!_-]{0,63}$,privaterefused); migration 15 rebuildsrouting_policies/routing_rules/routing_applieswith the new backend and addsmanaged_egress.router_revision/router_digest(additive: every existing row survives).- Router manager API on its own UDS (
/run/xray-router/manager.sock, Docker secretxray-router-manager-token, headerX-Xray-Router-Token):GET /v1/status,GET /v1/health,GET /v1/egress/{naive|mieru},POST /v1/egress/{svc}/plan | apply | rollback; codesegress_invalid,geosite_unknown,geoip_unknown(422),egress_conflict,operation_conflict,egress_no_previous,egress_unreachable(409),egress_readback_mismatch(502),artifact_mismatch,manual_intervention_required(503). Capabilities:whole_direct,whole_warp,block_domain | block_cidr | block_port | block_geosite | block_geoip,selective_domain | selective_cidr | selective_port | selective_geosite | selective_geoip. - Host identifiers: container
proxy-control-xray-router(Compose servicexray-router, overlaycompose.xray-router.yaml, image contextxray_router_manager/), identityxray-router10006:10006, binaries/usr/local/lib/proxy-control/xray-router/{xray,geoip.dat,geosite.dat}, state/var/lib/xray-router(0700), ingresses127.0.0.1:45101(naive) and127.0.0.1:45102(mieru), secretssecrets/xray-router-manager-token,secrets/xray-router-ingress-naive,secrets/xray-router-ingress-mieru(root:10006 0440,user:password), the managers' copies/var/lib/naive-manager/xray-router-ingressand/var/lib/mieru-manager/xray-router-ingress(0400), volumexray-router-run, marker/etc/proxy-control/xray-router-owned, helpers/usr/local/libexec/prepare-xray-router-stateandrotate-xray-router-ingress, env.env.xray-router(XRAY_ROUTER_BIN_DIR,XRAY_ROUTER_STATE_DIR,XRAY_ROUTER_XRAY_SHA256,XRAY_ROUTER_GEOIP_SHA256,XRAY_ROUTER_GEOSITE_SHA256,XRAY_ROUTER_EGRESS_WARP) and the managers'NAIVE_EGRESS_ROUTER/NAIVE_EGRESS_ROUTER_CREDENTIAL_FILE,MIERU_EGRESS_ROUTER/MIERU_EGRESS_ROUTER_CREDENTIAL_FILE; the installer's[egress] routerand the choicerouterfornaive/mieru; the pinned artifactxray26.3.27 (Xray-linux-64.zip) inrelease/external-artifacts.jsonwith per-member digests. - Routing API and audit:
POST /api/routing/targets/{node}/{protocol}/attach | detach;targets[].router = {available, attached, xray_version, restart_required, reason}andtargets[].backend = xray_routerfor an attached service;PolicyInput.backend; the codesrouter_unavailable,router_unreachable,not_attached,node_lacks_router,artifact_mismatch(503),geosite_unknown,geoip_unknownand the warningrouter_credential_stale; audit actionsrouting.target.attach | detach.
schema_version and api_version are unchanged. New, all optional and absent from the wire and the digest
when unused: Resource.lane ("own"), GenerationDocument.relay ({enabled, port, server_name, accounts[{email: relay:<guid>:<direct|warp>, credential_ref}]} — the UUIDs travel in the push's secrets),
ObservedGeneration.relay ({state, enabled, port, server_name, public_key, short_id, accounts[], error}),
ObservedEgress.lanes[]; capabilities egress.lanes.v1, relay.v1; identity.router.relay
({enabled, port, server_name, public_key, short_ids, accounts}) and identity.router.lanes. A central sends the
new fields only to a node that declared the capabilities (node_lacks_lanes, node_lacks_relay otherwise);
a central that does not know them ignores the new report fields. A policy's exit is a string warp | node:<guid>[,…][:warp], a
policy key carries lane (svc for the service's own policy); migration 16 is additive in effect. New routes:
POST /api/routing/lanes/{grant_id}, POST /api/routing/policies/{node}/{protocol}/explain,
POST /api/routing/relay/{node}/enable | rotate; every policy route takes ?lane=. Secret purpose
relay-account. Host identifiers: relay port 45443 ([egress] relay_port), slot units mita@<n> with sockets
/run/mita/lane-<n>.sock, state /var/lib/mita/lanes/<n>, ports 46100+n, env MIERU_LANE_SLOTS; the
router's lanes.json and relay.json in /var/lib/xray-router (0600); router intent schema 2.
schema_version and api_version are unchanged. New, optional: capability egress.mtproxy.v1; the key
mtproxy in GenerationDocument.egress (sent only to a node that declared the capability,
node_lacks_mtproxy_egress otherwise) and in identity.protocols[*].egress / identity.router.services
(the latter only when the node's router manager serves it). Backend value mtproxy_native; Telemt's native
documents {"schema": 1, "upstream": null | {"provider": "router"}}. Codes router_lacks_mtproxy,
ingress_unreachable, node_lacks_mtproxy_egress, telemt_api_unsupported, egress_reload_failed.
Migration 21 (routing_policies, managed_egress allow mtproxy/mtproxy_native) is additive in effect.
Router manager: service mtproxy, GET /v1/ingress/mtproxy, ingress-mtproxy.json in /var/lib/xray-router
(0600), env XRAY_ROUTER_INGRESS_MTPROXY_SOCKET (empty turns the ingress off). Host identifiers: Compose service
and container xray-router-ingress / proxy-control-xray-router-ingress (port 45103 on the Compose network,
never published), socket /run/xray-router/ingress-mtproxy.sock in the xray-router-run volume; the panel's
journal /data/telemt-egress.json.
schema_version, api_version, the Fleet v2 documents and every identifier above are unchanged. Two additive
relaxations: MieruOptions.quotas defaults to [] (a grant request may omit it — the Clients screen always
did), and a local attach/detach records the backend's pass-through digest on the policy (in the existing
columns). docs/VERIFICATION_MATRIX.md lists every route with the row that proves it; scripts/dev/route-coverage.py
fails the full gate on a route that is not gated, not mentioned by a test, or public without being listed.