[Python] Saving image from captive portal page closes portal pop-up and WLAN connection...

Discussão em 'Python' iniciado por Stack, Agosto 18, 2025.

  1. Stack

    Stack Membro Participativo

    Background


    I'm developing a physical python-based application on a Raspberry Pi 4 with a basic UI, at the end of which I present the user with a photo to download. To do that, a QR code is presented that gets the user onto a WLAN that the Pi hosts. A captive portal page shows the image (when the captive portal actually pops up) which can then be saved with a long-press. I do it this way instead of just serving the file directly as a download because I want the photo to end up in the user's photo roll and — at least on iOS — when you serve an image as a download, it saves to Files instead.

    I've been able to set up the WLAN, redirect traffic, etc., and host the basic page using Flask.

    Problem


    On an iOS device (haven't tested on Android yet), when the captive portal window is displayed, and the user long-presses and selects the option to save the image:

    1. The captive portal window disappears
    2. The phone immediately disconnects from the WLAN
    3. The image was never saved

    If the user selects the "Cancel" button at the top right of the captive portal UI, opts to continue using the network "without internet," then loads the page intentionally in a browser, the desired functionality fully works.

    I know on iOS that when a captive portal does actually connect you to the web, the "Cancel" button at the top right of the UI changes to something like "OK" or "Done." But I also know that captive portal pages support sending and receiving HTTP requests/responses and directing to new pages, etc., before the device reaches the actual internet, so I'm not really sure why this is an issue. That said, I have no idea what I'm doing. I'm hoping someone here does and can advise me on either how to accomplish my goal, or why it's not possible.

    Code


    Acknowledgement: I've been heavily using AI to try to troubleshoot this and I know some of the code is slop. I'll get to fixing it.

    Here's the script I use to host the WLAN and direct traffic:

    #!/bin/bash

    # Stop existing services
    sudo systemctl stop hostapd.service
    sudo systemctl stop dnsmasq.service

    # Clear existing interface configuration
    sudo ip addr flush dev wlan0
    sudo ip link set wlan0 down

    # Configure the interface
    sudo ip addr add 192.168.4.1/24 dev wlan0
    sudo ip link set wlan0 up

    # Update dnsmasq configuration for captive portal
    sudo tee /etc/dnsmasq.conf > /dev/null << 'EOF'
    # Basic DHCP configuration
    interface=wlan0
    dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h

    # Captive portal specific redirects - only redirect these, not everything
    address=/captive.apple.com/192.168.4.1
    address=/connectivitycheck.gstatic.com/192.168.4.1
    address=/www.google.com/192.168.4.1
    address=/clients3.google.com/192.168.4.1
    address=/connectivitycheck.android.com/192.168.4.1

    # Don't redirect everything - let other DNS queries fail naturally
    # This prevents iOS from thinking we're intercepting all traffic

    # Never forward plain names (without a dot or domain part)
    domain-needed
    # Never forward addresses in the non-routed address spaces
    bogus-priv

    # Use interface wlan0
    bind-interfaces

    # Don't read /etc/resolv.conf or any other file
    no-resolv

    # Log all DNS queries for debugging
    log-queries
    log-facility=/var/log/dnsmasq.log
    EOF

    # Start services
    sudo systemctl start dnsmasq.service
    sudo systemctl start hostapd.service

    # Clear any existing iptables rules for our ports
    sudo iptables -t nat -D PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 5000 2>/dev/null || true
    sudo iptables -t nat -D PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000 2>/dev/null || true

    # Add new redirect rules - redirect both HTTP and HTTPS to our server
    sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 5000
    sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 5000

    # Enable IP forwarding (though we're not actually forwarding to internet)
    echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward > /dev/null

    echo "WiFi hotspot configured with captive portal DNS interception"
    echo "All DNS queries will be redirected to 192.168.4.1"
    echo "HTTP and HTTPS traffic will be redirected to port 5000"


    Here is how I'm defining my url rules (I have a class I defined to actually do this so I can run it as a secondary thread. Hence all the selfs):

    # Main content endpoints
    self._app.add_url_rule('/', endpoint='index', view_func=self._image_delivery)
    self._app.add_url_rule(
    f'/{ENDPOINT_FINAL_PRODUCT_UPDATE}', methods=['POST'], view_func=self._update_deliverable
    )

    # Captive portal detection endpoints - ALL redirect to main page to show content
    self._app.add_url_rule('/hotspot-detect.html', endpoint='hotspot_detect', view_func=self._redirect_to_main)
    self._app.add_url_rule('/library/test/success.html', endpoint='library_test_success',
    view_func=self._redirect_to_main)
    self._app.add_url_rule('/canonical.html', endpoint='canonical', view_func=self._redirect_to_main)
    self._app.add_url_rule('/kindle-wifi/wifistub.html', endpoint='kindle', view_func=self._redirect_to_main)

    # Keep these as status responses since they're not typically used for initial captive portal detection
    self._app.add_url_rule('/generate_204', endpoint='generate_204', view_func=self._generate_204)
    self._app.add_url_rule('/gen_204', endpoint='gen_204', view_func=self._generate_204)
    self._app.add_url_rule('/ncsi.txt', endpoint='ncsi', view_func=self._ncsi_response)
    self._app.add_url_rule('/connecttest.txt', endpoint='connecttest', view_func=self._connecttest_response)
    self._app.add_url_rule('/success.txt', endpoint='success_txt', view_func=self._success_txt)

    # Catch-all route for any unhandled requests - redirect to main page
    self._app.add_url_rule('/<path:path>', endpoint='catch_all', view_func=self._catch_all_redirect)


    And the relevant view functions:

    def _redirect_to_main(self):
    """Redirects captive portal detection requests to main page so users see content"""
    self._logger.debug("Captive portal detection endpoint called - redirecting to main page")
    # Return a redirect response to the main page
    from flask import redirect, url_for
    return redirect(url_for('index'))

    def _image_delivery(self):
    """Serves the page where the final product can be downloaded"""
    self._logger.debug(f"'image_delivery' endpoint called. deliverable_filename: {self.deliverable_filename}")
    return render_template('index.html', filename=self.deliverable_filename)

    def _catch_all_redirect(self, path):
    """Redirects any unhandled requests to the main captive portal page"""
    self._logger.debug(f"Catch-all redirect called for path: {path}")
    # For any unrecognized path, serve the main page
    return self._image_delivery()

    Continue reading...

Compartilhe esta Página