release: 1.4.4 — channel «Облако» (WebSocket via KeenDNS cloud to own Keenetic): wsgw gateway, Android 1.4.4, iOS, docs/cloud.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Бобырев Андрей
2026-10-31 05:16:31 +03:00
parent d795bc6f70
commit dde6403cea
38 changed files with 855 additions and 58 deletions

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -4,7 +4,8 @@ import WireGuardKit
/// PCA Phobos packet tunnel:
/// WireGuard (userspace) -> 127.0.0.1:13255 wg-obfuscator -> 127.0.0.1:13258 pcarelay -> server (UDP or TCP).
/// providerConfiguration: "conf" (phobos:// payload), "ruDirect" (Bool), "transport" ("auto"|"udp"|"tcp").
/// providerConfiguration: "conf" (phobos:// payload), "ruDirect" (Bool), "transport" ("auto"|"udp"|"tcp"|"cloud"), "wsURL".
/// "cloud": pcarelay hands packets to WsLink -> wss://<Keenetic KeenDNS subdomain>/ -> wsgw on the router -> server UDP.
final class PacketTunnelProvider: NEPacketTunnelProvider {
private static let wgPort = 13255, relayPort = 13258
private static var obfuscatorStarted = false // one wg-obfuscator per process (it forks for more)
@@ -31,7 +32,17 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
return
}
pcarelay_use_tcp(transport == "tcp" ? 1 : 0)
let wsURL = (pc["wsURL"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? profile.wsURL
if transport == "cloud" {
guard let s = wsURL, let url = URL(string: s) else {
completionHandler(NSError(domain: "PCAPhobos", code: 4, userInfo: [NSLocalizedDescriptionKey: "не задан адрес роутера (wss://…) для канала «Облако»"]))
return
}
WsLink.shared.start(url)
pcarelay_use_ws { p, n in WsLink.shared.send(p, n) }
} else {
pcarelay_use_tcp(transport == "tcp" ? 1 : 0)
}
if !Self.obfuscatorStarted {
Self.obfuscatorStarted = true
pcarelay_start(Int32(Self.relayPort), host, Int32(udpPort), Int32(tcpPort))
@@ -44,8 +55,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
}
}
let allowed = ruDirect ? Self.ruBypassAllowedIPs() : nil
let wgText = profile.wireGuardConfig(endpoint: "127.0.0.1:\(Self.wgPort)", allowedIPs: allowed)
// full tunnel; Russian networks (all sizes, incl. CDN /24s) are excluded routes when ruDirect
PhobosRoutes.excludedIPv4 = ruDirect ? Self.ruNetworks() : []
let wgText = profile.wireGuardConfig(endpoint: "127.0.0.1:\(Self.wgPort)", allowedIPs: nil)
let tunnelConfig: TunnelConfiguration
do {
tunnelConfig = try TunnelConfiguration(fromWgQuickConfig: wgText, called: "phobos")
@@ -79,14 +91,17 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
/// App asks for status: {"transport": "UDP"|"TCP", "rx": bytes}
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
let status: [String: Any] = ["transport": pcarelay_is_tcp() != 0 ? "TCP" : "UDP",
let status: [String: Any] = ["transport": pcarelay_is_ws() != 0 ? "Облако" : pcarelay_is_tcp() != 0 ? "TCP" : "UDP",
"rx": pcarelay_rx_bytes()]
completionHandler?(try? JSONSerialization.data(withJSONObject: status))
}
private static func ruBypassAllowedIPs() -> String? {
/// ru_bypass.txt: Russian IPv4 networks (comma-separated). IPv6 stays fully in the tunnel,
/// otherwise blocked sites leak over carrier IPv6.
private static func ruNetworks() -> [String] {
guard let url = Bundle.main.url(forResource: "ru_bypass", withExtension: "txt"),
let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
return text.split(separator: "\n").first { !$0.hasPrefix("#") && $0.contains("/") }.map(String.init)
let text = try? String(contentsOf: url, encoding: .utf8),
let line = text.split(separator: "\n").first(where: { !$0.hasPrefix("#") && $0.contains("/") }) else { return [] }
return line.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}
}

59
ios/Tunnel/WsLink.swift Normal file
View File

@@ -0,0 +1,59 @@
import Foundation
/// Channel «Облако»: one binary WebSocket message = one obfuscated packet, both ways.
/// pcarelay (C) calls send() for every packet from the obfuscator; received messages go back via pcarelay_ws_in().
final class WsLink {
static let shared = WsLink()
private let q = DispatchQueue(label: "pca.ws")
private let session = URLSession(configuration: .ephemeral)
private var url: URL?
private var task: URLSessionWebSocketTask?
func start(_ url: URL) {
q.async { self.url = url; self.connect() }
}
func send(_ p: UnsafePointer<UInt8>?, _ n: Int32) {
guard let p, n > 0 else { return }
let data = Data(bytes: p, count: Int(n))
q.async {
if self.task == nil { self.connect() }
guard let t = self.task else { return }
t.send(.data(data)) { [weak self] err in
if let err { self?.q.async { if self?.task === t { self?.drop("send: \(err.localizedDescription)") } } }
}
}
}
private func connect() {
guard let url, task == nil else { return }
let t = session.webSocketTask(with: url)
t.maximumMessageSize = 1 << 20
task = t
t.resume()
NSLog("PCAPhobos ws: connect %@", url.absoluteString)
receive(t)
}
private func receive(_ t: URLSessionWebSocketTask) {
t.receive { [weak self] result in
switch result {
case .success(.data(let d)):
d.withUnsafeBytes { pcarelay_ws_in($0.bindMemory(to: UInt8.self).baseAddress, Int32(d.count)) }
self?.receive(t)
case .success:
self?.receive(t)
case .failure(let err):
self?.q.async { if self?.task === t { self?.drop("recv: \(err.localizedDescription)") } }
}
}
}
/// Next packet from the obfuscator reconnects (WireGuard keepalives make that quick).
private func drop(_ why: String) {
NSLog("PCAPhobos ws: %@", why)
task?.cancel(with: .goingAway, reason: nil)
task = nil
}
}

View File

@@ -3,6 +3,7 @@
* wg-obfuscator (one instance, iOS forbids fork) sends to 127.0.0.1:<local>;
* we forward either over UDP to the server's obfuscator port or over TCP to
* the server's udptcp (frames: 2-byte length + packet). Switch with pcarelay_use_tcp().
* Mode 2 ("Облако"): packets go to a Swift callback (WebSocket to the user's Keenetic), answers come back via pcarelay_ws_in().
*/
#include "pcarelay.h"
#include <arpa/inet.h>
@@ -18,7 +19,12 @@
#include <time.h>
#include <unistd.h>
static atomic_int use_tcp = 0;
static atomic_int use_tcp = 0; /* 0 udp, 1 tcp, 2 ws */
static pcarelay_ws_out_fn ws_out;
static int g_local = -1;
static pthread_mutex_t peer_mu = PTHREAD_MUTEX_INITIALIZER;
static struct sockaddr_storage g_peer;
static socklen_t g_pl;
static atomic_ullong rx_bytes = 0;
static char g_host[256], g_udp_port[8], g_tcp_port[8];
static int g_local_port;
@@ -59,15 +65,17 @@ static void *loop(void *arg) {
la.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int local = socket(AF_INET, SOCK_DGRAM, 0);
if (local < 0 || bind(local, (struct sockaddr *)&la, sizeof la) < 0) return NULL;
g_local = local;
int udp = -1, tcp = -1;
struct sockaddr_storage peer; socklen_t pl = 0;
unsigned char buf[65537];
time_t last_dial = 0;
for (;;) {
int want_tcp = atomic_load(&use_tcp);
int mode = atomic_load(&use_tcp), want_tcp = mode == 1;
if (mode == 2) { if (udp >= 0) { close(udp); udp = -1; } if (tcp >= 0) { close(tcp); tcp = -1; } }
if (!want_tcp && tcp >= 0) { close(tcp); tcp = -1; }
if (want_tcp && udp >= 0) { close(udp); udp = -1; }
if (!want_tcp && udp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); udp = dial(SOCK_DGRAM, g_udp_port); }
if (mode == 0 && udp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); udp = dial(SOCK_DGRAM, g_udp_port); }
int up = want_tcp ? tcp : udp;
struct pollfd p[2] = {{local, POLLIN, 0}, {up, POLLIN, 0}};
poll(p, up >= 0 ? 2 : 1, 1000);
@@ -75,7 +83,10 @@ static void *loop(void *arg) {
pl = sizeof peer;
ssize_t n = recvfrom(local, buf, 65535, 0, (struct sockaddr *)&peer, &pl);
if (n <= 0) continue;
if (want_tcp) {
pthread_mutex_lock(&peer_mu); g_peer = peer; g_pl = pl; pthread_mutex_unlock(&peer_mu);
if (mode == 2) {
if (ws_out) ws_out(buf, (int)n);
} else if (want_tcp) {
if (tcp < 0 && time(NULL) != last_dial) { last_dial = time(NULL); tcp = dial(SOCK_STREAM, g_tcp_port); }
if (tcp >= 0) {
unsigned char h[2] = {(unsigned char)(n >> 8), (unsigned char)n};
@@ -114,5 +125,17 @@ int pcarelay_start(int local_port, const char *host, int udp_port, int tcp_port)
}
void pcarelay_use_tcp(int on) { atomic_store(&use_tcp, on ? 1 : 0); }
int pcarelay_is_tcp(void) { return atomic_load(&use_tcp); }
int pcarelay_is_tcp(void) { return atomic_load(&use_tcp) == 1; }
void pcarelay_use_ws(pcarelay_ws_out_fn out) { ws_out = out; atomic_store(&use_tcp, 2); }
int pcarelay_is_ws(void) { return atomic_load(&use_tcp) == 2; }
void pcarelay_ws_in(const unsigned char *p, int n) {
if (g_local < 0 || n <= 0) return;
atomic_fetch_add(&rx_bytes, (unsigned long long)n);
pthread_mutex_lock(&peer_mu);
struct sockaddr_storage peer = g_peer; socklen_t pl = g_pl;
pthread_mutex_unlock(&peer_mu);
if (pl) sendto(g_local, p, (size_t)n, 0, (struct sockaddr *)&peer, pl);
}
unsigned long long pcarelay_rx_bytes(void) { return atomic_load(&rx_bytes); }

View File

@@ -8,6 +8,13 @@ int pcarelay_is_tcp(void);
/* Bytes received back from the server on any path (auto-detects a dead UDP). */
unsigned long long pcarelay_rx_bytes(void);
/* Mode "Облако": each packet from the obfuscator goes to out() (Swift sends it over WebSocket);
packets from the WebSocket are handed back with pcarelay_ws_in(). */
typedef void (*pcarelay_ws_out_fn)(const unsigned char *p, int n);
void pcarelay_use_ws(pcarelay_ws_out_fn out);
int pcarelay_is_ws(void);
void pcarelay_ws_in(const unsigned char *p, int n);
/* wg-obfuscator compiled in with -Dmain=wgobf_main (one instance per process). */
int wgobf_main(int argc, char **argv);

File diff suppressed because one or more lines are too long