142 lines
6.5 KiB
Swift
142 lines
6.5 KiB
Swift
import Foundation
|
|
|
|
public enum VPNStoreError: Error, LocalizedError {
|
|
case extractFailed(String)
|
|
case noConfigsFound
|
|
case ioError(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .extractFailed(let s): "Couldn't unpack the archive: \(s)"
|
|
case .noConfigsFound: "No .ovpn configs were found in that file."
|
|
case .ioError(let s): "File error: \(s)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Manages the on-disk store of imported OpenVPN configs at
|
|
/// `~/.config/tv-anarchy/vpn/<group>/<name>.ovpn` (sidecar certs alongside). The
|
|
/// directory scan IS the source of truth (no separate manifest) — same pattern as
|
|
/// the library index. All methods touch the filesystem; call off the main thread.
|
|
public enum VPNConfigStore {
|
|
private static let fm = FileManager.default
|
|
|
|
public static func rootURL() -> URL {
|
|
fm.homeDirectoryForCurrentUser.appendingPathComponent(".config/tv-anarchy/vpn", isDirectory: true)
|
|
}
|
|
|
|
private static func groupURL(_ group: String) -> URL {
|
|
rootURL().appendingPathComponent(OVPNParser.sanitizeGroup(group), isDirectory: true)
|
|
}
|
|
|
|
// MARK: read
|
|
|
|
/// Every imported profile, scanned from disk, grouped dir → profiles.
|
|
public static func list() -> [OVPNProfile] {
|
|
guard let groups = try? fm.contentsOfDirectory(at: rootURL(),
|
|
includingPropertiesForKeys: nil) else { return [] }
|
|
var out: [OVPNProfile] = []
|
|
for groupDir in groups where (try? groupDir.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true {
|
|
let group = groupDir.lastPathComponent
|
|
let files = (try? fm.contentsOfDirectory(at: groupDir, includingPropertiesForKeys: nil)) ?? []
|
|
for file in files where file.pathExtension.lowercased() == "ovpn" {
|
|
out.append(profile(for: file, group: group))
|
|
}
|
|
}
|
|
return out.sorted { ($0.group, $0.name) < ($1.group, $1.name) }
|
|
}
|
|
|
|
public static func groups() -> [String] {
|
|
Array(Set(list().map(\.group))).sorted()
|
|
}
|
|
|
|
private static func profile(for fileURL: URL, group: String) -> OVPNProfile {
|
|
let name = fileURL.deletingPathExtension().lastPathComponent
|
|
let parsed = (try? String(contentsOf: fileURL, encoding: .utf8)).map(OVPNParser.parse) ?? OVPNParser.Parsed()
|
|
return OVPNProfile(id: "\(group)/\(fileURL.lastPathComponent)", name: name, group: group,
|
|
fileURL: fileURL, remote: parsed.remote, proto: parsed.proto,
|
|
requiresAuth: parsed.requiresAuth, inlineCerts: parsed.inlineCerts)
|
|
}
|
|
|
|
// MARK: import
|
|
|
|
/// Import loose `.ovpn` files (and any sidecar certs they reference) into `group`.
|
|
@discardableResult
|
|
public static func importFiles(_ urls: [URL], group: String = "imported") throws -> [OVPNProfile] {
|
|
let dest = groupURL(group)
|
|
try fm.createDirectory(at: dest, withIntermediateDirectories: true)
|
|
var imported: [OVPNProfile] = []
|
|
for src in urls where src.pathExtension.lowercased() == "ovpn" {
|
|
try harvest(ovpn: src, into: dest)
|
|
imported.append(profile(for: dest.appendingPathComponent(src.lastPathComponent), group: group))
|
|
}
|
|
guard !imported.isEmpty else { throw VPNStoreError.noConfigsFound }
|
|
return imported
|
|
}
|
|
|
|
/// Import a provider zip bundle: unpack, find every `.ovpn` (recursively), carry
|
|
/// each one plus its referenced sidecar certs into a group named after the zip.
|
|
@discardableResult
|
|
public static func importZip(_ zipURL: URL) throws -> [OVPNProfile] {
|
|
let group = OVPNParser.sanitizeGroup(zipURL.deletingPathExtension().lastPathComponent)
|
|
let tmp = fm.temporaryDirectory.appendingPathComponent("tva-vpn-\(UUID().uuidString)", isDirectory: true)
|
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
|
defer { try? fm.removeItem(at: tmp) }
|
|
|
|
// `ditto -x -k` extracts a zip natively on macOS (no PATH/unzip dependency).
|
|
let r = ProcessRunner.run("/usr/bin/ditto", ["-x", "-k", zipURL.path, tmp.path])
|
|
guard r.ok else { throw VPNStoreError.extractFailed(r.stderr.isEmpty ? "ditto exit \(r.status)" : r.stderr) }
|
|
|
|
let ovpns = ovpnFiles(under: tmp)
|
|
guard !ovpns.isEmpty else { throw VPNStoreError.noConfigsFound }
|
|
|
|
let dest = groupURL(group)
|
|
try fm.createDirectory(at: dest, withIntermediateDirectories: true)
|
|
var imported: [OVPNProfile] = []
|
|
for src in ovpns {
|
|
try harvest(ovpn: src, into: dest)
|
|
imported.append(profile(for: dest.appendingPathComponent(src.lastPathComponent), group: group))
|
|
}
|
|
return imported
|
|
}
|
|
|
|
/// Copy one `.ovpn` and the sidecar files it references (resolved relative to the
|
|
/// config's own directory) into `dest`, flattening to basenames so the config's
|
|
/// relative `ca ca.crt` references still resolve next to it. Overwrites on repeat.
|
|
private static func harvest(ovpn src: URL, into dest: URL) throws {
|
|
let text = (try? String(contentsOf: src, encoding: .utf8)) ?? ""
|
|
copy(src, into: dest)
|
|
let srcDir = src.deletingLastPathComponent()
|
|
for ref in OVPNParser.parse(text).sidecarRefs {
|
|
let refURL = srcDir.appendingPathComponent(ref)
|
|
if fm.fileExists(atPath: refURL.path) { copy(refURL, into: dest) }
|
|
}
|
|
}
|
|
|
|
private static func copy(_ src: URL, into dest: URL) {
|
|
let target = dest.appendingPathComponent(src.lastPathComponent)
|
|
try? fm.removeItem(at: target)
|
|
try? fm.copyItem(at: src, to: target)
|
|
}
|
|
|
|
/// Recursively collect `.ovpn` files under a directory.
|
|
static func ovpnFiles(under dir: URL) -> [URL] {
|
|
guard let en = fm.enumerator(at: dir, includingPropertiesForKeys: nil) else { return [] }
|
|
return en.compactMap { $0 as? URL }.filter { $0.pathExtension.lowercased() == "ovpn" }
|
|
}
|
|
|
|
// MARK: delete
|
|
|
|
public static func delete(_ profile: OVPNProfile) throws {
|
|
do { try fm.removeItem(at: profile.fileURL) }
|
|
catch { throw VPNStoreError.ioError(error.localizedDescription) }
|
|
// Drop the group dir if it's now empty (sidecars-only leftovers included).
|
|
let groupDir = profile.fileURL.deletingLastPathComponent()
|
|
if VPNConfigStore.ovpnFiles(under: groupDir).isEmpty { try? fm.removeItem(at: groupDir) }
|
|
}
|
|
|
|
public static func deleteGroup(_ group: String) throws {
|
|
do { try fm.removeItem(at: groupURL(group)) }
|
|
catch { throw VPNStoreError.ioError(error.localizedDescription) }
|
|
}
|
|
}
|