Compare commits

...

5 Commits

11 changed files with 252 additions and 15 deletions
+10
View File
@@ -36,6 +36,15 @@ jobs:
run: |
set -o pipefail
mkdir -p build
python3 -u tools/tls_fixture.py > build/tls-fixture.log 2>&1 &
TLS_PID=$!
trap 'kill "$TLS_PID" 2>/dev/null || true' EXIT
for attempt in {1..30}; do
if grep -q 'TLS fixtures ready' build/tls-fixture.log; then break; fi
if ! kill -0 "$TLS_PID" 2>/dev/null; then cat build/tls-fixture.log; exit 1; fi
sleep 1
done
grep -q 'TLS fixtures ready' build/tls-fixture.log || { cat build/tls-fixture.log; exit 1; }
xcodebuild test \
-project MusicBridge.xcodeproj \
-scheme MusicBridge \
@@ -53,6 +62,7 @@ jobs:
name: ios-test-results
path: |
build/xcodebuild.log
build/tls-fixture.log
build/MusicBridge.xcresult
if-no-files-found: warn
retention-days: 7
+27 -7
View File
@@ -81,11 +81,21 @@
"isa" = "PBXBuildFile";
"fileRef" = "ACD0393DCDDDF044F2966340";
};
"8BED860F1BD59C500D215E8D" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "TLSTests.swift";
"sourceTree" = "<group>";
};
"DC1A5B0B94F4C98F1CA36676" = {
"isa" = "PBXBuildFile";
"fileRef" = "8BED860F1BD59C500D215E8D";
};
"E1AD768E105BF42350625991" = {
"isa" = "PBXGroup";
"path" = "MusicBridgeTests";
"sourceTree" = "<group>";
"children" = ("ACD0393DCDDDF044F2966340", "F16D05EC6B29248D2C61ADB1",);
"children" = ("ACD0393DCDDDF044F2966340", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
};
"F16D05EC6B29248D2C61ADB1" = {
"isa" = "PBXFileReference";
@@ -97,6 +107,16 @@
"isa" = "PBXBuildFile";
"fileRef" = "F16D05EC6B29248D2C61ADB1";
};
"4731785CAB9BB914B8834AE6" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "text.json";
"path" = "tls-fixture.json";
"sourceTree" = "<group>";
};
"7D079549A35384F9CAE8A5C4" = {
"isa" = "PBXBuildFile";
"fileRef" = "4731785CAB9BB914B8834AE6";
};
"7C35A0276A762371ABAE5596" = {
"isa" = "PBXFileReference";
"explicitFileType" = "wrapper.application";
@@ -136,8 +156,8 @@
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "1";
"MARKETING_VERSION" = "0.1.0";
"CURRENT_PROJECT_VERSION" = "2";
"MARKETING_VERSION" = "0.1.1";
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
"ENABLE_TESTABILITY" = "YES";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
@@ -157,8 +177,8 @@
"CODE_SIGN_STYLE" = "Automatic";
"GENERATE_INFOPLIST_FILE" = "NO";
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
"CURRENT_PROJECT_VERSION" = "1";
"MARKETING_VERSION" = "0.1.0";
"CURRENT_PROJECT_VERSION" = "2";
"MARKETING_VERSION" = "0.1.1";
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
};
};
@@ -189,7 +209,7 @@
"AB453A3F0E1490D48374A69A" = {
"isa" = "PBXSourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("17E248E58CB43C680B48DD51",);
"files" = ("17E248E58CB43C680B48DD51", "DC1A5B0B94F4C98F1CA36676",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"9AA2A4C344B5C474AF5D336C" = {
@@ -201,7 +221,7 @@
"5D92C2C610AA78B83DDF2133" = {
"isa" = "PBXResourcesBuildPhase";
"buildActionMask" = 2147483647;
"files" = ("F8A2DE539EBA355A3807D729",);
"files" = ("F8A2DE539EBA355A3807D729", "7D079549A35384F9CAE8A5C4",);
"runOnlyForDeploymentPostprocessing" = 0;
};
"DAB01D73CBBCF1FEDE84E2D6" = {
+17 -1
View File
@@ -9,6 +9,9 @@ final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
#if DEBUG
print("MusicBridge TLS: received server authentication challenge")
#endif
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
challenge.protectionSpace.host == connection.endpoint.host,
challenge.protectionSpace.port == (connection.endpoint.port ?? 443),
@@ -19,18 +22,31 @@ final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate
let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data)
.map { String(format: "%02X", $0) }.joined()
guard digest == connection.fingerprint else {
#if DEBUG
print("MusicBridge TLS: fingerprint mismatch")
#endif
completionHandler(.cancelAuthenticationChallenge, nil); return
}
// Trust only this exact out-of-band certificate, while checking its validity dates.
SecTrustSetAnchorCertificates(trust, [leaf] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, true)
SecTrustSetPolicies(trust, SecPolicyCreateBasicX509())
guard SecTrustEvaluateWithError(trust, nil) else {
var trustError: CFError?
guard SecTrustEvaluateWithError(trust, &trustError) else {
#if DEBUG
print("MusicBridge TLS: pinned certificate rejected: \(String(describing: trustError))")
#endif
completionHandler(.cancelAuthenticationChallenge, nil); return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
func urlSession(_ session: URLSession, task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
urlSession(session, didReceive: challenge, completionHandler: completionHandler)
}
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
+18
View File
@@ -34,6 +34,24 @@
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>10.0.0.0/8</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>172.16.0.0/12</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>192.168.0.0/16</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
+36
View File
@@ -0,0 +1,36 @@
import XCTest
@testable import MusicBridge
final class TLSTests: XCTestCase {
private func connection(_ name: String, wrongPin: Bool = false) throws -> Connection {
let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json"))
let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url))
guard let fixture = fixtures[name] else { throw XCTSkip("Start tools/tls_fixture.py before building TLS tests.") }
return try Connection(endpoint: XCTUnwrap(fixture["endpoint"]),
fingerprint: wrongPin ? String(repeating: "0", count: 64) : XCTUnwrap(fixture["fingerprint"]))
}
func testPinnedSelfSignedHTTPSAndWSS() async throws {
let client = BridgeClient(try connection("valid"))
defer { client.close() }
let state = try await client.state()
XCTAssertEqual(state.protocolVersion, 1)
client.openEvents()
let event = try await client.nextState()
XCTAssertEqual(event.title, state.title)
}
func testWrongFingerprintIsRejected() async throws {
let client = BridgeClient(try connection("valid", wrongPin: true))
defer { client.close() }
do { _ = try await client.state(); XCTFail("Accepted the wrong fingerprint") }
catch { XCTAssertTrue(error is URLError) }
}
func testExpiredPinnedCertificateIsRejected() async throws {
let client = BridgeClient(try connection("expired"))
defer { client.close() }
do { _ = try await client.state(); XCTFail("Accepted an expired certificate") }
catch { XCTAssertTrue(error is URLError) }
}
}
+1
View File
@@ -0,0 +1 @@
{}
+32 -3
View File
@@ -1,6 +1,6 @@
# MusicBridge iOS — план
Обновлено: 2026-09-10. Пользователь разрешил начать нативное iOS-приложение.
Обновлено: 2026-09-12. Пользователь разрешил начать нативное iOS-приложение.
Репозиторий: `C:/Users/areso/iOS MusicBridge`; origin — dev.yukinoki.ru/musicbridge/ios.
Windows-агент — соседний проект `C:/Users/areso/MusicBridge/MusicBridge.Agent`.
@@ -30,9 +30,10 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
- [x] Первый успешный xcodebuild + XCTest на macOS: 7 тестов, 0 отказов.
- [x] Подключён GitHub remote polskikh13/musicbridgeios; Actions и чтение логов через gh работают.
- [ ] Интеграционные тесты TLS: верный/чужой/истёкший сертификат, redirect, WSS.
- [x] Интеграционные тесты TLS: верный/чужой/истёкший сертификат, HTTPS и WSS.
- [ ] Интеграционный тест отказа от redirect.
- [ ] Проверить 401 и отзыв устройства, Keychain после переустановки/блокировки.
- [ ] Настроить подпись и установку на физический iPhone.
- [x] Установить на физический iPhone: пользователь подтвердил установку через iloader 2026-09-12.
- [ ] Проверить LAN/плеер/команды/обложку с настоящим Windows-агентом.
- [ ] Камера для QR, системные разрешения и обработка отказа.
- [ ] Bonjour-поиск `_musicbridge._tcp`, список ПК и обновление адреса при смене IP.
@@ -45,6 +46,34 @@ Windows-агент — соседний проект `C:/Users/areso/MusicBridge
Потеря ответа на успешное сопряжение может оставить устройство на ПК: удалить его
в окне агента перед повтором. Облачный runner не имеет доступа к домашней LAN.
## Проверка на iPhone 2026-09-12
- Windows GUI запущен с `--tray --lan`, порт TCP 8765 слушает.
- На iPhone первая попытка сопряжения завершилась ошибкой недействительного
сертификата сервера. Установка приложения уже работает; это отдельная TLS-проблема.
- Ошибка воспроизведена настоящим HTTPS-запросом в iOS Simulator: старый код
возвращает NSURLErrorDomain -1202 даже с верным отпечатком. Одни ATS-исключения
её не устранили. Исправление: обрабатывать server trust также в
`URLSessionTaskDelegate.didReceive`, направляя его в общую проверку отпечатка
и срока сертификата. После этого HTTPS и WSS проходят.
- Подготовлены ATS-исключения только для RFC1918 CIDR: `10.0.0.0/8`,
`172.16.0.0/12`, `192.168.0.0/16`. Начиная с iOS 17, для IP нужны явные
исключения. Клиент продолжает требовать HTTPS, точный SHA-256 отпечаток,
действующий сертификат и запрещает redirect. Глобального отключения ATS нет.
- Добавлены настоящие HTTPS/WSS проверки в симуляторе с сертификатом того же
типа, что у агента: RSA 2048, самоподписанный, CA:FALSE, SAN localhost/127.0.0.1,
срок пять лет. Отдельно проверяются чужой отпечаток и истёкший сертификат.
`tools/tls_fixture.py` запускается в CI перед сборкой тестов; закрытые ключи
создаются временно на runner и не включаются в приложение или артефакты.
- Сообщение «непригодные данные» при импорте приглашения пока не разобрано:
пользователь попросил отложить его. Следующий ручной шаг — повторить сопряжение
после установки новой IPA, затем проверить команды, прогресс и обложку.
- `93f7c9c`: [Actions 34694138394](https://github.com/polskikh13/musicbridgeios/actions/runs/34694138394)
— 10 XCTest, 0 отказов, успешный Release archive. Версия 0.1.1 (build 2),
IPA скачана в `build/iPhone-0.1.1/MusicBridge-iPhone.ipa`; локально проверены ZIP,
iPhoneOS ARM64, версия и ATS-настройки внутри готовой IPA.
Сопряжение на физическом iPhone после этого исправления ещё не подтверждено.
## Карта
- `MusicBridge/Models.swift`: контракт и проверка входных данных.
+5
View File
@@ -53,6 +53,11 @@ Apple ID и сертификаты для этой проверки не тре
## Установка на iPhone через Sideloadly
Пользователь также подтвердил установку через iloader. Для повторной установки
после исправления TLS используйте версию **0.1.1 (build 2)**:
`build/iPhone-0.1.1/MusicBridge-iPhone.ipa`. Выберите этот файл в iloader и
подпишите тем же Apple Account, которым установлена первая версия.
Workflow также выполняет Release archive для `generic/platform=iOS` и создаёт
`MusicBridge-iPhone.ipa` в артефакте `MusicBridge-iPhone`. Это ARM64-сборка для
iOS 17+, без подписи. Подпись создаёт Sideloadly на вашем ПК с вашим Apple Account.
+12 -3
View File
@@ -39,6 +39,9 @@ test_group, test_sources = source_group("MusicBridgeTests")
fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>")
objects[test_group]["children"].append(fixture)
fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture)
tls_fixture = add("tls-fixture", "PBXFileReference", lastKnownFileType="text.json", path="tls-fixture.json", sourceTree="<group>")
objects[test_group]["children"].append(tls_fixture)
tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture)
products = []
targets = []
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True)]:
@@ -47,7 +50,7 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0),
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[fixture_build] if is_test else [], runOnlyForDeploymentPostprocessing=0)]
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[fixture_build, tls_fixture_build] if is_test else [], runOnlyForDeploymentPostprocessing=0)]
settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".tests" if is_test else ""),
SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="17.0", SDKROOT="iphoneos",
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
@@ -58,7 +61,7 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg
proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
else:
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="1", MARKETING_VERSION="0.1.0")
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="2", MARKETING_VERSION="0.1.1")
targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product,
productType="com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application",
buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
@@ -103,6 +106,12 @@ info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", C
LSRequiresIPhoneOS=True, UILaunchScreen={},
UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"],
NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.",
NSAppTransportSecurity=dict(NSAllowsLocalNetworking=True))
# iOS 17+ requires explicit IP exceptions for custom self-signed trust.
# BridgeClient still requires HTTPS, an exact SHA-256 pin and valid dates.
# Never disable ATS globally or include public IP ranges.
NSAppTransportSecurity=dict(NSAllowsLocalNetworking=True, NSExceptionDomains={
network: dict(NSExceptionAllowsInsecureHTTPLoads=True)
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
}))
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
print("Generated Xcode project, shared scheme and Info.plist.")
+89
View File
@@ -0,0 +1,89 @@
"""CI-only HTTPS/WSS server with the same certificate shape as the Windows agent."""
import base64
import hashlib
import http.server
import ipaddress
import json
import pathlib
import socket
import socketserver
import ssl
import struct
import subprocess
import threading
root = pathlib.Path(__file__).resolve().parents[1]
work = root / "build/tls"
work.mkdir(parents=True, exist_ok=True)
# Discover the runner's LAN address without sending a packet.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.connect(("192.0.2.1", 80))
address = probe.getsockname()[0]
assert any(ipaddress.ip_address(address) in ipaddress.ip_network(n)
for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")), address
config = work / "cert.cnf"
config.write_text("""[req]
distinguished_name = dn
prompt = no
[dn]
CN = MusicBridge Agent
[ext]
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:localhost,IP:127.0.0.1
""")
state = json.loads((root / "MusicBridgeTests/state-v1.json").read_text())
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/v1/events":
accept = base64.b64encode(hashlib.sha1(
(self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
self.send_response(101)
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept)
self.end_headers()
payload = json.dumps({"type": "state", "state": state}).encode()
self.wfile.write(b"\x81\x7e" + struct.pack("!H", len(payload)) + payload)
self.wfile.flush()
else:
payload = json.dumps(state).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
fixtures = {}
class Server(http.server.ThreadingHTTPServer):
def server_bind(self):
# HTTPServer's reverse DNS lookup can stall on hosted runner addresses.
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address
for name, days in (("valid", "1825"), ("expired", "-1")):
print(f"Generating {name} certificate for {address}", flush=True)
key, csr, cert = (work / (name + suffix) for suffix in (".key", ".csr", ".pem"))
subprocess.run(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", str(config),
"-keyout", str(key), "-out", str(csr)], check=True, capture_output=True, timeout=15)
subprocess.run(["openssl", "x509", "-req", "-in", str(csr), "-signkey", str(key), "-sha256",
"-days", days, "-extfile", str(config), "-extensions", "ext", "-out", str(cert)],
check=True, capture_output=True, timeout=15)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(cert, key)
server = Server((address, 0), Handler)
server.socket = context.wrap_socket(server.socket, server_side=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
fixtures[name] = {"endpoint": f"https://{address}:{server.server_port}",
"fingerprint": hashlib.sha256(ssl.PEM_cert_to_DER_cert(cert.read_text())).hexdigest()}
(root / "MusicBridgeTests/tls-fixture.json").write_text(json.dumps(fixtures))
print("TLS fixtures ready", flush=True)
threading.Event().wait()
+5 -1
View File
@@ -25,7 +25,11 @@ referenced = set(re.findall(r'"([A-F0-9]{24})"', project))
assert defined == referenced, "Project has dangling object references."
info = plistlib.loads(generated[2].read_bytes())
assert info["NSLocalNetworkUsageDescription"]
assert info["NSAppTransportSecurity"] == {"NSAllowsLocalNetworking": True}
assert info["NSAppTransportSecurity"] == {
"NSAllowsLocalNetworking": True,
"NSExceptionDomains": {network: {"NSExceptionAllowsInsecureHTTPLoads": True}
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")},
}
ET.parse(generated[1])
fixture = json.loads((root / "MusicBridgeTests/state-v1.json").read_text(encoding="utf-8"))
assert fixture["protocolVersion"] == 1 and fixture["positionSeconds"] == 42.5