diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3dc62e68..2aa368ab 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1900,6 +1900,9 @@ } } } + }, + "8089" : { + }, "A channel index of 0 indicates the primary channel where broadcast packets are sent from. Location data is broadcast from the first channel where it is enabled with firmware 2.7 forward." : { "localizations" : { @@ -1910,6 +1913,9 @@ } } } + }, + "A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : { + }, "A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : { "localizations" : { @@ -2463,6 +2469,9 @@ } } } + }, + "Add CA" : { + }, "Add Channel" : { "localizations" : { @@ -7671,6 +7680,12 @@ "Client Base should only favorite other nodes you control. Improper use will hurt your local mesh." : { "comment" : "A message displayed in a confirmation dialog when trying to favorite a node as a CLIENT_BASE.", "isCommentAutoGenerated" : true + }, + "Client CA Certificate" : { + + }, + "Client Configuration" : { + }, "Client Hidden" : { "localizations" : { @@ -8128,6 +8143,9 @@ } } } + }, + "Configuration" : { + }, "Configuration for: %@" : { "localizations" : { @@ -9710,6 +9728,9 @@ } } } + }, + "Delete All" : { + }, "Delete all config, keys and BLE bonds? " : { "localizations" : { @@ -12201,6 +12222,9 @@ } } } + }, + "Download TAK Server Data Package" : { + }, "Drag & Drop Firmware Update" : { "localizations" : { @@ -12714,6 +12738,9 @@ } } } + }, + "Enable TAK Server" : { + }, "Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : { "localizations" : { @@ -13227,6 +13254,12 @@ } } } + }, + "Enter P12 Password" : { + + }, + "Enter the password for the PKCS#12 file" : { + }, "environment" : { "localizations" : { @@ -15947,6 +15980,9 @@ } } } + }, + "Generate a data package (.zip) to configure ITAK or other TAK clients to connect to this server." : { + }, "Generate a new private key to replace the one currently in use. The public key will automatically be regenerated from your private key." : { "localizations" : { @@ -18235,6 +18271,18 @@ } } } + }, + "Import" : { + + }, + "Import .pem" : { + + }, + "Import Custom .p12" : { + + }, + "Import Error" : { + }, "Import Route" : { "localizations" : { @@ -22097,6 +22145,9 @@ } } } + }, + "mTLS" : { + }, "Multiplier" : { "localizations" : { @@ -26322,6 +26373,9 @@ } } } + }, + "Port" : { + }, "Position" : { "localizations" : { @@ -28862,6 +28916,9 @@ } } } + }, + "Reload Bundled Certificates" : { + }, "Remote administration for: %@" : { "localizations" : { @@ -29396,6 +29453,9 @@ } } } + }, + "Reset to Default" : { + }, "Restart" : { "localizations" : { @@ -29430,6 +29490,9 @@ } } } + }, + "Restart Server" : { + }, "Restart to the node you are connected to" : { "localizations" : { @@ -31300,6 +31363,9 @@ } } } + }, + "Secure mTLS connection on port 8089. Both server and client certificates are required." : { + }, "Security" : { "localizations" : { @@ -33114,6 +33180,9 @@ } } } + }, + "Server Certificate" : { + }, "Server Option" : { "localizations" : { @@ -33142,6 +33211,9 @@ } } } + }, + "Server Status" : { + }, "Set" : { "localizations" : { @@ -35058,6 +35130,9 @@ } } } + }, + "Status" : { + }, "Stay Connected Anywhere" : { "localizations" : { @@ -35470,6 +35545,9 @@ } } } + }, + "TAK Server" : { + }, "TAK Tracker" : { "localizations" : { @@ -37773,6 +37851,9 @@ } } } + }, + "TLS Certificates" : { + }, "TLS Enabled" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index f1c8b759..2e0b7faf 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -82,18 +82,29 @@ 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; + 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; }; + 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; }; 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; 3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; }; 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; }; 3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.swift */; }; + 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F203877F307073096C89179 /* FountainCodec.swift */; }; 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; }; 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; }; 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; 6DEDA55C2A9592F900321D2E /* MessageEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */; }; + 7CCBCA0251DAB58FD9D63D06 /* GenericCoTHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */; }; + 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */; }; + 8A8F2D8A3769D24BAB88B4A1 /* CoTMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */; }; 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */; }; 8D3F8A412D44C2A6009EAAA4 /* PowerMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */; }; + 8E587743574CE17703E892C6 /* Certificates in Resources */ = {isa = PBXBuildFile; fileRef = 518D504DED9874EBF9D76578 /* Certificates */; }; + 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; + 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; + A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; }; + B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */; }; B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; }; B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; }; @@ -298,6 +309,8 @@ DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF924C926FBB953009FE055 /* ConnectedDevice.swift */; }; DDFEB3BB29900C1200EE7472 /* CurrentConditionsCompact.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */; }; DDFFA7472B3A7F3C004730DB /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFFA7462B3A7F3C004730DB /* Bundle.swift */; }; + E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */; }; + FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -332,6 +345,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; + 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; }; + 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; @@ -395,17 +411,27 @@ 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; + 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; }; + 3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; }; 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = ""; }; 3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = ""; }; 3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = ""; }; + 3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = ""; }; + 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = ""; }; + 518D504DED9874EBF9D76578 /* Certificates */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; name = Certificates; path = Certificates; sourceTree = ""; }; 6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = ""; }; 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntityExtension.swift; sourceTree = ""; }; + 748E4806582595DE80D455CD /* CoTXMLParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTXMLParser.swift; sourceTree = ""; }; + 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GenericCoTHandler.swift; sourceTree = ""; }; + 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+TAK.swift"; sourceTree = ""; }; + 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKMeshtasticBridge.swift; sourceTree = ""; }; 8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 49.xcdatamodel"; sourceTree = ""; }; 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = ""; }; 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = ""; }; + 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = ""; }; ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = ""; }; ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = ""; }; B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; @@ -674,7 +700,17 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = PreferenceKeys; sourceTree = ""; }; + DD4C11E02E8099C3003F2F2E /* PreferenceKeys */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = PreferenceKeys; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -792,6 +828,7 @@ 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */, 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */, 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */, + 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */, ); path = "Accessory Manager"; sourceTree = ""; @@ -897,6 +934,24 @@ path = AppIntents; sourceTree = ""; }; + C37572859BC745C4284A9B42 /* TAK */ = { + isa = PBXGroup; + children = ( + 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */, + 748E4806582595DE80D455CD /* CoTXMLParser.swift */, + 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */, + 01028778B8BFD81F7A039593 /* TAKConnection.swift */, + 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */, + 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */, + 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */, + 3F203877F307073096C89179 /* FountainCodec.swift */, + 3D0A8ABAEF1E587683970927 /* EXICodec.swift */, + 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */, + ); + name = TAK; + path = TAK; + sourceTree = ""; + }; D9C9839E2B79D0C600BDBE6A /* TextMessageField */ = { isa = PBXGroup; children = ( @@ -983,6 +1038,7 @@ DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */, DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */, + 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */, ); path = Settings; sourceTree = ""; @@ -1236,6 +1292,7 @@ DDB75A192A05EB67006ED576 /* alpha.png */, DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */, DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */, + 518D504DED9874EBF9D76578 /* Certificates */, ); path = Resources; sourceTree = ""; @@ -1300,6 +1357,7 @@ DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */, 6D825E612C34786C008DBEE4 /* CommonRegex.swift */, 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */, + C37572859BC745C4284A9B42 /* TAK */, ); path = Helpers; sourceTree = ""; @@ -1570,6 +1628,7 @@ DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */, DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */, DDDBC87B2BC62E4E001E8DF7 /* Settings.bundle in Resources */, + 8E587743574CE17703E892C6 /* Certificates in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1877,6 +1936,18 @@ BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */, D93068D72B8146690066FBC8 /* MessageText.swift in Sources */, DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */, + 8A8F2D8A3769D24BAB88B4A1 /* CoTMessage.swift in Sources */, + 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */, + B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */, + 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */, + 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */, + E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */, + FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */, + A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */, + 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */, + 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */, + 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */, + 7CCBCA0251DAB58FD9D63D06 /* GenericCoTHandler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b339cec..cb5d36cf 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4", + "originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4", "pins" : [ { "identity" : "cocoamqtt", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DataDog/dd-sdk-ios.git", "state" : { - "revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54", - "version" : "2.29.0" + "revision" : "2cddcb47c021365c5a6ebc377cb379aa979c450e", + "version" : "3.4.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" } } ], diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift index 1a0e9ebd..23762b6d 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift @@ -52,8 +52,12 @@ extension AccessoryManager { existing.rssi = newDevice.rssi self.devices[index] = existing } else { - // This is a new device, add it to our list - self.devices.append(newDevice) + // This is a new device, add it to our list if we are in the foreground + if !(self.isInBackground) { + self.devices.append(newDevice) + } else { + Logger.transport.debug("πŸ”Ž [Discovery] Found a new device but not in the foreground, not adding to our list: peripheral \(newDevice.name)") + } } if self.shouldAutomaticallyConnectToPreferredPeripheral, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift index 5bcead9b..d8e70f0e 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift @@ -93,6 +93,8 @@ extension AccessoryManager { } tryClearExistingChannels() + // Initialize TAK bridge for TAK integration + initializeTAKBridge() } func handleNodeInfo(_ nodeInfo: NodeInfo) { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift new file mode 100644 index 00000000..d6c96783 --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift @@ -0,0 +1,209 @@ +// +// AccessoryManager+TAK.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +extension AccessoryManager { + + // MARK: - TAK Server Initialization + + /// Initialize the TAK bridge when connected to a Meshtastic device + func initializeTAKBridge() { + let takServer = TAKServerManager.shared + + // Create the bridge + let bridge = TAKMeshtasticBridge( + accessoryManager: self, + takServerManager: takServer + ) + bridge.context = self.context + + // Assign bridge to server + takServer.bridge = bridge + + Logger.tak.info("TAK bridge initialized") + + // Start server if enabled + if takServer.enabled && !takServer.isRunning { + Task { + do { + try await takServer.start() + Logger.tak.info("TAK Server auto-started on connection") + } catch { + Logger.tak.error("Failed to auto-start TAK Server: \(error.localizedDescription)") + } + } + } + } + + /// Clean up TAK bridge when disconnecting + func cleanupTAKBridge() { + // Note: We don't stop the server here - it can continue running + // even without a Meshtastic connection (for TAK connectivity) + Logger.tak.info("TAK bridge cleanup") + } + + // MARK: - Send TAK Packet to Mesh + + /// Send a TAK packet to the Meshtastic mesh network + /// - Parameters: + /// - takPacket: The TAKPacket protobuf to send + /// - channel: Channel to send on (0 = default/primary) + func sendTAKPacket(_ takPacket: TAKPacket, channel: UInt32 = 0) async throws { + Logger.tak.debug("=== Sending TAKPacket to Mesh ===") + + guard let activeConnection else { + Logger.tak.error("Not connected to Meshtastic device") + throw AccessoryError.connectionFailed("Not connected to Meshtastic device") + } + + guard let deviceNum = activeConnection.device.num else { + Logger.tak.error("No device number available") + throw AccessoryError.connectionFailed("No device number available") + } + + Logger.tak.debug("Device num: \(deviceNum)") + + // Log TAKPacket details before serialization + Logger.tak.debug("TAKPacket to send:") + Logger.tak.debug(" hasContact: \(takPacket.hasContact)") + if takPacket.hasContact { + Logger.tak.debug(" callsign: \(takPacket.contact.callsign)") + Logger.tak.debug(" deviceCallsign: \(takPacket.contact.deviceCallsign)") + } + Logger.tak.debug(" hasGroup: \(takPacket.hasGroup)") + if takPacket.hasGroup { + Logger.tak.debug(" team: \(takPacket.group.team.rawValue)") + Logger.tak.debug(" role: \(takPacket.group.role.rawValue)") + } + Logger.tak.debug(" hasStatus: \(takPacket.hasStatus)") + if takPacket.hasStatus { + Logger.tak.debug(" battery: \(takPacket.status.battery)") + } + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Serialize the TAK packet + let serialized: Data + do { + serialized = try takPacket.serializedData() + Logger.tak.debug("Serialized TAKPacket: \(serialized.count) bytes") + Logger.tak.debug("Serialized hex: \(serialized.map { String(format: "%02x", $0) }.joined(separator: " "))") + } catch { + Logger.tak.error("Failed to serialize TAKPacket: \(error.localizedDescription)") + throw AccessoryError.ioFailed("Failed to serialize TAKPacket") + } + + // Build the mesh packet + var dataMessage = DataMessage() + dataMessage.portnum = .atakPlugin // Port 72 + dataMessage.payload = serialized + + var meshPacket = MeshPacket() + meshPacket.to = 0xFFFFFFFF // Broadcast + meshPacket.from = UInt32(deviceNum) + meshPacket.channel = channel + meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..= 2 && payload[0] == 0x08 && payload[1] == 0x01 { + Logger.tak.debug("Ignoring compressed TAKPacket (duplicate of uncompressed)") + return + } + + // Parse uncompressed TAKPacket protobuf + let takPacket: TAKPacket + do { + takPacket = try TAKPacket(serializedBytes: payload) + } catch { + Logger.tak.warning("Failed to parse TAKPacket from mesh packet: \(error.localizedDescription)") + Logger.tak.debug("Parse error details: \(error)") + Logger.tak.debug("Raw payload hex: \(payload.map { String(format: "%02x", $0) }.joined(separator: " "))") + return + } + + Logger.tak.info("Received TAKPacket from mesh node \(packet.from)") + Logger.tak.debug(" hasContact: \(takPacket.hasContact), hasGroup: \(takPacket.hasGroup), hasStatus: \(takPacket.hasStatus)") + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Forward to TAK clients via bridge + Task { + await TAKServerManager.shared.bridge?.broadcastToTAKClients(takPacket, from: packet.from) + } + } + + // MARK: - Handle ATAK Forwarder Packet (Port 257) + + /// Handle incoming ATAK_FORWARDER packet for generic CoT events + /// These are EXI-compressed CoT XML, possibly fountain-coded for large messages + func handleATAKForwarderPacket(_ packet: MeshPacket) { + guard case let .decoded(data) = packet.payloadVariant else { + Logger.tak.warning("Received ATAK_FORWARDER packet without decoded payload") + return + } + + Logger.tak.debug("Received ATAK_FORWARDER packet: \(data.payload.count) bytes from node \(packet.from)") + + // Process through GenericCoTHandler on main actor + let packetCopy = packet + let accessoryManagerRef = self + Task { @MainActor in + let handler = GenericCoTHandler.shared + handler.accessoryManager = accessoryManagerRef + + if let cotMessage = handler.handleIncomingForwarderPacket(packetCopy) { + // Forward to TAK clients via the server manager + await TAKServerManager.shared.broadcast(cotMessage) + Logger.tak.info("Forwarded generic CoT to TAK clients: \(cotMessage.type)") + } + } + } +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 868a6e6f..97f005c3 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -441,8 +441,6 @@ extension AccessoryManager { Logger.services.error("Error while sending saveChannelSet request. No active device.") throw AccessoryError.ioFailed("No active device") } - var i: Int32 = 0 - var myInfo: MyInfoEntity // Before we get started delete the existing channels from the myNodeInfo if !addChannels { tryClearExistingChannels() @@ -451,64 +449,74 @@ extension AccessoryManager { let decodedString = base64UrlString.base64urlToBase64() if let decodedData = Data(base64Encoded: decodedString) { let channelSet: ChannelSet = try ChannelSet(serializedBytes: decodedData) + + var myInfo: MyInfoEntity! + var i: Int32 = 0 + + if addChannels { + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum)) + + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) + if fetchedMyInfo.count != 1 { + throw AccessoryError.appError("MyInfo not found") + } + + // We are trying to add a channel so lets get the last index + myInfo = fetchedMyInfo[0] + i = Int32(myInfo.channels?.count ?? -1) + + // Bail out if the index is negative or bigger than our max of 8 + if i < 0 || i > 8 { + throw AccessoryError.appError("Index out of range \(i)") + } + } + for cs in channelSet.settings { + if addChannels { - // We are trying to add a channel so lets get the last index - let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum)) - do { - let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - if fetchedMyInfo.count == 1 { - i = Int32(fetchedMyInfo[0].channels?.count ?? -1) - myInfo = fetchedMyInfo[0] - // Bail out if the index is negative or bigger than our max of 8 - if i < 0 || i > 8 { - throw AccessoryError.appError("Index out of range \(i)") - } - // Bail out if there are no channels or if the same channel name already exists - guard let mutableChannels = myInfo.channels!.mutableCopy() as? NSMutableOrderedSet else { - throw AccessoryError.appError("No channels or channel") - } - if mutableChannels.first(where: {($0 as AnyObject).name == cs.name }) is ChannelEntity { - throw AccessoryError.appError("Channel already exists") - } - } - } catch { - Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)") + guard let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet else { + throw AccessoryError.appError("No channels or channel") + } + + // Bail out if there are no channels or if the same channel name already exists + if mutableChannels.first(where: { ($0 as AnyObject).name == cs.name }) is ChannelEntity { + throw AccessoryError.appError("Channel already exists") } } var chan = Channel() - if i == 0 { - chan.role = Channel.Role.primary - } else { - chan.role = Channel.Role.secondary - } + chan.role = (i == 0) ? .primary : .secondary chan.settings = cs chan.index = i i += 1 var adminPacket = AdminMessage() adminPacket.setChannel = chan - var meshPacket: MeshPacket = MeshPacket() + + var meshPacket = MeshPacket() meshPacket.to = UInt32(deviceNum) - meshPacket.from = UInt32(deviceNum) + meshPacket.from = UInt32(deviceNum) meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. [OSLogEntryLog] { diff --git a/Meshtastic/Helpers/Logger.swift b/Meshtastic/Helpers/Logger.swift deleted file mode 100644 index 35ee7337..00000000 --- a/Meshtastic/Helpers/Logger.swift +++ /dev/null @@ -1,19 +0,0 @@ -import OSLog - -extension Logger { - - /// The logger's subsystem. - private static var subsystem = Bundle.main.bundleIdentifier! - - /// All logs related to data such as decoding error, parsing issues, etc. - public static let data = Logger(subsystem: subsystem, category: "πŸ—„οΈ Data") - - /// All logs related to the mesh - public static let mesh = Logger(subsystem: subsystem, category: "πŸ•ΈοΈ Mesh") - - /// All logs related to services such as network calls, location, etc. - public static let services = Logger(subsystem: subsystem, category: "🍏 Services") - - /// All logs related to tracking and analytics. - public static let statistics = Logger(subsystem: subsystem, category: "πŸ“ˆ Stats") -} diff --git a/Meshtastic/Helpers/TAK/CoTMessage.swift b/Meshtastic/Helpers/TAK/CoTMessage.swift new file mode 100644 index 00000000..1d8419b8 --- /dev/null +++ b/Meshtastic/Helpers/TAK/CoTMessage.swift @@ -0,0 +1,527 @@ +// +// CoTMessage.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import CoreLocation + +/// Cursor on Target (CoT) message representation +/// Handles both parsing incoming CoT XML and generating outgoing CoT XML +struct CoTMessage: Identifiable, Sendable { + let id = UUID() + + // MARK: - Core CoT Event Attributes + + /// Unique identifier for this event + var uid: String + + /// CoT type (e.g., "a-f-G-U-C" for friendly ground unit, "b-t-f" for chat) + var type: String + + /// Event generation time + var time: Date + + /// Start of event validity + var start: Date + + /// When this event becomes stale + var stale: Date + + /// How the event was generated (e.g., "m-g" for machine GPS, "h-g-i-g-o" for human generated) + var how: String + + // MARK: - Point Element (Location) + + /// Latitude in degrees + var latitude: Double + + /// Longitude in degrees + var longitude: Double + + /// Height above ellipsoid in meters + var hae: Double + + /// Circular error in meters + var ce: Double + + /// Linear error in meters + var le: Double + + // MARK: - Detail Elements + + /// Contact information (callsign, endpoint) + var contact: CoTContact? + + /// Group/team assignment + var group: CoTGroup? + + /// Device status (battery) + var status: CoTStatus? + + /// Movement track (speed, course) + var track: CoTTrack? + + /// Chat message details + var chat: CoTChat? + + /// Remarks/comments text + var remarks: String? + + /// Raw detail XML content for elements we don't explicitly parse + /// Used to preserve generic CoT elements (colors, shapes, labels, etc.) + var rawDetailXML: String? + + // MARK: - Initialization + + init( + uid: String, + type: String, + time: Date = Date(), + start: Date = Date(), + stale: Date = Date().addingTimeInterval(600), + how: String = "m-g", + latitude: Double = 0, + longitude: Double = 0, + hae: Double = 9999999.0, + ce: Double = 9999999.0, + le: Double = 9999999.0, + contact: CoTContact? = nil, + group: CoTGroup? = nil, + status: CoTStatus? = nil, + track: CoTTrack? = nil, + chat: CoTChat? = nil, + remarks: String? = nil, + rawDetailXML: String? = nil + ) { + self.uid = uid + self.type = type + self.time = time + self.start = start + self.stale = stale + self.how = how + self.latitude = latitude + self.longitude = longitude + self.hae = hae + self.ce = ce + self.le = le + self.contact = contact + self.group = group + self.status = status + self.track = track + self.chat = chat + self.remarks = remarks + self.rawDetailXML = rawDetailXML + } + + // MARK: - Factory Methods + + /// Create a PLI (Position Location Information) message for a friendly ground unit + static func pli( + uid: String, + callsign: String, + latitude: Double, + longitude: Double, + altitude: Double = 9999999.0, + speed: Double = 0, + course: Double = 0, + team: String = "Cyan", + role: String = "Team Member", + battery: Int = 100, + staleMinutes: Int = 10 + ) -> CoTMessage { + let now = Date() + return CoTMessage( + uid: uid, + type: "a-f-G-U-C", + time: now, + start: now, + stale: now.addingTimeInterval(TimeInterval(staleMinutes * 60)), + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: altitude, + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), + group: CoTGroup(name: team, role: role), + status: CoTStatus(battery: battery), + track: CoTTrack(speed: speed, course: course) + ) + } + + /// Create a chat message (b-t-f type for outgoing) + static func chat( + senderUid: String, + senderCallsign: String, + message: String, + chatroom: String = "All Chat Rooms" + ) -> CoTMessage { + let now = Date() + let messageId = UUID().uuidString + return CoTMessage( + uid: "GeoChat.\(senderUid).\(chatroom).\(messageId)", + type: "b-t-f", + time: now, + start: now, + stale: now.addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + chat: CoTChat( + message: message, + senderCallsign: senderCallsign, + chatroom: chatroom + ), + remarks: message + ) + } + + // MARK: - Create from Meshtastic TAKPacket + + /// Convert Meshtastic TAKPacket protobuf to CoT message + static func fromTAKPacket(_ takPacket: TAKPacket, deviceUid: String? = nil) -> CoTMessage? { + let currentDate = Date() + let staleDate = currentDate.addingTimeInterval(10 * 60) // 10 minute stale + + // Handle PLI (Position Location Information) + if case .pli(let pli) = takPacket.payloadVariant { + // Validate we have required fields + guard takPacket.hasContact, + pli.latitudeI != 0 || pli.longitudeI != 0 else { + return nil + } + + // Parse device_callsign in case it contains smuggled messageId (shouldn't for PLI, but be safe) + let (actualDeviceCallsign, _) = TAKMeshtasticBridge.parseDeviceCallsign(takPacket.contact.deviceCallsign) + let uid = actualDeviceCallsign.isEmpty + ? (deviceUid ?? UUID().uuidString) + : actualDeviceCallsign + + return CoTMessage( + uid: uid, + type: "a-f-G-U-C", + time: currentDate, + start: currentDate, + stale: staleDate, + how: "m-g", + latitude: Double(pli.latitudeI) * 1e-7, + longitude: Double(pli.longitudeI) * 1e-7, + hae: Double(pli.altitude), + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact( + callsign: takPacket.contact.callsign, + endpoint: "0.0.0.0:4242:tcp" + ), + group: takPacket.hasGroup ? CoTGroup( + name: takPacket.group.team.cotColorName, + role: takPacket.group.role.cotRoleName + ) : CoTGroup(name: "Cyan", role: "Team Member"), + status: takPacket.hasStatus ? CoTStatus( + battery: Int(takPacket.status.battery) + ) : nil, + track: CoTTrack( + speed: Double(pli.speed), + course: Double(pli.course) + ) + ) + } + + // Handle GeoChat + if case .chat(let geoChat) = takPacket.payloadVariant { + // Parse device_callsign which may contain smuggled messageId + // Format: "|" or just "" + let rawDeviceCallsign = takPacket.hasContact ? takPacket.contact.deviceCallsign : "" + let (actualDeviceCallsign, smuggledMessageId) = TAKMeshtasticBridge.parseDeviceCallsign(rawDeviceCallsign) + + let uid = actualDeviceCallsign.isEmpty + ? (deviceUid ?? UUID().uuidString) + : actualDeviceCallsign + + let chatroom = geoChat.hasTo ? geoChat.to : "All Chat Rooms" + // Use smuggled messageId if present, otherwise generate new one + let messageId = smuggledMessageId ?? UUID().uuidString + + return CoTMessage( + uid: "GeoChat.\(uid).\(chatroom).\(messageId)", + type: "b-t-f", + time: currentDate, + start: currentDate, + stale: currentDate.addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + contact: takPacket.hasContact ? CoTContact( + callsign: takPacket.contact.callsign, + endpoint: "0.0.0.0:4242:tcp" + ) : nil, + chat: CoTChat( + message: geoChat.message, + senderCallsign: takPacket.hasContact ? takPacket.contact.callsign : nil, + chatroom: chatroom + ), + remarks: geoChat.message + ) + } + + return nil + } + + // MARK: - XML Generation + + /// Generate CoT XML string for transmission to TAK clients + func toXML() -> String { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var cot = "" + cot += "" + cot += "", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } +} + +// MARK: - Team/Role Extensions for Meshtastic Protobufs + +extension Team { + /// Convert Meshtastic Team enum to CoT color name + var cotColorName: String { + switch self { + case .white: return "White" + case .yellow: return "Yellow" + case .orange: return "Orange" + case .magenta: return "Magenta" + case .red: return "Red" + case .maroon: return "Maroon" + case .purple: return "Purple" + case .darkBlue: return "Dark Blue" + case .blue: return "Blue" + case .cyan: return "Cyan" + case .teal: return "Teal" + case .green: return "Green" + case .darkGreen: return "Dark Green" + case .brown: return "Brown" + case .unspecifedColor: return "Cyan" + case .UNRECOGNIZED: return "Cyan" + } + } + + /// Create Team from CoT color name + static func fromColorName(_ name: String) -> Team { + switch name.lowercased() { + case "white": return .white + case "yellow": return .yellow + case "orange": return .orange + case "magenta": return .magenta + case "red": return .red + case "maroon": return .maroon + case "purple": return .purple + case "dark blue", "darkblue": return .darkBlue + case "blue": return .blue + case "cyan": return .cyan + case "teal": return .teal + case "green": return .green + case "dark green", "darkgreen": return .darkGreen + case "brown": return .brown + default: return .cyan + } + } +} + +extension MemberRole { + /// Convert Meshtastic MemberRole enum to CoT role name + var cotRoleName: String { + switch self { + case .teamMember: return "Team Member" + case .teamLead: return "Team Lead" + case .hq: return "HQ" + case .sniper: return "Sniper" + case .medic: return "Medic" + case .forwardObserver: return "Forward Observer" + case .rto: return "RTO" + case .k9: return "K9" + case .unspecifed: return "Team Member" + case .UNRECOGNIZED: return "Team Member" + } + } + + /// Create MemberRole from CoT role name + static func fromRoleName(_ name: String) -> MemberRole { + switch name.lowercased() { + case "team member": return .teamMember + case "team lead": return .teamLead + case "hq", "headquarters": return .hq + case "sniper": return .sniper + case "medic": return .medic + case "forward observer": return .forwardObserver + case "rto": return .rto + case "k9": return .k9 + default: return .teamMember + } + } +} + +// MARK: - XML Parsing + +extension CoTMessage { + /// Parse a CoT XML string into a CoTMessage + /// - Parameter xml: The CoT XML string + /// - Returns: Parsed CoTMessage, or nil if parsing failed + static func parse(from xml: String) -> CoTMessage? { + guard let data = xml.data(using: .utf8) else { + return nil + } + + // Use the existing CoTXMLParser class + let parser = CoTXMLParser(data: data) + do { + return try parser.parse() + } catch { + return nil + } + } +} diff --git a/Meshtastic/Helpers/TAK/CoTXMLParser.swift b/Meshtastic/Helpers/TAK/CoTXMLParser.swift new file mode 100644 index 00000000..1c189c3e --- /dev/null +++ b/Meshtastic/Helpers/TAK/CoTXMLParser.swift @@ -0,0 +1,343 @@ +// +// CoTXMLParser.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import OSLog + +/// XML Parser delegate for parsing incoming CoT (Cursor on Target) messages from TAK clients +final class CoTXMLParser: NSObject, XMLParserDelegate { + private let data: Data + private var cotMessage: CoTMessage? + private var parseError: Error? + + // Current parsing state + private var currentElement = "" + private var currentText = "" + + // Temporary attribute storage during parsing + private var eventAttributes: [String: String] = [:] + private var pointAttributes: [String: String] = [:] + private var contactAttributes: [String: String] = [:] + private var groupAttributes: [String: String] = [:] + private var statusAttributes: [String: String] = [:] + private var trackAttributes: [String: String] = [:] + private var chatAttributes: [String: String] = [:] + private var chatgrpAttributes: [String: String] = [:] + private var remarksAttributes: [String: String] = [:] + private var remarksText = "" + private var linkAttributes: [String: String] = [:] + + // Track element hierarchy for nested elements + private var elementStack: [String] = [] + + // Raw detail XML for unrecognized elements (markers, shapes, colors, etc.) + private var rawDetailXML = "" + private var isCapturingRawDetail = false + private var rawDetailDepth = 0 + + // Known detail elements we handle explicitly + private let knownDetailElements: Set = [ + "contact", "__group", "status", "track", "__chat", "chatgrp", + "remarks", "link", "uid", "__serverdestination" + ] + + init(data: Data) { + self.data = data + } + + /// Parse the XML data and return a CoTMessage + func parse() throws -> CoTMessage { + let parser = XMLParser(data: data) + parser.delegate = self + parser.shouldProcessNamespaces = false + parser.shouldReportNamespacePrefixes = false + + guard parser.parse() else { + if let error = parseError { + throw error + } + throw CoTParseError.parseFailed(parser.parserError?.localizedDescription ?? "Unknown error") + } + + guard let message = cotMessage else { + throw CoTParseError.invalidMessage + } + + return message + } + + // MARK: - XMLParserDelegate + + func parser(_ parser: XMLParser, didStartElement elementName: String, + namespaceURI: String?, qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:]) { + elementStack.append(elementName) + currentElement = elementName + currentText = "" + + // Check if we're inside and this is an unrecognized element + let isInsideDetail = elementStack.contains("detail") && elementName != "detail" + + if isCapturingRawDetail { + // Continue capturing nested elements + rawDetailDepth += 1 + rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict) + } else if isInsideDetail && !knownDetailElements.contains(elementName) { + // Start capturing this unrecognized element + isCapturingRawDetail = true + rawDetailDepth = 1 + rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict) + } + + switch elementName { + case "event": + eventAttributes = attributeDict + case "point": + pointAttributes = attributeDict + case "contact": + contactAttributes = attributeDict + case "__group": + groupAttributes = attributeDict + case "status": + statusAttributes = attributeDict + case "track": + trackAttributes = attributeDict + case "__chat": + chatAttributes = attributeDict + case "chatgrp": + chatgrpAttributes = attributeDict + case "remarks": + remarksAttributes = attributeDict + case "link": + linkAttributes = attributeDict + default: + break + } + } + + /// Build an XML opening tag with attributes + private func buildOpeningTag(_ elementName: String, attributes: [String: String]) -> String { + var tag = "<\(elementName)" + for (key, value) in attributes { + tag += " \(key)='\(value.xmlEscaped)'" + } + tag += ">" + return tag + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentText += string + + // Capture text content for raw detail elements + if isCapturingRawDetail { + rawDetailXML += string.xmlEscaped + } + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, + namespaceURI: String?, qualifiedName qName: String?) { + if elementName == "remarks" { + remarksText = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Handle raw detail element closing + if isCapturingRawDetail { + rawDetailXML += "" + rawDetailDepth -= 1 + if rawDetailDepth == 0 { + isCapturingRawDetail = false + } + } + + if elementName == "event" { + buildCoTMessage() + } + + elementStack.removeLast() + currentElement = elementStack.last ?? "" + } + + func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + self.parseError = parseError + Logger.tak.error("CoT XML parse error: \(parseError.localizedDescription)") + } + + // MARK: - Build CoTMessage + + private func buildCoTMessage() { + Logger.tak.debug("=== Building CoTMessage from XML ===") + Logger.tak.debug("Event attributes: \(self.eventAttributes)") + Logger.tak.debug("Point attributes: \(self.pointAttributes)") + Logger.tak.debug("Contact attributes: \(self.contactAttributes)") + Logger.tak.debug("Group attributes: \(self.groupAttributes)") + Logger.tak.debug("Status attributes: \(self.statusAttributes)") + Logger.tak.debug("Track attributes: \(self.trackAttributes)") + Logger.tak.debug("Chat attributes: \(self.chatAttributes)") + Logger.tak.debug("Remarks text: \(self.remarksText)") + + // Parse timestamps + let time = parseDate(eventAttributes["time"]) + let start = parseDate(eventAttributes["start"]) + let stale = parseDate(eventAttributes["stale"]) + + // Build contact if present + var contact: CoTContact? + if !contactAttributes.isEmpty { + contact = CoTContact( + callsign: contactAttributes["callsign"] ?? "", + endpoint: contactAttributes["endpoint"], + phone: contactAttributes["phone"] + ) + Logger.tak.debug("Parsed contact: callsign=\(contact?.callsign ?? "nil")") + } + + // Build group if present + var group: CoTGroup? + if !groupAttributes.isEmpty { + group = CoTGroup( + name: groupAttributes["name"] ?? "Cyan", + role: groupAttributes["role"] ?? "Team Member" + ) + Logger.tak.debug("Parsed group: name=\(group?.name ?? "nil"), role=\(group?.role ?? "nil")") + } + + // Build status if present + var status: CoTStatus? + if let batteryStr = statusAttributes["battery"], let battery = Int(batteryStr) { + status = CoTStatus(battery: battery) + Logger.tak.debug("Parsed status: battery=\(battery)") + } + + // Build track if present + var track: CoTTrack? + if !trackAttributes.isEmpty { + let speed = Double(trackAttributes["speed"] ?? "0") ?? 0 + let course = Double(trackAttributes["course"] ?? "0") ?? 0 + track = CoTTrack(speed: speed, course: course) + Logger.tak.debug("Parsed track: speed=\(speed), course=\(course)") + } + + // Build chat if present + var chat: CoTChat? + if !chatAttributes.isEmpty { + chat = CoTChat( + message: remarksText, + senderCallsign: chatAttributes["senderCallsign"], + chatroom: chatAttributes["chatroom"] ?? chatAttributes["id"] ?? "All Chat Rooms" + ) + Logger.tak.debug("Parsed chat: message=\(self.remarksText.prefix(50)), chatroom=\(chat?.chatroom ?? "nil")") + } + + let uid = eventAttributes["uid"] ?? UUID().uuidString + let type = eventAttributes["type"] ?? "a-f-G-U-C" + let latitude = Double(pointAttributes["lat"] ?? "0") ?? 0 + let longitude = Double(pointAttributes["lon"] ?? "0") ?? 0 + let hae = Double(pointAttributes["hae"] ?? "9999999") ?? 9999999 + + Logger.tak.debug("Building CoTMessage: uid=\(uid), type=\(type)") + Logger.tak.debug(" location: lat=\(latitude), lon=\(longitude), hae=\(hae)") + + cotMessage = CoTMessage( + uid: uid, + type: type, + time: time, + start: start, + stale: stale, + how: eventAttributes["how"] ?? "m-g", + latitude: latitude, + longitude: longitude, + hae: hae, + ce: Double(pointAttributes["ce"] ?? "9999999") ?? 9999999, + le: Double(pointAttributes["le"] ?? "9999999") ?? 9999999, + contact: contact, + group: group, + status: status, + track: track, + chat: chat, + remarks: chat == nil && !remarksText.isEmpty ? remarksText : nil, + rawDetailXML: rawDetailXML.isEmpty ? nil : rawDetailXML + ) + + if !rawDetailXML.isEmpty { + Logger.tak.debug("Captured raw detail XML: \(self.rawDetailXML.prefix(200))...") + } + + Logger.tak.debug("=== CoTMessage built successfully ===") + } + + // MARK: - Date Parsing + + private func parseDate(_ string: String?) -> Date { + guard let string else { return Date() } + + // Try ISO8601 with fractional seconds first + let formatterWithFractional = ISO8601DateFormatter() + formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatterWithFractional.date(from: string) { + return date + } + + // Try ISO8601 without fractional seconds + let formatterWithoutFractional = ISO8601DateFormatter() + formatterWithoutFractional.formatOptions = [.withInternetDateTime] + if let date = formatterWithoutFractional.date(from: string) { + return date + } + + // Try basic date formatter + let basicFormatter = DateFormatter() + basicFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + basicFormatter.timeZone = TimeZone(identifier: "UTC") + if let date = basicFormatter.date(from: string) { + return date + } + + Logger.tak.warning("Failed to parse CoT date: \(string)") + return Date() + } +} + +// MARK: - Parse Error + +enum CoTParseError: LocalizedError { + case parseFailed(String) + case invalidMessage + case emptyData + + var errorDescription: String? { + switch self { + case .parseFailed(let reason): + return "Failed to parse CoT XML: \(reason)" + case .invalidMessage: + return "Invalid CoT message structure" + case .emptyData: + return "Empty data received" + } + } +} + +// MARK: - CoTMessage Parsing Extension + +extension CoTMessage { + /// Parse CoT XML data into a CoTMessage + static func parse(from data: Data) throws -> CoTMessage { + guard !data.isEmpty else { + throw CoTParseError.emptyData + } + + let parser = CoTXMLParser(data: data) + return try parser.parse() + } + + /// Parse CoT XML string into a CoTMessage + static func parse(from xmlString: String) throws -> CoTMessage { + guard let data = xmlString.data(using: .utf8) else { + throw CoTParseError.emptyData + } + return try parse(from: data) + } +} diff --git a/Meshtastic/Helpers/TAK/EXICodec.swift b/Meshtastic/Helpers/TAK/EXICodec.swift new file mode 100644 index 00000000..e1881e08 --- /dev/null +++ b/Meshtastic/Helpers/TAK/EXICodec.swift @@ -0,0 +1,148 @@ +// +// EXICodec.swift +// Meshtastic +// +// Zlib compression for CoT events over mesh network. +// Uses standard zlib format (78 xx header) for Android interoperability. +// +// IMPORTANT: Uses C zlib library directly to produce standard zlib format. +// Apple's Compression framework produces raw deflate which is NOT compatible +// with Android's standard zlib decompressor. +// +// Zlib header bytes: +// - 78 01: No compression +// - 78 9C: Default compression (what we use) +// - 78 DA: Best compression +// + +import Foundation +import zlib +import OSLog + +/// Codec for compressing/decompressing CoT XML using standard zlib +/// Named EXICodec for historical reasons - now uses zlib for Android compatibility +final class EXICodec { + + static let shared = EXICodec() + + private init() {} + + // MARK: - Compression + + /// Compress CoT XML to binary format using zlib + /// - Parameter xml: The CoT XML string + /// - Returns: Compressed data (78 9C header), or nil if compression failed + func compress(_ xml: String) -> Data? { + guard let xmlData = xml.data(using: .utf8) else { + Logger.tak.error("Zlib: Failed to convert XML to UTF-8 data") + return nil + } + + // Use standard zlib compression (produces 78 9C header that Android expects) + guard let compressed = compressZlib(xmlData) else { + Logger.tak.warning("Zlib: Compression failed, using raw data") + return xmlData + } + + let ratio = Double(compressed.count) / Double(xmlData.count) * 100 + Logger.tak.info("Zlib: Compressed \(xmlData.count) β†’ \(compressed.count) bytes (\(String(format: "%.1f", ratio))%)") + + // Log first few bytes to verify format (should start with 78 9C) + if compressed.count >= 2 { + Logger.tak.debug("Zlib: Header: \(String(format: "%02X %02X", compressed[0], compressed[1]))") + } + + return compressed + } + + /// Decompress zlib data to CoT XML + /// - Parameter data: The compressed data (expects 78 xx header) + /// - Returns: Decompressed XML string, or nil if decompression failed + func decompress(_ data: Data) -> String? { + // Log header for debugging + if data.count >= 2 { + Logger.tak.debug("Zlib: Decompressing data with header: \(String(format: "%02X %02X", data[0], data[1]))") + } + + // Try standard zlib decompression (78 xx header) + if let decompressed = decompressZlib(data) { + if let xml = String(data: decompressed, encoding: .utf8) { + Logger.tak.debug("Zlib: Decompressed \(data.count) β†’ \(decompressed.count) bytes") + return xml + } + } + + // Fallback: try interpreting as raw UTF-8 (uncompressed) + if let xml = String(data: data, encoding: .utf8) { + Logger.tak.debug("Zlib: Data was uncompressed UTF-8 (\(data.count) bytes)") + return xml + } + + Logger.tak.error("Zlib: Failed to decompress data (\(data.count) bytes)") + return nil + } + + // MARK: - Zlib Implementation + + /// Compress data using standard zlib format (78 9C header) + /// Uses C zlib library directly for Android compatibility + private func compressZlib(_ data: Data) -> Data? { + // Calculate maximum compressed size + var compressedLength = compressBound(uLong(data.count)) + var compressed = Data(count: Int(compressedLength)) + + let result = compressed.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + compress2( + destPtr.bindMemory(to: Bytef.self).baseAddress!, + &compressedLength, + srcPtr.bindMemory(to: Bytef.self).baseAddress!, + uLong(data.count), + Z_DEFAULT_COMPRESSION + ) + } + } + + guard result == Z_OK else { + Logger.tak.error("Zlib: compress2 failed with code \(result)") + return nil + } + + return compressed.prefix(Int(compressedLength)) + } + + /// Decompress standard zlib data (78 xx header) + private func decompressZlib(_ data: Data) -> Data? { + // Estimate uncompressed size (start with 10x, will retry if needed) + var uncompressedLength = uLong(data.count * 10) + var maxAttempts = 3 + + while maxAttempts > 0 { + var uncompressed = Data(count: Int(uncompressedLength)) + + let result = uncompressed.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + uncompress( + destPtr.bindMemory(to: Bytef.self).baseAddress!, + &uncompressedLength, + srcPtr.bindMemory(to: Bytef.self).baseAddress!, + uLong(data.count) + ) + } + } + + if result == Z_OK { + return uncompressed.prefix(Int(uncompressedLength)) + } else if result == Z_BUF_ERROR { + // Buffer too small, try larger + uncompressedLength *= 2 + maxAttempts -= 1 + } else { + Logger.tak.debug("Zlib: uncompress failed with code \(result)") + return nil + } + } + + return nil + } +} diff --git a/Meshtastic/Helpers/TAK/FountainCodec.swift b/Meshtastic/Helpers/TAK/FountainCodec.swift new file mode 100644 index 00000000..95f559a6 --- /dev/null +++ b/Meshtastic/Helpers/TAK/FountainCodec.swift @@ -0,0 +1,616 @@ +// +// FountainCodec.swift +// Meshtastic +// +// Fountain code (LT codes) implementation for reliable transfer over lossy mesh networks +// Based on the ATAK Meshtastic plugin protocol +// + +import Foundation +import CryptoKit +import OSLog + +// MARK: - Constants + +enum FountainConstants { + /// Magic bytes identifying fountain packets: "FTN" + static let magic: [UInt8] = [0x46, 0x54, 0x4E] + + /// Maximum payload size per block + static let blockSize = 220 + + /// Header size for data blocks + static let dataHeaderSize = 11 + + /// Size threshold for fountain coding (below this, send directly) + static let fountainThreshold = 233 + + /// Transfer type: CoT event + static let transferTypeCot: UInt8 = 0x00 + + /// Transfer type: File transfer + static let transferTypeFile: UInt8 = 0x01 + + /// ACK type: Transfer complete + static let ackTypeComplete: UInt8 = 0x02 + + /// ACK type: Need more blocks + static let ackTypeNeedMore: UInt8 = 0x03 + + /// ACK packet size + static let ackPacketSize = 19 +} + +// MARK: - Fountain Packet Types + +/// A received fountain block with its metadata +struct FountainBlock { + let seed: UInt16 + var indices: Set + var payload: Data + + func copy() -> FountainBlock { + return FountainBlock(seed: seed, indices: indices, payload: payload) + } +} + +/// State for receiving a fountain-coded transfer +class FountainReceiveState { + let transferId: UInt32 + let K: Int + let totalLength: Int + var blocks: [FountainBlock] = [] + let createdAt: Date + + init(transferId: UInt32, K: Int, totalLength: Int) { + self.transferId = transferId + self.K = K + self.totalLength = totalLength + self.createdAt = Date() + } + + func addBlock(_ block: FountainBlock) { + // Don't add duplicate seeds + if !blocks.contains(where: { $0.seed == block.seed }) { + blocks.append(block) + } + } + + var isExpired: Bool { + // Expire after 60 seconds + return Date().timeIntervalSince(createdAt) > 60 + } +} + +/// Parsed fountain data block header +struct FountainDataHeader { + let transferId: UInt32 // 24-bit, stored in lower 24 bits + let seed: UInt16 + let K: UInt8 + let totalLength: UInt16 +} + +/// Parsed fountain ACK packet +struct FountainAck { + let transferId: UInt32 + let type: UInt8 + let received: UInt16 + let needed: UInt16 + let dataHash: Data +} + +// MARK: - Java-Compatible Random Number Generator + +/// Java's java.util.Random implementation (Linear Congruential Generator) +/// CRITICAL: Must match Java exactly for Android interoperability +struct JavaRandom { + private var seed: Int64 + + init(seed: Int64) { + // Java's Random constructor: (seed ^ 0x5DEECE66DL) & ((1L << 48) - 1) + self.seed = (seed ^ 0x5DEECE66D) & ((Int64(1) << 48) - 1) + } + + /// Generate next random bits (Java's protected next(int bits) method) + mutating func next(bits: Int) -> Int32 { + // seed = (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1) + seed = (seed &* 0x5DEECE66D &+ 0xB) & ((Int64(1) << 48) - 1) + return Int32(truncatingIfNeeded: seed >> (48 - bits)) + } + + /// Generate random int in [0, bound) - matches Java's nextInt(int bound) + mutating func nextInt(bound: Int) -> Int { + guard bound > 0 else { return 0 } + + // Power of 2 optimization + if (bound & -bound) == bound { + return Int((Int64(bound) &* Int64(next(bits: 31))) >> 31) + } + + // Rejection sampling to avoid modulo bias + var bits: Int32 + var val: Int + repeat { + bits = next(bits: 31) + val = Int(bits) % bound + } while bits - Int32(val) + Int32(bound - 1) < 0 + + return val + } + + /// Generate random double in [0.0, 1.0) - matches Java's nextDouble() + mutating func nextDouble() -> Double { + let high = Int64(next(bits: 26)) + let low = Int64(next(bits: 27)) + return Double((high << 27) + low) / Double(Int64(1) << 53) + } +} + +// MARK: - Fountain Codec + +/// Encoder and decoder for fountain-coded transfers +final class FountainCodec { + + static let shared = FountainCodec() + + private var receiveStates: [UInt32: FountainReceiveState] = [:] + + private init() {} + + // MARK: - Transfer ID Generation + + /// Generate a unique random 24-bit transfer ID + /// CRITICAL: Must be random to avoid collisions with recent transfers + func generateTransferId() -> UInt32 { + let random = UInt32.random(in: 0...0xFFFFFF) + let time = UInt32(Date().timeIntervalSince1970) & 0xFFFF + return (random ^ time) & 0xFFFFFF + } + + // MARK: - Encoding + + /// Encode data into fountain-coded blocks + /// - Parameters: + /// - data: The data to encode (should include transfer type prefix) + /// - transferId: Unique transfer ID for this transmission + /// - Returns: Array of encoded block packets ready for transmission + func encode(data: Data, transferId: UInt32) -> [Data] { + // Guard against empty data + guard !data.isEmpty else { + Logger.tak.warning("Fountain encode: empty data") + return [] + } + + let K = max(1, Int(ceil(Double(data.count) / Double(FountainConstants.blockSize)))) + let overhead = getAdaptiveOverhead(K) + let blocksToSend = max(1, Int(ceil(Double(K) * (1.0 + overhead)))) + + // Split into source blocks (pad last block with zeros) + let sourceBlocks = splitIntoBlocks(data: data, K: K) + + // Debug: Log source block hashes to verify they're different + for (i, block) in sourceBlocks.enumerated() { + let hash = block.prefix(8).map { String(format: "%02X", $0) }.joined() + Logger.tak.debug("Fountain sourceBlock[\(i)]: first 8 bytes = \(hash)") + } + + var packets: [Data] = [] + + for i in 0.. [Data] { + var blocks: [Data] = [] + for i in 0.. Data { + var packet = Data() + + // Magic bytes + packet.append(contentsOf: FountainConstants.magic) + + // Transfer ID (24-bit, big-endian) + packet.append(UInt8((transferId >> 16) & 0xFF)) + packet.append(UInt8((transferId >> 8) & 0xFF)) + packet.append(UInt8(transferId & 0xFF)) + + // Seed (16-bit, big-endian) + packet.append(UInt8((seed >> 8) & 0xFF)) + packet.append(UInt8(seed & 0xFF)) + + // K (number of source blocks) + packet.append(K) + + // Total length (16-bit, big-endian) + packet.append(UInt8((totalLength >> 8) & 0xFF)) + packet.append(UInt8(totalLength & 0xFF)) + + // Payload + packet.append(payload) + + return packet + } + + // MARK: - Decoding + + /// Check if data is a fountain packet + static func isFountainPacket(_ data: Data) -> Bool { + guard data.count >= 3 else { return false } + return data[0] == FountainConstants.magic[0] + && data[1] == FountainConstants.magic[1] + && data[2] == FountainConstants.magic[2] + } + + /// Parse a fountain data block header + func parseDataHeader(_ data: Data) -> FountainDataHeader? { + guard data.count >= FountainConstants.dataHeaderSize else { return nil } + guard Self.isFountainPacket(data) else { return nil } + + let transferId = (UInt32(data[3]) << 16) | (UInt32(data[4]) << 8) | UInt32(data[5]) + let seed = (UInt16(data[6]) << 8) | UInt16(data[7]) + let K = data[8] + let totalLength = (UInt16(data[9]) << 8) | UInt16(data[10]) + + return FountainDataHeader(transferId: transferId, seed: seed, K: K, totalLength: totalLength) + } + + /// Handle an incoming fountain packet + /// - Parameters: + /// - data: The raw packet data + /// - senderNodeId: ID of the sending node + /// - Returns: Decoded data if transfer is complete, nil otherwise + func handleIncomingPacket(_ data: Data, senderNodeId: UInt32) -> (data: Data, transferId: UInt32)? { + // Clean up expired states + cleanupExpiredStates() + + guard let header = parseDataHeader(data) else { + Logger.tak.warning("Invalid fountain packet header") + return nil + } + + let payload = data.dropFirst(FountainConstants.dataHeaderSize) + guard payload.count == FountainConstants.blockSize else { + Logger.tak.warning("Invalid fountain payload size: \(payload.count)") + return nil + } + + // Get or create receive state + let state: FountainReceiveState + if let existing = receiveStates[header.transferId] { + state = existing + } else { + state = FountainReceiveState( + transferId: header.transferId, + K: Int(header.K), + totalLength: Int(header.totalLength) + ) + receiveStates[header.transferId] = state + Logger.tak.debug("New fountain transfer: id=\(header.transferId), K=\(header.K), len=\(header.totalLength)") + } + + // Regenerate source indices from seed + let indices = regenerateIndices(seed: header.seed, K: state.K, transferId: header.transferId) + + // Add block + let block = FountainBlock(seed: header.seed, indices: indices, payload: Data(payload)) + state.addBlock(block) + + Logger.tak.debug("Fountain block received: xferId=\(header.transferId), seed=\(header.seed), blocks=\(state.blocks.count)/\(state.K)") + + // Try to decode if we have enough blocks + if state.blocks.count >= state.K { + if let decoded = peelingDecode(state) { + // Remove completed state + receiveStates.removeValue(forKey: header.transferId) + Logger.tak.info("Fountain decode complete: \(decoded.count) bytes from \(state.blocks.count) blocks") + return (decoded, header.transferId) + } + } + + return nil + } + + /// Build an ACK packet + func buildAck(transferId: UInt32, type: UInt8, received: UInt16, needed: UInt16, dataHash: Data) -> Data { + var packet = Data() + + // Magic bytes + packet.append(contentsOf: FountainConstants.magic) + + // Transfer ID (24-bit, big-endian) + packet.append(UInt8((transferId >> 16) & 0xFF)) + packet.append(UInt8((transferId >> 8) & 0xFF)) + packet.append(UInt8(transferId & 0xFF)) + + // Type + packet.append(type) + + // Received (16-bit, big-endian) + packet.append(UInt8((received >> 8) & 0xFF)) + packet.append(UInt8(received & 0xFF)) + + // Needed (16-bit, big-endian) + packet.append(UInt8((needed >> 8) & 0xFF)) + packet.append(UInt8(needed & 0xFF)) + + // Data hash (8 bytes) + packet.append(dataHash.prefix(8)) + + return packet + } + + /// Parse an ACK packet + func parseAck(_ data: Data) -> FountainAck? { + guard data.count >= FountainConstants.ackPacketSize else { return nil } + guard Self.isFountainPacket(data) else { return nil } + + let transferId = (UInt32(data[3]) << 16) | (UInt32(data[4]) << 8) | UInt32(data[5]) + let type = data[6] + let received = (UInt16(data[7]) << 8) | UInt16(data[8]) + let needed = (UInt16(data[9]) << 8) | UInt16(data[10]) + let dataHash = Data(data[11..<19]) + + return FountainAck(transferId: transferId, type: type, received: received, needed: needed, dataHash: dataHash) + } + + // MARK: - Peeling Decoder + + /// Decode using the peeling algorithm + private func peelingDecode(_ state: FountainReceiveState) -> Data? { + var decoded: [Int: Data] = [:] + var workingBlocks = state.blocks.map { $0.copy() } + + var progress = true + while progress && decoded.count < state.K { + progress = false + + for i in 0..= state.K else { + Logger.tak.debug("Peeling decode incomplete: \(decoded.count)/\(state.K) blocks decoded") + return nil + } + + // Reassemble original data + var result = Data() + for i in 0.. Double { + if K <= 10 { return 0.50 } // 50% for very small + else if K <= 50 { return 0.25 } // 25% for small + else { return 0.15 } // 15% for larger + } + + /// Generate deterministic seed from transfer ID and block index + private func generateSeed(transferId: UInt32, blockIndex: Int) -> UInt16 { + let combined = Int(transferId) * 31337 + blockIndex * 7919 + return UInt16(combined & 0xFFFF) + } + + /// Generate indices for encoding a block + /// CRITICAL: Must match Android's exact algorithm for interoperability + /// Android uses Java's java.util.Random (LCG) with specific block 0 handling + private func generateBlockIndices(seed: UInt16, K: Int, blockIndex: Int) -> Set { + var rng = JavaRandom(seed: Int64(seed)) + + // ALWAYS sample degree first (advances RNG state) - matches Android + let sampledDegree = sampleRobustSolitonDegree(&rng, K: K) + + // For block 0: ignore sampled degree, use degree=1 instead + // For other blocks: use the sampled degree + // This matches Android's isFirstBlock logic + let degree = (blockIndex == 0) ? 1 : sampledDegree + + // Select indices with RNG now advanced past degree sampling + return selectIndices(&rng, K: K, degree: degree) + } + + /// Regenerate source indices from seed (must match sender's algorithm) + /// CRITICAL: Must use same RNG flow as generateBlockIndices for Android interop + private func regenerateIndices(seed: UInt16, K: Int, transferId: UInt32) -> Set { + var rng = JavaRandom(seed: Int64(seed)) + + // ALWAYS sample degree first (advances RNG state) - matches Android + let sampledDegree = sampleRobustSolitonDegree(&rng, K: K) + + // Check if this is block 0 (forced degree=1) + let expectedSeed0 = generateSeed(transferId: transferId, blockIndex: 0) + let degree = (seed == expectedSeed0) ? 1 : sampledDegree + + // Select indices with RNG now advanced past degree sampling + return selectIndices(&rng, K: K, degree: degree) + } + + /// Select source block indices using provided RNG + /// Matches Android's selectIndices algorithm exactly + private func selectIndices(_ rng: inout JavaRandom, K: Int, degree: Int) -> Set { + var indices = Set() + + // Select 'degree' unique indices + while indices.count < degree && indices.count < K { + let idx = rng.nextInt(bound: K) + indices.insert(idx) + } + + return indices + } + + /// Sample degree from Robust Soliton distribution using provided RNG + /// Matches Android's sampleDegree algorithm exactly + private func sampleRobustSolitonDegree(_ rng: inout JavaRandom, K: Int) -> Int { + let cdf = buildRobustSolitonCDF(K: K) + let u = rng.nextDouble() + + for d in 1...K { + if u <= cdf[d] { + return d + } + } + return K + } + + /// Build CDF for Robust Soliton distribution + private func buildRobustSolitonCDF(K: Int, c: Double = 0.1, delta: Double = 0.5) -> [Double] { + // Guard against K <= 0 + guard K > 0 else { + return [1.0] // Single element CDF + } + + // Ideal Soliton distribution + var rho = [Double](repeating: 0, count: K + 1) + rho[1] = 1.0 / Double(K) + for d in 2...K { + rho[d] = 1.0 / (Double(d) * Double(d - 1)) + } + + // Robust Soliton addition (tau) + let R = c * log(Double(K) / delta) * sqrt(Double(K)) + var tau = [Double](repeating: 0, count: K + 1) + let threshold = Int(Double(K) / R) + + for d in 1...K { + if d < threshold { + tau[d] = R / (Double(d) * Double(K)) + } else if d == threshold { + tau[d] = R * log(R / delta) / Double(K) + } + } + + // Combine and normalize + var mu = [Double](repeating: 0, count: K + 1) + var sum = 0.0 + for d in 1...K { + mu[d] = rho[d] + tau[d] + sum += mu[d] + } + + // Build CDF + var cdf = [Double](repeating: 0, count: K + 1) + var cumulative = 0.0 + for d in 1...K { + cumulative += mu[d] / sum + cdf[d] = cumulative + } + + return cdf + } + + /// XOR two data blocks + private func xor(_ a: Data, _ b: Data) -> Data { + // IMPORTANT: Rebase inputs to ensure 0-based indices + // Data slices keep original indices which causes crashes when accessing [i] + let aData = a.startIndex == 0 ? a : Data(a) + let bData = b.startIndex == 0 ? b : Data(b) + + var result = Data(count: max(aData.count, bData.count)) + for i in 0.. Data { + let digest = SHA256.hash(data: data) + return Data(digest.prefix(8)) + } + + /// Clean up expired receive states + private func cleanupExpiredStates() { + let expiredIds = receiveStates.filter { $0.value.isExpired }.map { $0.key } + for id in expiredIds { + receiveStates.removeValue(forKey: id) + Logger.tak.debug("Cleaned up expired fountain state: \(id)") + } + } +} diff --git a/Meshtastic/Helpers/TAK/GenericCoTHandler.swift b/Meshtastic/Helpers/TAK/GenericCoTHandler.swift new file mode 100644 index 00000000..6ed357fd --- /dev/null +++ b/Meshtastic/Helpers/TAK/GenericCoTHandler.swift @@ -0,0 +1,399 @@ +// +// GenericCoTHandler.swift +// Meshtastic +// +// Handles generic CoT events that don't map to TAKPacket protobuf +// Uses EXI compression and Fountain codes for reliable transfer +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +/// Port numbers for TAK communication +enum TAKPortNum: UInt32 { + /// TAKPacket protobuf (PLI, GeoChat) - small, structured messages + case atakPlugin = 72 + + /// EXI-compressed CoT XML - generic/large messages, fountain coded + case atakForwarder = 257 +} + +/// Handler for generic CoT events over the mesh network +@MainActor +final class GenericCoTHandler { + + static let shared = GenericCoTHandler() + + weak var accessoryManager: AccessoryManager? + + /// Pending outgoing fountain transfers awaiting ACK + private var pendingTransfers: [UInt32: PendingTransfer] = [:] + + private init() {} + + // MARK: - Outgoing CoT Classification + + /// Determine how a CoT message should be sent + enum CoTSendMethod { + /// Use TAKPacket.pli on ATAK_PLUGIN port + case takPacketPLI + /// Use TAKPacket.chat on ATAK_PLUGIN port + case takPacketChat + /// Use EXI compression on ATAK_FORWARDER port (small, no fountain) + case exiDirect + /// Use EXI + Fountain coding on ATAK_FORWARDER port (large) + case exiFountain + } + + /// Classify a CoT message to determine send method + func classifySendMethod(for cot: CoTMessage) -> CoTSendMethod { + // Self PLI (position) + if cot.type.hasPrefix("a-f-G") || cot.type.hasPrefix("a-f-g") { + return .takPacketPLI + } + + // GeoChat + if cot.type == "b-t-f" { + return .takPacketChat + } + + // Everything else goes through EXI/Forwarder + // Check compressed size to determine if fountain coding needed + let xml = cot.toXML() + if let compressed = EXICodec.shared.compress(xml) { + // +1 for transfer type byte + if compressed.count + 1 < FountainConstants.fountainThreshold { + return .exiDirect + } else { + return .exiFountain + } + } + + // Fallback to direct (compression failed, use raw) + return .exiDirect + } + + // MARK: - Sending Generic CoT + + /// Send a generic CoT event (markers, shapes, routes, etc.) + /// - Parameters: + /// - cot: The CoT message to send + /// - channel: Meshtastic channel (0 = primary) + func sendGenericCoT(_ cot: CoTMessage, channel: UInt32 = 0) async throws { + guard let accessoryManager else { + throw GenericCoTError.notConnected + } + + guard accessoryManager.isConnected else { + throw GenericCoTError.notConnected + } + + // Compress to EXI + let xml = cot.toXML() + guard let exiData = EXICodec.shared.compress(xml) else { + throw GenericCoTError.compressionFailed + } + + // Prepend transfer type + var payload = Data([FountainConstants.transferTypeCot]) + payload.append(exiData) + + Logger.tak.debug("Generic CoT: type=\(cot.type), xml=\(xml.count)B, compressed=\(payload.count)B") + + // Check if small enough to send directly + if payload.count < FountainConstants.fountainThreshold { + try await sendDirect(payload, channel: channel) + } else { + try await sendFountainCoded(payload, channel: channel) + } + } + + /// Send small payload directly (no fountain coding) + private func sendDirect(_ payload: Data, channel: UInt32) async throws { + guard let accessoryManager, let activeConnection = accessoryManager.activeConnection else { + throw GenericCoTError.notConnected + } + + guard let deviceNum = activeConnection.device.num else { + throw GenericCoTError.noDeviceNumber + } + + var dataMessage = DataMessage() + dataMessage.portnum = .atakForwarder // Port 257 + dataMessage.payload = payload + + var meshPacket = MeshPacket() + meshPacket.to = 0xFFFFFFFF // Broadcast + meshPacket.from = UInt32(deviceNum) + meshPacket.channel = channel + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. CoTMessage? { + guard case let .decoded(data) = packet.payloadVariant else { + Logger.tak.warning("ATAK_FORWARDER packet without decoded payload") + return nil + } + + let payload = data.payload + guard !payload.isEmpty else { + Logger.tak.warning("Empty ATAK_FORWARDER payload") + return nil + } + + // Check if this is a fountain packet (starts with "FTN" magic) + if FountainCodec.isFountainPacket(payload) { + // Distinguish between ACK (19 bytes) and data block (231 bytes) + // ACK: magic(3) + transferId(3) + type(1) + received(2) + needed(2) + hash(8) = 19 + // Data: magic(3) + transferId(3) + seed(2) + K(1) + totalLen(2) + payload(220) = 231 + if payload.count == FountainConstants.ackPacketSize { + // This is a fountain ACK - handle it and return nil (no CoT to forward) + handleIncomingAck(payload, from: packet.from) + return nil + } + return handleFountainPacket(payload, from: packet.from) + } + + // Direct packet (not fountain coded) + return handleDirectPacket(payload, from: packet.from) + } + + /// Handle direct (non-fountain) packet + private func handleDirectPacket(_ payload: Data, from nodeNum: UInt32) -> CoTMessage? { + guard payload.count > 1 else { + Logger.tak.warning("Direct packet too short: \(payload.count) bytes") + return nil + } + + let transferType = payload[0] + let exiData = payload.dropFirst() + + guard transferType == FountainConstants.transferTypeCot else { + Logger.tak.debug("Ignoring non-CoT transfer type: \(transferType)") + return nil + } + + // Decompress EXI to XML + guard let xml = EXICodec.shared.decompress(Data(exiData)) else { + Logger.tak.warning("Failed to decompress EXI data from node \(nodeNum)") + return nil + } + + // Parse CoT XML + guard let cot = CoTMessage.parse(from: xml) else { + Logger.tak.warning("Failed to parse CoT XML from node \(nodeNum)") + return nil + } + + Logger.tak.info("Received generic CoT from node \(nodeNum): \(cot.type)") + return cot + } + + /// Handle fountain-coded packet + private func handleFountainPacket(_ payload: Data, from nodeNum: UInt32) -> CoTMessage? { + // Pass to fountain codec + guard let (decodedData, transferId) = FountainCodec.shared.handleIncomingPacket(payload, senderNodeId: nodeNum) else { + // Not yet complete, waiting for more blocks + return nil + } + + // Transfer complete - send ACK (twice for redundancy) + let hash = FountainCodec.computeHash(decodedData) + Task { + await sendFountainAck(transferId: transferId, hash: hash, to: nodeNum) + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms delay + await sendFountainAck(transferId: transferId, hash: hash, to: nodeNum) + } + + // Extract transfer type and data + guard decodedData.count > 1 else { + Logger.tak.warning("Decoded fountain data too short") + return nil + } + + let transferType = decodedData[0] + let exiData = decodedData.dropFirst() + + guard transferType == FountainConstants.transferTypeCot else { + Logger.tak.debug("Ignoring non-CoT fountain transfer type: \(transferType)") + return nil + } + + // Decompress EXI to XML + guard let xml = EXICodec.shared.decompress(Data(exiData)) else { + Logger.tak.warning("Failed to decompress fountain EXI data") + return nil + } + + // Parse CoT XML + guard let cot = CoTMessage.parse(from: xml) else { + Logger.tak.warning("Failed to parse fountain CoT XML") + return nil + } + + Logger.tak.info("Received fountain-coded CoT from node \(nodeNum): \(cot.type)") + return cot + } + + /// Send fountain ACK + private func sendFountainAck(transferId: UInt32, hash: Data, to nodeNum: UInt32) async { + guard let accessoryManager, let activeConnection = accessoryManager.activeConnection else { + return + } + + guard let deviceNum = activeConnection.device.num else { + return + } + + let ackPacket = FountainCodec.shared.buildAck( + transferId: transferId, + type: FountainConstants.ackTypeComplete, + received: 0, + needed: 0, + dataHash: hash + ) + + var dataMessage = DataMessage() + dataMessage.portnum = .atakForwarder + dataMessage.payload = ackPacket + + var meshPacket = MeshPacket() + meshPacket.to = nodeNum + meshPacket.from = UInt32(deviceNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag, + kSecReturnRef as String: true + ] + var item: CFTypeRef? + return SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess + } + + /// Get the bundled CA certificate data for sharing to ITAK + func getBundledCACertificateData() -> Data? { + let pemURL = Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "ca", withExtension: "pem") + + guard let url = pemURL, let pemData = try? Data(contentsOf: url) else { + return nil + } + return pemData + } + + /// Get URL to bundled CA certificate for sharing + func getBundledCACertificateURL() -> URL? { + return Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "ca", withExtension: "pem") + } + + /// Get the bundled server P12 data for sharing to ITAK (used as truststore) + func getBundledServerP12Data() -> Data? { + let p12URL = Bundle.main.url(forResource: "server", withExtension: "p12", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "server", withExtension: "p12") + + guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else { + return nil + } + return p12Data + } + + /// Get the password for bundled certificates (for data package) + func getBundledCertificatePassword() -> String { + return bundledPassword + } + + /// Get the bundled client P12 data for sharing to ITAK (for mutual TLS) + func getBundledClientP12Data() -> Data? { + let p12URL = Bundle.main.url(forResource: "client", withExtension: "p12", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "client", withExtension: "p12") + + guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else { + return nil + } + return p12Data + } + + /// Check if a bundled client certificate exists + func hasBundledClientCertificate() -> Bool { + return getBundledClientP12Data() != nil + } + + // MARK: - Active Certificate Data (for Data Package) + + /// Get the active server P12 data (custom if available, otherwise bundled) + /// Used for generating data packages + func getActiveServerP12Data() -> Data? { + // Check for custom certificate first + if hasCustomServerCertificate(), + let customData = UserDefaults.standard.data(forKey: customServerP12DataKey) { + Logger.tak.debug("Using custom server P12 for data package") + return customData + } + // Fall back to bundled + Logger.tak.debug("Using bundled server P12 for data package") + return getBundledServerP12Data() + } + + /// Get the active client P12 data (custom if available, otherwise bundled) + /// Used for generating data packages + func getActiveClientP12Data() -> Data? { + // Check for custom certificate first + if let customData = UserDefaults.standard.data(forKey: customClientP12DataKey) { + Logger.tak.debug("Using custom client P12 for data package") + return customData + } + // Fall back to bundled + Logger.tak.debug("Using bundled client P12 for data package") + return getBundledClientP12Data() + } + + /// Get the password for the active server certificate + func getActiveServerCertificatePassword() -> String { + if hasCustomServerCertificate(), + let customPassword = UserDefaults.standard.string(forKey: customServerP12PasswordKey) { + return customPassword + } + return bundledPassword + } + + /// Get the password for the active client certificate + func getActiveClientCertificatePassword() -> String { + if let customPassword = UserDefaults.standard.string(forKey: customClientP12PasswordKey) { + return customPassword + } + return bundledPassword + } + + /// Import a custom client P12 certificate (for data package generation) + func importCustomClientP12(data: Data, password: String) { + UserDefaults.standard.set(data, forKey: customClientP12DataKey) + UserDefaults.standard.set(password, forKey: customClientP12PasswordKey) + Logger.tak.info("Custom client P12 imported for data package") + } + + /// Check if custom client P12 is available + func hasCustomClientP12() -> Bool { + return UserDefaults.standard.data(forKey: customClientP12DataKey) != nil + } + + /// Clear custom certificate data (called when resetting to defaults) + private func clearCustomCertificateData() { + UserDefaults.standard.removeObject(forKey: customServerP12DataKey) + UserDefaults.standard.removeObject(forKey: customServerP12PasswordKey) + UserDefaults.standard.removeObject(forKey: customClientP12DataKey) + UserDefaults.standard.removeObject(forKey: customClientP12PasswordKey) + Logger.tak.debug("Cleared custom certificate data") + } + + // MARK: - Server Identity (PKCS#12) + + /// Import server identity from PKCS#12 (.p12) file data + /// - Parameters: + /// - p12Data: The raw PKCS#12 file data + /// - password: Password to decrypt the PKCS#12 file + /// - isCustom: Whether this is a user-imported custom certificate (default: true) + /// - Returns: The imported SecIdentity + func importServerIdentity(from p12Data: Data, password: String, isCustom: Bool = true) throws -> SecIdentity { + let options: [String: Any] = [kSecImportExportPassphrase as String: password] + var items: CFArray? + + let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items) + + guard status == errSecSuccess else { + Logger.tak.error("Failed to import PKCS#12: \(status)") + throw TAKCertificateError.importFailed(status) + } + + guard let itemArray = items as? [[String: Any]], + let firstItem = itemArray.first, + let identity = firstItem[kSecImportItemIdentity as String] as! SecIdentity? else { + throw TAKCertificateError.noIdentityFound + } + + // Store in Keychain for persistence + try storeServerIdentity(identity, isCustom: isCustom) + + // Store the raw P12 data and password for data package generation (only for custom certs) + if isCustom { + UserDefaults.standard.set(p12Data, forKey: customServerP12DataKey) + UserDefaults.standard.set(password, forKey: customServerP12PasswordKey) + Logger.tak.debug("Stored custom server P12 data for data package generation") + } + + Logger.tak.info("Server identity imported successfully (custom: \(isCustom))") + return identity + } + + /// Store server identity in Keychain + private func storeServerIdentity(_ identity: SecIdentity, isCustom: Bool = true) throws { + let tag = isCustom ? serverIdentityCustomTag : serverIdentityTag + + // First delete any existing identity with this tag + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: tag + ] + SecItemDelete(deleteQuery as CFDictionary) + + // If storing custom cert, also delete the bundled one (custom takes precedence) + if isCustom { + let deleteBundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + SecItemDelete(deleteBundledQuery as CFDictionary) + } + + // Add new identity + let addQuery: [String: Any] = [ + kSecValueRef as String: identity, + kSecAttrLabel as String: tag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + Logger.tak.error("Failed to store server identity in Keychain: \(status)") + throw TAKCertificateError.keychainError(status) + } + } + + /// Retrieve stored server identity from Keychain + /// Custom certificates take precedence over bundled ones + func getServerIdentity() -> SecIdentity? { + // First try custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag, + kSecReturnRef as String: true + ] + + var item: CFTypeRef? + var status = SecItemCopyMatching(customQuery as CFDictionary, &item) + + if status == errSecSuccess { + return (item as! SecIdentity) + } + + // Fall back to bundled certificate + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag, + kSecReturnRef as String: true + ] + + status = SecItemCopyMatching(bundledQuery as CFDictionary, &item) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + Logger.tak.warning("Failed to retrieve server identity: \(status)") + } + return nil + } + + return (item as! SecIdentity) + } + + /// Check if server certificate is configured + func hasServerCertificate() -> Bool { + return getServerIdentity() != nil + } + + /// Delete custom server identity and reload bundled default + func deleteServerIdentity() { + // Delete custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag + ] + let customStatus = SecItemDelete(customQuery as CFDictionary) + + // Delete bundled certificate too + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + let bundledStatus = SecItemDelete(bundledQuery as CFDictionary) + + if customStatus == errSecSuccess || bundledStatus == errSecSuccess { + Logger.tak.info("Server identity deleted") + } + + // Reload bundled default + loadBundledServerIdentity() + } + + /// Reset to bundled default certificate (deletes custom certificate) + func resetToDefaultServerCertificate() { + // Clear custom certificate data from UserDefaults + clearCustomCertificateData() + + // Delete custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag + ] + SecItemDelete(customQuery as CFDictionary) + + // Delete existing bundled and reload + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + SecItemDelete(bundledQuery as CFDictionary) + + loadBundledServerIdentity() + Logger.tak.info("Reset to bundled default server certificate") + } + + /// Get certificate info for display purposes + func getServerCertificateInfo() -> String? { + guard let identity = getServerIdentity() else { return nil } + + var certificate: SecCertificate? + let status = SecIdentityCopyCertificate(identity, &certificate) + guard status == errSecSuccess, let cert = certificate else { return nil } + + let isCustom = hasCustomServerCertificate() + let prefix = isCustom ? "Custom: " : "Default: " + + if let summary = SecCertificateCopySubjectSummary(cert) as String? { + return prefix + summary + } + + return prefix + "Certificate loaded" + } + + // MARK: - Client CA Certificates (PEM) + + /// Import client CA certificate from PEM file data + /// - Parameter pemData: The raw PEM file data + /// - Returns: The imported SecCertificate + func importClientCACertificate(from pemData: Data) throws -> SecCertificate { + // Extract DER data from PEM format + let derData = try extractDERFromPEM(pemData) + + guard let certificate = SecCertificateCreateWithData(nil, derData as CFData) else { + throw TAKCertificateError.invalidCertificate + } + + // Store in Keychain + try storeClientCACertificate(certificate) + + Logger.tak.info("Client CA certificate imported successfully") + return certificate + } + + /// Extract DER-encoded certificate data from PEM format + private func extractDERFromPEM(_ pemData: Data) throws -> Data { + guard let pemString = String(data: pemData, encoding: .utf8) else { + throw TAKCertificateError.invalidPEM + } + + // Remove PEM headers and whitespace + let base64 = pemString + .replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "") + .replacingOccurrences(of: "-----END CERTIFICATE-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let derData = Data(base64Encoded: base64) else { + throw TAKCertificateError.invalidPEM + } + + return derData + } + + /// Store client CA certificate in Keychain + private func storeClientCACertificate(_ certificate: SecCertificate) throws { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: certificate, + kSecAttrLabel as String: clientCATag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + // Ignore duplicate item errors (certificate already imported) + guard status == errSecSuccess || status == errSecDuplicateItem else { + Logger.tak.error("Failed to store client CA certificate: \(status)") + throw TAKCertificateError.keychainError(status) + } + } + + /// Get all stored client CA certificates + func getClientCACertificates() -> [SecCertificate] { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: clientCATag, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var items: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &items) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + Logger.tak.warning("Failed to retrieve client CA certificates: \(status)") + } + return [] + } + + // Handle both single item and array returns + if let certificates = items as? [SecCertificate] { + return certificates + } else if let certificate = items as! SecCertificate? { + return [certificate] + } + + return [] + } + + /// Check if at least one client CA certificate is configured + func hasClientCACertificate() -> Bool { + return !getClientCACertificates().isEmpty + } + + /// Delete all client CA certificates from Keychain + func deleteClientCACertificates() { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: clientCATag + ] + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { + Logger.tak.info("Client CA certificates deleted") + } + } + + /// Get info about stored client CA certificates for display + func getClientCACertificateInfo() -> [String] { + let certificates = getClientCACertificates() + return certificates.compactMap { cert in + SecCertificateCopySubjectSummary(cert) as String? + } + } + + // MARK: - Certificate Validation + + /// Validate a client certificate against the stored CA certificates + func validateClientCertificate(_ trust: SecTrust) -> Bool { + let caCertificates = getClientCACertificates() + + guard !caCertificates.isEmpty else { + Logger.tak.warning("No client CA certificates configured for validation") + return false + } + + // Set the anchor certificates (trusted CAs) + SecTrustSetAnchorCertificates(trust, caCertificates as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + + var error: CFError? + let isValid = SecTrustEvaluateWithError(trust, &error) + + if !isValid { + Logger.tak.warning("Client certificate validation failed: \(error?.localizedDescription ?? "unknown")") + } + + return isValid + } +} + +// MARK: - Certificate Errors + +enum TAKCertificateError: LocalizedError { + case importFailed(OSStatus) + case noIdentityFound + case invalidCertificate + case invalidPEM + case keychainError(OSStatus) + case certificateExpired + case certificateNotYetValid + + var errorDescription: String? { + switch self { + case .importFailed(let status): + return "Failed to import PKCS#12: \(securityErrorMessage(status))" + case .noIdentityFound: + return "No identity (certificate + private key) found in PKCS#12 file" + case .invalidCertificate: + return "Invalid certificate data" + case .invalidPEM: + return "Invalid PEM format - ensure file contains a valid certificate" + case .keychainError(let status): + return "Keychain error: \(securityErrorMessage(status))" + case .certificateExpired: + return "Certificate has expired" + case .certificateNotYetValid: + return "Certificate is not yet valid" + } + } + + private func securityErrorMessage(_ status: OSStatus) -> String { + if let message = SecCopyErrorMessageString(status, nil) { + return message as String + } + return "Error code: \(status)" + } +} diff --git a/Meshtastic/Helpers/TAK/TAKConnection.swift b/Meshtastic/Helpers/TAK/TAKConnection.swift new file mode 100644 index 00000000..52d504e9 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKConnection.swift @@ -0,0 +1,496 @@ +// +// TAKConnection.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import Network +import OSLog + +/// Actor managing a single TAK client TLS connection +/// Handles CoT XML streaming protocol (messages delimited by ) +/// Implements TAK Protocol negotiation and keepalive +actor TAKConnection { + private let connection: NWConnection + private var messageBuffer = Data() + private var readerTask: Task? + private var keepaliveTask: Task? + private var continuation: AsyncStream.Continuation? + + // CoT XML message delimiters (from StreamingCotProtocol.java) + private let startTag = " AsyncStream { + AsyncStream { continuation in + self.continuation = continuation + + continuation.onTermination = { [weak self] _ in + Task { [weak self] in + await self?.disconnect() + } + } + + // Set up state handler + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + Task { + await self.handleStateChange(state) + } + } + + // Start the connection + connection.start(queue: DispatchQueue(label: "tak.connection.\(UUID().uuidString)")) + } + } + + /// Handle connection state changes + private func handleStateChange(_ state: NWConnection.State) { + switch state { + case .ready: + isConnected = true + Logger.tak.info("TAK client connected: \(self.connection.endpoint.debugDescription)") + + // Extract client certificate info if available + extractClientInfo() + + // Notify connected + let info = clientInfo ?? TAKClientInfo(endpoint: connection.endpoint, connectedAt: Date()) + continuation?.yield(.connected(info)) + + // Send protocol support advertisement + Task { + await sendProtocolSupport() + } + + // Start reading data + startReading() + + // Start keepalive task + startKeepalive() + + case .failed(let error): + Logger.tak.error("TAK connection failed: \(error.localizedDescription)") + isConnected = false + continuation?.yield(.error(error)) + continuation?.yield(.disconnected) + continuation?.finish() + + case .cancelled: + Logger.tak.info("TAK connection cancelled") + isConnected = false + continuation?.yield(.disconnected) + continuation?.finish() + + case .waiting(let error): + Logger.tak.warning("TAK connection waiting: \(error.localizedDescription)") + + case .preparing: + Logger.tak.debug("TAK connection preparing") + + case .setup: + Logger.tak.debug("TAK connection setup") + + @unknown default: + break + } + } + + /// Extract client information from the TLS session + private func extractClientInfo() { + // Client callsign/uid will be updated when first CoT message is received + // For now just create basic client info with endpoint + clientInfo = TAKClientInfo( + endpoint: connection.endpoint, + callsign: nil, + uid: nil, + connectedAt: Date() + ) + Logger.tak.info("TAK client connected from: \(self.connection.endpoint.debugDescription)") + } + + /// Start the reader task to continuously read from the connection + private func startReading() { + readerTask = Task { + while !Task.isCancelled && isConnected { + do { + let data = try await receiveData() + if !data.isEmpty { + processReceivedData(data) + } + } catch { + if !Task.isCancelled { + Logger.tak.error("TAK read error: \(error.localizedDescription)") + continuation?.yield(.error(error)) + continuation?.yield(.disconnected) + } + break + } + } + } + } + + /// Receive data from the connection + private func receiveData() async throws -> Data { + try await withCheckedThrowingContinuation { cont in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in + if let error { + cont.resume(throwing: error) + return + } + if isComplete { + cont.resume(throwing: TAKConnectionError.connectionClosed) + return + } + if let content { + cont.resume(returning: content) + } else { + cont.resume(returning: Data()) + } + } + } + } + + /// Process received data using streaming CoT protocol + /// Based on StreamingCotProtocol.java parsing logic from TAK Server + private func processReceivedData(_ newData: Data) { + messageBuffer.append(newData) + + // Search for complete CoT messages (delimited by ) + while let endRange = messageBuffer.range(of: endTag) { + // Find the start tag before this end tag + guard let startRange = messageBuffer.range(of: startTag) else { + // No start tag found, discard data up to end tag + Logger.tak.warning("CoT end tag without start tag, discarding") + messageBuffer.removeSubrange(.. maxMessageSize { + Logger.tak.warning("Message buffer exceeded limit (\(self.messageBuffer.count) bytes), clearing") + messageBuffer.removeAll() + } + } + + /// Parse XML data and yield the message event + private func parseAndYieldMessage(_ data: Data) { + // Log raw XML for debugging + if let xmlString = String(data: data, encoding: .utf8) { + Logger.tak.debug("=== Received CoT XML (\(data.count) bytes) ===") + Logger.tak.debug("\(xmlString)") + Logger.tak.debug("=== End Raw XML ===") + } + + do { + let cotMessage = try CoTMessage.parse(from: data) + + // Handle TAK Protocol control messages + if cotMessage.type.hasPrefix("t-x-takp") { + Logger.tak.debug("Handling TAK Protocol control message: \(cotMessage.type)") + Task { + await handleProtocolControl(cotMessage) + } + return // Don't forward control messages to app + } + + // Handle ping/pong messages (don't forward, just acknowledge) + if cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" { + Logger.tak.debug("Received ping from client") + return + } + + // Update client info if we got contact details + if let contact = cotMessage.contact { + if clientInfo?.callsign == nil { + clientInfo?.callsign = contact.callsign + } + if clientInfo?.uid == nil { + clientInfo?.uid = cotMessage.uid + } + // Update the connected event with new info + if let info = clientInfo { + continuation?.yield(.clientInfoUpdated(info)) + } + } + + Logger.tak.info("Received CoT message: type=\(cotMessage.type), uid=\(cotMessage.uid)") + Logger.tak.debug(" contact: \(cotMessage.contact?.callsign ?? "nil")") + Logger.tak.debug(" lat/lon: \(cotMessage.latitude), \(cotMessage.longitude)") + continuation?.yield(.message(cotMessage)) + + } catch { + Logger.tak.warning("Failed to parse CoT message: \(error.localizedDescription)") + // Log the raw XML for debugging + if let xmlString = String(data: data, encoding: .utf8) { + Logger.tak.debug("Failed Raw CoT XML: \(xmlString.prefix(500))") + } + } + } + + // MARK: - Protocol Negotiation + + /// Send TAK Protocol Support advertisement to client + /// This tells the client what protocol versions we support (Version 0 = XML only) + private func sendProtocolSupport() async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(60)) + + // TAK Protocol Support message - advertise version 0 (XML) only + // Type t-x-takp-v indicates TAK Protocol version advertisement + let xml = """ + + + + + + + + + """ + + do { + try await sendRawXML(xml) + Logger.tak.info("Sent TakProtocolSupport to client (version 0 - XML)") + } catch { + Logger.tak.error("Failed to send TakProtocolSupport: \(error.localizedDescription)") + } + } + + /// Handle TAK Protocol control messages (TakRequest, etc.) + private func handleProtocolControl(_ cotMessage: CoTMessage) async { + // Check for protocol request in the raw XML + // Type t-x-takp-q is a protocol request from client + if cotMessage.type == "t-x-takp-q" { + await sendProtocolResponse(accepted: true) + } + } + + /// Send protocol response to client + private func sendProtocolResponse(accepted: Bool) async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(60)) + + // Type t-x-takp-r is TAK Protocol response + let xml = """ + + + + + + + + + """ + + do { + try await sendRawXML(xml) + protocolNegotiated = true + Logger.tak.info("Sent TakResponse (accepted: \(accepted))") + } catch { + Logger.tak.error("Failed to send TakResponse: \(error.localizedDescription)") + } + } + + // MARK: - Keepalive + + /// Start the keepalive task to send periodic pings + private func startKeepalive() { + keepaliveTask = Task { + while !Task.isCancelled && isConnected { + do { + try await Task.sleep(nanoseconds: keepaliveInterval) + if isConnected { + await sendKeepalive() + } + } catch { + break + } + } + } + } + + /// Send a keepalive/ping message to client + private func sendKeepalive() async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(120)) + + // t-x-c-t is a ping/keepalive type, t-x-d-d is also used for takPong + let xml = """ + + + + + """ + + do { + try await sendRawXML(xml) + Logger.tak.debug("Sent keepalive to client") + } catch { + Logger.tak.warning("Failed to send keepalive: \(error.localizedDescription)") + } + } + + // MARK: - Send Methods + + /// Send raw XML string to the client + private func sendRawXML(_ xml: String) async throws { + guard isConnected else { + throw TAKConnectionError.notConnected + } + + guard let data = xml.data(using: .utf8) else { + throw TAKConnectionError.encodingFailed + } + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + }) + } + } + + /// Send a CoT message to this client + func send(_ cotMessage: CoTMessage) async throws { + guard isConnected else { + throw TAKConnectionError.notConnected + } + + let xml = cotMessage.toXML() + guard let data = xml.data(using: .utf8) else { + throw TAKConnectionError.encodingFailed + } + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + }) + } + + Logger.tak.debug("Sent CoT message to client: type=\(cotMessage.type)") + } + + /// Disconnect this client + func disconnect() { + guard isConnected else { return } + + Logger.tak.info("Disconnecting TAK client: \(self.connection.endpoint.debugDescription)") + + isConnected = false + readerTask?.cancel() + readerTask = nil + keepaliveTask?.cancel() + keepaliveTask = nil + connection.cancel() + messageBuffer.removeAll() + + continuation?.yield(.disconnected) + continuation?.finish() + continuation = nil + } +} + +// MARK: - Supporting Types + +/// Information about a connected TAK client +struct TAKClientInfo: Identifiable, Sendable { + let id = UUID() + let endpoint: NWEndpoint + var callsign: String? + var uid: String? + let connectedAt: Date + + init(endpoint: NWEndpoint, callsign: String? = nil, uid: String? = nil, connectedAt: Date = Date()) { + self.endpoint = endpoint + self.callsign = callsign + self.uid = uid + self.connectedAt = connectedAt + } + + var displayName: String { + callsign ?? uid ?? endpoint.debugDescription + } +} + +/// Events emitted by a TAK connection +enum TAKConnectionEvent: Sendable { + case connected(TAKClientInfo) + case clientInfoUpdated(TAKClientInfo) + case message(CoTMessage) + case disconnected + case error(Error) +} + +/// Errors specific to TAK connections +enum TAKConnectionError: LocalizedError { + case connectionClosed + case notConnected + case encodingFailed + case sendFailed(String) + + var errorDescription: String? { + switch self { + case .connectionClosed: + return "Connection was closed" + case .notConnected: + return "Not connected" + case .encodingFailed: + return "Failed to encode CoT message" + case .sendFailed(let reason): + return "Failed to send: \(reason)" + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift b/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift new file mode 100644 index 00000000..427a7aa8 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift @@ -0,0 +1,261 @@ +// +// 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 """ + + + + 1 + \(escapeXML(description)) + true + \(serverHost):\(port):\(protocolType) + + + true + cert/truststore.p12 + \(serverPassword) + cert/client.p12 + \(clientPassword) + + + """ + } else { + // TCP mode - no certificates needed + return """ + + + + 1 + \(escapeXML(description)) + true + \(serverHost):\(port):\(protocolType) + + + true + + + """ + } + } + + // 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 """ + + + + + + + + + + + + + """ + } else { + // TCP mode - just the pref file + return """ + + + + + + + + + + + """ + } + } + + // MARK: - Helper Methods + + private func escapeXML(_ string: String) -> String { + return string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + // 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 + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift new file mode 100644 index 00000000..9ed42c90 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift @@ -0,0 +1,516 @@ +// +// TAKMeshtasticBridge.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import OSLog +import CoreData + +/// Bridges CoT messages between TAK clients and the Meshtastic mesh network +/// Handles bidirectional conversion and message routing +@MainActor +final class TAKMeshtasticBridge { + + weak var accessoryManager: AccessoryManager? + weak var takServerManager: TAKServerManager? + + /// Core Data context for node lookups + var context: NSManagedObjectContext? + + /// Lookup table mapping callsigns to device UIDs + /// Populated when receiving PLI packets from other TAK users + /// Key: callsign (e.g., "OLD SALT"), Value: device UID (e.g., "ANDROID-abc123-def456") + private var callsignToDeviceUID: [String: String] = [:] + + init(accessoryManager: AccessoryManager?, takServerManager: TAKServerManager?) { + self.accessoryManager = accessoryManager + self.takServerManager = takServerManager + } + + // MARK: - Callsign to Device UID Mapping + + /// Register a callsign β†’ device UID mapping (called when receiving PLI from other users) + func registerContact(callsign: String, deviceUID: String) { + guard !callsign.isEmpty, !deviceUID.isEmpty else { return } + // Extract actual device UID in case it has a smuggled messageId + let (actualDeviceUID, _) = Self.parseDeviceCallsign(deviceUID) + guard !actualDeviceUID.isEmpty else { return } + let previousUID = callsignToDeviceUID[callsign] + callsignToDeviceUID[callsign] = actualDeviceUID + if previousUID != actualDeviceUID { + Logger.tak.debug("Registered contact: \(callsign) β†’ \(actualDeviceUID)") + } + } + + // MARK: - Read Receipt Handling + + /// Receipt type for GeoChat read receipts + enum ReceiptType { + case delivered // ACK:D - Message delivered to device + case read // ACK:R - Message read by user + } + + /// Parsed read receipt from a GeoChat message + struct ParsedReceipt { + let type: ReceiptType + let messageId: String + } + + /// Check if a GeoChat message is a read receipt + /// Receipt format: "ACK:D:" or "ACK:R:" + /// - Parameter message: The GeoChat message content + /// - Returns: Parsed receipt if this is a receipt, nil otherwise + nonisolated static func parseReceipt(from message: String) -> ParsedReceipt? { + guard message.hasPrefix("ACK:") else { return nil } + + let parts = message.split(separator: ":", maxSplits: 2) + guard parts.count == 3 else { + return nil + } + + let receiptTypeString = String(parts[1]) + let messageId = String(parts[2]) + + guard !messageId.isEmpty else { return nil } + + let receiptType: ReceiptType + switch receiptTypeString { + case "D": + receiptType = .delivered + case "R": + receiptType = .read + default: + return nil + } + + return ParsedReceipt(type: receiptType, messageId: messageId) + } + + /// Check if a TAKPacket GeoChat is a read receipt + nonisolated static func isReceipt(_ takPacket: TAKPacket) -> Bool { + guard case .chat(let geoChat) = takPacket.payloadVariant else { + return false + } + return geoChat.message.hasPrefix("ACK:") + } + + // MARK: - MessageId Smuggling in device_callsign + + /// Parse a device_callsign that may contain a smuggled messageId + /// Format: "|" or just "" + /// - Parameter combined: The device_callsign field value + /// - Returns: Tuple of (actualDeviceCallsign, messageId) where messageId is nil if not present + nonisolated static func parseDeviceCallsign(_ combined: String?) -> (deviceCallsign: String, messageId: String?) { + guard let combined = combined, !combined.isEmpty else { + return ("", nil) + } + + if let separatorIndex = combined.firstIndex(of: "|") { + let deviceCallsign = String(combined[..|" + /// - Parameters: + /// - deviceCallsign: The actual device UID + /// - messageId: The message ID to smuggle + /// - Returns: Combined string with messageId appended + nonisolated static func createSmuggledDeviceCallsign(deviceCallsign: String, messageId: String) -> String { + return "\(deviceCallsign)|\(messageId)" + } + + /// Look up a device UID from a callsign + func lookupDeviceUID(forCallsign callsign: String) -> String? { + return callsignToDeviceUID[callsign] + } + + // MARK: - TAK β†’ Meshtastic (CoT to TAKPacket) + + /// Send a CoT message received from TAK to the Meshtastic mesh + func sendToMesh(_ cotMessage: CoTMessage) async { + guard let accessoryManager else { + Logger.tak.warning("Cannot send to mesh: AccessoryManager not available") + return + } + + guard accessoryManager.isConnected else { + Logger.tak.warning("Cannot send to mesh: Not connected to Meshtastic device") + return + } + + // Determine send method based on CoT type + let sendMethod = GenericCoTHandler.shared.classifySendMethod(for: cotMessage) + + switch sendMethod { + case .takPacketPLI, .takPacketChat: + // Use TAKPacket protobuf on ATAK_PLUGIN port (72) + guard let takPacket = convertToTAKPacket(cot: cotMessage) else { + Logger.tak.warning("Failed to convert CoT to TAKPacket: \(cotMessage.type)") + return + } + + do { + try await accessoryManager.sendTAKPacket(takPacket) + Logger.tak.info("Sent TAKPacket to mesh: \(cotMessage.type)") + } catch { + Logger.tak.error("Failed to send TAKPacket to mesh: \(error.localizedDescription)") + } + + case .exiDirect, .exiFountain: + // Use EXI compression on ATAK_FORWARDER port (257) + GenericCoTHandler.shared.accessoryManager = accessoryManager + do { + try await GenericCoTHandler.shared.sendGenericCoT(cotMessage) + Logger.tak.info("Sent generic CoT to mesh via ATAK_FORWARDER: \(cotMessage.type)") + } catch { + Logger.tak.error("Failed to send generic CoT to mesh: \(error.localizedDescription)") + } + } + } + + /// Convert CoT message to Meshtastic TAKPacket protobuf + func convertToTAKPacket(cot: CoTMessage) -> TAKPacket? { + Logger.tak.debug("=== CoT β†’ TAKPacket Conversion ===") + Logger.tak.debug("CoT Input:") + Logger.tak.debug(" uid: \(cot.uid)") + Logger.tak.debug(" type: \(cot.type)") + Logger.tak.debug(" lat: \(cot.latitude), lon: \(cot.longitude), hae: \(cot.hae)") + Logger.tak.debug(" contact: \(cot.contact?.callsign ?? "nil")") + Logger.tak.debug(" group: \(cot.group?.name ?? "nil") / \(cot.group?.role ?? "nil")") + Logger.tak.debug(" status.battery: \(cot.status?.battery ?? -1)") + Logger.tak.debug(" track: speed=\(cot.track?.speed ?? -1), course=\(cot.track?.course ?? -1)") + Logger.tak.debug(" chat: \(cot.chat?.message ?? "nil")") + Logger.tak.debug(" remarks: \(cot.remarks ?? "nil")") + + var takPacket = TAKPacket() + + // Contact information + if let contact = cot.contact { + var cotContact = Contact() + cotContact.callsign = contact.callsign + cotContact.deviceCallsign = cot.uid + takPacket.contact = cotContact + Logger.tak.debug("TAKPacket.contact: callsign=\(cotContact.callsign), deviceCallsign=\(cotContact.deviceCallsign)") + } + + // Group/Team information + if let group = cot.group { + var cotGroup = Group() + cotGroup.team = Team.fromColorName(group.name) + cotGroup.role = MemberRole.fromRoleName(group.role) + takPacket.group = cotGroup + Logger.tak.debug("TAKPacket.group: team=\(cotGroup.team.rawValue), role=\(cotGroup.role.rawValue)") + } + + // Status (battery) + if let status = cot.status { + var cotStatus = Status() + cotStatus.battery = UInt32(max(0, status.battery)) + takPacket.status = cotStatus + Logger.tak.debug("TAKPacket.status: battery=\(cotStatus.battery)") + } + + // Determine payload type based on CoT type + // Accept any friendly ground unit type (a-f-G...) for PLI + if cot.type.hasPrefix("a-f-G") || cot.type.hasPrefix("a-f-g") { + // Register this TAK client's contact info for future DM lookups + if let contact = cot.contact, !contact.callsign.isEmpty, !cot.uid.isEmpty { + registerContact(callsign: contact.callsign, deviceUID: cot.uid) + } + + // Atom type (position) - create PLI + var pli = PLI() + + // Convert lat/lon to integer format (degrees * 1e7) + let latI = Int32(cot.latitude * 1e7) + let lonI = Int32(cot.longitude * 1e7) + + // Handle altitude - clamp to valid Int32 range, use 0 for unknown (9999999) + let altitudeValue: Int32 + if cot.hae >= 9999999.0 || cot.hae.isNaN || cot.hae.isInfinite { + altitudeValue = 0 // Unknown altitude + } else { + altitudeValue = Int32(clamping: Int(cot.hae)) + } + + pli.latitudeI = latI + pli.longitudeI = lonI + pli.altitude = altitudeValue + + if let track = cot.track { + pli.speed = UInt32(max(0, track.speed)) + pli.course = UInt32(max(0, track.course)) + } + + takPacket.pli = pli + + Logger.tak.debug("TAKPacket.pli created:") + Logger.tak.debug(" latitudeI: \(pli.latitudeI) (from \(cot.latitude))") + Logger.tak.debug(" longitudeI: \(pli.longitudeI) (from \(cot.longitude))") + Logger.tak.debug(" altitude: \(pli.altitude) (from \(cot.hae))") + Logger.tak.debug(" speed: \(pli.speed), course: \(pli.course)") + + } else if cot.type == "b-t-f" { + // Chat message - MUST include contact field for sender identification + var geoChat = GeoChat() + + // Extract messageId from CoT uid if present + // CoT uid format: "GeoChat.{senderUid}.{chatroom}.{messageId}" + var messageId: String? + var actualDeviceUid = cot.uid + let uidComponents = cot.uid.components(separatedBy: ".") + if uidComponents.count >= 4 && uidComponents[0] == "GeoChat" { + // Extract the actual device UID (second component) + actualDeviceUid = uidComponents[1] + // Extract messageId (last component) + messageId = uidComponents.last + Logger.tak.debug("GeoChat: Extracted messageId=\(messageId ?? "nil") from uid") + } + + // If no messageId found, generate one + if messageId == nil || messageId?.isEmpty == true { + messageId = UUID().uuidString + Logger.tak.debug("GeoChat: Generated new messageId=\(messageId!)") + } + + // Ensure contact (sender info) is always set for chat messages + // This is REQUIRED for Android ATAK to process the message correctly + if !takPacket.hasContact { + var senderContact = Contact() + // Get sender callsign from chat.senderCallsign or cot.contact + if let senderCallsign = cot.chat?.senderCallsign, !senderCallsign.isEmpty { + senderContact.callsign = senderCallsign + } else if let contactCallsign = cot.contact?.callsign, !contactCallsign.isEmpty { + senderContact.callsign = contactCallsign + } else { + senderContact.callsign = "Unknown" + } + // Smuggle messageId into device_callsign for proper threading on Android + // Format: "|" + senderContact.deviceCallsign = Self.createSmuggledDeviceCallsign( + deviceCallsign: actualDeviceUid, + messageId: messageId! + ) + takPacket.contact = senderContact + Logger.tak.debug("GeoChat: Added sender contact - callsign=\(senderContact.callsign), smuggled deviceCallsign=\(senderContact.deviceCallsign)") + } else { + // Contact already set, but we still need to smuggle the messageId + var updatedContact = takPacket.contact + let existingDeviceCallsign = updatedContact.deviceCallsign.isEmpty ? actualDeviceUid : updatedContact.deviceCallsign + updatedContact.deviceCallsign = Self.createSmuggledDeviceCallsign( + deviceCallsign: existingDeviceCallsign, + messageId: messageId! + ) + takPacket.contact = updatedContact + Logger.tak.debug("GeoChat: Updated contact with smuggled messageId - deviceCallsign=\(updatedContact.deviceCallsign)") + } + + if let chat = cot.chat { + geoChat.message = chat.message + + // Handle recipient addressing + // chat.chatroom contains either "All Chat Rooms" or the recipient's callsign + if chat.chatroom == "All Chat Rooms" { + // Broadcast message - set to literal "All Chat Rooms" + geoChat.to = "All Chat Rooms" + Logger.tak.debug("GeoChat: Broadcast to All Chat Rooms") + } else { + // Direct message - need to look up recipient's device UID from their callsign + let recipientCallsign = chat.chatroom + if let recipientDeviceUID = lookupDeviceUID(forCallsign: recipientCallsign) { + // Found the recipient's device UID + geoChat.to = recipientDeviceUID + geoChat.toCallsign = recipientCallsign + Logger.tak.debug("GeoChat DM: to=\(recipientDeviceUID), toCallsign=\(recipientCallsign)") + } else { + // Recipient device UID not found - use callsign as fallback + // This may not work on Android but is better than nothing + geoChat.to = recipientCallsign + geoChat.toCallsign = recipientCallsign + Logger.tak.warning("GeoChat DM: Unknown device UID for '\(recipientCallsign)', using callsign as fallback") + } + } + } else if let remarks = cot.remarks { + geoChat.message = remarks + geoChat.to = "All Chat Rooms" + } + + takPacket.chat = geoChat + + Logger.tak.debug("TAKPacket.chat created:") + Logger.tak.debug(" message: \(geoChat.message)") + Logger.tak.debug(" to: \(geoChat.to)") + Logger.tak.debug(" toCallsign: \(geoChat.toCallsign)") + Logger.tak.debug(" sender.callsign: \(takPacket.contact.callsign)") + Logger.tak.debug(" sender.deviceCallsign: \(takPacket.contact.deviceCallsign)") + + } else { + // Unknown type, skip + Logger.tak.debug("Skipping CoT type not mapped to TAKPacket: \(cot.type)") + return nil + } + + // Log the final TAKPacket structure + Logger.tak.debug("TAKPacket output:") + Logger.tak.debug(" hasContact: \(takPacket.hasContact)") + Logger.tak.debug(" hasGroup: \(takPacket.hasGroup)") + Logger.tak.debug(" hasStatus: \(takPacket.hasStatus)") + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Log serialized size for debugging + do { + let serialized = try takPacket.serializedData() + Logger.tak.debug(" serializedSize: \(serialized.count) bytes") + Logger.tak.debug(" serializedHex: \(serialized.prefix(64).map { String(format: "%02x", $0) }.joined(separator: " "))\(serialized.count > 64 ? "..." : "")") + } catch { + Logger.tak.error(" Failed to serialize TAKPacket: \(error.localizedDescription)") + } + + Logger.tak.debug("=== End Conversion ===") + return takPacket + } + + // MARK: - Meshtastic β†’ TAK (TAKPacket to CoT) + + /// Broadcast a Meshtastic TAKPacket to all connected TAK clients + func broadcastToTAKClients(_ takPacket: TAKPacket, from nodeNum: UInt32) async { + // Register contact info from incoming TAKPackets (for callsign β†’ deviceUID lookup) + if takPacket.hasContact { + let callsign = takPacket.contact.callsign + let deviceUID = takPacket.contact.deviceCallsign + if !callsign.isEmpty && !deviceUID.isEmpty { + registerContact(callsign: callsign, deviceUID: deviceUID) + } + } + + // Check if this is a read receipt - don't forward to TAK clients as chat message + if case .chat(let geoChat) = takPacket.payloadVariant { + if let receipt = Self.parseReceipt(from: geoChat.message) { + // This is a read receipt, handle it internally + let typeString = receipt.type == .delivered ? "Delivered" : "Read" + Logger.tak.info("Received \(typeString) receipt for messageId: \(receipt.messageId) from node \(nodeNum)") + // TODO: Update message status in Core Data if we track sent messages + // For now, just log and don't forward to TAK clients + return + } + } + + guard let takServerManager else { + Logger.tak.debug("Cannot broadcast to TAK: TAKServerManager not available") + return + } + + guard takServerManager.isRunning else { + Logger.tak.debug("Cannot broadcast to TAK: Server not running") + return + } + + guard !takServerManager.connectedClients.isEmpty else { + Logger.tak.debug("No TAK clients connected, skipping broadcast") + return + } + + // Look up node info for additional context + let nodeInfo = lookupNodeInfo(nodeNum: nodeNum) + + // Convert to CoT + guard let cotMessage = convertToCoT(from: takPacket, nodeNum: nodeNum, nodeInfo: nodeInfo) else { + Logger.tak.warning("Failed to convert TAKPacket to CoT from node \(nodeNum)") + return + } + + // Broadcast to all TAK clients + await takServerManager.broadcast(cotMessage) + Logger.tak.info("Broadcast CoT to TAK clients: \(cotMessage.type) from node \(nodeNum)") + } + + /// Convert Meshtastic TAKPacket to CoT message + func convertToCoT(from takPacket: TAKPacket, nodeNum: UInt32, nodeInfo: NodeInfoEntity?) -> CoTMessage? { + // Use the factory method from CoTMessage which handles the conversion + let deviceUid = "MESHTASTIC-\(String(format: "%08X", nodeNum))" + return CoTMessage.fromTAKPacket(takPacket, deviceUid: deviceUid) + } + + /// Create a CoT PLI message from a Meshtastic node's position + func createCoTFromNode(_ node: NodeInfoEntity) -> CoTMessage? { + guard let position = node.latestPosition, + let latitude = position.latitude, + let longitude = position.longitude, + latitude != 0 || longitude != 0 else { + return nil + } + + let uid = "MESHTASTIC-\(String(format: "%08X", node.num))" + let callsign = node.user?.shortName ?? node.user?.longName ?? "MESH-\(node.num)" + + // Get battery level from device metrics + let battery = Int(node.latestDeviceMetrics?.batteryLevel ?? 100) + + return CoTMessage.pli( + uid: uid, + callsign: callsign, + latitude: latitude, + longitude: longitude, + altitude: Double(position.altitude), + speed: Double(position.speed), + course: Double(position.heading), + team: "Green", // Meshtastic nodes shown as green by default + role: "Team Member", + battery: battery, + staleMinutes: 15 // Meshtastic positions can be older + ) + } + + // MARK: - Broadcast All Mesh Nodes to TAK + + /// Send all known mesh node positions to TAK clients + /// Useful when a new TAK client connects + func broadcastAllNodesToTAK() async { + guard let takServerManager, takServerManager.isRunning else { return } + guard let context else { return } + + let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() + // Only nodes with valid positions + fetchRequest.predicate = NSPredicate(format: "latestPosition != nil") + + do { + let nodes = try context.fetch(fetchRequest) + + for node in nodes { + if let cotMessage = createCoTFromNode(node) { + await takServerManager.broadcast(cotMessage) + } + } + + Logger.tak.info("Broadcast \(nodes.count) mesh node positions to TAK clients") + } catch { + Logger.tak.error("Failed to fetch nodes for TAK broadcast: \(error.localizedDescription)") + } + } + + // MARK: - Helper Methods + + private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? { + guard let context else { return nil } + + let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "num == %d", Int64(nodeNum)) + fetchRequest.fetchLimit = 1 + + do { + return try context.fetch(fetchRequest).first + } catch { + Logger.tak.warning("Failed to lookup node info for \(nodeNum): \(error.localizedDescription)") + return nil + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKServerManager.swift b/Meshtastic/Helpers/TAK/TAKServerManager.swift new file mode 100644 index 00000000..b71fa848 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKServerManager.swift @@ -0,0 +1,427 @@ +// +// TAKServerManager.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import Network +import OSLog +import Combine +import SwiftUI + +/// Manages the TAK Server lifecycle, TLS connections, and client management +/// Runs on MainActor for thread safety, following the AccessoryManager pattern +@MainActor +final class TAKServerManager: ObservableObject { + + static let shared = TAKServerManager() + + // MARK: - Published State + + @Published private(set) var isRunning = false + @Published private(set) var connectedClients: [TAKClientInfo] = [] + @Published var lastError: String? + + // MARK: - Configuration (persisted via AppStorage) + + @AppStorage("takServerEnabled") var enabled = false { + didSet { + Task { + if enabled && !isRunning { + try? await start() + } else if !enabled && isRunning { + stop() + } + } + } + } + + /// Fixed port - always use TLS port 8089 + static let defaultTLSPort = 8089 + static let defaultTCPPort = 8087 // Legacy, not used + + /// Port is fixed to 8089 (mTLS) + var port: Int { Self.defaultTLSPort } + + /// Always use TLS/mTLS + var useTLS: Bool { true } + + // MARK: - Bridge + + /// Bridge for converting between CoT and Meshtastic formats + var bridge: TAKMeshtasticBridge? + + // MARK: - Private Properties + + private var listener: NWListener? + private var connections: [ObjectIdentifier: TAKConnection] = [:] + private var connectionTasks: [ObjectIdentifier: Task] = [:] + private let queue = DispatchQueue(label: "tak.server", qos: .userInitiated) + + private init() {} + + // MARK: - Initialization + + /// Initialize the TAK server on app startup + /// Call this from app initialization to restore server state + func initializeOnStartup() { + guard enabled else { + Logger.tak.debug("TAK Server not enabled, skipping startup") + return + } + + guard !isRunning else { + Logger.tak.debug("TAK Server already running") + return + } + + Logger.tak.info("TAK Server enabled, starting on app launch") + Task { + do { + try await start() + } catch { + Logger.tak.error("Failed to start TAK Server on startup: \(error.localizedDescription)") + } + } + } + + // MARK: - Server Lifecycle + + /// Start the TAK server (TLS or TCP based on configuration) + func start() async throws { + guard !isRunning else { + Logger.tak.info("TAK Server already running") + return + } + + let mode = useTLS ? "TLS" : "TCP" + Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)") + + let parameters: NWParameters + + if useTLS { + // Validate we have a server certificate for TLS mode + guard let identity = TAKCertificateManager.shared.getServerIdentity() else { + let error = TAKServerError.noServerCertificate + lastError = error.localizedDescription + enabled = false + throw error + } + + // Create TLS options + let tlsOptions = NWProtocolTLS.Options() + + // Set server identity (certificate + private key) + let secIdentity = sec_identity_create(identity)! + sec_protocol_options_set_local_identity( + tlsOptions.securityProtocolOptions, + secIdentity + ) + + // Set minimum TLS version to 1.2 (TAK standard) + sec_protocol_options_set_min_tls_protocol_version( + tlsOptions.securityProtocolOptions, + .TLSv12 + ) + + // Configure mTLS - always require client certificate for TLS mode + sec_protocol_options_set_peer_authentication_required( + tlsOptions.securityProtocolOptions, + true + ) + + // Set up client certificate validation + let clientCAs = TAKCertificateManager.shared.getClientCACertificates() + Logger.tak.info("Loaded \(clientCAs.count) CA certificate(s) for client validation") + if !clientCAs.isEmpty { + for (index, ca) in clientCAs.enumerated() { + if let summary = SecCertificateCopySubjectSummary(ca) as String? { + Logger.tak.info("CA[\(index)]: \(summary)") + } + } + let trustRoots = clientCAs as CFArray + sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { _, secTrust, completion in + // Convert sec_trust_t to SecTrust + let trust = sec_trust_copy_ref(secTrust).takeRetainedValue() + + // Set policy for client certificate validation + // Use SSL policy with server=false to validate client certificates + // This properly accepts clientAuth ExtendedKeyUsage + let clientPolicy = SecPolicyCreateSSL(false, nil) + SecTrustSetPolicies(trust, clientPolicy) + + SecTrustSetAnchorCertificates(trust, trustRoots) + SecTrustSetAnchorCertificatesOnly(trust, true) + var error: CFError? + let isValid = SecTrustEvaluateWithError(trust, &error) + if let error = error { + Logger.tak.error("Client cert validation error: \(error.localizedDescription)") + } + Logger.tak.info("Client certificate validation: \(isValid ? "passed" : "failed")") + completion(isValid) + }, + queue + ) + } else { + Logger.tak.warning("mTLS enabled but no CA certificates configured for client validation") + } + + // TCP options + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 60 + + parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions) + } else { + // Plain TCP mode (no TLS) + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 60 + + parameters = NWParameters(tls: nil, tcp: tcpOptions) + } + + parameters.allowLocalEndpointReuse = true + + // Bind to localhost only - only allow TAK clients on the same device + parameters.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host("127.0.0.1"), + port: NWEndpoint.Port(integerLiteral: UInt16(port)) + ) + + // Create and configure listener + do { + listener = try NWListener(using: parameters) + } catch { + lastError = "Failed to create listener: \(error.localizedDescription)" + Logger.tak.error("Failed to create TAK listener: \(error.localizedDescription)") + enabled = false + throw error + } + + // Set up state handler + listener?.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleListenerState(state) + } + } + + // Set up new connection handler + listener?.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + await self?.handleNewConnection(connection) + } + } + + // Start listening + listener?.start(queue: queue) + } + + /// Stop the TAK server + func stop() { + Logger.tak.info("Stopping TAK Server") + + listener?.cancel() + listener = nil + + // Cancel all connection tasks + for (_, task) in connectionTasks { + task.cancel() + } + connectionTasks.removeAll() + + // Disconnect all clients + for (_, connection) in connections { + Task { + await connection.disconnect() + } + } + connections.removeAll() + connectedClients.removeAll() + + isRunning = false + lastError = nil + + Logger.tak.info("TAK Server stopped") + } + + /// Restart the server (useful after configuration changes) + func restart() async throws { + stop() + try await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay + try await start() + } + + // MARK: - State Handling + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + isRunning = true + lastError = nil + Logger.tak.info("TAK Server listening on port \(self.port)") + + case .failed(let error): + isRunning = false + lastError = error.localizedDescription + enabled = false + Logger.tak.error("TAK Server failed: \(error.localizedDescription)") + + case .cancelled: + isRunning = false + Logger.tak.info("TAK Server cancelled") + + case .waiting(let error): + Logger.tak.warning("TAK Server waiting: \(error.localizedDescription)") + + case .setup: + Logger.tak.debug("TAK Server setup") + + @unknown default: + break + } + } + + // MARK: - Connection Management + + private func handleNewConnection(_ nwConnection: NWConnection) async { + let connectionId = ObjectIdentifier(nwConnection) + let connection = TAKConnection(connection: nwConnection) + + connections[connectionId] = connection + + Logger.tak.info("New TAK client connecting: \(nwConnection.endpoint.debugDescription)") + + // Start handling the connection + let eventStream = await connection.start() + + // Create task to handle connection events + let task = Task { + for await event in eventStream { + await handleConnectionEvent(event, connectionId: connectionId) + } + // Connection ended + await removeConnection(connectionId) + } + + connectionTasks[connectionId] = task + } + + private func handleConnectionEvent(_ event: TAKConnectionEvent, connectionId: ObjectIdentifier) async { + switch event { + case .connected(let clientInfo): + connectedClients.append(clientInfo) + Logger.tak.info("TAK client connected: \(clientInfo.displayName)") + + case .clientInfoUpdated(let clientInfo): + // Update the client info in our list + if let index = connectedClients.firstIndex(where: { $0.id == clientInfo.id }) { + connectedClients[index] = clientInfo + } + + case .message(let cotMessage): + Logger.tak.info("Received CoT from TAK client: \(cotMessage.type)") + // Forward to Meshtastic mesh via bridge + await bridge?.sendToMesh(cotMessage) + + case .disconnected: + await removeConnection(connectionId) + + case .error(let error): + Logger.tak.error("TAK client error: \(error.localizedDescription)") + } + } + + private func removeConnection(_ connectionId: ObjectIdentifier) async { + connectionTasks[connectionId]?.cancel() + connectionTasks.removeValue(forKey: connectionId) + + if let connection = connections.removeValue(forKey: connectionId) { + let endpoint = await connection.endpoint + connectedClients.removeAll { $0.endpoint.debugDescription == endpoint.debugDescription } + Logger.tak.info("TAK client disconnected") + } + } + + // MARK: - Message Distribution + + /// Broadcast a CoT message to all connected TAK clients + func broadcast(_ cotMessage: CoTMessage) async { + guard !connections.isEmpty else { return } + + Logger.tak.info("Broadcasting CoT to \(self.connections.count) TAK client(s): \(cotMessage.type)") + + for (connectionId, connection) in connections { + do { + try await connection.send(cotMessage) + } catch { + Logger.tak.error("Failed to send to TAK client: \(error.localizedDescription)") + // Remove failed connection + await removeConnection(connectionId) + } + } + } + + /// Send a CoT message to a specific client + func send(_ cotMessage: CoTMessage, to clientId: UUID) async throws { + guard let clientInfo = connectedClients.first(where: { $0.id == clientId }) else { + throw TAKServerError.clientNotFound + } + + for (_, connection) in connections { + let endpoint = await connection.endpoint + if endpoint.debugDescription == clientInfo.endpoint.debugDescription { + try await connection.send(cotMessage) + return + } + } + + throw TAKServerError.clientNotFound + } + + // MARK: - Status + + /// Get server status description + var statusDescription: String { + if isRunning { + let mode = useTLS ? "TLS" : "TCP" + return "Running on port \(port) (\(mode))" + } else if let error = lastError { + return "Error: \(error)" + } else { + return "Stopped" + } + } +} + +// MARK: - Server Errors + +enum TAKServerError: LocalizedError { + case noServerCertificate + case noClientCACertificate + case tlsConfigurationFailed + case listenerFailed(String) + case clientNotFound + case notRunning + + var errorDescription: String? { + switch self { + case .noServerCertificate: + return "No server certificate configured. Import a .p12 file with the server certificate and private key." + case .noClientCACertificate: + return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates." + case .tlsConfigurationFailed: + return "Failed to configure TLS settings." + case .listenerFailed(let reason): + return "Failed to start listener: \(reason)" + case .clientNotFound: + return "Client not found" + case .notRunning: + return "TAK Server is not running" + } + } +} diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 4dbdb836..e8c10bea 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -25,6 +25,8 @@ com.apple.security.network.client + com.apple.security.network.server + com.apple.security.personal-information.location keychain-access-groups diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index d60ed940..5c42dd22 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -193,6 +193,7 @@ struct MeshtasticAppleApp: App { } } .onChange(of: scenePhase) { (_, newScenePhase) in + accessoryManager.isInBackground = (newScenePhase == .background) switch newScenePhase { case .background: Logger.services.info("🎬 [App] Scene is in the background") diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 19521601..2658a4bf 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -25,6 +25,10 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat if locationsHandler.backgroundActivity { locationsHandler.backgroundActivity = true } + // Initialize TAK Server if enabled + Task { @MainActor in + TAKServerManager.shared.initializeOnStartup() + } return true } // Lets us show the notification in the app in the foreground diff --git a/Meshtastic/Resources/Certificates/backup/ca.pem b/Meshtastic/Resources/Certificates/backup/ca.pem new file mode 100644 index 00000000..f00e8c1a --- /dev/null +++ b/Meshtastic/Resources/Certificates/backup/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIUZaXYUGEFhPeOcWWNXlwt5qyfIVgwDQYJKoZIhvcNAQEL +BQAwMTEaMBgGA1UEAwwRTWVzaHRhc3RpYyBUQUsgQ0ExEzARBgNVBAoMCk1lc2h0 +YXN0aWMwHhcNMjUxMjI3MjIyNDQ3WhcNMzUxMjI1MjIyNDQ3WjAxMRowGAYDVQQD +DBFNZXNodGFzdGljIFRBSyBDQTETMBEGA1UECgwKTWVzaHRhc3RpYzCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAL4kQSbJ3eqZg3DGAyD8XPMoeKS2ERy6 +i1w6Uyr70mE5cJoaUISlA+jYo+hKk2ysjct3byuB43XlZBeK0tUTt2900o3/EJXZ +ggRe0yIWrsiMqweRGf3TSgeusz6TrtmZ5KptYaLsc39/MGGKj2v00J+HmFSgDTRu +v5LY8do0haP+XaP5MxWgPcY0ySEB0yEYr7MtOOd6npZaHRJlw8UWALrvHznl7Yrv +80wYo3zBbQ8SeCamCOj+Is/Eye9fixosZi3UkR8FEMUONWtofTI83DfFfP1kDVaq +lWr2fzdlCebK7wY0pY0cBEbdpQadXFQ2PiqXDd3g7k6i+mjT7XzH/mMCAwEAAaNT +MFEwHQYDVR0OBBYEFF/jLHK/wvsWMW8TAbQMIV5BPSxUMB8GA1UdIwQYMBaAFF/j +LHK/wvsWMW8TAbQMIV5BPSxUMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBAAAzYYf5ktEHxDRvAd4pf8fv1dgGpuWfdE23h5Tg4wE+0pXCvtXqKGmQ +mPiEr7hAFphSppJZdRl7bvdv+jzllqeCoHgEyUJvJvgMugfG8f8IhIKkg7Q7cd38 +LQrVimjH+g1UKK1/XmJpv7wyDo53wvBsKRxsIwwEPdM4TUkjNIfkgNY5YpnOBrrx +Ubj9T8ZdHc/tM+Z03bgotIejXqh1PbK+Cfq5kXfv37uscOJHBCq8anA1AXsSGS31 +R1IN9vXmQ6kItJErPSJyY1l0PSgniWhYCbxmRmsSIFYlZjVq0BvDQi1Va1W/9LiV +Vp2YyFUrzlbnng24dpvQiSJU+pl/9Lk= +-----END CERTIFICATE----- diff --git a/Meshtastic/Resources/Certificates/backup/server.p12 b/Meshtastic/Resources/Certificates/backup/server.p12 new file mode 100644 index 00000000..b7455489 Binary files /dev/null and b/Meshtastic/Resources/Certificates/backup/server.p12 differ diff --git a/Meshtastic/Resources/Certificates/ca.pem b/Meshtastic/Resources/Certificates/ca.pem new file mode 100644 index 00000000..1dc6e36f --- /dev/null +++ b/Meshtastic/Resources/Certificates/ca.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID4zCCAsugAwIBAgIUeM9XhqZCtta+QorYNjZSdAk3gkMwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQH +DA1TYW4gRnJhbmNpc2NvMRMwEQYDVQQKDApNZXNodGFzdGljMRMwEQYDVQQLDApU +QUsgU2VydmVyMRowGAYDVQQDDBFNZXNodGFzdGljIFRBSyBDQTAeFw0yNTEyMzEx +OTQwMDJaFw0yODA0MDQxOTQwMDJaMIGAMQswCQYDVQQGEwJVUzETMBEGA1UECAwK +Q2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzETMBEGA1UECgwKTWVz +aHRhc3RpYzETMBEGA1UECwwKVEFLIFNlcnZlcjEaMBgGA1UEAwwRTWVzaHRhc3Rp +YyBUQUsgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2F6/n1CI2 +4dGtLt0irkfiU+PRmqkkuE7m49i7/FeH+38SEn9+0B4egW0kYRoRXmYdPzRsVttu +23LZ3RLjwB6fFI3tiA27mxD58AuEMfwVR7J29oHqFwuVhuqDyjkNpUPFUomKwzvK +SPJvoiHGkbQwWTMNP6T06tCg9llSE7SIgJWjzikQ+JsI37SqVGZ8K2evs7LTuyQh +ssJfYVB7aE1kNNyi8YFHLoCWQMB7h8qJ3hRd7QGFG9gfWuNrWtim61iiHgBAPTRw +gMn+YSIZiV9/iOytBKxFppNTxffEowF/iKBvgXwd9KHxYkk1Nvtcz5NJynSL75PT +8B7XiHCGhcgzAgMBAAGjUzBRMB0GA1UdDgQWBBRRe/o9Raj93Fq22ArNSNrpsye3 +AzAfBgNVHSMEGDAWgBRRe/o9Raj93Fq22ArNSNrpsye3AzAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAsuSQ+j/1Bm7HbZWzN5qChH554vucWoqI0 +sVRHThvCASC6+wSosWZlx/Ag5KnRmBVsYA6CX5ztoF5keiSRy5G7qyRQVjITOq1o +4XUAHBtGxKdRCEzS84GnsW9qeWX7t/xxf2fFr9gPZ7Z4nuyNg7QyX5FM01BtAlZC +HbBhXvJyHRqJkMe7keYU7GmiAs1RZa+7593uEQ8DQ/kRvCzU0XswFSguJrd4Fnpi +PGesGOk0NHFQY9pIu9oshgPgMA9dEWnhhvAF3PZ3sLRn9sSuslj5oumFsTYboByE +aOKQshFe5xEX/4O7DI+wsD1Pt5gdT75nAuG7GEAIFKKGjQtUUYfH +-----END CERTIFICATE----- diff --git a/Meshtastic/Resources/Certificates/client.p12 b/Meshtastic/Resources/Certificates/client.p12 new file mode 100644 index 00000000..2f27bff2 Binary files /dev/null and b/Meshtastic/Resources/Certificates/client.p12 differ diff --git a/Meshtastic/Resources/Certificates/server.p12 b/Meshtastic/Resources/Certificates/server.p12 new file mode 100644 index 00000000..88b9fcba Binary files /dev/null and b/Meshtastic/Resources/Certificates/server.p12 differ diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 48a97b93..ca828478 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -52,6 +52,7 @@ enum SettingsNavigationState: String { case debugLogs case appFiles case firmwareUpdates + case tak } struct NavigationState: Hashable { diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index afdda5a3..fc3bb485 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -27,12 +27,77 @@ struct MessageText: View { // State for handling channel URL sheet @State private var saveChannelLink: SaveChannelLinkData? @State private var isShowingDeleteConfirmation = false + @State private var isShowingTapbackInput = false + @State private var tapbackText = "" @FocusState private var isTapbackInputFocused: Bool @State private var tapbackText = "" var body: some View { SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) { + let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) + Text(markdownText) + .tint(Self.linkBlue) + .padding(.vertical, 10) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background(isCurrentUser ? .accentColor : Color(.gray)) + .cornerRadius(15) + .overlay { + /// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages + if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted { + VStack(alignment: .trailing) { + Spacer() + HStack { + Spacer() + Image(systemName: "lock.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .green) + .font(.system(size: 20)) + .offset(x: 8, y: 8) + } + } + } + let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue) + let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) + if isStoreAndForward { + VStack(alignment: .trailing) { + Spacer() + HStack { + Spacer() + Image(systemName: "envelope.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .gray) + .font(.system(size: 20)) + .offset(x: 8, y: 8) + } + } + } + if tapBackDestination.overlaySensorMessage { + VStack { + isDetectionSensorMessage ? Image(systemName: "sensor.fill") + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .foregroundStyle(Color.orange) + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .offset(x: 20, y: -20) + : nil + } + } else { + EmptyView() + } + } + .contextMenu { + MessageContextMenuItems( + message: message, + tapBackDestination: tapBackDestination, + isCurrentUser: isCurrentUser, + isShowingDeleteConfirmation: $isShowingDeleteConfirmation, + isShowingTapbackInput: $isShowingTapbackInput, + onReply: onReply + ) + } messageContent .environment(\.openURL, OpenURLAction { url in handleURL(url) @@ -46,6 +111,36 @@ struct MessageText: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $isShowingTapbackInput) { + TapbackInputView( + text: $tapbackText, + isPresented: $isShowingTapbackInput, + onEmojiSelected: { emoji in + Task { + do { + try await accessoryManager.sendMessage( + message: emoji, + toUserNum: tapBackDestination.userNum, + channel: tapBackDestination.channelNum, + isEmoji: true, + replyID: message.messageId + ) + Task { @MainActor in + switch tapBackDestination { + case let .channel(channel): + context.refresh(channel, mergeChanges: true) + case let .user(user): + context.refresh(user, mergeChanges: true) + } + } + } catch { + Logger.services.warning("Failed to send tapback.") + } + } + isShowingTapbackInput = false + } + ) + } .confirmationDialog( "Are you sure you want to delete this message?", isPresented: $isShowingDeleteConfirmation, diff --git a/Meshtastic/Views/Messages/TapbackInputView.swift b/Meshtastic/Views/Messages/TapbackInputView.swift index bf6de5a6..36a1e9b0 100644 --- a/Meshtastic/Views/Messages/TapbackInputView.swift +++ b/Meshtastic/Views/Messages/TapbackInputView.swift @@ -78,3 +78,16 @@ struct TapbackInputView: View { return nil } } + +extension UIView { + var firstResponder: UIView? { + guard !isFirstResponder else { return self } + for subview in subviews { + if let firstResponder = subview.firstResponder { + return firstResponder + } + } + return nil + } +} + diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index d3d15a66..449efc6c 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -327,6 +327,18 @@ struct Settings: View { } } + var takSection: some View { + Section(header: Text("TAK")) { + NavigationLink(value: SettingsNavigationState.tak) { + Label { + Text("TAK Server") + } icon: { + Image(systemName: "target") + } + } + } + } + var body: some View { NavigationStack( path: Binding<[SettingsNavigationState]>( @@ -458,6 +470,7 @@ struct Settings: View { developersSection #endif firmwareSection + takSection } } .navigationDestination(for: SettingsNavigationState.self) { destination in @@ -521,6 +534,8 @@ struct Settings: View { AppData() case .firmwareUpdates: Firmware(node: node) + case .tak: + TAKServerConfig() } } .onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift new file mode 100644 index 00000000..37ccc861 --- /dev/null +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -0,0 +1,390 @@ +// +// 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) + } +} diff --git a/itak-example-data-package/iphone.p12 b/itak-example-data-package/iphone.p12 new file mode 100644 index 00000000..7f836b2f Binary files /dev/null and b/itak-example-data-package/iphone.p12 differ diff --git a/itak-example-data-package/manifest.xml b/itak-example-data-package/manifest.xml new file mode 100644 index 00000000..f356ba95 --- /dev/null +++ b/itak-example-data-package/manifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/itak-example-data-package/server.p12 b/itak-example-data-package/server.p12 new file mode 100644 index 00000000..913f4692 Binary files /dev/null and b/itak-example-data-package/server.p12 differ diff --git a/itak-example-data-package/taky-server.pref b/itak-example-data-package/taky-server.pref new file mode 100644 index 00000000..82b1b864 --- /dev/null +++ b/itak-example-data-package/taky-server.pref @@ -0,0 +1,16 @@ + + + + 1 + Win10 Taky Server + true + 172.30.254.210:8089:ssl + + + true + cert/server.p12 + atakatak + atakatak + cert/iphone.p12 + +