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

@@ -11,6 +11,7 @@ struct ContentView: View {
ScrollView {
VStack(spacing: 18) {
profileCard
if vpn.servers.count > 1 { serversCard }
powerButton
statusLine
optionsCard
@@ -57,6 +58,30 @@ struct ContentView: View {
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private var serversCard: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Серверы").font(.subheadline.bold()).padding(.bottom, 6)
ForEach(vpn.servers, id: \.self) { s in
let n = s["name"] ?? ""
HStack {
Image(systemName: n == vpn.name ? "checkmark.circle.fill" : "circle")
.foregroundStyle(n == vpn.name ? .green : .secondary)
Text(n)
Spacer()
if n != vpn.name {
Button(role: .destructive) { vpn.remove(n) } label: { Image(systemName: "trash") }
.buttonStyle(.borderless)
}
}
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture { vpn.select(n) }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private var powerButton: some View {
Button(action: vpn.toggle) {
Text(vpn.isOn ? "Отключить" : "Подключить")
@@ -85,9 +110,16 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 6) {
Text("Канал до сервера").font(.subheadline.bold())
Picker("Канал", selection: $vpn.transportMode) {
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp")
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp"); Text("Облако").tag("cloud")
}
.pickerStyle(.segmented)
if vpn.transportMode == "cloud" {
TextField("wss://phobos.имя.keenetic.pro/", text: $vpn.wsURL)
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
.textFieldStyle(.roundedBorder).onSubmit { vpn.wsURL = vpn.wsURL.trimmingCharacters(in: .whitespaces) }
Text("Облако: телефон → облако KeenDNS → ваш Keenetic → сервер. Работает, когда до сервера напрямую не достучаться.")
.font(.caption).foregroundStyle(.secondary)
}
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
.font(.caption).foregroundStyle(.secondary)
}

View File

@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -30,9 +30,13 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

View File

@@ -21,6 +21,10 @@ final class VPNController: ObservableObject {
@Published var conf = UserDefaults.standard.string(forKey: "conf") ?? ""
@Published var ruDirect = UserDefaults.standard.object(forKey: "ruDirect") as? Bool ?? true { didSet { saveOptions() } }
@Published var transportMode = UserDefaults.standard.string(forKey: "transport") ?? "auto" { didSet { saveOptions() } }
/// Channel «Облако»: wss:// of the user's Keenetic (empty -> "# pca-ws" from the link).
@Published var wsURL = UserDefaults.standard.string(forKey: "wsURL") ?? "" { didSet { saveOptions() } }
/// Imported servers: [["name": …, "conf": …]]; name/conf above mirror the selected one.
@Published var servers = UserDefaults.standard.array(forKey: "servers") as? [[String: String]] ?? []
@Published var status: NEVPNStatus = .invalid
@Published var transport = ""
@Published var message = ""
@@ -29,6 +33,7 @@ final class VPNController: ObservableObject {
private var timer: Timer?
init() {
if servers.isEmpty && !conf.isEmpty { servers = [["name": name, "conf": conf]] } // pre-list single profile
Task { await load() }
NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.status = self?.manager?.connection.status ?? .invalid }
@@ -49,17 +54,40 @@ final class VPNController: ObservableObject {
func importLink(_ link: String) {
guard let parsed = PhobosProfile.fromLink(link) else { message = "Это не ссылка phobos:// из панели"; return }
conf = parsed.profile.raw
name = parsed.name
servers.removeAll { $0["name"] == parsed.name } // same name = updated link
servers.append(["name": parsed.name, "conf": parsed.profile.raw])
select(parsed.name)
message = "Профиль «\(name)» импортирован"
}
func select(_ server: String) {
guard let s = servers.first(where: { $0["name"] == server }) else { return }
name = server
conf = s["conf"] ?? ""
persist()
Task { await save(reconnect: isOn) }
}
func remove(_ server: String) {
servers.removeAll { $0["name"] == server }
if server == name {
if let next = servers.first?["name"] { select(next); return }
if isOn { manager?.connection.stopVPNTunnel() }
name = ""; conf = ""
}
persist()
}
private func persist() {
UserDefaults.standard.set(servers, forKey: "servers")
UserDefaults.standard.set(conf, forKey: "conf")
UserDefaults.standard.set(name, forKey: "name")
message = "Профиль «\(name)» импортирован"
Task { await save(reconnect: isOn) }
}
private func saveOptions() {
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
UserDefaults.standard.set(transportMode, forKey: "transport")
UserDefaults.standard.set(wsURL, forKey: "wsURL")
Task { await save(reconnect: isOn) }
}
@@ -70,7 +98,7 @@ final class VPNController: ObservableObject {
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = "com.bobyrev.pcaphobos.tunnel"
proto.serverAddress = profile.targetHost ?? "Phobos"
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode]
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode, "wsURL": wsURL]
m.protocolConfiguration = proto
m.localizedDescription = "PCA Phobos"
m.isEnabled = true

View File

@@ -14,6 +14,7 @@
38870650A7C2B1F0927A1230 /* wg-obfuscator.c in Sources */ = {isa = PBXBuildFile; fileRef = 1FADBB2A65AF77D4ECCAFE78 /* wg-obfuscator.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */; };
56F35C68C6A387E499703CFF /* masking.c in Sources */ = {isa = PBXBuildFile; fileRef = CCE5C98CD905BDE16A8D83D4 /* masking.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC83AB79BE0E361F4EE90875 /* WsLink.swift */; };
5F5D376513C0B27C45A3E7B6 /* ru_bypass.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2572D9A6F822B038D0652619 /* ru_bypass.txt */; };
78B74F81E0042D9AD94B7A6B /* config.c in Sources */ = {isa = PBXBuildFile; fileRef = 0E5C1673464AAC3EF81DD2FE /* config.c */; settings = {COMPILER_FLAGS = "-Dmain=wgobf_main -w"; }; };
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC1765DEC5F326E2B5F9C4 /* PhobosProfile.swift */; };
@@ -70,6 +71,7 @@
B3E470E13EF603CB26991683 /* PCAPhobosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCAPhobosApp.swift; sourceTree = "<group>"; };
CA6480536DA633DB94B6930F /* WireGuardKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WireGuardKit; path = WireGuardKit; sourceTree = SOURCE_ROOT; };
CCE5C98CD905BDE16A8D83D4 /* masking.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = masking.c; sourceTree = "<group>"; };
EC83AB79BE0E361F4EE90875 /* WsLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WsLink.swift; sourceTree = "<group>"; };
EF9115CA0D5EC52C8679A12A /* obfuscation.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = obfuscation.c; sourceTree = "<group>"; };
FF14952A4263FF0E004A3F04 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -103,6 +105,7 @@
2572D9A6F822B038D0652619 /* ru_bypass.txt */,
7500F0B31797D49B5BBEC5CC /* String+ArrayConversion.swift */,
2C1AA6DED9B45949864A9B7D /* TunnelConfiguration+WgQuickConfig.swift */,
EC83AB79BE0E361F4EE90875 /* WsLink.swift */,
);
path = Tunnel;
sourceTree = "<group>";
@@ -214,11 +217,11 @@
LastUpgradeCheck = 1430;
TargetAttributes = {
0FD066BC2C51A1E092914FE8 = {
DevelopmentTeam = "";
DevelopmentTeam = Q5FKU39TUA;
ProvisioningStyle = Automatic;
};
E1F7E69029753CF102C6CE2E = {
DevelopmentTeam = "";
DevelopmentTeam = Q5FKU39TUA;
ProvisioningStyle = Automatic;
};
};
@@ -274,6 +277,7 @@
7D8AB66421409CBFCCF2769A /* PhobosProfile.swift in Sources */,
4E4255B9E0F8626F6E209B0F /* String+ArrayConversion.swift in Sources */,
11F2A6A8D281AB36B45D4603 /* TunnelConfiguration+WgQuickConfig.swift in Sources */,
599057D9048B5FF1EA0E6000 /* WsLink.swift in Sources */,
78B74F81E0042D9AD94B7A6B /* config.c in Sources */,
1C05D29ACA95548374AD6FD9 /* logging.c in Sources */,
56F35C68C6A387E499703CFF /* masking.c in Sources */,
@@ -385,9 +389,9 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 6;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = Q5FKU39TUA;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -399,7 +403,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -445,9 +449,9 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 6;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = Q5FKU39TUA;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -465,7 +469,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;

View File

@@ -1,17 +1,18 @@
# PCA Phobos VPN — iPhone
То же, что Android-клиент: импорт `phobos://` из панели, обфускация (wg-obfuscator внутри
расширения), **российские сайты напрямую**, канал **Авто/UDP/TCP** (при блокировке UDP — сам на TCP).
расширения), **российские сайты напрямую**, канал **Авто/UDP/TCP/Облако** (при блокировке UDP — сам на TCP; «Облако» — через KeenDNS своего Keenetic, см. `../wsgw/README.md`).
Выбор отдельных приложений на iOS невозможен (Apple даёт это только через MDM) — делим по адресам.
Устройство:
- `App/` — SwiftUI-приложение (профиль, кнопка, настройки), `Shared/PhobosProfile.swift` — разбор ссылки.
- `Tunnel/` — Packet Tunnel: WireGuardKit → 127.0.0.1:13255 wg-obfuscator (`-Dmain=wgobf_main`, один
экземпляр: iOS запрещает fork) → 127.0.0.1:13258 `pcarelay.c` → сервер по UDP или TCP (формат udptcp).
экземпляр: iOS запрещает fork) → 127.0.0.1:13258 `pcarelay.c` → сервер по UDP или TCP (формат udptcp),
либо «Облако»: пакеты из pcarelay в `WsLink.swift` (URLSessionWebSocketTask) → wss://поддомен роутера → wsgw → сервер.
- `WireGuardKit/` — wireguard-apple (MIT), `WireGuardGo/<sdk>/libwg-go.a` — собран `make` (iphoneos) и
`go build -buildmode c-archive` с `-target arm64-apple-ios15.0-simulator` (симулятор).
Сборка: `xcodegen generate` → `PCAPhobos.xcodeproj`. Проверка без телефона: `Tests/ProfileCheck`.
Сборка: `xcodegen generate` → `PCAPhobos.xcodeproj`. Проверка без телефона: `Tests/ProfileCheck`, режим «Облако» релея — `Tests/RelayCheck/main.c`.
## Установка на iPhone (нужен ПЛАТНЫЙ Apple Developer, $99/год)
Network Extension (packet-tunnel-provider) Apple выдаёт только платным аккаунтам — с бесплатным

View File

@@ -60,6 +60,17 @@ struct PhobosProfile {
return nil
}
/// "# pca-ws = wss://…" — the user's Keenetic (KeenDNS cloud subdomain -> wsgw), channel «Облако».
var wsURL: String? {
for rawLine in raw.split(separator: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("#"), line.contains("pca-ws"), let eq = line.firstIndex(of: "=") {
return line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
}
}
return nil
}
func obfuscatorConfig(listen: Int, target: String) -> String {
var out = "[instance]\nsource-if = 127.0.0.1\nsource-lport = \(listen)\ntarget = \(target)\n"
for item in lines("[instance]") where !["source-if", "source-lport", "target", "verbose"].contains(item.key) {
@@ -70,7 +81,13 @@ struct PhobosProfile {
func wireGuardConfig(endpoint: String, allowedIPs: String?) -> String {
var out = "[Interface]\n"
for item in lines("[Interface]") where !item.key.hasSuffix("applications") { out += item.line + "\n" }
for item in lines("[Interface]") where !item.key.hasSuffix("applications") && item.key != "mtu" { out += item.line + "\n" }
// ponytail: obfuscator masking adds bytes; the panel's default 1420 stalls on mobile networks,
// so 1420/missing -> 1280, any other MTU in the link wins (set per client in the panel)
let linkMTU = lines("[Interface]").first { $0.key == "mtu" }
.flatMap { Int($0.line.split(separator: "=").last?.trimmingCharacters(in: .whitespaces) ?? "") }
let mtu = (linkMTU == nil || linkMTU == 1420) ? 1280 : linkMTU!
out += "MTU = \(mtu)\n"
out += "\n[Peer]\n"
for item in lines("[Peer]") {
switch item.key {

View File

@@ -35,4 +35,7 @@ let obf = p.obfuscatorConfig(listen: 13255, target: "127.0.0.1:13258")
precondition(obf.contains("target = 127.0.0.1:13258") && obf.contains("key = testkey") && !obf.contains("203.0.113.10"))
let wg = p.wireGuardConfig(endpoint: "127.0.0.1:13255", allowedIPs: "1.0.0.0/8")
precondition(wg.contains("Endpoint = 127.0.0.1:13255") && wg.contains("AllowedIPs = 1.0.0.0/8") && !wg.contains("= none"))
precondition(wg.contains("MTU = 1280") && !wg.contains("1420")) // panel default -> 1280
let custom = PhobosProfile.fromLink("phobos://" + Data(conf.replacingOccurrences(of: "MTU = 1420", with: "MTU = 1300").utf8).base64EncodedString())!.0
precondition(custom.wireGuardConfig(endpoint: "e", allowedIPs: nil).contains("MTU = 1300")) // link MTU wins
print("profile_check ok")

View File

@@ -0,0 +1,22 @@
/* Mode «Облако» of pcarelay without a phone: cc -I Tunnel -o /tmp/rc Tests/RelayCheck/main.c Tunnel/pcarelay.c && /tmp/rc */
#include "pcarelay.h"
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int wgobf_main(int c, char **v) { return 0; }
static void out(const unsigned char *p, int n) { unsigned char r[64]; memcpy(r, p, n); r[0] = 'R'; pcarelay_ws_in(r, n); }
int main(void) {
pcarelay_start(13299, "127.0.0.1", 9, 0);
pcarelay_use_ws(out);
usleep(200000);
assert(pcarelay_is_ws() && !pcarelay_is_tcp());
int s = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(13299), .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
struct timeval tv = {2, 0}; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
sendto(s, "ping", 4, 0, (struct sockaddr *)&a, sizeof a);
char b[16] = {0}; int n = recv(s, b, sizeof b, 0);
assert(n == 4 && !memcmp(b, "Ring", 4) && pcarelay_rx_bytes() == 4);
puts("pcarelay ws: ok");
}

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

View File

@@ -12,6 +12,11 @@ import WireGuardKitC
/// A type alias for `Result` type that holds a tuple with source and resolved endpoint.
typealias EndpointResolutionResult = Result<(Endpoint, Endpoint), DNSResolutionError>
/// PCA Phobos: IPv4 CIDRs that must not go into the tunnel (set before adapter.start).
public enum PhobosRoutes {
public static var excludedIPv4: [String] = []
}
class PacketTunnelSettingsGenerator {
let tunnelConfiguration: TunnelConfiguration
let resolvedEndpoints: [Endpoint?]
@@ -118,6 +123,11 @@ class PacketTunnelSettingsGenerator {
let ipv4Settings = NEIPv4Settings(addresses: ipv4Addresses.map { $0.destinationAddress }, subnetMasks: ipv4Addresses.map { $0.destinationSubnetMask })
ipv4Settings.includedRoutes = ipv4IncludedRoutes
// PCA Phobos: "Russian sites direct" = these networks bypass the tunnel
ipv4Settings.excludedRoutes = PhobosRoutes.excludedIPv4.compactMap { cidr in
guard let range = IPAddressRange(from: cidr) else { return nil }
return NEIPv4Route(destinationAddress: "\(range.address)", subnetMask: "\(range.subnetMask())")
}
networkSettings.ipv4Settings = ipv4Settings
let ipv6Settings = NEIPv6Settings(addresses: ipv6Addresses.map { $0.destinationAddress }, networkPrefixLengths: ipv6Addresses.map { $0.destinationNetworkPrefixLength })

View File

@@ -10,9 +10,9 @@ packages:
path: WireGuardKit
settings:
base:
MARKETING_VERSION: "1.4.1"
CURRENT_PROJECT_VERSION: "141"
DEVELOPMENT_TEAM: ""
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "6"
DEVELOPMENT_TEAM: Q5FKU39TUA
CODE_SIGN_STYLE: Automatic
targets:
PCAPhobos:
@@ -33,8 +33,12 @@ targets:
path: App/Info.plist
properties:
CFBundleDisplayName: PCA Phobos
ITSAppUsesNonExemptEncryption: false
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
UILaunchScreen: {}
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
UIRequiresFullScreen: true
CFBundleURLTypes:
- CFBundleURLName: com.bobyrev.pcaphobos.link
CFBundleURLSchemes: [phobos]
@@ -50,6 +54,7 @@ targets:
- Tunnel/TunnelConfiguration+WgQuickConfig.swift
- Tunnel/String+ArrayConversion.swift
- Tunnel/pcarelay.c
- Tunnel/WsLink.swift
- Shared
- path: Tunnel/ru_bypass.txt
buildPhase: resources
@@ -66,6 +71,8 @@ targets:
path: Tunnel/Info.plist
properties:
CFBundleDisplayName: PhobosTunnel
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider