From a4d41247256106b9fad5744c1c4378c01d8730aa Mon Sep 17 00:00:00 2001 From: korbin Date: Sat, 5 Jul 2025 12:23:45 -0600 Subject: [PATCH 01/10] add mDNS hostname advertisement --- components/connect/CMakeLists.txt | 1 + components/connect/connect.c | 45 ++++++++++++++++++++++++++++++- main/CMakeLists.txt | 1 + main/idf_component.yml | 9 ++++--- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/components/connect/CMakeLists.txt b/components/connect/CMakeLists.txt index 91e85c375..f0df62890 100644 --- a/components/connect/CMakeLists.txt +++ b/components/connect/CMakeLists.txt @@ -13,4 +13,5 @@ REQUIRES "esp_wifi" "esp_event" "stratum" + "mdns" ) diff --git a/components/connect/connect.c b/components/connect/connect.c index 673bb4545..6c94b7c04 100644 --- a/components/connect/connect.c +++ b/components/connect/connect.c @@ -11,6 +11,7 @@ #include "lwip/sys.h" #include "nvs_flash.h" #include "esp_wifi_types_generic.h" +#include "mdns.h" #include "connect.h" #include "global_state.h" @@ -61,6 +62,7 @@ static int clients_connected_to_ap = 0; static const char *get_wifi_reason_string(int reason); static void wifi_softap_on(void); static void wifi_softap_off(void); +static void mdns_init_hostname(void); esp_err_t get_wifi_current_rssi(int8_t *rssi) { @@ -204,7 +206,7 @@ static void event_handler(void * arg, esp_event_base_t event_base, int32_t event if (event_id == WIFI_EVENT_AP_STACONNECTED) { clients_connected_to_ap += 1; } - + if (event_id == WIFI_EVENT_AP_STADISCONNECTED) { clients_connected_to_ap -= 1; } @@ -222,6 +224,8 @@ static void event_handler(void * arg, esp_event_base_t event_base, int32_t event ESP_LOGI(TAG, "Connected to SSID: %s", GLOBAL_STATE->SYSTEM_MODULE.ssid); wifi_softap_off(); + + mdns_init_hostname(); } } @@ -474,3 +478,42 @@ static const char *get_wifi_reason_string(int reason) { } return "Unknown error"; } + +static void mdns_init_hostname(void) { + char * hostname = nvs_config_get_string(NVS_CONFIG_HOSTNAME, CONFIG_LWIP_LOCAL_HOSTNAME); + + ESP_LOGI(TAG, "Starting mDNS service"); + esp_err_t err = mdns_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "mDNS initialization failed: %s", esp_err_to_name(err)); + free(hostname); + return; + } + + ESP_LOGI(TAG, "Setting mDNS hostname to: %s", hostname); + err = mdns_hostname_set(hostname); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set mDNS hostname: %s", esp_err_to_name(err)); + free(hostname); + return; + } + + ESP_LOGI(TAG, "Setting mDNS instance name to: %s", hostname); + err = mdns_instance_name_set(hostname); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set mDNS instance name: %s", esp_err_to_name(err)); + free(hostname); + return; + } + + ESP_LOGI(TAG, "Adding mDNS service: _http._tcp on port 80"); + err = mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to add mDNS HTTP service: %s", esp_err_to_name(err)); + free(hostname); + return; + } + + ESP_LOGI(TAG, "mDNS service started successfully"); + free(hostname); +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f6bb63c76..b1e66c719 100755 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -64,6 +64,7 @@ PRIV_REQUIRES "spiffs" "vfs" "esp_driver_i2c" + "mdns" EMBED_FILES "http_server/recovery_page.html" ) diff --git a/main/idf_component.yml b/main/idf_component.yml index c3cdc258e..18f9445d7 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -1,8 +1,9 @@ ## IDF Component Manager Manifest File dependencies: - lvgl/lvgl: "9.3.0" - espressif/esp_lvgl_port: "^2.6.0" - esp_lcd_sh1107: "^1" + lvgl/lvgl: 9.3.0 + espressif/esp_lvgl_port: ^2.6.0 + esp_lcd_sh1107: ^1 ## Required IDF version idf: - version: ">=5.4.0" + version: '>=5.4.0' + espressif/mdns: ^1.8.2 From 702471fb126517abb101de7f7b2c5a0b2ce61ef8 Mon Sep 17 00:00:00 2001 From: korbin Date: Sat, 5 Jul 2025 12:38:41 -0600 Subject: [PATCH 02/10] temp: try adding mdns dependency from component manager in ci --- .github/workflows/build.yml | 4 +- .github/workflows/unittest.yml | 67 ++++++++++++++-------------------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 27d9dc832..320ae5326 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,9 @@ jobs: with: esp_idf_version: v5.4.2 target: esp32s3 - command: GITHUB_ACTIONS="true" idf.py build + command: | + GITHUB_ACTIONS="true" idf.py add-dependency "espressif/mdns^1.8.2" + GITHUB_ACTIONS="true" idf.py build path: '.' - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 5d3bfda9c..0213a2b1d 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -2,48 +2,35 @@ name: Unit Test on: [push, pull_request] permissions: + checks: write + pull-requests: write contents: read jobs: - build-and-test: + build: runs-on: ubuntu-latest steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - submodules: 'recursive' - - - name: esp-idf build - uses: espressif/esp-idf-ci-action@v1 - with: - esp_idf_version: v5.4.2 - target: esp32s3 - command: GITHUB_ACTIONS="true" idf.py build - path: 'test-ci' - - - name: Run tests - uses: bitaxeorg/esp32-qemu-test-action@main - with: - path: 'test-ci' - - - name: Upload event file - uses: actions/upload-artifact@v4 - with: - name: event-file - path: ${{ github.event_path }} - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: report.xml - - - name: Check for test failures - if: always() - run: | - FAILURES=$(grep -oP 'failures="\K[0-9]+' report.xml || echo 0) - if [ "$FAILURES" -gt 0 ]; then - echo "::error ::Detected $FAILURES test failures." - exit 1 - fi + - name: Checkout repo + uses: actions/checkout@v4 + with: + submodules: 'recursive' + - name: esp-idf build + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v5.4.2 + target: esp32s3 + command: | + GITHUB_ACTIONS="true" idf.py add-dependency "espressif/mdns^1.8.2" + GITHUB_ACTIONS="true" idf.py build + path: 'test-ci' + - name: Run tests and show result + uses: bitaxeorg/esp32-qemu-test-action@main + with: + path: 'test-ci' + - name: Inspect log + run: cat report.xml + - name: Publish Unit Test Results + uses: EnricoMi/publish-unit-test-result-action@v1 + if: always() + with: + files: report.xml From 2e6e032a6789386f74543bfe38576494baed49ce Mon Sep 17 00:00:00 2001 From: korbin Date: Sat, 5 Jul 2025 17:36:39 -0600 Subject: [PATCH 03/10] add bearer token authentication for cross-origin requests, add token functionality to frontend/nvs, move swarm scan functionality to backend, remove local network checks --- components/connect/connect.c | 4 +- config-102.cvs | 1 + config-201.cvs | 1 + config-202.cvs | 1 + config-203.cvs | 1 + config-204.cvs | 1 + config-205.cvs | 1 + config-207.cvs | 1 + config-401.cvs | 1 + config-402.cvs | 1 + config-403.cvs | 1 + config-601.cvs | 1 + config-602.cvs | 1 + config-800x.cvs | 1 + config-custom.cvs | 1 + config.cvs.example | 1 + main/CMakeLists.txt | 1 + .../axe-os/api/system/asic_settings.c | 4 +- .../src/app/components/edit/edit.component.ts | 1 + .../network-edit/network.edit.component.html | 20 ++ .../network-edit/network.edit.component.ts | 45 ++- .../app/components/swarm/swarm.component.html | 15 +- .../app/components/swarm/swarm.component.ts | 178 +++++++--- .../axe-os/src/app/services/system.service.ts | 3 + .../axe-os/src/models/ISystemInfo.ts | 3 + main/http_server/http_server.c | 304 +++++++++++++----- main/http_server/http_server.h | 2 + main/nvs_config.h | 1 + 28 files changed, 467 insertions(+), 129 deletions(-) diff --git a/components/connect/connect.c b/components/connect/connect.c index 6c94b7c04..3424ead0b 100644 --- a/components/connect/connect.c +++ b/components/connect/connect.c @@ -234,8 +234,8 @@ esp_netif_t * wifi_init_softap(char * ap_ssid) esp_netif_t * esp_netif_ap = esp_netif_create_default_wifi_ap(); uint8_t mac[6]; - esp_wifi_get_mac(ESP_IF_WIFI_AP, mac); - // Format the last 4 bytes of the MAC address as a hexadecimal string + esp_wifi_get_mac(WIFI_IF_STA, mac); + // Format the last 4 bytes of the Station MAC address as a hexadecimal string snprintf(ap_ssid, 32, "Bitaxe_%02X%02X", mac[4], mac[5]); wifi_config_t wifi_ap_config; diff --git a/config-102.cvs b/config-102.cvs index 069e0ff12..ba8d40e67 100644 --- a/config-102.cvs +++ b/config-102.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-201.cvs b/config-201.cvs index 459d839e0..f5fb951ce 100644 --- a/config-201.cvs +++ b/config-201.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-202.cvs b/config-202.cvs index e1003837a..b608bab14 100644 --- a/config-202.cvs +++ b/config-202.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-203.cvs b/config-203.cvs index 02ad19728..1bb2bdc59 100644 --- a/config-203.cvs +++ b/config-203.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-204.cvs b/config-204.cvs index 3599c707a..25e543df3 100644 --- a/config-204.cvs +++ b/config-204.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-205.cvs b/config-205.cvs index ffcc27609..3335d780d 100644 --- a/config-205.cvs +++ b/config-205.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-207.cvs b/config-207.cvs index 098cb7d97..1881f186c 100644 --- a/config-207.cvs +++ b/config-207.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-401.cvs b/config-401.cvs index 9f5d4bf69..b2009180d 100644 --- a/config-401.cvs +++ b/config-401.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-402.cvs b/config-402.cvs index 49a45cc9a..323a8d6f9 100644 --- a/config-402.cvs +++ b/config-402.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-403.cvs b/config-403.cvs index ccfac7388..7e4b88019 100644 --- a/config-403.cvs +++ b/config-403.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-601.cvs b/config-601.cvs index 23440ddeb..8aade30fb 100644 --- a/config-601.cvs +++ b/config-601.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-602.cvs b/config-602.cvs index 474797cb3..3121baa13 100644 --- a/config-602.cvs +++ b/config-602.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config-800x.cvs b/config-800x.cvs index ed5f9cc4f..d9a66b3ce 100644 --- a/config-800x.cvs +++ b/config-800x.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string,myssid wifipass,data,string,password stratumurl,data,string,public-pool.io diff --git a/config-custom.cvs b/config-custom.cvs index 13ef4d1a4..fc234f135 100644 --- a/config-custom.cvs +++ b/config-custom.cvs @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string, wifipass,data,string, stratumurl,data,string,public-pool.io diff --git a/config.cvs.example b/config.cvs.example index 258773205..c5df670eb 100644 --- a/config.cvs.example +++ b/config.cvs.example @@ -1,6 +1,7 @@ key,type,encoding,value main,namespace,, hostname,data,string,bitaxe +apisecret,data,string, wifissid,data,string,myssid wifipass,data,string,mypass stratumurl,data,string,public-pool.io diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b1e66c719..d3f6c32fc 100755 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -54,6 +54,7 @@ PRIV_REQUIRES "esp_adc" "esp_app_format" "esp_event" + "esp_http_client" "esp_http_server" "esp_netif" "esp_psram" diff --git a/main/http_server/axe-os/api/system/asic_settings.c b/main/http_server/axe-os/api/system/asic_settings.c index 1a87aa0d3..161f5d1ad 100644 --- a/main/http_server/axe-os/api/system/asic_settings.c +++ b/main/http_server/axe-os/api/system/asic_settings.c @@ -9,7 +9,7 @@ static GlobalState *GLOBAL_STATE = NULL; // Function declarations from http_server.c -extern esp_err_t is_network_allowed(httpd_req_t *req); +extern esp_err_t is_request_authorized(httpd_req_t *req); extern esp_err_t set_cors_headers(httpd_req_t *req); // Initialize the ASIC API with the global state @@ -20,7 +20,7 @@ void asic_api_init(GlobalState *global_state) { /* Handler for system asic endpoint */ esp_err_t GET_system_asic(httpd_req_t *req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } diff --git a/main/http_server/axe-os/src/app/components/edit/edit.component.ts b/main/http_server/axe-os/src/app/components/edit/edit.component.ts index 7c88ac896..bda5af395 100644 --- a/main/http_server/axe-os/src/app/components/edit/edit.component.ts +++ b/main/http_server/axe-os/src/app/components/edit/edit.component.ts @@ -344,4 +344,5 @@ export class EditComponent implements OnInit, OnDestroy, OnChanges { return !! Object.entries(this.form.controls) .filter(([field, control]) => control.dirty && !this.noRestartFields.includes(field)).length } + } diff --git a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html index 0f34b7790..c006c6083 100644 --- a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html +++ b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html @@ -4,6 +4,10 @@
+ + After restart, your bitaxe will be reachable at: + {{getHostnameUrl()}} +
@@ -31,6 +35,22 @@
+
+ +
+
+ + +
+ Optional 12-32 character string for API authentication +
+
+
diff --git a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts index 8e08ba3e8..5cda5d2a7 100644 --- a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts +++ b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.ts @@ -26,6 +26,7 @@ export class NetworkEditComponent implements OnInit { public form!: FormGroup; public savedChanges: boolean = false; public scanning: boolean = false; + public isInCaptivePortalMode: boolean = false; @Input() uri = ''; @@ -43,15 +44,52 @@ export class NetworkEditComponent implements OnInit { this.systemService.getInfo(this.uri) .pipe(this.loadingService.lockUIUntilComplete()) .subscribe(info => { + // Check if we're in captive portal mode (no SSID configured) + this.isInCaptivePortalMode = !info.ssid || info.ssid.trim() === ''; + + // Generate suggested hostname from MAC address (bitaxe-xxxx pattern) + const suggestedHostname = this.generateSuggestedHostname(info.macAddr); + const hostname = this.isInCaptivePortalMode ? suggestedHostname : info.hostname; + this.form = this.fb.group({ - hostname: [info.hostname, [Validators.required]], + hostname: [hostname, [Validators.required]], ssid: [info.ssid, [Validators.required]], wifiPass: ['*****'], + apiSecret: [info.apiSecret || '', [Validators.minLength(12), Validators.maxLength(32)]], }); this.formSubject.next(this.form); }); } + private generateSuggestedHostname(macAddr: string): string { + if (!macAddr) return 'bitaxe'; + + // Extract last 4 characters (2 bytes) from MAC address + // MAC format is XX:XX:XX:XX:XX:XX, so get last 2 pairs + const macParts = macAddr.split(':'); + if (macParts.length >= 6) { + const lastTwoBytes = macParts[4] + macParts[5]; + return `bitaxe-${lastTwoBytes.toLowerCase()}`; + } + + return 'bitaxe'; + } + + public generateApiSecret(): void { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < 24; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + this.form.patchValue({ apiSecret: result }); + this.form.markAsDirty(); + } + + public getHostnameUrl(): string { + const hostname = this.form?.get('hostname')?.value || 'bitaxe'; + return `http://${hostname}.local`; + } + public updateSystem() { @@ -69,6 +107,11 @@ export class NetworkEditComponent implements OnInit { form.ssid = form.ssid.trim(); } + // Handle API secret - remove if empty + if (form.apiSecret && form.apiSecret.trim() === '') { + form.apiSecret = ''; + } + this.systemService.updateSystem(this.uri, form) .pipe(this.loadingService.lockUIUntilComplete()) .subscribe({ diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html index 00f0721bb..381a529ee 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html @@ -4,11 +4,16 @@
- - - - - +
+ + + + + + Leave API Secret empty for same-network devices, or provide 12-32 character secret for cross-origin access +
diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts index 630e5ddc3..f3b3a457d 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts @@ -50,7 +50,8 @@ export class SwarmComponent implements OnInit, OnDestroy { ) { this.form = this.fb.group({ - manualAddIp: [null, [Validators.required, Validators.pattern('(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)')]] + manualAddIp: [null, [Validators.required, Validators.pattern('(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)')]], + manualAddApiSecret: ['', [Validators.minLength(12), Validators.maxLength(32)]] }); const storedRefreshTime = this.localStorageService.getNumber(SWARM_REFRESH_TIME) ?? 30; @@ -116,40 +117,119 @@ export class SwarmComponent implements OnInit, OnDestroy { scanNetwork() { this.scanning = true; - const { start, end } = this.calculateIpRange(window.location.hostname, '255.255.255.0'); - const ips = Array.from({ length: end - start + 1 }, (_, i) => this.intToIp(start + i)); - this.getAllDeviceInfo(ips, () => of(null)).subscribe({ - next: (result) => { - // Filter out null items first - const validResults = result.filter((item): item is SwarmDevice => item !== null); - // Merge new results with existing swarm entries - const existingIps = new Set(this.swarm.map(item => item.IP)); - const newItems = validResults.filter(item => !existingIps.has(item.IP)); - this.swarm = [...this.swarm, ...newItems]; - this.sortSwarm(); - this.localStorageService.setObject(SWARM_DATA, this.swarm); - this.calculateTotals(); + // Get current device IP info to determine subnet + this.httpClient.get('/api/system/info').subscribe({ + next: (deviceInfo) => { + if (!deviceInfo.currentIP || !deviceInfo.netmask) { + this.toastr.error('Unable to get network information', 'Scan Error'); + this.scanning = false; + return; + } + + // Calculate IP range from current IP and netmask + const ipRange = this.calculateIpRange(deviceInfo.currentIP, deviceInfo.netmask); + const ipsToScan: string[] = []; + + // Generate list of IPs to scan + for (let addr = ipRange.start; addr <= ipRange.end; addr++) { + const ip = this.intToIp(addr); + if (ip !== deviceInfo.currentIP) { // Skip our own IP + ipsToScan.push(ip); + } + } + + // Split IPs into chunks for parallel processing (4 concurrent requests) + const chunkSize = Math.ceil(ipsToScan.length / 4); + const chunks: string[][] = []; + for (let i = 0; i < ipsToScan.length; i += chunkSize) { + chunks.push(ipsToScan.slice(i, i + chunkSize)); + } + + // Process chunks in parallel + const scanPromises = chunks.map(chunk => this.scanIpChunk(chunk)); + + Promise.allSettled(scanPromises).then(results => { + const foundDevices: any[] = []; + + results.forEach(result => { + if (result.status === 'fulfilled') { + foundDevices.push(...result.value); + } + }); + + // Merge new results with existing swarm entries + const existingIps = new Set(this.swarm.map(item => item.IP)); + const newItems = foundDevices.filter(device => !existingIps.has(device.IP)); + + // Add new devices to swarm + this.swarm = [...this.swarm, ...newItems]; + this.sortSwarm(); + this.localStorageService.setObject(SWARM_DATA, this.swarm); + this.calculateTotals(); + + this.toastr.success(`Found ${newItems.length} new device(s)`, 'Network Scan Complete'); + this.scanning = false; + }); }, - complete: () => { + error: (error) => { + this.toastr.error(`Failed to get device info: ${error.message || 'Unknown error'}`, 'Scan Error'); this.scanning = false; } }); } - private getAllDeviceInfo(ips: string[], errorHandler: (error: any, ip: string) => Observable, fetchAsic: boolean = true) { - return from(ips).pipe( - mergeMap(IP => forkJoin({ - info: this.httpClient.get(`http://${IP}/api/system/info`), - asic: fetchAsic ? this.httpClient.get(`http://${IP}/api/system/asic`).pipe(catchError(() => of({}))) : of({}) - }).pipe( - map(({ info, asic }) => { - const existingDevice = this.swarm.find(device => device.IP === IP); - const result = { IP, ...(existingDevice ? existingDevice : {}), ...info, ...asic }; - return this.fallbackDeviceModel(result); + private scanIpChunk(ips: string[]): Promise { + const scanPromises = ips.map(ip => + this.httpClient.get(`/api/system/network/scan?ip=${ip}`).pipe( + timeout(400), // 400ms timeout to match backend + map(response => { + if (response.status === 'found' && response.ASICModel) { + return response; + } + return null; }), - timeout(5000), - catchError(error => errorHandler(error, IP)) - ), + catchError(() => of(null)) + ).toPromise() + ); + + return Promise.allSettled(scanPromises).then(results => + results + .filter(result => result.status === 'fulfilled' && result.value !== null) + .map((result: any) => result.value) + ); + } + + private getHttpOptionsForDevice(device: any): { headers: any } { + const headers: any = { + 'X-Requested-With': 'XMLHttpRequest' + }; + + // Check if device has an API secret stored + const apiSecret = device?.apiSecret; + if (apiSecret && apiSecret.trim() !== '') { + headers['Authorization'] = `Bearer ${apiSecret}`; + } + + return { headers }; + } + + private getAllDeviceInfo(errorHandler: (error: any, ip: string) => Observable, fetchAsic: boolean = true) { + return from(this.swarm).pipe( + mergeMap(device => { + const httpOptions = this.getHttpOptionsForDevice(device); + + return forkJoin({ + info: this.httpClient.get(`http://${device.IP}/api/system/info`, httpOptions), + asic: fetchAsic ? this.httpClient.get(`http://${device.IP}/api/system/asic`, httpOptions).pipe(catchError(() => of({}))) : of({}) + }).pipe( + map(({ info, asic }) => { + const result = { ...device, ...info, ...asic }; + return this.fallbackDeviceModel(result); + }), + timeout(5000), + catchError(error => errorHandler(error, device.IP)) + ); + }, 128 ), toArray() @@ -158,6 +238,7 @@ export class SwarmComponent implements OnInit, OnDestroy { public add() { const IP = this.form.value.manualAddIp; + const apiSecret = this.form.value.manualAddApiSecret; // Check if IP already exists if (this.swarm.some(item => item.IP === IP)) { @@ -165,18 +246,33 @@ export class SwarmComponent implements OnInit, OnDestroy { return; } + // Create temporary device object for header generation + const httpOptions = this.getHttpOptionsForDevice({ IP, apiSecret }); + forkJoin({ - info: this.httpClient.get(`http://${IP}/api/system/info`), - asic: this.httpClient.get(`http://${IP}/api/system/asic`).pipe(catchError(() => of({}))) - }).subscribe(({ info, asic }) => { - if (!info.ASICModel || !asic.ASICModel) { - return; - } + info: this.httpClient.get(`http://${IP}/api/system/info`, httpOptions), + asic: this.httpClient.get(`http://${IP}/api/system/asic`, httpOptions).pipe(catchError(() => of({}))) + }).subscribe({ + next: ({ info, asic }) => { + if (!info.ASICModel || !asic.ASICModel) { + return; + } + + // Store the API secret with the device for future requests + const deviceData = { IP, ...asic, ...info }; + if (apiSecret && apiSecret.trim() !== '') { + deviceData.apiSecret = apiSecret; + } - this.swarm.push({ IP, ...asic, ...info }); - this.sortSwarm(); - this.localStorageService.setObject(SWARM_DATA, this.swarm); - this.calculateTotals(); + this.swarm.push(deviceData); + this.sortSwarm(); + this.localStorageService.setObject(SWARM_DATA, this.swarm); + this.calculateTotals(); + this.toastr.success(`Device at ${IP} added successfully`, 'Success'); + }, + error: (error) => { + this.toastr.error(`Failed to add device at ${IP}: ${error.message || 'Unknown error'}`, 'Error'); + } }); } @@ -186,7 +282,7 @@ export class SwarmComponent implements OnInit, OnDestroy { } public restart(axe: any) { - this.httpClient.post(`http://${axe.IP}/api/system/restart`, {}).pipe( + this.httpClient.post(`http://${axe.IP}/api/system/restart`, {}, this.getHttpOptionsForDevice(axe)).pipe( catchError(error => { if (error.status === 0 || error.status === 200 || error.name === 'HttpErrorResponse') { return of('success'); @@ -232,10 +328,9 @@ export class SwarmComponent implements OnInit, OnDestroy { } this.refreshIntervalTime = this.refreshTimeSet; - const ips = this.swarm.map(axeOs => axeOs.IP); this.isRefreshing = true; - this.getAllDeviceInfo(ips, this.refreshErrorHandler, fetchAsic).subscribe({ + this.getAllDeviceInfo(this.refreshErrorHandler, fetchAsic).subscribe({ next: (result) => { this.swarm = result; this.sortSwarm(); @@ -354,6 +449,7 @@ export class SwarmComponent implements OnInit, OnDestroy { case 'Supra': return 'blue'; case 'UltraHex': return 'orange'; case 'Gamma': return 'green'; + case 'GammaHex': return 'lime'; // New color? case 'GammaTurbo': return 'cyan'; default: return 'gray'; } diff --git a/main/http_server/axe-os/src/app/services/system.service.ts b/main/http_server/axe-os/src/app/services/system.service.ts index 7ffb54bbc..70ee0a47c 100644 --- a/main/http_server/axe-os/src/app/services/system.service.ts +++ b/main/http_server/axe-os/src/app/services/system.service.ts @@ -39,6 +39,8 @@ export class SystemService { coreVoltage: 1200, coreVoltageActual: 1200, hostname: "Bitaxe", + currentIP: "0.0.0.0", + netmask: "255.255.255.255", macAddr: "2C:54:91:88:C9:E3", ssid: "default", wifiPass: "password", @@ -78,6 +80,7 @@ export class SystemService { temptarget: 60, statsFrequency: 30, fanrpm: 0, + apiSecret: "devapisecret", boardtemp1: 30, boardtemp2: 40, diff --git a/main/http_server/axe-os/src/models/ISystemInfo.ts b/main/http_server/axe-os/src/models/ISystemInfo.ts index bbf6d94e2..a9d17ad53 100644 --- a/main/http_server/axe-os/src/models/ISystemInfo.ts +++ b/main/http_server/axe-os/src/models/ISystemInfo.ts @@ -22,7 +22,10 @@ export interface ISystemInfo { freeHeap: number, coreVoltage: number, hostname: string, + apiSecret: string, macAddr: string, + currentIP: string, + netmask: string, ssid: string, wifiStatus: string, wifiRSSI: number, diff --git a/main/http_server/http_server.c b/main/http_server/http_server.c index 694ffff9b..24b6d172c 100644 --- a/main/http_server/http_server.c +++ b/main/http_server/http_server.c @@ -26,6 +26,7 @@ #include "lwip/netdb.h" #include "lwip/sockets.h" #include "lwip/sys.h" +#include "esp_http_client.h" #include "cJSON.h" #include "global_state.h" @@ -49,6 +50,106 @@ static const char * CORS_TAG = "CORS"; static char axeOSVersion[32]; +/* Handler for single IP scan endpoint */ +static esp_err_t GET_network_scan(httpd_req_t *req) +{ + if (is_request_authorized(req) != ESP_OK) { + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + } + + httpd_resp_set_type(req, "application/json"); + + // Set CORS headers + if (set_cors_headers(req) != ESP_OK) { + httpd_resp_send_500(req); + return ESP_OK; + } + + // Get IP parameter from query string + char ip_param[16] = {0}; + if (httpd_req_get_url_query_str(req, ip_param, sizeof(ip_param)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing IP parameter"); + return ESP_OK; + } + + char target_ip[16] = {0}; + if (httpd_query_key_value(ip_param, "ip", target_ip, sizeof(target_ip)) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid IP parameter"); + return ESP_OK; + } + + cJSON *root = cJSON_CreateObject(); + + // Simple HTTP client check for /api/system/info + esp_http_client_config_t config = { + .url = "", // Will be set below + .timeout_ms = 200, + .disable_auto_redirect = true, + }; + + char url[64]; + snprintf(url, sizeof(url), "http://%s/api/system/info", target_ip); + config.url = url; + + esp_http_client_handle_t client = esp_http_client_init(&config); + esp_err_t err = esp_http_client_perform(client); + + if (err == ESP_OK) { + int status_code = esp_http_client_get_status_code(client); + if (status_code == 200) { + // Device found - get response data + int content_length = esp_http_client_get_content_length(client); + if (content_length > 0 && content_length < 4096) { + char *buffer = malloc(content_length + 1); + if (buffer) { + esp_http_client_read_response(client, buffer, content_length); + buffer[content_length] = '\0'; + + // Parse JSON response + cJSON *device_info = cJSON_Parse(buffer); + if (device_info) { + // Add IP to the response and merge with root + cJSON_AddStringToObject(device_info, "IP", target_ip); + cJSON_AddStringToObject(root, "status", "found"); + + // Copy all fields from device_info to root + cJSON *item = device_info->child; + while (item) { + cJSON *next = item->next; + cJSON_DetachItemFromObject(device_info, item->string); + cJSON_AddItemToObject(root, item->string, item); + item = next; + } + ESP_LOGI(TAG, "Found device at %s", target_ip); + } else { + cJSON_AddStringToObject(root, "status", "invalid_response"); + } + free(buffer); + cJSON_Delete(device_info); + } else { + cJSON_AddStringToObject(root, "status", "memory_error"); + } + } else { + cJSON_AddStringToObject(root, "status", "invalid_content"); + } + } else { + cJSON_AddStringToObject(root, "status", "not_found"); + cJSON_AddNumberToObject(root, "status_code", status_code); + } + } else { + cJSON_AddStringToObject(root, "status", "connection_failed"); + } + + esp_http_client_cleanup(client); + + const char *response = cJSON_Print(root); + httpd_resp_sendstr(req, response); + + free((void *)response); + cJSON_Delete(root); + return ESP_OK; +} + /* Handler for WiFi scan endpoint */ static esp_err_t GET_wifi_scan(httpd_req_t *req) { @@ -113,102 +214,120 @@ typedef struct rest_server_context #define CHECK_FILE_EXTENSION(filename, ext) (strcasecmp(&filename[strlen(filename) - strlen(ext)], ext) == 0) -static esp_err_t ip_in_private_range(uint32_t address) { - uint32_t ip_address = ntohl(address); +static bool is_cross_origin_request(httpd_req_t * req) +{ + // Get the Host header (server's hostname/IP) + char host[128] = {0}; + if (httpd_req_get_hdr_value_str(req, "Host", host, sizeof(host)) != ESP_OK) { + ESP_LOGD(CORS_TAG, "No Host header found"); + return false; + } - // 10.0.0.0 - 10.255.255.255 (Class A) - if ((ip_address >= 0x0A000000) && (ip_address <= 0x0AFFFFFF)) { - return ESP_OK; + // Get the Origin header (client's origin) + char origin[128] = {0}; + if (httpd_req_get_hdr_value_str(req, "Origin", origin, sizeof(origin)) != ESP_OK) { + ESP_LOGD(CORS_TAG, "No Origin header found - not a cross-origin request"); + return false; } - // 172.16.0.0 - 172.31.255.255 (Class B) - if ((ip_address >= 0xAC100000) && (ip_address <= 0xAC1FFFFF)) { - return ESP_OK; + // Extract hostname from origin (remove protocol) + char *origin_host = origin; + if (strncmp(origin, "http://", 7) == 0) { + origin_host = origin + 7; + } else if (strncmp(origin, "https://", 8) == 0) { + origin_host = origin + 8; } - // 192.168.0.0 - 192.168.255.255 (Class C) - if ((ip_address >= 0xC0A80000) && (ip_address <= 0xC0A8FFFF)) { - return ESP_OK; + // Remove path from origin host if present + char *path_start = strchr(origin_host, '/'); + if (path_start) { + *path_start = '\0'; } - return ESP_FAIL; + // Compare host headers - if different, it's cross-origin + bool is_cross_origin = strcmp(host, origin_host) != 0; + + ESP_LOGD(CORS_TAG, "Host: %s, Origin: %s, Cross-origin: %s", + host, origin_host, is_cross_origin ? "yes" : "no"); + + return is_cross_origin; } -static uint32_t extract_origin_ip_addr(char *origin) +static bool constant_time_compare(const char *a, const char *b) { - char ip_str[16]; - uint32_t origin_ip_addr = 0; - - // Find the start of the IP address in the Origin header - const char *prefix = "http://"; - char *ip_start = strstr(origin, prefix); - if (ip_start) { - ip_start += strlen(prefix); // Move past "http://" - - // Extract the IP address portion (up to the next '/') - char *ip_end = strchr(ip_start, '/'); - size_t ip_len = ip_end ? (size_t)(ip_end - ip_start) : strlen(ip_start); - if (ip_len < sizeof(ip_str)) { - strncpy(ip_str, ip_start, ip_len); - ip_str[ip_len] = '\0'; // Null-terminate the string - - // Convert the IP address string to uint32_t - origin_ip_addr = inet_addr(ip_str); - if (origin_ip_addr == INADDR_NONE) { - ESP_LOGW(CORS_TAG, "Invalid IP address: %s", ip_str); - } else { - ESP_LOGD(CORS_TAG, "Extracted IP address %lu", origin_ip_addr); - } - } else { - ESP_LOGW(CORS_TAG, "IP address string is too long: %s", ip_start); - } + volatile uint8_t result = 0; + size_t i = 0; + + // Compare characters until we reach the end of both strings + while (a[i] != '\0' || b[i] != '\0') { + result |= a[i] ^ b[i]; + i++; } - return origin_ip_addr; + return result == 0; } -esp_err_t is_network_allowed(httpd_req_t * req) +esp_err_t is_request_authorized(httpd_req_t * req) { + // Always allow requests in AP mode if (GLOBAL_STATE->SYSTEM_MODULE.ap_enabled == true) { - ESP_LOGI(CORS_TAG, "Device in AP mode. Allowing CORS."); + ESP_LOGI(CORS_TAG, "Device in AP mode. Allowing request."); return ESP_OK; } - int sockfd = httpd_req_to_sockfd(req); - char ipstr[INET6_ADDRSTRLEN]; - struct sockaddr_in6 addr; // esp_http_server uses IPv6 addressing - socklen_t addr_size = sizeof(addr); + // If it's not a cross-origin request, allow it + if (!is_cross_origin_request(req)) { + ESP_LOGD(CORS_TAG, "Same-origin request - allowing"); + return ESP_OK; + } - if (getpeername(sockfd, (struct sockaddr *)&addr, &addr_size) < 0) { - ESP_LOGE(CORS_TAG, "Error getting client IP"); + // For cross-origin requests, require X-Requested-With header + ESP_LOGI(CORS_TAG, "Cross-origin request detected - checking requirements"); + + char x_requested_with[64] = {0}; + if (httpd_req_get_hdr_value_str(req, "X-Requested-With", x_requested_with, sizeof(x_requested_with)) != ESP_OK) { + ESP_LOGW(CORS_TAG, "Cross-origin request blocked - missing X-Requested-With header"); return ESP_FAIL; } - uint32_t request_ip_addr = addr.sin6_addr.un.u32_addr[3]; + char * configured_secret = nvs_config_get_string(NVS_CONFIG_API_SECRET, ""); - // // Convert to IPv6 string - // inet_ntop(AF_INET, &addr.sin6_addr, ipstr, sizeof(ipstr)); + // If no API secret is configured, deny cross-origin requests + if (strlen(configured_secret) == 0) { + ESP_LOGW(CORS_TAG, "Cross-origin request blocked - no API secret configured"); + free(configured_secret); + return ESP_FAIL; + } - // Convert to IPv4 string - inet_ntop(AF_INET, &request_ip_addr, ipstr, sizeof(ipstr)); + // Get the Authorization header and check for Bearer token + char auth_header[128] = {0}; + if (httpd_req_get_hdr_value_str(req, "Authorization", auth_header, sizeof(auth_header)) != ESP_OK) { + ESP_LOGW(CORS_TAG, "Cross-origin request blocked - no Authorization header"); + free(configured_secret); + return ESP_FAIL; + } - // Attempt to get the Origin header. - char origin[128]; - uint32_t origin_ip_addr; - if (httpd_req_get_hdr_value_str(req, "Origin", origin, sizeof(origin)) == ESP_OK) { - ESP_LOGD(CORS_TAG, "Origin header: %s", origin); - origin_ip_addr = extract_origin_ip_addr(origin); - } else { - ESP_LOGD(CORS_TAG, "No origin header found."); - origin_ip_addr = request_ip_addr; + // Check if it starts with "Bearer " + const char *bearer_prefix = "Bearer "; + if (strncmp(auth_header, bearer_prefix, strlen(bearer_prefix)) != 0) { + ESP_LOGW(CORS_TAG, "Cross-origin request blocked - Authorization header must use Bearer scheme"); + free(configured_secret); + return ESP_FAIL; } - if (ip_in_private_range(origin_ip_addr) == ESP_OK && ip_in_private_range(request_ip_addr) == ESP_OK) { - return ESP_OK; + // Extract the token + char *provided_secret = auth_header + strlen(bearer_prefix); + + // Compare secrets using constant-time comparison + if (!constant_time_compare(configured_secret, provided_secret)) { + ESP_LOGW(CORS_TAG, "Cross-origin request blocked - invalid API secret"); + free(configured_secret); + return ESP_FAIL; } - ESP_LOGI(CORS_TAG, "Client is NOT in the private ip ranges or same range as server."); - return ESP_FAIL; + ESP_LOGI(CORS_TAG, "Cross-origin request authorized with valid API secret"); + free(configured_secret); + return ESP_OK; } static void readAxeOSVersion(void) { @@ -306,7 +425,7 @@ esp_err_t set_cors_headers(httpd_req_t * req) return ESP_FAIL; } - err = httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); + err = httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With"); if (err != ESP_OK) { return ESP_FAIL; } @@ -317,7 +436,7 @@ esp_err_t set_cors_headers(httpd_req_t * req) /* Recovery handler */ static esp_err_t rest_recovery_handler(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -332,7 +451,7 @@ static esp_err_t rest_recovery_handler(httpd_req_t * req) /* Send a 404 as JSON for unhandled api routes */ static esp_err_t rest_api_common_handler(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -418,7 +537,7 @@ static esp_err_t rest_common_get_handler(httpd_req_t * req) static esp_err_t handle_options_request(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -437,7 +556,7 @@ static esp_err_t handle_options_request(httpd_req_t * req) static esp_err_t PATCH_update_settings(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -519,6 +638,9 @@ static esp_err_t PATCH_update_settings(httpd_req_t * req) if (cJSON_IsString(item = cJSON_GetObjectItem(root, "hostname"))) { nvs_config_set_string(NVS_CONFIG_HOSTNAME, item->valuestring); } + if (cJSON_IsString(item = cJSON_GetObjectItem(root, "apiSecret"))) { + nvs_config_set_string(NVS_CONFIG_API_SECRET, item->valuestring); + } if ((item = cJSON_GetObjectItem(root, "coreVoltage")) != NULL && item->valueint > 0) { nvs_config_set_u16(NVS_CONFIG_ASIC_VOLTAGE, item->valueint); } @@ -563,7 +685,7 @@ static esp_err_t PATCH_update_settings(httpd_req_t * req) static esp_err_t POST_restart(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -593,7 +715,7 @@ static esp_err_t POST_restart(httpd_req_t * req) /* Simple handler for getting system handler */ static esp_err_t GET_system_info(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -607,6 +729,7 @@ static esp_err_t GET_system_info(httpd_req_t * req) char * ssid = nvs_config_get_string(NVS_CONFIG_WIFI_SSID, CONFIG_ESP_WIFI_SSID); char * hostname = nvs_config_get_string(NVS_CONFIG_HOSTNAME, CONFIG_LWIP_LOCAL_HOSTNAME); + char * apiSecret = nvs_config_get_string(NVS_CONFIG_API_SECRET, ""); char * stratumURL = nvs_config_get_string(NVS_CONFIG_STRATUM_URL, CONFIG_STRATUM_URL); char * fallbackStratumURL = nvs_config_get_string(NVS_CONFIG_FALLBACK_STRATUM_URL, CONFIG_FALLBACK_STRATUM_URL); char * stratumUser = nvs_config_get_string(NVS_CONFIG_STRATUM_USER, CONFIG_STRATUM_USER); @@ -620,6 +743,18 @@ static esp_err_t GET_system_info(httpd_req_t * req) char formattedMac[18]; snprintf(formattedMac, sizeof(formattedMac), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + // Get current IP and netmask information + char currentIP[16] = "0.0.0.0"; + char netmask[16] = "0.0.0.0"; + esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (netif) { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) { + strcpy(currentIP, inet_ntoa(ip_info.ip)); + strcpy(netmask, inet_ntoa(ip_info.netmask)); + } + } + int8_t wifi_rssi = -90; get_wifi_current_rssi(&wifi_rssi); @@ -647,7 +782,10 @@ static esp_err_t GET_system_info(httpd_req_t * req) cJSON_AddNumberToObject(root, "frequency", frequency); cJSON_AddStringToObject(root, "ssid", ssid); cJSON_AddStringToObject(root, "macAddr", formattedMac); + cJSON_AddStringToObject(root, "currentIP", currentIP); + cJSON_AddStringToObject(root, "netmask", netmask); cJSON_AddStringToObject(root, "hostname", hostname); + cJSON_AddStringToObject(root, "apiSecret", apiSecret); cJSON_AddStringToObject(root, "wifiStatus", GLOBAL_STATE->SYSTEM_MODULE.wifi_status); cJSON_AddNumberToObject(root, "wifiRSSI", wifi_rssi); cJSON_AddNumberToObject(root, "apEnabled", GLOBAL_STATE->SYSTEM_MODULE.ap_enabled); @@ -707,6 +845,7 @@ static esp_err_t GET_system_info(httpd_req_t * req) free(ssid); free(hostname); + free(apiSecret); free(stratumURL); free(fallbackStratumURL); free(stratumUser); @@ -800,7 +939,7 @@ int create_json_statistics_dashboard(cJSON * root) static esp_err_t GET_system_statistics(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -829,7 +968,7 @@ static esp_err_t GET_system_statistics(httpd_req_t * req) static esp_err_t GET_system_statistics_dashboard(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -858,7 +997,7 @@ static esp_err_t GET_system_statistics_dashboard(httpd_req_t * req) esp_err_t POST_WWW_update(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -933,7 +1072,7 @@ esp_err_t POST_WWW_update(httpd_req_t * req) */ esp_err_t POST_OTA_update(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -1069,7 +1208,7 @@ void send_log_to_websocket(char *message) */ esp_err_t echo_handler(httpd_req_t * req) { - if (is_network_allowed(req) != ESP_OK) { + if (is_request_authorized(req) != ESP_OK) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } @@ -1206,6 +1345,15 @@ esp_err_t start_rest_server(void * pvParameters) }; httpd_register_uri_handler(server, &wifi_scan_get_uri); + /* URI handler for network scan */ + httpd_uri_t network_scan_get_uri = { + .uri = "/api/system/network/scan", + .method = HTTP_GET, + .handler = GET_network_scan, + .user_ctx = rest_context + }; + httpd_register_uri_handler(server, &network_scan_get_uri); + httpd_uri_t system_restart_uri = { .uri = "/api/system/restart", .method = HTTP_POST, .handler = POST_restart, diff --git a/main/http_server/http_server.h b/main/http_server/http_server.h index 51a1c16ec..cc8245c2a 100644 --- a/main/http_server/http_server.h +++ b/main/http_server/http_server.h @@ -3,5 +3,7 @@ #include esp_err_t start_rest_server(void *pvParameters); +esp_err_t is_request_authorized(httpd_req_t *req); +esp_err_t set_cors_headers(httpd_req_t *req); #endif \ No newline at end of file diff --git a/main/nvs_config.h b/main/nvs_config.h index db8cb077b..23f260ed3 100644 --- a/main/nvs_config.h +++ b/main/nvs_config.h @@ -8,6 +8,7 @@ #define NVS_CONFIG_WIFI_SSID "wifissid" #define NVS_CONFIG_WIFI_PASS "wifipass" #define NVS_CONFIG_HOSTNAME "hostname" +#define NVS_CONFIG_API_SECRET "apisecret" #define NVS_CONFIG_STRATUM_URL "stratumurl" #define NVS_CONFIG_STRATUM_PORT "stratumport" #define NVS_CONFIG_FALLBACK_STRATUM_URL "fbstratumurl" From 45cb2b3f633b90962fdafaa46c945af59c7c4a93 Mon Sep 17 00:00:00 2001 From: korbin Date: Sun, 6 Jul 2025 21:23:19 -0600 Subject: [PATCH 04/10] dont auth check preflight requests --- main/http_server/http_server.c | 4 ---- 1 file changed, 4 deletions(-) diff --git a/main/http_server/http_server.c b/main/http_server/http_server.c index 24b6d172c..d9d0e4d90 100644 --- a/main/http_server/http_server.c +++ b/main/http_server/http_server.c @@ -537,10 +537,6 @@ static esp_err_t rest_common_get_handler(httpd_req_t * req) static esp_err_t handle_options_request(httpd_req_t * req) { - if (is_request_authorized(req) != ESP_OK) { - return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); - } - // Set CORS headers for OPTIONS request if (set_cors_headers(req) != ESP_OK) { httpd_resp_send_500(req); From ae224ce97fb68fd22b6aa1f6afebd3e4d9cdb853 Mon Sep 17 00:00:00 2001 From: korbin Date: Sun, 6 Jul 2025 22:22:44 -0600 Subject: [PATCH 05/10] respond to all OPTIONS requests --- main/http_server/http_server.c | 89 ++++++++++++++++------------------ 1 file changed, 41 insertions(+), 48 deletions(-) diff --git a/main/http_server/http_server.c b/main/http_server/http_server.c index d9d0e4d90..0e9e19b41 100644 --- a/main/http_server/http_server.c +++ b/main/http_server/http_server.c @@ -1296,38 +1296,47 @@ esp_err_t start_rest_server(void * pvParameters) // Register theme API endpoints ESP_ERROR_CHECK(register_theme_api_endpoints(server, rest_context)); + /* Respond with * for all CORS preflight requests - all API routes require authentication for cross-origin requests */ + httpd_uri_t api_options_uri = { + .uri = "/api/*", + .method = HTTP_OPTIONS, + .handler = handle_options_request, + .user_ctx = NULL, + }; + httpd_register_uri_handler(server, &api_options_uri); + /* URI handler for fetching system info */ httpd_uri_t system_info_get_uri = { - .uri = "/api/system/info", - .method = HTTP_GET, - .handler = GET_system_info, + .uri = "/api/system/info", + .method = HTTP_GET, + .handler = GET_system_info, .user_ctx = rest_context }; httpd_register_uri_handler(server, &system_info_get_uri); /* URI handler for fetching system asic values */ httpd_uri_t system_asic_get_uri = { - .uri = "/api/system/asic", - .method = HTTP_GET, - .handler = GET_system_asic, + .uri = "/api/system/asic", + .method = HTTP_GET, + .handler = GET_system_asic, .user_ctx = rest_context }; httpd_register_uri_handler(server, &system_asic_get_uri); /* URI handler for fetching system statistic values */ httpd_uri_t system_statistics_get_uri = { - .uri = "/api/system/statistics", - .method = HTTP_GET, - .handler = GET_system_statistics, + .uri = "/api/system/statistics", + .method = HTTP_GET, + .handler = GET_system_statistics, .user_ctx = rest_context }; httpd_register_uri_handler(server, &system_statistics_get_uri); /* URI handler for fetching system statistic values for dashboard */ httpd_uri_t system_statistics_dashboard_get_uri = { - .uri = "/api/system/statistics/dashboard", - .method = HTTP_GET, - .handler = GET_system_statistics_dashboard, + .uri = "/api/system/statistics/dashboard", + .method = HTTP_GET, + .handler = GET_system_statistics_dashboard, .user_ctx = rest_context }; httpd_register_uri_handler(server, &system_statistics_dashboard_get_uri); @@ -1351,57 +1360,41 @@ esp_err_t start_rest_server(void * pvParameters) httpd_register_uri_handler(server, &network_scan_get_uri); httpd_uri_t system_restart_uri = { - .uri = "/api/system/restart", .method = HTTP_POST, - .handler = POST_restart, + .uri = "/api/system/restart", .method = HTTP_POST, + .handler = POST_restart, .user_ctx = rest_context }; httpd_register_uri_handler(server, &system_restart_uri); - httpd_uri_t system_restart_options_uri = { - .uri = "/api/system/restart", - .method = HTTP_OPTIONS, - .handler = handle_options_request, - .user_ctx = NULL - }; - httpd_register_uri_handler(server, &system_restart_options_uri); - httpd_uri_t update_system_settings_uri = { - .uri = "/api/system", - .method = HTTP_PATCH, - .handler = PATCH_update_settings, + .uri = "/api/system", + .method = HTTP_PATCH, + .handler = PATCH_update_settings, .user_ctx = rest_context }; httpd_register_uri_handler(server, &update_system_settings_uri); - httpd_uri_t system_options_uri = { - .uri = "/api/system", - .method = HTTP_OPTIONS, - .handler = handle_options_request, - .user_ctx = NULL, - }; - httpd_register_uri_handler(server, &system_options_uri); - httpd_uri_t update_post_ota_firmware = { - .uri = "/api/system/OTA", - .method = HTTP_POST, - .handler = POST_OTA_update, + .uri = "/api/system/OTA", + .method = HTTP_POST, + .handler = POST_OTA_update, .user_ctx = NULL }; httpd_register_uri_handler(server, &update_post_ota_firmware); httpd_uri_t update_post_ota_www = { - .uri = "/api/system/OTAWWW", - .method = HTTP_POST, - .handler = POST_WWW_update, + .uri = "/api/system/OTAWWW", + .method = HTTP_POST, + .handler = POST_WWW_update, .user_ctx = NULL }; httpd_register_uri_handler(server, &update_post_ota_www); httpd_uri_t ws = { - .uri = "/api/ws", - .method = HTTP_GET, - .handler = echo_handler, - .user_ctx = NULL, + .uri = "/api/ws", + .method = HTTP_GET, + .handler = echo_handler, + .user_ctx = NULL, .is_websocket = true }; httpd_register_uri_handler(server, &ws); @@ -1409,8 +1402,8 @@ esp_err_t start_rest_server(void * pvParameters) if (enter_recovery) { /* Make default route serve Recovery */ httpd_uri_t recovery_implicit_get_uri = { - .uri = "/*", .method = HTTP_GET, - .handler = rest_recovery_handler, + .uri = "/*", .method = HTTP_GET, + .handler = rest_recovery_handler, .user_ctx = rest_context }; httpd_register_uri_handler(server, &recovery_implicit_get_uri); @@ -1425,9 +1418,9 @@ esp_err_t start_rest_server(void * pvParameters) httpd_register_uri_handler(server, &api_common_uri); /* URI handler for getting web server files */ httpd_uri_t common_get_uri = { - .uri = "/*", - .method = HTTP_GET, - .handler = rest_common_get_handler, + .uri = "/*", + .method = HTTP_GET, + .handler = rest_common_get_handler, .user_ctx = rest_context }; httpd_register_uri_handler(server, &common_get_uri); From 90afb541dc12025bc2aa9a72098c2be590243d28 Mon Sep 17 00:00:00 2001 From: korbin Date: Mon, 7 Jul 2025 00:08:10 -0600 Subject: [PATCH 06/10] fix swarm scan, improve swarm ui, improve swarm device deduplication/validation --- .../network-edit/network.edit.component.html | 11 +- .../app/components/swarm/swarm.component.html | 48 +++--- .../app/components/swarm/swarm.component.ts | 139 +++++++++--------- 3 files changed, 102 insertions(+), 96 deletions(-) diff --git a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html index c006c6083..acb3eacaf 100644 --- a/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html +++ b/main/http_server/axe-os/src/app/components/network-edit/network.edit.component.html @@ -41,11 +41,12 @@
- +
Optional 12-32 character string for API authentication
diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html index 381a529ee..efdf8b59d 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html @@ -1,24 +1,3 @@ -
- -
-
- -
-
- - - - - - Leave API Secret empty for same-network devices, or provide 12-32 character secret for cross-origin access -
-
-
- -
-
@@ -26,6 +5,12 @@ {{ isRefreshing ? 'Refreshing...' : 'Refresh List (' + refreshIntervalTime + ')' }} + +
@@ -39,6 +24,25 @@
+
+
+
+ +
+
+ + + + + +
+
+
+
+
+
{{axe.IP}} + tooltipPosition="top">{{axe.currentIP || axe.IP}} {{axe.hostname}} {{axe.hashRate * 1000000000 | hashSuffix}} diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts index f3b3a457d..caf29d9c7 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { forkJoin, catchError, from, map, mergeMap, of, take, timeout, toArray, Observable } from 'rxjs'; +import { forkJoin, catchError, from, map, mergeMap, of, take, timeout, toArray, Observable, range, filter } from 'rxjs'; import { LocalStorageService } from 'src/app/local-storage.service'; import { ModalComponent } from '../modal/modal.component'; @@ -42,6 +42,8 @@ export class SwarmComponent implements OnInit, OnDestroy { public sortField: string = ''; public sortDirection: 'asc' | 'desc' = 'asc'; + public showManualAddition: boolean = false; + constructor( private fb: FormBuilder, private toastr: ToastrService, @@ -50,7 +52,7 @@ export class SwarmComponent implements OnInit, OnDestroy { ) { this.form = this.fb.group({ - manualAddIp: [null, [Validators.required, Validators.pattern('(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)')]], + manualAddIp: [null, [Validators.required, Validators.pattern('^(?:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))|(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)*[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)$')]], manualAddApiSecret: ['', [Validators.minLength(12), Validators.maxLength(32)]] }); @@ -128,47 +130,45 @@ export class SwarmComponent implements OnInit, OnDestroy { // Calculate IP range from current IP and netmask const ipRange = this.calculateIpRange(deviceInfo.currentIP, deviceInfo.netmask); - const ipsToScan: string[] = []; - // Generate list of IPs to scan - for (let addr = ipRange.start; addr <= ipRange.end; addr++) { - const ip = this.intToIp(addr); - if (ip !== deviceInfo.currentIP) { // Skip our own IP - ipsToScan.push(ip); + // Generate list of IPs to scan using RxJS range + range(ipRange.start, ipRange.end - ipRange.start + 1).pipe( + map(addr => this.intToIp(addr)), + filter(ip => ip !== deviceInfo.currentIP), // Skip our own IP + mergeMap(ip => + this.httpClient.get(`/api/system/network/scan?ip=${ip}`).pipe( + timeout(2000), + map(response => { + if (response.status === 'found' && response.ASICModel) { + return response; + } + return null; + }), + catchError(() => of(null)) + ), + 4 + ), + toArray(), // Collect all results into an array + map(results => results.filter(result => result !== null)) + ).subscribe({ + next: (foundDevices) => { + // Merge new results with existing swarm entries + const existingIps = new Set(this.swarm.map(item => item.currentIP || item.IP)); + const newItems = foundDevices.filter(device => !existingIps.has(device.IP)); + + // Add new devices to swarm + this.swarm = [...this.swarm, ...newItems]; + this.sortSwarm(); + this.localStorageService.setObject(SWARM_DATA, this.swarm); + this.calculateTotals(); + + this.toastr.success(`Found ${newItems.length} new device(s)`, 'Network Scan Complete'); + this.scanning = false; + }, + error: (error) => { + this.toastr.error(`Scan failed: ${error.message || 'Unknown error'}`, 'Scan Error'); + this.scanning = false; } - } - - // Split IPs into chunks for parallel processing (4 concurrent requests) - const chunkSize = Math.ceil(ipsToScan.length / 4); - const chunks: string[][] = []; - for (let i = 0; i < ipsToScan.length; i += chunkSize) { - chunks.push(ipsToScan.slice(i, i + chunkSize)); - } - - // Process chunks in parallel - const scanPromises = chunks.map(chunk => this.scanIpChunk(chunk)); - - Promise.allSettled(scanPromises).then(results => { - const foundDevices: any[] = []; - - results.forEach(result => { - if (result.status === 'fulfilled') { - foundDevices.push(...result.value); - } - }); - - // Merge new results with existing swarm entries - const existingIps = new Set(this.swarm.map(item => item.IP)); - const newItems = foundDevices.filter(device => !existingIps.has(device.IP)); - - // Add new devices to swarm - this.swarm = [...this.swarm, ...newItems]; - this.sortSwarm(); - this.localStorageService.setObject(SWARM_DATA, this.swarm); - this.calculateTotals(); - - this.toastr.success(`Found ${newItems.length} new device(s)`, 'Network Scan Complete'); - this.scanning = false; }); }, error: (error) => { @@ -178,26 +178,6 @@ export class SwarmComponent implements OnInit, OnDestroy { }); } - private scanIpChunk(ips: string[]): Promise { - const scanPromises = ips.map(ip => - this.httpClient.get(`/api/system/network/scan?ip=${ip}`).pipe( - timeout(400), // 400ms timeout to match backend - map(response => { - if (response.status === 'found' && response.ASICModel) { - return response; - } - return null; - }), - catchError(() => of(null)) - ).toPromise() - ); - - return Promise.allSettled(scanPromises).then(results => - results - .filter(result => result.status === 'fulfilled' && result.value !== null) - .map((result: any) => result.value) - ); - } private getHttpOptionsForDevice(device: any): { headers: any } { const headers: any = { @@ -241,7 +221,7 @@ export class SwarmComponent implements OnInit, OnDestroy { const apiSecret = this.form.value.manualAddApiSecret; // Check if IP already exists - if (this.swarm.some(item => item.IP === IP)) { + if (this.swarm.some(item => item.IP === IP || item.currentIP === IP)) { this.toastr.warning('This IP address already exists in the swarm', 'Duplicate Entry'); return; } @@ -264,6 +244,12 @@ export class SwarmComponent implements OnInit, OnDestroy { deviceData.apiSecret = apiSecret; } + // Prevent an edge case where users already have a Bitaxe in the Swarm as an IP, but attempt to then add via other names + if (this.swarm.some(item => item.IP === info.currentIP || item.currentIP === info.currentIP)) { + this.toastr.warning('This IP address already exists in the swarm', 'Duplicate Entry'); + return; + } + this.swarm.push(deviceData); this.sortSwarm(); this.localStorageService.setObject(SWARM_DATA, this.swarm); @@ -368,14 +354,29 @@ export class SwarmComponent implements OnInit, OnDestroy { const fieldType = typeof a[this.sortField]; if (this.sortField === 'IP') { - // Split IP into octets and compare numerically - const aOctets = a[this.sortField].split('.').map(Number); - const bOctets = b[this.sortField].split('.').map(Number); - for (let i = 0; i < 4; i++) { - if (aOctets[i] !== bOctets[i]) { - comparison = aOctets[i] - bOctets[i]; - break; + // Check if both are valid IP addresses (4 octets with numeric values) + const aIsIP = /^(\d{1,3}\.){3}\d{1,3}$/.test(a[this.sortField]); + const bIsIP = /^(\d{1,3}\.){3}\d{1,3}$/.test(b[this.sortField]); + + if (aIsIP && bIsIP) { + // Both are IPs - split into octets and compare numerically + const aOctets = a[this.sortField].split('.').map(Number); + const bOctets = b[this.sortField].split('.').map(Number); + for (let i = 0; i < 4; i++) { + if (aOctets[i] !== bOctets[i]) { + comparison = aOctets[i] - bOctets[i]; + break; + } } + } else if (aIsIP && !bIsIP) { + // IP addresses come before hostnames + comparison = -1; + } else if (!aIsIP && bIsIP) { + // Hostnames come after IP addresses + comparison = 1; + } else { + // Both are hostnames - use locale compare + comparison = a[this.sortField].localeCompare(b[this.sortField], undefined, { numeric: true }); } } else if (fieldType === 'number') { comparison = a[this.sortField] - b[this.sortField]; From 110a57d2fdd67808e9fd0b8a1bc49e2aa19eb19a Mon Sep 17 00:00:00 2001 From: korbin Date: Mon, 7 Jul 2025 00:48:01 -0600 Subject: [PATCH 07/10] switch swarm scan to use mDNS service discovery --- components/connect/connect.c | 22 ++- .../app/components/swarm/swarm.component.ts | 102 +++++++------- main/http_server/http_server.c | 126 ++++++++---------- 3 files changed, 123 insertions(+), 127 deletions(-) diff --git a/components/connect/connect.c b/components/connect/connect.c index 3424ead0b..3b520be2f 100644 --- a/components/connect/connect.c +++ b/components/connect/connect.c @@ -192,12 +192,12 @@ static void event_handler(void * arg, esp_event_base_t event_base, int32_t event ESP_LOGI(TAG, "Retrying Wi-Fi connection..."); esp_wifi_connect(); } - + if (event_id == WIFI_EVENT_AP_START) { ESP_LOGI(TAG, "Configuration Access Point enabled"); GLOBAL_STATE->SYSTEM_MODULE.ap_enabled = true; } - + if (event_id == WIFI_EVENT_AP_STOP) { ESP_LOGI(TAG, "Configuration Access Point disabled"); GLOBAL_STATE->SYSTEM_MODULE.ap_enabled = false; @@ -514,6 +514,24 @@ static void mdns_init_hostname(void) { return; } + // Add _bitaxe._tcp service with apiSecret as TXT record + char * api_secret = nvs_config_get_string(NVS_CONFIG_API_SECRET, ""); + + ESP_LOGI(TAG, "Adding mDNS service: _bitaxe._tcp on port 80"); + + mdns_txt_item_t bitaxe_txt_data[1]; + bitaxe_txt_data[0].key = "apiSecret"; + bitaxe_txt_data[0].value = api_secret; + + err = mdns_service_add(NULL, "_bitaxe", "_tcp", 80, bitaxe_txt_data, 1); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to add mDNS _bitaxe._tcp service: %s", esp_err_to_name(err)); + } else { + ESP_LOGI(TAG, "mDNS _bitaxe._tcp service added successfully"); + } + + free(api_secret); + ESP_LOGI(TAG, "mDNS service started successfully"); free(hostname); } diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts index caf29d9c7..4f5051f37 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { forkJoin, catchError, from, map, mergeMap, of, take, timeout, toArray, Observable, range, filter } from 'rxjs'; +import { forkJoin, catchError, from, map, mergeMap, of, take, timeout, toArray, Observable } from 'rxjs'; import { LocalStorageService } from 'src/app/local-storage.service'; import { ModalComponent } from '../modal/modal.component'; @@ -100,79 +100,77 @@ export class SwarmComponent implements OnInit, OnDestroy { this.form.reset(); } - private ipToInt(ip: string): number { - return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; - } - - private intToIp(int: number): string { - return `${(int >>> 24) & 255}.${(int >>> 16) & 255}.${(int >>> 8) & 255}.${int & 255}`; - } - - private calculateIpRange(ip: string, netmask: string): { start: number, end: number } { - const ipInt = this.ipToInt(ip); - const netmaskInt = this.ipToInt(netmask); - const network = ipInt & netmaskInt; - const broadcast = network | ~netmaskInt; - return { start: network + 1, end: broadcast - 1 }; - } scanNetwork() { this.scanning = true; - // Get current device IP info to determine subnet - this.httpClient.get('/api/system/info').subscribe({ - next: (deviceInfo) => { - if (!deviceInfo.currentIP || !deviceInfo.netmask) { - this.toastr.error('Unable to get network information', 'Scan Error'); + // Call the mDNS scan endpoint + this.httpClient.get('/api/system/network/scan').subscribe({ + next: (foundDevices) => { + if (!Array.isArray(foundDevices)) { + this.toastr.error('Invalid response from scan endpoint', 'Scan Error'); + this.scanning = false; + return; + } + + // Filter out devices we already have - check both IP and currentIP + const existingIps = new Set([...this.swarm.map(item => item.IP), ...this.swarm.map(item => item.currentIP)]); + const existingHostnames = new Set(this.swarm.map(item => item.hostname).filter(h => h)); + + const newDevices = foundDevices.filter(device => { + // Check if device IP matches any existing IP or currentIP + const ipNotExists = !existingIps.has(device.IP); + const hostnameNotExists = !device.hostname || !existingHostnames.has(device.hostname); + return ipNotExists && hostnameNotExists; + }); + + if (newDevices.length === 0) { + this.toastr.info('No new devices found', 'Network Scan Complete'); this.scanning = false; return; } - // Calculate IP range from current IP and netmask - const ipRange = this.calculateIpRange(deviceInfo.currentIP, deviceInfo.netmask); - - // Generate list of IPs to scan using RxJS range - range(ipRange.start, ipRange.end - ipRange.start + 1).pipe( - map(addr => this.intToIp(addr)), - filter(ip => ip !== deviceInfo.currentIP), // Skip our own IP - mergeMap(ip => - this.httpClient.get(`/api/system/network/scan?ip=${ip}`).pipe( - timeout(2000), - map(response => { - if (response.status === 'found' && response.ASICModel) { - return response; - } - return null; - }), - catchError(() => of(null)) - ), - 4 - ), - toArray(), // Collect all results into an array - map(results => results.filter(result => result !== null)) - ).subscribe({ - next: (foundDevices) => { - // Merge new results with existing swarm entries - const existingIps = new Set(this.swarm.map(item => item.currentIP || item.IP)); - const newItems = foundDevices.filter(device => !existingIps.has(device.IP)); + // Fetch device info for each discovered device + const deviceInfoRequests = newDevices.map(device => { + const httpOptions = this.getHttpOptionsForDevice(device); + + return forkJoin({ + info: this.httpClient.get(`http://${device.IP}/api/system/info`, httpOptions), + asic: this.httpClient.get(`http://${device.IP}/api/system/asic`, httpOptions).pipe(catchError(() => of({}))) + }).pipe( + map(({ info, asic }) => { + // Merge mDNS data with fetched info + return { ...device, ...info, ...asic }; + }), + timeout(5000), + catchError(error => { + this.toastr.error(`Failed to get info from ${device.IP}`, 'Device Error'); + return of(null); + }) + ); + }); + + forkJoin(deviceInfoRequests).subscribe({ + next: (devices) => { + const validDevices = devices.filter(d => d !== null && d.ASICModel); // Add new devices to swarm - this.swarm = [...this.swarm, ...newItems]; + this.swarm = [...this.swarm, ...validDevices]; this.sortSwarm(); this.localStorageService.setObject(SWARM_DATA, this.swarm); this.calculateTotals(); - this.toastr.success(`Found ${newItems.length} new device(s)`, 'Network Scan Complete'); + this.toastr.success(`Found ${validDevices.length} new device(s)`, 'Network Scan Complete'); this.scanning = false; }, error: (error) => { - this.toastr.error(`Scan failed: ${error.message || 'Unknown error'}`, 'Scan Error'); + this.toastr.error(`Failed to fetch device information: ${error.message || 'Unknown error'}`, 'Scan Error'); this.scanning = false; } }); }, error: (error) => { - this.toastr.error(`Failed to get device info: ${error.message || 'Unknown error'}`, 'Scan Error'); + this.toastr.error(`Scan failed: ${error.message || 'Unknown error'}`, 'Scan Error'); this.scanning = false; } }); diff --git a/main/http_server/http_server.c b/main/http_server/http_server.c index 0e9e19b41..bd9fa98dd 100644 --- a/main/http_server/http_server.c +++ b/main/http_server/http_server.c @@ -26,7 +26,8 @@ #include "lwip/netdb.h" #include "lwip/sockets.h" #include "lwip/sys.h" -#include "esp_http_client.h" +#include "mdns.h" +#include #include "cJSON.h" #include "global_state.h" @@ -50,7 +51,7 @@ static const char * CORS_TAG = "CORS"; static char axeOSVersion[32]; -/* Handler for single IP scan endpoint */ +/* Handler for mDNS network scan endpoint - discovers all _bitaxe._tcp services */ static esp_err_t GET_network_scan(httpd_req_t *req) { if (is_request_authorized(req) != ESP_OK) { @@ -65,83 +66,62 @@ static esp_err_t GET_network_scan(httpd_req_t *req) return ESP_OK; } - // Get IP parameter from query string - char ip_param[16] = {0}; - if (httpd_req_get_url_query_str(req, ip_param, sizeof(ip_param)) != ESP_OK) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing IP parameter"); - return ESP_OK; - } + // Use mDNS to discover _bitaxe._tcp services + ESP_LOGI(TAG, "Starting mDNS query for _bitaxe._tcp services"); + + cJSON *root = cJSON_CreateArray(); - char target_ip[16] = {0}; - if (httpd_query_key_value(ip_param, "ip", target_ip, sizeof(target_ip)) != ESP_OK) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid IP parameter"); + mdns_result_t *results = NULL; + esp_err_t err = mdns_query_ptr("_bitaxe", "_tcp", 3000, 20, &results); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "mDNS query failed: %s", esp_err_to_name(err)); + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "mDNS query failed"); return ESP_OK; } - cJSON *root = cJSON_CreateObject(); + if (!results) { + ESP_LOGI(TAG, "No _bitaxe._tcp services found"); + } else { + mdns_result_t *r = results; + while (r) { + cJSON *device = cJSON_CreateObject(); + + // Get IP address + if (r->addr) { + char ip_str[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &r->addr->addr.u_addr.ip4, ip_str, sizeof(ip_str)); + cJSON_AddStringToObject(device, "IP", ip_str); + + // Add hostname if available + if (r->hostname) { + cJSON_AddStringToObject(device, "hostname", r->hostname); + } - // Simple HTTP client check for /api/system/info - esp_http_client_config_t config = { - .url = "", // Will be set below - .timeout_ms = 200, - .disable_auto_redirect = true, - }; + // Add instance name if available + if (r->instance_name) { + cJSON_AddStringToObject(device, "instance", r->instance_name); + } - char url[64]; - snprintf(url, sizeof(url), "http://%s/api/system/info", target_ip); - config.url = url; - - esp_http_client_handle_t client = esp_http_client_init(&config); - esp_err_t err = esp_http_client_perform(client); - - if (err == ESP_OK) { - int status_code = esp_http_client_get_status_code(client); - if (status_code == 200) { - // Device found - get response data - int content_length = esp_http_client_get_content_length(client); - if (content_length > 0 && content_length < 4096) { - char *buffer = malloc(content_length + 1); - if (buffer) { - esp_http_client_read_response(client, buffer, content_length); - buffer[content_length] = '\0'; - - // Parse JSON response - cJSON *device_info = cJSON_Parse(buffer); - if (device_info) { - // Add IP to the response and merge with root - cJSON_AddStringToObject(device_info, "IP", target_ip); - cJSON_AddStringToObject(root, "status", "found"); - - // Copy all fields from device_info to root - cJSON *item = device_info->child; - while (item) { - cJSON *next = item->next; - cJSON_DetachItemFromObject(device_info, item->string); - cJSON_AddItemToObject(root, item->string, item); - item = next; + // Extract apiSecret from TXT records + if (r->txt && r->txt_count > 0) { + for (size_t i = 0; i < r->txt_count; i++) { + if (strcmp(r->txt[i].key, "apiSecret") == 0 && r->txt[i].value) { + cJSON_AddStringToObject(device, "apiSecret", r->txt[i].value); + break; } - ESP_LOGI(TAG, "Found device at %s", target_ip); - } else { - cJSON_AddStringToObject(root, "status", "invalid_response"); } - free(buffer); - cJSON_Delete(device_info); - } else { - cJSON_AddStringToObject(root, "status", "memory_error"); } - } else { - cJSON_AddStringToObject(root, "status", "invalid_content"); + + cJSON_AddItemToArray(root, device); } - } else { - cJSON_AddStringToObject(root, "status", "not_found"); - cJSON_AddNumberToObject(root, "status_code", status_code); + + r = r->next; } - } else { - cJSON_AddStringToObject(root, "status", "connection_failed"); + mdns_query_results_free(results); } - esp_http_client_cleanup(client); - const char *response = cJSON_Print(root); httpd_resp_sendstr(req, response); @@ -154,10 +134,10 @@ static esp_err_t GET_network_scan(httpd_req_t *req) static esp_err_t GET_wifi_scan(httpd_req_t *req) { httpd_resp_set_type(req, "application/json"); - + // Give some time for the connected flag to take effect vTaskDelay(100 / portTICK_PERIOD_MS); - + wifi_ap_record_simple_t ap_records[20]; uint16_t ap_count = 0; @@ -790,7 +770,7 @@ static esp_err_t GET_system_info(httpd_req_t * req) cJSON *error_array = cJSON_CreateArray(); cJSON_AddItemToObject(root, "sharesRejectedReasons", error_array); - + for (int i = 0; i < GLOBAL_STATE->SYSTEM_MODULE.rejected_reason_stats_count; i++) { cJSON *error_obj = cJSON_CreateObject(); cJSON_AddStringToObject(error_obj, "message", GLOBAL_STATE->SYSTEM_MODULE.rejected_reason_stats[i].message); @@ -826,7 +806,7 @@ static esp_err_t GET_system_info(httpd_req_t * req) cJSON_AddNumberToObject(root, "rotation", nvs_config_get_u16(NVS_CONFIG_ROTATION, 0)); cJSON_AddNumberToObject(root, "invertscreen", nvs_config_get_u16(NVS_CONFIG_INVERT_SCREEN, 0)); cJSON_AddNumberToObject(root, "displayTimeout", nvs_config_get_i32(NVS_CONFIG_DISPLAY_TIMEOUT, -1)); - + cJSON_AddNumberToObject(root, "autofanspeed", nvs_config_get_u16(NVS_CONFIG_AUTO_FAN_SPEED, 1)); cJSON_AddNumberToObject(root, "fanspeed", GLOBAL_STATE->POWER_MANAGEMENT_MODULE.fan_perc); @@ -1079,7 +1059,7 @@ esp_err_t POST_OTA_update(httpd_req_t * req) httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Not allowed in AP mode"); return ESP_OK; } - + GLOBAL_STATE->SYSTEM_MODULE.is_firmware_update = true; snprintf(GLOBAL_STATE->SYSTEM_MODULE.firmware_update_filename, 20, "esp-miner.bin"); snprintf(GLOBAL_STATE->SYSTEM_MODULE.firmware_update_status, 20, "Starting..."); @@ -1257,7 +1237,7 @@ void websocket_log_handler() esp_err_t start_rest_server(void * pvParameters) { GLOBAL_STATE = (GlobalState *) pvParameters; - + // Initialize the ASIC API with the global state asic_api_init(GLOBAL_STATE); const char * base_path = ""; @@ -1292,7 +1272,7 @@ esp_err_t start_rest_server(void * pvParameters) .user_ctx = rest_context }; httpd_register_uri_handler(server, &recovery_explicit_get_uri); - + // Register theme API endpoints ESP_ERROR_CHECK(register_theme_api_endpoints(server, rest_context)); From 6e675eb6d022d4494fda38da71e84e8c217a103f Mon Sep 17 00:00:00 2001 From: korbin Date: Mon, 7 Jul 2025 10:17:52 -0600 Subject: [PATCH 08/10] dont send X-Requested-With without an apiSecret to keep backwards compatibility --- .../axe-os/src/app/components/swarm/swarm.component.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts index 4f5051f37..7066c7584 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts @@ -178,13 +178,12 @@ export class SwarmComponent implements OnInit, OnDestroy { private getHttpOptionsForDevice(device: any): { headers: any } { - const headers: any = { - 'X-Requested-With': 'XMLHttpRequest' - }; + const headers: any = {}; // Check if device has an API secret stored const apiSecret = device?.apiSecret; if (apiSecret && apiSecret.trim() !== '') { + headers['X-Requested-With'] = 'XMLHttpRequest'; // NOTE: This should eventually *always* be sent. headers['Authorization'] = `Bearer ${apiSecret}`; } From 252ca5d6da5090e8d7da677d3ee427d8c8bc6511 Mon Sep 17 00:00:00 2001 From: korbin Date: Mon, 7 Jul 2025 11:13:56 -0600 Subject: [PATCH 09/10] fix swarm edit form api calls --- .../src/app/components/edit/edit.component.ts | 11 ++-- .../app/components/swarm/swarm.component.html | 2 +- .../axe-os/src/app/services/system.service.ts | 62 ++++++++++++------- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/main/http_server/axe-os/src/app/components/edit/edit.component.ts b/main/http_server/axe-os/src/app/components/edit/edit.component.ts index bda5af395..aae8bc979 100644 --- a/main/http_server/axe-os/src/app/components/edit/edit.component.ts +++ b/main/http_server/axe-os/src/app/components/edit/edit.component.ts @@ -31,6 +31,7 @@ export class EditComponent implements OnInit, OnDestroy, OnChanges { public settingsUnlocked: boolean = false; @Input() uri = ''; + @Input() apiSecret?: string; // Store frequency and voltage options from API public defaultFrequency: number = 0; @@ -99,7 +100,7 @@ export class EditComponent implements OnInit, OnDestroy, OnChanges { private saveOverclockSetting(enabled: number) { const deviceUri = this.uri || ''; - this.systemService.updateSystem(deviceUri, { overclockEnabled: enabled }) + this.systemService.updateSystem(deviceUri, { overclockEnabled: enabled }, this.apiSecret) .subscribe({ next: () => { console.log(`Overclock setting saved: ${enabled === 1 ? 'enabled' : 'disabled'}`); @@ -127,8 +128,8 @@ export class EditComponent implements OnInit, OnDestroy, OnChanges { // Fetch both system info and ASIC settings in parallel forkJoin({ - info: this.systemService.getInfo(deviceUri), - asic: this.systemService.getAsicSettings(deviceUri) + info: this.systemService.getInfo(deviceUri, this.apiSecret), + asic: this.systemService.getAsicSettings(deviceUri, this.apiSecret) }) .pipe( this.loadingService.lockUIUntilComplete(), @@ -221,7 +222,7 @@ export class EditComponent implements OnInit, OnDestroy, OnChanges { } const deviceUri = this.uri || ''; - this.systemService.updateSystem(deviceUri, form) + this.systemService.updateSystem(deviceUri, form, this.apiSecret) .pipe(this.loadingService.lockUIUntilComplete()) .subscribe({ next: () => { @@ -260,7 +261,7 @@ export class EditComponent implements OnInit, OnDestroy, OnChanges { } public restart() { - this.systemService.restart(this.uri) + this.systemService.restart(this.uri, this.apiSecret) .pipe(this.loadingService.lockUIUntilComplete()) .subscribe({ next: () => { diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html index efdf8b59d..36f8a25cc 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.html +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.html @@ -198,5 +198,5 @@
- + diff --git a/main/http_server/axe-os/src/app/services/system.service.ts b/main/http_server/axe-os/src/app/services/system.service.ts index 70ee0a47c..4bb2b4db8 100644 --- a/main/http_server/axe-os/src/app/services/system.service.ts +++ b/main/http_server/axe-os/src/app/services/system.service.ts @@ -16,9 +16,22 @@ export class SystemService { private httpClient: HttpClient ) { } - public getInfo(uri: string = ''): Observable { + private getHttpOptions(apiSecret?: string): { headers?: any } { + if (!apiSecret || apiSecret.trim() === '') { + return {}; + } + + return { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Authorization': `Bearer ${apiSecret}` + } + }; + } + + public getInfo(uri: string = '', apiSecret?: string): Observable { if (environment.production) { - return this.httpClient.get(`${uri}/api/system/info`) as Observable; + return this.httpClient.get(`${uri}/api/system/info`, this.getHttpOptions(apiSecret)) as Observable; } // Mock data for development @@ -89,9 +102,9 @@ export class SystemService { ).pipe(delay(1000)); } - public getStatistics(uri: string = ''): Observable { + public getStatistics(uri: string = '', apiSecret?: string): Observable { if (environment.production) { - return this.httpClient.get(`${uri}/api/system/statistics/dashboard`) as Observable; + return this.httpClient.get(`${uri}/api/system/statistics/dashboard`, this.getHttpOptions(apiSecret)) as Observable; } // Mock data for development @@ -112,33 +125,38 @@ export class SystemService { }).pipe(delay(1000)); } - public restart(uri: string = '') { - return this.httpClient.post(`${uri}/api/system/restart`, {}, {responseType: 'text'}); + public restart(uri: string = '', apiSecret?: string) { + const options = { ...this.getHttpOptions(apiSecret), responseType: 'text' as 'text' }; + return this.httpClient.post(`${uri}/api/system/restart`, {}, options); } - public updateSystem(uri: string = '', update: any) { + public updateSystem(uri: string = '', update: any, apiSecret?: string) { if (environment.production) { - return this.httpClient.patch(`${uri}/api/system`, update); + return this.httpClient.patch(`${uri}/api/system`, update, this.getHttpOptions(apiSecret)); } else { return of(true); } } - private otaUpdate(file: File | Blob, url: string) { + private otaUpdate(file: File | Blob, url: string, apiSecret?: string) { return new Observable>((subscriber) => { const reader = new FileReader(); reader.onload = (event: any) => { const fileContent = event.target.result; + const httpOptions = this.getHttpOptions(apiSecret); + const headers = { + 'Content-Type': 'application/octet-stream', + ...(httpOptions.headers || {}) + }; + return this.httpClient.post(url, fileContent, { reportProgress: true, observe: 'events', responseType: 'text', // Specify the response type - headers: { - 'Content-Type': 'application/octet-stream', // Set the content type - }, + headers: headers, }).subscribe({ next: (event) => { subscriber.next(event); @@ -155,16 +173,16 @@ export class SystemService { }); } - public performOTAUpdate(file: File | Blob) { - return this.otaUpdate(file, `/api/system/OTA`); + public performOTAUpdate(file: File | Blob, apiSecret?: string) { + return this.otaUpdate(file, `/api/system/OTA`, apiSecret); } - public performWWWOTAUpdate(file: File | Blob) { - return this.otaUpdate(file, `/api/system/OTAWWW`); + public performWWWOTAUpdate(file: File | Blob, apiSecret?: string) { + return this.otaUpdate(file, `/api/system/OTAWWW`, apiSecret); } - public getAsicSettings(uri: string = ''): Observable { + public getAsicSettings(uri: string = '', apiSecret?: string): Observable { if (environment.production) { - return this.httpClient.get(`${uri}/api/system/asic`) as Observable; + return this.httpClient.get(`${uri}/api/system/asic`, this.getHttpOptions(apiSecret)) as Observable; } // Mock data for development @@ -180,11 +198,11 @@ export class SystemService { }).pipe(delay(1000)); } - public getSwarmInfo(uri: string = ''): Observable<{ ip: string }[]> { - return this.httpClient.get(`${uri}/api/swarm/info`) as Observable<{ ip: string }[]>; + public getSwarmInfo(uri: string = '', apiSecret?: string): Observable<{ ip: string }[]> { + return this.httpClient.get(`${uri}/api/swarm/info`, this.getHttpOptions(apiSecret)) as Observable<{ ip: string }[]>; } - public updateSwarm(uri: string = '', swarmConfig: any) { - return this.httpClient.patch(`${uri}/api/swarm`, swarmConfig); + public updateSwarm(uri: string = '', swarmConfig: any, apiSecret?: string) { + return this.httpClient.patch(`${uri}/api/swarm`, swarmConfig, this.getHttpOptions(apiSecret)); } } From b781882b44b67785f2a78dd72f73391dbd16149c Mon Sep 17 00:00:00 2001 From: korbin Date: Tue, 8 Jul 2025 00:41:56 -0600 Subject: [PATCH 10/10] add *this* device to swarm when auto-scanning --- .../app/components/swarm/swarm.component.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts index 7066c7584..3123d15c2 100644 --- a/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts +++ b/main/http_server/axe-os/src/app/components/swarm/swarm.component.ts @@ -104,19 +104,35 @@ export class SwarmComponent implements OnInit, OnDestroy { scanNetwork() { this.scanning = true; - // Call the mDNS scan endpoint - this.httpClient.get('/api/system/network/scan').subscribe({ - next: (foundDevices) => { + forkJoin({ + currentDevice: this.httpClient.get('/api/system/info').pipe( + mergeMap(info => + this.httpClient.get('/api/system/asic').pipe( + map(asic => ({ ...info, ...asic })), + catchError(() => of(info)) + ) + ) + ), + foundDevices: this.httpClient.get('/api/system/network/scan') + }).subscribe({ + next: ({ currentDevice, foundDevices }) => { if (!Array.isArray(foundDevices)) { - this.toastr.error('Invalid response from scan endpoint', 'Scan Error'); - this.scanning = false; - return; + foundDevices = []; } - // Filter out devices we already have - check both IP and currentIP + // Check if current device should be added to swarm const existingIps = new Set([...this.swarm.map(item => item.IP), ...this.swarm.map(item => item.currentIP)]); - const existingHostnames = new Set(this.swarm.map(item => item.hostname).filter(h => h)); + const existingHostnames = new Set(this.swarm.map(item => item.hostname)); + + const currentDeviceExists = existingIps.has(currentDevice.currentIP) || (currentDevice.hostname && existingHostnames.has(currentDevice.hostname)); + // If current device is not in swarm, add it to the list of devices to process + if (!currentDeviceExists && currentDevice.ASICModel) { + currentDevice.IP = window.location.host; + foundDevices.unshift(currentDevice); + } + + // Filter out devices we already have - check both IP and currentIP const newDevices = foundDevices.filter(device => { // Check if device IP matches any existing IP or currentIP const ipNotExists = !existingIps.has(device.IP);