Compare commits
292
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf3f22b12 | ||
|
|
4d066cb879 | ||
|
|
f4882c53c2 | ||
|
|
116686a138 | ||
|
|
7e15cc199f | ||
|
|
f977874da6 | ||
|
|
14b3c2afac | ||
|
|
a84cca0668 | ||
|
|
8f2f418569 | ||
|
|
4a566e082a | ||
|
|
f451c65b2f | ||
|
|
64462d3639 | ||
|
|
12f2f30212 | ||
|
|
1ee453b3b6 | ||
|
|
c27c898ad5 | ||
|
|
74227ae38c | ||
|
|
9055934e92 | ||
|
|
3b515ee355 | ||
|
|
cedd31cc16 | ||
|
|
d4f228284e | ||
|
|
b843970ec2 | ||
|
|
daec12e013 | ||
|
|
416b2b294a | ||
|
|
df865fbe3d | ||
|
|
7cb25d0633 | ||
|
|
dc1fd76c44 | ||
|
|
0a0a932057 | ||
|
|
286a89051a | ||
|
|
f3be0b2fd0 | ||
|
|
bc86741397 | ||
|
|
7c255192b0 | ||
|
|
0b39211fbd | ||
|
|
8eccdd4050 | ||
|
|
804e08727e | ||
|
|
978fe71628 | ||
|
|
5a21a09b82 | ||
|
|
a9eca0e9d4 | ||
|
|
32217f4d17 | ||
|
|
019930924d | ||
|
|
0ea2f9d548 | ||
|
|
0290784526 | ||
|
|
b86812d02b | ||
|
|
08cc013def | ||
|
|
72c085a5b8 | ||
|
|
9d43e74d97 | ||
|
|
9e52ccc24d | ||
|
|
068a7f9890 | ||
|
|
6381760b27 | ||
|
|
3566f4bc0b | ||
|
|
501c498edf | ||
|
|
7e4da4bdcf | ||
|
|
17849ffd73 | ||
|
|
021cdb28d0 | ||
|
|
ded7b740dc | ||
|
|
79ffff194f | ||
|
|
396c5d1917 | ||
|
|
444f26c401 | ||
|
|
9ad0307333 | ||
|
|
9a8191dc91 | ||
|
|
5cad3e9061 | ||
|
|
af1c45e424 | ||
|
|
9432112fe2 | ||
|
|
aa9c959368 | ||
|
|
c89e56942a | ||
|
|
f233660dc3 | ||
|
|
ec68ba6a7b | ||
|
|
3a4173db43 | ||
|
|
d349ca5e29 | ||
|
|
3204ecf4a5 | ||
|
|
d612e227fa | ||
|
|
03b2ed5fb0 | ||
|
|
5da9686c27 | ||
|
|
f40221969b | ||
|
|
90433d7cd8 | ||
|
|
71ede44be0 | ||
|
|
f04b1752f4 | ||
|
|
34d8b681ad | ||
|
|
32be4380a3 | ||
|
|
837f058025 | ||
|
|
6b17f1982b | ||
|
|
6e701d4fc6 | ||
|
|
ca53b671ee | ||
|
|
10888ac012 | ||
|
|
c26d4cb43b | ||
|
|
3cdb0c735e | ||
|
|
4c61a04dc7 | ||
|
|
39f3467f9b | ||
|
|
b084d7e42c | ||
|
|
9751f4b8c8 | ||
|
|
a84e54f9dd | ||
|
|
41b6613837 | ||
|
|
ca0322f93a | ||
|
|
2151ff51f0 | ||
|
|
e9433e754f | ||
|
|
b2a2ed7104 | ||
|
|
fbf22e0b88 | ||
|
|
4c9822539d | ||
|
|
d32bc14108 | ||
|
|
70cc221f34 | ||
|
|
0a4a6d9443 | ||
|
|
0c376c72aa | ||
|
|
12375006d3 | ||
|
|
e6666558b7 | ||
|
|
ffd899033e | ||
|
|
8926a8877d | ||
|
|
ef4847a3e1 | ||
|
|
cf90fd5b4e | ||
|
|
00a2e1606b | ||
|
|
d0c5336b7e | ||
|
|
0a37121dbe | ||
|
|
c430557dc0 | ||
|
|
dfa9f09695 | ||
|
|
608f8cfcf2 | ||
|
|
53e6cf2146 | ||
|
|
9f31eaf396 | ||
|
|
560c243047 | ||
|
|
307ad02cd7 | ||
|
|
e774486b99 | ||
|
|
3157e9e8f7 | ||
|
|
36c8438b7f | ||
|
|
76b75624b8 | ||
|
|
44367e0ef3 | ||
|
|
158aaadd23 | ||
|
|
fdc6f687f3 | ||
|
|
c6d3fd39fb | ||
|
|
b4adcce26a | ||
|
|
7b94f2dee4 | ||
|
|
7f276b0404 | ||
|
|
c2f9623394 | ||
|
|
bc3cc12f69 | ||
|
|
8139543e9a | ||
|
|
162ef861d7 | ||
|
|
e5a69dcb73 | ||
|
|
a37c211c42 | ||
|
|
f629309f32 | ||
|
|
56f5e408e3 | ||
|
|
8c19f2cba9 | ||
|
|
90447de0aa | ||
|
|
5874df2fce | ||
|
|
d551d41b71 | ||
|
|
9a539409f0 | ||
|
|
0480e617cd | ||
|
|
77eaed8d90 | ||
|
|
e8c1c9d403 | ||
|
|
12c1b128f8 | ||
|
|
6bd51dc8fb | ||
|
|
fca3c0b705 | ||
|
|
d9745e9aed | ||
|
|
57330f1c78 | ||
|
|
24fda3e34e | ||
|
|
5b3d288405 | ||
|
|
2a71466670 | ||
|
|
2d1d89911e | ||
|
|
394fceac15 | ||
|
|
c40f465708 | ||
|
|
ba1e53a824 | ||
|
|
1fe13703eb | ||
|
|
17577ea460 | ||
|
|
2a214fc28b | ||
|
|
5e33360c92 | ||
|
|
ef33ad9c84 | ||
|
|
267afc5c7e | ||
|
|
005c7a101b | ||
|
|
ba15a25c89 | ||
|
|
d4897e5dcf | ||
|
|
7182bc2c1a | ||
|
|
8db9d30828 | ||
|
|
c56f51e07b | ||
|
|
d6ba05ac7d | ||
|
|
4ed25301db | ||
|
|
d49a1f6837 | ||
|
|
56304514ff | ||
|
|
4519577295 | ||
|
|
87cd83f89a | ||
|
|
fbbd6e40b4 | ||
|
|
483fce55f3 | ||
|
|
62d2044dc1 | ||
|
|
f135ade43b | ||
|
|
65bf88bf41 | ||
|
|
387cc273e8 | ||
|
|
1304a22f1f | ||
|
|
a0c66edb02 | ||
|
|
306a9b8ced | ||
|
|
7a6f9a26ac | ||
|
|
c9223aa3a9 | ||
|
|
e6b21ed8a9 | ||
|
|
74660d915f | ||
|
|
9da4fef1f0 | ||
|
|
457dd912d1 | ||
|
|
198669694c | ||
|
|
a775d8456a | ||
|
|
40f73ee98c | ||
|
|
e81a48a5b1 | ||
|
|
b0b9da51b6 | ||
|
|
4b326c5e99 | ||
|
|
0bf7d2ee30 | ||
|
|
b53cd08dcc | ||
|
|
edad26d978 | ||
|
|
322f5a125b | ||
|
|
85053f9948 | ||
|
|
9efd446d4e | ||
|
|
bfc85c3056 | ||
|
|
89feffd0b7 | ||
|
|
aa54be9c9b | ||
|
|
befd41933d | ||
|
|
b389664824 | ||
|
|
d5a42d8b7b | ||
|
|
e6bcdc9c62 | ||
|
|
a58fb26e4f | ||
|
|
0ab912c64c | ||
|
|
51312d51cd | ||
|
|
0a1aa8aed3 | ||
|
|
84efbe7450 | ||
|
|
42c15df8c9 | ||
|
|
fa3b455fab | ||
|
|
6f565ded2e | ||
|
|
41922ad30b | ||
|
|
9d4f312595 | ||
|
|
e19d33adb9 | ||
|
|
99f7f58fcb | ||
|
|
6bc7840fb1 | ||
|
|
d3b7f0d613 | ||
|
|
efa46d1ee5 | ||
|
|
149bb999dc | ||
|
|
288acbf0c8 | ||
|
|
b45dd2ae05 | ||
|
|
c5bdb10445 | ||
|
|
7dbf786c56 | ||
|
|
59f2264a2e | ||
|
|
a0f41baa36 | ||
|
|
c3d3aaa699 | ||
|
|
301b76c03e | ||
|
|
ab6de6996f | ||
|
|
0092ec4cde | ||
|
|
12ad0c8b78 | ||
|
|
b5d4c61783 | ||
|
|
f4990a4f55 | ||
|
|
ab44626a0f | ||
|
|
95edefa84f | ||
|
|
f914c28bc5 | ||
|
|
73488384e4 | ||
|
|
c6352d781f | ||
|
|
d02dbe10de | ||
|
|
2ef1e09986 | ||
|
|
6df8c525ef | ||
|
|
f264ce4a2f | ||
|
|
371adbcb50 | ||
|
|
3a930c9d8c | ||
|
|
1bdf12f174 | ||
|
|
3e8925c609 | ||
|
|
d12b0c01fc | ||
|
|
e16f401dc5 | ||
|
|
68844d67df | ||
|
|
ec8e748a43 | ||
|
|
62f50d9c28 | ||
|
|
cab4313c70 | ||
|
|
aab7533438 | ||
|
|
62b39cdf58 | ||
|
|
6ab5f50f95 | ||
|
|
4bb8507e3f | ||
|
|
b3fad00f80 | ||
|
|
5c9a291920 | ||
|
|
781cbbb026 | ||
|
|
499d2d3367 | ||
|
|
eeec4359b0 | ||
|
|
11f2c0ccb2 | ||
|
|
f89cba4a24 | ||
|
|
49be90a82c | ||
|
|
bb7250e4ac | ||
|
|
4f1a2f8bf6 | ||
|
|
7d1f5f89ed | ||
|
|
b1c8eea976 | ||
|
|
27b71077b1 | ||
|
|
3e18b833c6 | ||
|
|
0cd898d1c1 | ||
|
|
8476ab16e5 | ||
|
|
a8f2c6f3f9 | ||
|
|
a961b1b415 | ||
|
|
7489b5ef97 | ||
|
|
b716b370ac | ||
|
|
abd5a73b51 | ||
|
|
1ed79c3a1e | ||
|
|
8789496ae6 | ||
|
|
7d41dd86e7 | ||
|
|
81bed1513c | ||
|
|
d13eb0a9a4 | ||
|
|
71f8e0b84c | ||
|
|
03885d2e09 | ||
|
|
88eef527d5 | ||
|
|
c971b40eae | ||
|
|
327561b2e9 | ||
|
|
185a311a38 |
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: design-harbor-device-ecosystem
|
||||||
|
description: Design, plan, implement, review, or document Harbor Gateway integrations with current and future companion devices. Use for LAN advertisement and discovery, Connect-Gateway pairing, trusted device identity, connected-client presence, ecosystem membership in the Gateway device list, multi-Gateway selection, capability or protocol negotiation, and related API, persistence, migration, or UI contracts. Do not use for traffic-only LAN inventory or visual polish that does not change ecosystem behavior.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design Harbor Device Ecosystem
|
||||||
|
|
||||||
|
Keep discovery, trust, application presence, routing, and LAN observation separate. A device is not trusted because mDNS or the neighbor table reports it, and a paired device is not necessarily connected or routed through this Gateway.
|
||||||
|
|
||||||
|
## Scope and non-goals
|
||||||
|
|
||||||
|
- Apply this skill to Gateway integration with Harbor Connect on macOS and future clients, extensions, appliances, or companion services.
|
||||||
|
- Keep one platform-neutral protocol core. Isolate native DNS-SD and private-key storage behind thin platform adapters, and state whether a new target is desktop, headless, mobile, or browser-bound before choosing an adapter.
|
||||||
|
- Preserve the existing MAC-based inventory as an observational traffic and policy surface; do not turn it into ecosystem identity.
|
||||||
|
- Do not introduce a generic event bus, mesh, cloud account, fleet controller, or WAN discovery without a concrete requirement.
|
||||||
|
- Do not define a new visible pairing flow, badge, layout, wording, or management surface without the exact owner decision required by `workpack/DESIGN_OWNER_POLICY.md`.
|
||||||
|
- Do not deploy, SSH, pair live devices, or operate a live Gateway unless the current user request explicitly authorizes that exact operation.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read `PRODUCT.md`, `workpack/PRODUCT_PRIORITIES.md`, `workpack/DATA_CONSISTENCY_MODEL.md`, `workpack/DESIGN_OWNER_POLICY.md`, `workpack/STATUS.md`, and the selected workpack task.
|
||||||
|
2. Read [device-ecosystem-contract.md](references/device-ecosystem-contract.md) before changing discovery, pairing, identity, presence, routing handoff, or the Gateway device projection.
|
||||||
|
3. Trace the current producer -> transport -> persistence -> canonical snapshot -> UI path. Confirm the owning service and every caller before editing.
|
||||||
|
4. Classify each proposed field and state as one of: discovery candidate, persisted trust relationship, authenticated presence lease, route state, network observation, or derived binding. Reject fields that mix categories.
|
||||||
|
5. Reuse standard DNS-SD/mDNS for local discovery and the existing request/response control-plane shape for the first protocol slice. Do not treat the current plaintext HTTP transport as sufficient protection for invitations or credentials; require pinned end-to-end identity and confidentiality. Add push transport only when a concrete feature cannot work with bounded polling or heartbeats.
|
||||||
|
6. Keep persisted relationship mutations schema-versioned, atomic, revision-safe, and reversible. Keep transient session loss from deleting paired identity.
|
||||||
|
7. Join ecosystem membership to network inventory in the backend and expose one revisioned projection. Never join independent authoritative lists in React. Also use `design-vpn-client-ui` for any visible Connect UI work.
|
||||||
|
8. For runtime, API, dependency, UI, or deployment changes, also use `manage-harbor-versions` and run its affected-component gate. Skill-only and documentation-only changes need no Harbor version bump.
|
||||||
|
|
||||||
|
## Non-negotiable decisions
|
||||||
|
|
||||||
|
- Advertise Gateway availability through a versioned DNS-SD service on the local link. Treat every advertisement and resolved address as an untrusted candidate until paired identity is cryptographically verified.
|
||||||
|
- Put no subscription URL, pairing secret, device list, credential, or private identity material in mDNS/DNS-SD records, logs, fixtures, screenshots, or user-visible diagnostics.
|
||||||
|
- Pair explicitly. Use a short-lived, single-use invitation; bind the stable Gateway identity and client installation identity; persist each side atomically only after confirmation; make cross-device finalization idempotent and recoverable; support revoke and credential rotation.
|
||||||
|
- Use a high-entropy QR/manual invitation, or a vetted PAKE plus mutual confirmation for a short human code. Never send a low-entropy code as a bearer secret over unauthenticated HTTP.
|
||||||
|
- Give every Harbor installation a stable cryptographic device identity. Keep it independent of IP, hostname, interface, and randomized MAC.
|
||||||
|
- Define states precisely: `discovered` means advertised; `paired` means a persisted trust relationship; `connected` means a current authenticated lease; `routed` means this Gateway is the active route. Never collapse them into one `online` boolean.
|
||||||
|
- Derive lease freshness from Gateway receipt time. Do not trust a client-supplied clock, IP address, or MAC address as authoritative.
|
||||||
|
- Correlate a paired client with the existing neighbor inventory only as a derived, confidence-bearing binding. A missing or ambiguous binding must not erase membership or fabricate traffic attribution.
|
||||||
|
- Allow a client to pair with multiple Gateways, but keep one explicit active routing target. A discovered or unpaired Gateway must never hijack route selection.
|
||||||
|
- Preserve legacy subscription-based presence only as a bounded transition path. Never silently convert matching subscription URLs into permanent pairing records.
|
||||||
|
- Keep capability advertisement versioned and additive. Ignore unknown capabilities; block incompatible protocol versions explicitly. Never authorize a mutation from self-declared capabilities alone.
|
||||||
|
- Keep the Gateway backend as the source of truth for its paired-device registry and active leases. Preserve revision-safe frontend application and transport freshness separation.
|
||||||
|
|
||||||
|
## Acceptance pass
|
||||||
|
|
||||||
|
Before handing off an ecosystem change, verify:
|
||||||
|
|
||||||
|
- A supported client can discover more than one Gateway and survives address changes without changing Gateway identity.
|
||||||
|
- Spoofed discovery cannot create trust, change the active route, or appear as a paired ecosystem member.
|
||||||
|
- Pairing works without a subscription URL; invitations expire, are single-use, and reject replay.
|
||||||
|
- The Gateway can distinguish an ordinary LAN neighbor, a paired but disconnected device, a connected Harbor client, and a client currently routed through it.
|
||||||
|
- A paired Mac remains the same ecosystem device across DHCP, interface, or randomized-MAC changes; only its derived inventory binding changes.
|
||||||
|
- Discovery loss does not unpair a device; heartbeat loss expires the lease; revocation rejects the next authenticated request immediately.
|
||||||
|
- Multiple Gateways and multiple clients do not collide. Any aggregate network identity includes Gateway scope.
|
||||||
|
- Persisted writes are atomic and migrated with rollback; delayed snapshots cannot overwrite newer state.
|
||||||
|
- Legacy installs retain the documented transition behavior without exposing or copying subscription secrets.
|
||||||
|
- Automated tests cover discovery deduplication, pairing expiry/replay, authentication, lease expiry, revocation, binding ambiguity, revision ordering, and migration.
|
||||||
|
- Any visible UI outcome has a separately recorded owner decision and preserves loading, empty, stale, error, keyboard, reduced-motion, and responsive behavior.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
Report:
|
||||||
|
|
||||||
|
- current owner and traced data flow;
|
||||||
|
- chosen source of truth for identity, trust, presence, route, and inventory binding;
|
||||||
|
- protocol and persistence changes, including version negotiation;
|
||||||
|
- migration, compatibility, rollback, and security boundaries;
|
||||||
|
- exact automated checks and what remains unverified;
|
||||||
|
- unresolved owner decisions, especially visible UI and short-code pairing treatment.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Design Harbor Device Ecosystem"
|
||||||
|
short_description: "Design trusted Harbor device integration."
|
||||||
|
default_prompt: "Use $design-harbor-device-ecosystem to design Harbor Gateway discovery, pairing, and connected-device contracts."
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Harbor device ecosystem contract
|
||||||
|
|
||||||
|
Use this reference for Gateway advertisement, discovery, pairing, connected-device lists, or integration with future Harbor-capable subsystems.
|
||||||
|
|
||||||
|
## Layer ownership
|
||||||
|
|
||||||
|
| Layer | Source of truth | Identity | Lifetime | Meaning |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Discovery | DNS-SD browser cache | advertised Gateway instance | ephemeral | a compatible service may be reachable |
|
||||||
|
| Gateway trust | client pairing store | `gatewayId` plus pinned public identity | persisted | this is a Gateway the owner paired |
|
||||||
|
| Peer registry | Gateway pairing store | stable client installation ID plus public identity | persisted | this client belongs to the Harbor ecosystem |
|
||||||
|
| Presence | Gateway session owner | authenticated client ID plus lease | ephemeral | this paired client is currently connected to the control plane |
|
||||||
|
| Route | Connect route owner | selected `gatewayId` plus applied route state | runtime/canonical | this client currently intends to use this Gateway |
|
||||||
|
| Network inventory | existing device inventory | Gateway-scoped MAC-derived `dev_*` ID | observed/persisted | this network endpoint was seen and may own traffic or policy |
|
||||||
|
| Binding | Gateway backend projection | ecosystem client ID -> network inventory ID | derived | current correlation, never trust identity |
|
||||||
|
|
||||||
|
Do not reuse the current MAC-derived `dev_*` ID as an ecosystem ID. For a future fleet view, scope network inventory IDs by `gatewayId`.
|
||||||
|
|
||||||
|
## Minimal discovery profile
|
||||||
|
|
||||||
|
Advertise one TCP DNS-SD service such as `_harbor-gw._tcp.local.`. Use the service instance name as a short, user-friendly Gateway name; do not encode a MAC address or serial number in it.
|
||||||
|
|
||||||
|
Use SRV/A/AAAA for endpoint resolution. Keep TXT metadata small and additive:
|
||||||
|
|
||||||
|
- `txtvers=1` for the TXT schema;
|
||||||
|
- `protovers=1` for the application protocol;
|
||||||
|
- public `id=<gatewayId>` for candidate deduplication;
|
||||||
|
- compact capability flags and whether a pairing window is open.
|
||||||
|
|
||||||
|
Never advertise credentials, pairing invitations, subscription data, client names, client counts, or private network policy. Unknown TXT keys must be ignored. Resolve addresses immediately before connection and verify the paired identity after connecting.
|
||||||
|
|
||||||
|
mDNS is link-local. For another VLAN or routed segment, use an explicitly configured address, QR/manual endpoint, or unicast DNS-SD. Do not invent a custom UDP broadcast or assume multicast crosses routers.
|
||||||
|
|
||||||
|
In the current Gateway deployment, first inspect the Compose network boundary: the control service is bridge-networked while the dataplane owns host networking. Keep identity and trust in control, but publish the public DNS-SD descriptor through host Avahi or a narrow host-network publisher. Do not move the whole control plane to host networking merely to gain multicast. On macOS, prefer the native Bonjour/`dns-sd` surface through the existing host-side network monitor over a second custom discovery stack.
|
||||||
|
|
||||||
|
Discovery answers only “where might a Gateway be?” It does not answer “do I trust it?”, “am I connected?”, or “is my traffic routed through it?”.
|
||||||
|
|
||||||
|
## Identity and pairing
|
||||||
|
|
||||||
|
Maintain a stable Gateway identity and a stable key pair in persisted, backup-aware storage. Maintain a stable installation identity and key pair per client; store the client private key in the platform credential store, including macOS Keychain.
|
||||||
|
|
||||||
|
Do not reuse the current subscription `hwid` as public ecosystem identity: it is also sent to the subscription provider as `x-hwid`. Create a separate local identity so LAN and provider correlation remain independent.
|
||||||
|
|
||||||
|
Model the minimum persisted relationship records:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PairedGateway = gatewayId, displayName, pinnedPublicIdentity,
|
||||||
|
capabilities, pairedAt
|
||||||
|
|
||||||
|
PairedDevice = deviceId, displayName, deviceType, publicIdentity,
|
||||||
|
capabilities, pairedAt, revokedAt?
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat display names, platform, version, and capabilities as metadata, not authentication or authorization.
|
||||||
|
|
||||||
|
Use this pairing sequence:
|
||||||
|
|
||||||
|
1. Let the Gateway open a bounded pairing window and create a short-lived, single-use invitation.
|
||||||
|
2. Bind the invitation to the Gateway ID and public-identity fingerprint.
|
||||||
|
3. Let the client create or load its installation key and submit its public identity and minimal metadata.
|
||||||
|
4. Confirm the peer on a trusted surface before committing both records.
|
||||||
|
5. Issue a per-device credential or register proof-of-possession for later authenticated requests.
|
||||||
|
6. Consume the invitation atomically; reject expiry, replay, mismatch, or reuse.
|
||||||
|
7. Support revoke and credential rotation without changing unrelated LAN inventory.
|
||||||
|
|
||||||
|
Persist each local trust record atomically and make finalization idempotent so an interrupted client can resume or safely retry. Pairing spans two devices and is not a distributed transaction; never report it as cross-device atomicity.
|
||||||
|
|
||||||
|
For the smallest safe first slice, encode the same grouped high-entropy invitation in QR and manual copy/paste form. If the owner requires a short numeric code, use a vetted password-authenticated key exchange such as SPAKE2 and mutual key confirmation, or require an independent confirmation that provides equivalent protection. Use platform or Node standard cryptography for identity and signatures; do not design custom cryptography or add a crypto dependency without need.
|
||||||
|
|
||||||
|
Do not use the subscription URL as a pairing credential. Keep the existing subscription-HMAC presence only as an explicitly labelled legacy trust source during the transition window.
|
||||||
|
|
||||||
|
## Authenticated presence and route state
|
||||||
|
|
||||||
|
After pairing, let the client renew a bounded authenticated lease. Start with periodic requests using the existing control-plane API shape, but protect invitations and credentials with pinned end-to-end identity and confidentiality; the current plaintext HTTP transport alone is not sufficient. Do not add WebSocket, broker, or event-stream infrastructure until a concrete server-push feature requires it.
|
||||||
|
|
||||||
|
The Gateway derives:
|
||||||
|
|
||||||
|
```text
|
||||||
|
paired = persisted relationship exists and is not revoked
|
||||||
|
connected = paired and authenticated lease has not expired
|
||||||
|
routed = connected and current Connect route state selects this gatewayId
|
||||||
|
inventory = optional current network binding exists
|
||||||
|
```
|
||||||
|
|
||||||
|
Store or project `lastAuthenticatedAt` and `leaseExpiresAt`; derive status rather than persisting a mutable `online` boolean. Use Gateway receipt time for freshness. Let the client report `deviceType`, app/protocol version, capabilities, and selected route as authenticated metadata, but distinguish a reported route from dataplane-confirmed traffic.
|
||||||
|
|
||||||
|
Discovery failure marks the candidate stale. It does not revoke pairing or immediately terminate a still-valid lease. Lease expiry marks the client disconnected without deleting its relationship. Revocation invalidates the credential immediately.
|
||||||
|
|
||||||
|
## Joining a Mac to the Gateway device view
|
||||||
|
|
||||||
|
When a paired Mac renews its lease:
|
||||||
|
|
||||||
|
1. Authenticate its stable ecosystem identity.
|
||||||
|
2. Derive the remote source address from the accepted connection; do not trust a claimed IP.
|
||||||
|
3. Correlate that address with the current neighbor snapshot to obtain an optional MAC-derived inventory ID.
|
||||||
|
4. Publish the relationship, presence, route, and binding together from the Gateway backend.
|
||||||
|
|
||||||
|
Represent the binding with `networkDeviceId`, `confidence`, and `observedAt`. A randomized MAC, DHCP change, or interface switch updates or removes only this binding. The stable Mac relationship remains.
|
||||||
|
|
||||||
|
If the source address is NATed, missing, duplicated, or maps to an ambiguous neighbor, keep the Mac paired/connected but leave traffic and policy attribution unavailable. Never attach another device's counters by hostname or client-provided MAC.
|
||||||
|
|
||||||
|
Expose one revisioned Gateway projection to the UI. It may extend the current device snapshot or use a dedicated ecosystem snapshot, but the backend must perform the join. React must not fetch authoritative pairing, presence, and inventory lists independently and guess the relationship.
|
||||||
|
|
||||||
|
## Multi-Gateway behavior
|
||||||
|
|
||||||
|
- Let Connect persist several `PairedGateway` records keyed by stable Gateway ID.
|
||||||
|
- Let discovery resolve zero or more current endpoints for each identity.
|
||||||
|
- Keep one active route target and make selection or automatic policy explicit.
|
||||||
|
- Require a paired identity match before automatic handoff. A new advertisement is never enough.
|
||||||
|
- Preserve a verified active Gateway through transient discovery failure, but surface stale freshness.
|
||||||
|
- Let one Gateway register many clients without assuming macOS; branch on negotiated capabilities, not hard-coded platform paths.
|
||||||
|
- Do not add fleet federation. If it becomes real, aggregate with compound `{gatewayId, deviceId}` identities.
|
||||||
|
|
||||||
|
## Canonical data and failure rules
|
||||||
|
|
||||||
|
- Give the pairing registry its own `schemaVersion` and monotonic `revision`, or include it in an existing canonical aggregate with equivalent guarantees.
|
||||||
|
- Reuse the existing atomic JSON-store pattern before considering a database or another persistence dependency.
|
||||||
|
- Make pair, rename, revoke, and credential rotation atomic. Return the complete new snapshot after a mutation.
|
||||||
|
- Keep active leases process-local unless restart continuity has a demonstrated need. Persist last-seen metadata at a bounded cadence if required; never write every heartbeat by default.
|
||||||
|
- Apply incoming frontend snapshots only when their revision is not older than the current one. Keep transport stale/error state outside the domain snapshot.
|
||||||
|
- Preserve last-good discovery and registry data on source errors; expose freshness and the error separately.
|
||||||
|
- Treat public-key mismatch as an identity error requiring explicit repair or re-pairing, not an address update.
|
||||||
|
- Treat an unsupported protocol version as an incompatible state, not a generic offline state.
|
||||||
|
|
||||||
|
## Compatibility sequence
|
||||||
|
|
||||||
|
1. Add the new identity, discovery, pairing, and presence contract without changing current route behavior.
|
||||||
|
2. Prefer evolving the existing Gateway presence protocol to a version that signs challenges with ecosystem identity instead of creating a second overlapping presence subsystem.
|
||||||
|
3. Keep the subscription-HMAC default-gateway proof as a labelled `legacy-default-route` source for one documented transition release.
|
||||||
|
4. Publish the stable `gatewayId` in canonical route state; keep address, UI origin, and freshness as changeable observations.
|
||||||
|
5. Prefer a paired Gateway when its verified identity matches the current route candidate. Preserve the approved sticky verified-Gateway behavior through transient discovery failure.
|
||||||
|
6. Never auto-mint a pairing record from a matching subscription URL.
|
||||||
|
7. When forgetting the active Gateway, demote route state atomically before deleting trust.
|
||||||
|
8. Preserve existing `dev_*` inventory IDs, aliases, traffic totals, and policies; add only a derived ecosystem binding.
|
||||||
|
9. Remove the legacy proof only after migration evidence and an explicit cutover task.
|
||||||
|
|
||||||
|
## Automated evidence
|
||||||
|
|
||||||
|
Cover at least:
|
||||||
|
|
||||||
|
- zero, one, and multiple advertisements; deduplication and endpoint changes;
|
||||||
|
- mDNS loss and recovery without unpairing;
|
||||||
|
- no secrets in TXT metadata or logs;
|
||||||
|
- invitation expiry, replay, mismatch, and atomic single-use consumption;
|
||||||
|
- valid/invalid proof-of-possession, credential rotation, and revocation;
|
||||||
|
- lease renewal and expiry using Gateway receipt time;
|
||||||
|
- paired/connected/routed state separation;
|
||||||
|
- DHCP, interface, and randomized-MAC changes;
|
||||||
|
- missing and ambiguous inventory binding without false traffic attribution;
|
||||||
|
- multiple Gateways with one active route;
|
||||||
|
- revision ordering, migration, rollback, and legacy transition.
|
||||||
|
|
||||||
|
## Standards basis
|
||||||
|
|
||||||
|
- [RFC 6762: Multicast DNS](https://www.rfc-editor.org/rfc/rfc6762)
|
||||||
|
- [RFC 6763: DNS-Based Service Discovery](https://www.rfc-editor.org/rfc/rfc6763)
|
||||||
|
- [RFC 9382: SPAKE2](https://www.rfc-editor.org/rfc/rfc9382)
|
||||||
|
|
||||||
|
These standards define discovery and an available PAKE building block. They do not make an mDNS advertisement trustworthy; preserve end-to-end identity verification.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: design-vpn-client-ui
|
||||||
|
description: Design, implement, review, or refine the client-facing VPN interfaces in this repository using the established calm monospace visual language and smooth state-driven motion. Use for the current macOS client and future end-user gateway client screens, especially power controls, subscriptions, traffic usage, proxy copy controls, server selection, responsive layout, hover feedback, transitions, and animation polish. Do not use for the administrative gateway UI unless the user explicitly asks to apply the client visual language there.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design VPN Client UI
|
||||||
|
|
||||||
|
Preserve the repo's focused one-screen VPN client language: a centered primary action, quiet technical typography, mode-specific accents, and motion that makes live state and interaction legible without moving layout.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read `PRODUCT.md` and the complete client component and styles before editing.
|
||||||
|
2. Inspect supplied evidence and trace the real DOM and state change that causes the visual issue. Follow repository testing policy; do not launch manual or interactive visual testing unless the user explicitly requests it in the current prompt.
|
||||||
|
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
|
||||||
|
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
|
||||||
|
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
|
||||||
|
6. Keep geometry stable across every state. Reserve space before animating content.
|
||||||
|
7. Implement `prefers-reduced-motion` alongside every new animation.
|
||||||
|
8. Run `npm test`, `npm run build`, and `git diff --check`. Perform manual visual inspection only when explicitly requested.
|
||||||
|
|
||||||
|
## Communicating a proposed change
|
||||||
|
|
||||||
|
For implementation proposals and progress summaries, combine structure instead of maximizing it:
|
||||||
|
|
||||||
|
- start with one compact table grouped by affected modules such as frontend, UI, backend/API, styles, tests, and versions;
|
||||||
|
- use short lists only for cross-cutting details such as states, accessibility, motion, or changed files;
|
||||||
|
- keep unaffected modules visible in the table when that clarifies scope;
|
||||||
|
- avoid both an unstructured paragraph and a separate table for every subsection.
|
||||||
|
|
||||||
|
## Non-negotiable decisions
|
||||||
|
|
||||||
|
- Keep the power action on the screen's central vertical axis. Place subscription content to its right without shifting that axis.
|
||||||
|
- Keep the power hit target generous while rendering only the icon, never a large enclosing accent circle.
|
||||||
|
- Drive every active accent from the current mode token: Connect is blue-green; Gateway is orange. Keep inactive power gray, including hover, and preserve semantic warning/error colors.
|
||||||
|
- Never let labels, timers, feedback, icons, progress, or server rows shift neighboring content.
|
||||||
|
- Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties.
|
||||||
|
- Make live behavior visibly alive: running processes, changing values, mode changes, and interactive affordances should communicate through restrained motion instead of abrupt static replacement.
|
||||||
|
- Give every actionable icon a semantic hover/focus response; rotate cyclic actions, move the physical part of object-like controls, and keep their hit targets fixed.
|
||||||
|
- Let every visible cycle finish and return to its resting coordinates before stopping. Never cancel a hover animation, spinner, or list exit at an arbitrary frame.
|
||||||
|
- Animate dynamic rows through complete enter and exit phases; keep a departing row mounted until its exit finishes, with immediate removal under reduced motion.
|
||||||
|
- Animate only what changed. Keep unchanged digits, labels, icons, and surrounding geometry stable.
|
||||||
|
- Keep tooltips outside transformed, rotating, glowing, or filtered controls. Show them quickly above the control as independent translucent cloud surfaces.
|
||||||
|
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
|
||||||
|
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
|
||||||
|
- Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen.
|
||||||
|
- When the owner explicitly chooses modal treatment for critical confirmations, reuse one accessible full-screen confirmation popup: blur and block the background, reveal from center, then stage text and actions.
|
||||||
|
- Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state.
|
||||||
|
- In client-side editors, prefer flat text controls and accessible custom pickers over browser-native menus when the native surface breaks the visual language. Do not append another blank row until the current row is complete.
|
||||||
|
|
||||||
|
## Acceptance pass
|
||||||
|
|
||||||
|
Before handing off, verify:
|
||||||
|
|
||||||
|
- Power on/off is unmistakable without reading the label.
|
||||||
|
- Switching on/off preserves the exact positions of title, timer, and hint.
|
||||||
|
- Switching Connect/Gateway crossfades status in a fixed slot, changes the full accent palette, and clearly de-emphasizes data irrelevant to the active route.
|
||||||
|
- A timer tick animates only changed digits and reads as a soft flow, never a blink.
|
||||||
|
- Hover motion completes its current cycle and settles before stopping; ambient affordance motion remains subtle and infrequent.
|
||||||
|
- Tooltips remain upright, unfiltered, above adjacent content, and visually consistent across controls.
|
||||||
|
- Refresh and copy feedback cannot change element width or alignment.
|
||||||
|
- Server separators are compact and only slightly wider than their content.
|
||||||
|
- Repeated polling does not replay decorative list animations.
|
||||||
|
- Manual refresh has an obvious but non-jarring response.
|
||||||
|
- Icon-only controls respond on hover and focus, active cyclic work spins, and durable states such as pinned remain legible at rest.
|
||||||
|
- Keyboard focus remains visible even when the text caret is intentionally hidden.
|
||||||
|
- Narrow screens return to a simple single-column layout.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Design VPN Client UI"
|
||||||
|
short_description: "Design the repo's calm animated VPN client UI."
|
||||||
|
default_prompt: "Use $design-vpn-client-ui to design or refine the VPN client interface in this repository."
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Motion and interaction
|
||||||
|
|
||||||
|
## Motion character
|
||||||
|
|
||||||
|
Aim for fluid, slightly viscous motion: noticeable, calm, and complete. Avoid bounce, elastic easing, abrupt unmounts, decorative page choreography, or tiny effects too weak to communicate feedback.
|
||||||
|
|
||||||
|
Motion is functional feedback. If the system is running, refreshing, counting, switching route, or inviting interaction, show that activity with restrained movement. Do not animate every static decoration; animate the part that proves work, state, or affordance.
|
||||||
|
|
||||||
|
Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals. Typical timing:
|
||||||
|
|
||||||
|
- hover and press: 180-300 ms;
|
||||||
|
- state color and glow: 600-900 ms;
|
||||||
|
- content reveal: 600-850 ms;
|
||||||
|
- numeric tween and progress: about 900 ms;
|
||||||
|
- copy feedback: about 800 ms;
|
||||||
|
- server cascade: 620-760 ms per row with 90-110 ms stagger.
|
||||||
|
- tooltip arrival: about 90-140 ms with almost no delay;
|
||||||
|
- ambient affordance hint: one small cycle roughly every 10 seconds.
|
||||||
|
|
||||||
|
## Cycle completion
|
||||||
|
|
||||||
|
- On pointer leave, do not snap an infinite hover animation or reverse it from the middle. Mark it for stopping, let the current iteration reach its original coordinates, then remove the animation.
|
||||||
|
- If the user re-enters before the iteration ends, clear the stop request and continue the same behavior.
|
||||||
|
- Separate state transforms from repeating motion when both affect one control. Animate a child for the cycle and its wrapper for durable state, or wait for `animationiteration` before clearing the animated class.
|
||||||
|
- Keep reduced-motion behavior immediate and static; never wait for an iteration event that will not fire.
|
||||||
|
|
||||||
|
## Power state
|
||||||
|
|
||||||
|
- Transition gray to the current mode accent slowly when connecting and back to gray when disconnecting.
|
||||||
|
- Animate icon color, localized light, and SVG shadow together.
|
||||||
|
- Let the light expand and brighten on enable, then contract and fade on disable.
|
||||||
|
- Keep the hit target and all surrounding geometry fixed.
|
||||||
|
- Use a short press compression, followed by a slower release.
|
||||||
|
|
||||||
|
## Changing text and numbers
|
||||||
|
|
||||||
|
- Put alternate labels in fixed-size slots.
|
||||||
|
- Reveal connection title, timer, and hint with overlapping fixed layers, opacity, and light blur, never vertical layout movement.
|
||||||
|
- Crossfade `VPN включён`, `Gateway подключён`, and disconnected copy in the same reserved slot when route state changes.
|
||||||
|
- Split changing numeric values into stable digits. On a tick such as `33 → 34`, keep the first `3` mounted and animate only `3 → 4` with a soft color/glow/blur flow; avoid low-opacity blinking or scaling the whole seconds value.
|
||||||
|
- Persist user-selected timer presentation locally and restore it on the next visit.
|
||||||
|
- Tween numeric traffic values from old to new with `requestAnimationFrame` or an equivalent stable counter.
|
||||||
|
- Animate progress width concurrently and add a brief glow that fully fades.
|
||||||
|
- Never translate changing numbers if the user asked for a fluid morph; use numerical interpolation, opacity, color, blur, and light.
|
||||||
|
|
||||||
|
## Mode switch affordance
|
||||||
|
|
||||||
|
- Treat the Connect/Gateway brand as one state control with a foreground label, a background label, and two independently colored direction arrows.
|
||||||
|
- On hover, move both labels continuously: let the foreground drift slightly down while the background rises toward it. Move the right arrow right and the left arrow left, then return; keep amplitudes small.
|
||||||
|
- When mode changes, swap the arrows' positions smoothly and bring the new label to the foreground without changing the brand's centered geometry.
|
||||||
|
- When hover ends, finish the current cycle at rest before stopping. Outside hover, replay one smaller cycle about every 10 seconds to hint that the control is clickable.
|
||||||
|
- Keep explanatory tooltip geometry tied to the mode-label-to-arrows span, not to the entire Harbor wordmark.
|
||||||
|
|
||||||
|
## Refresh
|
||||||
|
|
||||||
|
- Use a clean, symmetric SVG refresh icon aligned in the same flex row as its label.
|
||||||
|
- Spin for at least one full cycle. If the request finishes mid-cycle, continue to the next cycle boundary before stopping.
|
||||||
|
- Update data immediately when it arrives; finishing the icon cycle must not delay the data.
|
||||||
|
- Manual refresh may replay meaningful data and server transitions.
|
||||||
|
- Background polling should update quietly and must not repeatedly replay the server cascade.
|
||||||
|
- On updated traffic, tween the number, advance the bar, and emit a visible but brief mode-accent flare.
|
||||||
|
- Keep refresh tooltip outside the rotating button so it remains upright and unfiltered.
|
||||||
|
|
||||||
|
## Icon controls
|
||||||
|
|
||||||
|
- Give every actionable icon a small semantic response on hover and keyboard focus; leave decorative icons still.
|
||||||
|
- Rotate cyclic actions such as refresh, ping, and traffic sorting on hover, then use the shared continuous spin while work is running.
|
||||||
|
- Move the physical part of object-like controls: lift and tilt a pin, pencil, or trash lid instead of moving its fixed hit target.
|
||||||
|
- Keep durable state on the icon wrapper and transient motion on the SVG child. A pinned icon stays lifted and tilted while its row moves to the pinned group.
|
||||||
|
- Keep the hit target, tooltip, and surrounding layout fixed. Tooltips remain outside the transformed SVG.
|
||||||
|
- Under reduced motion, preserve color, focus, and final state without animated travel or rotation.
|
||||||
|
|
||||||
|
## Server cascade
|
||||||
|
|
||||||
|
- On initial display, reveal rows from top to bottom with a small negative Y offset, opacity, and blur.
|
||||||
|
- On manual refresh, animate an explicit exit phase first. Fade rows top to bottom, then remount and enter top to bottom.
|
||||||
|
- Wait for the last exit delay and duration before starting entry.
|
||||||
|
- Disable pointer interaction during exit.
|
||||||
|
- Do not replay on ping updates or unrelated renders.
|
||||||
|
|
||||||
|
## Dynamic editors
|
||||||
|
|
||||||
|
- Reveal added rows with opacity, blur, and a small transform while keeping surrounding geometry predictable.
|
||||||
|
- Keep interactive add latency constant regardless of collection length. Never multiply an added row's delay by its index; use bounded staggering only for a one-time group reveal.
|
||||||
|
- Give removal its own exit state and keep the row mounted until `animationend`; then animate surviving rows into their new positions instead of letting layout snap. Under reduced motion, remove it immediately.
|
||||||
|
- Do not let repeated add actions accumulate unfinished rows. Disable add while any current row lacks its required value and explain the disabled state in a reserved hint slot.
|
||||||
|
- Track the editor's dirty draft against its open/save baseline. Guard Escape, outside click, navigation controls, Cancel, and page unload; use an inline discard confirmation for in-app exits.
|
||||||
|
- Replace browser-native dropdowns when their platform chrome conflicts with the client surface. Use an accessible custom listbox with trigger, selected state, outside-click and Escape closing, arrow-key navigation, and restored trigger focus.
|
||||||
|
- Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation.
|
||||||
|
|
||||||
|
## Subscription input
|
||||||
|
|
||||||
|
- Show the public domain while retaining the full URL internally.
|
||||||
|
- Disable browser autocomplete suggestions and neutralize autofill backgrounds.
|
||||||
|
- Hide the blinking caret when the paste-first interaction does not need it, while preserving keyboard input and focus outline.
|
||||||
|
- When an existing subscription is being edited and the field is idle, use the mode-accent underline as a five-second timeout indicator: start bright, fade to quiet, then restore display mode.
|
||||||
|
- Pause the timeout once the user enters content.
|
||||||
|
- Close and clear unfinished input on outside click or Escape.
|
||||||
|
- Animate the trash lid independently on hover. Use the shared critical confirmation popup instead of a browser-native confirm dialog.
|
||||||
|
|
||||||
|
## Critical confirmation popup
|
||||||
|
|
||||||
|
- Reserve the blocking popup for explicit destructive or data-loss confirmation. It must cover the viewport, make the background inert, and use `alertdialog` with `aria-modal`.
|
||||||
|
- Fade and blur the backdrop first, resolve the popup from the center, then reveal its title, description, and actions in a short sequence.
|
||||||
|
- Put initial focus on the safe action, trap Tab within the popup, let Escape and backdrop click choose the safe action, and restore the invoking focus on close.
|
||||||
|
- Reuse the same component and motion vocabulary for every critical confirmation. Reduced motion presents the final state immediately.
|
||||||
|
|
||||||
|
## First-run initialization
|
||||||
|
|
||||||
|
- With no subscription, show only the centered subscription input. Hide power, proxy controls, usage, and servers.
|
||||||
|
- After a valid subscription loads, keep the subscription and server list centered. Require an explicit server choice instead of silently selecting the first server.
|
||||||
|
- On server choice, slide the subscription column to the right while revealing the power column on the viewport's central axis.
|
||||||
|
- Preserve the chosen server on later visits, but return to first-run initialization after subscription deletion.
|
||||||
|
- Deleting a subscription must stop the VPN, clear its cached/configured state, and return the UI to the centered input without leaving stale controls visible.
|
||||||
|
|
||||||
|
## Copy feedback
|
||||||
|
|
||||||
|
- Keep protocol buttons fixed-size and centered.
|
||||||
|
- Copy the complete protocol URL while showing a shared address separately.
|
||||||
|
- Overlay mode-accent `Copied` feedback in the same fixed box; do not append text or move the label.
|
||||||
|
- Make feedback appear immediately, hold briefly, and fade fully before restoring the original label. Keep the whole cycle near 800 ms.
|
||||||
|
|
||||||
|
## Reduced motion
|
||||||
|
|
||||||
|
Under `prefers-reduced-motion: reduce`, remove transitions and keyframe animations while preserving final state, focus, color contrast, copy wording, and all functionality.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Visual language
|
||||||
|
|
||||||
|
## Scene and character
|
||||||
|
|
||||||
|
Design for a macOS user glancing at a small VPN control surface in a quiet desktop environment. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
- Make one primary action dominant: the VPN power icon.
|
||||||
|
- Keep the power control centered on the viewport's vertical axis, not merely centered inside a left column.
|
||||||
|
- Build the left flow vertically: power icon, stable connection copy, proxy address, copy actions.
|
||||||
|
- Place subscription identity, usage, expiry, and servers in a compact column to the right.
|
||||||
|
- Collapse to one centered column on narrow screens.
|
||||||
|
- Avoid enclosing frames, borders, and divider lines. Use spacing, type, subtle surface changes, light, depth, and state color for hierarchy.
|
||||||
|
- Do not introduce popups or modals without an explicit user request. Prefer inline disclosure or a non-modal side layer when supporting content must coexist with the main control surface.
|
||||||
|
- Keep server rows vertical and narrow. Underlines should be only slightly wider than the server label and ping.
|
||||||
|
|
||||||
|
## Geometry and alignment
|
||||||
|
|
||||||
|
- Reserve identical height for mutually exclusive content such as timer versus connection hint.
|
||||||
|
- Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow.
|
||||||
|
- Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets.
|
||||||
|
- Give repeated row actions one fixed-width trailing slot aligned to the same edge. Reserve that slot when labels wrap or statuses change; never place the action at the end of intrinsic label text.
|
||||||
|
- Preserve a generous invisible hit area around icon-only controls.
|
||||||
|
- Center proxy address and protocol actions with the power column.
|
||||||
|
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels.
|
||||||
|
- Center the semantic brand or label independently from optional action icons. Place secondary icons beside it without letting their width move the centered content.
|
||||||
|
- Layer mutually exclusive status text in one fixed slot and crossfade between layers. Never replace text in normal flow when its length can move the interface.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- Prefer the existing JetBrains Mono / SF Mono stack for the client surface.
|
||||||
|
- Use uppercase, tracked, muted micro-labels for metadata.
|
||||||
|
- Use stronger weight and size for the subscription domain and connection state.
|
||||||
|
- Use tabular numerals for timers and changing numeric data.
|
||||||
|
- Avoid display fonts, oversized headings, and mixed type families.
|
||||||
|
|
||||||
|
## Color and light
|
||||||
|
|
||||||
|
- Preserve green-tinted dark and light neutrals through the existing OKLCH variables.
|
||||||
|
- Treat mode color as a system-wide state, not a logo-only decoration: Connect uses its blue-green token and Gateway uses its orange token for power, glow, selected rows, progress, copy/refresh feedback, focus, and mode-relevant labels.
|
||||||
|
- Inactive power stays neutral gray even on hover. Warning and destructive actions remain semantic red rather than inheriting the mode accent.
|
||||||
|
- Prefer localized `drop-shadow`, `text-shadow`, or a soft radial light layer over filled accent containers.
|
||||||
|
- Let glow support state recognition. Do not leave every element glowing continuously.
|
||||||
|
- Give Connect and Gateway distinct favicons and brand marks using the same mode palette.
|
||||||
|
- When Gateway carries traffic, fade, desaturate, and disable the local subscription/server block: it remains understandable context but must not look active or actionable.
|
||||||
|
|
||||||
|
## Interactive surfaces
|
||||||
|
|
||||||
|
- Use one fast translucent cloud treatment for explanatory tooltips. Place the cloud above its target with strong enough contrast to survive busy content beneath it.
|
||||||
|
- Keep a tooltip as a sibling of the animated icon/button it describes. A tooltip must never rotate, glow, blur, scale, or move with the control.
|
||||||
|
- Use the shared full-screen critical confirmation popup for destructive actions and unsaved-data exits. Keep the centered surface flat, with hierarchy from blur, spacing, type, and staged motion rather than borders or rounded cards.
|
||||||
|
- Animate physical icon parts when their function suggests it, such as lifting a trash lid on hover, while keeping hit areas and nearby text fixed.
|
||||||
|
- Keep advanced client editors flat inside their side layer: rows, notes, selectors, and actions should not become nested cards, bordered fields, or raised buttons. Use spacing, type, focus light, and state color for hierarchy.
|
||||||
|
|
||||||
|
## Data presentation
|
||||||
|
|
||||||
|
- Show subscription domain, not the credential-like full URL.
|
||||||
|
- Show used traffic and total limit as the primary statistic.
|
||||||
|
- Omit upload/download breakdown when provider support is absent or ambiguous.
|
||||||
|
- Show expiry as both date and remaining days, with correct Russian forms.
|
||||||
|
- If there is no total, say `без лимита` and omit the progress bar.
|
||||||
|
- Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements.
|
||||||
|
- Keep `not tested`, `running`, `success`, and `failed` as separate row states. A result from one row must not turn untouched sibling rows into failures.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: manage-harbor-versions
|
||||||
|
description: Check and bump Harbor component versions for every runtime, UI, API, dependency, packaging, or deployment-config change in this repository. Use before completing implementation work, release preparation, or any change that can alter the shipped Mac client, Gateway client, or Gateway backend.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Manage Harbor Versions
|
||||||
|
|
||||||
|
Treat `src/shared/versions.ts` as the only component-version source. Do not use the root package version as a release version.
|
||||||
|
|
||||||
|
## Required workflow
|
||||||
|
|
||||||
|
1. Inspect the complete diff and choose the comparison base, normally `HEAD` for working-tree changes or the target branch for a review.
|
||||||
|
2. Run `npm run version:harbor -- affected <base>`.
|
||||||
|
3. Classify the highest compatibility impact:
|
||||||
|
- `major`: changes an ecosystem contract or requires all cooperating components and clients to update;
|
||||||
|
- `minor`: changes one component and its tightly linked components while remaining compatible with other clients on the same major;
|
||||||
|
- `hotfix`: changes only the affected component without requiring linked components or other clients to update.
|
||||||
|
4. Run one explicit bump command:
|
||||||
|
- `npm run version:harbor -- bump major`
|
||||||
|
- `npm run version:harbor -- bump minor <components...>`
|
||||||
|
- `npm run version:harbor -- bump hotfix <components...>`
|
||||||
|
5. Run `npm run version:harbor -- check <base>` and the repository tests before completion.
|
||||||
|
|
||||||
|
Valid component names are `mac`, `gateway-client`, and `gateway-backend`. A major bump always updates all three components. A minor bump for either Gateway component automatically updates both Gateway client and Gateway backend. A hotfix updates only the named component.
|
||||||
|
|
||||||
|
Do not bump documentation- or test-only changes. If the version contract is new and the base has no `src/shared/versions.ts`, keep the initial versions and let the checker report that no baseline exists.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Manage Harbor Versions"
|
||||||
|
short_description: "Check and bump Harbor component versions."
|
||||||
|
default_prompt: "Use $manage-harbor-versions to classify changes and update the required Harbor component versions."
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vpn-proxy
|
||||||
|
.runtime
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
+16
-1
@@ -1,8 +1,23 @@
|
|||||||
PORT=3456
|
PORT=3456
|
||||||
|
APP_MODE=gateway
|
||||||
|
CLIENT_UI_PORT=3456
|
||||||
|
CLIENT_PROXY_PORT=8082
|
||||||
|
HARBOR_GATEWAY_CONTROL_PORT=3456
|
||||||
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
|
SINGBOX_VERSION=1.14.0-rc.5
|
||||||
|
INSTALL_RUNTIME_DEPS=true
|
||||||
|
INSTALL_SINGBOX=true
|
||||||
PROXY_PORT=8080
|
PROXY_PORT=8080
|
||||||
|
PROXY_BIND_IP=0.0.0.0
|
||||||
|
SING_BOX_API_PORT=19090
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=snapshot
|
||||||
TPROXY_PORT=7895
|
TPROXY_PORT=7895
|
||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
ROUTING_RU_DIRECT=true
|
DIRECT_TRAFFIC_CHAIN=VPN_PROXY_DIRECT
|
||||||
|
DIRECT_TRAFFIC_MARK=0x40000000
|
||||||
|
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
|
||||||
|
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
|
||||||
|
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -1,28 +1,229 @@
|
|||||||
name: Build Gateway Image
|
name: Build and Deploy Gateway
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
hard_deploy:
|
||||||
|
description: Always rebuild and deploy both Gateway images
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/vpn-proxy
|
||||||
|
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||||
|
NODE_BUILD_IMAGE: mirror.gcr.io/library/node:20.19-bookworm
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||||
|
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||||
|
SINGBOX_VERSION: 1.14.0-rc.5
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
affected_components: ${{ steps['gateway-build'].outputs.affected_components }}
|
||||||
|
restart_scope: ${{ steps['gateway-build'].outputs.restart_scope }}
|
||||||
steps:
|
steps:
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
env:
|
env:
|
||||||
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" .
|
rm -rf repo
|
||||||
|
git clone "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
|
cd repo
|
||||||
git checkout ${{ gitea.sha }}
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
- name: Build and push gateway image
|
- name: Build and push gateway image
|
||||||
|
id: gateway-build
|
||||||
|
env:
|
||||||
|
HARD_DEPLOY_INPUT: ${{ inputs.hard_deploy }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo
|
||||||
|
|
||||||
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
CONTROL_IMAGE="${IMAGE}-control"
|
||||||
|
DATAPLANE_IMAGE="${IMAGE}-dataplane"
|
||||||
|
|
||||||
|
EVENT_NAME="${{ gitea.event_name }}"
|
||||||
|
case "${EVENT_NAME}:${HARD_DEPLOY_INPUT}" in
|
||||||
|
workflow_dispatch:true) HARD_DEPLOY=true ;;
|
||||||
|
workflow_dispatch:false|workflow_dispatch:|push:false|push:) HARD_DEPLOY=false ;;
|
||||||
|
*) echo "Invalid hard deploy request: ${EVENT_NAME}:${HARD_DEPLOY_INPUT}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
BEFORE_SHA="${{ gitea.event.before }}"
|
||||||
|
ZERO_SHA="0000000000000000000000000000000000000000"
|
||||||
|
if [ "$EVENT_NAME" = "push" ] \
|
||||||
|
&& [ -n "$BEFORE_SHA" ] \
|
||||||
|
&& [ "$BEFORE_SHA" != "$ZERO_SHA" ] \
|
||||||
|
&& git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
CHANGED_FILES="$(git diff --no-renames --name-only "$BEFORE_SHA" "${{ gitea.sha }}")"
|
||||||
|
elif [ "$EVENT_NAME" = "push" ]; then
|
||||||
|
CHANGED_FILES="package.json"
|
||||||
|
else
|
||||||
|
CHANGED_FILES="$(git diff-tree --no-renames --no-commit-id --name-only -r -m HEAD)"
|
||||||
|
fi
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | node scripts/runtime-impact.mjs --stdin)"
|
||||||
|
else
|
||||||
|
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v node >/dev/null'; then
|
||||||
|
echo "Cannot classify runtime impact: Node and the existing runtime base are unavailable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | docker run --rm -i \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.BASE_IMAGE }}" \
|
||||||
|
node scripts/runtime-impact.mjs --stdin)"
|
||||||
|
fi
|
||||||
|
AFFECTED_COMPONENTS="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^affected-components=//p')"
|
||||||
|
RESTART_SCOPE="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^restart-scope=//p')"
|
||||||
|
case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in
|
||||||
|
none:none|control:control|dataplane:both|control+dataplane:both) ;;
|
||||||
|
*) echo "Invalid runtime impact: ${RUNTIME_IMPACT}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
DOCKER_BUILD_OPTIONS=()
|
||||||
|
if [ "$HARD_DEPLOY" = "true" ]; then
|
||||||
|
echo "Hard deploy requested: forcing no-cache rebuild and deploy of both Gateway images."
|
||||||
|
AFFECTED_COMPONENTS="control+dataplane"
|
||||||
|
RESTART_SCOPE="both"
|
||||||
|
DOCKER_BUILD_OPTIONS=(--no-cache)
|
||||||
|
fi
|
||||||
|
echo "Affected components: ${AFFECTED_COMPONENTS}"
|
||||||
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
|
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm run typecheck
|
||||||
|
npm run check:boundaries
|
||||||
|
npm test
|
||||||
|
npm run build:production
|
||||||
|
else
|
||||||
|
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null && command -v git >/dev/null && test -x /bin/bash'; then
|
||||||
|
echo "Cannot validate change: host npm and the Node 20.19 build toolchain are unavailable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
|
sh -lc '
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm_status=$?
|
||||||
|
if [ "$npm_status" -ne 0 ] || [ ! -x node_modules/.bin/tsc ]; then
|
||||||
|
echo "npm ci failed to install the validation toolchain." >&2
|
||||||
|
tail -n 200 /root/.npm/_logs/*-debug-0.log >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm run typecheck && npm run check:boundaries && npm test && npm run build:production
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
if [ "$RESTART_SCOPE" = "none" ]; then
|
||||||
|
echo "Image build and push skipped: no Gateway runtime impact."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Build runner: $(hostname)"
|
||||||
|
echo "Base image: ${{ env.BASE_IMAGE }}"
|
||||||
|
echo "Docker context: $(docker context show 2>/dev/null || true)"
|
||||||
|
docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'
|
||||||
|
|
||||||
|
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc \
|
||||||
|
'command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}"'; then
|
||||||
|
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm or sing-box ${{ env.SINGBOX_VERSION }}; building it now."
|
||||||
|
BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
|
||||||
|
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||||
|
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
||||||
|
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
./scripts/build-runtime-base.sh
|
||||||
|
fi
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
docker build -t "${IMAGE}:latest" -t "${IMAGE}:${{ gitea.sha }}" .
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
docker push "${IMAGE}:latest"
|
"${DOCKER_BUILD_OPTIONS[@]}" \
|
||||||
docker push "${IMAGE}:${{ gitea.sha }}"
|
--network host \
|
||||||
|
--pull=false \
|
||||||
|
--build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
|
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
--build-arg INSTALL_RUNTIME_DEPS=false \
|
||||||
|
--build-arg INSTALL_SINGBOX=false \
|
||||||
|
-t "${CONTROL_IMAGE}:latest" \
|
||||||
|
-t "${CONTROL_IMAGE}:${{ gitea.sha }}" \
|
||||||
|
-t "${DATAPLANE_IMAGE}:latest" \
|
||||||
|
-t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \
|
||||||
|
.
|
||||||
|
docker run --rm --entrypoint sing-box "${CONTROL_IMAGE}:${{ gitea.sha }}" version 2>&1 \
|
||||||
|
| grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}"
|
||||||
|
docker run --rm --entrypoint sing-box "${DATAPLANE_IMAGE}:${{ gitea.sha }}" version 2>&1 \
|
||||||
|
| grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}"
|
||||||
|
docker push "${CONTROL_IMAGE}:latest"
|
||||||
|
docker push "${CONTROL_IMAGE}:${{ gitea.sha }}"
|
||||||
|
docker push "${DATAPLANE_IMAGE}:latest"
|
||||||
|
docker push "${DATAPLANE_IMAGE}:${{ gitea.sha }}"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: lxc-111
|
||||||
|
needs: build-and-push
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
rm -rf repo
|
||||||
|
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
|
cd repo
|
||||||
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Pull and deploy gateway image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo
|
||||||
|
|
||||||
|
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
CONTROL_IMAGE="${IMAGE}-control:${{ gitea.sha }}"
|
||||||
|
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
||||||
|
AFFECTED_COMPONENTS="${{ needs['build-and-push'].outputs.affected_components }}"
|
||||||
|
RESTART_SCOPE="${{ needs['build-and-push'].outputs.restart_scope }}"
|
||||||
|
case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in
|
||||||
|
none:none|control:control|dataplane:both|control+dataplane:both) ;;
|
||||||
|
*) echo "Invalid runtime impact output: ${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$RESTART_SCOPE" = "none" ]; then
|
||||||
|
echo "Deploy skipped: no Gateway runtime impact."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
UPDATE_DATAPLANE=false
|
||||||
|
if [ "$RESTART_SCOPE" = "both" ]; then
|
||||||
|
UPDATE_DATAPLANE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploy runner: $(hostname)"
|
||||||
|
echo "Affected components: ${AFFECTED_COMPONENTS}"
|
||||||
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
|
echo "Update dataplane: ${UPDATE_DATAPLANE}"
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \
|
||||||
|
CONTROL_IMAGE="${CONTROL_IMAGE}" \
|
||||||
|
DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \
|
||||||
|
UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \
|
||||||
|
bash scripts/deploy-gateway.sh
|
||||||
|
VERSION_JSON="$(curl --noproxy '*' -fsS http://127.0.0.1:3456/api/version)"
|
||||||
|
printf '%s\n' "$VERSION_JSON"
|
||||||
|
printf '%s\n' "$VERSION_JSON" \
|
||||||
|
| grep -F "\"singBox\":\"${{ env.SINGBOX_VERSION }}\""
|
||||||
|
|||||||
+7
-3
@@ -1,15 +1,19 @@
|
|||||||
# Local archive with the previous implementation and runtime secrets
|
|
||||||
_archive/
|
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
.env
|
.env
|
||||||
*.env.local
|
*.env.local
|
||||||
data/
|
data/
|
||||||
.vpn-proxy/
|
.vpn-proxy/
|
||||||
|
.runtime/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Local roadmap and task workspace
|
||||||
|
/workpack/
|
||||||
|
|
||||||
# Node/Vite
|
# Node/Vite
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.test-dist/
|
||||||
|
.tmp-tests/
|
||||||
coverage/
|
coverage/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Harbor task workflow
|
||||||
|
|
||||||
|
Use the checked-in `workpack/` directory as the only roadmap source. Do not require or read the original archive.
|
||||||
|
|
||||||
|
Follow `workpack/AGENTS.md` for every roadmap task, including status updates. Completed tasks must not be selected or implemented again unless the user explicitly asks to reopen one.
|
||||||
|
|
||||||
|
Before implementing any feature, record or refresh its plan in the selected `workpack/tasks/TASK-*.md` file using the mandatory feature-plan contract from `workpack/AGENTS.md`. Write the whole plan in simple language understandable without knowledge of the codebase: explain technical terms on first use, and use file paths or code names only as supporting detail. The plan must explain the implementation sequence and affected system components. For user-visible work it must also specify layout and states, exact icons, animation/motion behavior, accessibility and reduced-motion behavior; otherwise it must explicitly state that UI, icons and motion are unaffected. A proposed visible design is not owner approval.
|
||||||
|
|
||||||
|
For every runtime, UI, API, dependency or deployment-config change, use `.codex/skills/manage-harbor-versions/SKILL.md`. Before completion, classify the affected components, bump the required version level and run `npm run version:harbor -- check <base>`. Documentation- and test-only changes do not require a bump.
|
||||||
+50
-26
@@ -1,34 +1,55 @@
|
|||||||
FROM node:22-bookworm-slim AS ui-build
|
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
||||||
WORKDIR /app
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
COPY index.html vite.config.js ./
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
COPY src/web ./src/web
|
COPY src/web ./src/web
|
||||||
RUN npm run build
|
COPY src/server ./src/server
|
||||||
|
COPY src/shared ./src/shared
|
||||||
|
COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.json
|
||||||
|
RUN npm run build:production
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM ${BASE_IMAGE}
|
||||||
ARG SINGBOX_VERSION=1.12.13
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
|
ARG INSTALL_RUNTIME_DEPS=true
|
||||||
|
ARG INSTALL_SINGBOX=true
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \
|
apt-get update \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data nodejs dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
|
else \
|
||||||
|
command -v dumb-init >/dev/null \
|
||||||
|
&& command -v node >/dev/null \
|
||||||
|
&& command -v iptables >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||||
arch="$(dpkg --print-architecture)"; \
|
set -eux; \
|
||||||
case "$arch" in \
|
arch="$(dpkg --print-architecture)"; \
|
||||||
amd64) sb_arch="amd64" ;; \
|
case "$arch" in \
|
||||||
arm64) sb_arch="arm64" ;; \
|
amd64) sb_arch="amd64" ;; \
|
||||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
arm64) sb_arch="arm64" ;; \
|
||||||
esac; \
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
esac; \
|
||||||
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
chmod +x /usr/local/bin/sing-box; \
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
rm -rf /tmp/sing-box*
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*; \
|
||||||
|
else \
|
||||||
|
command -v sing-box >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=ui-build /app/dist /app/dist
|
COPY --from=build /src/dist /app/dist
|
||||||
COPY src/server /app/src/server
|
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||||
|
COPY package.json /app/package.json
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /entrypoint.sh \
|
RUN chmod +x /entrypoint.sh \
|
||||||
@@ -36,9 +57,12 @@ RUN chmod +x /entrypoint.sh \
|
|||||||
|
|
||||||
ENV PORT=3456 \
|
ENV PORT=3456 \
|
||||||
PROXY_PORT=8080 \
|
PROXY_PORT=8080 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
|
SING_BOX_API_PORT=19090 \
|
||||||
TPROXY_PORT=7895 \
|
TPROXY_PORT=7895 \
|
||||||
DATA_DIR=/var/lib/vpn-proxy \
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=snapshot
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
||||||
|
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
|
COPY src/web ./src/web
|
||||||
|
COPY src/server ./src/server
|
||||||
|
COPY src/shared ./src/shared
|
||||||
|
COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.json
|
||||||
|
RUN npm run build:production
|
||||||
|
|
||||||
|
FROM ${RUNTIME_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /src/dist /app/dist
|
||||||
|
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.client.sh \
|
||||||
|
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||||
|
|
||||||
|
ENV APP_MODE=client \
|
||||||
|
PORT=3456 \
|
||||||
|
PROXY_PORT=8082 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||||
|
ROUTING_RU_DIRECT=true \
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=native \
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
EXPOSE 3456 8082
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
|
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||||
|
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||||
|
ARG HTTP_PROXY
|
||||||
|
ARG HTTPS_PROXY
|
||||||
|
ARG NO_PROXY
|
||||||
|
ARG http_proxy
|
||||||
|
ARG https_proxy
|
||||||
|
ARG no_proxy
|
||||||
|
|
||||||
|
RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
|
||||||
|
&& export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}" \
|
||||||
|
&& export no_proxy="${no_proxy:-${NO_PROXY:-}}" \
|
||||||
|
&& for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do \
|
||||||
|
[ -f "$file" ] || continue; \
|
||||||
|
sed -i \
|
||||||
|
-e "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://security.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||||
|
"$file"; \
|
||||||
|
done \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
update \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data nodejs npm dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
export http_proxy="${http_proxy:-${HTTP_PROXY:-}}"; \
|
||||||
|
export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}"; \
|
||||||
|
export no_proxy="${no_proxy:-${NO_PROXY:-}}"; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
People running either a local macOS proxy client or a small Linux VPN gateway. They open the client only to add a subscription, choose a server, turn the VPN on or off, and copy the connection address.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
Provide one small, dependable control surface for the macOS client and the system gateway. Success means the connection state is obvious, while the gateway address and proxy URLs are ready to copy from the same screen.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and routing-rule administration.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- One screen, one primary action.
|
||||||
|
- Use plain language and hide implementation details.
|
||||||
|
- Make connection state unmistakable without relying on color alone.
|
||||||
|
- Prefer native controls and predictable macOS behavior.
|
||||||
|
- Show saved subscriptions as a domain, not as a credential-like URL.
|
||||||
|
- Make servers directly selectable instead of hiding them in a dropdown.
|
||||||
|
- Keep advanced and server-only features out of the client path.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences.
|
||||||
@@ -1,34 +1,409 @@
|
|||||||
# VPN Proxy Gateway
|
# Harbor
|
||||||
|
|
||||||
Новая версия проекта начинается с `gateway`-режима: контейнер поднимается в `network_mode: host`, применяет TProxy-правила на хосте и запускает `sing-box` как прозрачный gateway для устройств в локальной сети.
|
Harbor помогает пользоваться несколькими VPN-подписками дома и на Mac без ручной настройки `sing-box`.
|
||||||
|
|
||||||
## Что уже заложено
|
Проект работает в двух режимах:
|
||||||
|
|
||||||
- Web UI на Vite + React.
|
| Режим | Где работает | Для чего нужен |
|
||||||
- Один простой Node control-server вместо отдельного backend framework.
|
| --- | --- | --- |
|
||||||
- Парсинг subscription URL: JSON config, base64 список, plain-text VLESS links.
|
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
|
||||||
- Routing lists управляются из UI: можно отправлять отдельные домены/CIDR/порты в `direct`, `vpn` или `block`.
|
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
|
||||||
- Генерация `sing-box` config для gateway:
|
|
||||||
- `tproxy` inbound на `7895`;
|
|
||||||
- `mixed` inbound на `8080`;
|
|
||||||
- private IP ranges напрямую;
|
|
||||||
- RU rule sets напрямую;
|
|
||||||
- остальное через выбранный outbound.
|
|
||||||
- Docker entrypoint с idempotent TProxy setup/cleanup.
|
|
||||||
|
|
||||||
## Быстрый старт
|
Основной экран Connect и Gateway всегда показывает фактически применённые подписку и сервер. Управление подписками открывается отдельной верхней кнопкой в правой панели; Home, «Устройства» и «Диагностика» Gateway доступны и без подписки.
|
||||||
|
|
||||||
|
## Что понадобится
|
||||||
|
|
||||||
|
- ссылка на подписку от VPN-провайдера, если Harbor должен направлять трафик через VPN;
|
||||||
|
- Docker с командой `docker compose`;
|
||||||
|
- для ручной установки Gateway — Git;
|
||||||
|
- для Gateway — Linux-машина в одной локальной сети с устройствами;
|
||||||
|
- для Connect — Mac с запущенным Docker Desktop.
|
||||||
|
|
||||||
|
Harbor не является VPN-провайдером и не создаёт подписки самостоятельно.
|
||||||
|
|
||||||
|
## Что выбрать
|
||||||
|
|
||||||
|
Используйте **Harbor Connect**, если VPN нужен только приложениям на одном Mac.
|
||||||
|
|
||||||
|
Используйте **Harbor Gateway**, если нужно подключить телевизор, телефон, игровую приставку или сразу несколько устройств. Устройства можно направить через Gateway целиком либо настроить в отдельных приложениях общий прокси.
|
||||||
|
|
||||||
|
Оба режима можно использовать вместе. Дома Connect автоматически распознаёт настроенный Harbor Gateway и не запускает второй VPN-маршрут. В другой сети Connect возвращается к локальному VPN.
|
||||||
|
|
||||||
|
## Установка Harbor Gateway
|
||||||
|
|
||||||
|
### 1. Скачайте проект
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.dokops.ru/dokril/vpn-proxy.git
|
||||||
|
cd vpn-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Создайте настройки
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Стандартные значения подходят для обычной домашней сети. При необходимости откройте `.env` в текстовом редакторе и измените порты.
|
||||||
|
|
||||||
|
### 3. Запустите Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose -f docker-compose.gateway.yml up -d --build
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
UI будет доступен на хосте по `http://<gateway-host>:3456`.
|
Откройте в браузере:
|
||||||
|
|
||||||
## Важные ограничения v0.1
|
```text
|
||||||
|
http://АДРЕС-GATEWAY:3456
|
||||||
|
```
|
||||||
|
|
||||||
- IPv4 TProxy first. IPv6 routing будет отдельным этапом.
|
Например, если Linux-машина имеет адрес `192.168.1.20`, интерфейс будет доступен по адресу `http://192.168.1.20:3456`.
|
||||||
- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать клиентам DNS через роутер/DHCP.
|
|
||||||
- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`.
|
### 4. При необходимости добавьте подписку
|
||||||
- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state.
|
|
||||||
- Gateway не видит process name на клиентском ПК, поэтому правила для игр задаются через домены, suffix, IP CIDR и порты.
|
1. Нажмите «Подписки» — верхнюю кнопку в правой панели Gateway.
|
||||||
|
2. Нажмите «Добавить подписку», задайте понятное имя и вставьте ссылку VPN-провайдера.
|
||||||
|
3. Выберите сервер внутри добавленной группы.
|
||||||
|
4. При нескольких группах выберите нужную действием «Сделать активной».
|
||||||
|
5. Включите VPN.
|
||||||
|
|
||||||
|
После подключения Harbor покажет два варианта использования:
|
||||||
|
|
||||||
|
- **Gateway** — укажите IP-адрес Linux-машины как основной шлюз устройства. Через VPN пойдёт весь его интернет-трафик;
|
||||||
|
- **Gateway Proxy** — укажите адрес Linux-машины и порт `8080` в приложении. Поддерживаются HTTP и SOCKS5 на одном порту.
|
||||||
|
|
||||||
|
Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей.
|
||||||
|
|
||||||
|
### Резервный канал Gateway
|
||||||
|
|
||||||
|
После добавления подписок откройте «Резерв» — вторую кнопку в правой панели. Выберите основной и резервный серверы (они могут быть из одной или разных подписок), сервисы для проверки и отдельный таймаут каждого сервиса. Там же настраиваются длительность сбоя и восстановления, порог активного трафика, период тишины и защита от частых переключений.
|
||||||
|
|
||||||
|
При включении Harbor заранее проверяет dual-конфигурацию. Если VPN остановлен, она начнёт работать только после следующего обычного нажатия питания; сохранение само VPN не включает. Переключение меняет маршрут только для новых соединений — уже открытые соединения не закрываются. Если через VPN идёт активный трафик или его активность нельзя надёжно определить, Harbor ждёт и показывает скорость, число передающих соединений и безопасные подписи основных блокирующих потоков.
|
||||||
|
|
||||||
|
Выключенный резерв полностью пассивен: Harbor не запускает проверки, таймер выбора и отдельный подсчёт активности. Если dual-конфигурация уже загружена, отключение не перезапускает VPN и не меняет текущий маршрут; обычный stop и следующий запуск вернут single-channel config. Последние важные события — включение VPN, обновления подписок, переключения и ошибки — доступны в последней кнопке «Журнал» и хранятся 30 дней без ссылок подписок и сырых диагностических ответов.
|
||||||
|
|
||||||
|
### Устройства Gateway
|
||||||
|
|
||||||
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
||||||
|
|
||||||
|
Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются вместе с `devices.json`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway.
|
||||||
|
|
||||||
|
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
|
||||||
|
|
||||||
|
Устройство, впервые замеченное после обновления Gateway, по умолчанию идёт `Напрямую` и первые семь дней отмечается `NEW`; исчезновение метки маршрут не меняет. Уже известные при обновлении устройства сохраняют текущий VPN, даже если метка ещё видна по их `firstSeenAt`. VPN разрешается существующей последней иконкой маршрута. Если новый device пока распознан неоднозначно, Harbor сохраняет Direct-намерение, временно оставляет фактический VPN и применяет Direct после однозначного наблюдения.
|
||||||
|
|
||||||
|
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
||||||
|
|
||||||
|
Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Запись автоматически удаляется после 30 дней без подтверждённого контакта независимо от имени, закрепления или фонового положения; временная ошибка чтения сети этот срок не продвигает. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||||
|
|
||||||
|
Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
|
||||||
|
|
||||||
|
## Установка Harbor Connect на macOS
|
||||||
|
|
||||||
|
### 1. Запустите Docker Desktop
|
||||||
|
|
||||||
|
Установщик проверит наличие Docker, Docker Compose, `curl` и `tar`. Если Docker Desktop не запущен, установка остановится с понятным сообщением.
|
||||||
|
|
||||||
|
### 2. Запустите установщик
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Установщик:
|
||||||
|
|
||||||
|
- сохранит рабочую копию в `~/.vpn-proxy-client`;
|
||||||
|
- предложит порт для локального прокси;
|
||||||
|
- соберёт и запустит контейнер Harbor Connect;
|
||||||
|
- добавит пользовательский LaunchAgent для определения текущего Gateway.
|
||||||
|
|
||||||
|
По умолчанию используются адреса:
|
||||||
|
|
||||||
|
| Назначение | Адрес |
|
||||||
|
| --- | --- |
|
||||||
|
| Интерфейс Harbor Connect | `http://127.0.0.1:3456` |
|
||||||
|
| HTTP-прокси | `127.0.0.1:8082` |
|
||||||
|
| SOCKS5-прокси | `127.0.0.1:8082` |
|
||||||
|
|
||||||
|
### 3. Добавьте подписку
|
||||||
|
|
||||||
|
Откройте `http://127.0.0.1:3456`, добавьте подписку с понятным именем, выберите сервер внутри её группы и включите VPN. Остальные подписки можно добавить через правую панель «Подписки»; у каждой сохраняются собственные серверы, лимит и выбор.
|
||||||
|
|
||||||
|
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS.
|
||||||
|
|
||||||
|
Кнопка «Трафик» в правой панели показывает активные соединения, которые прошли через Harbor Connect. Данные о приложениях macOS недоступны, потому что sing-box работает внутри Docker.
|
||||||
|
|
||||||
|
### Другие порты
|
||||||
|
|
||||||
|
Передайте нужные значения при повторном запуске установщика:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | \
|
||||||
|
VPN_PROXY_CLIENT_PORT=9080 \
|
||||||
|
VPN_PROXY_CLIENT_UI_PORT=3457 \
|
||||||
|
sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси.
|
||||||
|
|
||||||
|
## Правила маршрутизации
|
||||||
|
|
||||||
|
После добавления подписки откройте «Правила маршрутизации» справа от основного экрана. При первом обновлении Harbor добавит включённое правило `*.ru → Напрямую`. Для каждого точного домена, suffix или фрагмента имени можно выбрать результат `VPN` либо `Напрямую`, выключить правило или удалить его. Правила проверяются сверху вниз, первое совпадение выбирает маршрут. Чтобы изменить порядок, возьмите строку за три точки слева и перетащите; с клавиатуры нажмите на этом хвате `Space` или `Enter`, переместите правило стрелками и повторно нажмите для размещения.
|
||||||
|
|
||||||
|
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила — результат уже найденного совпадения. Для устройства Gateway с маршрутом `VPN` и при обычном локальном VPN список применяется.
|
||||||
|
|
||||||
|
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
|
||||||
|
|
||||||
|
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс применяет новую конфигурацию, только если она изменилась. Если sing-box остановлен, правила сохраняются с признаком «ждут запуска» и начнут работать при следующем запуске или restart; в Connect с активным Harbor Gateway они сохраняются как желаемые, но локально не применяются.
|
||||||
|
|
||||||
|
## Системный прокси macOS
|
||||||
|
|
||||||
|
Сначала посмотрите точное имя сетевого подключения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
networksetup -listallnetworkservices
|
||||||
|
```
|
||||||
|
|
||||||
|
Для подключения с именем `Wi-Fi` включите HTTP, HTTPS и SOCKS5-прокси:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 8082
|
||||||
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8082
|
||||||
|
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 8082
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы отключить их:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
networksetup -setwebproxystate Wi-Fi off
|
||||||
|
networksetup -setsecurewebproxystate Wi-Fi off
|
||||||
|
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||||
|
```
|
||||||
|
|
||||||
|
Если сетевое подключение называется иначе, замените `Wi-Fi` его точным именем.
|
||||||
|
|
||||||
|
## Автоматическое использование домашнего Gateway
|
||||||
|
|
||||||
|
Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же выбранной VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway.
|
||||||
|
|
||||||
|
Для этого:
|
||||||
|
|
||||||
|
1. добавьте одну и ту же ссылку подписки в Gateway и Connect и выберите соответствующий профиль на обоих устройствах;
|
||||||
|
2. убедитесь, что Mac может открыть интерфейс Gateway на порту `3456`;
|
||||||
|
3. оставьте автоматический режим включённым в Harbor Connect.
|
||||||
|
|
||||||
|
Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. До отдельного pairing-flow Connect не получает от Gateway имя фактически применённых подписки и сервера, поэтому в режиме Gateway честно показывает `Gateway · сервер не определён`. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN.
|
||||||
|
|
||||||
|
## Повседневные команды
|
||||||
|
|
||||||
|
Все команды Gateway выполняются из каталога проекта. Команды Connect — из `~/.vpn-proxy-client`.
|
||||||
|
|
||||||
|
### Harbor Gateway
|
||||||
|
|
||||||
|
| Действие | Команда |
|
||||||
|
| --- | --- |
|
||||||
|
| Запустить или обновить после изменения файлов | `docker compose -f docker-compose.gateway.yml up -d --build` |
|
||||||
|
| Обновить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml build vpn-proxy-control && docker compose -f docker-compose.gateway.yml up -d --no-deps vpn-proxy-control` |
|
||||||
|
| Показать состояние | `docker compose -f docker-compose.gateway.yml ps` |
|
||||||
|
| Смотреть журнал | `docker compose -f docker-compose.gateway.yml logs -f` |
|
||||||
|
| Перезапустить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-control` |
|
||||||
|
| Перезапустить VPN dataplane | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-dataplane` |
|
||||||
|
| Остановить | `docker compose -f docker-compose.gateway.yml down` |
|
||||||
|
| Удалить вместе с сохранёнными данными | `docker compose -f docker-compose.gateway.yml down -v` |
|
||||||
|
|
||||||
|
### Harbor Connect
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.vpn-proxy-client
|
||||||
|
```
|
||||||
|
|
||||||
|
| Действие | Команда |
|
||||||
|
| --- | --- |
|
||||||
|
| Обновить и снова запустить | `./scripts/install-macos-client.sh` |
|
||||||
|
| Показать состояние | `docker compose -f docker-compose.client.yml ps` |
|
||||||
|
| Смотреть журнал | `docker compose -f docker-compose.client.yml logs -f` |
|
||||||
|
| Перезапустить | `docker compose -f docker-compose.client.yml restart` |
|
||||||
|
| Остановить | `docker compose -f docker-compose.client.yml down` |
|
||||||
|
| Удалить вместе с сохранёнными данными | `docker compose -f docker-compose.client.yml down -v` |
|
||||||
|
|
||||||
|
Команда с `-v` удаляет подписку, выбранный сервер и другие сохранённые данные. Для обычной остановки используйте `down` без `-v`.
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
### Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connect
|
||||||
|
|
||||||
|
Повторно запустите однострочный установщик. Он обновит рабочую копию, снова спросит порт прокси и пересоберёт Connect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`.
|
||||||
|
|
||||||
|
### Версии
|
||||||
|
|
||||||
|
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает строку `M` (Mac client). Gateway показывает `C` (Gateway client UI), `B` (текущий control-backend) и `D` (фактически развёрнутый dataplane). Поэтому после control-only deploy `B` обновится сразу, а `D` может намеренно остаться на прежней версии до следующего runtime-deploy. Наведите курсор или переведите клавиатурный фокус на цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у `D` также указана фактическая версия `sing-box`.
|
||||||
|
|
||||||
|
Компонентные версии меняются в `src/shared/versions.ts`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
|
||||||
|
|
||||||
|
Для изменения версии используйте `npm run version:harbor -- affected HEAD`, затем `npm run version:harbor -- bump <major|minor|hotfix> [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
|
||||||
|
|
||||||
|
## Настройки `.env`
|
||||||
|
|
||||||
|
Для большинства установок достаточно стандартных значений.
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PORT` | `3456` | Внутренний порт веб-интерфейса Gateway |
|
||||||
|
| `CLIENT_UI_PORT` | `3456` | Порт интерфейса Connect на Mac |
|
||||||
|
| `PROXY_PORT` | `8080` | Порт общего прокси Gateway |
|
||||||
|
| `CLIENT_PROXY_PORT` | `8082` | Порт локального прокси Connect |
|
||||||
|
| `HARBOR_GATEWAY_CONTROL_PORT` | `3456` | Порт, на котором Connect проверяет домашний Gateway |
|
||||||
|
| `PROXY_BIND_IP` | `0.0.0.0` | Адрес, на котором Gateway принимает прокси-подключения |
|
||||||
|
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
|
||||||
|
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
|
||||||
|
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
|
||||||
|
| `SING_BOX_TRAFFIC_SOURCE` | `snapshot` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` |
|
||||||
|
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
|
||||||
|
|
||||||
|
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
|
||||||
|
|
||||||
|
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
||||||
|
|
||||||
|
`snapshot` сохраняет прежний опрос Clash API раз в 2 секунды. `shadow` дополнительно читает native lifecycle, но оставляет snapshot единственным источником публичных totals. `native` делает lifecycle единственным writer и не опрашивает `/connections`; Clash API остаётся только для selector/failover. Режим меняется только при пересоздании обоих Gateway-контейнеров и не переключается автоматически при ошибке.
|
||||||
|
|
||||||
|
Rollback сохраняет volumes и возвращает прежний writer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SINGBOX_VERSION=1.13.18 \
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=snapshot \
|
||||||
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prometheus и Grafana
|
||||||
|
|
||||||
|
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: harbor_gateway
|
||||||
|
scrape_interval: 30s
|
||||||
|
scrape_timeout: 3s
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["<gateway>:3456"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
||||||
|
|
||||||
|
Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход.
|
||||||
|
|
||||||
|
Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
||||||
|
|
||||||
|
В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; данные всё равно хранятся в памяти только до перезапуска, а историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`.
|
||||||
|
|
||||||
|
Состояние collector и сравнение `shadow` экспортируются отдельными bounded gauges `harbor_traffic_collector_*` и `harbor_traffic_shadow_*`. Они не содержат UUID, IP, домены или пользовательские имена и не заменяют canonical traffic counters.
|
||||||
|
|
||||||
|
Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split.
|
||||||
|
|
||||||
|
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
||||||
|
|
||||||
|
## Если что-то не работает
|
||||||
|
|
||||||
|
### Интерфейс не открывается
|
||||||
|
|
||||||
|
Проверьте контейнер и журнал:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.gateway.yml ps
|
||||||
|
docker compose -f docker-compose.gateway.yml logs --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Для Connect замените имя файла на `docker-compose.client.yml` и выполняйте команду из `~/.vpn-proxy-client`.
|
||||||
|
|
||||||
|
### Прокси не отвечает
|
||||||
|
|
||||||
|
Убедитесь, что Harbor включён в интерфейсе, а приложение использует правильные адрес и порт. Для Connect это обычно `127.0.0.1:8082`; для Gateway — IP Linux-машины и порт `8080`.
|
||||||
|
|
||||||
|
### Connect не распознаёт Gateway
|
||||||
|
|
||||||
|
Проверьте три условия:
|
||||||
|
|
||||||
|
- Gateway является текущим основным шлюзом Mac;
|
||||||
|
- на обоих устройствах сохранена одна и та же подписка;
|
||||||
|
- с Mac открывается `http://АДРЕС-GATEWAY:3456`.
|
||||||
|
|
||||||
|
### Проверка конфигурации без запуска
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.gateway.yml config
|
||||||
|
docker compose -f docker-compose.client.yml config
|
||||||
|
docker compose -f docker-compose.client.local.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose.
|
||||||
|
|
||||||
|
### Локальное тестирование Harbor Connect
|
||||||
|
|
||||||
|
Тестовый Connect запускается рядом с установленным клиентом и использует отдельные контейнер, volumes и порты:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.client.local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить тестовый стек с сохранением его volumes можно командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.client.local.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
|
||||||
|
|
||||||
|
Для rollback canary на стабильный sing-box без инспектора используйте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SINGBOX_VERSION=1.13.18 \
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=disabled \
|
||||||
|
docker compose -f docker-compose.client.local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Не добавляйте `-v` к `down`, если хотите сохранить тестовые подписки и настройки.
|
||||||
|
|
||||||
|
## Служебные команды
|
||||||
|
|
||||||
|
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
|
||||||
|
|
||||||
|
### Команды npm
|
||||||
|
|
||||||
|
| Команда | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
|
||||||
|
| `npm test` | Запустить автоматические проверки |
|
||||||
|
| `npm run build` | Собрать веб-интерфейс в `dist/` |
|
||||||
|
| `npm run dev` | Запустить Vite для разработки интерфейса |
|
||||||
|
| `npm start` | Запустить управляющий Node.js-сервис в подготовленном окружении |
|
||||||
|
|
||||||
|
### Сборка и развёртывание
|
||||||
|
|
||||||
|
| Команда | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| `./scripts/build-runtime-base.sh` | Собрать базовый runtime-образ с Node.js, сетевыми утилитами и `sing-box` |
|
||||||
|
| `./scripts/build-on-107-deploy-111.sh` | Собрать Gateway на хосте `107` и развернуть на хосте `111`; хосты меняются через `BUILD_HOST` и `DEPLOY_HOST` |
|
||||||
|
| `GATEWAY_IMAGE=<образ> ./scripts/deploy-gateway.sh` | Развернуть уже собранный образ в `/opt/vpn-proxy` |
|
||||||
|
| `./scripts/harbor-network-monitor.sh` | Один раз записать текущий Gateway macOS; обычно этот скрипт запускает установленный LaunchAgent |
|
||||||
|
|
||||||
|
Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Каждый деплой пересоздаёт `vpn-proxy-control`, поэтому строка `B` соответствует текущему коду API. Процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`; он пересоздаётся только при изменении его runtime-зависимостей, а его фактическая версия показывается отдельно как `D`.
|
||||||
|
|
||||||
|
## Хранение данных
|
||||||
|
|
||||||
|
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
|
||||||
|
|
||||||
|
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
version: v2
|
||||||
|
clean: true
|
||||||
|
inputs:
|
||||||
|
- directory: proto/sing-box/v1.14.0-rc.5
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-es
|
||||||
|
out: src/server/generated
|
||||||
|
opt:
|
||||||
|
- target=ts
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
# Read-only аудит failover/failback
|
||||||
|
|
||||||
|
## Короткий вывод
|
||||||
|
|
||||||
|
Штатное переключение между `primary` и `reserve` меняет маршрут **только для новых соединений**. Уже установленные TCP/UDP-соединения не переносятся и не закрываются самим Harbor.
|
||||||
|
|
||||||
|
Поэтому:
|
||||||
|
|
||||||
|
- здоровая загрузка, игра или поток продолжаются через старый канал;
|
||||||
|
- если старый канал действительно умер, существующая сессия может оборваться независимо от переключения;
|
||||||
|
- после переключения новые соединения идут через новый канал;
|
||||||
|
- бесшовной миграции уже открытого TCP/UDP-сеанса на другой внешний адрес нет.
|
||||||
|
|
||||||
|
## 1. Точный механизм
|
||||||
|
|
||||||
|
### Конфигурация
|
||||||
|
|
||||||
|
Gateway собирает один dual-channel `sing-box` config:
|
||||||
|
|
||||||
|
- `channel-primary`;
|
||||||
|
- `channel-reserve`;
|
||||||
|
- selector `channel-selector`;
|
||||||
|
- стабильные `tproxy-in` и `mixed-in`;
|
||||||
|
- отдельные diagnostic inbounds для проверки каждого канала.
|
||||||
|
|
||||||
|
Пользовательский трафик направляется в selector, а selector настроен с:
|
||||||
|
|
||||||
|
```text
|
||||||
|
interrupt_exist_connections: false
|
||||||
|
```
|
||||||
|
|
||||||
|
См. `src/server/singbox.ts:204-247`.
|
||||||
|
|
||||||
|
Роль меняется через localhost Clash API:
|
||||||
|
|
||||||
|
- `PUT /proxies/channel-selector`;
|
||||||
|
- затем Harbor читает selector обратно и подтверждает выбранную роль.
|
||||||
|
|
||||||
|
См. `src/server/services/singboxSelectorService.ts:29-81`.
|
||||||
|
|
||||||
|
Failover API доступен только в Gateway:
|
||||||
|
|
||||||
|
- `PUT /api/failover`;
|
||||||
|
- `POST /api/failover/pause`;
|
||||||
|
- `POST /api/failover/switch`;
|
||||||
|
- `POST /api/failover/check`.
|
||||||
|
|
||||||
|
См. `src/server/http/routes/failoverRoute.ts:18-39`.
|
||||||
|
|
||||||
|
Переключение selector не вызывает `sing-box` restart/apply/stop.
|
||||||
|
|
||||||
|
### Автоматический failover
|
||||||
|
|
||||||
|
`FailoverService`:
|
||||||
|
|
||||||
|
1. Проверяет оба канала через отдельные diagnostic inbound и выбранные HTTPS-сервисы.
|
||||||
|
2. Считает канал healthy только если все проверки успешны; неизвестный результат даёт `unknown`.
|
||||||
|
3. При сбое primary ждёт `failureWindowMs`.
|
||||||
|
4. Переключается на reserve только если reserve healthy.
|
||||||
|
5. При включённом traffic guard ждёт свежий quiet-window.
|
||||||
|
6. Перед самой сменой повторно проверяет здоровье и активность.
|
||||||
|
7. Выполняет selector PUT, read-back и только после этого обновляет canonical applied state.
|
||||||
|
|
||||||
|
См. `src/server/features/failover/failoverService.ts:225-245`, `:320-514`; state machine — `src/shared/failover.ts:278-354`.
|
||||||
|
|
||||||
|
Дефолты:
|
||||||
|
|
||||||
|
- проверка каждые 60 секунд;
|
||||||
|
- сбой primary — 120 секунд;
|
||||||
|
- восстановление primary — 15 минут;
|
||||||
|
- quiet-window — 30 секунд;
|
||||||
|
- активный трафик — выше 32 КБ/с;
|
||||||
|
- минимум на reserve — 10 минут;
|
||||||
|
- после 3 failover за 24 часа — карантин primary на 6 часов.
|
||||||
|
|
||||||
|
См. `src/shared/failover.ts:137-148`.
|
||||||
|
|
||||||
|
### Автоматический failback
|
||||||
|
|
||||||
|
Отдельной реализации нет: это обратная ветка той же state machine.
|
||||||
|
|
||||||
|
Из reserve Harbor возвращается на primary только после:
|
||||||
|
|
||||||
|
- полного `recoveryWindowMs`;
|
||||||
|
- `minimumReserveMs`;
|
||||||
|
- окончания quarantine, если он действует;
|
||||||
|
- quiet-window при включённом traffic guard;
|
||||||
|
- подтверждения, что primary healthy.
|
||||||
|
|
||||||
|
Причина переключения публикуется как `primary-recovered`. См. `src/shared/failover.ts:311-354`.
|
||||||
|
|
||||||
|
## 2. Судьба существующих соединений
|
||||||
|
|
||||||
|
| Событие | Уже открытый TCP/UDP flow | Новые соединения |
|
||||||
|
|---|---|---|
|
||||||
|
| Автоматический failover primary → reserve | Остаётся на прежнем outbound; Harbor его не закрывает и не мигрирует | Идут через reserve |
|
||||||
|
| Автоматический failback reserve → primary | Остаётся на reserve | Идут через primary |
|
||||||
|
| Ручной selector switch | Не закрывается, если старый outbound ещё работает | Сразу идёт через выбранную роль |
|
||||||
|
| Pause | Ничего не меняет | Идут через текущую роль |
|
||||||
|
| Disable failover | Selector и текущий маршрут не меняются; dual config временно остаётся загруженным | Идут через текущую роль |
|
||||||
|
| Обычный stop/restart/config replacement | Процесс `sing-box` останавливается, поэтому TCP/UDP-сессии прерываются | После запуска — по новой конфигурации |
|
||||||
|
| Реальная авария primary | Уже существующий flow может оборваться сам; Harbor не может перенести его на другой внешний IP | После selector switch новые flow идут через reserve |
|
||||||
|
|
||||||
|
Проверка `interrupt_exist_connections: false` непосредственно подтверждена TCP- и UDP-fixture-тестом: существующие TCP socket и UDP association продолжают обмен после switch, а новые идут через reserve. См. `test/server/singbox-selector-capability.test.js:141-142`, `:237-356`.
|
||||||
|
|
||||||
|
## 3. Отличия режимов
|
||||||
|
|
||||||
|
### Ручное переключение
|
||||||
|
|
||||||
|
`POST /api/failover/switch` вызывает selector напрямую:
|
||||||
|
|
||||||
|
- traffic guard не проверяется;
|
||||||
|
- состояние здоровья целевого канала backend не проверяет;
|
||||||
|
- после успешного ручного переключения `failoverPolicy.paused` становится `true`;
|
||||||
|
- автоматический failback не произойдёт, пока пользователь не возобновит автоматическое переключение.
|
||||||
|
|
||||||
|
См. `src/server/features/failover/failoverService.ts:248-317`, `:614-631`.
|
||||||
|
|
||||||
|
Это означает, что ручной switch может быть выполнен даже на канал, который сейчас не подтверждён healthy. Это важная оговорка.
|
||||||
|
|
||||||
|
### Автоматический failover
|
||||||
|
|
||||||
|
Автоматическое переключение:
|
||||||
|
|
||||||
|
- ждёт failure window;
|
||||||
|
- требует healthy reserve;
|
||||||
|
- при включённом guard блокируется активным или неизвестным трафиком;
|
||||||
|
- повторно валидирует условия непосредственно перед selector mutation;
|
||||||
|
- не перезапускает `sing-box`.
|
||||||
|
|
||||||
|
### Восстановление primary
|
||||||
|
|
||||||
|
Восстановившийся primary не получает новые соединения сразу. Сначала выдерживаются recovery/hold/quarantine условия и quiet-window. Пока они не выполнены, новые подключения остаются на reserve.
|
||||||
|
|
||||||
|
### Pause и Disable
|
||||||
|
|
||||||
|
`pause` приостанавливает решения, но dual config и наблюдение остаются активными.
|
||||||
|
|
||||||
|
`disable` останавливает scheduler, probes и failover activity collector, но не переключает selector и не перезапускает процесс. После обычного stop/следующего запуска собирается single-channel config.
|
||||||
|
|
||||||
|
См. `README.md:78-84`, `docs/product/application-state.md:102-110`.
|
||||||
|
|
||||||
|
## 4. Практические сценарии
|
||||||
|
|
||||||
|
- **Загрузка файла:** при обычном автоматическом failover активная передача по умолчанию задерживает switch. Если primary всё же упал, текущая TCP-загрузка не переносится на reserve; она может завершиться ошибкой. Возобновление или новый HTTP-запрос после switch пойдёт через reserve.
|
||||||
|
- **Игровая сессия:** существующий TCP-сеанс остаётся на старом канале. TCP-сессия оборвётся, если primary реально недоступен. UDP-flow также не мигрирует и может начать терять пакеты или истечь по timeout; новая сессия после switch пойдёт через reserve.
|
||||||
|
- **Стрим:** активный поток обычно блокирует автоматический switch при включённом guard. Ручной switch может быть выполнен сразу, но существующий TCP/QUIC-поток остаётся на старом outbound. При аварии старого канала плеер должен переподключиться.
|
||||||
|
- **WebSocket/долгий polling:** действующий flow не переносится; новые подключения после switch используют новую роль.
|
||||||
|
- **Молчащее соединение:** наличие открытого socket само по себе не считается активностью. Guard смотрит на дельты переданных байтов.
|
||||||
|
|
||||||
|
## 5. Условия и оговорки
|
||||||
|
|
||||||
|
- Failover реализован только для **Gateway**, не для локального Connect или `gateway-direct`.
|
||||||
|
- Активность собирается существующим `/connections` observer каждые 2 секунды, с bounded окном до 10 секунд. См. `src/server/index.ts:311-333`, `src/server/services/domainTrafficService.ts:323-416`, `:437-495`.
|
||||||
|
- Короткое соединение, полностью завершившееся между двумя снимками, может не попасть в activity guard.
|
||||||
|
- Неизвестная или устаревшая activity-информация блокирует автоматический switch; ручной switch остаётся доступен.
|
||||||
|
- Отключение traffic guard разрешает автоматический switch без ожидания тишины, но `interrupt_exist_connections: false` всё равно защищает уже открытые connections от закрытия самим selector.
|
||||||
|
- При изменении policy во время работающего single-channel VPN dual config становится `pending`; скрытого restart нет. См. `src/server/features/connection/connectionService.ts:140-201`, `:288-361`.
|
||||||
|
- Явный stop/restart или обычная смена сервера вне активного failover уже является disruptive operation: `sing-box` получает SIGTERM и текущие сессии прекращаются. См. `src/server/singboxRuntime.ts:39-62`, `:65-122`.
|
||||||
|
- Оба канала находятся в одном процессе `sing-box`; process-wide crash не защищён selector-механизмом.
|
||||||
|
|
||||||
|
## 6. Основные файлы
|
||||||
|
|
||||||
|
- `src/server/singbox.ts:204-247` — dual config, selector, inbound routing.
|
||||||
|
- `src/server/services/singboxSelectorService.ts:29-81` — selector PUT/read-back.
|
||||||
|
- `src/server/features/failover/failoverService.ts:225-245` — проверки каналов.
|
||||||
|
- `src/server/features/failover/failoverService.ts:248-317` — selector switch, commit и rollback.
|
||||||
|
- `src/server/features/failover/failoverService.ts:320-514` — автоматический раунд и traffic guard.
|
||||||
|
- `src/server/features/failover/failoverService.ts:614-631` — ручное переключение.
|
||||||
|
- `src/shared/failover.ts:137-148`, `:278-354` — дефолты и state machine.
|
||||||
|
- `src/server/services/domainTrafficService.ts:129-160`, `:323-416`, `:437-495` — классификация и activity.
|
||||||
|
- `src/server/features/connection/connectionService.ts:140-201`, `:243-361` — обычный apply/stop/restart.
|
||||||
|
- `src/server/singboxRuntime.ts:39-122` — фактическая остановка и перезапуск процесса.
|
||||||
|
- `README.md:78-84` — пользовательская документация.
|
||||||
|
- `docs/product/application-state.md:102-110` — контракт состояния.
|
||||||
|
- `workpack/tasks/TASK-021-auto-server-selection-failover.md:93-106`, `:187-204` — относящийся план и ограничения; задача не выбиралась и не изменялась.
|
||||||
|
|
||||||
|
## 7. Тесты, подтверждающие выводы
|
||||||
|
|
||||||
|
- `test/server/singbox-selector-capability.test.js:141-356`
|
||||||
|
Интеграционная TCP/UDP-проверка: активный трафик задерживает failover, существующие TCP/UDP продолжают работать после switch, новые соединения идут через reserve, PID процесса не меняется. Тест opt-in и пропускается без `HARBOR_SINGBOX_IMAGE`.
|
||||||
|
- `test/server/singbox-gateway-mode.test.js:69-110`
|
||||||
|
Проверяет selector, `interrupt_exist_connections: false`, отдельные diagnostic routes и primary/reserve outbounds.
|
||||||
|
- `test/server/failover-service.test.js:83-200`
|
||||||
|
Failure window, traffic guard, both-unhealthy и failback recovery/hold/quarantine.
|
||||||
|
- `test/server/failover-service.test.js:255-420`
|
||||||
|
Disabled zero-work, отмена устаревших наблюдений и непосредственная revalidation активности.
|
||||||
|
- `test/server/failover-service.test.js:423-520`
|
||||||
|
Selector rollback, commit ordering и ручной switch с pause.
|
||||||
|
- `test/server/domain-traffic.test.js:209-239`
|
||||||
|
Activity считается по пользовательскому VPN traffic, diagnostic connection не блокирует switch.
|
||||||
|
- `test/server/connection-service.test.js:265-310`, `:340-390`
|
||||||
|
Restart dual config и rollback; pending edits для работающего single-channel.
|
||||||
|
- `test/server/singbox-runtime.test.js:16-50`
|
||||||
|
При изменении config runtime запускает новый процесс.
|
||||||
|
- `test/server/failover-route.test.js:10-53`
|
||||||
|
Gateway-only API и маршруты ручного switch/pause/check.
|
||||||
|
- `test/web/failover-feature-contract.test.js:19-76`
|
||||||
|
UI явно сообщает: новые подключения переключаются, открытые остаются на прежнем канале.
|
||||||
|
|
||||||
|
## Review findings и residual risks
|
||||||
|
|
||||||
|
- **medium — `src/server/features/failover/failoverService.ts:614-620`:** ручной switch не проверяет health целевого канала и не применяет traffic guard; он сразу меняет selector и ставит automation на pause.
|
||||||
|
- **medium — `test/server/singbox-selector-capability.test.js:141-356`:** capability test opt-in и использует deterministic `direct` outbounds, а не реальный VLESS/Trojan outage.
|
||||||
|
- **medium — `src/server/services/domainTrafficService.ts:414-416`, `:451-479`:** activity основана на polling и byte deltas; короткие или очень малые потоки могут не блокировать автоматическое решение.
|
||||||
|
- **info — `src/server/singboxRuntime.ts:39-122`:** явный stop/restart отличается от selector switch и прерывает существующие соединения.
|
||||||
|
- **info — архитектура одного процесса:** падение всего `sing-box` не компенсируется selector failover.
|
||||||
|
|
||||||
|
Файлы не изменялись. Тесты и live Gateway в рамках read-only аудита не запускались.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: harbor-connect-local
|
||||||
|
|
||||||
|
services:
|
||||||
|
harbor-connect:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.client.yml
|
||||||
|
service: harbor-connect
|
||||||
|
container_name: harbor-connect-local
|
||||||
|
environment:
|
||||||
|
PORT: ${LOCAL_CLIENT_UI_PORT:-3457}
|
||||||
|
PROXY_PORT: ${LOCAL_CLIENT_PROXY_PORT:-8083}
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}:${LOCAL_CLIENT_UI_PORT:-3457}"
|
||||||
|
- "127.0.0.1:${LOCAL_CLIENT_PROXY_PORT:-8083}:${LOCAL_CLIENT_PROXY_PORT:-8083}"
|
||||||
|
volumes: !override
|
||||||
|
- vpn-proxy-client-local-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-client-local-cache:/var/lib/sing-box
|
||||||
|
- ./.runtime:/run/harbor-host:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}/api/state"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-client-local-data:
|
||||||
|
sing-box-client-local-cache:
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
harbor-connect:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.client
|
||||||
|
args:
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
|
||||||
|
container_name: harbor-connect
|
||||||
|
environment:
|
||||||
|
APP_MODE: client
|
||||||
|
PORT: ${PORT:-3456}
|
||||||
|
PROXY_PORT: ${CLIENT_PROXY_PORT:-8082}
|
||||||
|
PROXY_BIND_IP: 0.0.0.0
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
|
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
|
||||||
|
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
ALL_PROXY: ""
|
||||||
|
http_proxy: ""
|
||||||
|
https_proxy: ""
|
||||||
|
all_proxy: ""
|
||||||
|
NO_PROXY: "localhost,127.0.0.1,host.docker.internal"
|
||||||
|
no_proxy: "localhost,127.0.0.1,host.docker.internal"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||||
|
- "127.0.0.1:${CLIENT_PROXY_PORT:-8082}:${CLIENT_PROXY_PORT:-8082}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-client-cache:/var/lib/sing-box
|
||||||
|
- ./.runtime:/run/harbor-host:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-client-data:
|
||||||
|
sing-box-client-cache:
|
||||||
@@ -1,24 +1,76 @@
|
|||||||
|
x-gateway-image: &gateway-image
|
||||||
|
image: ${GATEWAY_IMAGE:-vpn-proxy-gateway:local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
|
||||||
|
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
|
||||||
|
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
vpn-proxy-gateway:
|
vpn-proxy-dataplane:
|
||||||
build:
|
<<: *gateway-image
|
||||||
context: .
|
container_name: vpn-proxy-dataplane
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: vpn-proxy-gateway
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- path: .env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
|
APP_COMPONENT: dataplane
|
||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
|
||||||
|
SING_BOX_API_SECRET: /var/lib/sing-box/api.secret
|
||||||
|
SING_BOX_RUNTIME_CONFIG: /var/lib/sing-box/runtime-config.json
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
volumes:
|
volumes:
|
||||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
- sing-box-cache:/var/lib/sing-box
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--unix-socket", "/run/vpn-proxy/dataplane.sock", "-fsS", "http://localhost/status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
vpn-proxy-control:
|
||||||
|
<<: *gateway-image
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
APP_COMPONENT: control
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3456}:${PORT:-3456}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
|
depends_on:
|
||||||
|
vpn-proxy-dataplane:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
vpn-proxy-data:
|
vpn-proxy-data:
|
||||||
sing-box-cache:
|
sing-box-cache:
|
||||||
|
vpn-proxy-runtime:
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Harbor responsive layout
|
||||||
|
|
||||||
|
The client page has three stable regions: the Harbor brand, the primary power control and the subscription/details form.
|
||||||
|
|
||||||
|
## Desktop
|
||||||
|
|
||||||
|
At widths above 920 px the main panel uses a symmetric three-column grid. Equal outer columns keep the power control on the exact horizontal center axis; the details form occupies the right column. The form has a viewport-relative maximum height and its own vertical scroll for unusually long content, so a large server list cannot move the power control away from the visual center.
|
||||||
|
|
||||||
|
The no-subscription setup state collapses the panel to one column and centers the form. No `left` offset or translated absolute element participates in either layout.
|
||||||
|
|
||||||
|
## Tablet and mobile
|
||||||
|
|
||||||
|
At 920 px and below all main regions use one normal-flow grid column. The brand becomes an absolute header inside the page shell, while reserved top padding prevents it from overlapping the primary content. Errors become normal-flow rows instead of floating over nearby controls. Form width is capped by both the available space and a readable maximum.
|
||||||
|
|
||||||
|
At 560 px and below spacing and drawer padding become more compact. Controls with a preferred fixed size, such as the duration switch, use `min(..., 100%)` so the primary flow remains available at 320 px.
|
||||||
|
|
||||||
|
## Motion and overflow contract
|
||||||
|
|
||||||
|
Layout properties are not animated. State feedback may animate opacity and blur, and the existing `prefers-reduced-motion` rules disable those transitions and animations. Drawers are capped at `100vw`; dialogs and inline content retain viewport-relative width limits.
|
||||||
|
|
||||||
|
Automated source-contract tests protect the symmetric desktop grid, normal-flow narrow layout, viewport-safe control widths and reduced-motion fallback. Release acceptance still includes a rendered check at 390, 768 and 1440 px; TASK-017 will later make that browser matrix automatic in CI.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Harbor application state v1
|
||||||
|
|
||||||
|
`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is `state.json` schema v8; React keeps only drafts, disclosure, focus, animation and transport freshness.
|
||||||
|
|
||||||
|
An abbreviated snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiVersion": 1,
|
||||||
|
"revision": 42,
|
||||||
|
"mode": "client",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"id": "profile_primary",
|
||||||
|
"label": "Личный",
|
||||||
|
"subscription": {
|
||||||
|
"status": "ready",
|
||||||
|
"host": "provider.example/…",
|
||||||
|
"fetchedAt": "2026-08-11T12:00:00.000Z",
|
||||||
|
"userInfo": {},
|
||||||
|
"lastRefreshAttemptAt": null,
|
||||||
|
"errorCode": null
|
||||||
|
},
|
||||||
|
"desiredServerId": "srv_amsterdam",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "srv_amsterdam",
|
||||||
|
"label": "Amsterdam",
|
||||||
|
"host": "nl.example.net",
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "vless"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selection": {
|
||||||
|
"desiredProfileId": "profile_primary",
|
||||||
|
"desiredServerId": "srv_amsterdam",
|
||||||
|
"appliedProfileId": "profile_primary",
|
||||||
|
"appliedServerId": "srv_amsterdam",
|
||||||
|
"appliedServerSnapshot": {
|
||||||
|
"id": "srv_amsterdam",
|
||||||
|
"label": "Amsterdam",
|
||||||
|
"host": "nl.example.net",
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "vless"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"kind": null,
|
||||||
|
"status": "idle",
|
||||||
|
"startedAt": null,
|
||||||
|
"error": null,
|
||||||
|
"profileId": null,
|
||||||
|
"serverId": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every profile owns one private provider URL/config, public metadata, server list and desired server. The URL/config never enters the public snapshot. `subscription` and top-level `servers` remain a one-release projection of the desired profile for older clients; they are not a second owner.
|
||||||
|
|
||||||
|
## Revision rules
|
||||||
|
|
||||||
|
`revision` increases for every visible domain transition, including operation start, completion and failure. Commands carry `expectedRevision`; stale commands fail with `STATE_CONFLICT`. Duplicate profile labels are rejected by preflight without a provider request or revision change.
|
||||||
|
|
||||||
|
The frontend accepts only newer snapshots. Equal revisions preserve object identity, and older polling responses cannot overwrite mutation results. After a failed mutation the browser immediately synchronizes the authoritative snapshot before allowing another command or retry.
|
||||||
|
|
||||||
|
Browser boot/offline/stale state remains a transport envelope beside the domain snapshot. A transport failure retains the last accepted domain state.
|
||||||
|
|
||||||
|
Failover health and traffic observations are transient: they do not write `state.json` or increase the domain `revision` every few seconds. Each control-process lifetime publishes a new `observationEpoch` and increasing `observationSequence`. At the same domain revision the browser accepts only a newer sequence from the active epoch; after accepting a new epoch it retires the old one so a late response cannot restore stale health.
|
||||||
|
|
||||||
|
## Desired and applied identity
|
||||||
|
|
||||||
|
`desiredProfileId` and each profile's `desiredServerId` record the next local choice. `appliedProfileId`, `appliedServerId` and `appliedServerSnapshot` describe the runtime that actually owns traffic. There is no third `activeProfileId`.
|
||||||
|
|
||||||
|
While stopped, selecting or activating a profile only updates desired state. While running, changing the applied profile/server builds a candidate config, starts it, then publishes desired and applied identity in one final state commit. Until that commit the old applied pair remains authoritative. A failure restores the previous config, runtime and state.
|
||||||
|
|
||||||
|
If refresh removes the applied server, the running process is not silently switched. The provider list and desired selection are cleared as needed, while `appliedServerSnapshot` retains the last applied label until explicit stop or a successful switch. Stop clears applied identity and keeps the desired pair.
|
||||||
|
|
||||||
|
## Profile operations
|
||||||
|
|
||||||
|
The canonical API is scoped by profile:
|
||||||
|
|
||||||
|
- `POST /api/profiles` adds a profile after one explicit provider fetch;
|
||||||
|
- `PATCH /api/profiles/:id` renames it locally;
|
||||||
|
- `PUT /api/profiles/:id/server` selects one of its servers;
|
||||||
|
- `POST /api/profiles/:id/activate` activates/switches it;
|
||||||
|
- `POST /api/profiles/:id/refresh` refreshes only that provider;
|
||||||
|
- `DELETE /api/profiles/:id` deletes it, with explicit `stop-and-delete` for a running applied profile;
|
||||||
|
- `POST /api/profiles/:id/servers/ping` performs bounded transient health checks.
|
||||||
|
|
||||||
|
Provider failure retains the last successful list and metadata, marks only the target profile stale and records the last successful timestamp. Refreshing, pinging or deleting an inactive profile does not mutate the applied config/runtime. Background refresh iterates profiles independently every 15 minutes.
|
||||||
|
|
||||||
|
## Ordered routing rules
|
||||||
|
|
||||||
|
`route.localRules` is the desired ordered list. Every rule has an explicit `outbound: "vpn" | "direct"`; the first enabled matcher wins and disabled rules retain their position without entering the generated config. `route.activeLocalRules` is the exact canonical list used to generate the running rules-enabled config, not a second desired owner.
|
||||||
|
|
||||||
|
The route-rules mutation uses the whole-array `PUT /api/route-rules/v2` with `rulesContractVersion: 2` and `expectedRulesRevision`. Contract v2 requires an explicit outbound on every rule. The versioned path prevents a stale v2 tab from writing to a rolled-back v1 backend; the legacy path on a v2 backend rejects its payload without changing state, config or runtime. A client that receives a snapshot without capability version 2 can read legacy rules as direct but keeps the editor read-only.
|
||||||
|
|
||||||
|
In Connect `gateway-direct`, local user rules are intentionally omitted and the snapshot reports no active or pending local rules. Gateway device policy `Напрямую` bypasses sing-box before these rules; policy `VPN` and an ordinary local/Gateway VPN pipeline evaluate them.
|
||||||
|
|
||||||
|
## Gateway failover and activity journal
|
||||||
|
|
||||||
|
`failoverPolicy` is the desired Gateway-only policy: master enable, primary/reserve profile and server, service checks with individual timeouts, health windows, active-traffic guard and flap protection. `failoverRuntimeState` stores switch history, hold and quarantine deadlines separately, so a runtime decision is not mistaken for a desired configuration change. `appliedFailoverPolicy` stores only the two loaded targets and safe configuration fingerprints.
|
||||||
|
|
||||||
|
Enabling failover while VPN is stopped validates a temporary dual-channel candidate but does not start VPN. The dual config is loaded only by the next explicit power-on. Enabling it over a running single-channel config remains pending until a later stop and power-on. Disabling automation stops its timer, probes and activity collector immediately, but does not restart sing-box or change the selected route; the already loaded dual config is reported as `passive-loaded` until the ordinary stop lifecycle clears it.
|
||||||
|
|
||||||
|
The dual config keeps one stable inbound and a sing-box selector with `interrupt_exist_connections: false`. A switch changes the outbound for new connections only. Before an automatic switch, the existing `/connections` observer measures VPN byte deltas over a bounded 10-second window. Active or unknown traffic blocks the switch; the public snapshot contains only aggregate speed, connection count and at most three safe device/service labels.
|
||||||
|
|
||||||
|
Failover mutations use `PUT /api/failover`, `POST /api/failover/pause` and `POST /api/failover/switch`. Important user events are stored separately in `activity-journal.json` and read through `GET /api/activity-journal`. The journal is not a second state owner, contains no provider URLs or raw diagnostics, uses stable ID cursors and prunes entries after 30 days.
|
||||||
|
|
||||||
|
## Compatibility and migration
|
||||||
|
|
||||||
|
Schema v5 migrates the legacy singleton and `subscription-cache.json` into one profile named `Основной`. Stable endpoint identity preserves unambiguous desired/applied selection, including transport variants whose normalized IDs differ from old labels. An explicitly stopped legacy state does not resurrect an old applied target.
|
||||||
|
|
||||||
|
Schema v6 adds the routing-rule outbound. Rules read from schemas v0-v5 migrate to `outbound: "direct"` in their existing order and both desired/applied arrays are normalized together. A schema-v6 rule without a valid outbound is rejected rather than silently rewritten. Schema v7 adds canonical connectivity-diagnostics settings. Schema v8 adds a disabled failover policy, empty runtime history and no applied dual config, so upgrading does not start monitoring or change traffic.
|
||||||
|
|
||||||
|
Migration atomically backs up the previous `state.json`. After the embedded profile is committed, Harbor also backs up and removes the legacy subscription cache so there is one persisted owner. Invalid legacy cache/config returns to a truthful stopped first-run state instead of starting stale generated config.
|
||||||
|
|
||||||
|
The old HTTP projection remains bounded for one release. Schema v8 persistence is not downgrade-compatible: stop Harbor and restore the `state.json.backup-v<fromVersion>-*` matching the rollback binary instead of deploying old code over v8 data. Rolling back before profiles still also requires the matching legacy subscription-cache backup.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Data consistency regression suite
|
||||||
|
|
||||||
|
`npm test` is the required fast regression gate. It uses generated fixtures, temporary directories, loopback HTTP servers and a fake sing-box executable; it does not require internet, root or an installed sing-box.
|
||||||
|
|
||||||
|
The protected invariants are:
|
||||||
|
|
||||||
|
| Invariant | Regression coverage |
|
||||||
|
|---|---|
|
||||||
|
| One backend canonical snapshot owns servers and desired/applied selection | `test/data-consistency-regression.test.js`, `test/server/state-contract.test.js` |
|
||||||
|
| Revisions increase and an older response cannot replace newer state | `test/server/state-contract.test.js`, `test/web/harbor-state.test.js` |
|
||||||
|
| Provider failure, parser failure and runtime failure do not partially commit subscription state | `test/server/state-contract.test.js` |
|
||||||
|
| Atomic write failure preserves the last file and corrupt JSON preserves its original bytes | `test/server/state-store.test.js` |
|
||||||
|
| Legacy state migrates with an explicit result for ambiguous selection | `test/server/state-store.test.js` |
|
||||||
|
| Stable IDs survive reorder and duplicate labels for 1, 30 and 300 servers | `test/data-consistency-regression.test.js`, `test/server/subscription.test.js` |
|
||||||
|
| Initial control outage does not invent domain state; repeated failures retain and mark the last snapshot stale | `test/web/harbor-state.test.js` |
|
||||||
|
|
||||||
|
Fixtures are generated in test code to keep the suite small and deterministic. Packet-level networking, browser screenshots and accessibility automation are intentionally deferred to their dedicated roadmap tasks.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Harbor error contract v1
|
||||||
|
|
||||||
|
Public API failures use one envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "PROVIDER_UNAVAILABLE",
|
||||||
|
"message": "Провайдер подписки временно недоступен.",
|
||||||
|
"retryable": true,
|
||||||
|
"correlationId": "6f1a63de-30f9-4dc5-b8ce-38d38c164fe3",
|
||||||
|
"details": "HTTP 503"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`code`, Russian user copy, HTTP status and retry policy come from `src/shared/errors.ts`. The browser maps copy and retry behavior by `code`; it does not display server-provided `details`. Unknown failures use `UNKNOWN`, never expose the raw exception, and always receive a correlation reference. Server logs use the same reference and redact complete HTTP(S) URLs.
|
||||||
|
|
||||||
|
Errors are local operation results, not canonical state replacements. A failed apply keeps the previous snapshot; in particular, server existence is validated before `desiredServerId` is persisted. Frontend errors are shown beside subscription or connection controls. Only retryable codes expose `Повторить`.
|
||||||
|
|
||||||
|
This is a coordinated API change: old frontends do not understand the object-valued `error` field, so frontend and control plane must be deployed together. Persisted files and volumes are unchanged. Rollback is code-only and requires no data migration.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Frontend operation registry
|
||||||
|
|
||||||
|
Harbor tracks active browser mutations by operation key instead of one global `busy` flag:
|
||||||
|
|
||||||
|
- `connection`: start, stop and restart;
|
||||||
|
- `serverApply`: apply a `(profileId, serverId)` pair;
|
||||||
|
- `profileAdd`, `profileRename`, `profileSelect`, `profileActivate`, `profileRefresh`, `profileDelete`;
|
||||||
|
- `gatewayAuto`: change the active route preference.
|
||||||
|
|
||||||
|
Each entry is `{ status: "running", startedAt }`. A repeated operation key receives the same in-flight Promise, so a double click sends one request. A conflicting key resolves to `false` without starting its action. The symmetric conflict matrix lives in `src/web/state/operations.ts`.
|
||||||
|
|
||||||
|
The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure.
|
||||||
|
|
||||||
|
Subscription URL validation is local and accepts only well-formed `http` and `https` URLs. It does not contact the provider; explicit profile add performs the single provider request and reports provider failures at that profile.
|
||||||
|
|
||||||
|
The registry is local transport/UI state for immediate feedback. It does not replace backend `snapshot.operation`, which preserves the target across polling, reloads and other windows. A `diagnostics` key is intentionally deferred until diagnostics become a conflicting mutation.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Harbor state recovery
|
||||||
|
|
||||||
|
Harbor keeps the existing data directory and `state.json` path. The current persisted format is `schemaVersion: 8`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, v5 embeds the canonical `profiles[]` collection with desired/applied profile identity, v6 adds an explicit `vpn` or `direct` outbound to every route rule, v7 stores connectivity-diagnostics settings, and v8 adds Gateway failover state.
|
||||||
|
|
||||||
|
## Atomic writes
|
||||||
|
|
||||||
|
Persistent files are written to a unique temporary file in the same directory, flushed with `fsync`, closed and atomically renamed over the target. A failure before rename leaves the previous target untouched and removes the temporary file.
|
||||||
|
|
||||||
|
Profile/server switching prepares candidate config and runtime before the final state publication. If any later step fails, Harbor restores the previous config, runtime and canonical state.
|
||||||
|
|
||||||
|
## Migration to profiles
|
||||||
|
|
||||||
|
On startup, a legacy state is normalized before the process starts. Harbor creates one profile named `Основной`, moves the provider URL/config and metadata into it, and preserves unambiguous desired/applied server identity. A legacy state explicitly marked stopped clears stale applied residue.
|
||||||
|
|
||||||
|
Before replacing state Harbor saves the original beside it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
state.json.backup-v4-2026-08-11T12-00-00-000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
After a valid profile has been committed, the raw legacy cache is saved and removed as a second owner:
|
||||||
|
|
||||||
|
```text
|
||||||
|
subscription-cache.json.backup-v1-2026-08-11T12-00-00-000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
An invalid legacy provider config is backed up but not started. Harbor removes stale generated config and returns to a stopped first-run state.
|
||||||
|
|
||||||
|
## Migration to ordered VPN/Direct rules
|
||||||
|
|
||||||
|
When schemas v0-v5 are read, Harbor preserves the order of `routeRules` and `appliedRouteRules` and adds `outbound: "direct"` to legacy entries before atomically committing schema v6. The original file is preserved using its actual source version, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
state.json.backup-v5-2026-08-17T12-00-00-000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
After migration, malformed schema-v6 rules are rejected; Harbor does not reinterpret a missing or unknown outbound as direct.
|
||||||
|
|
||||||
|
## Migration to failover
|
||||||
|
|
||||||
|
Schemas v0-v7 migrate to v8 with failover disabled, empty switch history and no applied dual config. Migration does not start probes, enable traffic accounting or change the single-channel runtime. The original state is preserved as `state.json.backup-v<fromVersion>-*` before the atomic replacement.
|
||||||
|
|
||||||
|
The separate `activity-journal.json` is created on the first important event. It uses the same atomic write and corrupt-file isolation mechanism as state, retains at most 30 days, and can be removed while Harbor is stopped without affecting subscriptions, routing or VPN startup.
|
||||||
|
|
||||||
|
## Corrupt JSON
|
||||||
|
|
||||||
|
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
state.json.corrupt-2026-08-11T12-00-00-000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
It then creates a valid empty current-schema state and reports storage recovery. A corrupt legacy subscription cache is preserved with the same suffix and is never used to start stale config.
|
||||||
|
|
||||||
|
## Manual recovery and downgrade
|
||||||
|
|
||||||
|
Perform recovery while Harbor is stopped:
|
||||||
|
|
||||||
|
1. Copy the whole data directory.
|
||||||
|
2. Inspect the intended backup with `jq . <backup-file>`.
|
||||||
|
3. Restore only matching state/cache backups to their original filenames.
|
||||||
|
4. Start Harbor and verify `GET /api/state` before applying a profile.
|
||||||
|
|
||||||
|
A pre-v8 binary cannot interpret failover state. Restore `state.json.backup-v<fromVersion>-*` matching the rollback binary; deploying old code over schema v8 is not safe. A rollback to pre-v5 additionally requires the matching state and subscription-cache backups because that binary cannot interpret canonical profiles.
|
||||||
-105
@@ -1,105 +0,0 @@
|
|||||||
# Roadmap: VPN Proxy rebuild
|
|
||||||
|
|
||||||
## Целевая модель
|
|
||||||
|
|
||||||
Проект должен стать multi-mode системой вокруг `sing-box`:
|
|
||||||
|
|
||||||
| Режим | Назначение | Runtime | Статус |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
|
||||||
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
|
||||||
| `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок |
|
|
||||||
|
|
||||||
## Gateway mode
|
|
||||||
|
|
||||||
Цель: контейнер, который становится прозрачным gateway для сети.
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- `sing-box` внутри контейнера.
|
|
||||||
- `network_mode: host`.
|
|
||||||
- `CAP_NET_ADMIN` и `CAP_NET_RAW`.
|
|
||||||
- TProxy inbound на `7895`.
|
|
||||||
- Mixed HTTP/SOCKS inbound на `8080`.
|
|
||||||
- Web UI на `3456`.
|
|
||||||
- Subscription URL вводится в UI, парсится, пользователь выбирает сервер.
|
|
||||||
- Пользовательские routing lists управляются из UI.
|
|
||||||
- Генерируется `/etc/sing-box/config.json`.
|
|
||||||
- `sing-box check` перед применением.
|
|
||||||
- Restart `sing-box` после применения.
|
|
||||||
- Idempotent iptables setup.
|
|
||||||
- Cleanup iptables/ip rule/ip route при остановке контейнера.
|
|
||||||
|
|
||||||
Маршрутизация v1:
|
|
||||||
|
|
||||||
- private IP ranges -> `direct`.
|
|
||||||
- пользовательские списки -> `direct`, `vpn` или `block`.
|
|
||||||
- `geoip-ru` -> `direct`.
|
|
||||||
- `geosite-category-ru` -> `direct`.
|
|
||||||
- все остальное -> выбранный VPN outbound.
|
|
||||||
|
|
||||||
Порядок правил:
|
|
||||||
|
|
||||||
1. safety private-direct, чтобы не ломать LAN.
|
|
||||||
2. custom routing lists из UI.
|
|
||||||
3. RU direct rules.
|
|
||||||
4. default VPN outbound.
|
|
||||||
|
|
||||||
Формат пользовательского списка:
|
|
||||||
|
|
||||||
- `name`.
|
|
||||||
- `enabled`.
|
|
||||||
- `outbound`: `direct`, `vpn`, `block`.
|
|
||||||
- `domains`: exact domains.
|
|
||||||
- `domainSuffixes`: доменные suffix, удобно для игр/сервисов.
|
|
||||||
- `domainKeywords`: keyword matching.
|
|
||||||
- `ipCidrs`: CIDR ranges.
|
|
||||||
- `ports`: TCP/UDP ports.
|
|
||||||
- `networks`: `tcp`, `udp`.
|
|
||||||
- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование.
|
|
||||||
|
|
||||||
Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса.
|
|
||||||
|
|
||||||
Отдельно решить позже:
|
|
||||||
|
|
||||||
- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound.
|
|
||||||
- IPv6 TProxy.
|
|
||||||
- nftables backend.
|
|
||||||
- health checks и smoke diagnostics.
|
|
||||||
- secret storage через Infisical/Vault/env.
|
|
||||||
|
|
||||||
## Desktop proxy mode
|
|
||||||
|
|
||||||
Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy.
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- UI на `3456`.
|
|
||||||
- Mixed inbound на `8080`.
|
|
||||||
- Subscription parser.
|
|
||||||
- Выбор сервера.
|
|
||||||
- Fallback proxy через `urltest`.
|
|
||||||
- Direct mode toggle.
|
|
||||||
- Не требует `NET_ADMIN`.
|
|
||||||
|
|
||||||
## Windows gaming mode
|
|
||||||
|
|
||||||
Цель: сохранить сценарий для Discord/Vesktop/игр.
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- Native `sing-box.exe`.
|
|
||||||
- Scheduled task или Windows service.
|
|
||||||
- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
|
|
||||||
- Управление из PowerShell helper.
|
|
||||||
- Позже можно сделать Electron/Tauri UI поверх privileged helper.
|
|
||||||
|
|
||||||
## Рабочий порядок
|
|
||||||
|
|
||||||
1. Сделать новый gateway root.
|
|
||||||
2. Реализовать Docker image + entrypoint TProxy lifecycle.
|
|
||||||
3. Реализовать маленький control-server.
|
|
||||||
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
|
||||||
5. Добавить gateway docs/install script.
|
|
||||||
6. Потом переносить desktop-proxy.
|
|
||||||
7. Потом приводить Windows mode к новой архитектуре.
|
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${PORT:-3456}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8082}"
|
||||||
|
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||||
|
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||||
|
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[client-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR" "$(dirname "$SING_BOX_CONFIG")" "$(dirname "$SING_BOX_CACHE")"
|
||||||
|
|
||||||
|
export APP_MODE=client
|
||||||
|
export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
|
||||||
|
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
|
||||||
|
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
||||||
|
exec node /app/dist/server/main.js
|
||||||
Regular → Executable
+230
-10
@@ -1,22 +1,100 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_COMPONENT="${APP_COMPONENT:-combined}"
|
||||||
TPROXY_PORT="${TPROXY_PORT:-7895}"
|
TPROXY_PORT="${TPROXY_PORT:-7895}"
|
||||||
TPROXY_MARK="${TPROXY_MARK:-1}"
|
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
|
DEVICE_POLICY_CHAIN="${DEVICE_POLICY_CHAIN:-VPN_PROXY_DEVICE_POLICY}"
|
||||||
|
GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
|
||||||
|
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
|
||||||
|
TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}"
|
||||||
|
TRAFFIC_DOWNLOAD_CHAIN="${TRAFFIC_DOWNLOAD_CHAIN:-VPN_PROXY_TRAFFIC_DOWN}"
|
||||||
|
DIRECT_TRAFFIC_CHAIN="${DIRECT_TRAFFIC_CHAIN:-VPN_PROXY_DIRECT}"
|
||||||
|
DIRECT_TRAFFIC_MARK="${DIRECT_TRAFFIC_MARK:-0x40000000}"
|
||||||
|
GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
|
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||||
|
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
|
export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN DIRECT_TRAFFIC_CHAIN DIRECT_TRAFFIC_MARK GATEWAY_CLIENT_CIDRS BYPASS_CIDRS
|
||||||
|
DEVICE_TRAFFIC_CONFIG_VALID=false
|
||||||
|
export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=false
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[gateway-entrypoint] %s\n' "$*"
|
printf '[gateway-entrypoint] %s\n' "$*"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if [[ "$APP_COMPONENT" == "control" ]]; then
|
||||||
|
exec node /app/dist/server/main.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
validate_device_traffic_config() {
|
||||||
|
if [[ -z "$DIRECT_TRAFFIC_CHAIN" || ${#DIRECT_TRAFFIC_CHAIN} -gt 24
|
||||||
|
|| "$DIRECT_TRAFFIC_CHAIN" =~ [^a-zA-Z0-9_] ]]; then
|
||||||
|
log "device traffic counters unavailable: invalid DIRECT_TRAFFIC_CHAIN"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local direct_names=("$DIRECT_TRAFFIC_CHAIN" "${DIRECT_TRAFFIC_CHAIN}_A" "${DIRECT_TRAFFIC_CHAIN}_B")
|
||||||
|
local reserved_names=(
|
||||||
|
PREROUTING INPUT FORWARD OUTPUT POSTROUTING
|
||||||
|
"$TPROXY_CHAIN"
|
||||||
|
"$DEVICE_POLICY_CHAIN" "${DEVICE_POLICY_CHAIN}_A" "${DEVICE_POLICY_CHAIN}_B"
|
||||||
|
"$TRAFFIC_DOWNLOAD_CHAIN" "${TRAFFIC_DOWNLOAD_CHAIN}_A" "${TRAFFIC_DOWNLOAD_CHAIN}_B"
|
||||||
|
"${TRAFFIC_DOWNLOAD_CHAIN}_A_P" "${TRAFFIC_DOWNLOAD_CHAIN}_B_P"
|
||||||
|
)
|
||||||
|
for direct_name in "${direct_names[@]}"; do
|
||||||
|
for reserved_name in "${reserved_names[@]}"; do
|
||||||
|
if [[ "$direct_name" == "$reserved_name" ]]; then
|
||||||
|
log "device traffic counters unavailable: DIRECT_TRAFFIC_CHAIN conflicts with ${reserved_name}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
if ! [[ "$DIRECT_TRAFFIC_MARK" =~ ^(0[xX][0-9a-fA-F]{1,8}|[0-9]{1,10})$
|
||||||
|
&& "$TPROXY_MARK" =~ ^(0[xX][0-9a-fA-F]{1,8}|[0-9]{1,10})$ ]]; then
|
||||||
|
log "device traffic counters unavailable: invalid traffic mark"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local direct_mark_value tproxy_mark_value
|
||||||
|
if [[ "$DIRECT_TRAFFIC_MARK" =~ ^0[xX] ]]; then
|
||||||
|
direct_mark_value=$((16#${DIRECT_TRAFFIC_MARK:2}))
|
||||||
|
else
|
||||||
|
direct_mark_value=$((10#$DIRECT_TRAFFIC_MARK))
|
||||||
|
fi
|
||||||
|
if [[ "$TPROXY_MARK" =~ ^0[xX] ]]; then
|
||||||
|
tproxy_mark_value=$((16#${TPROXY_MARK:2}))
|
||||||
|
else
|
||||||
|
tproxy_mark_value=$((10#$TPROXY_MARK))
|
||||||
|
fi
|
||||||
|
if (( direct_mark_value == 0 || direct_mark_value > 0xffffffff
|
||||||
|
|| (direct_mark_value & (direct_mark_value - 1)) != 0
|
||||||
|
|| (direct_mark_value & tproxy_mark_value) != 0 )); then
|
||||||
|
log "device traffic counters unavailable: DIRECT_TRAFFIC_MARK must be one bit outside TPROXY_MARK"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
DEVICE_TRAFFIC_CONFIG_VALID=true
|
||||||
|
}
|
||||||
|
|
||||||
ipt() {
|
ipt() {
|
||||||
iptables -w "$@"
|
iptables -w "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipt_traffic() {
|
||||||
|
iptables -w 1 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_proxy_firewall() {
|
||||||
|
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
cleanup_tproxy() {
|
cleanup_tproxy() {
|
||||||
log "cleanup tproxy rules"
|
|
||||||
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
@@ -24,40 +102,182 @@ cleanup_tproxy() {
|
|||||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_device_policy() {
|
||||||
|
ipt -t mangle -F "$DEVICE_POLICY_CHAIN" 2>/dev/null || true
|
||||||
|
for slot in A B; do
|
||||||
|
ipt -t mangle -F "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ipt -t mangle -X "$DEVICE_POLICY_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_device_policy() {
|
||||||
|
cleanup_device_policy
|
||||||
|
ipt -t mangle -N "$DEVICE_POLICY_CHAIN" || return 1
|
||||||
|
ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_A" || return 1
|
||||||
|
ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_B" || return 1
|
||||||
|
ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1
|
||||||
|
ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1
|
||||||
|
ipt -t mangle -A "$DEVICE_POLICY_CHAIN" -j "${DEVICE_POLICY_CHAIN}_A" || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_gateway_forwarding() {
|
||||||
|
ipt -D FORWARD -j "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -D POSTROUTING -j "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -F "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -X "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_device_traffic() {
|
||||||
|
[[ "$DEVICE_TRAFFIC_CONFIG_VALID" == "true" ]] || return 0
|
||||||
|
ipt_traffic -t mangle -D "$TPROXY_CHAIN" -j CONNMARK --set-xmark "0x0/$DIRECT_TRAFFIC_MARK" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -D PREROUTING -j "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -D PREROUTING -j "$DIRECT_TRAFFIC_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -D POSTROUTING -j "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -F "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "$DIRECT_TRAFFIC_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
for slot in A B; do
|
||||||
|
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "${DIRECT_TRAFFIC_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "${DIRECT_TRAFFIC_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "$DIRECT_TRAFFIC_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_device_traffic() {
|
||||||
|
log "setup device traffic counters"
|
||||||
|
cleanup_device_traffic
|
||||||
|
ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||||
|
ipt_traffic -t mangle -N "$DIRECT_TRAFFIC_CHAIN" || return 1
|
||||||
|
ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||||
|
for slot in A B; do
|
||||||
|
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}" || return 1
|
||||||
|
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" || return 1
|
||||||
|
ipt_traffic -t mangle -N "${DIRECT_TRAFFIC_CHAIN}_${slot}" || return 1
|
||||||
|
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || return 1
|
||||||
|
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1
|
||||||
|
done
|
||||||
|
ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||||
|
# sing-box inserts TPROXY at position 1 later; this jump then sees only packets not intercepted by it.
|
||||||
|
ipt_traffic -t mangle -I PREROUTING 1 -j "$DIRECT_TRAFFIC_CHAIN" || return 1
|
||||||
|
ipt_traffic -t mangle -I POSTROUTING 1 -j "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||||
|
local policy_rule=4
|
||||||
|
for _cidr in $BYPASS_CIDRS; do
|
||||||
|
policy_rule=$((policy_rule + 1))
|
||||||
|
done
|
||||||
|
ipt_traffic -t mangle -I "$TPROXY_CHAIN" "$policy_rule" -j CONNMARK --set-xmark "0x0/$DIRECT_TRAFFIC_MARK" || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||||
|
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
||||||
|
elif command -v sysctl >/dev/null 2>&1; then
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_proxy_firewall() {
|
||||||
|
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
ipt -N "$PROXY_INPUT_CHAIN"
|
||||||
|
for cidr in $PROXY_ALLOWED_CIDRS; do
|
||||||
|
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
ipt -A "$PROXY_INPUT_CHAIN" -j DROP
|
||||||
|
ipt -I INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||||
|
ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_gateway_forwarding() {
|
||||||
|
log "setup direct gateway forwarding"
|
||||||
|
cleanup_gateway_forwarding
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
|
ipt -N "$GATEWAY_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -N "$GATEWAY_NAT_CHAIN"
|
||||||
|
for cidr in $GATEWAY_CLIENT_CIDRS; do
|
||||||
|
ipt -A "$GATEWAY_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
|
ipt -A "$GATEWAY_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
ipt -t nat -A "$GATEWAY_NAT_CHAIN" -s "$cidr" -m addrtype ! --dst-type LOCAL -j MASQUERADE
|
||||||
|
done
|
||||||
|
ipt -I FORWARD 1 -j "$GATEWAY_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -I POSTROUTING 1 -j "$GATEWAY_NAT_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
setup_tproxy() {
|
setup_tproxy() {
|
||||||
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
|
log "setup tproxy on port ${TPROXY_PORT}"
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
if ! setup_device_policy; then
|
||||||
|
log "device policy unavailable; using global VPN fallback"
|
||||||
|
cleanup_device_policy
|
||||||
|
fi
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||||
|
|
||||||
ipt -t mangle -N "$TPROXY_CHAIN"
|
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -i 'br-+' -j RETURN
|
||||||
|
|
||||||
|
# Private/local destinations stay reachable; every intercepted public packet goes to VPN.
|
||||||
for cidr in $BYPASS_CIDRS; do
|
for cidr in $BYPASS_CIDRS; do
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||||
done
|
done
|
||||||
|
if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN"
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
else
|
||||||
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
|
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup_gateway_forwarding
|
||||||
setup_tproxy
|
setup_tproxy
|
||||||
|
if validate_device_traffic_config; then
|
||||||
|
if ! setup_device_traffic; then
|
||||||
|
log "device traffic counters unavailable; VPN routing remains active"
|
||||||
|
cleanup_device_traffic
|
||||||
|
else
|
||||||
|
export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
setup_proxy_firewall
|
||||||
|
|
||||||
node /app/src/server/index.js &
|
node /app/dist/server/main.js &
|
||||||
APP_PID=$!
|
APP_PID=$!
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
log "shutdown requested"
|
|
||||||
kill "$APP_PID" 2>/dev/null || true
|
kill "$APP_PID" 2>/dev/null || true
|
||||||
wait "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
cleanup_device_traffic
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
cleanup_device_policy
|
||||||
|
cleanup_gateway_forwarding
|
||||||
}
|
}
|
||||||
|
|
||||||
trap 'shutdown; exit 0' SIGTERM SIGINT
|
trap 'shutdown; exit 0' SIGTERM SIGINT
|
||||||
|
|
||||||
wait "$APP_PID"
|
wait "$APP_PID"
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
cleanup_device_traffic
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
cleanup_device_policy
|
||||||
|
cleanup_gateway_forwarding
|
||||||
exit "$STATUS"
|
exit "$STATUS"
|
||||||
|
|||||||
+3
-2
@@ -3,10 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VPN Proxy Gateway</title>
|
<link id="harbor-favicon" rel="icon" href="/harbor-connect.svg?v=2" type="image/svg+xml" sizes="any" />
|
||||||
|
<title>Harbor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/web/App.jsx"></script>
|
<script type="module" src="/src/web/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
need() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
printf '[harbor-connect] error: %s is required\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
need curl
|
||||||
|
need tar
|
||||||
|
[ -x /bin/bash ] || { printf '[harbor-connect] error: /bin/bash is required\n' >&2; exit 1; }
|
||||||
|
|
||||||
|
branch="${VPN_PROXY_BRANCH:-master}"
|
||||||
|
archive_url="${VPN_PROXY_ARCHIVE_URL:-https://git.dokops.ru/dokril/vpn-proxy/archive/${branch}.tar.gz}"
|
||||||
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/harbor-connect.XXXXXX")"
|
||||||
|
trap 'rm -rf "$tmp"' 0 1 2 3 15
|
||||||
|
|
||||||
|
mkdir -p "$tmp/source"
|
||||||
|
curl -fsSL "$archive_url" | tar -xzf - -C "$tmp/source" --strip-components=1
|
||||||
|
[ -f "$tmp/source/scripts/install-macos-client.sh" ] || {
|
||||||
|
printf '[harbor-connect] error: installer is missing from archive\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
VPN_PROXY_SOURCE_DIR="$tmp/source" /bin/bash "$tmp/source/scripts/install-macos-client.sh"
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+2409
File diff suppressed because it is too large
Load Diff
+28
-4
@@ -6,14 +6,38 @@
|
|||||||
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"generate:singbox-api": "XDG_CACHE_HOME=${TMPDIR:-/tmp}/harbor-buf-cache buf generate --template buf.gen.yaml",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "node src/server/index.js"
|
"build:production": "npm run build && npm run build:server",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
|
"build:test": "npm run build:production && node scripts/clean-test-dist.mjs && tsc -p tsconfig.test.json",
|
||||||
|
"check:boundaries": "node scripts/check-import-boundaries.mjs",
|
||||||
|
"prestart": "npm run build:production",
|
||||||
|
"test": "npm run build:test && node --test",
|
||||||
|
"typecheck": "tsc -p tsconfig.web.json && tsc -p tsconfig.server.json --noEmit",
|
||||||
|
"version:harbor": "node scripts/harbor-version.mjs",
|
||||||
|
"start": "node dist/server/main.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.6.0",
|
||||||
|
"@connectrpc/connect": "2.0.3",
|
||||||
|
"@connectrpc/connect-node": "2.0.3",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"vite": "^7.0.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {
|
||||||
|
"@babel/parser": "7.29.3",
|
||||||
|
"@bufbuild/buf": "1.47.2",
|
||||||
|
"@bufbuild/protoc-gen-es": "2.6.0",
|
||||||
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
|
"@types/node": "22.19.17",
|
||||||
|
"@types/node18": "npm:@types/node@18.19.130",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"postcss": "8.5.14",
|
||||||
|
"postcss-selector-parser": "7.1.4",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package daemon;
|
||||||
|
option go_package = "github.com/sagernet/sing-box/daemon";
|
||||||
|
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
service StartedService {
|
||||||
|
rpc GetVersion(google.protobuf.Empty) returns(Version) {}
|
||||||
|
rpc SubscribeServiceStatus(google.protobuf.Empty) returns(stream ServiceStatus) {}
|
||||||
|
rpc SubscribeLog(google.protobuf.Empty) returns(stream Log) {}
|
||||||
|
rpc GetDefaultLogLevel(google.protobuf.Empty) returns(DefaultLogLevel) {}
|
||||||
|
rpc ClearLogs(google.protobuf.Empty) returns(google.protobuf.Empty) {}
|
||||||
|
rpc SubscribeStatus(SubscribeStatusRequest) returns(stream Status) {}
|
||||||
|
rpc SubscribeGroups(google.protobuf.Empty) returns(stream Groups) {}
|
||||||
|
|
||||||
|
rpc GetClashModeStatus(google.protobuf.Empty) returns(ClashModeStatus) {}
|
||||||
|
rpc SubscribeClashMode(google.protobuf.Empty) returns(stream ClashMode) {}
|
||||||
|
rpc SetClashMode(ClashMode) returns(google.protobuf.Empty) {}
|
||||||
|
|
||||||
|
rpc URLTest(URLTestRequest) returns(google.protobuf.Empty) {}
|
||||||
|
rpc SelectOutbound(SelectOutboundRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SetGroupExpand(SetGroupExpandRequest) returns (google.protobuf.Empty) {}
|
||||||
|
|
||||||
|
rpc SubscribeConnections(SubscribeConnectionsRequest) returns(stream ConnectionEvents) {}
|
||||||
|
rpc CloseConnection(CloseConnectionRequest) returns(google.protobuf.Empty) {}
|
||||||
|
rpc CloseAllConnections(google.protobuf.Empty) returns(google.protobuf.Empty) {}
|
||||||
|
rpc GetDeprecatedWarnings(google.protobuf.Empty) returns(DeprecatedWarnings) {}
|
||||||
|
rpc GetStartedAt(google.protobuf.Empty) returns(StartedAt) {}
|
||||||
|
|
||||||
|
rpc SubscribeOutbounds(google.protobuf.Empty) returns (stream OutboundList) {}
|
||||||
|
rpc StartNetworkQualityTest(NetworkQualityTestRequest) returns (stream NetworkQualityTestProgress) {}
|
||||||
|
rpc StartSTUNTest(STUNTestRequest) returns (stream STUNTestProgress) {}
|
||||||
|
rpc SubscribeTailscaleStatus(google.protobuf.Empty) returns (stream TailscaleStatusUpdate) {}
|
||||||
|
rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {}
|
||||||
|
rpc SetTailscaleExitNode(SetTailscaleExitNodeRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc TailscaleLogout(TailscaleLogoutRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc GetTailscaleCertificate(TailscaleCertificateRequest) returns (TailscaleCertificate) {}
|
||||||
|
rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {}
|
||||||
|
rpc SubscribeTaildropInbox(SubscribeTaildropInboxRequest) returns (stream TaildropInbox) {}
|
||||||
|
rpc MarkTaildropInboxRead(MarkTaildropInboxReadRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SendTaildropFiles(stream TaildropSendClientMessage) returns (stream TaildropSendServerMessage) {}
|
||||||
|
rpc DownloadTaildropFile(DownloadTaildropFileRequest) returns (stream DownloadTaildropFileChunk) {}
|
||||||
|
rpc DeleteTaildropFile(DeleteTaildropFileRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc CancelTaildropReceiving(CancelTaildropReceivingRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {}
|
||||||
|
rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {}
|
||||||
|
rpc SubscribeOpenConnectStatus(google.protobuf.Empty) returns (stream OpenConnectStatusUpdate) {}
|
||||||
|
rpc SubmitOpenConnectAuthResponse(OpenConnectAuthResponseSubmission) returns (google.protobuf.Empty) {}
|
||||||
|
rpc CancelOpenConnectAuthChallenge(OpenConnectAuthChallengeCancel) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SubscribeOpenVPNStatus(google.protobuf.Empty) returns (stream OpenVPNStatusUpdate) {}
|
||||||
|
rpc SubmitOpenVPNChallengeResponse(OpenVPNChallengeSubmission) returns (google.protobuf.Empty) {}
|
||||||
|
rpc CancelOpenVPNChallenge(OpenVPNChallengeCancel) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SubscribeNotifications(google.protobuf.Empty) returns (stream NotificationEvent) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Version {
|
||||||
|
string version = 1;
|
||||||
|
int32 apiVersion = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ServiceStatus {
|
||||||
|
enum Type {
|
||||||
|
IDLE = 0;
|
||||||
|
STARTING = 1;
|
||||||
|
STARTED = 2;
|
||||||
|
STOPPING = 3;
|
||||||
|
FATAL = 4;
|
||||||
|
}
|
||||||
|
Type status = 1;
|
||||||
|
string errorMessage = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeStatusRequest {
|
||||||
|
int64 interval = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogLevel {
|
||||||
|
PANIC = 0;
|
||||||
|
FATAL = 1;
|
||||||
|
ERROR = 2;
|
||||||
|
WARN = 3;
|
||||||
|
INFO = 4;
|
||||||
|
DEBUG = 5;
|
||||||
|
TRACE = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Log {
|
||||||
|
repeated Message messages = 1;
|
||||||
|
bool reset = 2;
|
||||||
|
message Message {
|
||||||
|
LogLevel level = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message DefaultLogLevel {
|
||||||
|
LogLevel level = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Status {
|
||||||
|
uint64 memory = 1;
|
||||||
|
int32 goroutines = 2;
|
||||||
|
int32 connectionsIn = 3;
|
||||||
|
int32 connectionsOut = 4;
|
||||||
|
bool trafficAvailable = 5;
|
||||||
|
int64 uplink = 6;
|
||||||
|
int64 downlink = 7;
|
||||||
|
int64 uplinkTotal = 8;
|
||||||
|
int64 downlinkTotal = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Groups {
|
||||||
|
repeated Group group = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Group {
|
||||||
|
string tag = 1;
|
||||||
|
string type = 2;
|
||||||
|
bool selectable = 3;
|
||||||
|
string selected = 4;
|
||||||
|
bool isExpand = 5;
|
||||||
|
repeated GroupItem items = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GroupItem {
|
||||||
|
string tag = 1;
|
||||||
|
string type = 2;
|
||||||
|
int64 urlTestTime = 3;
|
||||||
|
int32 urlTestDelay = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message URLTestRequest {
|
||||||
|
string outboundTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SelectOutboundRequest {
|
||||||
|
string groupTag = 1;
|
||||||
|
string outboundTag = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetGroupExpandRequest {
|
||||||
|
string groupTag = 1;
|
||||||
|
bool isExpand = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClashMode {
|
||||||
|
string mode = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClashModeStatus {
|
||||||
|
repeated string modeList = 1;
|
||||||
|
string currentMode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeConnectionsRequest {
|
||||||
|
int64 interval = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionEventType {
|
||||||
|
CONNECTION_EVENT_NEW = 0;
|
||||||
|
CONNECTION_EVENT_UPDATE = 1;
|
||||||
|
CONNECTION_EVENT_CLOSED = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConnectionEvent {
|
||||||
|
ConnectionEventType type = 1;
|
||||||
|
string id = 2;
|
||||||
|
Connection connection = 3;
|
||||||
|
int64 uplinkDelta = 4;
|
||||||
|
int64 downlinkDelta = 5;
|
||||||
|
int64 closedAt = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConnectionEvents {
|
||||||
|
repeated ConnectionEvent events = 1;
|
||||||
|
bool reset = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Connection {
|
||||||
|
string id = 1;
|
||||||
|
string inbound = 2;
|
||||||
|
string inboundType = 3;
|
||||||
|
int32 ipVersion = 4;
|
||||||
|
string network = 5;
|
||||||
|
string source = 6;
|
||||||
|
string destination = 7;
|
||||||
|
string domain = 8;
|
||||||
|
string protocol = 9;
|
||||||
|
string user = 10;
|
||||||
|
string fromOutbound = 11;
|
||||||
|
int64 createdAt = 12;
|
||||||
|
int64 closedAt = 13;
|
||||||
|
int64 uplink = 14;
|
||||||
|
int64 downlink = 15;
|
||||||
|
int64 uplinkTotal = 16;
|
||||||
|
int64 downlinkTotal = 17;
|
||||||
|
string rule = 18;
|
||||||
|
string outbound = 19;
|
||||||
|
string outboundType = 20;
|
||||||
|
repeated string chainList = 21;
|
||||||
|
ProcessInfo processInfo = 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProcessInfo {
|
||||||
|
uint32 processId = 1;
|
||||||
|
int32 userId = 2;
|
||||||
|
string userName = 3;
|
||||||
|
string processPath = 4;
|
||||||
|
repeated string packageNames = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CloseConnectionRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeprecatedWarnings {
|
||||||
|
repeated DeprecatedWarning warnings = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeprecatedWarning {
|
||||||
|
string message = 1;
|
||||||
|
bool impending = 2;
|
||||||
|
string migrationLink = 3;
|
||||||
|
string description = 4;
|
||||||
|
string deprecatedVersion = 5;
|
||||||
|
string scheduledVersion = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StartedAt {
|
||||||
|
int64 startedAt = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OutboundList {
|
||||||
|
repeated GroupItem outbounds = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NetworkQualityTestRequest {
|
||||||
|
string configURL = 1;
|
||||||
|
string outboundTag = 2;
|
||||||
|
bool serial = 3;
|
||||||
|
int32 maxRuntimeSeconds = 4;
|
||||||
|
bool http3 = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NetworkQualityTestProgress {
|
||||||
|
int32 phase = 1;
|
||||||
|
int64 downloadCapacity = 2;
|
||||||
|
int64 uploadCapacity = 3;
|
||||||
|
int32 downloadRPM = 4;
|
||||||
|
int32 uploadRPM = 5;
|
||||||
|
int32 idleLatencyMs = 6;
|
||||||
|
int64 elapsedMs = 7;
|
||||||
|
bool isFinal = 8;
|
||||||
|
string error = 9;
|
||||||
|
int32 downloadCapacityAccuracy = 10;
|
||||||
|
int32 uploadCapacityAccuracy = 11;
|
||||||
|
int32 downloadRPMAccuracy = 12;
|
||||||
|
int32 uploadRPMAccuracy = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message STUNTestRequest {
|
||||||
|
string server = 1;
|
||||||
|
string outboundTag = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message STUNTestProgress {
|
||||||
|
int32 phase = 1;
|
||||||
|
string externalAddr = 2;
|
||||||
|
int32 latencyMs = 3;
|
||||||
|
int32 natMapping = 4;
|
||||||
|
int32 natFiltering = 5;
|
||||||
|
bool isFinal = 6;
|
||||||
|
string error = 7;
|
||||||
|
bool natTypeSupported = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleStatusUpdate {
|
||||||
|
repeated TailscaleEndpointStatus endpoints = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleEndpointStatus {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string backendState = 2;
|
||||||
|
string stateText = 3;
|
||||||
|
string authURL = 4;
|
||||||
|
string networkName = 5;
|
||||||
|
string magicDNSSuffix = 6;
|
||||||
|
TailscalePeer self = 7;
|
||||||
|
repeated TailscaleUserGroup userGroups = 8;
|
||||||
|
TailscalePeer exitNode = 9;
|
||||||
|
bool keyAuth = 10;
|
||||||
|
bool canShareFiles = 11;
|
||||||
|
int32 waitingFileCount = 12;
|
||||||
|
int32 receivingFileCount = 13;
|
||||||
|
int32 unreadFileCount = 14;
|
||||||
|
repeated string certDomains = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleUserGroup {
|
||||||
|
int64 userID = 1;
|
||||||
|
string loginName = 2;
|
||||||
|
string displayName = 3;
|
||||||
|
string profilePicURL = 4;
|
||||||
|
repeated TailscalePeer peers = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscalePeer {
|
||||||
|
string hostName = 1;
|
||||||
|
string dnsName = 2;
|
||||||
|
string os = 3;
|
||||||
|
repeated string tailscaleIPs = 4;
|
||||||
|
bool online = 5;
|
||||||
|
bool exitNode = 6;
|
||||||
|
bool exitNodeOption = 7;
|
||||||
|
bool active = 8;
|
||||||
|
int64 rxBytes = 9;
|
||||||
|
int64 txBytes = 10;
|
||||||
|
int64 keyExpiry = 11;
|
||||||
|
string stableID = 12;
|
||||||
|
bool expired = 13;
|
||||||
|
repeated string sshHostKeys = 14;
|
||||||
|
bool shareeNode = 15;
|
||||||
|
int64 lastSeen = 16;
|
||||||
|
bool canReceiveFiles = 17;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscalePingRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string peerIP = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscalePingResponse {
|
||||||
|
double latencyMs = 1;
|
||||||
|
bool isDirect = 2;
|
||||||
|
string endpoint = 3;
|
||||||
|
int32 derpRegionID = 4;
|
||||||
|
string derpRegionCode = 5;
|
||||||
|
string error = 6;
|
||||||
|
string peerRelay = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetTailscaleExitNodeRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string stableID = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleLogoutRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleCertificateRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string domain = 2;
|
||||||
|
int64 minValiditySeconds = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleCertificate {
|
||||||
|
bytes certificatePEM = 1;
|
||||||
|
bytes privateKeyPEM = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHClientMessage {
|
||||||
|
oneof message {
|
||||||
|
TailscaleSSHStart start = 1;
|
||||||
|
TailscaleSSHInput input = 2;
|
||||||
|
TailscaleSSHResize resize = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHStart {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string peerAddress = 2;
|
||||||
|
string username = 3;
|
||||||
|
string terminalType = 4;
|
||||||
|
int32 columns = 5;
|
||||||
|
int32 rows = 6;
|
||||||
|
int32 widthPixels = 7;
|
||||||
|
int32 heightPixels = 8;
|
||||||
|
repeated string hostKeys = 9;
|
||||||
|
bool forward_agent = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHInput {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHResize {
|
||||||
|
int32 columns = 1;
|
||||||
|
int32 rows = 2;
|
||||||
|
int32 widthPixels = 3;
|
||||||
|
int32 heightPixels = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHServerMessage {
|
||||||
|
oneof message {
|
||||||
|
TailscaleSSHAuthBanner authBanner = 1;
|
||||||
|
TailscaleSSHReady ready = 2;
|
||||||
|
TailscaleSSHOutput output = 3;
|
||||||
|
TailscaleSSHExit exit = 4;
|
||||||
|
TailscaleSSHError error = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHAuthBanner {
|
||||||
|
string message = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHReady {
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHOutput {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHExit {
|
||||||
|
int32 exitCode = 1;
|
||||||
|
string signal = 2;
|
||||||
|
string errorMessage = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHError {
|
||||||
|
string message = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeTaildropInboxRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MarkTaildropInboxReadRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropInbox {
|
||||||
|
string endpointTag = 1;
|
||||||
|
repeated TaildropFile files = 2;
|
||||||
|
repeated TaildropReceivingFile receiving = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropFile {
|
||||||
|
string name = 1;
|
||||||
|
int64 size = 2;
|
||||||
|
string senderName = 3;
|
||||||
|
int64 modifiedAt = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropReceivingFile {
|
||||||
|
string name = 1;
|
||||||
|
int64 size = 2;
|
||||||
|
int64 receivedBytes = 3;
|
||||||
|
string senderID = 4;
|
||||||
|
string senderName = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropSendClientMessage {
|
||||||
|
oneof message {
|
||||||
|
TaildropSendStart start = 1;
|
||||||
|
TaildropFileChunk chunk = 2;
|
||||||
|
TaildropFileDone fileDone = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropSendStart {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string peerStableID = 2;
|
||||||
|
repeated TaildropOutgoingFile files = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropOutgoingFile {
|
||||||
|
string name = 1;
|
||||||
|
int64 size = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropFileChunk {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropFileDone {}
|
||||||
|
|
||||||
|
message TaildropSendServerMessage {
|
||||||
|
oneof message {
|
||||||
|
TaildropSendProgress progress = 1;
|
||||||
|
int64 receivedBytes = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropSendProgress {
|
||||||
|
int32 fileIndex = 1;
|
||||||
|
int64 sentBytes = 2;
|
||||||
|
bool fileCompleted = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DownloadTaildropFileRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DownloadTaildropFileChunk {
|
||||||
|
int64 size = 1;
|
||||||
|
bytes data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteTaildropFileRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CancelTaildropReceivingRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string senderID = 2;
|
||||||
|
string name = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBProviderMessage {
|
||||||
|
oneof message {
|
||||||
|
USBDeviceAttach attach = 1;
|
||||||
|
USBDeviceDetach detach = 2;
|
||||||
|
USBURBResponse urbResponse = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBServerMessage {
|
||||||
|
oneof message {
|
||||||
|
USBDeviceReady ready = 1;
|
||||||
|
USBURBRequest urbRequest = 2;
|
||||||
|
USBEndpointAbort abort = 3;
|
||||||
|
USBError error = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceDescriptor {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint32 busNum = 2;
|
||||||
|
uint32 devNum = 3;
|
||||||
|
uint32 speed = 4;
|
||||||
|
uint32 vendorId = 5;
|
||||||
|
uint32 productId = 6;
|
||||||
|
uint32 bcdDevice = 7;
|
||||||
|
uint32 deviceClass = 8;
|
||||||
|
uint32 deviceSubClass = 9;
|
||||||
|
uint32 deviceProtocol = 10;
|
||||||
|
uint32 configurationValue = 11;
|
||||||
|
uint32 numConfigurations = 12;
|
||||||
|
repeated USBInterface interfaces = 13;
|
||||||
|
string serial = 14;
|
||||||
|
string product = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceAttach {
|
||||||
|
string serverTag = 1;
|
||||||
|
USBDeviceDescriptor descriptor = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBInterface {
|
||||||
|
uint32 interfaceClass = 1;
|
||||||
|
uint32 interfaceSubClass = 2;
|
||||||
|
uint32 interfaceProtocol = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceDetach {
|
||||||
|
string deviceId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceReady {
|
||||||
|
string deviceId = 1;
|
||||||
|
string busId = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBURBRequest {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint64 seq = 2;
|
||||||
|
uint32 endpoint = 3;
|
||||||
|
bool directionIn = 4;
|
||||||
|
uint32 transferFlags = 5;
|
||||||
|
bytes setup = 6;
|
||||||
|
uint32 transferBufferLength = 7;
|
||||||
|
bytes outData = 8;
|
||||||
|
int32 numberOfPackets = 9;
|
||||||
|
int32 startFrame = 10;
|
||||||
|
int32 interval = 11;
|
||||||
|
repeated USBIsoPacket isoPackets = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBURBResponse {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint64 seq = 2;
|
||||||
|
int32 status = 3;
|
||||||
|
int32 actualLength = 4;
|
||||||
|
bytes inData = 5;
|
||||||
|
repeated USBIsoPacket isoPackets = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBIsoPacket {
|
||||||
|
int32 offset = 1;
|
||||||
|
int32 length = 2;
|
||||||
|
int32 actualLength = 3;
|
||||||
|
int32 status = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBEndpointAbort {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint32 endpoint = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBError {
|
||||||
|
string deviceId = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBIPServerStatusUpdate {
|
||||||
|
repeated USBIPServerStatus servers = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBIPServerStatus {
|
||||||
|
string serverTag = 1;
|
||||||
|
repeated USBSharedDevice devices = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBSharedDevice {
|
||||||
|
USBDeviceDescriptor descriptor = 1;
|
||||||
|
string busId = 2;
|
||||||
|
string stableId = 3;
|
||||||
|
USBBackend backend = 4;
|
||||||
|
USBDeviceState state = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum USBDeviceState {
|
||||||
|
USB_DEVICE_STATE_IDLE = 0;
|
||||||
|
USB_DEVICE_STATE_ATTACHED = 1;
|
||||||
|
USB_DEVICE_STATE_UNAVAILABLE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum USBBackend {
|
||||||
|
USB_BACKEND_UNSPECIFIED = 0;
|
||||||
|
USB_BACKEND_LINUX_SYSFS = 1;
|
||||||
|
USB_BACKEND_DYNAMIC = 2;
|
||||||
|
USB_BACKEND_DARWIN_IOKIT = 3;
|
||||||
|
USB_BACKEND_WINDOWS_VBOXUSB = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectStatusUpdate {
|
||||||
|
repeated OpenConnectEndpointStatus endpoints = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectEndpointStatus {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string state = 2;
|
||||||
|
string stateText = 3;
|
||||||
|
OpenConnectAuthChallenge authChallenge = 4;
|
||||||
|
string error = 5;
|
||||||
|
OpenConnectTunnelInfo tunnelInfo = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectTunnelInfo {
|
||||||
|
string server = 1;
|
||||||
|
string flavor = 2;
|
||||||
|
string transport = 3;
|
||||||
|
repeated string ipv4 = 4;
|
||||||
|
repeated string ipv6 = 5;
|
||||||
|
repeated string dns = 6;
|
||||||
|
uint32 mtu = 7;
|
||||||
|
int64 connectedSince = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthChallenge {
|
||||||
|
string id = 1;
|
||||||
|
string banner = 2;
|
||||||
|
string message = 3;
|
||||||
|
string error = 4;
|
||||||
|
oneof challenge {
|
||||||
|
OpenConnectAuthForm form = 5;
|
||||||
|
OpenConnectBrowserRequest browser = 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthForm {
|
||||||
|
repeated OpenConnectAuthFormField fields = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthFormField {
|
||||||
|
string submissionKey = 1;
|
||||||
|
string name = 2;
|
||||||
|
string label = 3;
|
||||||
|
string kind = 4;
|
||||||
|
string value = 5;
|
||||||
|
repeated OpenConnectAuthFormChoice options = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthFormChoice {
|
||||||
|
string value = 1;
|
||||||
|
string label = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserRequest {
|
||||||
|
string url = 1;
|
||||||
|
string finalURL = 2;
|
||||||
|
repeated string cookieNames = 3;
|
||||||
|
repeated string headerNames = 4;
|
||||||
|
repeated string callbackURLPrefixes = 5;
|
||||||
|
repeated string earlyCookieNames = 6;
|
||||||
|
string cacheID = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserCookie {
|
||||||
|
string name = 1;
|
||||||
|
string value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserHeader {
|
||||||
|
string name = 1;
|
||||||
|
repeated string values = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthFormResponse {
|
||||||
|
map<string, string> values = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserResult {
|
||||||
|
string finalURL = 1;
|
||||||
|
repeated OpenConnectBrowserCookie cookies = 2;
|
||||||
|
repeated OpenConnectBrowserHeader headers = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthResponseSubmission {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
oneof response {
|
||||||
|
OpenConnectAuthFormResponse form = 3;
|
||||||
|
OpenConnectBrowserResult browser = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthChallengeCancel {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNStatusUpdate {
|
||||||
|
repeated OpenVPNEndpointStatus endpoints = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNEndpointStatus {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string state = 2;
|
||||||
|
string stateText = 3;
|
||||||
|
OpenVPNChallenge challenge = 4;
|
||||||
|
string error = 5;
|
||||||
|
OpenVPNTunnelInfo tunnelInfo = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNTunnelInfo {
|
||||||
|
string server = 1;
|
||||||
|
reserved 2;
|
||||||
|
string network = 3;
|
||||||
|
repeated string ipv4 = 4;
|
||||||
|
repeated string ipv6 = 5;
|
||||||
|
repeated string dns = 6;
|
||||||
|
uint32 mtu = 7;
|
||||||
|
int64 connectedSince = 8;
|
||||||
|
string cipher = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNChallenge {
|
||||||
|
string id = 1;
|
||||||
|
string kind = 2;
|
||||||
|
string username = 3;
|
||||||
|
string message = 4;
|
||||||
|
string url = 5;
|
||||||
|
string secretMessage = 6;
|
||||||
|
bool echo = 7;
|
||||||
|
string previousError = 8;
|
||||||
|
int64 deadline = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNChallengeSubmission {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
string username = 3;
|
||||||
|
string password = 4;
|
||||||
|
string secret = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNChallengeCancel {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NotificationEvent {
|
||||||
|
oneof event {
|
||||||
|
Notification send = 1;
|
||||||
|
NotificationCancel cancel = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Notification {
|
||||||
|
string identifier = 1;
|
||||||
|
string typeName = 2;
|
||||||
|
int32 typeID = 3;
|
||||||
|
string title = 4;
|
||||||
|
string subtitle = 5;
|
||||||
|
string body = 6;
|
||||||
|
string openURL = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NotificationCancel {
|
||||||
|
string identifier = 1;
|
||||||
|
int32 typeID = 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="9" fill="#101812"/>
|
||||||
|
<circle cx="16" cy="16" r="11" fill="#56c9bd" opacity=".09"/>
|
||||||
|
<g fill="none" stroke="#62d6c9" stroke-width="2.6" stroke-linecap="round">
|
||||||
|
<path d="M16 6.5v9"/>
|
||||||
|
<path d="M10.1 10.3a8 8 0 1 0 11.8 0"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="9" fill="#18150f"/>
|
||||||
|
<circle cx="16" cy="7" r="2.5" fill="none" stroke="#efad58" stroke-width="2.3"/>
|
||||||
|
<g fill="none" stroke="#efad58" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 9.5V25M10.5 14h11"/>
|
||||||
|
<path d="M16 27c-5 0-8-2.8-9.5-6.5M16 27c5 0 8-2.8 9.5-6.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 418 B |
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_HOST="${BUILD_HOST:-107}"
|
||||||
|
DEPLOY_HOST="${DEPLOY_HOST:-111}"
|
||||||
|
BUILD_PATH="${BUILD_PATH:-/opt/vpn-proxy-build}"
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-vpn-proxy-gateway}"
|
||||||
|
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
||||||
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
|
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||||
|
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
||||||
|
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
||||||
|
AUTO_BUILD_RUNTIME_BASE="${AUTO_BUILD_RUNTIME_BASE:-true}"
|
||||||
|
SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-10}"
|
||||||
|
|
||||||
|
echo "Build host: ${BUILD_HOST}"
|
||||||
|
echo "Deploy host: ${DEPLOY_HOST}"
|
||||||
|
echo "Image: ${GATEWAY_IMAGE}"
|
||||||
|
echo "Base image: ${BASE_IMAGE}"
|
||||||
|
echo "Runtime base source: ${RUNTIME_BASE_SOURCE_IMAGE}"
|
||||||
|
|
||||||
|
ensure_known_host() {
|
||||||
|
local host="$1"
|
||||||
|
if [ "${host}" = "local" ]; then return 0; fi
|
||||||
|
local scan_host="${host#*@}"
|
||||||
|
scan_host="${scan_host%%:*}"
|
||||||
|
mkdir -p "${HOME}/.ssh"
|
||||||
|
chmod 700 "${HOME}/.ssh"
|
||||||
|
if ! ssh-keygen -F "${scan_host}" >/dev/null 2>&1; then
|
||||||
|
ssh-keyscan -H "${scan_host}" >> "${HOME}/.ssh/known_hosts"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_cmd() {
|
||||||
|
ssh \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
|
||||||
|
-o ServerAliveInterval=15 \
|
||||||
|
-o ServerAliveCountMax=4 \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Syncing source to ${BUILD_HOST}:${BUILD_PATH}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
BUILD_PATH="$(pwd)"
|
||||||
|
echo "Using local source at ${BUILD_PATH}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "mkdir -p '${BUILD_PATH}'"
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.vpn-proxy' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
./ "${BUILD_HOST}:${BUILD_PATH}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building image on ${BUILD_HOST}"
|
||||||
|
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1 || ! docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION} on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' . && docker run --rm --entrypoint sing-box '${GATEWAY_IMAGE}' version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
bash -lc "${BUILD_COMMAND}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "${BUILD_COMMAND}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Loading image into ${DEPLOY_HOST}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ] && [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
docker image inspect "${GATEWAY_IMAGE}" >/dev/null
|
||||||
|
elif [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
echo "Checking SSH access to ${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "true"
|
||||||
|
echo "Transferring image to ${DEPLOY_HOST}"
|
||||||
|
docker save "${GATEWAY_IMAGE}" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
elif [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | docker load
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Copying deploy script to ${DEPLOY_HOST}:${DEPLOY_PATH}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
cp scripts/deploy-gateway.sh "${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
scripts/deploy-gateway.sh "${DEPLOY_HOST}:${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting gateway on ${DEPLOY_HOST}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
chmod +x ./deploy-gateway.sh
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" \
|
||||||
|
"cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh"
|
||||||
|
fi
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
|
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||||
|
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
||||||
|
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
||||||
|
HTTPS_PROXY="${HTTPS_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTPS Proxy:/ {print $2; exit}')}"
|
||||||
|
NO_PROXY="${NO_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/No Proxy:/ {print $2; exit}')}"
|
||||||
|
|
||||||
|
echo "Building runtime base: ${RUNTIME_BASE_IMAGE}"
|
||||||
|
echo "Source base image: ${BASE_IMAGE}"
|
||||||
|
echo "APT mirror: ${APT_MIRROR}"
|
||||||
|
echo "APT security mirror: ${APT_SECURITY_MIRROR}"
|
||||||
|
if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
|
||||||
|
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
||||||
|
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
||||||
|
--build-arg APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR}" \
|
||||||
|
--build-arg HTTP_PROXY="${HTTP_PROXY}" \
|
||||||
|
--build-arg HTTPS_PROXY="${HTTPS_PROXY}" \
|
||||||
|
--build-arg NO_PROXY="${NO_PROXY}" \
|
||||||
|
--build-arg http_proxy="${HTTP_PROXY}" \
|
||||||
|
--build-arg https_proxy="${HTTPS_PROXY}" \
|
||||||
|
--build-arg no_proxy="${NO_PROXY}" \
|
||||||
|
-f Dockerfile.runtime-base \
|
||||||
|
-t "${RUNTIME_BASE_IMAGE}" \
|
||||||
|
.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SyntaxKind } from 'typescript/unstable/ast';
|
||||||
|
import { createScanner } from 'typescript/unstable/ast/scanner';
|
||||||
|
|
||||||
|
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
function normalized(value) {
|
||||||
|
return value.replaceAll(path.sep, '/').replace(/^\.\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTarget(importer, specifier) {
|
||||||
|
if (!specifier.startsWith('.')) return null;
|
||||||
|
return normalized(path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function featurePath(file) {
|
||||||
|
const match = /^src\/(server|web)\/features\/([^/]+)(?:\/(.*))?$/.exec(file);
|
||||||
|
return match ? { layer: match[1], name: match[2], privatePath: match[3] || '' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importBoundaryViolation(importerValue, specifier) {
|
||||||
|
const importer = normalized(importerValue);
|
||||||
|
const target = relativeTarget(importer, specifier);
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (importer.startsWith('src/shared/') && /^src\/(server|web)\//.test(target)) {
|
||||||
|
return 'shared cannot import server or web';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/') && target.startsWith('src/web/')) {
|
||||||
|
return 'server cannot import web';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/web/') && target.startsWith('src/server/')) {
|
||||||
|
return 'web cannot import server';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/http/') && target.startsWith('src/server/infrastructure/')) {
|
||||||
|
return 'server/http cannot import infrastructure directly';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/features/') && target.startsWith('src/server/http/')) {
|
||||||
|
return 'server/features cannot import http';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/web/ui/')
|
||||||
|
&& /^src\/web\/(?:features(?:\/|$)|api(?:\/|\.[cm]?[jt]sx?$|$))/.test(target)) {
|
||||||
|
return 'web/ui cannot import api or features';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromFeature = featurePath(importer);
|
||||||
|
const toFeature = featurePath(target);
|
||||||
|
if (fromFeature && toFeature
|
||||||
|
&& fromFeature.layer === toFeature.layer
|
||||||
|
&& fromFeature.name !== toFeature.name
|
||||||
|
&& toFeature.privatePath
|
||||||
|
&& !/^index(?:\.[cm]?[jt]sx?)?$/.test(toFeature.privatePath)) {
|
||||||
|
return 'cross-feature imports must use the feature index';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesUnder(directory) {
|
||||||
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const absolute = path.join(directory, entry.name);
|
||||||
|
return entry.isDirectory() ? filesUnder(absolute) : [absolute];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importedSpecifiers(source) {
|
||||||
|
const specifiers = [];
|
||||||
|
const scanner = createScanner(true, undefined, source);
|
||||||
|
const tokens = [];
|
||||||
|
for (let token = scanner.scan(); token !== SyntaxKind.EndOfFile; token = scanner.scan()) {
|
||||||
|
if (token === SyntaxKind.SlashToken) token = scanner.reScanSlashToken();
|
||||||
|
tokens.push({ kind: token, text: scanner.getTokenText(), value: scanner.getTokenValue() });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < tokens.length; index += 1) {
|
||||||
|
const token = tokens[index];
|
||||||
|
const previous = tokens[index - 1];
|
||||||
|
const next = tokens[index + 1];
|
||||||
|
const isProperty = previous?.kind === SyntaxKind.DotToken
|
||||||
|
|| previous?.kind === SyntaxKind.QuestionDotToken;
|
||||||
|
|
||||||
|
if (token.text === 'import' && !isProperty) {
|
||||||
|
if (next?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(next.value);
|
||||||
|
} else if (next?.kind === SyntaxKind.OpenParenToken) {
|
||||||
|
const argument = tokens[index + 2];
|
||||||
|
if (argument?.kind === SyntaxKind.StringLiteral) specifiers.push(argument.value);
|
||||||
|
} else if (next?.kind === SyntaxKind.OpenBraceToken
|
||||||
|
|| next?.kind === SyntaxKind.AsteriskToken
|
||||||
|
|| next?.kind === SyntaxKind.Identifier
|
||||||
|
|| next?.text === 'type') {
|
||||||
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
||||||
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
||||||
|
if (tokens[cursor].text === 'from'
|
||||||
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[cursor + 1].value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (token.text === 'export' && !isProperty) {
|
||||||
|
if (next?.kind !== SyntaxKind.OpenBraceToken
|
||||||
|
&& next?.kind !== SyntaxKind.AsteriskToken
|
||||||
|
&& next?.text !== 'type') continue;
|
||||||
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
||||||
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
||||||
|
if (tokens[cursor].text === 'from'
|
||||||
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[cursor + 1].value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (token.text === 'require' && !isProperty) {
|
||||||
|
if (next?.kind === SyntaxKind.OpenParenToken
|
||||||
|
&& tokens[index + 2]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[index + 2].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkImportBoundaries(repositoryRoot = root) {
|
||||||
|
const sourceRoot = path.join(repositoryRoot, 'src');
|
||||||
|
const files = filesUnder(sourceRoot).filter((file) => SOURCE_FILE.test(file));
|
||||||
|
const violations = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const importer = normalized(path.relative(repositoryRoot, file));
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
for (const specifier of importedSpecifiers(source)) {
|
||||||
|
const rule = importBoundaryViolation(importer, specifier);
|
||||||
|
if (rule) violations.push({ importer, specifier, rule });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { filesChecked: files.length, violations };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const result = checkImportBoundaries();
|
||||||
|
if (result.violations.length) {
|
||||||
|
for (const violation of result.violations) {
|
||||||
|
console.error(`${violation.importer}: ${violation.rule} (${violation.specifier})`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`Import boundaries: ${result.filesChecked} files checked.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
fs.rmSync(path.resolve('.test-dist'), { recursive: true, force: true });
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
CONTROL_IMAGE="${CONTROL_IMAGE:-${GATEWAY_IMAGE:-}}"
|
||||||
|
DATAPLANE_IMAGE="${DATAPLANE_IMAGE:-${GATEWAY_IMAGE:-}}"
|
||||||
|
CONTROL_IMAGE="${CONTROL_IMAGE:?CONTROL_IMAGE or GATEWAY_IMAGE is required}"
|
||||||
|
DATAPLANE_IMAGE="${DATAPLANE_IMAGE:?DATAPLANE_IMAGE or GATEWAY_IMAGE is required}"
|
||||||
|
UPDATE_DATAPLANE="${UPDATE_DATAPLANE:-false}"
|
||||||
|
PULL_IMAGE="${PULL_IMAGE:-true}"
|
||||||
|
|
||||||
|
echo "Preparing deploy directory: ${DEPLOY_PATH}"
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
EXISTING_DATAPLANE_IMAGE="$(docker inspect --format '{{.Config.Image}}' vpn-proxy-dataplane 2>/dev/null || true)"
|
||||||
|
FIRST_SPLIT_DEPLOY=false
|
||||||
|
if [ -z "${EXISTING_DATAPLANE_IMAGE}" ]; then
|
||||||
|
FIRST_SPLIT_DEPLOY=true
|
||||||
|
elif [ "${UPDATE_DATAPLANE}" != "true" ]; then
|
||||||
|
DATAPLANE_IMAGE="${EXISTING_DATAPLANE_IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${DEPLOY_PATH}/docker-compose.server.yml" <<EOF
|
||||||
|
services:
|
||||||
|
vpn-proxy-dataplane:
|
||||||
|
image: ${DATAPLANE_IMAGE}
|
||||||
|
container_name: vpn-proxy-dataplane
|
||||||
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_COMPONENT: dataplane
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--unix-socket", "/run/vpn-proxy/dataplane.sock", "-fsS", "http://localhost/status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
vpn-proxy-control:
|
||||||
|
image: ${CONTROL_IMAGE}
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_COMPONENT: control
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
|
ports:
|
||||||
|
- "\${PORT:-3456}:\${PORT:-3456}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
|
depends_on:
|
||||||
|
vpn-proxy-dataplane:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:\${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-data:
|
||||||
|
sing-box-cache:
|
||||||
|
vpn-proxy-runtime:
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
|
||||||
|
cat > "${DEPLOY_PATH}/.env" <<'EOF'
|
||||||
|
PORT=3456
|
||||||
|
PROXY_PORT=8080
|
||||||
|
PROXY_BIND_IP=0.0.0.0
|
||||||
|
TPROXY_PORT=7895
|
||||||
|
TPROXY_MARK=1
|
||||||
|
TPROXY_TABLE=100
|
||||||
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
|
||||||
|
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
|
||||||
|
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||||
|
LOG_LEVEL=info
|
||||||
|
EOF
|
||||||
|
echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports."
|
||||||
|
else
|
||||||
|
echo "Preserving existing .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
echo "Control image: ${CONTROL_IMAGE}"
|
||||||
|
echo "Dataplane image: ${DATAPLANE_IMAGE}"
|
||||||
|
if [ "${PULL_IMAGE}" = "true" ]; then
|
||||||
|
docker compose -f docker-compose.server.yml pull vpn-proxy-control
|
||||||
|
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ] || [ "${UPDATE_DATAPLANE}" = "true" ]; then
|
||||||
|
docker compose -f docker-compose.server.yml pull vpn-proxy-dataplane
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Skipping image pull"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ]; then
|
||||||
|
echo "Migrating the legacy combined gateway to split services..."
|
||||||
|
docker stop vpn-proxy-gateway 2>/dev/null || true
|
||||||
|
docker rm vpn-proxy-gateway 2>/dev/null || true
|
||||||
|
docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90
|
||||||
|
elif [ "${UPDATE_DATAPLANE}" = "true" ]; then
|
||||||
|
echo "Updating control and dataplane..."
|
||||||
|
docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90
|
||||||
|
else
|
||||||
|
echo "Updating control; keeping dataplane running..."
|
||||||
|
docker compose -f docker-compose.server.yml up -d --no-deps --wait --wait-timeout 90 vpn-proxy-control
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Current containers:"
|
||||||
|
docker ps --filter "name=vpn-proxy-gateway" --filter "name=vpn-proxy-dataplane"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export LC_ALL=C
|
||||||
|
|
||||||
|
RUNTIME_DIR="${HARBOR_RUNTIME_DIR:-$HOME/.vpn-proxy-client/.runtime}"
|
||||||
|
STATE_FILE="$RUNTIME_DIR/network.json"
|
||||||
|
ROUTE_BIN="${HARBOR_ROUTE_BIN:-/sbin/route}"
|
||||||
|
ARP_BIN="${HARBOR_ARP_BIN:-/usr/sbin/arp}"
|
||||||
|
NETSTAT_BIN="${HARBOR_NETSTAT_BIN:-/usr/sbin/netstat}"
|
||||||
|
|
||||||
|
route_info="$($ROUTE_BIN -n get default 2>/dev/null || true)"
|
||||||
|
gateway="$(awk '/^[[:space:]]*gateway:/{print $2; exit}' <<<"$route_info")"
|
||||||
|
network_interface="$(awk '/^[[:space:]]*interface:/{print $2; exit}' <<<"$route_info")"
|
||||||
|
|
||||||
|
if [[ -z "$gateway" || -z "$network_interface" ]]; then
|
||||||
|
read -r gateway network_interface < <(
|
||||||
|
"$NETSTAT_BIN" -rn -f inet 2>/dev/null \
|
||||||
|
| awk '$1 == "default" && $2 ~ /^[0-9]+\./ { print $2, $4; exit }'
|
||||||
|
) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$gateway" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||||
|
gateway=""
|
||||||
|
network_interface=""
|
||||||
|
fi
|
||||||
|
if [[ ! "$network_interface" =~ ^[a-zA-Z0-9._-]{1,32}$ ]]; then
|
||||||
|
network_interface=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
mac=""
|
||||||
|
if [[ -n "$gateway" ]]; then
|
||||||
|
mac="$($ARP_BIN -n "$gateway" 2>/dev/null | awk '/ at /{print $4; exit}' || true)"
|
||||||
|
if [[ ! "$mac" =~ ^[a-fA-F0-9]{2}(:[a-fA-F0-9]{2}){5}$ ]]; then
|
||||||
|
mac=""
|
||||||
|
else
|
||||||
|
mac="$(printf '%s' "$mac" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$RUNTIME_DIR"
|
||||||
|
tmp="$(mktemp "${STATE_FILE}.XXXXXX")"
|
||||||
|
trap 'rm -f "$tmp"' EXIT
|
||||||
|
printf '{"gateway":"%s","interface":"%s","mac":"%s","observedAt":"%s"}\n' \
|
||||||
|
"$gateway" "$network_interface" "$mac" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$tmp"
|
||||||
|
mv "$tmp" "$STATE_FILE"
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend'];
|
||||||
|
const VERSION_FILE = 'src/shared/versions.ts';
|
||||||
|
const LEGACY_VERSION_FILE = 'src/shared/versions.js';
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const aliases = {
|
||||||
|
mac: 'macClient',
|
||||||
|
'mac-client': 'macClient',
|
||||||
|
client: 'gatewayClient',
|
||||||
|
'gateway-client': 'gatewayClient',
|
||||||
|
backend: 'gatewayBackend',
|
||||||
|
'gateway-backend': 'gatewayBackend',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseVersion(value) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
|
||||||
|
return match ? {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2]),
|
||||||
|
hotfix: Number(match[3]),
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionCompatibility(versions) {
|
||||||
|
const mac = parseVersion(versions?.macClient);
|
||||||
|
const client = parseVersion(versions?.gatewayClient);
|
||||||
|
const backend = parseVersion(versions?.gatewayBackend);
|
||||||
|
const major = Boolean(mac && client && backend
|
||||||
|
&& mac.major === client.major
|
||||||
|
&& client.major === backend.major);
|
||||||
|
const gateway = Boolean(client && backend
|
||||||
|
&& client.major === backend.major
|
||||||
|
&& client.minor === backend.minor);
|
||||||
|
return { compatible: major && gateway, major, gateway };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionsFromSource(source) {
|
||||||
|
return Object.fromEntries(COMPONENTS.map((component) => {
|
||||||
|
const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source);
|
||||||
|
if (!match) throw new Error(`Не найдена версия ${component}`);
|
||||||
|
return [component, match[1]];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function affectedComponents(files) {
|
||||||
|
const affected = new Set();
|
||||||
|
const add = (...components) => components.forEach((component) => affected.add(component));
|
||||||
|
for (const file of files) {
|
||||||
|
if (file === VERSION_FILE) continue;
|
||||||
|
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
||||||
|
else if (/^(src\/web\/|public\/|monitoring\/grafana\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
||||||
|
add('macClient', 'gatewayClient');
|
||||||
|
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
||||||
|
else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
|
||||||
|
add('macClient');
|
||||||
|
} else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
|
||||||
|
add('gatewayBackend');
|
||||||
|
} else if (/^(\.gitea\/workflows\/gateway-build\.yml|scripts\/runtime-impact\.mjs)$/.test(file)) {
|
||||||
|
add('gatewayBackend');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return COMPONENTS.filter((component) => affected.has(component));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVersion({ major, minor, hotfix }) {
|
||||||
|
return `${major}.${minor}.${hotfix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bumpVersions(versions, level, requested = []) {
|
||||||
|
const current = Object.fromEntries(COMPONENTS.map((component) => {
|
||||||
|
const parsed = parseVersion(versions[component]);
|
||||||
|
if (!parsed) throw new Error(`Некорректная версия ${component}: ${versions[component]}`);
|
||||||
|
return [component, parsed];
|
||||||
|
}));
|
||||||
|
if (level === 'major') {
|
||||||
|
const major = Math.max(...COMPONENTS.map((component) => current[component].major)) + 1;
|
||||||
|
return Object.fromEntries(COMPONENTS.map((component) => [component, `${major}.0.0`]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = new Set(requested.map((target) => aliases[target] || target));
|
||||||
|
if (!targets.size) throw new Error(`${level} требует хотя бы один компонент`);
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!COMPONENTS.includes(target)) throw new Error(`Неизвестный компонент: ${target}`);
|
||||||
|
}
|
||||||
|
if (level === 'minor' && (targets.has('gatewayClient') || targets.has('gatewayBackend'))) {
|
||||||
|
targets.add('gatewayClient');
|
||||||
|
targets.add('gatewayBackend');
|
||||||
|
}
|
||||||
|
if (!['minor', 'hotfix'].includes(level)) throw new Error(`Неизвестный уровень: ${level}`);
|
||||||
|
|
||||||
|
const next = { ...versions };
|
||||||
|
if (level === 'minor' && targets.has('gatewayClient')) {
|
||||||
|
const minor = Math.max(current.gatewayClient.minor, current.gatewayBackend.minor) + 1;
|
||||||
|
next.gatewayClient = `${current.gatewayClient.major}.${minor}.0`;
|
||||||
|
next.gatewayBackend = `${current.gatewayBackend.major}.${minor}.0`;
|
||||||
|
targets.delete('gatewayClient');
|
||||||
|
targets.delete('gatewayBackend');
|
||||||
|
}
|
||||||
|
for (const target of targets) {
|
||||||
|
const value = current[target];
|
||||||
|
next[target] = level === 'minor'
|
||||||
|
? `${value.major}.${value.minor + 1}.0`
|
||||||
|
: formatVersion({ ...value, hotfix: value.hotfix + 1 });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(args) {
|
||||||
|
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedFiles(base) {
|
||||||
|
const tracked = git(['diff', '--no-renames', '--name-only', base, '--']).split('\n');
|
||||||
|
const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n');
|
||||||
|
return [...new Set([...tracked, ...untracked].filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function baselineVersions(base) {
|
||||||
|
try {
|
||||||
|
return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`]));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return versionsFromSource(git(['show', `${base}:${LEGACY_VERSION_FILE}`]));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(before, after) {
|
||||||
|
const left = parseVersion(before);
|
||||||
|
const right = parseVersion(after);
|
||||||
|
if (!left || !right) return -1;
|
||||||
|
for (const key of ['major', 'minor', 'hotfix']) {
|
||||||
|
if (right[key] !== left[key]) return right[key] > left[key] ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCompatibility(versions) {
|
||||||
|
const compatibility = versionCompatibility(versions);
|
||||||
|
if (!compatibility.major) throw new Error('У всех компонентов должен совпадать major');
|
||||||
|
if (!compatibility.gateway) throw new Error('Gateway client и backend должны совпадать по major.minor');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeVersions(versions) {
|
||||||
|
const file = path.join(root, VERSION_FILE);
|
||||||
|
let source = fs.readFileSync(file, 'utf8');
|
||||||
|
for (const component of COMPONENTS) {
|
||||||
|
source = source.replace(
|
||||||
|
new RegExp(`(${component}:\\s*')\\d+\\.\\d+\\.\\d+(')`),
|
||||||
|
`$1${versions[component]}$2`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printVersions(versions) {
|
||||||
|
for (const component of COMPONENTS) console.log(`${component}: ${versions[component]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main([command = 'check', ...args]) {
|
||||||
|
const source = fs.readFileSync(path.join(root, VERSION_FILE), 'utf8');
|
||||||
|
const current = versionsFromSource(source);
|
||||||
|
validateCompatibility(current);
|
||||||
|
|
||||||
|
if (command === 'bump') {
|
||||||
|
const next = bumpVersions(current, args[0], args.slice(1));
|
||||||
|
validateCompatibility(next);
|
||||||
|
writeVersions(next);
|
||||||
|
printVersions(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = args[0] || 'HEAD';
|
||||||
|
const affected = affectedComponents(changedFiles(base));
|
||||||
|
if (command === 'affected') {
|
||||||
|
console.log(affected.length ? affected.join('\n') : 'Нет изменений, требующих bump.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command !== 'check') throw new Error(`Неизвестная команда: ${command}`);
|
||||||
|
|
||||||
|
const baseline = baselineVersions(base);
|
||||||
|
if (!baseline) {
|
||||||
|
console.log('Version contract создаётся впервые; baseline для bump отсутствует.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const missing = affected.filter((component) => compareVersions(baseline[component], current[component]) <= 0);
|
||||||
|
if (missing.length) throw new Error(`Не повышена версия: ${missing.join(', ')}`);
|
||||||
|
console.log(affected.length ? `Version check: ${affected.join(', ')}` : 'Version check: bump не требуется.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||||
|
try {
|
||||||
|
main(process.argv.slice(2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[harbor-version] ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+371
@@ -0,0 +1,371 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
||||||
|
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||||
|
ARCHIVE_URL="${VPN_PROXY_ARCHIVE_URL:-https://git.dokops.ru/dokril/vpn-proxy/archive/${BRANCH}.tar.gz}"
|
||||||
|
SOURCE_DIR="${VPN_PROXY_SOURCE_DIR:-}"
|
||||||
|
COMPOSE_FILE="docker-compose.client.yml"
|
||||||
|
DEFAULT_PROXY_PORT="8082"
|
||||||
|
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||||
|
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||||
|
TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
|
TARGET_TRAFFIC_SOURCE="${SING_BOX_TRAFFIC_SOURCE:-native}"
|
||||||
|
CLIENT_CONTAINER_NAME="harbor-connect"
|
||||||
|
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||||
|
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[harbor-connect] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[harbor-connect] error: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
need() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_valid_port() {
|
||||||
|
case "$1" in
|
||||||
|
''|*[!0-9]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_proxy_port() {
|
||||||
|
local value=""
|
||||||
|
if [ -n "$REQUESTED_PROXY_PORT" ]; then
|
||||||
|
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$REQUESTED_PROXY_PORT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r /dev/tty ]; then
|
||||||
|
while true; do
|
||||||
|
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$DEFAULT_PROXY_PORT}"
|
||||||
|
if is_valid_port "$value"; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_valid_port "$DEFAULT_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$DEFAULT_PROXY_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
published_port_conflicts() {
|
||||||
|
local port="$1"
|
||||||
|
local line
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
case "$line" in
|
||||||
|
"${CLIENT_CONTAINER_NAME}"$'\t'*|"${LEGACY_CLIENT_CONTAINER_NAME}"$'\t'*) ;;
|
||||||
|
*) printf '%s\n' "$line" ;;
|
||||||
|
esac
|
||||||
|
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_port_conflicts() {
|
||||||
|
published_port_conflicts "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_proxy_port_available() {
|
||||||
|
local port="$1"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(proxy_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[harbor-connect] proxy port %s is already used:\n%s\n' \
|
||||||
|
"$port" "$conflicts" >&2
|
||||||
|
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_single_port_available() {
|
||||||
|
local label="$1"
|
||||||
|
local port="$2"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(published_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[harbor-connect] %s port %s is already used:\n%s\n' \
|
||||||
|
"$label" "$port" "$conflicts" >&2
|
||||||
|
die "choose another ${label} port or stop the conflicting container"
|
||||||
|
}
|
||||||
|
|
||||||
|
first_free_port() {
|
||||||
|
local start="$1"
|
||||||
|
local port
|
||||||
|
|
||||||
|
for port in $(seq "$start" 65535); do
|
||||||
|
if [ -z "$(published_port_conflicts "$port")" ]; then
|
||||||
|
printf '%s\n' "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_ui_port() {
|
||||||
|
local value="$1"
|
||||||
|
local suggested
|
||||||
|
|
||||||
|
if ! is_valid_port "$value"; then
|
||||||
|
die "CLIENT_UI_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then
|
||||||
|
assert_single_port_available "UI" "$value"
|
||||||
|
fi
|
||||||
|
|
||||||
|
suggested="$(first_free_port "$((value + 1))" || true)"
|
||||||
|
suggested="${suggested:-3457}"
|
||||||
|
while true; do
|
||||||
|
printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$suggested}"
|
||||||
|
if is_valid_port "$value" && [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a free port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_ui_outside_proxy_range() {
|
||||||
|
if [ "$UI_PORT" = "$PROXY_PORT" ]; then
|
||||||
|
die "UI port ${UI_PORT} overlaps proxy port"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_client_ui() {
|
||||||
|
local ui_port="${UI_PORT:-3456}"
|
||||||
|
local ui_url="http://127.0.0.1:${ui_port}/api/state"
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n[harbor-connect] client did not become ready at %s\n' "$ui_url" >&2
|
||||||
|
printf '[harbor-connect] docker compose status:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||||
|
printf '\n[harbor-connect] recent service logs:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=120 harbor-connect >&2 || true
|
||||||
|
die "client UI is not ready; see Docker status and logs above"
|
||||||
|
}
|
||||||
|
|
||||||
|
xml_escape() {
|
||||||
|
sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
install_network_monitor() {
|
||||||
|
local launch_agents_dir="$HOME/Library/LaunchAgents"
|
||||||
|
local plist_path="$launch_agents_dir/${NETWORK_MONITOR_LABEL}.plist"
|
||||||
|
local escaped_script_path
|
||||||
|
local escaped_runtime_dir
|
||||||
|
local user_domain="gui/$(id -u)"
|
||||||
|
|
||||||
|
escaped_script_path="$(printf '%s' "$INSTALL_DIR/scripts/harbor-network-monitor.sh" | xml_escape)"
|
||||||
|
escaped_runtime_dir="$(printf '%s' "$INSTALL_DIR/.runtime" | xml_escape)"
|
||||||
|
mkdir -p "$INSTALL_DIR/.runtime" "$launch_agents_dir"
|
||||||
|
|
||||||
|
cat > "$plist_path" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${NETWORK_MONITOR_LABEL}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>${escaped_script_path}</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>HARBOR_RUNTIME_DIR</key>
|
||||||
|
<string>${escaped_runtime_dir}</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StartInterval</key>
|
||||||
|
<integer>5</integer>
|
||||||
|
<key>ProcessType</key>
|
||||||
|
<string>Background</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
/bin/bash "$INSTALL_DIR/scripts/harbor-network-monitor.sh"
|
||||||
|
launchctl bootout "$user_domain" "$plist_path" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootstrap "$user_domain" "$plist_path"
|
||||||
|
log "automatic Gateway detection enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
|
||||||
|
if [ -f .env ] && grep -q "^${key}=" .env; then
|
||||||
|
awk -v key="$key" -v value="$value" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print key "=" value; next }
|
||||||
|
{ print }
|
||||||
|
' .env > "$tmp"
|
||||||
|
else
|
||||||
|
[ -f .env ] && cat .env > "$tmp"
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$tmp" .env
|
||||||
|
}
|
||||||
|
|
||||||
|
get_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
[ -f .env ] || return 0
|
||||||
|
awk -v key="$key" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }
|
||||||
|
' .env
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_source() {
|
||||||
|
local source_dir="$1"
|
||||||
|
[ -f "$source_dir/docker-compose.client.yml" ] || die "invalid Harbor source archive"
|
||||||
|
log "installing files to $INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='.runtime' \
|
||||||
|
--exclude='.git' \
|
||||||
|
"$source_dir/" "$INSTALL_DIR/"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_source() {
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
mkdir -p "$tmp_dir/source"
|
||||||
|
log "downloading $ARCHIVE_URL"
|
||||||
|
if ! curl -fsSL "$ARCHIVE_URL" | tar -xzf - -C "$tmp_dir/source" --strip-components=1; then
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
die "failed to download Harbor source"
|
||||||
|
fi
|
||||||
|
copy_source "$tmp_dir/source"
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||||
|
die "this installer is intended for macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need docker
|
||||||
|
need curl
|
||||||
|
need rsync
|
||||||
|
need tar
|
||||||
|
|
||||||
|
case "$TARGET_TRAFFIC_SOURCE" in
|
||||||
|
native|disabled) ;;
|
||||||
|
*) die "SING_BOX_TRAFFIC_SOURCE must be native or disabled" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||||
|
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||||
|
|
||||||
|
if [[ -n "$SOURCE_DIR" ]]; then
|
||||||
|
copy_source "$SOURCE_DIR"
|
||||||
|
elif [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
need git
|
||||||
|
log "updating $INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
download_source
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f .env && -f .env.example ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY_PORT="$(ask_proxy_port)"
|
||||||
|
assert_proxy_port_available "$PROXY_PORT"
|
||||||
|
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
|
||||||
|
UI_PORT="${UI_PORT:-3456}"
|
||||||
|
UI_PORT="$(choose_ui_port "$UI_PORT")"
|
||||||
|
assert_ui_outside_proxy_range
|
||||||
|
|
||||||
|
set_env_value APP_MODE client
|
||||||
|
set_env_value SINGBOX_VERSION "$TARGET_SINGBOX_VERSION"
|
||||||
|
set_env_value SING_BOX_TRAFFIC_SOURCE "$TARGET_TRAFFIC_SOURCE"
|
||||||
|
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||||
|
set_env_value PROXY_PORT "$PROXY_PORT"
|
||||||
|
|
||||||
|
log "UI port: http://127.0.0.1:${UI_PORT}"
|
||||||
|
log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
||||||
|
|
||||||
|
install_network_monitor
|
||||||
|
|
||||||
|
log "building and starting Docker client"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||||
|
wait_for_client_ui
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Harbor Connect is running.
|
||||||
|
|
||||||
|
UI:
|
||||||
|
http://127.0.0.1:${UI_PORT}
|
||||||
|
|
||||||
|
Proxy:
|
||||||
|
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
|
||||||
|
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
cd ~/.vpn-proxy-client
|
||||||
|
docker compose -f docker-compose.client.yml logs -f
|
||||||
|
docker compose -f docker-compose.client.yml restart
|
||||||
|
docker compose -f docker-compose.client.yml down
|
||||||
|
|
||||||
|
Optional macOS system proxy example:
|
||||||
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
|
||||||
|
Disable later:
|
||||||
|
networksetup -setwebproxystate Wi-Fi off
|
||||||
|
networksetup -setsecurewebproxystate Wi-Fi off
|
||||||
|
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||||
|
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const CODE_EXTENSION = String.raw`\.[cm]?[jt]sx?$`;
|
||||||
|
const noRuntimeImpact = [
|
||||||
|
/^\.codex\//,
|
||||||
|
/^\.tmp-tests\//,
|
||||||
|
/^docs\//,
|
||||||
|
/^test\//,
|
||||||
|
/^workpack\//,
|
||||||
|
/^(?:AGENTS|PRODUCT|README)\.md$/,
|
||||||
|
/^context\.md$/,
|
||||||
|
/^\.env\.example$/,
|
||||||
|
/^\.gitignore$/,
|
||||||
|
/^Dockerfile\.client$/,
|
||||||
|
/^docker-compose\.client(?:\.local)?\.yml$/,
|
||||||
|
/^entrypoint\.client\.sh$/,
|
||||||
|
/^install\.sh$/,
|
||||||
|
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
|
||||||
|
/^tools\/test-singbox-(?:client-rc|gateway-native-traffic|native-traffic)\.sh$/,
|
||||||
|
];
|
||||||
|
const foundation = [
|
||||||
|
/^\.dockerignore$/,
|
||||||
|
/^\.gitea\/workflows\//,
|
||||||
|
/^Dockerfile(?:\.runtime-base)?$/,
|
||||||
|
/^docker-compose\.gateway\.yml$/,
|
||||||
|
/^entrypoint\.sh$/,
|
||||||
|
/^package(?:-lock)?\.json$/,
|
||||||
|
/^scripts\/(?:build-on-107-deploy-111|build-runtime-base|deploy-gateway)\.sh$/,
|
||||||
|
/^scripts\/runtime-impact\.mjs$/,
|
||||||
|
/^tsconfig(?:\.[^.]+)?\.json$/,
|
||||||
|
];
|
||||||
|
const controlAndDataplane = [
|
||||||
|
/^buf\.gen\.yaml$/,
|
||||||
|
/^proto\//,
|
||||||
|
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/(?:config|gatewayNativeRuntime|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
|
||||||
|
/^src\/server\/generated\//,
|
||||||
|
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|liveTrafficService|singboxSelectorService)${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors|liveTraffic)${CODE_EXTENSION}`),
|
||||||
|
/^src\/server\/infrastructure\/dataplane\//,
|
||||||
|
];
|
||||||
|
const dataplane = [
|
||||||
|
new RegExp(`^src/server/dataplane${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/services/(?:deviceTrafficService|domainTrafficService)${CODE_EXTENSION}`),
|
||||||
|
];
|
||||||
|
const control = [
|
||||||
|
/^index\.html$/,
|
||||||
|
/^monitoring\//,
|
||||||
|
/^public\//,
|
||||||
|
/^src\/server\//,
|
||||||
|
/^src\/shared\//,
|
||||||
|
/^src\/web\//,
|
||||||
|
/^vite\.config\.[cm]?[jt]s$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchesAny(file, patterns) {
|
||||||
|
return patterns.some((pattern) => pattern.test(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFile(file) {
|
||||||
|
return file.trim().replaceAll('\\', '/').replace(/^\.\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyRuntimeImpact(files) {
|
||||||
|
const affected = new Set();
|
||||||
|
for (const value of files) {
|
||||||
|
const file = normalizeFile(value);
|
||||||
|
if (!file || matchesAny(file, noRuntimeImpact)) continue;
|
||||||
|
if (matchesAny(file, foundation) || matchesAny(file, controlAndDataplane)) {
|
||||||
|
affected.add('control');
|
||||||
|
affected.add('dataplane');
|
||||||
|
} else if (matchesAny(file, dataplane)) {
|
||||||
|
affected.add('dataplane');
|
||||||
|
} else if (matchesAny(file, control)) {
|
||||||
|
affected.add('control');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unclassified path: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedComponents = ['control', 'dataplane'].filter((component) => affected.has(component));
|
||||||
|
const restartScope = affected.has('dataplane') ? 'both' : affected.has('control') ? 'control' : 'none';
|
||||||
|
return { affectedComponents, restartScope };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImpact(impact) {
|
||||||
|
return [
|
||||||
|
`affected-components=${impact.affectedComponents.join('+') || 'none'}`,
|
||||||
|
`restart-scope=${impact.restartScope}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const files = args.includes('--stdin')
|
||||||
|
? fs.readFileSync(0, 'utf8').split(/\r?\n/)
|
||||||
|
: args;
|
||||||
|
console.log(formatImpact(classifyRuntimeImpact(files)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[runtime-impact] ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const ACTIVE_STATES = new Set(['REACHABLE', 'DELAY', 'PROBE', 'PERMANENT', 'NOARP']);
|
||||||
|
const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']);
|
||||||
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
|
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
||||||
|
|
||||||
|
interface NeighborEntry extends Record<string, unknown> {
|
||||||
|
state?: unknown;
|
||||||
|
lladdr?: unknown;
|
||||||
|
dev?: unknown;
|
||||||
|
dst?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeighborObservation {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
active: boolean;
|
||||||
|
observedAt: string;
|
||||||
|
source: 'neighbor';
|
||||||
|
}
|
||||||
|
|
||||||
|
function neighborEntry(value: unknown): NeighborEntry {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as NeighborEntry
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDeviceInterface(value: unknown) {
|
||||||
|
const name = String(value || '');
|
||||||
|
return INTERFACE_PATTERN.test(name)
|
||||||
|
&& name !== 'docker0' && !name.startsWith('br-') && !name.startsWith('veth');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNeighborSnapshot(value: unknown, observedAt = new Date().toISOString()): NeighborObservation[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.flatMap((value) => {
|
||||||
|
const entry = neighborEntry(value);
|
||||||
|
const states = (Array.isArray(entry.state) ? entry.state : [entry.state])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((state: unknown) => String(state).toUpperCase());
|
||||||
|
const mac = String(entry.lladdr || '').toLowerCase();
|
||||||
|
const deviceInterface = String(entry.dev || '');
|
||||||
|
if (!entry.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac)
|
||||||
|
|| states.some((state) => IGNORED_STATES.has(state))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
ip: String(entry.dst),
|
||||||
|
mac,
|
||||||
|
interface: deviceInterface,
|
||||||
|
active: states.some((state) => ACTIVE_STATES.has(state)),
|
||||||
|
observedAt,
|
||||||
|
source: 'neighbor',
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNeighborSnapshot(run: typeof spawnSync = spawnSync, now = () => new Date()) {
|
||||||
|
const observedAt = now().toISOString();
|
||||||
|
const result = run('ip', ['-j', 'neigh', 'show'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 1500,
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
return {
|
||||||
|
observedAt,
|
||||||
|
observations: [],
|
||||||
|
error: result.error?.message || String(result.stderr || 'ip neigh завершился с ошибкой').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
observedAt,
|
||||||
|
observations: parseNeighborSnapshot(JSON.parse(result.stdout || '[]'), observedAt),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || path.resolve('.vpn-proxy');
|
|
||||||
|
|
||||||
export const settings = {
|
|
||||||
port: Number(process.env.PORT || 3456),
|
|
||||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
|
||||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
|
||||||
bindIp: process.env.PROXY_BIND_IP || '0.0.0.0',
|
|
||||||
dataDir,
|
|
||||||
distDir: process.env.DIST_DIR || '/app/dist',
|
|
||||||
configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json',
|
|
||||||
cachePath: process.env.SING_BOX_CACHE || '/var/lib/sing-box/cache.db',
|
|
||||||
statePath: path.join(dataDir, 'state.json'),
|
|
||||||
customRulesPath: path.join(dataDir, 'custom-rules.json'),
|
|
||||||
subscriptionCachePath: path.join(dataDir, 'subscription-cache.json'),
|
|
||||||
hwidPath: path.join(dataDir, 'hwid'),
|
|
||||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || 'true') !== 'false',
|
|
||||||
logLevel: process.env.LOG_LEVEL || 'info',
|
|
||||||
appName: 'VPN Proxy Gateway',
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const appMode = process.env.APP_MODE === "client" ? "client" : "gateway";
|
||||||
|
const appComponent = process.env.APP_COMPONENT || "";
|
||||||
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
|
const parsePort = (value: string | undefined, fallback: number) => {
|
||||||
|
const parsed = Number.parseInt(value || '', 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
const proxyPort = parsePort(
|
||||||
|
process.env.PROXY_PORT,
|
||||||
|
appMode === "client" ? 8082 : 8080,
|
||||||
|
);
|
||||||
|
const trafficSource = process.env.SING_BOX_TRAFFIC_SOURCE
|
||||||
|
|| (appMode === "client" ? "native" : "snapshot");
|
||||||
|
if (appMode === "client" && trafficSource !== "native" && trafficSource !== "disabled") {
|
||||||
|
throw new Error("SING_BOX_TRAFFIC_SOURCE must be native or disabled in client mode");
|
||||||
|
}
|
||||||
|
if (appMode === "gateway" && !["snapshot", "shadow", "native"].includes(trafficSource)) {
|
||||||
|
throw new Error("SING_BOX_TRAFFIC_SOURCE must be snapshot, shadow or native in gateway mode");
|
||||||
|
}
|
||||||
|
if (appMode === "gateway" && trafficSource !== "snapshot"
|
||||||
|
&& ((appComponent !== "control" && appComponent !== "dataplane")
|
||||||
|
|| !process.env.DATAPLANE_SOCKET?.trim())) {
|
||||||
|
throw new Error("Gateway shadow and native traffic modes require split control/dataplane topology");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
appMode,
|
||||||
|
appComponent,
|
||||||
|
port: parsePort(process.env.PORT, 3456),
|
||||||
|
proxyPort,
|
||||||
|
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
|
||||||
|
failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081),
|
||||||
|
failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082),
|
||||||
|
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090),
|
||||||
|
singboxNativeApiPort: 19091,
|
||||||
|
singboxTrafficSource: trafficSource as "native" | "disabled" | "snapshot" | "shadow",
|
||||||
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
|
tproxyMark: process.env.TPROXY_MARK || "1",
|
||||||
|
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||||
|
devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY",
|
||||||
|
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
|
||||||
|
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
|
||||||
|
deviceTrafficAccountingEnabled: process.env.DEVICE_TRAFFIC_ACCOUNTING_ENABLED !== "false",
|
||||||
|
directTrafficChain: process.env.DIRECT_TRAFFIC_CHAIN || "VPN_PROXY_DIRECT",
|
||||||
|
directTrafficMark: process.env.DIRECT_TRAFFIC_MARK || "0x40000000",
|
||||||
|
gatewayClientCidrs: (process.env.GATEWAY_CLIENT_CIDRS
|
||||||
|
|| "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16")
|
||||||
|
.trim().split(/\s+/).filter(Boolean),
|
||||||
|
bypassCidrs: (process.env.BYPASS_CIDRS
|
||||||
|
|| "0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4")
|
||||||
|
.trim().split(/\s+/).filter(Boolean),
|
||||||
|
dataplaneSocket: process.env.DATAPLANE_SOCKET || "/run/vpn-proxy/dataplane.sock",
|
||||||
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
|
dataDir,
|
||||||
|
distDir: process.env.DIST_DIR || "/app/dist",
|
||||||
|
configPath:
|
||||||
|
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||||
|
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||||
|
gatewayNativeApiSecretPath:
|
||||||
|
process.env.SING_BOX_API_SECRET || "/var/lib/sing-box/api.secret",
|
||||||
|
gatewayRuntimeConfigPath:
|
||||||
|
process.env.SING_BOX_RUNTIME_CONFIG || "/var/lib/sing-box/runtime-config.json",
|
||||||
|
statePath: path.join(dataDir, "state.json"),
|
||||||
|
deviceStatePath: path.join(dataDir, "devices.json"),
|
||||||
|
activityJournalPath: path.join(dataDir, "activity-journal.json"),
|
||||||
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
|
hostNetworkStatePath:
|
||||||
|
process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json",
|
||||||
|
gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456),
|
||||||
|
subscriptionTimeoutMs: parsePort(process.env.SUBSCRIPTION_TIMEOUT_MS, 15_000),
|
||||||
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
|
logLevel: process.env.LOG_LEVEL || "info",
|
||||||
|
appName: "VPN Proxy Gateway",
|
||||||
|
};
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||||
|
import { buildVersionInfo } from './version.js';
|
||||||
|
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
||||||
|
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
||||||
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import {
|
||||||
|
createDomainTrafficService,
|
||||||
|
readSingboxConnections,
|
||||||
|
} from './services/domainTrafficService.js';
|
||||||
|
import { deviceId } from './services/deviceInventoryService.js';
|
||||||
|
import {
|
||||||
|
createLiveTrafficService,
|
||||||
|
} from './services/liveTrafficService.js';
|
||||||
|
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||||
|
|
||||||
|
const socketPath = settings.dataplaneSocket;
|
||||||
|
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
|
||||||
|
const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native';
|
||||||
|
const runtime = createSingboxRuntime({
|
||||||
|
configPath: settings.configPath,
|
||||||
|
gateway: true,
|
||||||
|
tproxyChain: settings.tproxyChain,
|
||||||
|
gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||||
|
...(nativeTrafficEnabled ? {
|
||||||
|
nativeApi: {
|
||||||
|
apiPort: settings.singboxNativeApiPort,
|
||||||
|
secretPath: settings.gatewayNativeApiSecretPath,
|
||||||
|
runtimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
});
|
||||||
|
const versionInfo = buildVersionInfo('gateway');
|
||||||
|
const traffic = createDeviceTrafficService({
|
||||||
|
observe: () => readNeighborSnapshot(),
|
||||||
|
uploadChain: settings.trafficUploadChain,
|
||||||
|
downloadChain: settings.trafficDownloadChain,
|
||||||
|
directChain: settings.directTrafficChain,
|
||||||
|
directMark: settings.directTrafficMark,
|
||||||
|
tproxyMark: settings.tproxyMark,
|
||||||
|
gatewayClientCidrs: settings.gatewayClientCidrs,
|
||||||
|
bypassCidrs: settings.bypassCidrs,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
});
|
||||||
|
const devicePolicy = createDevicePolicyService({
|
||||||
|
chain: settings.devicePolicyChain,
|
||||||
|
tproxyPort: settings.tproxyPort,
|
||||||
|
tproxyMark: settings.tproxyMark,
|
||||||
|
});
|
||||||
|
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
||||||
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
|
});
|
||||||
|
const failoverDiagnostics = {
|
||||||
|
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||||
|
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||||
|
};
|
||||||
|
const selector = createSingboxSelectorService({ port: settings.singboxApiPort });
|
||||||
|
const snapshotDomainTraffic = createDomainTrafficService({
|
||||||
|
observe: () => readSingboxConnections(settings.singboxApiPort),
|
||||||
|
devices: () => traffic.snapshot().devices,
|
||||||
|
});
|
||||||
|
const nativeDomainTraffic = createDomainTrafficService({
|
||||||
|
observe: () => ({ connections: [] }),
|
||||||
|
devices: () => traffic.snapshot().devices,
|
||||||
|
});
|
||||||
|
const domainTraffic = trafficMode === 'native' ? nativeDomainTraffic : snapshotDomainTraffic;
|
||||||
|
let originsByIp = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||||
|
let liveTraffic = createLiveTrafficService({
|
||||||
|
port: settings.singboxNativeApiPort,
|
||||||
|
enabled: false,
|
||||||
|
gateway: true,
|
||||||
|
isRuntimeRunning: () => false,
|
||||||
|
resolveOrigin,
|
||||||
|
});
|
||||||
|
let ready = false;
|
||||||
|
let trafficTimer: NodeJS.Timeout | null = null;
|
||||||
|
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||||
|
const MAX_POLICY_BODY_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOrigins(devices: unknown) {
|
||||||
|
const next = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||||
|
for (const value of Array.isArray(devices) ? devices : []) {
|
||||||
|
const device = record(value);
|
||||||
|
const ip = String(device.ip || '');
|
||||||
|
const mac = String(device.mac || '').toLowerCase();
|
||||||
|
if (!net.isIPv4(ip) || !/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(mac)) continue;
|
||||||
|
const origin: LiveTrafficConnection['origin'] = {
|
||||||
|
kind: 'device',
|
||||||
|
id: deviceId(mac),
|
||||||
|
label: ip,
|
||||||
|
provenance: 'source-ip',
|
||||||
|
};
|
||||||
|
next.set(ip, next.has(ip) ? null : origin);
|
||||||
|
}
|
||||||
|
originsByIp = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
|
||||||
|
return originsByIp.get(sourceIp) || {
|
||||||
|
kind: 'unknown',
|
||||||
|
id: null,
|
||||||
|
label: 'Неизвестное устройство',
|
||||||
|
provenance: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDeviceTraffic() {
|
||||||
|
try {
|
||||||
|
return await traffic.refresh();
|
||||||
|
} finally {
|
||||||
|
refreshOrigins();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshOrigins() {
|
||||||
|
updateOrigins(readNeighborSnapshot().observations);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimal(value: unknown) {
|
||||||
|
return typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackedTotals(snapshot: unknown) {
|
||||||
|
let upload = 0n;
|
||||||
|
let download = 0n;
|
||||||
|
for (const value of Array.isArray(record(snapshot).tracked) ? record(snapshot).tracked as unknown[] : []) {
|
||||||
|
const entry = record(value);
|
||||||
|
upload += decimal(entry.uploadBytes);
|
||||||
|
download += decimal(entry.downloadBytes);
|
||||||
|
}
|
||||||
|
return { upload, download };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mismatchCount(left: unknown, right: unknown, fields: string[]) {
|
||||||
|
const entries = (value: unknown) => {
|
||||||
|
const values = Array.isArray(value) ? value : [];
|
||||||
|
return new Map(values.map((item) => {
|
||||||
|
const entry = record(item);
|
||||||
|
const key = fields.map((field) => String(entry[field] || '')).join('\0');
|
||||||
|
return [key, `${entry.uploadBytes || '0'}\0${entry.downloadBytes || '0'}`];
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const leftEntries = entries(left);
|
||||||
|
const rightEntries = entries(right);
|
||||||
|
const keys = new Set([...leftEntries.keys(), ...rightEntries.keys()]);
|
||||||
|
let mismatches = 0;
|
||||||
|
for (const key of keys) if (leftEntries.get(key) !== rightEntries.get(key)) mismatches += 1;
|
||||||
|
return mismatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveTrafficSnapshot(): LiveTrafficSnapshot {
|
||||||
|
const snapshot = liveTraffic.snapshot();
|
||||||
|
return nativeTrafficEnabled && runtime.nativeApiWarning ? {
|
||||||
|
...snapshot,
|
||||||
|
source: {
|
||||||
|
...snapshot.source,
|
||||||
|
state: 'incompatible',
|
||||||
|
error: runtime.nativeApiWarning,
|
||||||
|
},
|
||||||
|
} : snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trafficCollectorSource() {
|
||||||
|
const canonical = domainTraffic.snapshot();
|
||||||
|
const canonicalSource = record(canonical.source);
|
||||||
|
const nativeLive = nativeTrafficEnabled ? liveTrafficSnapshot() : null;
|
||||||
|
const nativeProjection = nativeDomainTraffic.snapshot();
|
||||||
|
const legacyProjection = snapshotDomainTraffic.snapshot();
|
||||||
|
let shadow = null;
|
||||||
|
if (trafficMode === 'shadow') {
|
||||||
|
const nativeTotals = trackedTotals(nativeProjection);
|
||||||
|
const legacyTotals = trackedTotals(legacyProjection);
|
||||||
|
shadow = {
|
||||||
|
activeDifference: Number(record(nativeProjection.source).activeConnections || 0)
|
||||||
|
- Number(record(legacyProjection.source).activeConnections || 0),
|
||||||
|
uploadDifferenceBytes: (nativeTotals.upload - legacyTotals.upload).toString(),
|
||||||
|
downloadDifferenceBytes: (nativeTotals.download - legacyTotals.download).toString(),
|
||||||
|
routeMismatches: mismatchCount(nativeProjection.tracked, legacyProjection.tracked, ['source', 'outbound']),
|
||||||
|
deviceMismatches: mismatchCount(nativeProjection.routes, legacyProjection.routes, ['deviceId', 'source', 'outbound']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: runtime.nativeApiWarning
|
||||||
|
|| (trafficMode === 'native' ? nativeLive?.source.error : canonicalSource.error)
|
||||||
|
|| null,
|
||||||
|
mode: trafficMode,
|
||||||
|
writer: trafficMode === 'native' ? 'native' as const : 'snapshot' as const,
|
||||||
|
activeConnections: Number(canonicalSource.activeConnections || 0),
|
||||||
|
native: nativeLive ? {
|
||||||
|
state: nativeLive.source.state,
|
||||||
|
epoch: nativeLive.epoch,
|
||||||
|
sequence: nativeLive.sequence,
|
||||||
|
observedAt: nativeLive.observedAt,
|
||||||
|
active: nativeLive.summary.active,
|
||||||
|
unattributedUploadBytes: nativeLive.source.unattributedUploadBytes,
|
||||||
|
unattributedDownloadBytes: nativeLive.source.unattributedDownloadBytes,
|
||||||
|
} : null,
|
||||||
|
shadow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainTrafficSnapshot() {
|
||||||
|
const snapshot = domainTraffic.snapshot();
|
||||||
|
return { ...snapshot, source: trafficCollectorSource() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
req.on('data', (chunk: Buffer | string) => {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (!tooLarge && size > MAX_POLICY_BODY_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
reject(new Error('Device policy request слишком большой'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tooLarge) chunks.push(buffer);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Device policy request содержит невалидный JSON'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||||
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && req.url === '/status') {
|
||||||
|
return sendJson(res, ready ? 200 : 503, {
|
||||||
|
...await runtime.refresh(),
|
||||||
|
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
||||||
|
singBoxVersion: versionInfo.runtime.singBox,
|
||||||
|
devicePolicy: devicePolicy.snapshot(),
|
||||||
|
trafficCollector: trafficCollectorSource(),
|
||||||
|
ready,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/devices') {
|
||||||
|
return sendJson(res, 200, readNeighborSnapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/device-traffic') {
|
||||||
|
return sendJson(res, 200, traffic.snapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/domain-traffic') {
|
||||||
|
return sendJson(res, 200, domainTrafficSnapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/traffic/live') {
|
||||||
|
return sendJson(res, 200, liveTrafficSnapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/device-policy') {
|
||||||
|
return sendJson(res, 200, devicePolicy.snapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'PUT' && req.url === '/device-policy') {
|
||||||
|
const body = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
||||||
|
const { services = [], target = null } = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||||
|
vpnAvailable: runtime.running,
|
||||||
|
services,
|
||||||
|
target,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/failover/probe') {
|
||||||
|
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
||||||
|
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
||||||
|
return sendJson(res, 200, await failoverDiagnostics[role].runVpn({ services, target, timeoutMs: Number(timeoutMs) }));
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/failover/selector') {
|
||||||
|
return sendJson(res, 200, await selector.read());
|
||||||
|
}
|
||||||
|
if (req.method === 'PUT' && req.url === '/failover/selector') {
|
||||||
|
const { role } = record(await readJson(req));
|
||||||
|
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
||||||
|
return sendJson(res, 200, await selector.select(role));
|
||||||
|
}
|
||||||
|
if (req.method === 'PUT' && req.url === '/failover/activity') {
|
||||||
|
const { enabled } = record(await readJson(req));
|
||||||
|
if (enabled === true) domainTraffic.enableActivity();
|
||||||
|
else domainTraffic.disableActivity();
|
||||||
|
return sendJson(res, 200, { enabled: enabled === true });
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/failover/activity/read') {
|
||||||
|
const { thresholdBytesPerSecond = 0 } = record(await readJson(req));
|
||||||
|
const sourceLive = trafficMode !== 'native' || liveTrafficSnapshot().source.state === 'live';
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
activity: sourceLive ? domainTraffic.activitySnapshot(thresholdBytesPerSecond) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/config/check') {
|
||||||
|
const { config } = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, runtime.checkConfig(config));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/apply') {
|
||||||
|
return sendJson(res, 200, await runtime.apply());
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/restart') {
|
||||||
|
return sendJson(res, 200, await runtime.restart());
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/stop') {
|
||||||
|
return sendJson(res, 200, await runtime.stop());
|
||||||
|
}
|
||||||
|
return sendJson(res, 404, { error: 'Не найдено' });
|
||||||
|
} catch (error) {
|
||||||
|
return sendJson(res, 500, { error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
||||||
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
server.listen(socketPath, async () => {
|
||||||
|
fs.chmodSync(socketPath, 0o660);
|
||||||
|
try {
|
||||||
|
await runtime.apply();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
refreshOrigins();
|
||||||
|
liveTraffic = createLiveTrafficService({
|
||||||
|
port: settings.singboxNativeApiPort,
|
||||||
|
enabled: nativeTrafficEnabled,
|
||||||
|
gateway: true,
|
||||||
|
isRuntimeRunning: () => runtime.running && !runtime.nativeApiWarning,
|
||||||
|
resolveOrigin,
|
||||||
|
authorization: () => runtime.nativeApiSecret,
|
||||||
|
onProjection: (batch) => {
|
||||||
|
nativeDomainTraffic.ingestNative(batch);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
liveTraffic.start();
|
||||||
|
ready = true;
|
||||||
|
if (settings.deviceTrafficAccountingEnabled) {
|
||||||
|
setImmediate(() => {
|
||||||
|
refreshDeviceTraffic()
|
||||||
|
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||||
|
});
|
||||||
|
trafficTimer = setInterval(() => {
|
||||||
|
refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||||
|
}, 15_000);
|
||||||
|
trafficTimer.unref();
|
||||||
|
} else {
|
||||||
|
trafficTimer = setInterval(refreshOrigins, 15_000);
|
||||||
|
trafficTimer.unref();
|
||||||
|
}
|
||||||
|
if (trafficMode !== 'native') {
|
||||||
|
setImmediate(() => {
|
||||||
|
snapshotDomainTraffic.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||||
|
});
|
||||||
|
domainTrafficTimer = setInterval(() => {
|
||||||
|
snapshotDomainTraffic.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||||
|
}, 2_000);
|
||||||
|
domainTrafficTimer.unref();
|
||||||
|
}
|
||||||
|
console.log(`[dataplane] control socket: ${socketPath}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
async function shutdown() {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
ready = false;
|
||||||
|
if (trafficTimer) clearInterval(trafficTimer);
|
||||||
|
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
||||||
|
await liveTraffic.stop();
|
||||||
|
await runtime.shutdown();
|
||||||
|
server.close(() => {
|
||||||
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
|
||||||
|
type SendDataplaneRequest = (
|
||||||
|
socketPath: string,
|
||||||
|
pathname: string,
|
||||||
|
method?: string,
|
||||||
|
body?: unknown,
|
||||||
|
timeoutMs?: number,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(
|
||||||
|
socketPath: string,
|
||||||
|
pathname: string,
|
||||||
|
method = 'GET',
|
||||||
|
body: unknown = null,
|
||||||
|
timeoutMs = 6000,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const encoded = body == null ? null : JSON.stringify(body);
|
||||||
|
const req = http.request({
|
||||||
|
socketPath,
|
||||||
|
path: pathname,
|
||||||
|
method,
|
||||||
|
headers: encoded ? {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': Buffer.byteLength(encoded),
|
||||||
|
} : {},
|
||||||
|
}, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||||
|
} catch {
|
||||||
|
return reject(new Error('Dataplane вернул невалидный JSON'));
|
||||||
|
}
|
||||||
|
if ((res.statusCode || 500) >= 400) {
|
||||||
|
return reject(new Error(String(record(body).error || `Dataplane HTTP ${res.statusCode}`)));
|
||||||
|
}
|
||||||
|
resolve(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Dataplane не ответил за ${Math.ceil(timeoutMs / 1000)} секунд`)));
|
||||||
|
req.end(encoded);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDataplaneClient(socketPath: string, send: SendDataplaneRequest = request) {
|
||||||
|
let current: Record<string, unknown> = { running: false, startedAt: null };
|
||||||
|
const update = async (pathname: string, method: string) => {
|
||||||
|
try {
|
||||||
|
current = record(await send(socketPath, pathname, method));
|
||||||
|
return current;
|
||||||
|
} catch (cause) {
|
||||||
|
if (pathname === '/apply' || pathname === '/restart') {
|
||||||
|
throw new HarborError('PROCESS_START_FAILED', { cause });
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get running() { return Boolean(current.running); },
|
||||||
|
get startedAt() { return current.startedAt || null; },
|
||||||
|
refresh: () => update('/status', 'GET'),
|
||||||
|
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
||||||
|
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||||
|
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||||
|
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
|
||||||
|
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||||
|
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||||
|
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||||
|
try {
|
||||||
|
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000),
|
||||||
|
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
||||||
|
send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000)
|
||||||
|
),
|
||||||
|
readFailoverSelector: () => send(socketPath, '/failover/selector', 'GET'),
|
||||||
|
selectFailoverRole: (role: 'primary' | 'reserve') => send(socketPath, '/failover/selector', 'PUT', { role }),
|
||||||
|
setFailoverActivityEnabled: (enabled: boolean) => send(socketPath, '/failover/activity', 'PUT', { enabled }),
|
||||||
|
readFailoverActivity: (thresholdBytesPerSecond: number) => (
|
||||||
|
send(socketPath, '/failover/activity/read', 'POST', { thresholdBytesPerSecond })
|
||||||
|
),
|
||||||
|
apply: () => update('/apply', 'POST'),
|
||||||
|
restart: () => update('/restart', 'POST'),
|
||||||
|
stop: () => update('/stop', 'POST'),
|
||||||
|
shutdown: async () => current,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import {
|
||||||
|
profileById,
|
||||||
|
type StoredProfile,
|
||||||
|
type StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { finishRollback, type RollbackStep } from '../../services/rollback.js';
|
||||||
|
import type { AppliedFailoverPolicy } from '../../../shared/failover.js';
|
||||||
|
import type { ActivityJournalEventInput } from '../../../shared/activityJournal.js';
|
||||||
|
|
||||||
|
interface ConnectionServiceDependencies {
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
start(): Promise<unknown>;
|
||||||
|
stop(): Promise<unknown>;
|
||||||
|
stopCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restartCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
};
|
||||||
|
route?: { isGatewayDirect(): boolean };
|
||||||
|
failover?: {
|
||||||
|
build(state: StoredState, source?: 'desired' | 'applied'): {
|
||||||
|
config: unknown;
|
||||||
|
applied: AppliedFailoverPolicy;
|
||||||
|
primaryProfile: StoredProfile;
|
||||||
|
primaryServer: StoredProfile['servers'][number];
|
||||||
|
};
|
||||||
|
prepareActivation(role: 'primary' | 'reserve'): Promise<unknown>;
|
||||||
|
restoreAppliedActivation(state: StoredState): Promise<unknown>;
|
||||||
|
reconcile(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
onEvent?: (event: ActivityJournalEventInput) => void;
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeCommandResult =
|
||||||
|
| { ok: true; mutationStarted: true }
|
||||||
|
| { ok: false; mutationStarted: boolean; error: unknown };
|
||||||
|
|
||||||
|
export async function captureRuntimeCommand(
|
||||||
|
command: () => Promise<unknown>,
|
||||||
|
{ preMutationErrorCodes = [] }: { preMutationErrorCodes?: readonly string[] } = {},
|
||||||
|
): Promise<RuntimeCommandResult> {
|
||||||
|
try {
|
||||||
|
await command();
|
||||||
|
return { ok: true, mutationStarted: true };
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
return { ok: false, mutationStarted: !preMutationErrorCodes.includes(code), error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
|
||||||
|
if (expectedRevision === undefined) return;
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
|
||||||
|
throw new HarborError('STATE_CONFLICT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProfile(state: StoredState, profileId: unknown): StoredProfile {
|
||||||
|
const requested = String(profileId || '').trim();
|
||||||
|
const profile = profileById(state, requested)
|
||||||
|
|| (!requested && state.profiles.length === 1 ? state.profiles[0] : null);
|
||||||
|
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDesiredServer(state: StoredState, profile: StoredProfile, serverId: string) {
|
||||||
|
const nextProfile = { ...profile, desiredServerId: serverId };
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
profiles: state.profiles.map((candidate) => candidate.id === profile.id ? nextProfile : candidate),
|
||||||
|
desiredProfileId: profile.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionService(dependencies: ConnectionServiceDependencies) {
|
||||||
|
const prepareFailoverActivation = async (role: 'primary' | 'reserve') => {
|
||||||
|
try {
|
||||||
|
await dependencies.failover?.prepareActivation(role);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('PROCESS_START_FAILED', { cause });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const activationTarget = (
|
||||||
|
state: StoredState,
|
||||||
|
applied: AppliedFailoverPolicy,
|
||||||
|
role: 'primary' | 'reserve',
|
||||||
|
) => {
|
||||||
|
const target = applied[role];
|
||||||
|
const profile = profileById(state, target.profileId);
|
||||||
|
const server = profile?.servers.find(({ id }) => id === target.serverId);
|
||||||
|
if (!profile || !server) throw new HarborError('SERVER_NOT_FOUND');
|
||||||
|
return { profile, server };
|
||||||
|
};
|
||||||
|
const finishConnectionRollback = async (error: unknown, steps: RollbackStep[], message: string) => {
|
||||||
|
try {
|
||||||
|
await finishRollback(error, steps, message);
|
||||||
|
} catch (cause) {
|
||||||
|
const code = cause && typeof cause === 'object' && 'code' in cause
|
||||||
|
&& /^[A-Z0-9_]{1,50}$/.test(String(cause.code)) ? String(cause.code) : 'UNKNOWN';
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'connection.failed',
|
||||||
|
severity: 'error',
|
||||||
|
source: 'connection',
|
||||||
|
dedupeKey: `connection.failed:${dependencies.state.read().revision}:${code}`,
|
||||||
|
data: { errorCode: code },
|
||||||
|
});
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const applyWithinQueue = async (
|
||||||
|
previousState: StoredState,
|
||||||
|
profile: StoredProfile,
|
||||||
|
serverIdValue: unknown,
|
||||||
|
selectedTagValue: unknown,
|
||||||
|
) => {
|
||||||
|
const requestedId = String(serverIdValue || '').trim();
|
||||||
|
const requestedTag = String(selectedTagValue || '').trim();
|
||||||
|
const resolvedId = requestedId || (() => {
|
||||||
|
const matches = profile.servers.filter((server) => server.label === requestedTag);
|
||||||
|
return matches.length === 1 ? matches[0].id : '';
|
||||||
|
})();
|
||||||
|
const selectedServer = profile.servers.find((server) => server.id === resolvedId);
|
||||||
|
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
|
||||||
|
if (!profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||||
|
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
if (wasRunning && previousState.failoverPolicy?.enabled) {
|
||||||
|
dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
|
||||||
|
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dependencies.route?.isGatewayDirect()) {
|
||||||
|
dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
|
||||||
|
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
const failoverCandidate = previousState.failoverPolicy?.enabled
|
||||||
|
? dependencies.failover?.build(previousState, wasRunning ? 'applied' : 'desired')
|
||||||
|
: null;
|
||||||
|
const nextConfig = failoverCandidate?.config || dependencies.config.build(
|
||||||
|
profile.subscriptionConfig,
|
||||||
|
selectedServer.id,
|
||||||
|
previousState.routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(nextConfig);
|
||||||
|
runtimeMutationStarted = true;
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
if (failoverCandidate) await prepareFailoverActivation('primary');
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...withDesiredServer(state, profile, selectedServer.id),
|
||||||
|
connectionDesired: 'running',
|
||||||
|
appliedProfileId: failoverCandidate?.primaryProfile.id || profile.id,
|
||||||
|
appliedServerId: failoverCandidate?.primaryServer.id || selectedServer.id,
|
||||||
|
appliedServerSnapshot: failoverCandidate?.primaryServer || selectedServer,
|
||||||
|
appliedFailoverPolicy: failoverCandidate?.applied || null,
|
||||||
|
appliedAt: dependencies.now().toISOString(),
|
||||||
|
appliedRouteRules: state.routeRules,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishConnectionRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => previousConfig === null
|
||||||
|
? dependencies.config.remove()
|
||||||
|
: dependencies.config.restore(previousConfig),
|
||||||
|
}] : []),
|
||||||
|
...(runtimeMutationStarted ? [{
|
||||||
|
run: async () => {
|
||||||
|
if (!wasRunning) return dependencies.runtime.stop();
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||||
|
},
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await dependencies.failover?.reconcile();
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'connection.started',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'connection',
|
||||||
|
dedupeKey: `connection.started:${dependencies.state.read().revision}`,
|
||||||
|
data: {
|
||||||
|
profileLabel: failoverCandidate?.primaryProfile.label || profile.label,
|
||||||
|
serverLabel: failoverCandidate?.primaryServer.label || selectedServer.label,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = (
|
||||||
|
profileId: unknown,
|
||||||
|
serverId: unknown,
|
||||||
|
selectedTag: unknown = '',
|
||||||
|
expectedRevision?: unknown,
|
||||||
|
) => dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
requireExpectedRevision(state, expectedRevision);
|
||||||
|
return applyWithinQueue(state, resolveProfile(state, profileId), serverId, selectedTag);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activate = (profileId: unknown, expectedRevision?: unknown) => (
|
||||||
|
dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
requireExpectedRevision(state, expectedRevision);
|
||||||
|
const profile = resolveProfile(state, profileId);
|
||||||
|
const selectedServer = profile.servers.find((server) => server.id === profile.desiredServerId);
|
||||||
|
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
|
||||||
|
const running = await dependencies.runtime.isRunning();
|
||||||
|
if (!running || dependencies.route?.isGatewayDirect()) {
|
||||||
|
if (state.desiredProfileId !== profile.id) {
|
||||||
|
dependencies.state.update((current) => ({ ...current, desiredProfileId: profile.id }));
|
||||||
|
}
|
||||||
|
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||||
|
}
|
||||||
|
return applyWithinQueue(state, profile, selectedServer.id, '');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = () => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
let wasRunning: boolean | null = null;
|
||||||
|
try {
|
||||||
|
wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
} catch {}
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = await dependencies.runtime.stopCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
connectionDesired: 'stopped',
|
||||||
|
appliedProfileId: '',
|
||||||
|
appliedServerId: '',
|
||||||
|
appliedServerSnapshot: null,
|
||||||
|
appliedFailoverPolicy: null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishConnectionRollback(error, [
|
||||||
|
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||||
|
run: async () => {
|
||||||
|
if (!wasRunning) return dependencies.runtime.stop();
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||||
|
},
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
await dependencies.failover?.reconcile();
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'connection.stopped',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'connection',
|
||||||
|
dedupeKey: `connection.stopped:${dependencies.state.read().revision}`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const restart = () => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
const targetProfileId = wasRunning
|
||||||
|
? previousState.appliedProfileId
|
||||||
|
: previousState.desiredProfileId;
|
||||||
|
const targetServerId = wasRunning
|
||||||
|
? previousState.appliedServerId
|
||||||
|
: resolveProfile(previousState, targetProfileId).desiredServerId;
|
||||||
|
const profile = resolveProfile(previousState, targetProfileId);
|
||||||
|
const server = profile.servers.find((candidate) => candidate.id === targetServerId)
|
||||||
|
|| (previousState.appliedServerSnapshot?.id === targetServerId
|
||||||
|
? previousState.appliedServerSnapshot
|
||||||
|
: null);
|
||||||
|
if (!server || !profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||||
|
const failoverCandidate = previousState.failoverPolicy?.enabled
|
||||||
|
? dependencies.failover?.build(previousState, wasRunning ? 'applied' : 'desired')
|
||||||
|
: null;
|
||||||
|
const activationRole = failoverCandidate && wasRunning
|
||||||
|
&& previousState.appliedProfileId === failoverCandidate.applied.reserve.profileId
|
||||||
|
&& previousState.appliedServerId === failoverCandidate.applied.reserve.serverId
|
||||||
|
? 'reserve' as const
|
||||||
|
: 'primary' as const;
|
||||||
|
const failoverTarget = failoverCandidate
|
||||||
|
? activationTarget(previousState, failoverCandidate.applied, activationRole)
|
||||||
|
: null;
|
||||||
|
const candidateConfig = failoverCandidate?.config || dependencies.config.build(
|
||||||
|
profile.subscriptionConfig,
|
||||||
|
server.id,
|
||||||
|
previousState.routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
const command = await dependencies.runtime.restartCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
if (failoverCandidate) await prepareFailoverActivation(activationRole);
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
desiredProfileId: wasRunning ? state.desiredProfileId : profile.id,
|
||||||
|
appliedProfileId: failoverTarget?.profile.id || profile.id,
|
||||||
|
appliedServerId: failoverTarget?.server.id || server.id,
|
||||||
|
appliedServerSnapshot: failoverTarget?.server || server,
|
||||||
|
appliedFailoverPolicy: failoverCandidate?.applied || null,
|
||||||
|
connectionDesired: 'running',
|
||||||
|
appliedRouteRules: dependencies.route?.isGatewayDirect() ? [] : state.routeRules,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishConnectionRollback(error, [
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => previousConfig === null
|
||||||
|
? dependencies.config.remove()
|
||||||
|
: dependencies.config.restore(previousConfig),
|
||||||
|
}] : []),
|
||||||
|
...(runtimeMutationStarted ? [{
|
||||||
|
run: async () => {
|
||||||
|
if (!wasRunning) return dependencies.runtime.stop();
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||||
|
},
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
await dependencies.failover?.reconcile();
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'connection.started',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'connection',
|
||||||
|
dedupeKey: `connection.started:${dependencies.state.read().revision}`,
|
||||||
|
data: {
|
||||||
|
profileLabel: failoverTarget?.profile.label || profile.label,
|
||||||
|
serverLabel: failoverTarget?.server.label || server.label,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { apply, activate, stop, restart };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionService = ReturnType<typeof createConnectionService>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
captureRuntimeCommand,
|
||||||
|
createConnectionService,
|
||||||
|
type RuntimeCommandResult,
|
||||||
|
type ConnectionService,
|
||||||
|
} from './connectionService.js';
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeDiagnosticSettings,
|
||||||
|
type DiagnosticSettings,
|
||||||
|
} from '../../../shared/connectivityDiagnostics.js';
|
||||||
|
import type { HarborServer, StoredProfile, StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
|
||||||
|
interface DiagnosticState {
|
||||||
|
desiredProfileId?: unknown;
|
||||||
|
appliedProfileId?: unknown;
|
||||||
|
appliedServerId?: unknown;
|
||||||
|
appliedServerSnapshot?: HarborServer | null;
|
||||||
|
profiles?: StoredProfile[];
|
||||||
|
revision?: number;
|
||||||
|
diagnostics?: DiagnosticSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticsResult extends Record<string, unknown> {
|
||||||
|
vpn?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectivityDiagnosticsDependencies {
|
||||||
|
state: {
|
||||||
|
read(): DiagnosticState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new TypeError('Diagnostics adapter returned an invalid result');
|
||||||
|
}
|
||||||
|
return value as DiagnosticsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedServer(state: DiagnosticState) {
|
||||||
|
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
|
||||||
|
const appliedProfile = profiles.find((profile) => profile.id === state.appliedProfileId);
|
||||||
|
const applied = appliedProfile?.servers.find((server) => server.id === state.appliedServerId)
|
||||||
|
|| (state.appliedServerSnapshot?.id === state.appliedServerId
|
||||||
|
? state.appliedServerSnapshot
|
||||||
|
: null);
|
||||||
|
if (state.appliedServerId) return applied;
|
||||||
|
const desiredProfile = profiles.find((profile) => profile.id === state.desiredProfileId);
|
||||||
|
return desiredProfile?.servers.find((server) => server.id === desiredProfile.desiredServerId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsUseCase(
|
||||||
|
dependencies: ConnectivityDiagnosticsDependencies,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async run(target: unknown) {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
const selected = selectedServer(state);
|
||||||
|
const server = selected ? { id: selected.id, label: selected.label } : null;
|
||||||
|
const services = normalizeDiagnosticSettings(state.diagnostics).customServices;
|
||||||
|
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
vpn: {
|
||||||
|
...result.vpn,
|
||||||
|
server,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
updateSettings(settings: unknown, expectedRevision: unknown) {
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
let diagnostics: DiagnosticSettings;
|
||||||
|
try {
|
||||||
|
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
||||||
|
? settings as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true });
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
|
||||||
|
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectivityDiagnosticsUseCase = ReturnType<
|
||||||
|
typeof createConnectivityDiagnosticsUseCase
|
||||||
|
>;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
createConnectivityDiagnosticsUseCase,
|
||||||
|
type ConnectivityDiagnosticsUseCase,
|
||||||
|
} from './connectivityDiagnosticsUseCase.js';
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import {
|
||||||
|
createIdleFailoverSnapshot,
|
||||||
|
isFailoverConfigured,
|
||||||
|
nextFailoverDecision,
|
||||||
|
normalizeFailoverPolicy,
|
||||||
|
type AppliedFailoverPolicy,
|
||||||
|
type FailoverDecisionMemory,
|
||||||
|
type FailoverHealth,
|
||||||
|
type FailoverPolicy,
|
||||||
|
type FailoverRole,
|
||||||
|
type FailoverSnapshot,
|
||||||
|
} from '../../../shared/failover.js';
|
||||||
|
import type { HarborServer, StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import type { ActivityJournalEventInput } from '../../../shared/activityJournal.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
config: unknown;
|
||||||
|
applied: AppliedFailoverPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FailoverServiceDependencies {
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
runtime: { isRunning(): Promise<boolean> };
|
||||||
|
dataplane: {
|
||||||
|
checkConfig(config: unknown): Promise<unknown>;
|
||||||
|
runFailoverProbe(role: FailoverRole, services: unknown, target: string, timeoutMs: number): Promise<unknown>;
|
||||||
|
readFailoverSelector(): Promise<unknown>;
|
||||||
|
selectFailoverRole(role: FailoverRole): Promise<unknown>;
|
||||||
|
setFailoverActivityEnabled(enabled: boolean): Promise<unknown>;
|
||||||
|
readFailoverActivity(thresholdBytesPerSecond: number): Promise<unknown>;
|
||||||
|
};
|
||||||
|
buildCandidate(state: StoredState): Candidate;
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
scheduler?: {
|
||||||
|
setTimeout(callback: () => void, intervalMs: number): NodeJS.Timeout;
|
||||||
|
clearTimeout(timer: NodeJS.Timeout): void;
|
||||||
|
};
|
||||||
|
now?: () => Date;
|
||||||
|
onWarning?: (error: unknown) => void;
|
||||||
|
onSwitch?: (from: FailoverRole, to: FailoverRole, reason: string) => void;
|
||||||
|
onEvent?: (event: ActivityJournalEventInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = (value: unknown): Record<string, unknown> => (
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||||
|
);
|
||||||
|
|
||||||
|
function targetServer(state: StoredState, role: FailoverRole): HarborServer | null {
|
||||||
|
const target = state.appliedFailoverPolicy?.[role] || state.failoverPolicy[role];
|
||||||
|
return state.profiles.find(({ id }) => id === target.profileId)
|
||||||
|
?.servers.find(({ id }) => id === target.serverId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentRole(state: StoredState): FailoverRole | null {
|
||||||
|
const applied = state.appliedFailoverPolicy;
|
||||||
|
if (!applied) return null;
|
||||||
|
for (const role of ['primary', 'reserve'] as const) {
|
||||||
|
if (
|
||||||
|
state.appliedProfileId === applied[role].profileId
|
||||||
|
&& state.appliedServerId === applied[role].serverId
|
||||||
|
) return role;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeHealth(value: unknown): boolean {
|
||||||
|
const vpn = record(record(value).vpn);
|
||||||
|
const sites = Array.isArray(vpn.sites) ? vpn.sites.map(record) : [];
|
||||||
|
return sites.length === 1 && sites[0].status === 'available';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeErrorCode(error: unknown) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
return /^[A-Z0-9_]{1,50}$/.test(code) ? code : 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFailoverService(dependencies: FailoverServiceDependencies) {
|
||||||
|
const scheduler = dependencies.scheduler || {
|
||||||
|
setTimeout: (callback: () => void, intervalMs: number) => setTimeout(callback, intervalMs),
|
||||||
|
clearTimeout: (timer: NodeJS.Timeout) => clearTimeout(timer),
|
||||||
|
};
|
||||||
|
const now = dependencies.now || (() => new Date());
|
||||||
|
const epoch = crypto.randomUUID();
|
||||||
|
let sequence = 0;
|
||||||
|
let generation = 0;
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
let collectorEnabled: boolean | null = null;
|
||||||
|
let roundPromise: Promise<void> | null = null;
|
||||||
|
let roundGeneration: number | null = null;
|
||||||
|
let decisionMemory: FailoverDecisionMemory | undefined;
|
||||||
|
const healthMemory: Record<FailoverRole, 'healthy' | 'unhealthy' | undefined> = {
|
||||||
|
primary: undefined,
|
||||||
|
reserve: undefined,
|
||||||
|
};
|
||||||
|
function clearHealthMemory() {
|
||||||
|
healthMemory.primary = undefined;
|
||||||
|
healthMemory.reserve = undefined;
|
||||||
|
}
|
||||||
|
function recordHealthTransition(
|
||||||
|
role: FailoverRole,
|
||||||
|
health: FailoverHealth,
|
||||||
|
capturedGeneration?: number,
|
||||||
|
) {
|
||||||
|
if (capturedGeneration !== undefined && capturedGeneration !== generation) return;
|
||||||
|
if (health !== 'healthy' && health !== 'unhealthy') return;
|
||||||
|
const previous = healthMemory[role];
|
||||||
|
healthMemory[role] = health;
|
||||||
|
if (previous === health) return;
|
||||||
|
if (previous === undefined && health === 'healthy') return;
|
||||||
|
const type = health === 'unhealthy'
|
||||||
|
? role === 'primary' ? 'failover.primary_unavailable' : 'failover.reserve_unavailable'
|
||||||
|
: role === 'primary' ? 'failover.primary_recovered' : 'failover.reserve_recovered';
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type,
|
||||||
|
severity: health === 'unhealthy' ? 'warning' : 'info',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: null,
|
||||||
|
data: {
|
||||||
|
role,
|
||||||
|
reason: health === 'unhealthy' ? 'probe-failed' : 'probe-recovered',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = createIdleFailoverSnapshot(dependencies.state.read().failoverPolicy, epoch, sequence);
|
||||||
|
|
||||||
|
function appliedMatchesDesired(state: StoredState) {
|
||||||
|
if (!state.appliedFailoverPolicy) return false;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(dependencies.buildCandidate(state).applied)
|
||||||
|
=== JSON.stringify(state.appliedFailoverPolicy);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(next: FailoverSnapshot) {
|
||||||
|
sequence += 1;
|
||||||
|
snapshot = { ...next, observationEpoch: epoch, observationSequence: sequence };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimer() {
|
||||||
|
if (timer) scheduler.clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(delay: number) {
|
||||||
|
clearTimer();
|
||||||
|
timer = scheduler.setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
void runRound().catch(dependencies.onWarning);
|
||||||
|
}, delay);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableCollector() {
|
||||||
|
if (collectorEnabled === false) return;
|
||||||
|
await dependencies.dataplane.setFailoverActivityEnabled(false);
|
||||||
|
collectorEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivate(policy: FailoverPolicy, passiveRole: FailoverRole | null = null) {
|
||||||
|
generation += 1;
|
||||||
|
clearTimer();
|
||||||
|
decisionMemory = undefined;
|
||||||
|
clearHealthMemory();
|
||||||
|
await disableCollector();
|
||||||
|
const idle = createIdleFailoverSnapshot(policy, epoch, sequence);
|
||||||
|
if (passiveRole) {
|
||||||
|
idle.activation = 'passive-loaded';
|
||||||
|
idle.currentRole = passiveRole;
|
||||||
|
idle.reason = 'disabled';
|
||||||
|
}
|
||||||
|
publish(idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeSnapshot(state: StoredState, status: FailoverSnapshot['status'] = 'observing'): FailoverSnapshot {
|
||||||
|
const role = state.failoverRuntimeState.reasonCode === 'selector-unknown' ? null : currentRole(state);
|
||||||
|
const channel = (target: typeof state.failoverPolicy.primary) => ({
|
||||||
|
target,
|
||||||
|
health: 'unknown' as FailoverHealth,
|
||||||
|
failingServiceIds: [],
|
||||||
|
checkedAt: null,
|
||||||
|
stateSince: null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
observationEpoch: epoch,
|
||||||
|
observationSequence: sequence,
|
||||||
|
configured: isFailoverConfigured(state.failoverPolicy),
|
||||||
|
enabled: state.failoverPolicy.enabled,
|
||||||
|
paused: state.failoverPolicy.paused,
|
||||||
|
activation: appliedMatchesDesired(state) ? 'active' : 'pending',
|
||||||
|
currentRole: role || 'other',
|
||||||
|
status,
|
||||||
|
primary: channel(state.appliedFailoverPolicy?.primary || state.failoverPolicy.primary),
|
||||||
|
reserve: channel(state.appliedFailoverPolicy?.reserve || state.failoverPolicy.reserve),
|
||||||
|
nextDecisionAt: null,
|
||||||
|
reason: null,
|
||||||
|
trafficActivity: null,
|
||||||
|
policy: state.failoverPolicy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcile() {
|
||||||
|
let state = dependencies.state.read();
|
||||||
|
const policy = state.failoverPolicy;
|
||||||
|
if (!policy.enabled) {
|
||||||
|
const role = currentRole(state);
|
||||||
|
const passiveRole = role && await dependencies.runtime.isRunning() ? role : null;
|
||||||
|
return deactivate(policy, passiveRole);
|
||||||
|
}
|
||||||
|
const running = await dependencies.runtime.isRunning();
|
||||||
|
if (!running || !state.appliedFailoverPolicy || !currentRole(state)) {
|
||||||
|
generation += 1;
|
||||||
|
clearHealthMemory();
|
||||||
|
clearTimer();
|
||||||
|
await disableCollector();
|
||||||
|
const pending = activeSnapshot(state, 'idle');
|
||||||
|
pending.activation = running ? 'pending' : 'inactive';
|
||||||
|
pending.reason = running ? 'pending-activation' : 'vpn-stopped';
|
||||||
|
publish(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const role = currentRole(state)!;
|
||||||
|
const selected = record(await dependencies.dataplane.readFailoverSelector());
|
||||||
|
if (selected.role !== role) await dependencies.dataplane.selectFailoverRole(role);
|
||||||
|
if (state.failoverRuntimeState.reasonCode === 'selector-unknown') {
|
||||||
|
state = dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
failoverRuntimeState: { ...current.failoverRuntimeState, reasonCode: null },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (collectorEnabled !== true) {
|
||||||
|
await dependencies.dataplane.setFailoverActivityEnabled(true);
|
||||||
|
collectorEnabled = true;
|
||||||
|
}
|
||||||
|
generation += 1;
|
||||||
|
const active = activeSnapshot(state);
|
||||||
|
if (active.activation === 'pending') active.reason = 'pending-activation';
|
||||||
|
publish(active);
|
||||||
|
schedule(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileAfterCommit() {
|
||||||
|
try {
|
||||||
|
await reconcile();
|
||||||
|
} catch (error) {
|
||||||
|
dependencies.onWarning?.(error);
|
||||||
|
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||||
|
failed.reason = 'reconcile-failed';
|
||||||
|
publish(failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assessRole(role: FailoverRole, policy: FailoverPolicy) {
|
||||||
|
const custom = dependencies.state.read().diagnostics.customServices;
|
||||||
|
const results = await Promise.all(policy.checks.map(async (check) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
id: check.serviceId,
|
||||||
|
ok: probeHealth(await dependencies.dataplane.runFailoverProbe(
|
||||||
|
role,
|
||||||
|
custom,
|
||||||
|
`site:${check.serviceId}`,
|
||||||
|
check.timeoutMs,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { id: check.serviceId, ok: null };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
health: results.some(({ ok }) => ok === null)
|
||||||
|
? 'unknown' as const
|
||||||
|
: results.every(({ ok }) => ok) ? 'healthy' as const : 'unhealthy' as const,
|
||||||
|
failingServiceIds: results.filter(({ ok }) => ok === false).map(({ id }) => id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchWithinQueue(role: FailoverRole, reason: string) {
|
||||||
|
const before = dependencies.state.read();
|
||||||
|
const from = currentRole(before);
|
||||||
|
if (!from || from === role) return;
|
||||||
|
try {
|
||||||
|
await dependencies.dataplane.selectFailoverRole(role);
|
||||||
|
const server = targetServer(before, role);
|
||||||
|
if (!server) throw new HarborError('SERVER_NOT_FOUND');
|
||||||
|
const target = before.appliedFailoverPolicy![role];
|
||||||
|
const switchedAt = now().toISOString();
|
||||||
|
const cutoff = now().getTime() - before.failoverPolicy.flapProtection.windowMs;
|
||||||
|
const history = role === 'reserve'
|
||||||
|
? [...before.failoverRuntimeState.failoverHistory.filter((value) => Date.parse(value) >= cutoff), switchedAt]
|
||||||
|
: before.failoverRuntimeState.failoverHistory.filter((value) => Date.parse(value) >= cutoff);
|
||||||
|
const quarantine = history.length >= before.failoverPolicy.flapProtection.count
|
||||||
|
? new Date(now().getTime() + before.failoverPolicy.flapProtection.quarantineMs).toISOString()
|
||||||
|
: before.failoverRuntimeState.primaryQuarantineUntil;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
appliedProfileId: target.profileId,
|
||||||
|
appliedServerId: target.serverId,
|
||||||
|
appliedServerSnapshot: server,
|
||||||
|
failoverPolicy: reason === 'manual'
|
||||||
|
? state.failoverPolicy
|
||||||
|
: role === 'primary' ? { ...state.failoverPolicy, paused: false } : state.failoverPolicy,
|
||||||
|
failoverRuntimeState: {
|
||||||
|
...state.failoverRuntimeState,
|
||||||
|
lastSwitchAt: switchedAt,
|
||||||
|
holdUntil: role === 'reserve'
|
||||||
|
? new Date(now().getTime() + state.failoverPolicy.minimumReserveMs).toISOString()
|
||||||
|
: null,
|
||||||
|
primaryQuarantineUntil: quarantine,
|
||||||
|
failoverHistory: history,
|
||||||
|
reasonCode: reason,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await dependencies.dataplane.selectFailoverRole(from);
|
||||||
|
} catch (rollback) {
|
||||||
|
generation += 1;
|
||||||
|
clearTimer();
|
||||||
|
decisionMemory = undefined;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
failoverPolicy: { ...state.failoverPolicy, paused: true },
|
||||||
|
failoverRuntimeState: { ...state.failoverRuntimeState, reasonCode: 'selector-unknown' },
|
||||||
|
}));
|
||||||
|
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||||
|
failed.reason = 'selector-unknown';
|
||||||
|
publish(failed);
|
||||||
|
throw new AggregateError([error, rollback], 'Failover selector rollback failed');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
dependencies.onSwitch?.(from, role, reason);
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'failover.switched',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: `failover.switched:${dependencies.state.read().revision}`,
|
||||||
|
data: {
|
||||||
|
fromRole: from,
|
||||||
|
toRole: role,
|
||||||
|
primaryLabel: targetServer(dependencies.state.read(), 'primary')?.label || 'Primary',
|
||||||
|
reserveLabel: targetServer(dependencies.state.read(), 'reserve')?.label || 'Reserve',
|
||||||
|
reason,
|
||||||
|
manual: reason === 'manual',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRound(capturedGeneration: number) {
|
||||||
|
const prepared = await dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
const role = currentRole(state);
|
||||||
|
if (
|
||||||
|
capturedGeneration !== generation
|
||||||
|
|| !state.failoverPolicy.enabled
|
||||||
|
|| !state.appliedFailoverPolicy
|
||||||
|
|| !role
|
||||||
|
) return null;
|
||||||
|
const selected = record(await dependencies.dataplane.readFailoverSelector());
|
||||||
|
if (selected.role !== role) await dependencies.dataplane.selectFailoverRole(role);
|
||||||
|
await dependencies.dataplane.setFailoverActivityEnabled(true);
|
||||||
|
collectorEnabled = true;
|
||||||
|
const latest = dependencies.state.read();
|
||||||
|
return capturedGeneration === generation
|
||||||
|
&& latest.failoverPolicy.enabled
|
||||||
|
&& currentRole(latest) === role
|
||||||
|
? { state: latest, policy: latest.failoverPolicy, role }
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
if (!prepared) return;
|
||||||
|
const { state, policy, role } = prepared;
|
||||||
|
const checkedAt = now().toISOString();
|
||||||
|
const previousSnapshot = snapshot;
|
||||||
|
publish({ ...snapshot, reason: 'checking-channels' });
|
||||||
|
let primary;
|
||||||
|
let reserve;
|
||||||
|
try {
|
||||||
|
[primary, reserve] = await Promise.all([
|
||||||
|
assessRole('primary', policy),
|
||||||
|
assessRole('reserve', policy),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
primary = { health: 'unknown' as const, failingServiceIds: [] };
|
||||||
|
reserve = { health: 'unknown' as const, failingServiceIds: [] };
|
||||||
|
}
|
||||||
|
if (capturedGeneration !== generation || !dependencies.state.read().failoverPolicy.enabled) return;
|
||||||
|
recordHealthTransition('primary', primary.health, capturedGeneration);
|
||||||
|
recordHealthTransition('reserve', reserve.health, capturedGeneration);
|
||||||
|
const activityResponse = record(await dependencies.dataplane.readFailoverActivity(
|
||||||
|
policy.trafficGuard.thresholdBytesPerSecond,
|
||||||
|
));
|
||||||
|
if (capturedGeneration !== generation || !dependencies.state.read().failoverPolicy.enabled) return;
|
||||||
|
const activity = record(activityResponse.activity);
|
||||||
|
const observedAt = typeof activity.observedAt === 'string' ? Date.parse(activity.observedAt) : NaN;
|
||||||
|
const activityState = Number.isFinite(observedAt) && now().getTime() - observedAt <= 4_000
|
||||||
|
&& (activity.state === 'active' || activity.state === 'quiet')
|
||||||
|
? activity.state
|
||||||
|
: 'unknown';
|
||||||
|
const decision = nextFailoverDecision({
|
||||||
|
now: now().getTime(),
|
||||||
|
policy,
|
||||||
|
currentRole: role,
|
||||||
|
primaryHealth: primary.health,
|
||||||
|
reserveHealth: reserve.health,
|
||||||
|
activity: activityState,
|
||||||
|
holdUntil: Date.parse(state.failoverRuntimeState.holdUntil || '') || null,
|
||||||
|
primaryQuarantineUntil: Date.parse(state.failoverRuntimeState.primaryQuarantineUntil || '') || null,
|
||||||
|
memory: decisionMemory,
|
||||||
|
});
|
||||||
|
decisionMemory = decision.memory;
|
||||||
|
const next = activeSnapshot(state, decision.status);
|
||||||
|
next.currentRole = role;
|
||||||
|
next.primary = {
|
||||||
|
...next.primary,
|
||||||
|
...primary,
|
||||||
|
checkedAt,
|
||||||
|
stateSince: previousSnapshot.primary.health === primary.health
|
||||||
|
? previousSnapshot.primary.stateSince || checkedAt
|
||||||
|
: checkedAt,
|
||||||
|
};
|
||||||
|
next.reserve = {
|
||||||
|
...next.reserve,
|
||||||
|
...reserve,
|
||||||
|
checkedAt,
|
||||||
|
stateSince: previousSnapshot.reserve.health === reserve.health
|
||||||
|
? previousSnapshot.reserve.stateSince || checkedAt
|
||||||
|
: checkedAt,
|
||||||
|
};
|
||||||
|
next.reason = decision.reason;
|
||||||
|
next.nextDecisionAt = decision.nextDecisionAt ? new Date(decision.nextDecisionAt).toISOString() : null;
|
||||||
|
next.trafficActivity = activityState === 'unknown' ? {
|
||||||
|
state: 'unknown',
|
||||||
|
observedAt: Number.isFinite(observedAt) ? new Date(observedAt).toISOString() : checkedAt,
|
||||||
|
windowMs: 10_000,
|
||||||
|
thresholdBytesPerSecond: policy.trafficGuard.thresholdBytesPerSecond,
|
||||||
|
totalBytesPerSecond: 0,
|
||||||
|
transmittingConnections: 0,
|
||||||
|
quietSince: null,
|
||||||
|
switchTarget: decision.switchTo,
|
||||||
|
blockers: [],
|
||||||
|
} : {
|
||||||
|
state: activityState,
|
||||||
|
observedAt: String(activity.observedAt),
|
||||||
|
windowMs: Number(activity.windowMs) || 10_000,
|
||||||
|
thresholdBytesPerSecond: policy.trafficGuard.thresholdBytesPerSecond,
|
||||||
|
totalBytesPerSecond: Number(activity.totalBytesPerSecond) || 0,
|
||||||
|
transmittingConnections: Number(activity.transmittingConnections) || 0,
|
||||||
|
quietSince: typeof activity.quietSince === 'string' ? activity.quietSince : null,
|
||||||
|
switchTarget: decision.switchTo,
|
||||||
|
blockers: (Array.isArray(activity.blockers) ? activity.blockers : []).slice(0, 3) as FailoverSnapshot['trafficActivity'] extends infer T ? T extends { blockers: infer B } ? B : never : never,
|
||||||
|
};
|
||||||
|
publish(next);
|
||||||
|
if (decision.status === 'waiting-for-idle' && previousSnapshot.status !== 'waiting-for-idle') {
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'failover.waiting_for_idle',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: `failover.waiting_for_idle:${state.revision}:${role}:${decision.reason}`,
|
||||||
|
data: { fromRole: role, toRole: decision.switchTo || (role === 'primary' ? 'reserve' : 'primary'), reason: decision.reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (decision.reason === 'both-unhealthy' && previousSnapshot.reason !== 'both-unhealthy') {
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'failover.both_unhealthy',
|
||||||
|
severity: 'warning',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: `failover.both_unhealthy:${state.revision}`,
|
||||||
|
data: { reason: decision.reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (decision.switchTo) {
|
||||||
|
try {
|
||||||
|
const switched = await dependencies.serialize(async () => {
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
if (
|
||||||
|
capturedGeneration !== generation
|
||||||
|
|| !current.failoverPolicy.enabled
|
||||||
|
|| current.failoverPolicy.paused
|
||||||
|
|| currentRole(current) !== role
|
||||||
|
|| !current.appliedFailoverPolicy
|
||||||
|
) return false;
|
||||||
|
let freshPrimary;
|
||||||
|
let freshReserve;
|
||||||
|
try {
|
||||||
|
[freshPrimary, freshReserve] = await Promise.all([
|
||||||
|
assessRole('primary', current.failoverPolicy),
|
||||||
|
assessRole('reserve', current.failoverPolicy),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const healthStillAllowsSwitch = decision.switchTo === 'reserve'
|
||||||
|
? freshPrimary.health === 'unhealthy' && freshReserve.health === 'healthy'
|
||||||
|
: freshPrimary.health === 'healthy';
|
||||||
|
if (!healthStillAllowsSwitch || capturedGeneration !== generation) return false;
|
||||||
|
if (current.failoverPolicy.trafficGuard.enabled) {
|
||||||
|
const freshResponse = record(await dependencies.dataplane.readFailoverActivity(
|
||||||
|
current.failoverPolicy.trafficGuard.thresholdBytesPerSecond,
|
||||||
|
));
|
||||||
|
const freshActivity = record(freshResponse.activity);
|
||||||
|
const freshObservedAt = typeof freshActivity.observedAt === 'string'
|
||||||
|
? Date.parse(freshActivity.observedAt)
|
||||||
|
: NaN;
|
||||||
|
const freshQuietSince = typeof freshActivity.quietSince === 'string'
|
||||||
|
? Date.parse(freshActivity.quietSince)
|
||||||
|
: NaN;
|
||||||
|
if (
|
||||||
|
capturedGeneration !== generation
|
||||||
|
|| freshActivity.state !== 'quiet'
|
||||||
|
|| !Number.isFinite(freshObservedAt)
|
||||||
|
|| now().getTime() - freshObservedAt > 4_000
|
||||||
|
|| !Number.isFinite(freshQuietSince)
|
||||||
|
|| now().getTime() - freshQuietSince < current.failoverPolicy.trafficGuard.quietWindowMs
|
||||||
|
) return false;
|
||||||
|
}
|
||||||
|
await switchWithinQueue(decision.switchTo!, decision.reason);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!switched) {
|
||||||
|
const cancelled = activeSnapshot(dependencies.state.read(), 'observing');
|
||||||
|
cancelled.reason = 'revalidation-required';
|
||||||
|
publish(cancelled);
|
||||||
|
decisionMemory = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||||
|
failed.reason = dependencies.state.read().failoverRuntimeState.reasonCode === 'selector-unknown'
|
||||||
|
? 'selector-unknown'
|
||||||
|
: 'switch-failed';
|
||||||
|
publish(failed);
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'failover.switch_failed',
|
||||||
|
severity: 'error',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: `failover.switch_failed:${state.revision}:${role}:${decision.switchTo}`,
|
||||||
|
data: { fromRole: role, toRole: decision.switchTo, reason: decision.reason, errorCode: safeErrorCode(error) },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (capturedGeneration !== generation) return;
|
||||||
|
publish(activeSnapshot(dependencies.state.read(), decision.switchTo === 'reserve' ? 'reserve' : 'primary'));
|
||||||
|
decisionMemory = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRound(): Promise<void> {
|
||||||
|
if (roundPromise) {
|
||||||
|
const pending = roundPromise;
|
||||||
|
if (roundGeneration === generation) return pending;
|
||||||
|
try {
|
||||||
|
await pending;
|
||||||
|
} catch {
|
||||||
|
// The original caller owns the stale round error; continue with current-generation work.
|
||||||
|
}
|
||||||
|
if (roundPromise && roundPromise !== pending) return roundPromise;
|
||||||
|
return runRound();
|
||||||
|
}
|
||||||
|
const capturedGeneration = generation;
|
||||||
|
let trackedPromise: Promise<void>;
|
||||||
|
trackedPromise = performRound(capturedGeneration).finally(() => {
|
||||||
|
if (roundPromise === trackedPromise) {
|
||||||
|
roundPromise = null;
|
||||||
|
roundGeneration = null;
|
||||||
|
}
|
||||||
|
if (capturedGeneration === generation && snapshot.reason === 'checking-channels') {
|
||||||
|
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||||
|
failed.reason = 'health-unknown';
|
||||||
|
publish(failed);
|
||||||
|
}
|
||||||
|
const policy = dependencies.state.read().failoverPolicy;
|
||||||
|
if (capturedGeneration === generation && policy.enabled && dependencies.state.read().appliedFailoverPolicy) {
|
||||||
|
const decisionAt = snapshot.nextDecisionAt ? Date.parse(snapshot.nextDecisionAt) : NaN;
|
||||||
|
const decisionDelay = Number.isFinite(decisionAt)
|
||||||
|
? Math.max(250, decisionAt - now().getTime())
|
||||||
|
: policy.intervalMs;
|
||||||
|
schedule(Math.min(policy.intervalMs, decisionDelay));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
roundPromise = trackedPromise;
|
||||||
|
roundGeneration = capturedGeneration;
|
||||||
|
return trackedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(value: unknown) {
|
||||||
|
return dependencies.serialize(async () => {
|
||||||
|
let policy: FailoverPolicy;
|
||||||
|
try {
|
||||||
|
policy = normalizeFailoverPolicy(value, { strict: true });
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
if (policy.enabled && !isFailoverConfigured(policy)) throw new HarborError('REQUEST_INVALID');
|
||||||
|
const before = dependencies.state.read();
|
||||||
|
const candidateState = { ...before, failoverPolicy: policy };
|
||||||
|
if (policy.enabled) {
|
||||||
|
const candidate = dependencies.buildCandidate(candidateState);
|
||||||
|
await dependencies.dataplane.checkConfig(candidate.config);
|
||||||
|
}
|
||||||
|
dependencies.state.update((state) => {
|
||||||
|
const role = before.failoverPolicy.enabled && !policy.enabled ? currentRole(state) : null;
|
||||||
|
const target = role ? state.appliedFailoverPolicy?.[role] : null;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
failoverPolicy: policy,
|
||||||
|
...(target ? {
|
||||||
|
desiredProfileId: target.profileId,
|
||||||
|
profiles: state.profiles.map((profile) => profile.id === target.profileId
|
||||||
|
? { ...profile, desiredServerId: target.serverId }
|
||||||
|
: profile),
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
decisionMemory = undefined;
|
||||||
|
if (before.failoverPolicy.enabled !== policy.enabled) clearHealthMemory();
|
||||||
|
if (before.failoverPolicy.enabled !== policy.enabled) {
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: policy.enabled ? 'failover.enabled' : 'failover.disabled',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: `failover.${policy.enabled ? 'enabled' : 'disabled'}:${dependencies.state.read().revision}`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await reconcileAfterCommit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(paused: boolean) {
|
||||||
|
return dependencies.serialize(async () => {
|
||||||
|
const before = dependencies.state.read();
|
||||||
|
if (!paused && (
|
||||||
|
!before.appliedFailoverPolicy
|
||||||
|
|| !targetServer(before, 'primary')
|
||||||
|
|| !targetServer(before, 'reserve')
|
||||||
|
)) throw new HarborError('REQUEST_INVALID');
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
failoverPolicy: { ...state.failoverPolicy, paused },
|
||||||
|
}));
|
||||||
|
decisionMemory = undefined;
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: paused ? 'failover.paused' : 'failover.resumed',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: `failover.${paused ? 'paused' : 'resumed'}:${dependencies.state.read().revision}`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
await reconcileAfterCommit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualSwitch(role: FailoverRole) {
|
||||||
|
await dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
if (!state.failoverPolicy.enabled || !state.appliedFailoverPolicy || !currentRole(state)) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
await switchWithinQueue(role, 'manual');
|
||||||
|
});
|
||||||
|
await reconcileAfterCommit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareActivation = (role: FailoverRole) => dependencies.dataplane.selectFailoverRole(role);
|
||||||
|
|
||||||
|
async function restoreAppliedActivation(state: StoredState) {
|
||||||
|
if (!state.appliedFailoverPolicy) return;
|
||||||
|
const role = currentRole(state);
|
||||||
|
if (!role) throw new HarborError('CONFIG_INVALID');
|
||||||
|
await prepareActivation(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNow() {
|
||||||
|
return dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
if (!state.failoverPolicy.enabled || !state.appliedFailoverPolicy || !currentRole(state)) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
const capturedGeneration = ++generation;
|
||||||
|
clearTimer();
|
||||||
|
const checkedAt = now().toISOString();
|
||||||
|
publish({ ...snapshot, reason: 'checking-channels' });
|
||||||
|
let primary;
|
||||||
|
let reserve;
|
||||||
|
try {
|
||||||
|
[primary, reserve] = await Promise.all([
|
||||||
|
assessRole('primary', state.failoverPolicy),
|
||||||
|
assessRole('reserve', state.failoverPolicy),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
primary = { health: 'unknown' as const, failingServiceIds: [] };
|
||||||
|
reserve = { health: 'unknown' as const, failingServiceIds: [] };
|
||||||
|
}
|
||||||
|
if (capturedGeneration !== generation || !dependencies.state.read().failoverPolicy.enabled) return;
|
||||||
|
recordHealthTransition('primary', primary.health, capturedGeneration);
|
||||||
|
recordHealthTransition('reserve', reserve.health, capturedGeneration);
|
||||||
|
const next = activeSnapshot(dependencies.state.read(), 'observing');
|
||||||
|
next.primary = { ...next.primary, ...primary, checkedAt, stateSince: checkedAt };
|
||||||
|
next.reserve = { ...next.reserve, ...reserve, checkedAt, stateSince: checkedAt };
|
||||||
|
next.reason = 'manual-check';
|
||||||
|
publish(next);
|
||||||
|
schedule(state.failoverPolicy.intervalMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: () => snapshot,
|
||||||
|
save,
|
||||||
|
pause,
|
||||||
|
manualSwitch,
|
||||||
|
checkNow,
|
||||||
|
prepareActivation,
|
||||||
|
restoreAppliedActivation,
|
||||||
|
reconcile,
|
||||||
|
runRound,
|
||||||
|
shutdown: () => deactivate(dependencies.state.read().failoverPolicy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FailoverService = ReturnType<typeof createFailoverService>;
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appliedProfile,
|
||||||
|
desiredProfile,
|
||||||
|
type GatewayAutoState,
|
||||||
|
type StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface HostNetworkState {
|
||||||
|
gateway: string;
|
||||||
|
interface: string;
|
||||||
|
mac: string;
|
||||||
|
observedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifiedGateway {
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin?: string;
|
||||||
|
verifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimerHandle = NodeJS.Timeout;
|
||||||
|
|
||||||
|
interface GatewayAutoServiceDependencies {
|
||||||
|
appMode: string;
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: { readConfig(profileId: string): unknown | null };
|
||||||
|
config: {
|
||||||
|
build(
|
||||||
|
subscriptionConfig: unknown,
|
||||||
|
selectedServerId: string,
|
||||||
|
routeRules: StoredState['routeRules'],
|
||||||
|
gatewayAuto: GatewayAutoState,
|
||||||
|
): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): boolean;
|
||||||
|
applyCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restoreRunning(): Promise<unknown>;
|
||||||
|
stopCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
};
|
||||||
|
discovery: {
|
||||||
|
readHostNetwork(): HostNetworkState | null;
|
||||||
|
probeGateway(input: {
|
||||||
|
gateway: string;
|
||||||
|
subscriptionUrl: string;
|
||||||
|
}): Promise<VerifiedGateway>;
|
||||||
|
};
|
||||||
|
transition: {
|
||||||
|
createInitial(): GatewayAutoState;
|
||||||
|
applyPreference(state: GatewayAutoState, enabled: boolean): GatewayAutoState;
|
||||||
|
next(
|
||||||
|
current: GatewayAutoState,
|
||||||
|
input: {
|
||||||
|
network: HostNetworkState | null;
|
||||||
|
verifiedGateway?: VerifiedGateway | null;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
): GatewayAutoState;
|
||||||
|
sameRoute(
|
||||||
|
previous: GatewayAutoState['gateway'] | HostNetworkState | null | undefined,
|
||||||
|
current: HostNetworkState | null | undefined,
|
||||||
|
): boolean;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
scheduler: {
|
||||||
|
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||||
|
clearInterval(timer: TimerHandle): void;
|
||||||
|
};
|
||||||
|
onRouteChange(state: GatewayAutoState): void;
|
||||||
|
onDiscoveryWarning(reason: string): void;
|
||||||
|
onTimerError(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommitOptions {
|
||||||
|
reconfigure?: boolean;
|
||||||
|
persistEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshOptions {
|
||||||
|
reconfigure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error && typeof error === 'object' && 'message' in error && error.message
|
||||||
|
? String(error.message)
|
||||||
|
: 'Gateway presence check failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoService(dependencies: GatewayAutoServiceDependencies) {
|
||||||
|
let current = dependencies.transition.createInitial();
|
||||||
|
let refreshPromise: Promise<GatewayAutoState> | null = null;
|
||||||
|
let discoveryTimer: TimerHandle | null = null;
|
||||||
|
|
||||||
|
const restoreConfig = (previous: string | null) => {
|
||||||
|
if (previous === null) dependencies.config.remove();
|
||||||
|
else dependencies.config.restore(previous);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitCandidate = async (
|
||||||
|
candidate: GatewayAutoState,
|
||||||
|
{ reconfigure = true, persistEnabled }: CommitOptions = {},
|
||||||
|
) => {
|
||||||
|
const previousGatewayAuto = current;
|
||||||
|
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
|
||||||
|
const modeChanged = previousGatewayAuto.mode !== candidate.mode;
|
||||||
|
const leavesGatewayDirect = previousGatewayAuto.mode === 'gateway-direct'
|
||||||
|
&& candidate.mode !== 'gateway-direct';
|
||||||
|
if (!stateChanged && persistEnabled === undefined) return current;
|
||||||
|
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
const wasRunning = modeChanged ? dependencies.runtime.isRunning() : false;
|
||||||
|
const targetProfile = wasRunning
|
||||||
|
? appliedProfile(previousState)
|
||||||
|
: desiredProfile(previousState);
|
||||||
|
const targetServerId = wasRunning
|
||||||
|
? previousState.appliedServerId
|
||||||
|
: targetProfile?.desiredServerId || '';
|
||||||
|
const subscriptionConfig = modeChanged && targetProfile
|
||||||
|
? dependencies.subscription.readConfig(targetProfile.id)
|
||||||
|
: null;
|
||||||
|
const stopUnavailableTarget = async (cause: unknown) => {
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let gatewayAutoPublished = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
try {
|
||||||
|
const command = await dependencies.runtime.stopCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
current = candidate;
|
||||||
|
gatewayAutoPublished = true;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
connectionDesired: 'stopped',
|
||||||
|
appliedProfileId: '',
|
||||||
|
appliedServerId: '',
|
||||||
|
appliedServerSnapshot: null,
|
||||||
|
...(persistEnabled === undefined ? {} : { gatewayAutoEnabled: persistEnabled }),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
|
||||||
|
...(runtimeMutationStarted ? [{
|
||||||
|
run: () => dependencies.runtime.restoreRunning(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Gateway auto safe-stop rollback failed');
|
||||||
|
}
|
||||||
|
dependencies.onDiscoveryWarning(errorMessage(cause));
|
||||||
|
if (modeChanged) dependencies.onRouteChange(candidate);
|
||||||
|
throw cause;
|
||||||
|
};
|
||||||
|
if (modeChanged && wasRunning && (!targetProfile || !targetServerId || !subscriptionConfig)) {
|
||||||
|
if (leavesGatewayDirect) return stopUnavailableTarget(new HarborError('CONFIG_INVALID'));
|
||||||
|
throw new HarborError('CONFIG_INVALID');
|
||||||
|
}
|
||||||
|
let candidateConfig: unknown | null = null;
|
||||||
|
if (modeChanged && targetServerId && subscriptionConfig) {
|
||||||
|
try {
|
||||||
|
candidateConfig = dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
targetServerId,
|
||||||
|
previousState.routeRules,
|
||||||
|
candidate,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
if (wasRunning && leavesGatewayDirect && ['CONFIG_INVALID', 'SERVER_NOT_FOUND'].includes(code)) {
|
||||||
|
return stopUnavailableTarget(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const previousConfig = candidateConfig === null ? null : dependencies.config.read();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let gatewayAutoPublished = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (candidateConfig !== null) {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
if (reconfigure && wasRunning) {
|
||||||
|
const command = await dependencies.runtime.applyCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) {
|
||||||
|
current = candidate;
|
||||||
|
gatewayAutoPublished = true;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
...(modeChanged && wasRunning && reconfigure ? {
|
||||||
|
appliedRouteRules: candidate.mode === 'gateway-direct' ? [] : state.routeRules,
|
||||||
|
} : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (persistEnabled !== undefined) {
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
gatewayAutoEnabled: persistEnabled,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
|
||||||
|
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||||
|
...(wasRunning && runtimeMutationStarted ? [{
|
||||||
|
run: () => dependencies.runtime.restoreRunning(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Gateway auto rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeChanged) dependencies.onRouteChange(candidate);
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
const profile = desiredProfile(state);
|
||||||
|
const subscriptionUrl = profile?.subscriptionUrl || '';
|
||||||
|
const network = subscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!network) {
|
||||||
|
const discoveryError = 'macOS default gateway недоступен или устарел';
|
||||||
|
const discoveredState = dependencies.transition.next(current, {
|
||||||
|
network: null,
|
||||||
|
error: discoveryError,
|
||||||
|
});
|
||||||
|
const candidate = dependencies.transition.applyPreference(
|
||||||
|
subscriptionUrl
|
||||||
|
? { ...discoveredState, lastError: discoveryError }
|
||||||
|
: discoveredState,
|
||||||
|
state.gatewayAutoEnabled !== false,
|
||||||
|
);
|
||||||
|
return commitCandidate(candidate, { reconfigure });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.mode === 'gateway-direct' &&
|
||||||
|
!dependencies.transition.sameRoute(current.gateway, network)
|
||||||
|
) {
|
||||||
|
await commitCandidate(
|
||||||
|
dependencies.transition.next(current, { network }),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let verifiedGateway: VerifiedGateway;
|
||||||
|
try {
|
||||||
|
verifiedGateway = await dependencies.discovery.probeGateway({
|
||||||
|
gateway: network.gateway,
|
||||||
|
subscriptionUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = errorMessage(error);
|
||||||
|
const latestState = dependencies.state.read();
|
||||||
|
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
|
||||||
|
const latestNetwork = latestSubscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
latestSubscriptionUrl !== subscriptionUrl ||
|
||||||
|
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||||
|
) {
|
||||||
|
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||||
|
}
|
||||||
|
if (current.lastError !== reason) dependencies.onDiscoveryWarning(reason);
|
||||||
|
return commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(
|
||||||
|
dependencies.transition.next(current, {
|
||||||
|
network: latestNetwork,
|
||||||
|
error: reason,
|
||||||
|
}),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestState = dependencies.state.read();
|
||||||
|
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
|
||||||
|
const latestNetwork = latestSubscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
latestSubscriptionUrl !== subscriptionUrl ||
|
||||||
|
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||||
|
) {
|
||||||
|
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||||
|
}
|
||||||
|
return commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(
|
||||||
|
dependencies.transition.next(current, { network: latestNetwork, verifiedGateway }),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = (options: RefreshOptions = {}) => {
|
||||||
|
if (dependencies.appMode !== 'client') return Promise.resolve(current);
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
refreshPromise = dependencies.serialize(() => runRefresh(options)).finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
return refreshPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEnabled = (enabled: boolean) => dependencies.serialize(() => commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(current, enabled),
|
||||||
|
{ persistEnabled: enabled },
|
||||||
|
));
|
||||||
|
|
||||||
|
const startDiscovery = (intervalMs: number) => {
|
||||||
|
if (discoveryTimer) return;
|
||||||
|
discoveryTimer = dependencies.scheduler.setInterval(() => {
|
||||||
|
void refresh().catch(dependencies.onTimerError);
|
||||||
|
}, intervalMs);
|
||||||
|
discoveryTimer.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDiscovery = () => {
|
||||||
|
if (!discoveryTimer) return;
|
||||||
|
dependencies.scheduler.clearInterval(discoveryTimer);
|
||||||
|
discoveryTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
read: () => current,
|
||||||
|
set: (value: GatewayAutoState) => { current = value; },
|
||||||
|
createInitial: dependencies.transition.createInitial,
|
||||||
|
setEnabled,
|
||||||
|
refresh,
|
||||||
|
startDiscovery,
|
||||||
|
stopDiscovery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GatewayAutoService = ReturnType<typeof createGatewayAutoService>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
createRouteRulesService,
|
||||||
|
type RouteRulesService,
|
||||||
|
} from './routeRulesService.js';
|
||||||
|
export {
|
||||||
|
createGatewayAutoService,
|
||||||
|
type GatewayAutoService,
|
||||||
|
} from './gatewayAutoService.js';
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appliedProfile,
|
||||||
|
desiredProfile,
|
||||||
|
type RouteRule,
|
||||||
|
type StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import {
|
||||||
|
normalizeRouteRules,
|
||||||
|
ROUTE_RULES_CONTRACT_VERSION,
|
||||||
|
} from '../../../shared/routingRules.js';
|
||||||
|
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface RouteRulesDependencies {
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: { readConfig(profileId: string): unknown | null };
|
||||||
|
config: {
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
applyCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restoreRunning(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
route?: { isGatewayDirect(): boolean };
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
runOperation<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
afterApply?: () => Promise<unknown>;
|
||||||
|
restoreAppliedActivation?: (state: StoredState) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||||
|
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
const bypassed = dependencies.route?.isGatewayDirect() === true;
|
||||||
|
const targetProfile = wasRunning
|
||||||
|
? appliedProfile(previousState)
|
||||||
|
: desiredProfile(previousState);
|
||||||
|
const targetServerId = wasRunning
|
||||||
|
? previousState.appliedServerId
|
||||||
|
: targetProfile?.desiredServerId || '';
|
||||||
|
const subscriptionConfig = targetProfile
|
||||||
|
? dependencies.subscription.readConfig(targetProfile.id)
|
||||||
|
: null;
|
||||||
|
if (bypassed || !targetServerId || !subscriptionConfig) {
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
try {
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
routeRules,
|
||||||
|
...(bypassed ? { appliedRouteRules: [] } : {}),
|
||||||
|
routeRulesRevision: state.routeRulesRevision + 1,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Route rules rollback failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateConfig = dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
targetServerId,
|
||||||
|
routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const configChanged = previousConfig !== JSON.stringify(candidateConfig, null, 2);
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (configChanged) {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
}
|
||||||
|
if (wasRunning && configChanged) {
|
||||||
|
const command = await dependencies.runtime.applyCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
}
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
routeRules,
|
||||||
|
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
||||||
|
routeRulesRevision: state.routeRulesRevision + 1,
|
||||||
|
}));
|
||||||
|
await dependencies.afterApply?.();
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => previousConfig === null
|
||||||
|
? dependencies.config.remove()
|
||||||
|
: dependencies.config.restore(previousConfig),
|
||||||
|
}] : []),
|
||||||
|
...(wasRunning && runtimeMutationStarted ? [{
|
||||||
|
run: async () => {
|
||||||
|
await dependencies.runtime.restoreRunning();
|
||||||
|
await dependencies.restoreAppliedActivation?.(previousState);
|
||||||
|
},
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Route rules rollback failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (
|
||||||
|
rules: unknown,
|
||||||
|
expectedRulesRevision: unknown,
|
||||||
|
rulesContractVersion: unknown,
|
||||||
|
) => {
|
||||||
|
if (rulesContractVersion !== ROUTE_RULES_CONTRACT_VERSION) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
let routeRules: RouteRule[];
|
||||||
|
try {
|
||||||
|
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(expectedRulesRevision) || Number(expectedRulesRevision) < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return dependencies.serialize(async () => {
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
if (current.routeRulesRevision !== expectedRulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||||
|
await dependencies.runOperation(() => applyRules(current, routeRules));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteRulesService = ReturnType<typeof createRouteRulesService>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
checkServerHealth,
|
||||||
|
createServerHealthService,
|
||||||
|
SERVER_HEALTH_CONCURRENCY,
|
||||||
|
SERVER_HEALTH_MAX_COUNT,
|
||||||
|
type ServerHealthService,
|
||||||
|
} from './serverHealth.js';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
|
||||||
|
export const SERVER_HEALTH_MAX_COUNT = 30;
|
||||||
|
export const SERVER_HEALTH_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
type HealthServer = Pick<HarborServer, 'id' | 'label' | 'host' | 'port'>;
|
||||||
|
type Ping = (host: string, port: number) => Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export async function checkServerHealth(
|
||||||
|
servers: HealthServer[],
|
||||||
|
ping: Ping,
|
||||||
|
{
|
||||||
|
maxCount = SERVER_HEALTH_MAX_COUNT,
|
||||||
|
concurrency = SERVER_HEALTH_CONCURRENCY,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const queue = servers.slice(0, maxCount);
|
||||||
|
const results: Array<Record<string, unknown>> = new Array(queue.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (nextIndex < queue.length) {
|
||||||
|
const index = nextIndex++;
|
||||||
|
const server = queue[index];
|
||||||
|
results[index] = {
|
||||||
|
id: server.id,
|
||||||
|
tag: server.label,
|
||||||
|
...await ping(server.host, server.port),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerHealthDependencies {
|
||||||
|
readProfiles(): StoredProfile[];
|
||||||
|
readDesiredProfileId(): string;
|
||||||
|
ping: Ping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthService(dependencies: ServerHealthDependencies) {
|
||||||
|
return {
|
||||||
|
check(profileIdValue: unknown, serverIds: unknown) {
|
||||||
|
const requestedProfileId = String(profileIdValue || '').trim();
|
||||||
|
const profileId = requestedProfileId || dependencies.readDesiredProfileId();
|
||||||
|
const profile = dependencies.readProfiles().find((candidate) => candidate.id === profileId);
|
||||||
|
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
|
||||||
|
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
||||||
|
return checkServerHealth(
|
||||||
|
requestedIds.size
|
||||||
|
? profile.servers.filter((server) => requestedIds.has(server.id))
|
||||||
|
: profile.servers,
|
||||||
|
dependencies.ping,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerHealthService = ReturnType<typeof createServerHealthService>;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
createStateSnapshot,
|
||||||
|
normalizeStoredState,
|
||||||
|
type GatewayAutoState,
|
||||||
|
type OperationState,
|
||||||
|
type StateSnapshot,
|
||||||
|
type StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
import type { FailoverSnapshot } from '../../../shared/failover.js';
|
||||||
|
|
||||||
|
interface RuntimeState {
|
||||||
|
running?: boolean;
|
||||||
|
startedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateReadResult {
|
||||||
|
snapshot: StateSnapshot;
|
||||||
|
storedState: StoredState;
|
||||||
|
gatewayAuto: GatewayAutoState;
|
||||||
|
configExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateServiceDependencies {
|
||||||
|
appMode: string;
|
||||||
|
readStoredState: () => unknown;
|
||||||
|
refreshRuntime: () => Promise<RuntimeState>;
|
||||||
|
getGatewayAutoState: () => GatewayAutoState;
|
||||||
|
getOperationState: () => OperationState;
|
||||||
|
configExists: () => boolean;
|
||||||
|
getFailoverSnapshot?: () => FailoverSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionHost(value: unknown) {
|
||||||
|
try {
|
||||||
|
return `${new URL(String(value)).host}/…`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateService(dependencies: StateServiceDependencies) {
|
||||||
|
return {
|
||||||
|
async read(): Promise<StateReadResult> {
|
||||||
|
const runtime = await dependencies.refreshRuntime();
|
||||||
|
const storedState = normalizeStoredState(dependencies.readStoredState());
|
||||||
|
const gatewayAuto = dependencies.getGatewayAutoState();
|
||||||
|
const configExists = dependencies.configExists();
|
||||||
|
const snapshot = createStateSnapshot({
|
||||||
|
storedState,
|
||||||
|
runtime,
|
||||||
|
gatewayAuto,
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
configExists,
|
||||||
|
subscriptionHost: subscriptionHost(storedState.subscriptionUrl),
|
||||||
|
operation: dependencies.getOperationState(),
|
||||||
|
failoverSnapshot: dependencies.getFailoverSnapshot?.(),
|
||||||
|
});
|
||||||
|
return { snapshot, storedState, gatewayAuto, configExists };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateService = ReturnType<typeof createStateService>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
createValidateSubscription,
|
||||||
|
type ValidateSubscription,
|
||||||
|
} from './validateSubscription.js';
|
||||||
|
export {
|
||||||
|
createSubscriptionService,
|
||||||
|
type SubscriptionService,
|
||||||
|
} from './subscriptionService.js';
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
profileById,
|
||||||
|
type GatewayAutoState,
|
||||||
|
type HarborServer,
|
||||||
|
type StoredProfile,
|
||||||
|
type StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import type { ActivityJournalEventInput } from '../../../shared/activityJournal.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface ParsedSubscription {
|
||||||
|
config: unknown;
|
||||||
|
servers: HarborServer[];
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimerHandle = NodeJS.Timeout;
|
||||||
|
|
||||||
|
interface SubscriptionServiceDependencies {
|
||||||
|
provider: {
|
||||||
|
fetchSubscription(url: string): Promise<ParsedSubscription>;
|
||||||
|
selectRefreshedServer(
|
||||||
|
currentServerId: string,
|
||||||
|
currentServers: HarborServer[],
|
||||||
|
nextServers: HarborServer[],
|
||||||
|
): string;
|
||||||
|
};
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
stop(): Promise<unknown>;
|
||||||
|
start(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
gatewayAuto: {
|
||||||
|
read(): GatewayAutoState;
|
||||||
|
set(value: GatewayAutoState): void;
|
||||||
|
createInitial(): GatewayAutoState;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
scheduler: {
|
||||||
|
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||||
|
clearInterval(handle: TimerHandle): void;
|
||||||
|
};
|
||||||
|
onRefreshError(error: unknown): void;
|
||||||
|
onEvent?: (event: ActivityJournalEventInput) => void;
|
||||||
|
failover?: {
|
||||||
|
reconcile(): Promise<unknown>;
|
||||||
|
restoreAppliedActivation(state: StoredState): Promise<unknown>;
|
||||||
|
};
|
||||||
|
now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileMutationResult extends Record<string, unknown> {
|
||||||
|
success: true;
|
||||||
|
profileId: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeErrorCode = (error: unknown) => (
|
||||||
|
error && typeof error === 'object' && 'code' in error
|
||||||
|
? String(error.code)
|
||||||
|
: 'UNKNOWN'
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanLabel = (value: unknown) => String(value || '').trim();
|
||||||
|
const foldedLabel = (value: unknown) => cleanLabel(value).toLocaleLowerCase('ru');
|
||||||
|
|
||||||
|
function requireLabel(value: unknown) {
|
||||||
|
const label = cleanLabel(value);
|
||||||
|
if (!label || label.length > 64) throw new HarborError('REQUEST_INVALID');
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
|
||||||
|
if (expectedRevision === undefined) return;
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
|
||||||
|
throw new HarborError('STATE_CONFLICT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireProfile(state: StoredState, profileId: unknown) {
|
||||||
|
const profile = profileById(state, profileId);
|
||||||
|
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUniqueLabel(state: StoredState, label: string, exceptProfileId = '') {
|
||||||
|
if (state.profiles.some((profile) => (
|
||||||
|
profile.id !== exceptProfileId && foldedLabel(profile.label) === foldedLabel(label)
|
||||||
|
))) throw new HarborError('PROFILE_NAME_CONFLICT');
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceProfile(state: StoredState, nextProfile: StoredProfile) {
|
||||||
|
return state.profiles.map((profile) => profile.id === nextProfile.id ? nextProfile : profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutationResult(profile: Pick<StoredProfile, 'id' | 'label'>): ProfileMutationResult {
|
||||||
|
return { success: true, profileId: profile.id, label: profile.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicHost(url: string) {
|
||||||
|
try { return new URL(url).hostname; } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
|
||||||
|
const refreshPromises = new Map<string, Promise<ProfileMutationResult>>();
|
||||||
|
let refreshTimer: TimerHandle | null = null;
|
||||||
|
const now = dependencies.now || (() => new Date());
|
||||||
|
|
||||||
|
const restoreConfig = (previous: string | null) => {
|
||||||
|
if (previous === null) dependencies.config.remove();
|
||||||
|
else dependencies.config.restore(previous);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preflightAddProfile = (labelValue: unknown, expectedRevision?: unknown) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
requireExpectedRevision(state, expectedRevision);
|
||||||
|
assertUniqueLabel(state, requireLabel(labelValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
const preflightRenameProfile = (
|
||||||
|
profileId: unknown,
|
||||||
|
labelValue: unknown,
|
||||||
|
expectedRevision?: unknown,
|
||||||
|
) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
requireExpectedRevision(state, expectedRevision);
|
||||||
|
const profile = requireProfile(state, profileId);
|
||||||
|
assertUniqueLabel(state, requireLabel(labelValue), profile.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProfile = async (
|
||||||
|
labelValue: unknown,
|
||||||
|
subscriptionUrlValue: unknown,
|
||||||
|
expectedRevision?: unknown,
|
||||||
|
) => {
|
||||||
|
const label = requireLabel(labelValue);
|
||||||
|
const subscriptionUrl = String(subscriptionUrlValue || '').trim();
|
||||||
|
const preflight = dependencies.state.read();
|
||||||
|
requireExpectedRevision(preflight, expectedRevision);
|
||||||
|
assertUniqueLabel(preflight, label);
|
||||||
|
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||||
|
|
||||||
|
// Admission CAS already passed; background freshness may advance the global revision during provider I/O.
|
||||||
|
return dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
assertUniqueLabel(state, label);
|
||||||
|
const profile: StoredProfile = {
|
||||||
|
id: `profile_${crypto.randomUUID()}`,
|
||||||
|
label,
|
||||||
|
subscriptionUrl,
|
||||||
|
subscriptionConfig: parsed.config,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
desiredServerId: '',
|
||||||
|
lastRefreshAttemptAt: parsed.fetchedAt,
|
||||||
|
lastRefreshErrorCode: null,
|
||||||
|
};
|
||||||
|
dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
profiles: [...current.profiles, profile],
|
||||||
|
desiredProfileId: current.profiles.length ? current.desiredProfileId : profile.id,
|
||||||
|
}));
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'subscription.added',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'subscription',
|
||||||
|
dedupeKey: `subscription.added:${dependencies.state.read().revision}`,
|
||||||
|
data: { profileId: profile.id, profileLabel: profile.label, host: publicHost(profile.subscriptionUrl), serverCount: profile.servers.length },
|
||||||
|
});
|
||||||
|
return mutationResult(profile);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameProfile = (profileId: unknown, labelValue: unknown, expectedRevision?: unknown) => (
|
||||||
|
dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
requireExpectedRevision(state, expectedRevision);
|
||||||
|
const profile = requireProfile(state, profileId);
|
||||||
|
const label = requireLabel(labelValue);
|
||||||
|
if (profile.label === label) return mutationResult(profile);
|
||||||
|
assertUniqueLabel(state, label, profile.id);
|
||||||
|
const renamed = { ...profile, label };
|
||||||
|
dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
profiles: replaceProfile(current, renamed),
|
||||||
|
}));
|
||||||
|
return mutationResult(renamed);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectProfileServer = (
|
||||||
|
profileId: unknown,
|
||||||
|
serverIdValue: unknown,
|
||||||
|
expectedRevision?: unknown,
|
||||||
|
) => dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
requireExpectedRevision(state, expectedRevision);
|
||||||
|
const profile = requireProfile(state, profileId);
|
||||||
|
const serverId = String(serverIdValue || '').trim();
|
||||||
|
if (!profile.servers.some((server) => server.id === serverId)) {
|
||||||
|
throw new HarborError('SERVER_NOT_FOUND');
|
||||||
|
}
|
||||||
|
if (profile.desiredServerId === serverId && state.desiredProfileId === profile.id) {
|
||||||
|
return mutationResult(profile);
|
||||||
|
}
|
||||||
|
const selected = profile.desiredServerId === serverId
|
||||||
|
? profile
|
||||||
|
: { ...profile, desiredServerId: serverId };
|
||||||
|
dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
profiles: replaceProfile(current, selected),
|
||||||
|
desiredProfileId: profile.id,
|
||||||
|
}));
|
||||||
|
return mutationResult(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordRefreshError = async (
|
||||||
|
profileId: string,
|
||||||
|
subscriptionUrl: string,
|
||||||
|
error: unknown,
|
||||||
|
origin: 'manual' | 'scheduled',
|
||||||
|
) => dependencies.serialize(async () => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
const profile = requireProfile(state, profileId);
|
||||||
|
if (profile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
|
||||||
|
const failed = {
|
||||||
|
...profile,
|
||||||
|
lastRefreshAttemptAt: now().toISOString(),
|
||||||
|
lastRefreshErrorCode: safeErrorCode(error),
|
||||||
|
};
|
||||||
|
dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
profiles: replaceProfile(current, failed),
|
||||||
|
}));
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'subscription.refresh_failed',
|
||||||
|
severity: 'warning',
|
||||||
|
source: 'subscription',
|
||||||
|
dedupeKey: origin === 'scheduled'
|
||||||
|
? `subscription.refresh_failed:${failed.id}:${failed.fetchedAt || 'never'}:${failed.lastRefreshErrorCode}`
|
||||||
|
: `subscription.refresh_failed:${dependencies.state.read().revision}`,
|
||||||
|
data: {
|
||||||
|
profileId: failed.id,
|
||||||
|
profileLabel: failed.label,
|
||||||
|
host: publicHost(failed.subscriptionUrl),
|
||||||
|
errorCode: failed.lastRefreshErrorCode || 'UNKNOWN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitRefresh = (
|
||||||
|
profileId: string,
|
||||||
|
subscriptionUrl: string,
|
||||||
|
parsed: ParsedSubscription,
|
||||||
|
origin: 'manual' | 'scheduled',
|
||||||
|
) => dependencies.serialize(async () => {
|
||||||
|
// Re-read the profile after provider I/O and guard its owner instead of rejecting background-only revisions.
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
const previousProfile = requireProfile(previousState, profileId);
|
||||||
|
if (previousProfile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
|
||||||
|
|
||||||
|
const desiredServerId = dependencies.provider.selectRefreshedServer(
|
||||||
|
previousProfile.desiredServerId,
|
||||||
|
previousProfile.servers,
|
||||||
|
parsed.servers,
|
||||||
|
);
|
||||||
|
const refreshedProfile: StoredProfile = {
|
||||||
|
...previousProfile,
|
||||||
|
subscriptionConfig: parsed.config,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
desiredServerId,
|
||||||
|
lastRefreshAttemptAt: parsed.fetchedAt,
|
||||||
|
lastRefreshErrorCode: null,
|
||||||
|
};
|
||||||
|
const contentChanged = JSON.stringify({
|
||||||
|
subscriptionConfig: previousProfile.subscriptionConfig,
|
||||||
|
servers: previousProfile.servers,
|
||||||
|
userInfo: previousProfile.userInfo,
|
||||||
|
}) !== JSON.stringify({
|
||||||
|
subscriptionConfig: refreshedProfile.subscriptionConfig,
|
||||||
|
servers: refreshedProfile.servers,
|
||||||
|
userInfo: refreshedProfile.userInfo,
|
||||||
|
});
|
||||||
|
const appendRefreshEvent = () => {
|
||||||
|
if (origin === 'scheduled' && !contentChanged && !previousProfile.lastRefreshErrorCode) return;
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'subscription.refreshed',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'subscription',
|
||||||
|
dedupeKey: `subscription.refreshed:${dependencies.state.read().revision}`,
|
||||||
|
data: {
|
||||||
|
profileId: refreshedProfile.id,
|
||||||
|
profileLabel: refreshedProfile.label,
|
||||||
|
host: publicHost(refreshedProfile.subscriptionUrl),
|
||||||
|
serverCount: refreshedProfile.servers.length,
|
||||||
|
added: refreshedProfile.servers.filter(({ id }) => !previousProfile.servers.some((server) => server.id === id)).length,
|
||||||
|
removed: previousProfile.servers.filter(({ id }) => !refreshedProfile.servers.some((server) => server.id === id)).length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const loadedTargets = previousState.appliedFailoverPolicy
|
||||||
|
? [previousState.appliedFailoverPolicy.primary, previousState.appliedFailoverPolicy.reserve]
|
||||||
|
: [];
|
||||||
|
const missingLoadedTarget = loadedTargets.some((target) => (
|
||||||
|
target.profileId === profileId
|
||||||
|
&& !refreshedProfile.servers.some(({ id }) => id === target.serverId)
|
||||||
|
));
|
||||||
|
const pauseFailover = previousState.failoverPolicy?.enabled
|
||||||
|
&& !previousState.failoverPolicy.paused
|
||||||
|
&& missingLoadedTarget;
|
||||||
|
const running = await dependencies.runtime.isRunning();
|
||||||
|
const refreshesApplied = running
|
||||||
|
&& previousState.appliedProfileId === profileId
|
||||||
|
&& !previousState.appliedFailoverPolicy;
|
||||||
|
const nextAppliedServerId = refreshesApplied
|
||||||
|
? dependencies.provider.selectRefreshedServer(
|
||||||
|
previousState.appliedServerId,
|
||||||
|
previousProfile.servers,
|
||||||
|
parsed.servers,
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!refreshesApplied || !nextAppliedServerId) {
|
||||||
|
dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
profiles: replaceProfile(current, refreshedProfile),
|
||||||
|
...(pauseFailover ? { failoverPolicy: { ...current.failoverPolicy, paused: true } } : {}),
|
||||||
|
}));
|
||||||
|
if (pauseFailover) dependencies.onEvent?.({
|
||||||
|
type: 'failover.paused',
|
||||||
|
severity: 'warning',
|
||||||
|
source: 'failover',
|
||||||
|
dedupeKey: `failover.paused:missing-target:${profileId}:${dependencies.state.read().revision}`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
await dependencies.failover?.reconcile();
|
||||||
|
appendRefreshEvent();
|
||||||
|
return mutationResult(refreshedProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAppliedServer = parsed.servers.find((server) => server.id === nextAppliedServerId)!;
|
||||||
|
const candidateConfig = dependencies.config.build(
|
||||||
|
parsed.config,
|
||||||
|
nextAppliedServerId,
|
||||||
|
previousState.routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
runtimeMutationStarted = true;
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
profiles: replaceProfile(current, refreshedProfile),
|
||||||
|
appliedServerId: nextAppliedServerId,
|
||||||
|
appliedServerSnapshot: nextAppliedServer,
|
||||||
|
appliedRouteRules: dependencies.gatewayAuto.read().mode === 'gateway-direct'
|
||||||
|
? []
|
||||||
|
: current.routeRules,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||||
|
...(runtimeMutationStarted ? [{
|
||||||
|
run: async () => {
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||||
|
},
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
], 'Subscription refresh rollback failed');
|
||||||
|
}
|
||||||
|
await dependencies.failover?.reconcile();
|
||||||
|
appendRefreshEvent();
|
||||||
|
return mutationResult(refreshedProfile);
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshProfile = (
|
||||||
|
profileIdValue: unknown,
|
||||||
|
expectedRevision?: unknown,
|
||||||
|
origin: 'manual' | 'scheduled' = 'manual',
|
||||||
|
) => {
|
||||||
|
const profileId = String(profileIdValue || '').trim();
|
||||||
|
const existing = refreshPromises.get(profileId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const initialState = dependencies.state.read();
|
||||||
|
requireExpectedRevision(initialState, expectedRevision);
|
||||||
|
const initialProfile = requireProfile(initialState, profileId);
|
||||||
|
const operation = (async () => {
|
||||||
|
let parsed: ParsedSubscription;
|
||||||
|
try {
|
||||||
|
parsed = await dependencies.provider.fetchSubscription(initialProfile.subscriptionUrl);
|
||||||
|
} catch (error) {
|
||||||
|
if (safeErrorCode(error) !== 'STATE_CONFLICT') {
|
||||||
|
await recordRefreshError(
|
||||||
|
profileId,
|
||||||
|
initialProfile.subscriptionUrl,
|
||||||
|
error,
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return commitRefresh(
|
||||||
|
profileId,
|
||||||
|
initialProfile.subscriptionUrl,
|
||||||
|
parsed,
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
})().finally(() => refreshPromises.delete(profileId));
|
||||||
|
refreshPromises.set(profileId, operation);
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProfile = (
|
||||||
|
profileIdValue: unknown,
|
||||||
|
modeValue: unknown = 'delete',
|
||||||
|
expectedRevision?: unknown,
|
||||||
|
) => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
requireExpectedRevision(previousState, expectedRevision);
|
||||||
|
const profile = requireProfile(previousState, profileIdValue);
|
||||||
|
const mode = String(modeValue || 'delete');
|
||||||
|
if (!['delete', 'stop-and-delete'].includes(mode)) throw new HarborError('REQUEST_INVALID');
|
||||||
|
const running = await dependencies.runtime.isRunning();
|
||||||
|
const failoverReferencesProfile = Boolean(previousState.appliedFailoverPolicy && (
|
||||||
|
previousState.appliedFailoverPolicy.primary.profileId === profile.id
|
||||||
|
|| previousState.appliedFailoverPolicy.reserve.profileId === profile.id
|
||||||
|
));
|
||||||
|
const desiredFailoverReferencesProfile = previousState.failoverPolicy?.primary.profileId === profile.id
|
||||||
|
|| previousState.failoverPolicy?.reserve.profileId === profile.id;
|
||||||
|
const applied = running && (previousState.appliedProfileId === profile.id || failoverReferencesProfile);
|
||||||
|
if (applied && mode !== 'stop-and-delete') throw new HarborError('PROFILE_IN_USE');
|
||||||
|
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||||
|
const removesAppliedTarget = previousState.appliedProfileId === profile.id || failoverReferencesProfile;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let gatewayMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (applied) {
|
||||||
|
runtimeMutationStarted = true;
|
||||||
|
await dependencies.runtime.stop();
|
||||||
|
}
|
||||||
|
if (removesAppliedTarget) {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.remove();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
previousState.desiredProfileId === profile.id
|
||||||
|
&& !(running && previousState.appliedProfileId !== profile.id)
|
||||||
|
) {
|
||||||
|
gatewayMutationStarted = true;
|
||||||
|
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||||
|
}
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((current) => ({
|
||||||
|
...current,
|
||||||
|
profiles: current.profiles.filter((candidate) => candidate.id !== profile.id),
|
||||||
|
desiredProfileId: current.desiredProfileId === profile.id ? '' : current.desiredProfileId,
|
||||||
|
appliedProfileId: current.appliedProfileId === profile.id ? '' : current.appliedProfileId,
|
||||||
|
appliedServerId: current.appliedProfileId === profile.id ? '' : current.appliedServerId,
|
||||||
|
appliedServerSnapshot: current.appliedProfileId === profile.id
|
||||||
|
? null
|
||||||
|
: current.appliedServerSnapshot,
|
||||||
|
...(removesAppliedTarget ? {
|
||||||
|
connectionDesired: 'stopped',
|
||||||
|
appliedProfileId: '',
|
||||||
|
appliedServerId: '',
|
||||||
|
appliedServerSnapshot: null,
|
||||||
|
appliedFailoverPolicy: null,
|
||||||
|
} : {}),
|
||||||
|
...(failoverReferencesProfile || desiredFailoverReferencesProfile ? {
|
||||||
|
failoverPolicy: {
|
||||||
|
...current.failoverPolicy,
|
||||||
|
enabled: false,
|
||||||
|
paused: false,
|
||||||
|
primary: current.failoverPolicy.primary.profileId === profile.id
|
||||||
|
? { profileId: '', serverId: '' }
|
||||||
|
: current.failoverPolicy.primary,
|
||||||
|
reserve: current.failoverPolicy.reserve.profileId === profile.id
|
||||||
|
? { profileId: '', serverId: '' }
|
||||||
|
: current.failoverPolicy.reserve,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
...(gatewayMutationStarted ? [{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) }] : []),
|
||||||
|
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||||
|
...(runtimeMutationStarted ? [{
|
||||||
|
run: async () => {
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||||
|
},
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
], 'Subscription delete rollback failed');
|
||||||
|
}
|
||||||
|
await dependencies.failover?.reconcile();
|
||||||
|
dependencies.onEvent?.({
|
||||||
|
type: 'subscription.deleted',
|
||||||
|
severity: 'info',
|
||||||
|
source: 'subscription',
|
||||||
|
dedupeKey: `subscription.deleted:${dependencies.state.read().revision}`,
|
||||||
|
data: { profileId: profile.id, profileLabel: profile.label },
|
||||||
|
});
|
||||||
|
return mutationResult(profile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One-release compatibility for the old single-subscription client.
|
||||||
|
const importSubscription = (subscriptionUrl: string, expectedRevision?: unknown) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
if (state.profiles.length) throw new HarborError('STATE_CONFLICT');
|
||||||
|
return addProfile('Основной', subscriptionUrl, expectedRevision);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSavedSubscription = (expectedRevision?: unknown) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
|
||||||
|
return refreshProfile(state.profiles[0].id, expectedRevision);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSavedSubscription = ({
|
||||||
|
stopRuntime = true,
|
||||||
|
expectedRevision,
|
||||||
|
}: { stopRuntime?: boolean; expectedRevision?: unknown } = {}) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
if (!state.profiles.length) return Promise.resolve(false);
|
||||||
|
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
|
||||||
|
return deleteProfile(
|
||||||
|
state.profiles[0].id,
|
||||||
|
stopRuntime ? 'stop-and-delete' : 'delete',
|
||||||
|
expectedRevision,
|
||||||
|
).then(() => true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoRefresh = (intervalMs: number) => {
|
||||||
|
if (refreshTimer) return;
|
||||||
|
refreshTimer = dependencies.scheduler.setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
for (const { id } of dependencies.state.read().profiles) {
|
||||||
|
try {
|
||||||
|
await refreshProfile(id, undefined, 'scheduled');
|
||||||
|
} catch (error) {
|
||||||
|
dependencies.onRefreshError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, intervalMs);
|
||||||
|
refreshTimer.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (!refreshTimer) return;
|
||||||
|
dependencies.scheduler.clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
preflightAddProfile,
|
||||||
|
preflightRenameProfile,
|
||||||
|
addProfile,
|
||||||
|
renameProfile,
|
||||||
|
selectProfileServer,
|
||||||
|
refreshProfile,
|
||||||
|
deleteProfile,
|
||||||
|
importSubscription,
|
||||||
|
refreshSavedSubscription,
|
||||||
|
resetSavedSubscription,
|
||||||
|
startAutoRefresh,
|
||||||
|
stopAutoRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionService = ReturnType<typeof createSubscriptionService>;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
interface SubscriptionResult {
|
||||||
|
servers: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchSubscription = (url: string) => Promise<SubscriptionResult>;
|
||||||
|
|
||||||
|
export function createValidateSubscription(fetchSubscription: FetchSubscription) {
|
||||||
|
return async (url: unknown) => {
|
||||||
|
const parsed = await fetchSubscription(String(url).trim());
|
||||||
|
return { servers: parsed.servers.length };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidateSubscription = ReturnType<typeof createValidateSubscription>;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
interface MaterializeOptions {
|
||||||
|
apiPort: number;
|
||||||
|
secretPath: string;
|
||||||
|
runtimeConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateWrite(filePath: string, value: unknown) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
||||||
|
let descriptor: number | null = null;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(
|
||||||
|
temporaryPath,
|
||||||
|
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(descriptor, JSON.stringify(value));
|
||||||
|
fs.fchmodSync(descriptor, 0o600);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
descriptor = null;
|
||||||
|
fs.renameSync(temporaryPath, filePath);
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
const status = fs.lstatSync(filePath);
|
||||||
|
if (!status.isFile() || status.isSymbolicLink() || (status.mode & 0o777) !== 0o600) {
|
||||||
|
throw new Error('private runtime config is not a regular 0600 file');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== null) fs.closeSync(descriptor);
|
||||||
|
fs.rmSync(temporaryPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSecret(secretPath: string) {
|
||||||
|
const readFlags = fs.constants.O_RDWR | fs.constants.O_NOFOLLOW;
|
||||||
|
try {
|
||||||
|
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
descriptor: fs.openSync(
|
||||||
|
secretPath,
|
||||||
|
readFlags | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
||||||
|
0o600,
|
||||||
|
),
|
||||||
|
created: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||||
|
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureGatewayNativeApiSecret(secretPath: string) {
|
||||||
|
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||||||
|
const { descriptor, created } = openSecret(secretPath);
|
||||||
|
try {
|
||||||
|
const secret = created
|
||||||
|
? crypto.randomBytes(32).toString('hex')
|
||||||
|
: fs.readFileSync(descriptor, 'utf8');
|
||||||
|
if (created) {
|
||||||
|
fs.writeFileSync(descriptor, secret);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(secret)) {
|
||||||
|
throw new Error('native API secret must contain exactly 64 lowercase hex characters');
|
||||||
|
}
|
||||||
|
fs.fchmodSync(descriptor, 0o600);
|
||||||
|
const opened = fs.fstatSync(descriptor);
|
||||||
|
const linked = fs.lstatSync(secretPath);
|
||||||
|
if (!opened.isFile() || linked.isSymbolicLink() || !linked.isFile()
|
||||||
|
|| opened.dev !== linked.dev || opened.ino !== linked.ino
|
||||||
|
|| (opened.mode & 0o777) !== 0o600 || (linked.mode & 0o777) !== 0o600) {
|
||||||
|
throw new Error('native API secret is not a regular 0600 file');
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutApiServices(config: unknown) {
|
||||||
|
const safe = structuredClone(record(config));
|
||||||
|
const services = Array.isArray(safe.services)
|
||||||
|
? safe.services.filter((service) => record(service).type !== 'api')
|
||||||
|
: [];
|
||||||
|
if (services.length) safe.services = services;
|
||||||
|
else delete safe.services;
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeGatewaySnapshotConfig(config: unknown, runtimeConfigPath: string) {
|
||||||
|
privateWrite(runtimeConfigPath, withoutApiServices(config));
|
||||||
|
return { configPath: runtimeConfigPath, secret: null, warning: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAuthenticatedApi(config: unknown, secret: string) {
|
||||||
|
const materialized = structuredClone(record(config));
|
||||||
|
const services = Array.isArray(materialized.services) ? materialized.services : [];
|
||||||
|
materialized.services = services.map((service) => (
|
||||||
|
record(service).type === 'api'
|
||||||
|
? { ...record(service), secret }
|
||||||
|
: service
|
||||||
|
));
|
||||||
|
return materialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApiService(config: unknown, apiPort: number) {
|
||||||
|
const configuredServices = record(config).services;
|
||||||
|
const services = Array.isArray(configuredServices) ? configuredServices : [];
|
||||||
|
const apiServices = services.map(record).filter(({ type }) => type === 'api');
|
||||||
|
if (apiServices.length !== 1) throw new Error('expected exactly one native API service');
|
||||||
|
const [service] = apiServices;
|
||||||
|
if (service.listen !== '127.0.0.1' || service.listen_port !== apiPort
|
||||||
|
|| service.dashboard !== false || Object.hasOwn(service, 'secret')) {
|
||||||
|
throw new Error(`native API service must be unauthenticated base config on 127.0.0.1:${apiPort}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeGatewayNativeConfig(
|
||||||
|
config: unknown,
|
||||||
|
{ apiPort, secretPath, runtimeConfigPath }: MaterializeOptions,
|
||||||
|
) {
|
||||||
|
let warning: string | null = null;
|
||||||
|
try {
|
||||||
|
validateApiService(config, apiPort);
|
||||||
|
const secret = ensureGatewayNativeApiSecret(secretPath);
|
||||||
|
privateWrite(runtimeConfigPath, withAuthenticatedApi(config, secret));
|
||||||
|
return { configPath: runtimeConfigPath, secret, warning };
|
||||||
|
} catch (error) {
|
||||||
|
warning = `Native traffic API disabled: ${message(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeConfig = withoutApiServices(config);
|
||||||
|
try {
|
||||||
|
privateWrite(runtimeConfigPath, safeConfig);
|
||||||
|
return { configPath: runtimeConfigPath, secret: null, warning };
|
||||||
|
} catch (error) {
|
||||||
|
warning = `${warning}; private runtime config unavailable: ${message(error)}`;
|
||||||
|
const suffix = crypto.createHash('sha256').update(runtimeConfigPath).digest('hex').slice(0, 12);
|
||||||
|
const fallbackPath = path.join(os.tmpdir(), `harbor-singbox-runtime-${process.pid}-${suffix}.json`);
|
||||||
|
privateWrite(fallbackPath, safeConfig);
|
||||||
|
return { configPath: fallbackPath, secret: null, warning };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
|
||||||
|
const NONCE_RE = /^[a-f0-9]{32}$/;
|
||||||
|
const PROOF_RE = /^[a-f0-9]{64}$/;
|
||||||
|
const INTERFACE_RE = /^[a-zA-Z0-9._-]{1,32}$/;
|
||||||
|
const MAC_RE = /^[a-f0-9]{2}(?::[a-f0-9]{2}){5}$/i;
|
||||||
|
const SECRET_QUERY_KEYS = new Set(['access_token', 'auth', 'key', 'secret', 'token', 'uuid']);
|
||||||
|
|
||||||
|
interface GatewayRoute {
|
||||||
|
gateway: string;
|
||||||
|
interface: string;
|
||||||
|
mac: string;
|
||||||
|
observedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifiedGateway {
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin?: string;
|
||||||
|
verifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayAutoRuntimeState {
|
||||||
|
mode: 'local-vpn' | 'gateway-direct';
|
||||||
|
failures: number;
|
||||||
|
gateway: GatewayRoute | null;
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin: string;
|
||||||
|
lastVerifiedAt: string | null;
|
||||||
|
lastError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4(value: unknown) {
|
||||||
|
const parts = String(value || '').split('.');
|
||||||
|
return parts.length === 4 && parts.every((part) => (
|
||||||
|
/^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionSecret(subscriptionUrl: unknown) {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(subscriptionUrl || '').trim());
|
||||||
|
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||||
|
const candidates = [
|
||||||
|
url.username,
|
||||||
|
url.password,
|
||||||
|
...[...url.searchParams.entries()]
|
||||||
|
.filter(([key]) => SECRET_QUERY_KEYS.has(key.toLowerCase()))
|
||||||
|
.map(([, value]) => value),
|
||||||
|
pathSegments.at(-1),
|
||||||
|
]
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter((value) => value.length >= 16);
|
||||||
|
if (!candidates.length) return '';
|
||||||
|
|
||||||
|
url.hash = '';
|
||||||
|
url.searchParams.sort();
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presenceProof(subscriptionUrl: unknown, nonce: unknown, gatewayId: unknown) {
|
||||||
|
const credentialUrl = subscriptionSecret(subscriptionUrl);
|
||||||
|
if (!credentialUrl) return '';
|
||||||
|
const key = crypto.createHash('sha256')
|
||||||
|
.update(`harbor-gateway-presence-key\n${credentialUrl}`)
|
||||||
|
.digest();
|
||||||
|
return crypto.createHmac('sha256', key)
|
||||||
|
.update(`v1\n${nonce}\n${gatewayId}`)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }: {
|
||||||
|
appMode: unknown;
|
||||||
|
subscriptionUrl: unknown;
|
||||||
|
gatewayId: unknown;
|
||||||
|
nonce: unknown;
|
||||||
|
}) {
|
||||||
|
if (!NONCE_RE.test(String(nonce || ''))) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = String(subscriptionUrl || '').trim();
|
||||||
|
const id = String(gatewayId || '').trim();
|
||||||
|
if (appMode !== 'gateway' || !subscriptionSecret(subscription) || !id) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
available: false,
|
||||||
|
product: 'harbor',
|
||||||
|
role: appMode,
|
||||||
|
protocolVersion: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
available: true,
|
||||||
|
product: 'harbor',
|
||||||
|
role: 'gateway',
|
||||||
|
protocolVersion: 1,
|
||||||
|
gatewayId: id,
|
||||||
|
transparentRouting: true,
|
||||||
|
proof: presenceProof(subscription, nonce, id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyGatewayPresence(
|
||||||
|
value: unknown,
|
||||||
|
{ subscriptionUrl, nonce }: { subscriptionUrl: unknown; nonce: unknown },
|
||||||
|
) {
|
||||||
|
const payload = record(value);
|
||||||
|
if (
|
||||||
|
payload?.available !== true ||
|
||||||
|
payload?.product !== 'harbor' ||
|
||||||
|
payload?.role !== 'gateway' ||
|
||||||
|
payload?.protocolVersion !== 1 ||
|
||||||
|
payload?.transparentRouting !== true ||
|
||||||
|
!payload.gatewayId ||
|
||||||
|
!NONCE_RE.test(String(nonce || '')) ||
|
||||||
|
!PROOF_RE.test(String(payload.proof || ''))
|
||||||
|
) return false;
|
||||||
|
|
||||||
|
const actual = Buffer.from(String(payload.proof), 'hex');
|
||||||
|
const expectedProof = presenceProof(subscriptionUrl, nonce, String(payload.gatewayId));
|
||||||
|
if (!expectedProof) return false;
|
||||||
|
const expected = Buffer.from(expectedProof, 'hex');
|
||||||
|
return crypto.timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeGatewayPresence({
|
||||||
|
gateway,
|
||||||
|
subscriptionUrl,
|
||||||
|
port = 3456,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
timeoutMs = 1000,
|
||||||
|
nonce = crypto.randomBytes(16).toString('hex'),
|
||||||
|
}: {
|
||||||
|
gateway: string;
|
||||||
|
subscriptionUrl: unknown;
|
||||||
|
port?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
timeoutMs?: number;
|
||||||
|
nonce?: string;
|
||||||
|
}): Promise<VerifiedGateway> {
|
||||||
|
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
|
||||||
|
|
||||||
|
const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`;
|
||||||
|
const response = await fetchImpl(
|
||||||
|
presenceUrl,
|
||||||
|
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
|
||||||
|
);
|
||||||
|
const payload = record(await response.json().catch(() => ({})));
|
||||||
|
if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) {
|
||||||
|
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gatewayId: String(payload.gatewayId),
|
||||||
|
uiOrigin: new URL(presenceUrl).origin,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHostNetworkState(value: unknown, {
|
||||||
|
now = Date.now(),
|
||||||
|
maxAgeMs = 15_000,
|
||||||
|
}: { now?: number; maxAgeMs?: number } = {}): GatewayRoute | null {
|
||||||
|
const candidate = record(value);
|
||||||
|
const gateway = String(candidate.gateway || '').trim();
|
||||||
|
const networkInterface = String(candidate.interface || '').trim();
|
||||||
|
const mac = String(candidate.mac || '').trim().toLowerCase();
|
||||||
|
const observedAt = Date.parse(String(candidate.observedAt || ''));
|
||||||
|
|
||||||
|
// ponytail: IPv4-only matches the current Gateway; add IPv6 when its TProxy path supports it.
|
||||||
|
if (
|
||||||
|
!isIpv4(gateway) ||
|
||||||
|
!INTERFACE_RE.test(networkInterface) ||
|
||||||
|
!MAC_RE.test(mac) ||
|
||||||
|
!Number.isFinite(observedAt) ||
|
||||||
|
observedAt > now + 5_000 ||
|
||||||
|
now - observedAt > maxAgeMs
|
||||||
|
) return null;
|
||||||
|
|
||||||
|
return { gateway, interface: networkInterface, mac, observedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readHostNetworkState(filePath: string, options?: { now?: number; maxAgeMs?: number }) {
|
||||||
|
try {
|
||||||
|
return normalizeHostNetworkState(
|
||||||
|
JSON.parse(fs.readFileSync(filePath, 'utf8')),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameGatewayRoute(previous: GatewayRoute | null, current: GatewayRoute | null) {
|
||||||
|
return Boolean(
|
||||||
|
previous &&
|
||||||
|
current &&
|
||||||
|
previous.gateway === current.gateway &&
|
||||||
|
previous.interface === current.interface &&
|
||||||
|
previous.mac === current.mac,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoState(): GatewayAutoRuntimeState {
|
||||||
|
return {
|
||||||
|
mode: 'local-vpn',
|
||||||
|
failures: 0,
|
||||||
|
gateway: null,
|
||||||
|
gatewayId: '',
|
||||||
|
uiOrigin: '',
|
||||||
|
lastVerifiedAt: null,
|
||||||
|
lastError: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyGatewayPreference(state: GatewayAutoRuntimeState, enabled: boolean): GatewayAutoRuntimeState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextGatewayAutoState(current: GatewayAutoRuntimeState, {
|
||||||
|
network,
|
||||||
|
verifiedGateway = null,
|
||||||
|
error = 'Gateway presence check failed',
|
||||||
|
}: {
|
||||||
|
network: GatewayRoute | null;
|
||||||
|
verifiedGateway?: VerifiedGateway | null;
|
||||||
|
error?: unknown;
|
||||||
|
}): GatewayAutoRuntimeState {
|
||||||
|
if (!network) {
|
||||||
|
if (!current.gatewayId) return createGatewayAutoState();
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
failures: current.failures + 1,
|
||||||
|
lastError: String(error || 'Gateway presence check failed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeChanged = !sameGatewayRoute(current.gateway, network);
|
||||||
|
const base = routeChanged
|
||||||
|
? { ...createGatewayAutoState(), gateway: network }
|
||||||
|
: { ...current, gateway: network };
|
||||||
|
|
||||||
|
if (verifiedGateway?.gatewayId) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
mode: 'gateway-direct',
|
||||||
|
failures: 0,
|
||||||
|
gatewayId: verifiedGateway.gatewayId,
|
||||||
|
uiOrigin: verifiedGateway.uiOrigin || '',
|
||||||
|
lastVerifiedAt: verifiedGateway.verifiedAt || new Date().toISOString(),
|
||||||
|
lastError: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const failures = base.failures + 1;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
mode: base.gatewayId ? 'gateway-direct' : 'local-vpn',
|
||||||
|
failures,
|
||||||
|
lastError: String(error || 'Gateway presence check failed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const options = { encoding: 'utf8' as const };
|
||||||
|
const CHAIN_PATTERN = /^[a-z0-9_-]{1,28}$/i;
|
||||||
|
|
||||||
|
export function setGatewayInterception(enabled: boolean, chain: string, run: typeof spawnSync = spawnSync) {
|
||||||
|
if (!CHAIN_PATTERN.test(chain)) throw new Error('Некорректная TProxy chain');
|
||||||
|
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
|
||||||
|
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exists) {
|
||||||
|
const input = `*mangle\n-D PREROUTING -j ${chain}\n-I PREROUTING 1 -j ${chain}\nCOMMIT\n`;
|
||||||
|
const result = run('iptables-restore', ['-w', '--noflush'], { ...options, input });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
// The transaction keeps the already-working jump intact; leave routing up.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = run(
|
||||||
|
'iptables',
|
||||||
|
[...rule.slice(0, 3), '-I', 'PREROUTING', '1', '-j', chain],
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error((result.stderr || 'Не удалось включить Gateway VPN').trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { normalizeHarborError } from '../../shared/errors.js';
|
||||||
|
|
||||||
|
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||||
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactLogDetails(value: unknown) {
|
||||||
|
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendError(res: ServerResponse, error: unknown) {
|
||||||
|
const harborError = normalizeHarborError(error);
|
||||||
|
const correlationId = crypto.randomUUID();
|
||||||
|
const technical = harborError.cause instanceof Error
|
||||||
|
? harborError.cause.message
|
||||||
|
: harborError.details || error;
|
||||||
|
const technicalMessage = technical instanceof Error
|
||||||
|
? technical.message
|
||||||
|
: technical;
|
||||||
|
console.error(
|
||||||
|
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technicalMessage)}`,
|
||||||
|
);
|
||||||
|
return sendJson(res, harborError.status, {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: harborError.code,
|
||||||
|
message: harborError.message,
|
||||||
|
retryable: harborError.retryable,
|
||||||
|
correlationId,
|
||||||
|
...(harborError.details ? { details: harborError.details } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import type { ActivityJournalService } from '../../services/activityJournalService.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
export function createActivityJournalRoute({ journal }: { journal: ActivityJournalService }) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (url.pathname !== '/api/activity-journal' || req.method !== 'GET') return false;
|
||||||
|
sendJson(res, 200, journal.page(
|
||||||
|
Number(url.searchParams.get('limit')) || 50,
|
||||||
|
url.searchParams.get('cursor'),
|
||||||
|
));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectionService } from '../../features/connection/index.js';
|
||||||
|
|
||||||
|
interface ConnectionRuntimeRouteDependencies {
|
||||||
|
connection: Pick<ConnectionService, 'stop' | 'restart'>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra: { singboxRunning: boolean }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionRuntimeRoute(dependencies: ConnectionRuntimeRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||||
|
await dependencies.withOperation('stop', () => dependencies.connection.stop());
|
||||||
|
await dependencies.sendState(res, { singboxRunning: false });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||||
|
await dependencies.withOperation('start', () => dependencies.connection.restart());
|
||||||
|
await dependencies.sendState(res, { singboxRunning: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/index.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface ConnectivityDiagnosticsRouteDependencies {
|
||||||
|
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsRoute(
|
||||||
|
dependencies: ConnectivityDiagnosticsRouteDependencies,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
||||||
|
const { target = null } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.diagnostics.run(target);
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/diagnostics/settings' && req.method === 'PUT') {
|
||||||
|
const { settings, expectedRevision } = await dependencies.readBody(req);
|
||||||
|
dependencies.diagnostics.updateSettings(settings, expectedRevision);
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface DeviceInventoryPort {
|
||||||
|
snapshot(): unknown;
|
||||||
|
refresh(): Promise<unknown>;
|
||||||
|
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
||||||
|
createTag(name: unknown, expectedRevision: unknown): unknown;
|
||||||
|
renameTag(tagId: string, name: unknown, expectedRevision: unknown): unknown;
|
||||||
|
deleteTag(tagId: string, expectedRevision: unknown): unknown;
|
||||||
|
resetTraffic(expectedRevision: unknown): Promise<unknown>;
|
||||||
|
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceInventoryRouteDependencies {
|
||||||
|
deviceInventory: DeviceInventoryPort | null;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
|
||||||
|
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/;
|
||||||
|
const DEVICE_TAG_PATH = /^\/api\/device-tags\/(tag_[a-f0-9]{16})$/;
|
||||||
|
|
||||||
|
export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
|
||||||
|
if (pathname === '/api/devices') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendJson(res, 200, dependencies.deviceInventory.snapshot());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/devices/refresh') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'POST') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendJson(res, 200, await dependencies.deviceInventory.refresh());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/devices/traffic') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'DELETE') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(res, 200, await dependencies.deviceInventory.resetTraffic(body.expectedRevision));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/device-tags') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'POST') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
dependencies.deviceInventory.createTag(body.name, body.expectedRevision),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagMatch = pathname.match(DEVICE_TAG_PATH);
|
||||||
|
if (tagMatch) {
|
||||||
|
if (!dependencies.deviceInventory || !['PUT', 'DELETE'].includes(req.method || '')) {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
req.method === 'PUT'
|
||||||
|
? dependencies.deviceInventory.renameTag(tagMatch[1], body.name, body.expectedRevision)
|
||||||
|
: dependencies.deviceInventory.deleteTag(tagMatch[1], body.expectedRevision),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceMatch = pathname.match(DEVICE_PATH);
|
||||||
|
if (deviceMatch) {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const { expectedRevision, ...patch } = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
dependencies.deviceInventory.update(deviceMatch[1], patch, expectedRevision),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyMatch = pathname.match(DEVICE_POLICY_PATH);
|
||||||
|
if (policyMatch) {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
await dependencies.deviceInventory.setPolicy(
|
||||||
|
policyMatch[1],
|
||||||
|
body.mode,
|
||||||
|
body.expectedRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import type { FailoverService } from '../../features/failover/failoverService.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
|
||||||
|
interface FailoverRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
failover: Pick<FailoverService, 'save' | 'pause' | 'manualSwitch' | 'checkNow'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>, options?: { expectedRevision?: unknown }): Promise<T>;
|
||||||
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFailoverRoute(dependencies: FailoverRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
if (!pathname.startsWith('/api/failover')) return false;
|
||||||
|
if (dependencies.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
if (pathname === '/api/failover' && req.method === 'PUT') {
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'failover-save',
|
||||||
|
() => dependencies.failover.save(body.policy),
|
||||||
|
{ expectedRevision: body.expectedRevision },
|
||||||
|
);
|
||||||
|
} else if (pathname === '/api/failover/pause' && req.method === 'POST') {
|
||||||
|
if (typeof body.paused !== 'boolean') throw new HarborError('REQUEST_INVALID');
|
||||||
|
await dependencies.withOperation(
|
||||||
|
body.paused ? 'failover-pause' : 'failover-resume',
|
||||||
|
() => dependencies.failover.pause(body.paused as boolean),
|
||||||
|
{ expectedRevision: body.expectedRevision },
|
||||||
|
);
|
||||||
|
} else if (pathname === '/api/failover/switch' && req.method === 'POST') {
|
||||||
|
if (body.role !== 'primary' && body.role !== 'reserve') throw new HarborError('REQUEST_INVALID');
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'failover-switch',
|
||||||
|
() => dependencies.failover.manualSwitch(body.role as 'primary' | 'reserve'),
|
||||||
|
{ expectedRevision: body.expectedRevision },
|
||||||
|
);
|
||||||
|
} else if (pathname === '/api/failover/check' && req.method === 'POST') {
|
||||||
|
await dependencies.failover.checkNow();
|
||||||
|
} else {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { GatewayAutoService } from '../../features/routing/index.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface GatewayAutoRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
gatewayAuto: Pick<GatewayAutoService, 'setEnabled'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
readStatePayload(): Promise<Record<string, unknown> & { gatewayAuto?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoRoute(dependencies: GatewayAutoRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/gateway-auto') return false;
|
||||||
|
if (dependencies.appMode !== 'client') throw new HarborError('REQUEST_INVALID');
|
||||||
|
const { enabled } = await dependencies.readBody(req);
|
||||||
|
if (typeof enabled !== 'boolean') throw new HarborError('REQUEST_INVALID');
|
||||||
|
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'gateway-auto',
|
||||||
|
() => dependencies.gatewayAuto.setEnabled(enabled),
|
||||||
|
);
|
||||||
|
const state = await dependencies.readStatePayload();
|
||||||
|
sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildGatewayPresence } from '../../gatewayPresence.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface GatewayPresenceState {
|
||||||
|
subscriptionUrl?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayPresenceRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
readState(): GatewayPresenceState;
|
||||||
|
getHwid(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayPresenceRoute(dependencies: GatewayPresenceRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (req.method !== 'GET' || requestUrl.pathname !== '/api/gateway-presence') return false;
|
||||||
|
|
||||||
|
const state = dependencies.readState();
|
||||||
|
const gatewayId = dependencies.getHwid();
|
||||||
|
sendJson(res, 200, buildGatewayPresence({
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
subscriptionUrl: state.subscriptionUrl,
|
||||||
|
gatewayId,
|
||||||
|
nonce: requestUrl.searchParams.get('nonce'),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertLiveTrafficSnapshot,
|
||||||
|
type LiveTrafficSnapshot,
|
||||||
|
} from '../../../shared/liveTraffic.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface LiveTrafficReader {
|
||||||
|
snapshot(): unknown | Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceInventoryReader {
|
||||||
|
snapshot(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichLiveTrafficDeviceLabels(
|
||||||
|
snapshot: LiveTrafficSnapshot,
|
||||||
|
inventory: unknown,
|
||||||
|
): LiveTrafficSnapshot {
|
||||||
|
const devices = Array.isArray(record(inventory).devices)
|
||||||
|
? (record(inventory).devices as unknown[]).map(record)
|
||||||
|
: [];
|
||||||
|
const labels = new Map(devices.flatMap((device) => {
|
||||||
|
const id = String(device.id || '');
|
||||||
|
if (!/^dev_[a-f0-9]{16}$/.test(id)) return [];
|
||||||
|
const label = [device.alias, device.hostname, device.ip]
|
||||||
|
.find((value) => typeof value === 'string' && value.trim());
|
||||||
|
return label ? [[id, String(label).trim()] as const] : [];
|
||||||
|
}));
|
||||||
|
if (!labels.size) return snapshot;
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
connections: snapshot.connections.map((connection) => {
|
||||||
|
const label = connection.origin.kind === 'device' && connection.origin.id
|
||||||
|
? labels.get(connection.origin.id)
|
||||||
|
: null;
|
||||||
|
return label ? {
|
||||||
|
...connection,
|
||||||
|
origin: { ...connection.origin, label },
|
||||||
|
} : connection;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveTrafficRoute({
|
||||||
|
traffic,
|
||||||
|
deviceInventory = null,
|
||||||
|
}: {
|
||||||
|
traffic: LiveTrafficReader | null;
|
||||||
|
deviceInventory?: DeviceInventoryReader | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
if (pathname !== '/api/traffic/live') return false;
|
||||||
|
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
||||||
|
const enriched = deviceInventory
|
||||||
|
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
|
||||||
|
: snapshot;
|
||||||
|
sendJson(res, 200, enriched);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendPrometheusMetrics } from '../../prometheusMetrics.js';
|
||||||
|
|
||||||
|
interface MetricsSnapshotPort {
|
||||||
|
metricsSnapshot(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrometheusMetricsRouteDependencies {
|
||||||
|
deviceInventory: MetricsSnapshotPort | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrometheusMetricsRoute(dependencies: PrometheusMetricsRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
if (pathname !== '/metrics') return false;
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendPrometheusMetrics(res, dependencies.deviceInventory.metricsSnapshot());
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { RouteRulesService } from '../../features/routing/index.js';
|
||||||
|
|
||||||
|
interface RouteRulesRouteDependencies {
|
||||||
|
routeRules: RouteRulesService;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'PUT' || !['/api/route-rules', '/api/route-rules/v2'].includes(req.url || '')) return false;
|
||||||
|
const { rules, expectedRulesRevision, rulesContractVersion } = await dependencies.readBody(req);
|
||||||
|
await dependencies.routeRules.update(rules, expectedRulesRevision, rulesContractVersion);
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectionService } from '../../features/connection/index.js';
|
||||||
|
|
||||||
|
interface ServerApplyRouteDependencies {
|
||||||
|
connection: Pick<ConnectionService, 'apply'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(
|
||||||
|
kind: string,
|
||||||
|
operation: (operationRevision: number) => Promise<T>,
|
||||||
|
options?: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown },
|
||||||
|
): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra: { profileId: string; serverId: string; selectedTag: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
|
||||||
|
const {
|
||||||
|
profileId = '',
|
||||||
|
serverId = '',
|
||||||
|
selectedTag = '',
|
||||||
|
expectedRevision,
|
||||||
|
} = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'apply-server',
|
||||||
|
(operationRevision) => dependencies.connection.apply(
|
||||||
|
profileId,
|
||||||
|
serverId,
|
||||||
|
selectedTag,
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
{ expectedRevision, profileId, serverId },
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ServerHealthService } from '../../features/servers/index.js';
|
||||||
|
|
||||||
|
interface ServerHealthRouteDependencies {
|
||||||
|
serverHealth: ServerHealthService;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse, extra: {
|
||||||
|
profileId: string;
|
||||||
|
results: Array<Record<string, unknown>>;
|
||||||
|
}): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/servers\/ping$/);
|
||||||
|
if (req.method !== 'POST' || (!profileMatch && pathname !== '/api/servers/ping-all')) return false;
|
||||||
|
const { serverIds = [] } = await dependencies.readBody(req);
|
||||||
|
const profileId = profileMatch ? decodeURIComponent(profileMatch[1]) : '';
|
||||||
|
const results = await dependencies.serverHealth.check(profileId, serverIds);
|
||||||
|
await dependencies.sendState(res, { profileId, results });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildSharedProxyInfo } from '../../sharedProxy.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface SharedProxyRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
proxyPort: unknown;
|
||||||
|
sharedProxyHost: unknown;
|
||||||
|
refreshRuntime(): Promise<{ running?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSharedProxyRoute(dependencies: SharedProxyRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/shared-proxy') return false;
|
||||||
|
|
||||||
|
const runtime = await dependencies.refreshRuntime();
|
||||||
|
sendJson(res, 200, buildSharedProxyInfo({
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
proxyPort: dependencies.proxyPort,
|
||||||
|
running: runtime.running,
|
||||||
|
hostHeader: req.headers.host,
|
||||||
|
sharedProxyHost: dependencies.sharedProxyHost,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { normalizeStoredState, type GatewayAutoState, type StateSnapshot } from '../../../shared/contracts/state.js';
|
||||||
|
import type { StateReadResult, StateService } from '../../features/state/stateService.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
export interface LegacyStatePayload extends StateSnapshot, Record<string, unknown> {
|
||||||
|
port: number;
|
||||||
|
proxyPort: number;
|
||||||
|
configExists: boolean;
|
||||||
|
singboxRunning: boolean;
|
||||||
|
singboxStartedAt: string | null;
|
||||||
|
subscriptionHost: string;
|
||||||
|
hasSubscription: boolean;
|
||||||
|
selectedTag: string;
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
gatewayAuto: {
|
||||||
|
mode: string;
|
||||||
|
enabled: boolean;
|
||||||
|
available: boolean;
|
||||||
|
address: string;
|
||||||
|
uiOrigin: string;
|
||||||
|
interface: string;
|
||||||
|
failures: number;
|
||||||
|
lastError: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateRouteDependencies {
|
||||||
|
stateService: StateService;
|
||||||
|
port: number;
|
||||||
|
proxyPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStateV0Compatibility(
|
||||||
|
{ snapshot, storedState, gatewayAuto, configExists }: StateReadResult,
|
||||||
|
{ port, proxyPort }: Pick<StateRouteDependencies, 'port' | 'proxyPort'>,
|
||||||
|
): LegacyStatePayload {
|
||||||
|
const stored = normalizeStoredState(storedState);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
port,
|
||||||
|
proxyPort,
|
||||||
|
configExists,
|
||||||
|
singboxRunning: snapshot.connection.process === 'running',
|
||||||
|
singboxStartedAt: snapshot.connection.startedAt,
|
||||||
|
subscriptionHost: snapshot.subscription.host,
|
||||||
|
hasSubscription: snapshot.subscription.status !== 'missing',
|
||||||
|
selectedTag: stored.selectedTag,
|
||||||
|
userInfo: snapshot.subscription.userInfo,
|
||||||
|
fetchedAt: snapshot.subscription.fetchedAt,
|
||||||
|
gatewayAuto: snapshot.mode === 'client'
|
||||||
|
? legacyGatewayAuto(gatewayAuto, stored.gatewayAutoEnabled !== false)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyGatewayAuto(gatewayAuto: GatewayAutoState, enabled: boolean) {
|
||||||
|
return {
|
||||||
|
mode: gatewayAuto?.mode || 'local-vpn',
|
||||||
|
enabled,
|
||||||
|
available: Boolean(gatewayAuto?.gatewayId),
|
||||||
|
address: gatewayAuto?.gateway?.gateway || '',
|
||||||
|
uiOrigin: gatewayAuto?.uiOrigin || '',
|
||||||
|
interface: gatewayAuto?.gateway?.interface || '',
|
||||||
|
failures: Number(gatewayAuto?.failures) || 0,
|
||||||
|
lastError: gatewayAuto?.lastError || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateRoute(dependencies: StateRouteDependencies) {
|
||||||
|
const readPayload = async () => withStateV0Compatibility(
|
||||||
|
await dependencies.stateService.read(),
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
readPayload,
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/state') return false;
|
||||||
|
sendJson(res, 200, await readPayload());
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async send(res: ServerResponse, extra: Record<string, unknown> = {}) {
|
||||||
|
sendJson(res, 200, { success: true, ...extra, state: await readPayload() });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateRoute = ReturnType<typeof createStateRoute>;
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectionService } from '../../features/connection/index.js';
|
||||||
|
import type { SubscriptionService } from '../../features/subscription/index.js';
|
||||||
|
|
||||||
|
interface OperationOptions {
|
||||||
|
expectedRevision?: unknown;
|
||||||
|
profileId?: unknown;
|
||||||
|
serverId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionMutationRouteDependencies {
|
||||||
|
subscriptionService: Pick<
|
||||||
|
SubscriptionService,
|
||||||
|
| 'preflightAddProfile'
|
||||||
|
| 'preflightRenameProfile'
|
||||||
|
| 'addProfile'
|
||||||
|
| 'renameProfile'
|
||||||
|
| 'selectProfileServer'
|
||||||
|
| 'refreshProfile'
|
||||||
|
| 'deleteProfile'
|
||||||
|
| 'importSubscription'
|
||||||
|
| 'refreshSavedSubscription'
|
||||||
|
| 'resetSavedSubscription'
|
||||||
|
>;
|
||||||
|
connection: Pick<ConnectionService, 'activate'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(
|
||||||
|
kind: string,
|
||||||
|
operation: (operationRevision: number) => Promise<T>,
|
||||||
|
options?: OperationOptions,
|
||||||
|
): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)$/);
|
||||||
|
const serverMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/server$/);
|
||||||
|
const activateMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/activate$/);
|
||||||
|
const refreshMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/refresh$/);
|
||||||
|
|
||||||
|
if (req.method === 'POST' && pathname === '/api/profiles') {
|
||||||
|
const { label = '', url = '', expectedRevision } = await dependencies.readBody(req);
|
||||||
|
dependencies.subscriptionService.preflightAddProfile(label, expectedRevision);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'profile-add',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.addProfile(
|
||||||
|
label,
|
||||||
|
url,
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
{ expectedRevision },
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'PATCH' && profileMatch) {
|
||||||
|
const profileId = decodeURIComponent(profileMatch[1]);
|
||||||
|
const { label = '', expectedRevision } = await dependencies.readBody(req);
|
||||||
|
dependencies.subscriptionService.preflightRenameProfile(profileId, label, expectedRevision);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'profile-rename',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.renameProfile(
|
||||||
|
profileId,
|
||||||
|
label,
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
{ expectedRevision, profileId },
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'PUT' && serverMatch) {
|
||||||
|
const profileId = decodeURIComponent(serverMatch[1]);
|
||||||
|
const { serverId = '', expectedRevision } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'profile-select-server',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.selectProfileServer(
|
||||||
|
profileId,
|
||||||
|
serverId,
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
{ expectedRevision, profileId, serverId },
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && activateMatch) {
|
||||||
|
const profileId = decodeURIComponent(activateMatch[1]);
|
||||||
|
const { expectedRevision } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'profile-activate',
|
||||||
|
(operationRevision) => dependencies.connection.activate(profileId, operationRevision),
|
||||||
|
{ expectedRevision, profileId },
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && refreshMatch) {
|
||||||
|
const profileId = decodeURIComponent(refreshMatch[1]);
|
||||||
|
const { expectedRevision } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'profile-refresh',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.refreshProfile(
|
||||||
|
profileId,
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
{ expectedRevision, profileId },
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && profileMatch) {
|
||||||
|
const profileId = decodeURIComponent(profileMatch[1]);
|
||||||
|
const { mode = 'delete', expectedRevision } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'profile-delete',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.deleteProfile(
|
||||||
|
profileId,
|
||||||
|
mode,
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
{ expectedRevision, profileId },
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-release compatibility for the old single-subscription client.
|
||||||
|
if (req.method === 'POST' && pathname === '/api/subscription/fetch') {
|
||||||
|
const { url = '' } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'subscription-import',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.importSubscription(
|
||||||
|
String(url).trim(),
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && pathname === '/api/subscription/refresh') {
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'subscription-refresh',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.refreshSavedSubscription(
|
||||||
|
operationRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && pathname === '/api/subscription') {
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'subscription-forget',
|
||||||
|
(operationRevision) => dependencies.subscriptionService.resetSavedSubscription({
|
||||||
|
expectedRevision: operationRevision,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ValidateSubscription } from '../../features/subscription/index.js';
|
||||||
|
|
||||||
|
interface SubscriptionValidationRouteDependencies {
|
||||||
|
validateSubscription: ValidateSubscription;
|
||||||
|
readBody: (req: IncomingMessage) => Promise<Record<string, unknown>>;
|
||||||
|
sendState: (res: ServerResponse, extra: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionValidationRoute(dependencies: SubscriptionValidationRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/subscription/validate') return false;
|
||||||
|
const { url = '' } = await dependencies.readBody(req);
|
||||||
|
await dependencies.sendState(res, await dependencies.validateSubscription(url));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildGatewayVersionInfo } from '../../version.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface DataplaneVersionState {
|
||||||
|
gatewayBackendVersion?: unknown;
|
||||||
|
singBoxVersion?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionRouteDependencies {
|
||||||
|
versionInfo: Record<string, unknown>;
|
||||||
|
refreshDataplaneRuntime: (() => Promise<DataplaneVersionState>) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVersionRoute(dependencies: VersionRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/version') return false;
|
||||||
|
|
||||||
|
const payload = dependencies.refreshDataplaneRuntime
|
||||||
|
? buildGatewayVersionInfo(
|
||||||
|
dependencies.versionInfo,
|
||||||
|
await dependencies.refreshDataplaneRuntime(),
|
||||||
|
)
|
||||||
|
: dependencies.versionInfo;
|
||||||
|
sendJson(res, 200, payload);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import http from 'node:http';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
|
||||||
import { settings } from './config.js';
|
|
||||||
import { fetchSubscription } from './subscription.js';
|
|
||||||
import { buildGatewayConfig, writeSingboxConfig } from './singbox.js';
|
|
||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
|
||||||
|
|
||||||
let singboxProcess = null;
|
|
||||||
let singboxStartedAt = null;
|
|
||||||
|
|
||||||
function readJson(filePath, fallback) {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(filePath)) return fallback;
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJson(filePath, value) {
|
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(res, statusCode, payload) {
|
|
||||||
const body = JSON.stringify(payload, null, 2);
|
|
||||||
res.writeHead(statusCode, {
|
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
'content-length': Buffer.byteLength(body),
|
|
||||||
});
|
|
||||||
res.end(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBody(req) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks = [];
|
|
||||||
req.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
req.on('end', () => {
|
|
||||||
if (!chunks.length) return resolve({});
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
||||||
} catch {
|
|
||||||
reject(new Error('Invalid JSON body'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkSingboxConfig() {
|
|
||||||
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSingbox() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!singboxProcess) return resolve();
|
|
||||||
|
|
||||||
const current = singboxProcess;
|
|
||||||
singboxProcess = null;
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
current.kill('SIGKILL');
|
|
||||||
resolve();
|
|
||||||
}, 4000);
|
|
||||||
|
|
||||||
current.once('exit', () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
current.kill('SIGTERM');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startSingbox() {
|
|
||||||
if (!fs.existsSync(settings.configPath)) return false;
|
|
||||||
|
|
||||||
checkSingboxConfig();
|
|
||||||
await stopSingbox();
|
|
||||||
|
|
||||||
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
singboxStartedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
singboxProcess.once('exit', (code, signal) => {
|
|
||||||
console.log(`[control] sing-box exited: code=${code} signal=${signal}`);
|
|
||||||
if (singboxProcess?.exitCode === code) singboxProcess = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicState() {
|
|
||||||
const state = readJson(settings.statePath, {});
|
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
|
||||||
return {
|
|
||||||
mode: 'gateway',
|
|
||||||
port: settings.port,
|
|
||||||
proxyPort: settings.proxyPort,
|
|
||||||
tproxyPort: settings.tproxyPort,
|
|
||||||
routingRuDirect: settings.routingRuDirect,
|
|
||||||
configExists: fs.existsSync(settings.configPath),
|
|
||||||
singboxRunning: Boolean(singboxProcess),
|
|
||||||
singboxStartedAt,
|
|
||||||
customRules,
|
|
||||||
...state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeList(value) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
return String(value || '')
|
|
||||||
.split(/\r?\n|,/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCustomRules(input) {
|
|
||||||
const rules = Array.isArray(input) ? input : [];
|
|
||||||
return rules.map((rule, index) => ({
|
|
||||||
id: String(rule.id || `rule-${Date.now()}-${index}`),
|
|
||||||
name: String(rule.name || `Rule ${index + 1}`).trim(),
|
|
||||||
enabled: rule.enabled !== false,
|
|
||||||
outbound: ['direct', 'vpn', 'block'].includes(rule.outbound) ? rule.outbound : 'direct',
|
|
||||||
domains: normalizeList(rule.domains),
|
|
||||||
domainSuffixes: normalizeList(rule.domainSuffixes),
|
|
||||||
domainKeywords: normalizeList(rule.domainKeywords),
|
|
||||||
ipCidrs: normalizeList(rule.ipCidrs),
|
|
||||||
ports: normalizeList(rule.ports),
|
|
||||||
networks: normalizeList(rule.networks).filter((network) => ['tcp', 'udp'].includes(network)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleApi(req, res) {
|
|
||||||
if (req.method === 'GET' && req.url === '/api/state') {
|
|
||||||
return sendJson(res, 200, publicState());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && req.url === '/api/rules') {
|
|
||||||
return sendJson(res, 200, {
|
|
||||||
success: true,
|
|
||||||
rules: readJson(settings.customRulesPath, []),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'PUT' && req.url === '/api/rules') {
|
|
||||||
const body = await readBody(req);
|
|
||||||
const rules = normalizeCustomRules(body.rules);
|
|
||||||
writeJson(settings.customRulesPath, rules);
|
|
||||||
return sendJson(res, 200, { success: true, rules });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
|
||||||
const body = await readBody(req);
|
|
||||||
const url = String(body.url || '').trim();
|
|
||||||
if (!url) return sendJson(res, 400, { success: false, error: 'Subscription URL is required' });
|
|
||||||
|
|
||||||
const parsed = await fetchSubscription(url);
|
|
||||||
writeJson(settings.subscriptionCachePath, { url, ...parsed });
|
|
||||||
|
|
||||||
const prevState = readJson(settings.statePath, {});
|
|
||||||
writeJson(settings.statePath, {
|
|
||||||
...prevState,
|
|
||||||
subscriptionUrl: url,
|
|
||||||
servers: parsed.servers,
|
|
||||||
userInfo: parsed.userInfo,
|
|
||||||
fetchedAt: parsed.fetchedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return sendJson(res, 200, { success: true, ...parsed });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
|
||||||
const body = await readBody(req);
|
|
||||||
const selectedTag = String(body.selectedTag || '').trim();
|
|
||||||
if (!selectedTag) return sendJson(res, 400, { success: false, error: 'selectedTag is required' });
|
|
||||||
|
|
||||||
const cached = readJson(settings.subscriptionCachePath, null);
|
|
||||||
if (!cached?.config) {
|
|
||||||
return sendJson(res, 400, { success: false, error: 'Fetch subscription before applying a server' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
|
||||||
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag);
|
|
||||||
writeSingboxConfig(generated);
|
|
||||||
await startSingbox();
|
|
||||||
|
|
||||||
const prevState = readJson(settings.statePath, {});
|
|
||||||
writeJson(settings.statePath, {
|
|
||||||
...prevState,
|
|
||||||
selectedTag,
|
|
||||||
appliedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return sendJson(res, 200, {
|
|
||||||
success: true,
|
|
||||||
selectedTag,
|
|
||||||
configPath: settings.configPath,
|
|
||||||
singboxRunning: Boolean(singboxProcess),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return sendJson(res, 404, { success: false, error: 'Not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mime = {
|
|
||||||
'.html': 'text/html; charset=utf-8',
|
|
||||||
'.js': 'text/javascript; charset=utf-8',
|
|
||||||
'.css': 'text/css; charset=utf-8',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.json': 'application/json; charset=utf-8',
|
|
||||||
};
|
|
||||||
|
|
||||||
function serveStatic(req, res) {
|
|
||||||
const requestPath = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
|
||||||
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
|
|
||||||
const filePath = path.resolve(settings.distDir, `.${cleanPath}`);
|
|
||||||
const distRoot = path.resolve(settings.distDir);
|
|
||||||
|
|
||||||
if (!filePath.startsWith(distRoot)) {
|
|
||||||
res.writeHead(403);
|
|
||||||
return res.end('Forbidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
|
||||||
? filePath
|
|
||||||
: path.join(settings.distDir, 'index.html');
|
|
||||||
|
|
||||||
const ext = path.extname(finalPath);
|
|
||||||
res.writeHead(200, { 'content-type': mime[ext] || 'application/octet-stream' });
|
|
||||||
fs.createReadStream(finalPath).pipe(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
if (req.url?.startsWith('/api/')) {
|
|
||||||
return await handleApi(req, res);
|
|
||||||
}
|
|
||||||
return serveStatic(req, res);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[control] request failed', error);
|
|
||||||
return sendJson(res, 500, { success: false, error: error.message || String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
|
||||||
await stopSingbox();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
await stopSingbox();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await startSingbox().catch((error) => {
|
|
||||||
console.warn(`[control] sing-box was not started: ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(settings.port, '0.0.0.0', () => {
|
|
||||||
console.log(`[control] gateway UI listening on :${settings.port}`);
|
|
||||||
});
|
|
||||||
+1125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
process.env.DIST_DIR ||= path.resolve('dist');
|
||||||
|
|
||||||
|
if (process.env.APP_COMPONENT === 'dataplane') {
|
||||||
|
await import('./dataplane.js');
|
||||||
|
} else {
|
||||||
|
await import('./index.js');
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом.
|
||||||
|
// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент).
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 3000;
|
||||||
|
|
||||||
|
export interface PingResult {
|
||||||
|
ok: boolean;
|
||||||
|
latency: number | null;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tcpPing(host: string, port: number, timeout = DEFAULT_TIMEOUT): Promise<PingResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
return new Promise<PingResult>((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const finish = (result: PingResult) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
socket.once("connect", () =>
|
||||||
|
finish({ ok: true, latency: Date.now() - start }),
|
||||||
|
);
|
||||||
|
socket.once("timeout", () =>
|
||||||
|
finish({ ok: false, latency: null, error: "timeout" }),
|
||||||
|
);
|
||||||
|
socket.once("error", (err: NodeJS.ErrnoException) =>
|
||||||
|
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.connect(port, host);
|
||||||
|
} catch (err) {
|
||||||
|
finish({ ok: false, latency: null, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveHost(host: string): Promise<string | null> {
|
||||||
|
if (net.isIP(host)) return host;
|
||||||
|
try {
|
||||||
|
const result = await dns.lookup(host);
|
||||||
|
return result.address;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
const COUNTER_PATTERN = /^\d+$/;
|
||||||
|
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
|
||||||
|
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
|
||||||
|
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
|
||||||
|
const COLLECTOR_STATES = new Set([
|
||||||
|
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const labelValue = (value: unknown) => String(value ?? '')
|
||||||
|
.replaceAll('\\', '\\\\')
|
||||||
|
.replaceAll('\n', '\\n')
|
||||||
|
.replaceAll('"', '\\"');
|
||||||
|
|
||||||
|
const labels = (values: Record<string, unknown>) => Object.entries(values)
|
||||||
|
.map(([key, value]) => `${key}="${labelValue(value)}"`)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function counter(value: unknown) {
|
||||||
|
const decimal = String(value ?? '');
|
||||||
|
if (!COUNTER_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus counter: ${decimal}`);
|
||||||
|
return decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedGauge(value: unknown) {
|
||||||
|
const decimal = String(value ?? '');
|
||||||
|
if (!SIGNED_DECIMAL_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus gauge: ${decimal}`);
|
||||||
|
return decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: unknown, { signed = false } = {}) {
|
||||||
|
if (!Number.isSafeInteger(value) || (!signed && Number(value) < 0)) {
|
||||||
|
throw new Error(`Invalid Prometheus gauge: ${String(value)}`);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value: unknown) {
|
||||||
|
const milliseconds = Date.parse(String(value ?? ''));
|
||||||
|
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(
|
||||||
|
lines: string[],
|
||||||
|
name: string,
|
||||||
|
metricLabels: Record<string, unknown>,
|
||||||
|
value: unknown,
|
||||||
|
) {
|
||||||
|
lines.push(`${name}{${labels(metricLabels)}} ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPrometheusMetrics(value: unknown) {
|
||||||
|
const snapshot = record(value);
|
||||||
|
const traffic = record(snapshot.traffic);
|
||||||
|
const lines = [
|
||||||
|
'# HELP harbor_traffic_bytes_total Total traffic accounted by Harbor.',
|
||||||
|
'# TYPE harbor_traffic_bytes_total counter',
|
||||||
|
];
|
||||||
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(traffic.gatewayBytes));
|
||||||
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(traffic.proxyBytes));
|
||||||
|
|
||||||
|
const globalFreshness = [
|
||||||
|
['gateway', traffic.gatewayObservedAt],
|
||||||
|
['proxy', traffic.proxyObservedAt],
|
||||||
|
].map(([source, observedAt]) => [source, timestamp(observedAt)] as const).filter(([, observedAt]) => observedAt);
|
||||||
|
if (globalFreshness.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful Harbor traffic observation.',
|
||||||
|
'# TYPE harbor_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
for (const [source, observedAt] of globalFreshness) {
|
||||||
|
metric(lines, 'harbor_traffic_last_observed_timestamp_seconds', { source }, observedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = Array.isArray(snapshot.devices) ? snapshot.devices.map(record) : [];
|
||||||
|
if (devices.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_info Current Harbor device identity metadata.',
|
||||||
|
'# TYPE harbor_device_info gauge',
|
||||||
|
);
|
||||||
|
for (const device of devices) {
|
||||||
|
metric(lines, 'harbor_device_info', {
|
||||||
|
device_id: device.id,
|
||||||
|
name: device.alias || device.hostname || device.ip || device.id,
|
||||||
|
ip: device.ip || '',
|
||||||
|
}, '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTraffic = devices.flatMap((device) => [
|
||||||
|
timestamp(device.trafficObservedAt)
|
||||||
|
? { device, source: 'gateway', observedAt: timestamp(device.trafficObservedAt), uploadBytes: device.uploadBytes, downloadBytes: device.downloadBytes }
|
||||||
|
: null,
|
||||||
|
timestamp(device.proxyTrafficObservedAt)
|
||||||
|
? { device, source: 'proxy', observedAt: timestamp(device.proxyTrafficObservedAt), uploadBytes: device.proxyUploadBytes, downloadBytes: device.proxyDownloadBytes }
|
||||||
|
: null,
|
||||||
|
].filter((entry): entry is NonNullable<typeof entry> => entry !== null));
|
||||||
|
if (deviceTraffic.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_traffic_bytes_total Total traffic accounted by Harbor for a device.',
|
||||||
|
'# TYPE harbor_device_traffic_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const { device, source, uploadBytes, downloadBytes } of deviceTraffic) {
|
||||||
|
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
direction: 'download',
|
||||||
|
}, counter(downloadBytes));
|
||||||
|
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
direction: 'upload',
|
||||||
|
}, counter(uploadBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful device traffic observation.',
|
||||||
|
'# TYPE harbor_device_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
for (const { device, source, observedAt } of deviceTraffic) {
|
||||||
|
metric(lines, 'harbor_device_traffic_last_observed_timestamp_seconds', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
}, observedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const directTraffic = record(snapshot.directTraffic);
|
||||||
|
const directSeries = Array.isArray(directTraffic.series) ? directTraffic.series.map(record) : [];
|
||||||
|
const directObservedAt = timestamp(directTraffic.observedAt);
|
||||||
|
if (directObservedAt) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_direct_ipv4_packet_bytes_total IPv4 L3 packet bytes forwarded directly instead of entering sing-box; includes IP headers and retransmissions.',
|
||||||
|
'# TYPE harbor_direct_ipv4_packet_bytes_total counter',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'download' }, counter(directTraffic.downloadBytes));
|
||||||
|
metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'upload' }, counter(directTraffic.uploadBytes));
|
||||||
|
}
|
||||||
|
if (directSeries.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_direct_ipv4_packet_bytes_total Attributed IPv4 L3 packet bytes forwarded directly instead of entering sing-box.',
|
||||||
|
'# TYPE harbor_device_direct_ipv4_packet_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const series of directSeries) {
|
||||||
|
for (const [direction, amount] of [
|
||||||
|
['download', series.downloadBytes],
|
||||||
|
['upload', series.uploadBytes],
|
||||||
|
]) {
|
||||||
|
metric(lines, 'harbor_device_direct_ipv4_packet_bytes_total', {
|
||||||
|
device_id: series.deviceId,
|
||||||
|
direction,
|
||||||
|
}, counter(amount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (directObservedAt) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_direct_ipv4_packet_last_observed_timestamp_seconds Unix timestamp of the last successful direct IPv4 packet observation.',
|
||||||
|
'# TYPE harbor_direct_ipv4_packet_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
lines.push(`harbor_direct_ipv4_packet_last_observed_timestamp_seconds ${directObservedAt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainTraffic = record(snapshot.domainTraffic);
|
||||||
|
const collectorSource = record(domainTraffic.source);
|
||||||
|
const hasCollectorDiagnostics = ['mode', 'writer', 'native', 'shadow']
|
||||||
|
.some((field) => Object.hasOwn(collectorSource, field));
|
||||||
|
if (hasCollectorDiagnostics) {
|
||||||
|
const source = collectorSource;
|
||||||
|
const mode = String(source.mode || '');
|
||||||
|
const writer = String(source.writer || '');
|
||||||
|
if (!COLLECTOR_MODES.has(mode) || !COLLECTOR_WRITERS.has(writer)) {
|
||||||
|
throw new Error('Invalid traffic collector labels');
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_collector_info Current Gateway traffic collector mode and canonical writer.',
|
||||||
|
'# TYPE harbor_traffic_collector_info gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_collector_info', { mode, writer }, '1');
|
||||||
|
|
||||||
|
if (source.native !== null) {
|
||||||
|
const native = record(source.native);
|
||||||
|
const state = String(native.state || '');
|
||||||
|
if (!COLLECTOR_STATES.has(state)) throw new Error('Invalid traffic collector state');
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_collector_state Current native traffic collector state.',
|
||||||
|
'# TYPE harbor_traffic_collector_state gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_collector_state', { state }, '1');
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_collector_unattributed_bytes Native traffic bytes not attributed to a lifecycle connection.',
|
||||||
|
'# TYPE harbor_traffic_collector_unattributed_bytes gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'download' }, counter(native.unattributedDownloadBytes));
|
||||||
|
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'upload' }, counter(native.unattributedUploadBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.shadow !== null) {
|
||||||
|
const shadow = record(source.shadow);
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_shadow_active_difference Native active connections minus snapshot active connections.',
|
||||||
|
'# TYPE harbor_traffic_shadow_active_difference gauge',
|
||||||
|
`harbor_traffic_shadow_active_difference ${safeInteger(shadow.activeDifference, { signed: true })}`,
|
||||||
|
'# HELP harbor_traffic_shadow_difference_bytes Native traffic bytes minus snapshot traffic bytes.',
|
||||||
|
'# TYPE harbor_traffic_shadow_difference_bytes gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'download' }, signedGauge(shadow.downloadDifferenceBytes));
|
||||||
|
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'upload' }, signedGauge(shadow.uploadDifferenceBytes));
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_shadow_route_mismatches Route aggregate keys that differ between native and snapshot projections.',
|
||||||
|
'# TYPE harbor_traffic_shadow_route_mismatches gauge',
|
||||||
|
`harbor_traffic_shadow_route_mismatches ${safeInteger(shadow.routeMismatches)}`,
|
||||||
|
'# HELP harbor_traffic_shadow_device_mismatches Device aggregate keys that differ between native and snapshot projections.',
|
||||||
|
'# TYPE harbor_traffic_shadow_device_mismatches gauge',
|
||||||
|
`harbor_traffic_shadow_device_mismatches ${safeInteger(shadow.deviceMismatches)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
|
||||||
|
if (trackedSeries.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the configured sing-box traffic collector; excludes IP and tunnel overhead.',
|
||||||
|
'# TYPE harbor_singbox_tracked_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const series of trackedSeries) {
|
||||||
|
const source = String(series.source || '');
|
||||||
|
const outbound = String(series.outbound || '');
|
||||||
|
if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) {
|
||||||
|
throw new Error('Invalid sing-box outbound labels');
|
||||||
|
}
|
||||||
|
for (const [direction, amount] of [
|
||||||
|
['download', series.downloadBytes],
|
||||||
|
['upload', series.uploadBytes],
|
||||||
|
]) {
|
||||||
|
metric(lines, 'harbor_singbox_tracked_bytes_total', { source, outbound, direction }, counter(amount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const routeSeries = Array.isArray(domainTraffic.routes) ? domainTraffic.routes.map(record) : [];
|
||||||
|
if (routeSeries.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_singbox_tracked_bytes_total Attributed bytes observed by the sing-box TCP/UDP tracker for a selected outbound.',
|
||||||
|
'# TYPE harbor_device_singbox_tracked_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const series of routeSeries) {
|
||||||
|
const source = String(series.source || '');
|
||||||
|
const outbound = String(series.outbound || '');
|
||||||
|
if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) {
|
||||||
|
throw new Error('Invalid sing-box outbound labels');
|
||||||
|
}
|
||||||
|
for (const [direction, amount] of [
|
||||||
|
['download', series.downloadBytes],
|
||||||
|
['upload', series.uploadBytes],
|
||||||
|
]) {
|
||||||
|
metric(lines, 'harbor_device_singbox_tracked_bytes_total', {
|
||||||
|
device_id: series.deviceId,
|
||||||
|
source,
|
||||||
|
outbound,
|
||||||
|
direction,
|
||||||
|
}, counter(amount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
|
||||||
|
if (domainSeries.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_domain_traffic_bytes_total Traffic observed by sing-box for a device and domain.',
|
||||||
|
'# TYPE harbor_device_domain_traffic_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const series of domainSeries) {
|
||||||
|
for (const [direction, value] of [
|
||||||
|
['download', series.downloadBytes],
|
||||||
|
['upload', series.uploadBytes],
|
||||||
|
]) {
|
||||||
|
metric(lines, 'harbor_device_domain_traffic_bytes_total', {
|
||||||
|
device_id: series.deviceId,
|
||||||
|
domain: series.domain,
|
||||||
|
service: series.service,
|
||||||
|
source: series.source,
|
||||||
|
direction,
|
||||||
|
}, counter(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const domainObservedAt = timestamp(domainTraffic.observedAt);
|
||||||
|
if (domainObservedAt) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_domain_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box connection observation.',
|
||||||
|
'# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`);
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_singbox_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box traffic observation.',
|
||||||
|
'# TYPE harbor_singbox_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
`harbor_singbox_traffic_last_observed_timestamp_seconds ${domainObservedAt}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (domainTraffic.overflowConnections != null) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_domain_traffic_overflow_connections_total Connections aggregated after the domain series limit was reached.',
|
||||||
|
'# TYPE harbor_domain_traffic_overflow_connections_total counter',
|
||||||
|
);
|
||||||
|
lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`);
|
||||||
|
}
|
||||||
|
const attributionEvents = record(domainTraffic.attributionEvents);
|
||||||
|
if (domainTraffic.attributionEvents) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.',
|
||||||
|
'# TYPE harbor_domain_traffic_attribution_events_total counter',
|
||||||
|
);
|
||||||
|
for (const outcome of ['unresolved_host', 'unknown_device', 'unsupported_source']) {
|
||||||
|
metric(
|
||||||
|
lines,
|
||||||
|
'harbor_domain_traffic_attribution_events_total',
|
||||||
|
{ outcome },
|
||||||
|
counter(attributionEvents[outcome]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPrometheusMetrics(res: ServerResponse, snapshot: unknown) {
|
||||||
|
const body = renderPrometheusMetrics(snapshot);
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {
|
||||||
|
ACTIVITY_JOURNAL_MAX_EVENTS,
|
||||||
|
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
||||||
|
normalizeActivityEventInput,
|
||||||
|
normalizeStoredActivityEvent,
|
||||||
|
type ActivityJournalEvent,
|
||||||
|
type ActivityJournalEventInput,
|
||||||
|
type ActivityJournalPage,
|
||||||
|
} from '../../shared/activityJournal.js';
|
||||||
|
import { createJsonStore } from './stateStore.js';
|
||||||
|
|
||||||
|
interface JournalState {
|
||||||
|
schemaVersion: 1;
|
||||||
|
events: ActivityJournalEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrateJournal = (value: unknown): JournalState => {
|
||||||
|
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
events: (Array.isArray(candidate.events) ? candidate.events : [])
|
||||||
|
.map(normalizeStoredActivityEvent)
|
||||||
|
.filter((event): event is ActivityJournalEvent => Boolean(event)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createActivityJournalService({
|
||||||
|
filePath,
|
||||||
|
now = () => new Date(),
|
||||||
|
}: {
|
||||||
|
filePath: string;
|
||||||
|
now?: () => Date;
|
||||||
|
}) {
|
||||||
|
const store = createJsonStore<JournalState>({
|
||||||
|
filePath,
|
||||||
|
defaultValue: { schemaVersion: 1, events: [] },
|
||||||
|
migrate: migrateJournal,
|
||||||
|
});
|
||||||
|
let recoveryRecorded = false;
|
||||||
|
let writeFailed = false;
|
||||||
|
|
||||||
|
function retained(events: ActivityJournalEvent[]) {
|
||||||
|
const cutoff = now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000;
|
||||||
|
return events
|
||||||
|
.filter(({ occurredAt }) => Date.parse(occurredAt) >= cutoff)
|
||||||
|
.slice(-ACTIVITY_JOURNAL_MAX_EVENTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function append(value: ActivityJournalEventInput) {
|
||||||
|
const input = normalizeActivityEventInput(value);
|
||||||
|
const storedInput = input.dedupeKey ? {
|
||||||
|
...input,
|
||||||
|
dedupeKey: `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`,
|
||||||
|
} : input;
|
||||||
|
let appended: ActivityJournalEvent | null = null;
|
||||||
|
try {
|
||||||
|
store.update((state) => {
|
||||||
|
const events = retained(state.events);
|
||||||
|
if (storedInput.dedupeKey && events.some(({ dedupeKey }) => dedupeKey === storedInput.dedupeKey)) {
|
||||||
|
return { schemaVersion: 1, events };
|
||||||
|
}
|
||||||
|
appended = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
occurredAt: now().toISOString(),
|
||||||
|
...storedInput,
|
||||||
|
};
|
||||||
|
return { schemaVersion: 1, events: retained([...events, appended]) };
|
||||||
|
});
|
||||||
|
writeFailed = false;
|
||||||
|
} catch (error) {
|
||||||
|
writeFailed = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return appended;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRecoveryEvent() {
|
||||||
|
if (!store.recovery || recoveryRecorded) return;
|
||||||
|
append({
|
||||||
|
type: 'journal.recovered',
|
||||||
|
severity: 'warning',
|
||||||
|
source: 'storage',
|
||||||
|
dedupeKey: `journal.recovered:${store.recovery.recoveredAt}`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
recoveryRecorded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
||||||
|
try {
|
||||||
|
let state = store.read();
|
||||||
|
ensureRecoveryEvent();
|
||||||
|
if (store.recovery) state = store.read();
|
||||||
|
const retainedEvents = retained(state.events);
|
||||||
|
if (retainedEvents.length !== state.events.length) {
|
||||||
|
state = store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
||||||
|
}
|
||||||
|
const events = [...state.events].reverse();
|
||||||
|
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
||||||
|
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
||||||
|
const cursorIndex = cursor ? events.findIndex(({ id }) => id === cursor) : -1;
|
||||||
|
if (cursor && cursorIndex < 0) return {
|
||||||
|
events: [],
|
||||||
|
nextCursor: null,
|
||||||
|
retentionDays: 30,
|
||||||
|
generatedAt: now().toISOString(),
|
||||||
|
storage: writeFailed
|
||||||
|
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||||
|
: { status: 'ready', errorCode: null },
|
||||||
|
};
|
||||||
|
const safeStart = cursorIndex + 1;
|
||||||
|
const selected = events.slice(safeStart, safeStart + limit);
|
||||||
|
return {
|
||||||
|
events: selected.map((event) => ({ ...event, dedupeKey: null })),
|
||||||
|
nextCursor: safeStart + selected.length < events.length ? selected.at(-1)?.id || null : null,
|
||||||
|
retentionDays: 30,
|
||||||
|
generatedAt: now().toISOString(),
|
||||||
|
storage: writeFailed
|
||||||
|
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||||
|
: { status: 'ready', errorCode: null },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
events: [],
|
||||||
|
nextCursor: null,
|
||||||
|
retentionDays: 30,
|
||||||
|
generatedAt: now().toISOString(),
|
||||||
|
storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
const state = store.read();
|
||||||
|
const retainedEvents = retained(state.events);
|
||||||
|
if (retainedEvents.length !== state.events.length) {
|
||||||
|
store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
writeFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { append, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityJournalService = ReturnType<typeof createActivityJournalService>;
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||||
|
import net from 'node:net';
|
||||||
|
import {
|
||||||
|
assessConnectivity,
|
||||||
|
CONNECTIVITY_IP_SOURCES,
|
||||||
|
CONNECTIVITY_NETWORK_SOURCE,
|
||||||
|
CONNECTIVITY_SITES,
|
||||||
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
import type { ConnectivityPathResult } from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
|
type PathKind = 'direct' | 'vpn';
|
||||||
|
|
||||||
|
interface CurlExecution {
|
||||||
|
exitCode: number | null;
|
||||||
|
error: string;
|
||||||
|
stderr: string;
|
||||||
|
stdout: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurlExecutor = (args: string[]) => Promise<CurlExecution>;
|
||||||
|
type DnsLookup = typeof dnsLookup;
|
||||||
|
|
||||||
|
interface BaseProbe {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpProbe extends BaseProbe {
|
||||||
|
family: 4 | 6;
|
||||||
|
address: (body: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteProbe extends BaseProbe {
|
||||||
|
follow?: boolean;
|
||||||
|
resolve?: string;
|
||||||
|
validationError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
body?: boolean;
|
||||||
|
ipv4?: boolean;
|
||||||
|
follow?: boolean;
|
||||||
|
resolve?: string | null;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestResult {
|
||||||
|
ok: boolean;
|
||||||
|
body: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
httpStatus: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
totalMs: number | null;
|
||||||
|
stage: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpProbeResult {
|
||||||
|
source: string;
|
||||||
|
label: string;
|
||||||
|
family: 4 | 6;
|
||||||
|
address: string | null;
|
||||||
|
attempts: number;
|
||||||
|
latencyMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkProbeResult {
|
||||||
|
address: string | null;
|
||||||
|
asn: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
city: string | null;
|
||||||
|
country: string | null;
|
||||||
|
attempts: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteProbeResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
httpStatus: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
totalMs: number | null;
|
||||||
|
stage: string;
|
||||||
|
error: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiagnosticTarget =
|
||||||
|
| { kind: 'network' }
|
||||||
|
| { kind: 'ip'; probe: IpProbe }
|
||||||
|
| { kind: 'site'; probe: SiteProbe };
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
|
||||||
|
|
||||||
|
const IP_PROBES: IpProbe[] = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
||||||
|
...probe,
|
||||||
|
family: probe.family === 6 ? 6 : 4,
|
||||||
|
address: probe.id === 'cloudflare'
|
||||||
|
? (body: string) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
|
||||||
|
: probe.id === 'yandex-internet'
|
||||||
|
? (body: string) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
|
||||||
|
: (body: string) => body.trim(),
|
||||||
|
}));
|
||||||
|
const SITE_PROBES: SiteProbe[] = [...CONNECTIVITY_SITES];
|
||||||
|
const TARGET_SAMPLE_COUNT = 3;
|
||||||
|
|
||||||
|
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
||||||
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
||||||
|
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
||||||
|
['224.0.0.0', 4], ['240.0.0.0', 4],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
|
||||||
|
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
|
||||||
|
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||||
|
|
||||||
|
function runCurl(args: string[]): Promise<CurlExecution> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
exitCode: typeof error?.code === 'number' && Number.isInteger(error.code) ? error.code : error ? null : 0,
|
||||||
|
error: error?.message || '',
|
||||||
|
stderr: stderr || '',
|
||||||
|
stdout: stdout || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageFor(exitCode: number | null) {
|
||||||
|
if (exitCode === 6) return 'dns';
|
||||||
|
if (exitCode === 7) return 'tcp';
|
||||||
|
if (exitCode !== null && [35, 51, 58, 60].includes(exitCode)) return 'tls';
|
||||||
|
if (exitCode === 28) return 'timeout';
|
||||||
|
return 'request';
|
||||||
|
}
|
||||||
|
|
||||||
|
function milliseconds(value: unknown) {
|
||||||
|
const seconds = Number(value);
|
||||||
|
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values: Array<number | null>) {
|
||||||
|
const numbers = values.filter((value): value is number => Number.isFinite(value));
|
||||||
|
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mostCommon<T>(values: T[]): T | null {
|
||||||
|
const counts = new Map<T, number>();
|
||||||
|
let selected: T | null = null;
|
||||||
|
let selectedCount = 0;
|
||||||
|
for (const value of values) {
|
||||||
|
const count = (counts.get(value) || 0) + 1;
|
||||||
|
counts.set(value, count);
|
||||||
|
if (count >= selectedCount) {
|
||||||
|
selected = value;
|
||||||
|
selectedCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(probe: BaseProbe, path: PathKind, proxyPort: number, execute: CurlExecutor, {
|
||||||
|
body = false,
|
||||||
|
ipv4 = false,
|
||||||
|
follow = true,
|
||||||
|
resolve = null,
|
||||||
|
timeoutMs = 6_000,
|
||||||
|
}: RequestOptions = {}): Promise<RequestResult> {
|
||||||
|
const boundedTimeoutMs = Math.min(30_000, Math.max(1_000, Math.round(timeoutMs)));
|
||||||
|
const args = [
|
||||||
|
'--silent',
|
||||||
|
'--show-error',
|
||||||
|
...(follow ? ['--location'] : []),
|
||||||
|
'--proto',
|
||||||
|
'=https',
|
||||||
|
'--proto-redir',
|
||||||
|
'=https',
|
||||||
|
'--connect-timeout',
|
||||||
|
String(Math.min(3_000, boundedTimeoutMs) / 1_000),
|
||||||
|
'--max-time',
|
||||||
|
String(boundedTimeoutMs / 1_000),
|
||||||
|
'--user-agent',
|
||||||
|
'Harbor-Diagnostics/1',
|
||||||
|
'--output',
|
||||||
|
body ? '-' : '/dev/null',
|
||||||
|
'--write-out',
|
||||||
|
`${CURL_META_MARKER}%{json}`,
|
||||||
|
...(path === 'vpn'
|
||||||
|
? ['--proxy', `http://127.0.0.1:${proxyPort}`]
|
||||||
|
: ['--noproxy', '*']),
|
||||||
|
...(ipv4 ? ['--ipv4'] : []),
|
||||||
|
...(resolve ? ['--resolve', resolve] : []),
|
||||||
|
probe.url,
|
||||||
|
];
|
||||||
|
const result = await execute(args);
|
||||||
|
const marker = result.stdout.lastIndexOf(CURL_META_MARKER);
|
||||||
|
const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : '';
|
||||||
|
let meta: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
meta = record(JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}'));
|
||||||
|
} catch {
|
||||||
|
// Curl diagnostics remain useful even when an old curl cannot emit JSON metadata.
|
||||||
|
}
|
||||||
|
const exitCode = typeof meta.exitcode === 'number' && Number.isInteger(meta.exitcode)
|
||||||
|
? meta.exitcode
|
||||||
|
: result.exitCode;
|
||||||
|
const ok = exitCode === 0;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
body: responseBody,
|
||||||
|
exitCode,
|
||||||
|
httpStatus: Number(meta.http_code) || null,
|
||||||
|
latencyMs: milliseconds(meta.time_starttransfer),
|
||||||
|
totalMs: milliseconds(meta.time_total),
|
||||||
|
stage: ok ? 'complete' : stageFor(exitCode),
|
||||||
|
error: ok ? null : String(meta.errormsg || result.stderr || result.error || 'request failed').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ipProbe(
|
||||||
|
probe: IpProbe,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sampleCount = 1,
|
||||||
|
): Promise<IpProbeResult> {
|
||||||
|
const samples: Array<RequestResult & { address: string | null }> = [];
|
||||||
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
|
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
||||||
|
const parsed = result.ok ? probe.address(result.body) : null;
|
||||||
|
samples.push({
|
||||||
|
...result,
|
||||||
|
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const address = mostCommon(samples.map((sample) => sample.address).filter((value): value is string => Boolean(value)));
|
||||||
|
const matching = samples.filter((sample) => sample.address === address);
|
||||||
|
return {
|
||||||
|
source: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
family: probe.family,
|
||||||
|
address,
|
||||||
|
attempts: samples.length,
|
||||||
|
latencyMs: average(matching.map((sample) => sample.latencyMs)),
|
||||||
|
error: address ? null : samples.at(-1)?.error || 'invalid IP response',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecutor) {
|
||||||
|
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
|
||||||
|
const ipv4 = probes.filter((probe) => probe.family === 4);
|
||||||
|
const ipv6 = probes.find((probe) => probe.family === 6);
|
||||||
|
return {
|
||||||
|
ipv4: {
|
||||||
|
addresses: [...new Set(ipv4.map((probe) => probe.address).filter((value): value is string => Boolean(value)))],
|
||||||
|
sources: ipv4,
|
||||||
|
},
|
||||||
|
ipv6: ipv6?.address || null,
|
||||||
|
ipv6Source: ipv6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNetwork(body: string): Omit<NetworkProbeResult, 'attempts' | 'error'> | null {
|
||||||
|
try {
|
||||||
|
const value = record(JSON.parse(body));
|
||||||
|
const connection = record(value.connection);
|
||||||
|
const address = text(value.ip);
|
||||||
|
if (value.success === false || !address || net.isIP(address) === 0) return null;
|
||||||
|
const number = Number(connection.asn);
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
asn: Number.isSafeInteger(number) && number > 0 ? `AS${number}` : null,
|
||||||
|
provider: text(connection.isp) || text(connection.org),
|
||||||
|
city: text(value.city),
|
||||||
|
country: text(value.country_code) || text(value.country),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkProbe(
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sampleCount = 1,
|
||||||
|
): Promise<NetworkProbeResult> {
|
||||||
|
const samples: Array<RequestResult & { network: ReturnType<typeof parseNetwork> }> = [];
|
||||||
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
|
const result = await request(CONNECTIVITY_NETWORK_SOURCE, path, proxyPort, execute, { body: true, ipv4: true });
|
||||||
|
samples.push({ ...result, network: result.ok ? parseNetwork(result.body) : null });
|
||||||
|
}
|
||||||
|
const selected = mostCommon(samples
|
||||||
|
.map(({ network }) => network && JSON.stringify(network))
|
||||||
|
.filter((value): value is string => Boolean(value)));
|
||||||
|
const network = selected ? JSON.parse(selected) as ReturnType<typeof parseNetwork> : null;
|
||||||
|
return {
|
||||||
|
address: network?.address || null,
|
||||||
|
asn: network?.asn || null,
|
||||||
|
provider: network?.provider || null,
|
||||||
|
city: network?.city || null,
|
||||||
|
country: network?.country || null,
|
||||||
|
attempts: samples.length,
|
||||||
|
error: network ? null : samples.at(-1)?.error || 'invalid network response',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicAddress(address: string, family: number) {
|
||||||
|
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
|
||||||
|
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
|
||||||
|
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCustomProbes(services: unknown, lookup: DnsLookup): Promise<SiteProbe[]> {
|
||||||
|
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
|
||||||
|
return Promise.all(requested.map(async (service, index) => {
|
||||||
|
const value = record(service);
|
||||||
|
const requestedId = String(value.id || '');
|
||||||
|
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(String(value.url || '').trim());
|
||||||
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
|
||||||
|
throw new Error('Разрешены только публичные HTTPS-адреса');
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const resolved = await lookup(hostname, { all: true, verbatim: true });
|
||||||
|
const addresses = Array.isArray(resolved) ? resolved : [resolved];
|
||||||
|
if (!addresses.length || addresses.some(({ address, family }) => !isPublicAddress(address, family))) {
|
||||||
|
throw new Error('Адрес ведёт во внутреннюю или служебную сеть');
|
||||||
|
}
|
||||||
|
const target = addresses.find(({ family }) => family === 4) || addresses[0];
|
||||||
|
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(value.label || '').trim().slice(0, 40) || hostname,
|
||||||
|
url: parsed.href,
|
||||||
|
follow: false,
|
||||||
|
resolve: `${hostname}:443:${pinned}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(value.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
|
||||||
|
url: '',
|
||||||
|
validationError: error instanceof Error ? error.message : 'Некорректный адрес',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteStatus(result: RequestResult) {
|
||||||
|
if (!result.ok) return 'unavailable';
|
||||||
|
return result.httpStatus !== null && result.httpStatus >= 200 && result.httpStatus < 400
|
||||||
|
? 'available'
|
||||||
|
: 'responded';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function siteProbe(
|
||||||
|
probe: SiteProbe,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sampleCount = 1,
|
||||||
|
timeoutMs = 6_000,
|
||||||
|
retryFailure = true,
|
||||||
|
): Promise<SiteProbeResult> {
|
||||||
|
if (probe.validationError) return {
|
||||||
|
id: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
status: 'unavailable',
|
||||||
|
attempts: 0,
|
||||||
|
httpStatus: null,
|
||||||
|
latencyMs: null,
|
||||||
|
totalMs: null,
|
||||||
|
stage: 'validation',
|
||||||
|
error: probe.validationError,
|
||||||
|
};
|
||||||
|
const options = { follow: probe.follow !== false, resolve: probe.resolve, timeoutMs };
|
||||||
|
const samples = [];
|
||||||
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
|
}
|
||||||
|
if (retryFailure && sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||||
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
|
}
|
||||||
|
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
|
||||||
|
const matching = samples.filter((sample) => siteStatus(sample) === status);
|
||||||
|
const representative = matching.at(-1) || samples.at(-1);
|
||||||
|
if (!representative) throw new Error('Diagnostic probe produced no samples');
|
||||||
|
return {
|
||||||
|
id: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
status,
|
||||||
|
attempts: samples.length,
|
||||||
|
httpStatus: mostCommon(matching.map((sample) => sample.httpStatus)),
|
||||||
|
latencyMs: average(matching.map((sample) => sample.latencyMs)),
|
||||||
|
totalMs: average(matching.map((sample) => sample.totalMs)),
|
||||||
|
stage: representative.stage,
|
||||||
|
error: representative.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probePath(
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sites: SiteProbe[],
|
||||||
|
): Promise<ConnectivityPathResult> {
|
||||||
|
const [network, ip, siteResults] = await Promise.all([
|
||||||
|
networkProbe(path, proxyPort, execute),
|
||||||
|
publicIps(path, proxyPort, execute),
|
||||||
|
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
internetAvailable: Boolean(
|
||||||
|
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
|
||||||
|
),
|
||||||
|
network,
|
||||||
|
...ip,
|
||||||
|
sites: siteResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailablePath(): ConnectivityPathResult {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'vpn-off',
|
||||||
|
internetAvailable: false,
|
||||||
|
ipv4: { addresses: [], sources: [] },
|
||||||
|
ipv6: null,
|
||||||
|
ipv6Source: null,
|
||||||
|
sites: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
|
||||||
|
if (typeof targetId !== 'string') return null;
|
||||||
|
if (targetId === CONNECTIVITY_NETWORK_SOURCE.id) return { kind: 'network' };
|
||||||
|
if (targetId.startsWith('ip:')) {
|
||||||
|
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
|
||||||
|
return probe ? { kind: 'ip', probe } : null;
|
||||||
|
}
|
||||||
|
if (targetId.startsWith('site:')) {
|
||||||
|
const probe = sites.find(({ id }) => id === targetId.slice(5));
|
||||||
|
return probe ? { kind: 'site', probe } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeTarget(
|
||||||
|
target: DiagnosticTarget,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
timeoutMs = 6_000,
|
||||||
|
sampleCount = TARGET_SAMPLE_COUNT,
|
||||||
|
retryFailure = true,
|
||||||
|
): Promise<ConnectivityPathResult> {
|
||||||
|
const network = target.kind === 'network'
|
||||||
|
? await networkProbe(path, proxyPort, execute, sampleCount)
|
||||||
|
: null;
|
||||||
|
const ip = target.kind === 'ip'
|
||||||
|
? await ipProbe(target.probe, path, proxyPort, execute, sampleCount)
|
||||||
|
: null;
|
||||||
|
const site = target.kind === 'site'
|
||||||
|
? await siteProbe(target.probe, path, proxyPort, execute, sampleCount, timeoutMs, retryFailure)
|
||||||
|
: null;
|
||||||
|
const ipv4Sources = ip?.family === 4 ? [ip] : [];
|
||||||
|
const ipv6Source = ip?.family === 6 ? ip : null;
|
||||||
|
const sites = site ? [site] : [];
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
internetAvailable: Boolean(network?.address || ip?.address || (site && site.status !== 'unavailable')),
|
||||||
|
...(network ? { network } : {}),
|
||||||
|
ipv4: {
|
||||||
|
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
|
||||||
|
sources: ipv4Sources,
|
||||||
|
},
|
||||||
|
ipv6: ipv6Source?.address || null,
|
||||||
|
ipv6Source,
|
||||||
|
sites,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { assessConnectivity };
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsService({
|
||||||
|
proxyPort,
|
||||||
|
execute = runCurl,
|
||||||
|
lookup = dnsLookup,
|
||||||
|
now = () => new Date().toISOString(),
|
||||||
|
}: {
|
||||||
|
proxyPort: number;
|
||||||
|
execute?: CurlExecutor;
|
||||||
|
lookup?: DnsLookup;
|
||||||
|
now?: () => string;
|
||||||
|
}) {
|
||||||
|
async function runOnce({ vpnAvailable, services = [], target: targetId = null }: {
|
||||||
|
vpnAvailable: boolean;
|
||||||
|
services?: unknown;
|
||||||
|
target?: unknown;
|
||||||
|
}) {
|
||||||
|
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
|
||||||
|
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
|
||||||
|
: targetId ? [] : services;
|
||||||
|
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||||
|
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||||
|
const target = resolveTarget(targetId, siteProbes);
|
||||||
|
if (targetId && !target) throw new Error('Unknown diagnostic target');
|
||||||
|
const directPromise = target
|
||||||
|
? probeTarget(target, 'direct', proxyPort, execute)
|
||||||
|
: probePath('direct', proxyPort, execute, siteProbes);
|
||||||
|
const vpnPromise = vpnAvailable
|
||||||
|
? target
|
||||||
|
? probeTarget(target, 'vpn', proxyPort, execute)
|
||||||
|
: probePath('vpn', proxyPort, execute, siteProbes)
|
||||||
|
: Promise.resolve(unavailablePath());
|
||||||
|
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
|
||||||
|
return {
|
||||||
|
checkedAt: now(),
|
||||||
|
direct,
|
||||||
|
vpn,
|
||||||
|
assessment: assessConnectivity(direct, vpn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function runVpn({ services = [], target: targetId = null, timeoutMs = 6_000 }: {
|
||||||
|
services?: unknown;
|
||||||
|
target?: unknown;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}) {
|
||||||
|
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
|
||||||
|
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
|
||||||
|
: [];
|
||||||
|
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||||
|
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||||
|
const target = resolveTarget(targetId, siteProbes);
|
||||||
|
if (!target || target.kind !== 'site') throw new Error('Failover check ожидает site target');
|
||||||
|
return {
|
||||||
|
checkedAt: now(),
|
||||||
|
vpn: await probeTarget(target, 'vpn', proxyPort, execute, timeoutMs, TARGET_SAMPLE_COUNT, false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
run: runOnce,
|
||||||
|
runVpn,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
||||||
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
|
|
||||||
|
const COMMAND_OPTIONS = { encoding: 'utf8' as const, timeout: 2_000, killSignal: 'SIGKILL' as const };
|
||||||
|
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||||
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
|
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
|
||||||
|
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
|
||||||
|
const MAX_DEVICES = 512;
|
||||||
|
|
||||||
|
export interface DirectDevice {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicySnapshot {
|
||||||
|
epoch: string;
|
||||||
|
generation: string;
|
||||||
|
fingerprint: string;
|
||||||
|
observedAt: string;
|
||||||
|
appliedIds: string[];
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||||
|
const fingerprint = (devices: readonly DirectDevice[]) => crypto.createHash('sha256')
|
||||||
|
.update(JSON.stringify(devices))
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
function commandError(command: string, result: SpawnSyncReturns<string>) {
|
||||||
|
return new Error(String(
|
||||||
|
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
||||||
|
).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDirectDevices(value: unknown): DirectDevice[] {
|
||||||
|
if (!Array.isArray(value) || value.length > MAX_DEVICES) {
|
||||||
|
throw new Error('Некорректный набор device policy');
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const tuples = new Set<string>();
|
||||||
|
const devices = value.map((value) => {
|
||||||
|
const device = record(value);
|
||||||
|
const normalized = {
|
||||||
|
id: String(device?.id || ''),
|
||||||
|
ip: String(device?.ip || ''),
|
||||||
|
mac: String(device?.mac || '').toLowerCase(),
|
||||||
|
interface: String(device?.interface || ''),
|
||||||
|
};
|
||||||
|
const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`;
|
||||||
|
if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip)
|
||||||
|
|| !MAC_PATTERN.test(normalized.mac) || !isDeviceInterface(normalized.interface)
|
||||||
|
|| ids.has(normalized.id) || tuples.has(tuple)) {
|
||||||
|
throw new Error('Некорректная или повторяющаяся device policy identity');
|
||||||
|
}
|
||||||
|
ids.add(normalized.id);
|
||||||
|
tuples.add(tuple);
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
return devices.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fingerprintDirectDevices = (value: unknown) => fingerprint(normalizeDirectDevices(value));
|
||||||
|
|
||||||
|
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }: {
|
||||||
|
devices: readonly DirectDevice[];
|
||||||
|
chain: string;
|
||||||
|
slot: string;
|
||||||
|
tproxyPort: number;
|
||||||
|
tproxyMark: string;
|
||||||
|
}) {
|
||||||
|
const child = childChain(chain, slot);
|
||||||
|
const rules = ['*mangle', `-F ${child}`];
|
||||||
|
for (const device of devices) {
|
||||||
|
rules.push(`-A ${child} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-policy:${device.id}:direct -j RETURN`);
|
||||||
|
}
|
||||||
|
rules.push(
|
||||||
|
`-A ${child} -p tcp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`,
|
||||||
|
`-A ${child} -p udp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`,
|
||||||
|
'COMMIT',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return rules.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDevicePolicyService({
|
||||||
|
chain,
|
||||||
|
tproxyPort,
|
||||||
|
tproxyMark,
|
||||||
|
run = spawnSync,
|
||||||
|
now = () => new Date(),
|
||||||
|
nextGeneration = () => crypto.randomUUID(),
|
||||||
|
}: {
|
||||||
|
chain: string;
|
||||||
|
tproxyPort: number;
|
||||||
|
tproxyMark: string;
|
||||||
|
run?: typeof spawnSync;
|
||||||
|
now?: () => Date;
|
||||||
|
nextGeneration?: () => string;
|
||||||
|
}) {
|
||||||
|
if (!CHAIN_PATTERN.test(String(chain || ''))
|
||||||
|
|| !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535
|
||||||
|
|| !MARK_PATTERN.test(String(tproxyMark || ''))) {
|
||||||
|
throw new Error('Некорректная конфигурация device policy');
|
||||||
|
}
|
||||||
|
const epoch = nextGeneration();
|
||||||
|
let activeSlot: 'A' | 'B' = 'A';
|
||||||
|
let activeSignature = JSON.stringify([]);
|
||||||
|
let generation = epoch;
|
||||||
|
let appliedDevices: DirectDevice[] = [];
|
||||||
|
let observedAt = now().toISOString();
|
||||||
|
let queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function execute(command: string, args: string[], input?: string) {
|
||||||
|
const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input });
|
||||||
|
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(changed = false): PolicySnapshot {
|
||||||
|
return {
|
||||||
|
epoch,
|
||||||
|
generation,
|
||||||
|
fingerprint: fingerprint(appliedDevices),
|
||||||
|
observedAt,
|
||||||
|
appliedIds: appliedDevices.map(({ id }) => id),
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function performApply(value: unknown) {
|
||||||
|
const devices = normalizeDirectDevices(value);
|
||||||
|
const signature = JSON.stringify(devices);
|
||||||
|
if (signature === activeSignature) return snapshot(false);
|
||||||
|
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
|
||||||
|
execute('iptables-restore', ['-w', '1', '--noflush'], buildDevicePolicyRestore({
|
||||||
|
devices,
|
||||||
|
chain,
|
||||||
|
slot: nextSlot,
|
||||||
|
tproxyPort,
|
||||||
|
tproxyMark,
|
||||||
|
}));
|
||||||
|
execute('iptables', [
|
||||||
|
'-w', '1', '-t', 'mangle', '-R', chain, '1', '-j', childChain(chain, nextSlot),
|
||||||
|
]);
|
||||||
|
activeSlot = nextSlot;
|
||||||
|
activeSignature = signature;
|
||||||
|
appliedDevices = devices;
|
||||||
|
generation = nextGeneration();
|
||||||
|
observedAt = now().toISOString();
|
||||||
|
return snapshot(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(devices: unknown): Promise<PolicySnapshot> {
|
||||||
|
const result = queue.then(() => performApply(devices));
|
||||||
|
queue = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apply, snapshot: () => snapshot(false) };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user