Meshtastic-Apple/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift
niccellular 2a9f3d571a Initial TAK Server implementation for IOS based TAK clients
This is my initial implementation for a TAK Server running inside Meshtastic-Apple.
2026-01-05 12:45:52 -05:00

261 lines
9.6 KiB
Swift

//
// TAKDataPackageGenerator.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import Foundation
import OSLog
/// Generates TAK data packages (.zip) for configuring TAK clients like ITAK
/// to connect to the Meshtastic TAK server
final class TAKDataPackageGenerator {
static let shared = TAKDataPackageGenerator()
private init() {}
// MARK: - Data Package Generation
/// Generate a TAK data package for ITAK client configuration
/// - Parameters:
/// - serverHost: The server hostname/IP (default: 127.0.0.1 for localhost)
/// - port: The server port
/// - useTLS: Whether to use TLS (ssl) with mTLS or plain TCP
/// - description: Description shown in TAK client
/// - Returns: URL to the generated zip file, or nil if generation failed
func generateDataPackage(
serverHost: String = "127.0.0.1",
port: Int,
useTLS: Bool = true,
description: String = "Meshtastic TAK Server"
) -> URL? {
let fileManager = FileManager.default
// Create temporary directory for package contents
let packageName = "Meshtastic_TAK_Server"
let tempDir = fileManager.temporaryDirectory.appendingPathComponent(packageName)
do {
// Clean up any existing temp directory
if fileManager.fileExists(atPath: tempDir.path) {
try fileManager.removeItem(at: tempDir)
}
try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true)
// Create certs subdirectory (matches working data package structure)
let certsDir = tempDir.appendingPathComponent("certs")
try fileManager.createDirectory(at: certsDir, withIntermediateDirectories: true)
// Generate preference file in certs directory
let prefFileName = "meshtastic-server.pref"
let configPref = generateConfigPref(
serverHost: serverHost,
port: port,
useTLS: useTLS,
description: description
)
let configPrefURL = certsDir.appendingPathComponent(prefFileName)
try configPref.write(to: configPrefURL, atomically: true, encoding: .utf8)
Logger.tak.debug("Created certs/\(prefFileName)")
// Copy certificates (only needed for TLS/mTLS mode)
if useTLS {
// Truststore (server cert for verifying server) - uses custom if available
if let serverP12Data = TAKCertificateManager.shared.getActiveServerP12Data() {
let truststoreURL = certsDir.appendingPathComponent("truststore.p12")
try serverP12Data.write(to: truststoreURL)
Logger.tak.debug("Created certs/truststore.p12 (custom: \(TAKCertificateManager.shared.hasCustomServerCertificate()))")
} else {
Logger.tak.warning("No server certificate data available")
}
// Client certificate for mTLS - uses custom if available
if let clientP12Data = TAKCertificateManager.shared.getActiveClientP12Data() {
let clientURL = certsDir.appendingPathComponent("client.p12")
try clientP12Data.write(to: clientURL)
Logger.tak.debug("Created certs/client.p12 (custom: \(TAKCertificateManager.shared.hasCustomClientP12()))")
} else {
Logger.tak.warning("No client certificate data available")
}
}
// Generate manifest.xml at root level (not in subdirectory)
let manifest = generateManifest(description: description, useTLS: useTLS, prefFileName: prefFileName)
let manifestURL = tempDir.appendingPathComponent("manifest.xml")
try manifest.write(to: manifestURL, atomically: true, encoding: .utf8)
Logger.tak.debug("Created manifest.xml")
// Create the zip file in Documents directory for better share sheet compatibility
let zipFileName = "\(packageName).zip"
guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
Logger.tak.error("Could not get Documents directory")
return nil
}
let zipURL = documentsDir.appendingPathComponent(zipFileName)
// Remove existing zip if present
if fileManager.fileExists(atPath: zipURL.path) {
try fileManager.removeItem(at: zipURL)
}
// Create zip archive
try createZipArchive(from: tempDir, to: zipURL)
// Verify zip was created
guard fileManager.fileExists(atPath: zipURL.path) else {
Logger.tak.error("ZIP file was not created")
return nil
}
// Cleanup temp directory
try? fileManager.removeItem(at: tempDir)
Logger.tak.info("Generated TAK data package: \(zipURL.path)")
return zipURL
} catch {
Logger.tak.error("Failed to generate TAK data package: \(error.localizedDescription)")
try? fileManager.removeItem(at: tempDir)
return nil
}
}
// MARK: - Pref File Generation (matches working TAK data package format)
private func generateConfigPref(serverHost: String, port: Int, useTLS: Bool, description: String) -> String {
let protocolType = useTLS ? "ssl" : "tcp"
// Use active certificate passwords (custom if available, otherwise bundled)
let serverPassword = TAKCertificateManager.shared.getActiveServerCertificatePassword()
let clientPassword = TAKCertificateManager.shared.getActiveClientCertificatePassword()
if useTLS {
// TLS mode with mTLS (mutual TLS with client certificate)
return """
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
<preferences>
<preference version="1" name="cot_streams">
<entry key="count" class="class java.lang.Integer">1</entry>
<entry key="description0" class="class java.lang.String">\(escapeXML(description))</entry>
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
<entry key="connectString0" class="class java.lang.String">\(serverHost):\(port):\(protocolType)</entry>
</preference>
<preference version="1" name="com.atakmap.app_preferences">
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
<entry key="caLocation" class="class java.lang.String">cert/truststore.p12</entry>
<entry key="caPassword" class="class java.lang.String">\(serverPassword)</entry>
<entry key="certificateLocation" class="class java.lang.String">cert/client.p12</entry>
<entry key="clientPassword" class="class java.lang.String">\(clientPassword)</entry>
</preference>
</preferences>
"""
} else {
// TCP mode - no certificates needed
return """
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
<preferences>
<preference version="1" name="cot_streams">
<entry key="count" class="class java.lang.Integer">1</entry>
<entry key="description0" class="class java.lang.String">\(escapeXML(description))</entry>
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
<entry key="connectString0" class="class java.lang.String">\(serverHost):\(port):\(protocolType)</entry>
</preference>
<preference version="1" name="com.atakmap.app_preferences">
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
</preference>
</preferences>
"""
}
}
// MARK: - Manifest Generation (matches working TAK data package format)
private func generateManifest(description: String, useTLS: Bool, prefFileName: String) -> String {
let uid = UUID().uuidString
if useTLS {
// TLS mode with mTLS - includes truststore and client certificate
return """
<MissionPackageManifest version="2">
<Configuration>
<Parameter name="uid" value="\(uid)"/>
<Parameter name="name" value="Meshtastic_TAK_Server"/>
<Parameter name="onReceiveDelete" value="true"/>
</Configuration>
<Contents>
<Content ignore="false" zipEntry="certs\\\(prefFileName)"/>
<Content ignore="false" zipEntry="certs\\truststore.p12"/>
<Content ignore="false" zipEntry="certs\\client.p12"/>
</Contents>
</MissionPackageManifest>
"""
} else {
// TCP mode - just the pref file
return """
<MissionPackageManifest version="2">
<Configuration>
<Parameter name="uid" value="\(uid)"/>
<Parameter name="name" value="Meshtastic_TAK_Server"/>
<Parameter name="onReceiveDelete" value="true"/>
</Configuration>
<Contents>
<Content ignore="false" zipEntry="certs\\\(prefFileName)"/>
</Contents>
</MissionPackageManifest>
"""
}
}
// MARK: - Helper Methods
private func escapeXML(_ string: String) -> String {
return string
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
// MARK: - ZIP Archive Creation
/// Create a ZIP archive from a directory
private func createZipArchive(from sourceDir: URL, to destinationURL: URL) throws {
let fileManager = FileManager.default
var copyError: Error?
// Use NSFileCoordinator to create zip - this is the built-in approach on iOS
var coordinatorError: NSError?
let coordinator = NSFileCoordinator()
Logger.tak.debug("Creating ZIP from: \(sourceDir.path)")
coordinator.coordinate(
readingItemAt: sourceDir,
options: .forUploading,
error: &coordinatorError
) { zipURL in
Logger.tak.debug("Coordinator provided ZIP at: \(zipURL.path)")
do {
// The coordinator creates a temporary zip, copy it to our destination
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
try fileManager.copyItem(at: zipURL, to: destinationURL)
Logger.tak.debug("Copied ZIP to: \(destinationURL.path)")
} catch {
Logger.tak.error("Failed to copy ZIP: \(error.localizedDescription)")
copyError = error
}
}
if let coordinatorError = coordinatorError {
Logger.tak.error("Coordinator error: \(coordinatorError.localizedDescription)")
throw coordinatorError
}
if let copyError = copyError {
throw copyError
}
}
}