Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion LocalPackages/PIALibrary/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ let package = Package(
.testTarget(
name: "PIALibraryTests",
dependencies: [
"PIALibrary"
"PIALibrary",
.product(
name: "TunnelKitOpenVPN",
package: "mobile-ios-openvpn",
condition: .when(platforms: [.iOS, .macCatalyst])
)
],
resources: [
.process("Resources")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public final class MockVPNProvider: VPNProvider, ConfigurationAccess, DatabaseAc
callback?(nil)
}

/// :nodoc:
public func obtainVPNPermission(_ callback: SuccessLibraryCallback?) {
Macros.postNotification(.PIAVPNDidInstall)
callback?(nil)
}

/// :nodoc:
public func uninstall(_ callback: SuccessLibraryCallback?) {
callback?(nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ public final class DefaultVPNProvider: VPNProvider, ConfigurationAccess, Databas
}

public func install(force forceInstall: Bool, _ callback: SuccessLibraryCallback?) {
install(force: forceInstall, allowServerPlaceholder: false, callback)
}

public func obtainVPNPermission(_ callback: SuccessLibraryCallback?) {
install(force: true, allowServerPlaceholder: true, callback)
}

private func install(force forceInstall: Bool, allowServerPlaceholder: Bool, _ callback: SuccessLibraryCallback?) {
guard accessedProviders.accountProvider.isLoggedIn else {
callback?(ClientError.unauthorized)
return
Expand All @@ -160,7 +168,7 @@ public final class DefaultVPNProvider: VPNProvider, ConfigurationAccess, Databas

let forcedStatuses = DefaultVPNProvider.forcedStatuses.contains(accessedDatabase.transient.vpnStatus)
let installBlock: SuccessLibraryCallback = { (error) in
guard let configuration = self.vpnClientConfiguration(for: profile) else {
guard let configuration = self.vpnClientConfiguration(for: profile, allowServerPlaceholder: allowServerPlaceholder) else {
callback?(ClientError.vpnProfileUnavailable)
return
}
Expand Down Expand Up @@ -400,7 +408,7 @@ public final class DefaultVPNProvider: VPNProvider, ConfigurationAccess, Databas
return activeProfile
}

private func vpnClientConfiguration(for profile: VPNProfile? = nil) -> VPNConfiguration? {
private func vpnClientConfiguration(for profile: VPNProfile? = nil, allowServerPlaceholder: Bool = false) -> VPNConfiguration? {
log.info("vpnClientConfiguration: currentUser=\(accessedProviders.accountProvider.currentUser != nil), currentPasswordReference=\(accessedProviders.accountProvider.currentPasswordReference != nil), activeProfile=\(String(describing: (profile ?? activeProfile)?.vpnType))")

guard let currentUser = accessedProviders.accountProvider.currentUser else {
Expand All @@ -418,7 +426,19 @@ public final class DefaultVPNProvider: VPNProvider, ConfigurationAccess, Databas
return nil
}

guard let targetServer = try? accessedProviders.serverProvider.targetServer else {
let targetServer: Server
var usesServerPlaceholder = false
if let resolvedServer = try? accessedProviders.serverProvider.targetServer {
targetServer = resolvedServer
} else if allowServerPlaceholder {
// The server list is not available yet (e.g. right after signup, before
// the first download completes). Use a placeholder so the profile can
// still be saved to obtain the OS VPN permission; connect() re-resolves
// the real server and re-saves before starting the tunnel.
log.warning("vpnClientConfiguration: No target server available, using permission placeholder")
targetServer = .vpnPermissionPlaceholder
usesServerPlaceholder = true
} else {
log.error("vpnClientConfiguration: No target server available")
return nil
}
Comment thread
kp-nils-crefeld marked this conversation as resolved.
Expand All @@ -430,7 +450,10 @@ public final class DefaultVPNProvider: VPNProvider, ConfigurationAccess, Databas
username: currentUser.credentials.username,
passwordReference: currentPasswordReference,
server: targetServer,
isOnDemand: accessedPreferences.isPersistentConnection,
// A placeholder profile must never carry on-demand rules: if an enabled
// manager already exists, doSave would honor them and the OS could try
// to bring up a tunnel to the placeholder endpoint on its own.
isOnDemand: usesServerPlaceholder ? false : accessedPreferences.isPersistentConnection,
disconnectsOnSleep: accessedPreferences.vpnDisconnectsOnSleep,
customConfiguration: customConfiguration,
leakProtection: accessedPreferences.leakProtection,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,14 @@
customCfg = builder.build()
}

if serverAddress.isEmpty {
// No resolved address for the active socket type (e.g. the server
// list is not downloaded yet and a placeholder server is used to
// obtain the OS VPN permission). Fall back to the hostname like
// WireGuard and IKEv2 do, instead of persisting an empty endpoint.
serverAddress = configuration.server.hostname
}
Comment thread
kp-nils-crefeld marked this conversation as resolved.

var username = configuration.username
var passwordReference = configuration.passwordReference

Expand Down
10 changes: 10 additions & 0 deletions LocalPackages/PIALibrary/Sources/PIALibrary/VPN/VPNProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ public protocol VPNProvider: AnyObject {
*/
func install(force forceInstall: Bool, _ callback: SuccessLibraryCallback?)

/**
Obtains the one-time OS VPN permission by saving a profile to the system
preferences. Unlike `install(force:_:)`, this succeeds even when the server
list has not been downloaded yet (a placeholder endpoint is used); the real
target server is resolved and re-saved on the next `connect(_:)`.

- Parameter callback: Returns `nil` on success.
Comment thread
kp-nils-crefeld marked this conversation as resolved.
*/
func obtainVPNPermission(_ callback: SuccessLibraryCallback?)

/**
Disables the current profile.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// Server+PermissionPlaceholder.swift
// PIALibrary
//
// Copyright © 2026 Private Internet Access, Inc.
//
// This file is part of the Private Internet Access iOS Client.
//
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any later version.
//
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with the Private
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
//

import Foundation

extension Server {

/// Placeholder server used exclusively to obtain the one-time OS VPN permission
/// (`NEVPNManager.saveToPreferences()`) when the real server list has not been
/// downloaded yet — e.g. right after a fresh signup, when logout wiped the cache.
///
/// The saved configuration is inert: `connect()` always re-resolves the real
/// `targetServer` and re-saves the profile before starting the tunnel, so this
/// endpoint is never actually dialed.
///
/// The hostname uses the RFC 6761 reserved `.invalid` TLD: it can never resolve
/// and never collides with PIA-domain checks such as `needsMigrationToGEN4()`.
/// Computed (not `static let`) because `Server` is a mutable class — each access
/// returns a fresh instance instead of a shared, mutable process-wide one.
static var vpnPermissionPlaceholder: Server {
Server(
serial: "",
name: "VPN Permission Placeholder",
country: "us",
hostname: "vpn-permission-placeholder.invalid",
pingAddress: nil,
regionIdentifier: "vpn-permission-placeholder"
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// VPNPermissionPlaceholderTests.swift
// PIALibraryTests
//
// Copyright © 2026 Private Internet Access, Inc.
//
// This file is part of the Private Internet Access iOS Client.
//
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any later version.
//
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with the Private
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
//

// PIATunnelProfile and PIAWGTunnelProfile are only declared for iOS.
#if os(iOS)

import TunnelKitCore
import TunnelKitOpenVPN
import XCTest

@testable import PIALibrary

/// Locks the invariant behind the VPN-permission placeholder (KM-17461): a profile
/// generated from `Server.vpnPermissionPlaceholder` must always carry a non-empty
/// `serverAddress`, so the OS profile save that grants the one-time VPN permission
/// cannot fail or persist an empty endpoint when the server list is unavailable.
class VPNPermissionPlaceholderTests: XCTestCase {

private var previousLastServerCN: String?

override func setUp() {
super.setUp()

Client.database = Client.Database(group: "group.com.privateinternetaccess")
Client.providers.accountProvider = MockAccountProvider()
Client.providers.vpnProvider = MockVPNProvider()

// generatedProtocol persists lastServerCN as a side effect; keep other
// tests isolated from the placeholder's empty CN.
previousLastServerCN = Client.database.plain.lastServerCN
}

override func tearDown() {
Client.database.plain.lastServerCN = previousLastServerCN
super.tearDown()
}

private func makeConfiguration(customConfiguration: VPNCustomConfiguration? = nil) -> VPNConfiguration {
VPNConfiguration(
name: "PIA Test",
username: "p0000000",
passwordReference: Data(),
server: .vpnPermissionPlaceholder,
isOnDemand: false,
disconnectsOnSleep: false,
customConfiguration: customConfiguration,
leakProtection: false,
allowLocalDeviceAccess: false
)
}

/// Mirrors the session configuration the app passes in production
/// (`AppConfiguration.VPN.piaDefaultConfigurationBuilder`), so the test walks the
/// real `endpointProtocols` / `bestAddressForOVPN(tcp:)` branch and not just the
/// trivial no-custom-configuration path.
private func makeOpenVPNConfiguration() -> OpenVPNProvider.Configuration {
var sessionBuilder = OpenVPN.ConfigurationBuilder()
sessionBuilder.cipher = .aes128gcm
sessionBuilder.digest = .sha256
sessionBuilder.endpointProtocols = [
EndpointProtocol(.udp, 8080),
EndpointProtocol(.tcp, 443)
]
sessionBuilder.usesPIAPatches = true
return OpenVPNProvider.ConfigurationBuilder(sessionConfiguration: sessionBuilder.build()).build()
}

func testPlaceholderServerHasNonEmptyHostname() {
XCTAssertFalse(Server.vpnPermissionPlaceholder.hostname.isEmpty)
// RFC 6761 reserved TLD: never resolves, never matches PIA-domain checks
// such as needsMigrationToGEN4().
XCTAssertTrue(Server.vpnPermissionPlaceholder.hostname.hasSuffix(".invalid"))
XCTAssertFalse(Server.vpnPermissionPlaceholder.hostname.contains("privateinternetaccess.com"))
}

func testWireGuardGeneratedProtocolFallsBackToPlaceholderHostname() throws {
let profile = PIAWGTunnelProfile(bundleIdentifier: "com.test.wg-tunnel")
let proto = try profile.generatedProtocol(withConfiguration: makeConfiguration())

XCTAssertEqual(proto.serverAddress, Server.vpnPermissionPlaceholder.hostname)
}

func testOpenVPNGeneratedProtocolFallsBackToPlaceholderHostname() {
let profile = PIATunnelProfile(bundleIdentifier: "com.test.ovpn-tunnel")
let configuration = makeConfiguration(customConfiguration: makeOpenVPNConfiguration())
let proto = profile.generatedProtocol(withConfiguration: configuration)

XCTAssertEqual(proto.serverAddress, Server.vpnPermissionPlaceholder.hostname)
XCTAssertNotEqual(proto.serverAddress, "")
}
}

#endif
20 changes: 12 additions & 8 deletions PIA VPN/UI/Dashboard/VPNPermissionViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,19 @@ final class VPNPermissionViewController: AutolayoutViewController {

@IBAction private func submit() {
let vpn = Client.providers.vpnProvider
vpn.install(
force: true,
{ (error) in
vpn.obtainVPNPermission { [weak self] (error) in
// saveToPreferences does not document its completion queue; hop to
// main before touching UIKit.
DispatchQueue.main.async {
guard let self else { return }
if let error {
log.error("Failed to install VPN: \(error)")
log.error("Failed to obtain VPN permission: \(error)")
self.alertRequiredPermission()
return
}
self.dismissingViewController?.dismiss(animated: true)
})
}
}
}
Comment thread
kp-nils-crefeld marked this conversation as resolved.

private func alertRequiredPermission() {
Expand All @@ -96,9 +99,10 @@ final class VPNPermissionViewController: AutolayoutViewController {
self.contactCustomerSupport()
}
}
alert.addCancelActionWithTitle(L10n.Global.ok) {
self.submit()
}
// Just dismiss the alert. The permission screen stays on screen, so the
// user can retry via its OK button. Auto-retrying from here created an
// undismissable alert loop when the install failed instantly (KM-17461).
alert.addCancelActionWithTitle(L10n.Global.ok) {}
present(alert, animated: true, completion: nil)
}

Expand Down
Loading