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 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..3b520be2f 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) { @@ -190,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; @@ -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(); } } @@ -230,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; @@ -474,3 +478,60 @@ 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; + } + + // 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/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 f6bb63c76..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" @@ -64,6 +65,7 @@ PRIV_REQUIRES "spiffs" "vfs" "esp_driver_i2c" + "mdns" EMBED_FILES "http_server/recovery_page.html" ) 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..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: () => { @@ -344,4 +345,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..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 @@ -4,6 +4,10 @@
+ + After restart, your bitaxe will be reachable at: + {{getHostnameUrl()}} +
@@ -31,6 +35,23 @@
+
+ +
+
+ + +
+ 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..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 @@ -1,19 +1,3 @@ -
- -
-
- -
- - - - - -
-
- -
-
@@ -21,6 +5,12 @@ {{ isRefreshing ? 'Refreshing...' : 'Refresh List (' + refreshIntervalTime + ')' }} + +
@@ -34,6 +24,25 @@
+
+
+
+ +
+
+ + + + + +
+
+
+
+
+
{{axe.IP}} + tooltipPosition="top">{{axe.currentIP || axe.IP}} {{axe.hostname}} {{axe.hashRate * 1000000000 | hashSuffix}} @@ -189,5 +198,5 @@
- + 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..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 @@ -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,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]?))|(?:(?:[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)]] }); const storedRefreshTime = this.localStorageService.getNumber(SWARM_REFRESH_TIME) ?? 30; @@ -97,59 +100,129 @@ 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; - 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(); + 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)) { + foundDevices = []; + } + + // 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)); + + 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); + 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; + } + + // 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, ...validDevices]; + this.sortSwarm(); + this.localStorageService.setObject(SWARM_DATA, this.swarm); + this.calculateTotals(); + + this.toastr.success(`Found ${validDevices.length} new device(s)`, 'Network Scan Complete'); + this.scanning = false; + }, + error: (error) => { + this.toastr.error(`Failed to fetch device information: ${error.message || 'Unknown error'}`, 'Scan Error'); + this.scanning = false; + } + }); }, - complete: () => { + error: (error) => { + this.toastr.error(`Scan failed: ${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); - }), - timeout(5000), - catchError(error => errorHandler(error, IP)) - ), + + private getHttpOptionsForDevice(device: any): { headers: any } { + 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}`; + } + + 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,25 +231,47 @@ 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)) { + 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; } + // 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; + } - this.swarm.push({ IP, ...asic, ...info }); - this.sortSwarm(); - this.localStorageService.setObject(SWARM_DATA, this.swarm); - this.calculateTotals(); + // Store the API secret with the device for future requests + const deviceData = { IP, ...asic, ...info }; + if (apiSecret && apiSecret.trim() !== '') { + 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); + 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 +281,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 +327,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(); @@ -273,14 +367,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]; @@ -354,6 +463,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..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 @@ -39,6 +52,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 +93,7 @@ export class SystemService { temptarget: 60, statsFrequency: 30, fanrpm: 0, + apiSecret: "devapisecret", boardtemp1: 30, boardtemp2: 40, @@ -86,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 @@ -109,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); @@ -152,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 @@ -177,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)); } } 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..bd9fa98dd 100644 --- a/main/http_server/http_server.c +++ b/main/http_server/http_server.c @@ -26,6 +26,8 @@ #include "lwip/netdb.h" #include "lwip/sockets.h" #include "lwip/sys.h" +#include "mdns.h" +#include #include "cJSON.h" #include "global_state.h" @@ -49,14 +51,93 @@ static const char * CORS_TAG = "CORS"; static char axeOSVersion[32]; +/* 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) { + 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; + } + + // Use mDNS to discover _bitaxe._tcp services + ESP_LOGI(TAG, "Starting mDNS query for _bitaxe._tcp services"); + + cJSON *root = cJSON_CreateArray(); + + 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; + } + + 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); + } + + // Add instance name if available + if (r->instance_name) { + cJSON_AddStringToObject(device, "instance", r->instance_name); + } + + // 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; + } + } + } + + cJSON_AddItemToArray(root, device); + } + + r = r->next; + } + mdns_query_results_free(results); + } + + 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) { 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; @@ -113,102 +194,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; + } + + // 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; } - 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); + // For cross-origin requests, require X-Requested-With header + ESP_LOGI(CORS_TAG, "Cross-origin request detected - checking requirements"); - if (getpeername(sockfd, (struct sockaddr *)&addr, &addr_size) < 0) { - ESP_LOGE(CORS_TAG, "Error getting client IP"); + 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 +405,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 +416,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 +431,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,10 +517,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_network_allowed(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); @@ -437,7 +532,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 +614,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 +661,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 +691,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 +705,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 +719,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 +758,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); @@ -656,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); @@ -692,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); @@ -707,6 +821,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 +915,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 +944,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 +973,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 +1048,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"); } @@ -944,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..."); @@ -1069,7 +1184,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"); } @@ -1122,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 = ""; @@ -1157,42 +1272,51 @@ 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)); + /* 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); @@ -1206,58 +1330,51 @@ esp_err_t start_rest_server(void * pvParameters) }; httpd_register_uri_handler(server, &wifi_scan_get_uri); - httpd_uri_t system_restart_uri = { - .uri = "/api/system/restart", .method = HTTP_POST, - .handler = POST_restart, + /* 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, &system_restart_uri); + httpd_register_uri_handler(server, &network_scan_get_uri); - httpd_uri_t system_restart_options_uri = { - .uri = "/api/system/restart", - .method = HTTP_OPTIONS, - .handler = handle_options_request, - .user_ctx = NULL + httpd_uri_t system_restart_uri = { + .uri = "/api/system/restart", .method = HTTP_POST, + .handler = POST_restart, + .user_ctx = rest_context }; - httpd_register_uri_handler(server, &system_restart_options_uri); + httpd_register_uri_handler(server, &system_restart_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); @@ -1265,8 +1382,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); @@ -1281,9 +1398,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); 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/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 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"