Meshtastic-Apple/Meshtastic/Views/Settings/TAKServerConfig.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

390 lines
9.3 KiB
Swift

//
// TAKServerConfig.swift
// Meshtastic
//
// Created by niccellular 12/26/25
//
import SwiftUI
import UniformTypeIdentifiers
import OSLog
enum CertificateImportType {
case p12
case pem
}
struct TAKServerConfig: View {
@StateObject private var takServer = TAKServerManager.shared
@State private var showingFileImporter = false
@State private var importType: CertificateImportType = .p12
@State private var p12Password = ""
@State private var showingPasswordPrompt = false
@State private var pendingP12Data: Data?
@State private var importError: String?
@State private var showingImportError = false
@State private var showingFileExporter = false
@State private var dataPackageURL: URL?
private let certManager = TAKCertificateManager.shared
var body: some View {
Form {
serverStatusSection
serverConfigSection
certificatesSection
dataPackageSection
}
.navigationTitle("TAK Server")
.fileImporter(
isPresented: $showingFileImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false
) { result in
switch importType {
case .p12:
handleP12Import(result)
case .pem:
handlePEMImport(result)
}
}
.alert("Enter P12 Password", isPresented: $showingPasswordPrompt) {
SecureField("Password", text: $p12Password)
Button("Import") {
importP12WithPassword()
}
Button("Cancel", role: .cancel) {
p12Password = ""
pendingP12Data = nil
}
} message: {
Text("Enter the password for the PKCS#12 file")
}
.alert("Import Error", isPresented: $showingImportError) {
Button("OK", role: .cancel) {}
} message: {
Text(importError ?? "Unknown error")
}
.fileExporter(
isPresented: $showingFileExporter,
document: dataPackageURL.map { ZipDocument(url: $0) },
contentType: .zip,
defaultFilename: "Meshtastic_TAK_Server.zip"
) { result in
switch result {
case .success(let url):
Logger.tak.info("Data package saved to: \(url.path)")
case .failure(let error):
importError = "Failed to save: \(error.localizedDescription)"
showingImportError = true
}
// Clean up the source file
if let sourceURL = dataPackageURL {
try? FileManager.default.removeItem(at: sourceURL)
}
dataPackageURL = nil
}
}
// MARK: - Server Status Section
private var serverStatusSection: some View {
Section {
HStack {
Label {
Text("Status")
} icon: {
Circle()
.fill(takServer.isRunning ? .green : .gray)
.frame(width: 10, height: 10)
}
Spacer()
Text(takServer.statusDescription)
.foregroundColor(.secondary)
}
if let error = takServer.lastError {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(error)
.font(.caption)
.foregroundColor(.orange)
}
}
} header: {
Text("Server Status")
}
}
// MARK: - Server Configuration Section
private var serverConfigSection: some View {
Section {
Toggle(isOn: $takServer.enabled) {
Label("Enable TAK Server", systemImage: "antenna.radiowaves.left.and.right")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
HStack {
Label("Port", systemImage: "number")
Spacer()
Text("8089")
.foregroundColor(.secondary)
}
HStack {
Label("Security", systemImage: "lock.fill")
Spacer()
Text("mTLS")
.foregroundColor(.secondary)
}
if takServer.isRunning {
Button {
Task {
try? await takServer.restart()
}
} label: {
Label("Restart Server", systemImage: "arrow.clockwise")
}
}
} header: {
Text("Configuration")
} footer: {
Text("Secure mTLS connection on port 8089. Both server and client certificates are required.")
}
}
// MARK: - Certificates Section
private var certificatesSection: some View {
Section {
// Server Certificate
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Server Certificate", systemImage: "key.fill")
Spacer()
if certManager.hasServerCertificate() {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
} else {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
if let certInfo = certManager.getServerCertificateInfo() {
Text(certInfo)
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
Button {
importType = .p12
showingFileImporter = true
} label: {
Text("Import Custom .p12")
}
.buttonStyle(.bordered)
if certManager.hasCustomServerCertificate() {
Button {
certManager.resetToDefaultServerCertificate()
} label: {
Text("Reset to Default")
}
.buttonStyle(.bordered)
}
}
}
.padding(.vertical, 4)
// Client CA Certificate
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Client CA Certificate", systemImage: "person.badge.shield.checkmark")
Spacer()
if certManager.hasClientCACertificate() {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
} else {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
let caInfo = certManager.getClientCACertificateInfo()
if !caInfo.isEmpty {
ForEach(caInfo, id: \.self) { info in
Text(info)
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack {
Button {
importType = .pem
showingFileImporter = true
} label: {
Text(certManager.hasClientCACertificate() ? "Add CA" : "Import .pem")
}
.buttonStyle(.bordered)
if certManager.hasClientCACertificate() {
Button(role: .destructive) {
certManager.deleteClientCACertificates()
} label: {
Text("Delete All")
}
.buttonStyle(.bordered)
}
}
}
.padding(.vertical, 4)
// Reset to bundled defaults
Button {
certManager.reloadBundledCertificates()
if takServer.isRunning {
Task {
try? await takServer.restart()
}
}
} label: {
Label("Reload Bundled Certificates", systemImage: "arrow.triangle.2.circlepath")
}
} header: {
Text("TLS Certificates")
} footer: {
Text("A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients.")
}
}
// MARK: - Data Package Section
private var dataPackageSection: some View {
Section {
Button {
generateAndShareDataPackage()
} label: {
Label("Download TAK Server Data Package", systemImage: "arrow.down.doc.fill")
}
} header: {
Text("Client Configuration")
} footer: {
Text("Generate a data package (.zip) to configure ITAK or other TAK clients to connect to this server.")
}
}
// MARK: - Import Handlers
private func handleP12Import(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
guard url.startAccessingSecurityScopedResource() else {
importError = "Cannot access file"
showingImportError = true
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
pendingP12Data = try Data(contentsOf: url)
p12Password = ""
showingPasswordPrompt = true
} catch {
importError = "Failed to read file: \(error.localizedDescription)"
showingImportError = true
}
case .failure(let error):
importError = error.localizedDescription
showingImportError = true
}
}
private func importP12WithPassword() {
guard let data = pendingP12Data else { return }
do {
_ = try certManager.importServerIdentity(from: data, password: p12Password)
Logger.tak.info("Server certificate imported successfully")
} catch {
importError = error.localizedDescription
showingImportError = true
}
p12Password = ""
pendingP12Data = nil
}
private func handlePEMImport(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
guard url.startAccessingSecurityScopedResource() else {
importError = "Cannot access file"
showingImportError = true
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
let data = try Data(contentsOf: url)
_ = try certManager.importClientCACertificate(from: data)
Logger.tak.info("Client CA certificate imported successfully")
} catch {
importError = error.localizedDescription
showingImportError = true
}
case .failure(let error):
importError = error.localizedDescription
showingImportError = true
}
}
// MARK: - Data Package Generation
private func generateAndShareDataPackage() {
guard let url = TAKDataPackageGenerator.shared.generateDataPackage(
port: TAKServerManager.defaultTLSPort,
useTLS: true,
description: "Meshtastic TAK Server"
) else {
importError = "Failed to generate data package"
showingImportError = true
return
}
dataPackageURL = url
showingFileExporter = true
}
}
// MARK: - Zip Document for File Exporter
struct ZipDocument: FileDocument {
static var readableContentTypes: [UTType] { [.zip] }
let data: Data
init(url: URL) {
self.data = (try? Data(contentsOf: url)) ?? Data()
}
init(configuration: ReadConfiguration) throws {
self.data = configuration.file.regularFileContents ?? Data()
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}