From e2d0e04bd555aaaadfbe42878a841e25d48bb230 Mon Sep 17 00:00:00 2001 From: "Frank A. Zdarsky" Date: Fri, 13 Mar 2026 12:33:38 +0100 Subject: [PATCH 1/3] fix(app): Fix USB device discovery Signed-off-by: Frank A. Zdarsky --- README.md | 11 +++++--- mobile/app/index.tsx | 2 +- mobile/src/hooks/useDeviceDiscovery.ts | 36 ++++++++++---------------- mobile/src/services/discovery/scan.ts | 23 +++++++++------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a9d2aa2..9357b02 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,22 @@ BoardingPass provides a minimal service that runs on the headless device. You co ```sh VERSION=$(curl -s https://api.github.com/repos/fzdarsky/boardingpass/releases/latest | jq -r .tag_name) - sudo dnf install "https://github.com/fzdarsky/boardingpass/releases/download/${VERSION}/boardingpass-${VERSION#v}-1.$(uname -m).rpm" + sudo dnf install -y "https://github.com/fzdarsky/boardingpass/releases/download/${VERSION}/boardingpass-${VERSION#v}-1.$(uname -m).rpm" ``` 2. If you want to provision through a WiFi or Bluetooth transport, you need to install additional packages: ```sh # For WiFi AP transport: - sudo dnf install hostapd dnsmasq + sudo dnf install -y hostapd dnsmasq # For Bluetooth/BLE transport: - sudo dnf bluez + sudo dnf install -y bluez + + # For USB transport: + sudo dnf install -y epel-release + sudo dnf install -y libimobiledevice + # You also need a very recent version of usbmuxd, which is not available on RHEL9 3. Start the service: diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 84a31c9..247cc66 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -192,7 +192,7 @@ export default function DeviceDiscoveryScreen() { onDevicePress={handleDevicePress} onDeleteDevice={handleDeleteDevice} onStartScan={handleStartScan} - scanDisabled={mdnsUnavailableReason !== null} + scanDisabled={isScanning} onAddDevice={handleOpenAddDialog} /> diff --git a/mobile/src/hooks/useDeviceDiscovery.ts b/mobile/src/hooks/useDeviceDiscovery.ts index 69d5dfe..1491973 100644 --- a/mobile/src/hooks/useDeviceDiscovery.ts +++ b/mobile/src/hooks/useDeviceDiscovery.ts @@ -56,6 +56,8 @@ export function useDeviceDiscovery(): UseDeviceDiscoveryResult { const cleanupFunctions = useRef<(() => void)[]>([]); const discoveryTimeout = useRef(null); const probeInterval = useRef(null); + const isScanningRef = useRef(isScanning); + isScanningRef.current = isScanning; const devicesRef = useRef(devices); devicesRef.current = devices; @@ -133,14 +135,19 @@ export function useDeviceDiscovery(): UseDeviceDiscoveryResult { const scanner = getSubnetScannerService(); const cleanup = scanner.onDeviceFound(addOrUpdateDevice); cleanupFunctions.current.push(cleanup); - scanner.start(); + scanner.start().finally(() => { + // Only clear scanning state when running as standalone fallback (no mDNS timeout active) + if (!discoveryTimeout.current) { + setIsScanning(false); + } + }); }, [addOrUpdateDevice]); /** * Start device discovery */ const startDiscovery = useCallback(() => { - if (isScanning) { + if (isScanningRef.current) { // eslint-disable-next-line no-console console.log('Discovery already in progress'); return; @@ -155,21 +162,12 @@ export function useDeviceDiscovery(): UseDeviceDiscoveryResult { const cleanupResolved = mdnsService.current.onDeviceResolved(addOrUpdateDevice); const cleanupRemoved = mdnsService.current.onDeviceRemoved(removeDevice); const cleanupError = mdnsService.current.onError(err => { - // Clear the discovery timeout since we're handling the error now - if (discoveryTimeout.current) { - clearTimeout(discoveryTimeout.current); - discoveryTimeout.current = null; - } - - // On iOS, mDNS may be unavailable on simulator or without multicast entitlement + // On iOS, mDNS may be unavailable on simulator or without multicast entitlement. + // The subnet scan is already running (started below), so just record the reason. if (Platform.OS === 'ios') { - // Determine reason: simulator vs missing entitlement (paid developer account) const reason: MDNSUnavailableReason = ExpoDevice.isDevice ? 'entitlement' : 'simulator'; setMdnsUnavailableReason(reason); setError(null); - setIsScanning(false); - // Scan subnet since mDNS failed - startSubnetScan(); return; } @@ -183,7 +181,6 @@ export function useDeviceDiscovery(): UseDeviceDiscoveryResult { setError(appError); setErrorCount(prev => prev + 1); - setIsScanning(false); }); cleanupFunctions.current.push(cleanupFound, cleanupResolved, cleanupRemoved, cleanupError); @@ -206,8 +203,7 @@ export function useDeviceDiscovery(): UseDeviceDiscoveryResult { const reason: MDNSUnavailableReason = ExpoDevice.isDevice ? 'entitlement' : 'simulator'; setMdnsUnavailableReason(reason); setError(null); - setIsScanning(false); - // Still scan subnet + // Still scan subnet (scan completion will clear isScanning) startSubnetScan(); return; } @@ -222,16 +218,12 @@ export function useDeviceDiscovery(): UseDeviceDiscoveryResult { setErrorCount(prev => prev + 1); setIsScanning(false); } - }, [isScanning, addOrUpdateDevice, removeDevice, startSubnetScan]); + }, [addOrUpdateDevice, removeDevice, startSubnetScan]); /** * Stop device discovery */ const stopDiscovery = useCallback(() => { - if (!isScanning) { - return; - } - // Stop mDNS scan mdnsService.current.stop(); @@ -251,7 +243,7 @@ export function useDeviceDiscovery(): UseDeviceDiscoveryResult { setIsScanning(false); // eslint-disable-next-line no-console console.log('Discovery stopped'); - }, [isScanning]); + }, []); /** * Refresh devices (re-scan) diff --git a/mobile/src/services/discovery/scan.ts b/mobile/src/services/discovery/scan.ts index d58e6e6..ab93418 100644 --- a/mobile/src/services/discovery/scan.ts +++ b/mobile/src/services/discovery/scan.ts @@ -110,9 +110,9 @@ export class SubnetScannerService { /** * Determine which subnets to scan based on current network state. * - * - USB tethering (non-WiFi/non-cellular): scan well-known tethering subnets - * - WiFi with routable IP: scan the /24 around the phone's IP - * - Link-local (169.254.x.x) or no IP: no scan (user must add manually) + * Always includes platform-specific tethering subnets (iOS: 172.20.10.0/28, + * just 14 hosts) since USB tethering can be active alongside WiFi but + * NetInfo only reports the primary connection. */ public async getSubnetsToScan(): Promise { let state: NetInfoState; @@ -124,21 +124,24 @@ export class SubnetScannerService { if (!state.isConnected) return []; - // Non-WiFi, non-cellular connection → likely USB tethering - if (state.type !== 'wifi' && state.type !== 'cellular') { - return this.getTetheringSubnets(); - } + // Scan tethering subnets first — they're small (iOS: 14 hosts) and + // most likely to contain a BoardingPass device on a direct connection + const subnets: SubnetRange[] = [...this.getTetheringSubnets()]; - // WiFi connected — derive /24 from phone's IP + // WiFi connected — also scan the /24 around the phone's IP if (state.type === 'wifi') { const details = state.details as { ipAddress?: string } | null; const ip = details?.ipAddress; if (ip && !ip.startsWith('169.254.')) { - return [this.subnetFromIP(ip)]; + const wifiSubnet = this.subnetFromIP(ip); + const isDuplicate = subnets.some(s => s.network === wifiSubnet.network); + if (!isDuplicate) { + subnets.push(wifiSubnet); + } } } - return []; + return subnets; } /** From 51f59ee9c6bc5619f88cdd8b2c4dd06a75b82d26 Mon Sep 17 00:00:00 2001 From: "Frank A. Zdarsky" Date: Fri, 13 Mar 2026 16:14:00 +0100 Subject: [PATCH 2/3] fix(svc): Protect provisioning route, fix activation Signed-off-by: Frank A. Zdarsky --- build/config.yaml | 8 ++--- build/scripts/connectivity-test.sh | 24 ++++++++++--- build/scripts/reload-connection.sh | 52 +++++++++++++++++++++++------ mobile/src/hooks/useConfigWizard.ts | 22 ++++++++++-- 4 files changed, 84 insertions(+), 22 deletions(-) diff --git a/build/config.yaml b/build/config.yaml index 5b83c5d..5369d2e 100644 --- a/build/config.yaml +++ b/build/config.yaml @@ -2,8 +2,8 @@ # Install to: /etc/boardingpass/config.yaml service: - inactivity_timeout: "10m" # Max idle time before service self-terminates (Go duration: "5m", "1h") - session_ttl: "30m" # Authenticated session lifetime (min: 5m) + inactivity_timeout: "60m" # Max idle time before service self-terminates (Go duration: "5m", "1h") + session_ttl: "60m" # Authenticated session lifetime (min: 5m) sentinel_file: "/etc/boardingpass/issued" # Created on provisioning completion; prevents service restart port: 9455 # HTTPS listen port (shared by all transports) tls_cert: "/var/lib/boardingpass/tls/server.crt" # Path to TLS certificate (auto-generated if missing) @@ -122,12 +122,12 @@ commands: - id: "reload-connection" path: "/usr/lib/boardingpass/scripts/reload-connection.sh" args: [] - max_params: 1 + max_params: 2 - id: "connectivity-test" path: "/usr/lib/boardingpass/scripts/connectivity-test.sh" args: [] - max_params: 2 + max_params: 3 - id: "enroll-insights" path: "/usr/lib/boardingpass/scripts/enroll-insights.sh" diff --git a/build/scripts/connectivity-test.sh b/build/scripts/connectivity-test.sh index 51f572e..ecbfd39 100755 --- a/build/scripts/connectivity-test.sh +++ b/build/scripts/connectivity-test.sh @@ -1,18 +1,20 @@ #!/bin/bash # connectivity-test.sh - Test network connectivity and output JSON results -# Usage: connectivity-test.sh -- [gateway] +# Usage: connectivity-test.sh -- [gateway] [expected-ip] # If gateway is omitted, the default gateway from the routing table is used. +# If expected-ip is provided, verifies that specific IP is assigned (not just any IP). # Called by BoardingPass via the command allow-list. set -euo pipefail [ "${1:-}" = "--" ] && shift if [ $# -lt 1 ]; then - echo "Usage: connectivity-test.sh [gateway]" >&2 + echo "Usage: connectivity-test.sh [gateway] [expected-ip]" >&2 exit 1 fi IFACE="$1" GATEWAY="${2:-}" +EXPECTED_IP="${3:-}" # Validate interface name if ! [[ "$IFACE" =~ ^[a-zA-Z0-9._-]+$ ]]; then @@ -37,16 +39,28 @@ if [ -n "$GATEWAY" ] && ! [[ "$GATEWAY" =~ ^[0-9a-fA-F.:]+$ ]]; then exit 1 fi +# Validate expected IP if provided (IPv4 or IPv6) +if [ -n "$EXPECTED_IP" ] && ! [[ "$EXPECTED_IP" =~ ^[0-9a-fA-F.:]+$ ]]; then + echo "Error: invalid expected IP address '$EXPECTED_IP'" >&2 + exit 1 +fi + # 1. Check link/carrier (is a cable plugged in?) LINK_UP=false if [ "$(cat /sys/class/net/"$IFACE"/carrier 2>/dev/null || echo 0)" = "1" ]; then LINK_UP=true fi -# 2. Check interface has an IP address +# 2. Check interface has the expected IP address (or any IP if not specified) IP_ASSIGNED=false -if ip -j addr show "$IFACE" 2>/dev/null | grep -q '"local"'; then - IP_ASSIGNED=true +if [ -n "$EXPECTED_IP" ]; then + if ip -j addr show "$IFACE" 2>/dev/null | grep -q "\"local\":\"$EXPECTED_IP\""; then + IP_ASSIGNED=true + fi +else + if ip -j addr show "$IFACE" 2>/dev/null | grep -q '"local"'; then + IP_ASSIGNED=true + fi fi # 3. Ping gateway via the provisioning interface (skip if no gateway found) diff --git a/build/scripts/reload-connection.sh b/build/scripts/reload-connection.sh index 5c8abeb..38dc6ea 100755 --- a/build/scripts/reload-connection.sh +++ b/build/scripts/reload-connection.sh @@ -1,30 +1,60 @@ #!/bin/bash -# reload-connection.sh - Reload a NetworkManager connection profile from disk -# Usage: reload-connection.sh -- +# reload-connection.sh - Reload and optionally activate a NetworkManager connection +# Usage: reload-connection.sh -- [provisioning-interface] +# +# When provisioning-interface is provided (immediate mode): +# 1. Reloads connection files from disk +# 2. Protects the provisioning interface's default route with metric 1 +# 3. Activates the connection (nmcli connection up) +# +# When only connection-name is provided (deferred mode): +# 1. Reloads connection files from disk (activation deferred to reboot) +# # Called by BoardingPass via the command allow-list. set -euo pipefail [ "${1:-}" = "--" ] && shift if [ $# -lt 1 ]; then - echo "Usage: reload-connection.sh " >&2 + echo "Usage: reload-connection.sh [provisioning-interface]" >&2 exit 1 fi CONN_NAME="$1" +PROV_IFACE="${2:-}" -# Validate connection name (alphanumeric + hyphens + underscores) +# Validate connection name (alphanumeric + hyphens + underscores + dots) if ! [[ "$CONN_NAME" =~ ^[a-zA-Z0-9._-]+$ ]]; then echo "Error: invalid connection name '$CONN_NAME'" >&2 exit 1 fi +# Validate provisioning interface name if provided +if [ -n "$PROV_IFACE" ] && ! [[ "$PROV_IFACE" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "Error: invalid interface name '$PROV_IFACE'" >&2 + exit 1 +fi + # Reload all connection files from disk so NetworkManager picks up the new profile. -# We intentionally do NOT force-activate (nmcli connection up) because during -# provisioning we may be communicating over a different interface. Force-activating -# a connection with a gateway would add a competing default route whose lower metric -# (e.g. 100 for ethernet vs 600 for WiFi) could hijack traffic away from the -# provisioning interface, breaking the session. The profile has autoconnect=true, -# so NetworkManager will activate it when the carrier is detected or on reboot. nmcli connection reload -echo "Connection '$CONN_NAME' reloaded" +if [ -n "$PROV_IFACE" ]; then + # Immediate mode: protect provisioning interface route, then activate. + # + # Activating a connection with a gateway adds a default route (e.g. metric 100 + # for ethernet). To prevent this from hijacking traffic away from the + # provisioning interface, we temporarily add a high-priority (metric 1) default + # route via the provisioning interface. This route is ephemeral and disappears + # on reboot. + PROV_GW=$(ip route show default dev "$PROV_IFACE" 2>/dev/null | awk '/default/ {print $3; exit}' || true) + if [ -n "$PROV_GW" ]; then + ip route replace default via "$PROV_GW" dev "$PROV_IFACE" metric 1 2>/dev/null || true + echo "Protected provisioning route via $PROV_IFACE (metric 1)" + fi + + nmcli connection up "$CONN_NAME" + echo "Connection '$CONN_NAME' reloaded and activated" +else + # Deferred mode: reload only. The profile has autoconnect=true, so + # NetworkManager will activate it when the carrier is detected or on reboot. + echo "Connection '$CONN_NAME' reloaded" +fi diff --git a/mobile/src/hooks/useConfigWizard.ts b/mobile/src/hooks/useConfigWizard.ts index 512bd00..ca1315a 100644 --- a/mobile/src/hooks/useConfigWizard.ts +++ b/mobile/src/hooks/useConfigWizard.ts @@ -188,9 +188,14 @@ export function buildStepCommands(step: number, state: WizardState): StepCommand // No commands for interface selection break; - case WIZARD_STEPS.ADDRESSING: - commands.push({ id: 'reload-connection', params: [CONNECTION_NAME] }); + case WIZARD_STEPS.ADDRESSING: { + const reloadParams = [CONNECTION_NAME]; + if (state.applyMode === 'immediate' && state.serviceInterfaceName) { + reloadParams.push(state.serviceInterfaceName); + } + commands.push({ id: 'reload-connection', params: reloadParams }); break; + } case WIZARD_STEPS.SERVICES: if (state.services.ntp.mode === 'manual' || state.services.proxy) { @@ -586,6 +591,12 @@ export function useConfigWizard() { if (state.addressing.ipv4.gateway) { params.push(state.addressing.ipv4.gateway); } + if (state.addressing.ipv4.method === 'static' && state.addressing.ipv4.address) { + if (!state.addressing.ipv4.gateway) { + params.push(''); // placeholder for gateway (auto-detect) + } + params.push(state.addressing.ipv4.address); + } const testResult = await executeCommand(client, 'connectivity-test', params); if (testResult.stdout) { connectivityResult = parseConnectivityResult(testResult.stdout); @@ -737,6 +748,13 @@ export function useConfigWizard() { if (state.addressing.ipv4.gateway) { params.push(state.addressing.ipv4.gateway); } + if (state.addressing.ipv4.method === 'static' && state.addressing.ipv4.address) { + // Pass expected IP so the test verifies the configured address is assigned + if (!state.addressing.ipv4.gateway) { + params.push(''); // placeholder for gateway (auto-detect) + } + params.push(state.addressing.ipv4.address); + } const testResult = await executeCommand(client, 'connectivity-test', params); if (testResult.stdout) { const parsed = parseConnectivityResult(testResult.stdout); From 9cb99eaeeadaf77b0843d966e2193d30eadc0683 Mon Sep 17 00:00:00 2001 From: "Frank A. Zdarsky" Date: Fri, 13 Mar 2026 19:30:22 +0100 Subject: [PATCH 3/3] fix(svc): Fix missing SANs using USB Signed-off-by: Frank A. Zdarsky --- internal/api/server.go | 15 +- internal/tls/certgen.go | 65 +++++++-- internal/tls/certgen_test.go | 78 +++++++++++ internal/tls/config.go | 194 +++++++++++++++++++++++--- internal/tls/config_test.go | 161 +++++++++++++++++++++ mobile/src/services/discovery/scan.ts | 48 +++++-- 6 files changed, 515 insertions(+), 46 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 04e7a7d..c4a2815 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -27,6 +27,9 @@ type Server struct { mu sync.Mutex } +// DefaultCertValidDays is the certificate validity period used for regeneration. +const DefaultCertValidDays = 365 + // New creates a new API server instance. func New(cfg *config.Config, logger *logging.Logger) (*Server, error) { mux := http.NewServeMux() @@ -43,14 +46,20 @@ func New(cfg *config.Config, logger *logging.Logger) (*Server, error) { config: cfg, } - // Configure TLS - tlsCfg, err := tlspkg.NewServerConfig( + // Configure TLS with CertManager for dynamic SAN support. + // When a TLS handshake arrives on an IP not in the cert's SANs, + // the cert is regenerated automatically. + certMgr, err := tlspkg.NewCertManager( cfg.Service.TLSCert, cfg.Service.TLSKey, + DefaultCertValidDays, + logger, ) if err != nil { - return nil, fmt.Errorf("failed to create TLS config: %w", err) + return nil, fmt.Errorf("failed to create cert manager: %w", err) } + + tlsCfg := certMgr.ServerTLSConfig() server.httpServer.TLSConfig = tlsCfg server.tlsConfig = tlsCfg diff --git a/internal/tls/certgen.go b/internal/tls/certgen.go index f349b14..2a97130 100644 --- a/internal/tls/certgen.go +++ b/internal/tls/certgen.go @@ -99,7 +99,6 @@ func buildSANs() (dnsNames []string, ipAddresses []net.IP) { } // GenerateSelfSignedCert generates a self-signed TLS certificate and key. -// Returns the paths to the generated cert and key files. // //nolint:gosec // G304: File paths are from config, G302: 0644 is appropriate for certs func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error { @@ -109,17 +108,40 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error { return fmt.Errorf("failed to generate private key: %w", err) } - // Generate a random serial number + if err := writeKeyFile(keyPath, privateKey); err != nil { + return err + } + + return writeCertFile(certPath, privateKey, validDays) +} + +// RegenerateCert creates a new certificate using the existing private key. +// The new cert captures current network interfaces in its SANs while +// preserving the same public key (SPKI), minimizing TOFU disruption. +// +//nolint:gosec // G304: File paths are from config, G302: 0644 is appropriate for certs +func RegenerateCert(certPath, keyPath string, validDays int) error { + privateKey, err := loadPrivateKey(keyPath) + if err != nil { + return fmt.Errorf("failed to load existing private key: %w", err) + } + + return writeCertFile(certPath, privateKey, validDays) +} + +// writeCertFile creates a self-signed certificate using the given key and +// writes it to certPath. SANs are populated from current network state. +// +//nolint:gosec // G304: File paths are from config, G302: 0644 is appropriate for certs +func writeCertFile(certPath string, privateKey *ecdsa.PrivateKey, validDays int) error { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { return fmt.Errorf("failed to generate serial number: %w", err) } - // Build SANs (includes static + dynamic hostname/IPs) dnsNames, ipAddresses := buildSANs() - // Create certificate template template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ @@ -133,18 +155,15 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error { ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, - // Add SANs for maximum compatibility (static + dynamic) DNSNames: dnsNames, IPAddresses: ipAddresses, } - // Create self-signed certificate derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) if err != nil { return fmt.Errorf("failed to create certificate: %w", err) } - // Write certificate to file certFile, err := os.Create(certPath) if err != nil { return fmt.Errorf("failed to create cert file: %w", err) @@ -159,13 +178,18 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error { return fmt.Errorf("failed to write cert: %w", err) } - // Set certificate file permissions (read-only for all) //nolint:gofumpt // formatting is acceptable if err := os.Chmod(certPath, 0644); err != nil { return fmt.Errorf("failed to set cert permissions: %w", err) } - // Write private key to file + return nil +} + +// writeKeyFile marshals an ECDSA private key and writes it to keyPath. +// +//nolint:gosec // G304: File paths are from config +func writeKeyFile(keyPath string, privateKey *ecdsa.PrivateKey) (err error) { keyFile, err := os.Create(keyPath) if err != nil { return fmt.Errorf("failed to create key file: %w", err) @@ -185,7 +209,6 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error { return fmt.Errorf("failed to write key: %w", err) } - // Set key file permissions (read-only for owner) //nolint:gofumpt // formatting is acceptable if err := os.Chmod(keyPath, 0600); err != nil { return fmt.Errorf("failed to set key permissions: %w", err) @@ -194,6 +217,28 @@ func GenerateSelfSignedCert(certPath, keyPath string, validDays int) error { return nil } +// loadPrivateKey reads an ECDSA private key from a PEM file. +// +//nolint:gosec // G304: Key path is from config +func loadPrivateKey(keyPath string) (*ecdsa.PrivateKey, error) { + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("failed to read key file: %w", err) + } + + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block from key file") + } + + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse EC private key: %w", err) + } + + return key, nil +} + // CertificateExists checks if both certificate and key files exist. func CertificateExists(certPath, keyPath string) bool { if _, err := os.Stat(certPath); os.IsNotExist(err) { diff --git a/internal/tls/certgen_test.go b/internal/tls/certgen_test.go index 8c68eba..ca30ad4 100644 --- a/internal/tls/certgen_test.go +++ b/internal/tls/certgen_test.go @@ -170,6 +170,84 @@ func TestValidateCertificate_InvalidPEM(t *testing.T) { assert.Contains(t, err.Error(), "failed to decode PEM block") } +func TestRegenerateCert(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "server.crt") + keyPath := filepath.Join(tmpDir, "server.key") + + // Generate initial cert+key + err := tlspkg.GenerateSelfSignedCert(certPath, keyPath, 365) + require.NoError(t, err) + + // Read original key + origKey, err := os.ReadFile(keyPath) + require.NoError(t, err) + + // Read original cert + origCert, err := os.ReadFile(certPath) + require.NoError(t, err) + + // Regenerate cert (reuses existing key) + err = tlspkg.RegenerateCert(certPath, keyPath, 365) + require.NoError(t, err) + + // Key file should be unchanged + newKey, err := os.ReadFile(keyPath) + require.NoError(t, err) + assert.Equal(t, origKey, newKey, "private key should not change during regeneration") + + // Cert file should be different (new serial number at minimum) + newCert, err := os.ReadFile(certPath) + require.NoError(t, err) + assert.NotEqual(t, origCert, newCert, "certificate should change during regeneration") + + // New cert should be valid and parseable + block, _ := pem.Decode(newCert) + require.NotNil(t, block) + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.Contains(t, cert.DNSNames, "localhost") + assert.Contains(t, cert.Subject.Organization, "BoardingPass") +} + +func TestRegenerateCert_PreservesPublicKey(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "server.crt") + keyPath := filepath.Join(tmpDir, "server.key") + + err := tlspkg.GenerateSelfSignedCert(certPath, keyPath, 365) + require.NoError(t, err) + + // Parse original cert's public key + origPEM, _ := os.ReadFile(certPath) + origBlock, _ := pem.Decode(origPEM) + origCert, _ := x509.ParseCertificate(origBlock.Bytes) + origPubKey := origCert.PublicKey + + // Regenerate + err = tlspkg.RegenerateCert(certPath, keyPath, 365) + require.NoError(t, err) + + // Parse regenerated cert's public key + newPEM, _ := os.ReadFile(certPath) + newBlock, _ := pem.Decode(newPEM) + newCert, _ := x509.ParseCertificate(newBlock.Bytes) + newPubKey := newCert.PublicKey + + assert.Equal(t, origPubKey, newPubKey, "public key (SPKI) should be preserved") +} + +func TestRegenerateCert_MissingKeyFile(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "server.crt") + keyPath := filepath.Join(tmpDir, "server.key") + + err := tlspkg.RegenerateCert(certPath, keyPath, 365) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to load existing private key") +} + func TestGenerateSelfSignedCert_CustomValidDays(t *testing.T) { tmpDir := t.TempDir() certPath := filepath.Join(tmpDir, "server.crt") diff --git a/internal/tls/config.go b/internal/tls/config.go index 79d3d54..bc72567 100644 --- a/internal/tls/config.go +++ b/internal/tls/config.go @@ -2,11 +2,179 @@ package tls import ( "crypto/tls" + "crypto/x509" + "encoding/pem" "fmt" + "net" + "os" + "sync" + + "github.com/fzdarsky/boardingpass/internal/logging" ) +// CertManager manages the TLS certificate with on-demand regeneration. +// When a TLS handshake arrives on an IP not in the certificate's SANs, +// the cert is regenerated (reusing the existing private key) to include +// all currently active network interfaces. +type CertManager struct { + certPath string + keyPath string + validDays int + logger *logging.Logger + + mu sync.RWMutex + current *tls.Certificate + sanIPs map[string]bool +} + +// NewCertManager creates a CertManager that loads the initial certificate +// from disk and serves it via GetCertificate, regenerating when needed. +func NewCertManager(certPath, keyPath string, validDays int, logger *logging.Logger) (*CertManager, error) { + cm := &CertManager{ + certPath: certPath, + keyPath: keyPath, + validDays: validDays, + logger: logger, + } + + if err := cm.loadCert(); err != nil { + return nil, fmt.Errorf("failed to load initial certificate: %w", err) + } + + return cm, nil +} + +// GetCertificate is called by the TLS stack on each handshake. It checks +// whether the listener's local IP is covered by the current cert's SANs. +// If not, the cert is regenerated to include all current network interfaces. +func (cm *CertManager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + localIP := localAddrIP(hello.Conn) + if localIP == "" { + cm.mu.RLock() + defer cm.mu.RUnlock() + return cm.current, nil + } + + cm.mu.RLock() + if cm.sanIPs[localIP] { + defer cm.mu.RUnlock() + return cm.current, nil + } + cm.mu.RUnlock() + + // IP not in SANs — regenerate cert under write lock + cm.mu.Lock() + defer cm.mu.Unlock() + + // Double-check after acquiring write lock + if cm.sanIPs[localIP] { + return cm.current, nil + } + + cm.logger.Info("regenerating TLS certificate for new interface IP", map[string]any{ + "ip": localIP, + }) + + if err := RegenerateCert(cm.certPath, cm.keyPath, cm.validDays); err != nil { + cm.logger.Warn("failed to regenerate certificate, serving existing cert", map[string]any{ + "error": err.Error(), + "ip": localIP, + }) + return cm.current, nil + } + + if err := cm.loadCertLocked(); err != nil { + cm.logger.Warn("failed to reload regenerated certificate", map[string]any{ + "error": err.Error(), + }) + } + + return cm.current, nil +} + +// ServerTLSConfig returns a tls.Config using this CertManager's GetCertificate callback. +func (cm *CertManager) ServerTLSConfig() *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + CipherSuites: nil, // Use defaults for TLS 1.3 + + GetCertificate: cm.GetCertificate, + + NextProtos: []string{"http/1.1"}, + PreferServerCipherSuites: true, + SessionTicketsDisabled: true, + ClientAuth: tls.NoClientCert, + } +} + +// loadCert loads the certificate from disk and updates the cached state. +func (cm *CertManager) loadCert() error { + cm.mu.Lock() + defer cm.mu.Unlock() + return cm.loadCertLocked() +} + +// loadCertLocked loads the certificate from disk. Caller must hold cm.mu write lock. +// +//nolint:gosec // G304: File paths are from config +func (cm *CertManager) loadCertLocked() error { + cert, err := tls.LoadX509KeyPair(cm.certPath, cm.keyPath) + if err != nil { + return fmt.Errorf("failed to load TLS key pair: %w", err) + } + + // Parse the leaf certificate to extract SANs + certPEM, err := os.ReadFile(cm.certPath) + if err != nil { + return fmt.Errorf("failed to read cert file: %w", err) + } + + block, _ := pem.Decode(certPEM) + if block == nil { + return fmt.Errorf("failed to decode certificate PEM") + } + + x509Cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + sanIPs := make(map[string]bool, len(x509Cert.IPAddresses)) + for _, ip := range x509Cert.IPAddresses { + sanIPs[ip.String()] = true + } + + cm.current = &cert + cm.sanIPs = sanIPs + + return nil +} + +// localAddrIP extracts the IP string from a net.Conn's local address. +// Returns empty string if the address cannot be parsed. +func localAddrIP(conn net.Conn) string { + if conn == nil { + return "" + } + addr := conn.LocalAddr() + if addr == nil { + return "" + } + host, _, err := net.SplitHostPort(addr.String()) + if err != nil { + return "" + } + ip := net.ParseIP(host) + if ip == nil { + return "" + } + return ip.String() +} + // NewServerConfig creates a TLS configuration for the HTTPS server. -// Enforces TLS 1.3 minimum and uses FIPS 140-3 compliant cipher suites. +// +// Deprecated: Use NewCertManager + ServerTLSConfig for dynamic SAN support. func NewServerConfig(certPath, keyPath string) (*tls.Config, error) { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { @@ -17,34 +185,18 @@ func NewServerConfig(certPath, keyPath string) (*tls.Config, error) { MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}, + CipherSuites: nil, + NextProtos: []string{"http/1.1"}, - // TLS 1.3 cipher suites (FIPS 140-3 compliant) - // Note: In TLS 1.3, cipher suites are automatically negotiated - // and don't need explicit configuration. Go's crypto/tls uses - // FIPS-compliant ciphers by default when built with FIPS mode. - CipherSuites: nil, // Use defaults for TLS 1.3 - - // Disable HTTP/2: BoardingPass is a simple bootstrap service where - // sequential API calls don't benefit from H2 multiplexing. iOS - // clients experience H2 RST_STREAM errors when the Go HTTP/2 stack - // encounters incomplete body reads from json.Decoder. - NextProtos: []string{"http/1.1"}, - - // Prefer server cipher suites PreferServerCipherSuites: true, - - // Disable session tickets for ephemeral operation - SessionTicketsDisabled: true, - - // Client authentication not required for BoardingPass - ClientAuth: tls.NoClientCert, + SessionTicketsDisabled: true, + ClientAuth: tls.NoClientCert, }, nil } // GetFIPSCipherSuites returns the list of FIPS 140-3 compliant cipher suites. // For TLS 1.3, these are the default suites provided by Go's crypto/tls. func GetFIPSCipherSuites() []uint16 { - // TLS 1.3 cipher suites (all FIPS-compliant when using stdlib crypto) return []uint16{ tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384, diff --git a/internal/tls/config_test.go b/internal/tls/config_test.go index dade39e..8e0465a 100644 --- a/internal/tls/config_test.go +++ b/internal/tls/config_test.go @@ -2,9 +2,15 @@ package tls_test import ( "crypto/tls" + "crypto/x509" + "encoding/pem" + "net" + "os" "path/filepath" "testing" + "time" + "github.com/fzdarsky/boardingpass/internal/logging" tlspkg "github.com/fzdarsky/boardingpass/internal/tls" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -101,3 +107,158 @@ func TestNewServerConfig_TLS13Only(t *testing.T) { // Ensure TLS 1.2 is not acceptable assert.Greater(t, cfg.MinVersion, uint16(tls.VersionTLS12)) } + +func testLogger() *logging.Logger { + return logging.New(logging.LevelDebug, logging.FormatHuman) +} + +func TestNewCertManager(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "server.crt") + keyPath := filepath.Join(tmpDir, "server.key") + + err := tlspkg.GenerateSelfSignedCert(certPath, keyPath, 365) + require.NoError(t, err) + + cm, err := tlspkg.NewCertManager(certPath, keyPath, 365, testLogger()) + require.NoError(t, err) + require.NotNil(t, cm) +} + +func TestNewCertManager_InvalidPath(t *testing.T) { + cm, err := tlspkg.NewCertManager("/nonexistent/cert.pem", "/nonexistent/key.pem", 365, testLogger()) + assert.Error(t, err) + assert.Nil(t, cm) +} + +func TestCertManager_ServerTLSConfig(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "server.crt") + keyPath := filepath.Join(tmpDir, "server.key") + + err := tlspkg.GenerateSelfSignedCert(certPath, keyPath, 365) + require.NoError(t, err) + + cm, err := tlspkg.NewCertManager(certPath, keyPath, 365, testLogger()) + require.NoError(t, err) + + cfg := cm.ServerTLSConfig() + require.NotNil(t, cfg) + + assert.Equal(t, uint16(tls.VersionTLS13), cfg.MinVersion) + assert.Equal(t, uint16(tls.VersionTLS13), cfg.MaxVersion) + assert.True(t, cfg.SessionTicketsDisabled) + assert.Equal(t, tls.NoClientCert, cfg.ClientAuth) + assert.NotNil(t, cfg.GetCertificate, "GetCertificate callback must be set") + assert.Empty(t, cfg.Certificates, "Certificates should be empty when using GetCertificate") +} + +func TestCertManager_GetCertificate_NilConn(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "server.crt") + keyPath := filepath.Join(tmpDir, "server.key") + + err := tlspkg.GenerateSelfSignedCert(certPath, keyPath, 365) + require.NoError(t, err) + + cm, err := tlspkg.NewCertManager(certPath, keyPath, 365, testLogger()) + require.NoError(t, err) + + // GetCertificate with nil Conn should return current cert without error + cert, err := cm.GetCertificate(&tls.ClientHelloInfo{}) + require.NoError(t, err) + require.NotNil(t, cert) +} + +func TestCertManager_GetCertificate_RegeneratesForNewIP(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "server.crt") + keyPath := filepath.Join(tmpDir, "server.key") + + err := tlspkg.GenerateSelfSignedCert(certPath, keyPath, 365) + require.NoError(t, err) + + cm, err := tlspkg.NewCertManager(certPath, keyPath, 365, testLogger()) + require.NoError(t, err) + + // Read the original cert fingerprint + origPEM, err := os.ReadFile(certPath) + require.NoError(t, err) + origBlock, _ := pem.Decode(origPEM) + origCert, err := x509.ParseCertificate(origBlock.Bytes) + require.NoError(t, err) + + // 127.0.0.1 is always in SANs — GetCertificate should NOT regenerate + cert, err := cm.GetCertificate(&tls.ClientHelloInfo{ + Conn: &fakeConn{localAddr: "127.0.0.1:9455"}, + }) + require.NoError(t, err) + require.NotNil(t, cert) + + // Cert on disk should be unchanged (no regeneration) + afterPEM, err := os.ReadFile(certPath) + require.NoError(t, err) + assert.Equal(t, origPEM, afterPEM, "cert should not regenerate for known SAN IP") + + // Now simulate a connection from an IP NOT in SANs. + // This should trigger regeneration. The new cert will include + // whatever IPs are currently on the system (we can't control that + // in a unit test), but we can verify the cert file changed. + unknownIP := findIPNotInSANs(origCert) + if unknownIP == "" { + t.Skip("all test IPs are in the cert SANs, cannot test regeneration") + } + + cert, err = cm.GetCertificate(&tls.ClientHelloInfo{ + Conn: &fakeConn{localAddr: unknownIP + ":9455"}, + }) + require.NoError(t, err) + require.NotNil(t, cert) + + // Cert on disk should have changed (regenerated) + regenPEM, err := os.ReadFile(certPath) + require.NoError(t, err) + assert.NotEqual(t, origPEM, regenPEM, "cert should regenerate for unknown IP") + + // Key should be unchanged (same private key reused) + origKey, _ := os.ReadFile(keyPath) + // Key was written by GenerateSelfSignedCert and not touched by RegenerateCert + afterKey, _ := os.ReadFile(keyPath) + assert.Equal(t, origKey, afterKey) +} + +// findIPNotInSANs returns an IP address that is not in the certificate's SANs. +func findIPNotInSANs(cert *x509.Certificate) string { + sanSet := make(map[string]bool) + for _, ip := range cert.IPAddresses { + sanSet[ip.String()] = true + } + + // Try some IPs that are unlikely to be on any real interface + candidates := []string{"198.51.100.1", "203.0.113.1", "192.0.2.1"} + for _, ip := range candidates { + if !sanSet[ip] { + return ip + } + } + return "" +} + +// fakeConn implements net.Conn minimally for testing GetCertificate. +type fakeConn struct { + localAddr string +} + +func (f *fakeConn) LocalAddr() net.Addr { return fakeAddr(f.localAddr) } +func (f *fakeConn) RemoteAddr() net.Addr { return fakeAddr("0.0.0.0:0") } +func (f *fakeConn) Read([]byte) (int, error) { return 0, nil } +func (f *fakeConn) Write([]byte) (int, error) { return 0, nil } +func (f *fakeConn) Close() error { return nil } +func (f *fakeConn) SetDeadline(time.Time) error { return nil } +func (f *fakeConn) SetReadDeadline(time.Time) error { return nil } +func (f *fakeConn) SetWriteDeadline(time.Time) error { return nil } + +type fakeAddr string + +func (a fakeAddr) Network() string { return "tcp" } +func (a fakeAddr) String() string { return string(a) } diff --git a/mobile/src/services/discovery/scan.ts b/mobile/src/services/discovery/scan.ts index ab93418..0f8df2b 100644 --- a/mobile/src/services/discovery/scan.ts +++ b/mobile/src/services/discovery/scan.ts @@ -25,6 +25,9 @@ const BATCH_SIZE = 20; /** Delay between batches (ms) to avoid overwhelming the network */ const BATCH_DELAY = 100; +/** Delay before auto-retry when no devices found (ms) */ +const RETRY_DELAY = 3000; + export type ScanDeviceCallback = (device: Device) => void; export type ScanProgressCallback = (scanned: number, total: number) => void; @@ -40,6 +43,7 @@ export class SubnetScannerService { private progressCallbacks: ScanProgressCallback[] = []; private running = false; private abortController: AbortController | null = null; + private foundCount = 0; /** * Start a subnet scan. Determines subnets from current network state. @@ -50,18 +54,18 @@ export class SubnetScannerService { this.abortController = new AbortController(); try { - const subnets = await this.getSubnetsToScan(); - - if (subnets.length === 0) { - // eslint-disable-next-line no-console - console.log('[Scanner] No scannable subnets found'); - return; - } - - for (const subnet of subnets) { - if (this.abortController.signal.aborted) break; - const hosts = this.generateHosts(subnet); - await this.scanHosts(hosts, DEFAULT_BOARDINGPASS_PORT); + this.foundCount = 0; + await this.scanAllSubnets(); + + // If nothing found, wait and retry once — covers the race where a + // USB tethering listener isn't ready yet on the service side. + if (this.foundCount === 0 && !this.abortController.signal.aborted) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); + if (!this.abortController.signal.aborted) { + // eslint-disable-next-line no-console + console.log('[Scanner] No devices found, retrying...'); + await this.scanAllSubnets(); + } } } catch (error) { if (!this.abortController?.signal.aborted) { @@ -107,6 +111,25 @@ export class SubnetScannerService { return this.running; } + /** + * Run a single pass over all scannable subnets. + */ + private async scanAllSubnets(): Promise { + const subnets = await this.getSubnetsToScan(); + + if (subnets.length === 0) { + // eslint-disable-next-line no-console + console.log('[Scanner] No scannable subnets found'); + return; + } + + for (const subnet of subnets) { + if (this.abortController?.signal.aborted) break; + const hosts = this.generateHosts(subnet); + await this.scanHosts(hosts, DEFAULT_BOARDINGPASS_PORT); + } + } + /** * Determine which subnets to scan based on current network state. * @@ -212,6 +235,7 @@ export class SubnetScannerService { lastSeen: new Date(), }; + this.foundCount++; for (const cb of this.deviceCallbacks) { cb(device); }