File: README.md
VPN Hotspot
Connecting things to your VPN made simple. Share your VPN connection over hotspot or repeater. (root required)
This app is useful for:
- Connecting things that don't support VPN like Chromecasts behind corporate firewalls;
- Setting up gapps behind corporate firewalls;
- Connecting to your mobile hotspot but you're not bothered to set up VPN on your device;
- Identifying, monitoring and blocking (unwanted) clients;
- Bypassing tethering limits by:
- (recommended) Use this app with a real VPN/socksifier;
- Use this app with some adblock/DNS apps that uses system VPN service APIs, i.e. fake VPNs; (troubleshooting/a list of apps that work)
- Try your luck and simply use this app.
P.S. You can also do the similar on Windows,
Mac,
and iOS.
Features That Requires System App Installation
The following features in the app requires it to be installed under /system/priv-app since some restricted permissions are required.
One way to do this is to use App systemizer for Magisk.
- (Android 8-10, since app v2.4.0)
android.permission.OVERRIDE_WIFI_CONFIG: Read/write system Wi-Fi hotspot configuration. (#117)
Installing as system app also has the side benefit of launching root daemon less frequently due to having privileged permissions listed below.
android.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKSandroid.permission.LOCAL_MAC_ADDRESSandroid.permission.MANAGE_USBandroid.permission.OVERRIDE_WIFI_CONFIGandroid.permission.READ_WIFI_CREDENTIALandroid.permission.TETHER_PRIVILEGEDandroid.permission.WRITE_SECURE_SETTINGS- Other system-app exclusive features that are not gated by permissions.
Whenever you install an app update, if there was a new protected permission addition (last updated in v2.17.1), you should update the app installed in system as well to make the system grant the privileged permission.
Q & A
Search the issue tracker for more.
What persistent changes can this app do to my system?
This app only indirectly write persistent changes through system APIs thus it should be generally pretty safe.
- In Supplicant mode, the repeater asks
wpa_supplicantto create a persistent group on startup, which Android persists in/data/vendor/wifi/wpa/p2p_supplicant.confor/data/misc/wifi/p2p_supplicant.conf. - If you edited the system Wi-Fi hotspot configuration through this app, those changes will persist.
- If you toggle tethering hardware offload through this app, the Android global
tether_offload_disabledsetting will persist.
Toggle it back in this app or the matching Developer options setting to revert it.
Routing, firewall, addresses, and daemon/service state managed by this app are cleaned up when stopped, by Clean, or upon reboot.
[No root?](https://github.com/Mygod/VPNHotspot/issues/62)
Failed to create group due to internal error/repeater shuts down after a while?
This could caused by the Wi-Fi channel you selected is no longer available, due to:
- Your device doesn't support operating on this channel, or
- There is some nearby Wi-Fi direct device that broadcasted that it can't operate on the channel you picked.
For maximum stability, you need to set channel = 0 so that your device will pick a channel automatically.
You can also use WPS to connect your 2.4GHz-only device to force the repeater to switch from 5GHz to 2.4GHz for this time.
Private APIs used / Assumptions for Android customizations
a.k.a. things that can go wrong if this app doesn't work.
This is a list of stuff that might impact this app's functionality if unavailable.
This is only meant to be an index.
You can read more in the source code.
API restrictions are updated up to SHA-256 checksum 9102af02fe6ab68b92464bdff5e5b09f3bd62c65d1130aaf85d3296f17d38074.
Greylisted/blacklisted APIs or internal constants: (some constants are hardcoded or implicitly used)
- (prior to API 30)
Landroid/net/ConnectivityManager;->getLastTetherError(Ljava/lang/String;)I,max-target-r - (prior to API 30)
Landroid/net/ConnectivityManager;->EXTRA_ACTIVE_LOCAL_ONLY:Ljava/lang/String;,lo-prio,max-target-o - (prior to API 30)
Landroid/net/ConnectivityManager;->EXTRA_ACTIVE_TETHER:Ljava/lang/String;,max-target-r - (prior to API 30)
Landroid/net/ConnectivityManager;->EXTRA_AVAILABLE_TETHER:Ljava/lang/String;,max-target-r - (prior to API 30)
Landroid/net/ConnectivityManager;->ACTION_TETHER_STATE_CHANGED:Ljava/lang/String;,max-target-r - (prior to API 30)
Landroid/net/ConnectivityManager;->EXTRA_ERRORED_TETHER:Ljava/lang/String;,max-target-r - (since API 30)
Landroid/net/ConnectivityModuleConnector;->IN_PROCESS_SUFFIX:Ljava/lang/String; - (since API 31)
Landroid/net/INetd$Stub;->asInterface(Landroid/os/IBinder;)Landroid/net/INetd; - (since API 31)
Landroid/net/INetd;->ipSecUpdateSecurityPolicy(IIILjava/lang/String;Ljava/lang/String;IIII)V - (since API 30)
Landroid/net/IIntResultListener$Stub;->()V,blocked - (since API 30)
Landroid/net/IIntResultListener;->onResult(I)V,blocked - (since API 30)
Landroid/net/ITetheringConnector;->stopTethering(ILjava/lang/String;Landroid/net/IIntResultListener;)V,blocked - (since API 30)
Landroid/net/ITetheringConnector;->stopTethering(ILjava/lang/String;Ljava/lang/String;Landroid/net/IIntResultListener;)V,blocked - (since API 30)
Landroid/net/TetheringManager$ConnectorConsumer;->onConnectorAvailable(Landroid/net/ITetheringConnector;)V,blocked - (since API 30)
Landroid/net/TetheringManager$TetheringEventCallback;->onTetherableInterfaceRegexpsChanged(Landroid/net/TetheringManager$TetheringInterfaceRegexps;)V,blocked - (since API 31)
Landroid/net/TetheringManager$TetheringEventCallback;->onSupportedTetheringTypes(Ljava/util/Set;)V,blocked - (since API 30)
Landroid/net/TetheringManager;->getConnector(Landroid/net/TetheringManager$ConnectorConsumer;)V,blocked Landroid/net/TetheringManager;->TETHER_ERROR_*:I,blocked- (since API 30)
Landroid/net/TetheringManager;->TETHERING_VIRTUAL:I,blocked - (since API 30)
Landroid/net/TetheringManager;->TETHERING_WIGIG:I,blocked - (since API 31)
Landroid/net/IpSecManager;->DIRECTION_FWD:I,blocked - (since API 31)
Landroid/net/IpSecManager;->INVALID_SECURITY_PARAMETER_INDEX:I,blocked - (since API 31)
Landroid/net/wifi/SoftApCapability;->getCountryCode()Ljava/lang/String;,blocked - (since API 33)
Landroid/net/wifi/SoftApConfiguration$Builder;->setRandomizedMacAddress(Landroid/net/MacAddress;)Landroid/net/wifi/SoftApConfiguration$Builder;,blocked - (since API 31)
Landroid/net/wifi/SoftApConfiguration;->BAND_TYPES:[I,blocked - (since API 31)
Landroid/net/wifi/SoftApInfo;->getApInstanceIdentifier()Ljava/lang/String;,blocked Landroid/net/wifi/ISoftApCallback$Stub;->asInterface(Landroid/os/IBinder;)Landroid/net/wifi/ISoftApCallback;,lo-prio,max-target-o- (since API 33)
Landroid/net/wifi/IWifiManager;->registerLocalOnlyHotspotSoftApCallback(Landroid/net/wifi/ISoftApCallback;Landroid/os/Bundle;)V,blocked - (prior to API 31)
Landroid/net/wifi/IWifiManager;->registerSoftApCallback(Landroid/os/IBinder;Landroid/net/wifi/ISoftApCallback;I)V,max-target-o - (since API 31)
Landroid/net/wifi/IWifiManager;->registerSoftApCallback(Landroid/net/wifi/ISoftApCallback;)V,blocked - (since API 33)
Landroid/net/wifi/IWifiManager;->unregisterLocalOnlyHotspotSoftApCallback(Landroid/net/wifi/ISoftApCallback;Landroid/os/Bundle;)V,blocked - (prior to API 31)
Landroid/net/wifi/IWifiManager;->unregisterSoftApCallback(I)V,max-target-o - (since API 31)
Landroid/net/wifi/IWifiManager;->unregisterSoftApCallback(Landroid/net/wifi/ISoftApCallback;)V,blocked - (since API 31)
Landroid/net/wifi/WifiClient;->getApInstanceIdentifier()Ljava/lang/String;,blocked - (prior to API 30)
Landroid/net/wifi/WifiConfiguration;->AP_BAND_2GHZ:I,lo-prio,max-target-o - (prior to API 30)
Landroid/net/wifi/WifiConfiguration;->AP_BAND_5GHZ:I,lo-prio,max-target-o - (prior to API 30)
Landroid/net/wifi/WifiConfiguration;->AP_BAND_ANY:I,lo-prio,max-target-o - (prior to API 30)
Landroid/net/wifi/WifiConfiguration;->apBand:I,unsupported - (prior to API 30)
Landroid/net/wifi/WifiConfiguration;->apChannel:I,unsupported - (since API 30)
Landroid/net/wifi/WifiContext;->ACTION_RESOURCES_APK:Ljava/lang/String;,blocked - (since API 30)
Landroid/net/wifi/WifiContext;->(Landroid/content/Context;)V,blocked Landroid/net/wifi/WifiManager$SoftApCallbackProxy;->(Landroid/net/wifi/WifiManager;Landroid/os/Looper;Landroid/net/wifi/WifiManager$SoftApCallback;)VLandroid/net/wifi/WifiManager$SoftApCallbackProxy;->(Landroid/net/wifi/WifiManager;Ljava/util/concurrent/Executor;Landroid/net/wifi/WifiManager$SoftApCallback;)V,blockedLandroid/net/wifi/WifiManager$SoftApCallbackProxy;->(Landroid/net/wifi/WifiManager;Ljava/util/concurrent/Executor;Landroid/net/wifi/WifiManager$SoftApCallback;I)V,blockedLandroid/net/wifi/WifiManager$SoftApCallbackProxy;->(Ljava/util/concurrent/Executor;Landroid/net/wifi/WifiManager$SoftApCallback;I)V- (prior to API 30)
Landroid/net/wifi/WifiManager$SoftApCallback;->onNumClientsChanged(I)V,greylist-max-o - (since API 33)
Landroid/net/wifi/WifiManager;->EXTRA_PARAM_KEY_ATTRIBUTION_SOURCE:Ljava/lang/String;,blocked Landroid/net/wifi/WifiManager;->cancelLocalOnlyHotspotRequest()V,unsupportedLandroid/net/wifi/WifiManager;->mService:Landroid/net/wifi/IWifiManager;,unsupportedLandroid/net/wifi/p2p/WifiP2pConfig$Builder;->MAC_ANY_ADDRESS:Landroid/net/MacAddress;,blockedLandroid/net/wifi/p2p/WifiP2pConfig$Builder;->mNetworkName:Ljava/lang/String;,blocked- (since API 30)
Landroid/net/wifi/p2p/WifiP2pGroup;->interfaceAddress:[B,unsupported Landroid/net/wifi/p2p/WifiP2pManager;->startWps(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/WpsInfo;Landroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,unsupportedLandroid/hardware/wifi/supplicant/V1_0/IfaceType;->P2P:ILandroid/hardware/wifi/supplicant/V1_0/ISupplicant;->getService()Landroid/hardware/wifi/supplicant/V1_0/ISupplicant;Landroid/hardware/wifi/supplicant/V1_0/ISupplicant;->getInterface(Landroid/hardware/wifi/supplicant/V1_0/ISupplicant$IfaceInfo;Landroid/hardware/wifi/supplicant/V1_0/ISupplicant$getInterfaceCallback;)VLandroid/hardware/wifi/supplicant/V1_0/ISupplicant$getInterfaceCallback;->onValues(Landroid/hardware/wifi/supplicant/V1_0/SupplicantStatus;Landroid/hardware/wifi/supplicant/V1_0/ISupplicantIface;)VLandroid/hardware/wifi/supplicant/V1_0/ISupplicant;->listInterfaces(Landroid/hardware/wifi/supplicant/V1_0/ISupplicant$listInterfacesCallback;)VLandroid/hardware/wifi/supplicant/V1_0/ISupplicant$IfaceInfo;->name:Ljava/lang/String;Landroid/hardware/wifi/supplicant/V1_0/ISupplicant$IfaceInfo;->type:ILandroid/hardware/wifi/supplicant/V1_0/ISupplicant$listInterfacesCallback;->onValues(Landroid/hardware/wifi/supplicant/V1_0/SupplicantStatus;Ljava/util/ArrayList;)VLandroid/hardware/wifi/supplicant/V1_0/ISupplicantP2pIface;->asInterface(Landroid/os/IHwBinder;)Landroid/hardware/wifi/supplicant/V1_0/ISupplicantP2pIface;Landroid/hardware/wifi/supplicant/V1_0/SupplicantStatusCode;->FAILURE_ARGS_INVALID:ILandroid/hardware/wifi/supplicant/V1_0/SupplicantStatus;->code:ILandroid/hardware/wifi/supplicant/V1_0/SupplicantStatus;->debugMessage:Ljava/lang/String;Landroid/hardware/wifi/supplicant/V1_2/ISupplicantP2pIface;->addGroup_1_2(Ljava/util/ArrayList;Ljava/lang/String;ZI[BZ)Landroid/hardware/wifi/supplicant/V1_0/SupplicantStatus;Landroid/hardware/wifi/supplicant/V1_2/ISupplicantP2pIface;->castFrom(Landroid/os/IHwInterface;)Landroid/hardware/wifi/supplicant/V1_2/ISupplicantP2pIface;Landroid/hardware/wifi/supplicant/V1_2/ISupplicantP2pIface;->setMacRandomization(Z)Landroid/hardware/wifi/supplicant/V1_0/SupplicantStatus;- (since API 30)
Landroid/os/ServiceManager;->waitForDeclaredService(Ljava/lang/String;)Landroid/os/IBinder;,blocked - (since API 30)
Landroid/os/ServiceManager;->waitForService(Ljava/lang/String;)Landroid/os/IBinder;,blocked - (since API 30)
Lcom/android/server/wifi/SupplicantStaIfaceHalAidlMainlineImpl;->isServiceAvailable(Landroid/content/Context;)Z - (since API 30)
Landroid/system/wifi/mainline_supplicant/IMainlineSupplicant$Stub;->asInterface(Landroid/os/IBinder;)Landroid/system/wifi/mainline_supplicant/IMainlineSupplicant; - (since API 30)
Landroid/system/wifi/mainline_supplicant/IMainlineSupplicant;->getVendorSupplicant()Lcom/android/wifi/x/android/hardware/wifi/supplicant/ISupplicant; - (prior to API 30)
Landroid/provider/Settings$Global;->SOFT_AP_TIMEOUT_ENABLED:Ljava/lang/String;,lo-prio,max-target-o - (on API 34)
Landroid/service/quicksettings/TileService;->mToken:Landroid/os/IBinder;,lo-prio,max-target-o - (prior to API 30)
Lcom/android/internal/R$array;->config_tether_bluetooth_regexs:I,max-target-q - (prior to API 30)
Lcom/android/internal/R$array;->config_tether_usb_regexs:I,max-target-q - (prior to API 30)
Lcom/android/internal/R$array;->config_tether_wifi_regexs:I,max-target-q - (on API 29)
Lcom/android/internal/R$bool;->config_wifi_p2p_mac_randomization_supported:I,blacklist - (prior to API 30)
Lcom/android/internal/R$integer;->config_wifi_framework_soft_ap_timeout_delay:I,greylist-max-o Lcom/android/internal/R$string;->config_ethernet_iface_regex:I,lo-prio,max-target-o- (since API 31)
Lcom/android/server/IpSecService;->FULL_MASK:I - (since API 30)
Lcom/android/server/wifi/p2p/SupplicantP2pIfaceHalAidlBase;->HAL_INSTANCE_NAME:Ljava/lang/String; - (since API 30)
Lcom/android/server/wifi/p2p/SupplicantP2pIfaceHalAidlMainlineImpl;->MAINLINE_SUPPLICANT_SERVICE_NAME:Ljava/lang/String; Lcom/android/server/wifi/p2p/WifiP2pServiceImpl;->ANONYMIZED_DEVICE_ADDRESS:Ljava/lang/String;- (since API 30)
Lcom/android/server/SystemServer;->TETHERING_CONNECTOR_CLASS:Ljava/lang/String; - (since API 33)
Ldalvik/system/BaseDexClassLoader;->pathList:Ldalvik/system/DexPathList;,unsupported - (since API 33)
Ldalvik/system/DexPathList;->nativeLibraryDirectories:Ljava/util/List;,unsupported - (prior to API 33)
Ljava/lang/invoke/MethodHandles$Lookup;->(Ljava/lang/Class;I)V,unsupported - (prior to API 33)
Ljava/lang/invoke/MethodHandles$Lookup;->ALL_MODES:I,lo-prio,max-target-o
See mobile/src/hiddenApiStubs for hidden whitelisted/system APIs as well as partial SDK-class stubs.
Nonexported system resources:
- (since API 30)
@com.android.networkstack.tethering:array/config_tether_bluetooth_regexs - (since API 30)
@com.android.networkstack.tethering:array/config_tether_ncm_regexs - (since API 30)
@com.android.networkstack.tethering:array/config_tether_usb_regexs - (since API 30)
@com.android.networkstack.tethering:array/config_tether_wifi_p2p_regexs - (since API 30)
@com.android.networkstack.tethering:array/config_tether_wifi_regexs - (since API 30)
@com.android.networkstack.tethering:array/config_tether_wigig_regexs - (since API 30)
@com.android.wifi.resources:bool/config_wifi_p2p_mac_randomization_supported - (since API 31)
@com.android.wifi.resources:integer/config_wifiFrameworkSoftApShutDownIdleInstanceInBridgedModeTimeoutMillisecond - (since API 30)
@com.android.wifi.resources:integer/config_wifiFrameworkSoftApShutDownTimeoutMilliseconds
Other:
- (prior to API 30) Activity
com.android.settings/.Settings$TetherSettingsActivityis assumed to be exported. - P2P HIDL fallback probes generated supplicant HIDL Java classes from Android 10
wifi-service.jar
and Android 11+ AOSP Wi-Fiservice-wifi.jarcom.android.wifi.x.*jarjar packages. - P2P AIDL mode probes the Wi-Fi mainline service
wifi_mainline_supplicantfrom liveservice-wifi.jarto recover the stable vendor supplicant AIDL binder before falling back to direct
vendor HAL registration. IPv6 NATmode depends on the iptablesTPROXYandNFQUEUEtargets and
transparent sockets. ICMPv6 Echo interception uses app-owned queue30000
and assumes queued downstream packets expose six-byte source hardware-address
metadata throughNFQA_HWADDR.- (since API 30) Relevant tethering APEX classes used here, including
android.net.ITetheringConnector,
may be jarjar-relocated under the optional prefixesandroid.net.connectivityorcom.android.connectivity. - (since API 31) Relevant netd APEX classes used here, including
android.net.INetd*,
may be jarjar-relocated under the optional prefixesandroid.net.connectivityorcom.android.connectivity. - (since API 30) When runtime
TetheringEventCallback.onLocalOnlyInterfacesChangedis present, AOSP dispatches
startup tether-state callbacks from oneexecutor.execute { ... }block inonCallbackStarted,
and later tether-state updates from oneexecutor.execute { ... }block inonTetherStatesChanged. - The Rust DNS proxy submits upstream queries through
android_res_nsend/android_res_nresult.
To keep daemon tasks nonblocking while still usingandroid_res_nresultas the public result
reader/closer, it waits fordnsproxydto close the one-shotresnsendclient socket before
reading the result. This assumesresnsendwrites the complete resolver result before returning
and the socket receive buffer can hold that result until the framework socket listener closes the
client socket. - For
ip rulepriorities, AOSP local-network/tethering priorities are assumed to be 17000/18000
on API 29..30 and 20000/21000 on API 31+. VPNHotspot uses the 17500..17900 or 20500..20900
gap between them. - For route-table numbers, Android interface tables are assumed to start at ifindex + 1000;
IPv6 NAT
TPROXY uses table 900 to stay below that range and away from AOSP fixed tables 97..99 and kernel built-ins. - Clean flushes table 900 because that table is reserved by VPNHotspot.
IPv6 NATalso adds its
deterministic ULA /64 route to Android's sharedlocal_networkroute table 97; Clean never flushes
that table and only deletes VPNHotspot prefixes reconstructed from current interface names. - For packet marks, Android fwmark is assumed to use low bits for netId and routing metadata.
IPv6 NATfwmark fallback for TPROXY uses masked high reserved bits0x10000000/0x10000000.
That fallback is expected on only kernels without effectiveFRA_IP_PROTOpolicy-rule support, which upstream Linux added in 4.17.
Probe cleanup deletes at most one rule, omittingFRA_IP_PROTOwhen support or
the mutation outcome is uncertain; repeated stale-rule deletion is reserved for Clean.- Daemon reply sockets use the AOSP local-network protected mark
0x00030063, which assumesLOCAL_NET_ID = 99plus theexplicitlySelectedandprotectedFromVpnfwmark bits.
System/root command assumptions:
The following Android system binaries are assumed to be bundled and executable:
/system/bin/dumpsys(ipsec);/system/bin/iptables-restore,/system/bin/ip6tables-restore(-w --noflush, restore input
commands including-I,-D,-N,-nvx -L <chain>);/system/bin/ndc(ipfwd,nat);/system/bin/settings(put global);/system/bin/linkeror/system/bin/linker64(path.zip!/program).
File: docs/vpnhotspotd/dns.md
DNS
The DNS runtime is per session. It owns per-MAC daemon listeners for downstream
DNS and the handoff to Android's resolver API. Routing owns how downstream
packets reach those listeners.
Listener Ownership
dns::Runtime::start
attempts to bind TCP and UDP listeners on ephemeral ports for each allowed
client MAC. Each MAC/protocol listener is an independent best-effort
capability. A listener that starts publishes its port to routing; a listener
that fails is reported as a structured nonfatal with downstream, MAC, and
protocol, and that MAC/protocol DNS redirect is omitted from routing.
Routing redirects downstream IPv4 DNS with MAC-matched DNAT rules to the
ephemeral ports that exist. Each redirected port also has a filter INPUT
guard: packets are allowed to reach the listener only when conntrack says the
original destination was the downstream gateway on port 53. Direct connections
to another client's listener port are rejected before the daemon accepts them.
Packets still addressed to the downstream gateway on port 53 are rejected infilter INPUT, so blocked clients and missing capability cases do not fall
through to an accidental local DNS service.
NAT66 TCP and UDP also special-case DNS to the NAT66 gateway on port 53 and
call the same DNS handlers from the per-MAC NAT66 listener context. This keeps
resolver selection, MAC attribution, accounting, and DNS response generation in
one runtime instead of duplicating DNS behavior in NAT66.
Config Snapshots
The DNS runtime receives the shared session config. For each query or TCP
connection, it clones a snapshot before resolving. That snapshot determines the
primary and fallback Android networks used for resolver calls.
Current selection is simple:
- use
primary_networkwhen present; - otherwise use
fallback_networkwhen present; - otherwise return a DNS failure.
DNS does not hold the config mutex while waiting for Android resolver results.
The selected network is internal resolver state; DNS counters are not persisted
by actual upstream interface.
Resolver Handoff
DNS queries are sent through bionic's Android resolver API:
android_res_nsendstarts a one-shot query on the selected Android network;android_res_nresultreads and closes the result;android_res_cancelis used if the query object is dropped before finish.
android_res_nsend returns a file descriptor. The daemon sets it nonblocking
and wraps it in AsyncFd. The daemon waits for the resolver-side socket to
become readable and then for EOF before calling android_res_nresult.
This shape exists because android_res_nresult is the public result
reader/closer. The root README records the platform assumption: the resolver
service writes the complete result before returning and closes the socket when
the result is ready, so the daemon can stay nonblocking while still using the
public result API.
DNS accounting counts the DNS payload bytes handed to android_res_nsend and
the response bytes returned by android_res_nresult. It does not try to account
for Android's physical DNS transport, DNS-over-TLS, packet headers, or resolver
retransmits.
Locally generated DNS errors are client responses, not resolver responses. If
no resolver query is handed to Android, the daemon returns the client-visible
error without increasing DNS counters. If android_res_nsend accepts a query
but no resolver response is returned, only the sent query side is counted.
TCP DNS
TCP DNS accepts normal DNS-over-TCP framing:
- read a two-byte length;
- read exactly that many query bytes;
- resolve the query through the selected Android network;
- write a two-byte response length and response bytes.
Each framed query and framed response is one DNS TCP accounting unit. DNS TCP
does not try to infer lower-layer TCP packet counts.
Unexpected EOF while reading the next frame ends the connection cleanly.
Host- or network-unreachable response writes are treated as downstream
reachability churn and logged. Other I/O failures are returned to the
connection task.
UDP DNS
UDP DNS reads one datagram, clones the config snapshot, and resolves the query
in a child task. If resolution succeeds, the response is sent back to the
datagram source. Host- or network-unreachable reply sends are treated as
downstream reachability churn and logged with source context. Other send
failures are reported with source context.
The UDP listener intentionally does not serialize all queries through one
worker. Each query has its own child task tied to the session stop token.
Each query datagram and response datagram is one DNS UDP accounting unit.
Failure Semantics
Resolver failure normally returns a SERVFAIL response when the query can be
parsed enough to build one. If a SERVFAIL response cannot be generated, the
query is dropped.
Unexpected per-query resolver failures are logged but do not stop the DNS
runtime. Routine resolver unavailability, such as no selected upstream network
or an Android resolver timeout, returns SERVFAIL when possible without emitting
one stderr log per query. Listener setup failures do not stop the session.
Routing omits the missing DNS redirect, so normal IP traffic and manually
configured downstream DNS can still work.
Per-MAC listener setup and routing failures remove only that MAC/protocol DNS
capability. If a listener was staged but routing did not commit the matching
MAC redirect and direct-port guard, the staged listener is cancelled before the
session publishes committed capabilities. A TCP listener accept failure after
cancellation is treated as teardown; transient active-listener accept failures
are retried.
File: docs/vpnhotspotd/errors.md
Errors And Reports
Daemon errors need enough structure for the Kotlin side to show useful
diagnostics, attach Crashlytics keys, and distinguish terminal call failures
from background nonfatal reports.
Report Shape
Structured reports are built inshared/protocol.rs
and sent byreport.rs. A report
contains:
- context string;
- message;
- optional errno;
- kind;
- Rust source file, line, and column from
track_caller; - daemon process ID;
- bounded key/value details.
Use context strings that name the owning subsystem and operation, such asrouting.start, control.replace_session, or nat66.udp_connect. Details
should include the concrete identifiers needed to debug the failing operation,
such as downstream interface, upstream interface, session ID, client, or
destination. Per-MAC admission and accounting failures should include the MAC,
downstream interface, protocol/source, and queue number or listener port when
that state exists.
Terminal Call Errors
A terminal call error is sent as an error frame for the call ID. Kotlin converts
it into DaemonException and completes the matching one-shot reply or closes
the matching event channel.
Use terminal errors when the requested operation cannot be completed:
- malformed control frames or command payloads;
- duplicate active call ID;
- duplicate session for a downstream;
- missing session for replacement;
- downstream IPv4 discovery failure during session start;
- static-address replacement failure;
- Clean failure that prevents the command from completing.
When adding context to an io::Error, use the report extension helpers so the
first useful daemon context is preserved and not overwritten by generic wrapper
layers.
Nonfatal Reports
A nonfatal report is sent independently of the terminal frame path. Kotlin logs
it and shows an app-visible warning. Nonfatal reports are appropriate when the
daemon preserves the broader requested operation but loses an optional behavior
or observes unexpected background state.
Representative examples:
- a per-MAC DNS or NAT66 listener/routing capability fails, and the daemon omits
only that MAC/protocol capability; - NAT66 ICMPv6 receives a packet without usable committed-client attribution and
drops that packet while preserving the broader NAT66 session; - IPv4 forwarding counter readout fails during a traffic-counter read, while
daemon-owned DNS/NAT66 counters can still be returned; - a background task or best-effort cleanup step fails without invalidating the
command's main result.
Tie the report to a call ID when the failure belongs to a specific active call.
Use process-level nonfatal reports only for daemon-global background failures or
when no meaningful call owns the failure.
Nonfatal reports are coalesced before they are sent to Kotlin. The first report
for a category is emitted immediately. Further reports with the same context,
kind, errno, and Rust source file/line are suppressed for the current
one-second window; when the window closes, the daemon emits the last suppressed
report with coalesced.suppressed_count and coalesced.window_ms details. If
reports continue, subsequent windows keep emitting at most one summary per
second instead of reopening with another immediate report. If a category goes
quiet for a full window, the pending batch closes and the next report is again
emitted immediately. Daemon shutdown flushes pending summaries before the
control writer is closed, but nonfatal control-frame delivery is best-effort
during controller disconnect or idle shutdown. If an emitted nonfatal cannot be
written to the control socket, the daemon writes the report to stderr, which
falls back to logcat if the app-side stderr pipe is already closed. Terminal
error frames are not coalesced.
Logs
report::stdout! and report::stderr! write to stdio, falling back to logcat
if the stdio pipe is already closed. Use logs for expected remote/network
outcomes and low-value runtime noise that should not surface as a structured
app warning.
Do not use stderr-only logging for unexpected daemon failures in networking,
resolver, routing, firewall, netlink, fd, process, or cleanup operations.
Unexpected background failures should become structured nonfatal reports.
Cancellation
Cancellation is not an error by itself. If a call is cancelled and the failing
operation returns Interrupted, the call task should finish without sending a
terminal error. Background tasks tied to a stop token should exit quietly when
that token is cancelled. DNS TCP accept also re-checks its stop token before
reporting accept errors and retries transient active-listener errors.
If cancellation exposes an unexpected cleanup or channel failure, report that
failure only when it affects daemon-owned state or indicates a broken invariant.
Best-Effort Cleanup
Best-effort cleanup should handle expected benign absence explicitly, for
example missing routes, missing rules, missing addresses, or already-closed
file descriptors. Other errors should be reported with context.
Do not hide cleanup errors with let _ = ..., .ok(), broad catches, or
stderr-only messages. If a cleanup operation is intentionally allowed to fail,
document the expected failure mode in code or in the owning doc.
File: docs/vpnhotspotd/invariants.md
Invariants
These are daemon-wide rules that should stay true across modules. When a change
breaks one, document the new invariant in the owning doc and explain the
compatibility or cleanup impact.
Ownership
- A session owns daemon runtime state for exactly one downstream interface.
- The downstream interface is immutable for a session. Replacing a session may
change config details, but notSessionConfig.downstream. - DNS and NAT66 runtimes own listeners and proxy state. Routing owns system
interception that sends traffic to those runtimes. - Traffic accounting and client blocking are MAC-facing. IP addresses may be
hidden counter selectors, but they are not the live client identity. - Each rtnetlink connection has one lifecycle owner. Multicast-only event
connections are separate from request connections; session routing retains
its request connection for the session lifetime and never reconnects it. - The process-wide ICMP dispatcher exists only for NAT66 ICMPv6 state that must
be shared across sessions because the kernel queue is process-wide. It owns
queue30000and must attribute queued Echo Requests from source
hardware-address metadata. - The process-wide NAT66 UDP reply-socket registry exists because exact socket
binds share the daemon's network namespace across MAC listeners and sessions.
For its immutable daemon reply mark, it owns at most one live socket per exact
IPv6 source address and port. Associations, per-listener DNS anchors, and DNS
query tasks hold leases; only the last lease may remove and close the socket.
Interception
- Never install routing/firewall interception for a runtime capability that did
not start successfully. - DNS TCP and UDP listener ports are per-MAC, per-protocol capabilities. NAT66
TCP and UDP listener ports are also per-MAC, per-protocol capabilities. NAT66
ICMP Echo remains one session-level capability. - Optional NAT66 TCP, UDP, or ICMP failure must not disable the other NAT66
protocol capabilities that started successfully. - ICMPv6 local-link control traffic is not upstream NAT66 payload.
- DNS, NAT66 TCP, and NAT66 UDP must carry MAC identity by entering through
per-MAC listener resources. NAT66 ICMPv6 must carry MAC identity from a
six-byteNFQA_HWADDR; source-IP-to-MAC lookup is not a valid fallback. - Per-MAC listener ports are not client-provided identity tokens. Routing must
reject or otherwise fail closed on direct listener-port access that bypasses
the MAC-matched interception rule. NAT66 TCP/UDP listener ports are internal::1TPROXY endpoints. - Local downstream traffic is outside the traffic-control boundary and must not
be blocked or counted as upstream traffic by these mechanisms.
Cleanup
- Cleanup must not depend on private app databases, preferences, caches, or
daemon memory when the state can outlive the process. - Session rollback may use the in-memory applied mutation list. Clean must
reconstruct cleanup from deterministic identifiers and current system state. - Missing state during cleanup is expected. Unexpected cleanup failures should
be reported with structured context. - Shared platform tables and globally owned platform state must not be flushed
or disabled without an app-owned identifier. - Adding a new persistent route, rule, address, firewall rule, mark, table, or
chain requires adding or identifying its Clean path. - Reply-socket leases are process-local descriptor ownership, not persistent
routing or firewall state. Session stop and replacement release their leases,
detached tasks may retain leases until completion, and process death closes
any remaining descriptors without Clean bookkeeping. - When a change mutates existing rules or similar, backwards compatibility is almost never considered since these mutations are cleared upon reboot.
It is almost never worth the maintainability burden to carry over cleanup for legacy rules.
Configuration
- Runtime packet/query work reads
SessionConfigthrough snapshots and must
not hold the config mutex while waiting on network I/O or resolver I/O.
Session replacement may hold the mutex while routing is reconciled because
that lock is the commit gate that keeps DNS/NAT66 readers on the previous
config until the new routing state has committed. SessionConfig.clientsis keyed by MAC. A client entry may have no IPv4
addresses and still be a valid DNS/NAT66 authorization input.- Session replacement reconciles routing before publishing the new config
snapshot to NAT66/DNS readers. - An empty client set is a deferred NAT66 state, not a failure. Later
replacements may start NAT66 when clients appear.
Errors
- Terminal call errors mean the requested command could not complete.
- Nonfatal reports mean the daemon preserved the broader requested operation
but lost an optional capability or observed unexpected background state. - Per-MAC listener/routing failures are nonfatal when the daemon can omit only
that MAC/protocol capability. Unattributable NAT66 ICMPv6 NFQUEUE packets are
nonfatal background state and are dropped. - Unexpected background failures in daemon-owned networking, resolver, netlink,
routing, firewall, file descriptor, process, or cleanup work should become
structured reports, not stderr-only logs. - Cancellation is not an error by itself. Cleanup or channel failures observed
during cancellation should be reported only when they affect daemon-owned
state or indicate a broken invariant.
Platform Assumptions
- Public app compatibility assumptions stay in the root README.
- Inline source comments should stay near hardcoded AOSP-derived values and
hidden platform behavior. - These docs should explain how the daemon depends on those assumptions without
becoming a second hidden-API or platform compatibility index.
File: docs/vpnhotspotd/lifecycle.md
Lifecycle
vpnhotspotd is started lazily byDaemonController
when the app sends the first daemon command. The daemon stays alive only while
the controller has active calls. When the last call is closed, Kotlin closes the
control connection; the Rust control loop then stops all daemon-owned runtime
state and exits.
Process Startup
Kotlin locates the native vpnhotspotd library in the APK and runs it through
Android's linker from a root command. It creates:
- an abstract Unix-domain server socket name for the control channel;
- stdout and stderr pipes that are drained into Timber;
- one root command invocation that starts the daemon with the socket name.
The Rust entry point accepts exactly one argument: that socket name. It connects
back to the abstract Unix socket, splits the stream, starts a writer task for
outbound frames, initializes the nonfatal reporter, and builds process-wide
bookkeeping:
- one NAT66 ICMP dispatcher shared by NAT66 sessions and bound to the
app-owned NFQUEUE number30000; - one NAT66 UDP reply-socket registry shared by all MAC listeners and sessions,
using the process's immutable daemon reply mark; - a session map keyed by the start-session call ID;
- one process-wide upstream-interface aggregate for optional IPsec probes;
- one optional neighbour monitor;
- one process-wide flag for the NAT66 firewall base chains.
Each notification consumer owns a multicast-only rtnetlink connection and a
separate request connection where needed. Session routing retains its request
connection for the session lifetime; one-shot commands open their own.
The daemon does not listen for arbitrary clients. The app-side controller owns
the listening socket and accepts only a peer whose Unix socket credentials haveuid=0; non-root peers are closed and the controller keeps waiting within the
startup timeout. The daemon connects to that single controller.
Calls
The control loop decodes one client envelope at a time and dispatches each
non-cancel command into a task tracked by call ID. CancelCommand is handled
before dispatch and cancels the active call's cancellation token.
There are two call shapes:
- one-shot calls return one reply or one terminal error;
- event-style calls keep the call ID active, deliver one or more event frames,
and finish only when the controller cancels them, the daemon reports an error,
the control connection closes, or the daemon sends a completion.
StartSessionCommand and StartNeighbourMonitorCommand are event calls.ReplaceSessionCommand, ReadTrafficCountersCommand,ReplaceStaticAddressesCommand, and CleanRoutingCommand are one-shot calls.
StartSessionCommand sends an event ACK after the session is established, then
keeps the call active as the session owner. The session event stream may later
carry optional daemon-to-routing requests, such as an IPsec forwarding-policy
update request. StartNeighbourMonitorCommand sends an initial
neighbour/topology snapshot and then streams updates. The protobuf schema still
describes the frames; "event-style call" only describes the controller lifetime
shape.
Call IDs are part of lifecycle ownership. A session is stored under the call ID
that started it. Closing that event call is the normal request to stop that
session.
In these docs, a session means the daemon-owned runtime for one downstream
interface named by SessionConfig.downstream. It bundles that interface's DNS
proxy listeners, optional NAT66 proxy state, and routing mutations. It is not a
client connection and it is not an upstream network.
Session Startup
StartSessionCommand reserves a session slot before doing setup. The daemon
rejects a second active session for the same downstream interface. If an existing
session for that downstream has already been cancelled and is tearing down, the
new start waits for the old session to finish teardown and remove its daemon
slot, then retries insertion. If the new start is cancelled while waiting, it
exits without starting a session. If IPv6 NAT is requested, the process-wide
IPv6 NAT firewall base chains are attempted before the session runtime starts.
Failure there is reported as a structured nonfatal tied to the start call and
IPv6 NAT is disabled for that session start.
Session::start
constructs the session in this order:
- Open a temporary link/IPv4 event connection and the request connection that
will ultimately belong to routing, then wait for a downstream IPv4 address. - Start the DNS runtime bound to that downstream IPv4 address. TCP and UDP
listener setup is staged per MAC and protocol as independent best-effort
capabilities. - Start NAT66 if requested. NAT66 TCP and UDP listener setup is staged per MAC
and protocol; RA and ICMP setup remain session-level capabilities. The RA
task owns separate IPv6-address event and request connections. Failures are
reported as structured nonfatals tied to the start call. If the initial client
set is empty, NAT66 is deferred with no interception. If clients are present
and NAT66 produces no commit-ready TCP or UDP listener, the session continues
with IPv6 NAT disabled. - Transfer the startup request connection into routing beside its applied
mutation ledger and reconcile the staged DNS and NAT66 capabilities. Routing
applies each mutation best effort and reports setup failures without rolling
back unrelated successful mutations. - Publish only capabilities whose daemon resource and routing rule both
committed. Staged per-MAC resources whose routing rules failed are cancelled
before the ACK. If clients are present and routing commits no NAT66 TCP or
UDP capability, NAT66 is stopped and the session is published with IPv6 NAT
disabled.
Downstream IPv4 discovery is still required before a session can be established.
After that point, DNS, NAT66, and routing setup failures remove only the
affected MAC/protocol capability or mutation from the best-effort setup result.
After the session is installed, Rust publishes a session-control handle and
sends an event ACK. Read, replace, and stop operations enqueue commands through
that handle; the start-session task owns the session runtime and processes those
commands in order. When cancelled normally, it removes the control handle from
the slot, drains already queued commands, stops the session runtimes, removes the
session from daemon state, and releases any same-downstream start waiting behind
that teardown.
After the ACK, the daemon updates process-wide IPsec tracking for each active
session's upstream interface names and upstream generation. The tracked upstream
set is the union of primary and fallback upstream interfaces because either role
can be used by the installed routing policy for a given packet. On Android 12+,
if a session's upstream set or upstream generation changes, the daemon spawns a
best-effort global probe that runs /system/bin/dumpsys ipsec. Probe requests
are coalesced while one is already running; upstream churn does not queue a
trailing probe. The probe parses every matching IPv4 tunnel forwarding-policy
target in the dump, and the process-wide tracker emits only newly observed
targets whose interface is still in an active session's upstream set. Repeated
probes that observe the same target do not emit another request. No-match is
quiet; dumpsys or parser failures are structured global nonfatals. The daemon
clears its emitted-target record when the target disappears from a later probe or
its interface leaves all session upstream sets. The daemon does not separately
supervise a stuck dumpsys process, and it does not track or clean up IPsec
policy state; tunnel and policy teardown remain platform-owned.
Session Replacement
ReplaceSessionCommand updates the config for an existing session. The
downstream interface is immutable; replacing it is rejected because routing and
session ownership are keyed to that interface. Replacement is ordered through
the session-control command loop, so it cannot interleave with traffic-counter
reads or session stop. It reuses routing's session-owned request connection.
Inside that ordered replacement, the session holds its config mutex as a commit
gate for DNS and NAT66 readers:
- routing reconciles from the previous committed config/capability set to the
next desired config/capability set; - NAT66 records replacement state that matters for later cleanup;
- the shared config snapshot is replaced;
- NAT66 is notified after the mutex is released.
This means active DNS/NAT66 work that needs a config snapshot can pause behind
replacement, but it cannot observe the next config before routing has committed
the matching interception state.
Client changes are MAC-scoped. Replacement stages DNS and NAT66 resources for
new MAC/protocol capabilities, reconciles routing, publishes only committed
capabilities, and cancels removed or uncommitted per-MAC resources. Before a MAC
or counter source is removed, the session exposes its final daemon-owned
counters through the next traffic-counter read.
When the next client set is empty, replacement publishes no NAT66 routing
capabilities. Existing NAT66 runtime state may stay alive only to preserve
session-owned counters and deferred NAT66 eligibility for a later non-empty
client set.
If process-wide firewall-base setup failed, NAT66 produced no runtime for a
non-empty client set, or routing committed no NAT66 TCP/UDP capability for a
non-empty client set, later replacements keep ipv6_nat disabled for that
session. An empty client set is not failure; replacement may start NAT66 when a
later neighbour snapshot adds a MAC.
After a successful replacement, the same process-wide IPsec state is updated
from the session's new upstream interface union and upstream generation. A
replacement triggers one IPsec probe when that interface union changes or when
Kotlin reports that either upstream role has a new upstream snapshot, unless a
global probe is already running. Client and downstream-only changes do not
trigger a probe by themselves.
Shutdown And Clean
Normal session stop cancels the session stop token first so DNS and NAT66
listeners normally choose shutdown over reporting teardown-time socket errors.
It rolls routing back through the session-owned request connection without
waiting for listener or per-packet tasks to drain, then stops NAT66, which may
withdraw advertised prefixes through the same connection. Request failures are
reported without replacing the connection. NAT66 UDP associations and
per-listener DNS anchors release their reply-socket leases during this teardown.
An overlapping detached DNS task retains its own lease, so session stop or
replacement cannot close its reply socket prematurely. The process-wide
registry removes and closes an exact source bind when its last lease is
released.
When the control connection closes, the daemon cancels active calls, waits for
call tasks, stops the neighbour monitor, stops all sessions without extra
withdraw-cleanup, clears the IPsec aggregate, removes process-wide IPv6 NAT
firewall base state, drops the writer, and exits. Exiting also drops the NAT66
UDP reply-socket registry and closes any remaining file descriptors; the
registry creates no persistent routing or firewall state for Clean to remove.
CleanRoutingCommand is stronger than normal shutdown. It:
- drains all session slots from daemon state;
- marks those sessions as cleaning so their start-session tasks do not try to
remove the same state again; - detaches and completes the corresponding event calls;
- stops sessions with
withdraw_cleanup = true; - clears the process-wide IPsec upstream aggregate;
- removes the process-wide NAT66 firewall base chains;
- opens a scoped request connection and runs deterministic routing cleanup that
reconstructs app-owned state from current kernel/interface state and the
prefix seed.
Clean must not depend on private app databases, preferences, or daemon memory.
Anything that can outlive the process needs a deterministic cleanup path.
Traffic history is not a cleanup input. Per-MAC listeners, redirect rules,
TPROXY rules, and the single NAT66 ICMPv6 NFQUEUE rule are removed through
normal routing cleanup or deterministic Clean reconstruction.
Neighbour Monitor
The daemon allows one neighbour monitor at a time. Starting a monitor opens a
multicast-only neighbour/link event connection plus a separate request
connection, sends an initial neighbour dump and bridge topology snapshot, then
streams deltas until cancellation. Stopping drops both connections. Link events
refresh bridge topology only when it changes.
File: docs/vpnhotspotd/nat66.md
NAT66
NAT66 is VPNHotspot's IPv6 NAT mode. It assigns a deterministic app-owned ULA
prefix to the downstream and proxies selected IPv6 traffic through daemon-owned
sockets. It is not full packet NAT and does not forward arbitrary IPv6 next
headers.
Runtime Shape
nat66::Runtime is
optional per session. If SessionConfig.ipv6_nat is absent, NAT66 startup
returns None and routing must not install NAT66 interception.
When enabled, startup attempts to create:
- optional per-MAC TCP TPROXY listeners;
- optional per-MAC UDP TPROXY listeners;
- an optional router-advertisement task;
- an optional ICMPv6 Echo registration through the process-wide dispatcher.
The TCP and UDP listener ports become routing inputs only when their listener,
MAC-scoped routing rules, gateway DNS preludes, local/special destination
exclusions, and base input filter rules are committed. ICMP Echo support is one
session-level capability. If one MAC/protocol capability fails, the daemon
reports a structured nonfatal and continues with the remaining NAT66
capabilities. An empty client set is a deferred state, not a NAT66 failure: the
session may keep ipv6_nat configured with no committed NAT66 interception
until a later neighbour snapshot adds a MAC. If clients are present and no TCP
or UDP listener can be committed, the session continues with IPv6 NAT disabled;
ICMP alone does not keep NAT66 enabled.
NAT66 shares the session config through an Arc<Mutex<SessionConfig>>. Per-flow
or per-packet work should clone a config snapshot at the point where a stable
decision is needed. Long-lived tasks should not keep mutable references to
session config across awaits.
ICMP registration uses the session routing request connection before routing
takes ownership. The RA task owns separate request and IPv6-address event
connections.
Ownership Levels
NAT66 state is split by lifetime:
| Lifetime | Owner | State |
|---|---|---|
| Process | IcmpDispatcher |
NFQUEUE task on queue 30000, ICMP session registrations, shared Echo state, upstream ICMP sockets |
| Process | ReplySocketPool |
daemon reply mark, exact UDP reply-source registry, socket leases shared across sessions |
| Session | nat66::Runtime |
committed per-MAC TCP/UDP capabilities, optional RA task, ICMP registration, cleanup prefixes, counter store |
| RA task | nat66/ra.rs |
IPv6-address event and request connections, raw ICMPv6 receive socket, periodic/suppression state |
| Client | per-MAC runtime | TCP/UDP listener ports, stop token, active DNS/NAT66 tasks, source-scoped counters |
| Listener | TCP/UDP loops | Accepted TCP connections, UDP association table, per-listener DNS reply-socket anchor |
| Flow | TCP task, UDP association task, or UDP DNS query task | MAC/downstream accounting context, upstream socket, downstream reply path and reply-socket lease, ICMP error registration where applicable |
The process-wide dispatcher exists because ICMP interception uses one NFQUEUE
number. Sessions register by downstream interface index and committed MAC set.
The registration is a weak pointer so stale registrations cannot keep a stopped
session alive.
Session stop drops the ICMP registration before prefix withdrawal. Dropping the
registration removes the interface from dispatch, and dropping the session state
removes Echo allocations for that session. Removing one MAC cancels only that
MAC's client runtime, including active Echo allocations and UDP error
registrations attributed to that MAC.
Network Selection
NAT66 upstream selection uses primary_network when the destination matches one
of primary_routes; otherwise it uses fallback_network when present. Missing
selection usually drops the proxied packet or connection quietly because there
is no daemon-owned upstream path.
Upstream sockets are bound to Android networks with android_setsocknetwork.
Reply sockets use the daemon reply mark so responses route through Android's
local-network path before VPN UID rules.
NAT66 DNS traffic to the NAT66 gateway reuses the DNS runtime's TCP and UDP
handlers. Host- or network-unreachable DNS TCP response writes are treated as
downstream reachability churn and logged, matching direct DNS TCP handling,
instead of surfacing as nat66.tcp_connection nonfatals.
The selected Android network is external state owned by ConnectivityService and
netd. If it disappears between session config publication and upstream socket
setup, android_setsocknetwork can fail with ENONET. NAT66 logs that
per-flow/per-association setup loss with the selected network and role, then
drops the intercepted connection or datagram without emitting a structured
nonfatal. No daemon-owned socket state is committed in that case, so normal
session stop, replacement, process death, or Clean has no extra cleanup work.
Routing Contract
Routing owns packet interception. NAT66 owns only the listeners, proxy tasks,
and protocol state behind that interception.
Startup and replacement stage NAT66 capabilities for routing:
- TCP and UDP listener ports are keyed by MAC and protocol, and each enables
one MAC/protocol listener TPROXY rule for gateway DNS and upstream proxying; icmp_echois true only when the session is registered with the ICMP
dispatcher;- an empty client set publishes no NAT66 routing capabilities even if a NAT66
runtime is retained for counters or future clients; - missing NAT66 config returns no runtime and no NAT66 routing state.
This is a hard boundary. Routing must not install a NAT66 interception rule
unless the runtime capability exists. Optional TCP, UDP, or ICMP failure must
remove only that protocol's interception from desired routing state.
A staged per-MAC TCP or UDP listener becomes committed only if routing also
installs the required session-level local/special exclusions, base input filter
rules, matching MAC-scoped TPROXY rule, and gateway DNS preludes. If routing
fails those rules, the listener is cancelled and the committed session omits
that MAC/protocol capability.
The mangle path returns local-link ICMPv6 control traffic first. Downstream
gateway DNS goes through vpnhotspot_acl, then admitted gateway DNS reaches
protocol-specific handling. Other local or special destinations return before
the generic upstream ACL/proxy path. The remaining upstream path gates throughvpnhotspot_acl, then applies per-MAC TCP/UDP TPROXY or ICMPv6 NFQUEUE
handling. Client allow rules return from the ACL chain; the base ACL rule drops
what remains. Blocked or unknown MACs never reach daemon-owned DNS, TCP/UDP
upstream TPROXY, or ICMPv6 NFQUEUE handling. TCP and UDP listeners bind to::1 and routing uses --on-ip ::1, so NAT66 listener ports are internal
TPROXY endpoints. Direct connections to those ports on local or special
downstream destinations do not reach the listener and fall through the base
input reject path.
TCP
TCP interception uses the transparent listener's local address as the original
destination. Each per-MAC listener carries the MAC/downstream accounting
context for accepted connections. The TCP runtime:
- marks the inbound socket with the daemon reply mark;
- special-cases DNS to the NAT66 gateway on port 53 and hands it to the DNS TCP
handler; - selects an upstream Android network for ordinary unicast destinations;
- connects an upstream TCP socket on that network;
- relays bytes bidirectionally, preserving TCP half-close semantics.
Connection setup failures caused by the remote path are logged and consumed.
When android_setsocknetwork reports ENONET, the selected upstream network
handle has already disappeared; the connection is logged and dropped because no
upstream socket state was committed. Other socket setup failures that indicate
daemon or platform state problems are terminal for that connection task and
reported with the selected network context through the daemon report path.
TCP is connection-local. It does not publish separate NAT66 state after the
connection task starts. A graceful EOF on one side shuts down only the write half
of the opposite socket and the other direction keeps relaying until it also
closes or an I/O error occurs. Reset, broken-pipe, timeout, host-unreachable, and
network-unreachable errors end only the connection task. The session runtime
does not track completed TCP connections.
TCP relay reports are attributed to the relay leg before they leave the
connection task. Read, write, flush, and shutdown errors use separate contexts
under nat66.tcp_relay.inbound_to_outbound.* ornat66.tcp_relay.outbound_to_inbound.* and include the MAC, client,
destination, selected network, role, direction, operation, and relay stage.
Expected connection-close and route-unreachable errors remain log-only;
unexpected relay I/O errors become structured daemon nonfatals with that relay
context preserved.
TCP byte counters update during relay. NAT66 TCP also increments its sent packet
counter once after a remote upstream socket is successfully opened; that counter
is a connection count, not an IP or TCP segment count.
UDP
UDP interception uses IPV6_RECVORIGDSTADDR and hop-limit metadata from each
per-MAC listener. The UDP runtime owns an association table keyed by MAC, client
socket, and original destination socket.
For each downstream datagram, the listener:
- handles UDP DNS to the NAT66 gateway through the DNS resolver path;
- ignores special local/link destinations that NAT66 does not own;
- requires downstream hop-limit metadata;
- sends local Time Exceeded when the hop limit expires at the NAT66 boundary;
- selects an upstream Android network;
- reuses or creates a connected upstream UDP socket for that association;
- forwards the datagram with the decremented hop limit when possible.
UDP counts one sent unit per upstream datagram and one received unit per
upstream response datagram. NAT66 gateway DNS datagrams are counted by DNS, not
by the /nat66/udp source.
Creating a UDP association first selects an Android network, binds the upstream
socket to that network, and connects the socket to the IPv6 destination. ENONET
from network selection is treated the same as TCP setup loss. EHOSTUNREACH orENETUNREACH from the connected UDP socket setup means the selected network
accepted the socket but cannot route that destination, for example a fallback
network without a usable IPv6 route. NAT66 logs the failure with MAC, client,
destination, selected network, and role, then drops the datagram without creating
an association. Other UDP setup/connect failures remain structured nonfatals.
Each association has one task that owns upstream receives and downstream
responses. The listener owns downstream receives and association creation. The
association task reports activity back to the listener, and idle associations
are cancelled after the NAT66 idle timeout.
Reply socket leases keep the daemon-wide pool aware of an exact reply-source
bind while an association may send responses through it. Association teardown
releases its lease, but the socket remains registered while another association,
DNS anchor, or DNS query task still holds a lease. This prevents overlapping
per-MAC listeners or sessions from racing a duplicate transparent bind.
ICMP error translation for UDP is association-local and MAC-attributed. The
association registers only while the connected upstream UDP socket is alive, and
it must not revive an expired association.
The listener is the sole owner of downstream datagram admission. Association
tasks own upstream receives and downstream replies for their connected upstream
socket. Association tasks report activity and close events back to the listener
over an internal channel. If an association closes, the listener removes it only
when the close event matches the current association ID for that key; stale
close events from an older task must not remove a newer association.
The control process owns one reply socket pool for every NAT66 UDP listener in
every session. Its reply mark is immutable, and it serializes create-or-reuse
for each exact IPv6 source address and port. Consequently, one bound socket for
that source and mark is shared across MAC listeners and sessions rather than
being recreated in each listener. User associations hold leases while alive.
Each listener also keeps a DNS anchor lease for its gateway source, and each
short-lived DNS query task holds its own lease so listener cancellation cannot
close the socket while a detached query is completing. The last lease removes
the entry and closes the socket.
A reply socket remains a nonblocking IPv6 UDP socket with SO_REUSEADDR, the
daemon SO_MARK, and IPV6_TRANSPARENT, bound to the exact original destination
address and port that must appear as the downstream reply source. A failed
downstream send is retried once on the same leased file descriptor; it does not
create a second exact bind while the original socket is still live. Host- or
network-unreachable downstream reply sends are treated as client reachability
churn and logged. Reply socket acquisition and other unexpected send failures
remain structured nonfatals.
UDP hop-limit behavior is part of the NAT66 contract. Missing hop-limit
metadata is reported and the datagram is dropped. Expired hop limit produces a
local Time Exceeded from the NAT66 gateway. Forwarded datagrams use the
decremented hop limit when sent upstream.
If a datagram exceeds the upstream socket MTU, UDP sends a Packet Too Big back
downstream when it has enough context. If the connected upstream socket reports
ICMP errors through the error queue, the association's ICMP registration maps
those errors back to the original client/destination.
ICMPv6
The ICMP dispatcher is process-wide, but registrations are per NAT66 session.
The dispatcher owns:
- one NFQUEUE receive task for downstream Echo Requests on queue
30000; - a registration map from downstream interface index to weak session state and
committed MAC set; - shared Echo mapping state;
- UDP error registrations used by live UDP associations.
A NAT66 session registers ICMP only after proving that downstream send support
is available for its downstream interface and reply mark. If the transparent raw
IPv6 bind probe fails with EADDRNOTAVAIL, kernels older than Linux 5.11.14
and unparsable uname.release values are treated as expected legacy lack of
support and do not emit a structured nonfatal. The session still continues
without ICMP Echo interception. Dropping the registration removes that
downstream interface from ICMP dispatch. Dropping the session removes its Echo
state.
ICMP Echo interception is optional. Routing installs the ICMP NFQUEUE rule only
when the registration exists. Ordinary local control-plane ICMPv6, neighbour
discovery, router solicitation/advertisement, multicast, link-local, loopback,
and unspecified destinations are outside the Echo proxy ownership boundary.
The NFQUEUE task always gives a verdict for queued packets. Packets with no live
session registration, no committed IPv6 NAT config, missing source
hardware-address metadata, non-six-byte hardware addresses, or a hardware
address outside the committed MAC set are dropped and reported as structured
nonfatals. The dispatcher must not fall back to source IPv6 neighbour lookup.
Malformed packets and ICMP that NAT66 does not own are dropped or accepted based
on the ownership decision innat66/icmp/downstream.rs.
Routable Echo Requests are copied, then the original queued packet is dropped.
The daemon allocates a rewritten Echo identifier/sequence, records the original
client MAC, client IPv6 address, and hop limit, sends a daemon-owned upstream
Echo Request on the selected Android network, and restores the client-visible
identifier when the Echo Reply returns.
The NFQUEUE path does not reassemble fragmented downstream Echo Requests, so an
Echo Request whose Fragment header actually fragments the ICMPv6 payload is not
proxied. Atomic fragments continue through the ordinary Echo path.
If the selected network reports host- or network-unreachable during the
upstream Echo send, the daemon logs the route loss, removes the Echo allocation,
and drops the intercepted request without emitting a structured nonfatal.
Echo upstream sockets are per Android network and shared with UDP ICMP error
translation. They stay alive while that network has Echo allocations or UDP
error registrations. When the last entry for a network expires or is removed,
the upstream socket is cancelled and removed.
ICMP errors are translated only when they map to daemon-owned Echo or UDP state.
The translated error types are Destination Unreachable, Packet Too Big, Time
Exceeded, and Parameter Problem. Generated downstream ICMP errors preserve the
upstream offender source when that source is meaningful on the downstream link;
link-local upstream offenders are rewritten to the NAT66 gateway. Error quotes
are parsed through complete supported IPv6 extension-header chains. An atomic or
first fragment can be mapped when its Fragment offset is zero and the quote
contains the complete Echo or UDP header; non-initial fragments cannot identify
daemon-owned transport state and are left unmapped. Reverse translation rewrites
the existing quote, including the transport checksum, while preserving its
extension headers and Fragment identification. Quotes are capped so the complete
generated IPv6 packet stays within the IPv6 minimum MTU. Expected kernel socket
errors from remote ICMP delivery are consumed after error-queue processing when
no daemon-owned quote can be mapped. Unmapped remote ICMP errors are not guessed
into downstream errors.
ICMPv6 counters count one sent unit per daemon-owned upstream Echo Request and
one received unit per upstream Echo Reply or upstream ICMPv6 error translated
for daemon-owned Echo/UDP state. Locally generated NAT66 control messages do not
inflate upstream counters.
Router Advertisements
The RA task owns NAT66 prefix advertisement on the downstream. It sends current
RAs periodically, answers router solicitations, watches downstream IPv6 address
changes, and suppresses or withdraws non-NAT66 downstream prefixes when needed.
Solicitations from a usable source address are answered to that source. A
solicitation from the unspecified address :: is answered with a
downstream-scoped all-nodes multicast RA to ff02::1, because :: is not a
routable unicast reply target.
The task requires a downstream link-local router address. If the address is not
available, it waits and logs that state instead of inventing a router source.
When the committed client set is empty, the task suppresses current NAT66 RAs.
If it had advertised the NAT66 prefix before the set became empty, it attempts a
zero-lifetime withdrawal and waits for clients to reappear.
On NAT66 stop, the runtime waits for the RA task and attempts to withdraw the
current gateway prefix through the session request connection. During Clean or
replacement cleanup, it may also withdraw older gateway prefixes recorded during
config replacement. Failure is reported without reconnecting.
The RA task also watches existing downstream IPv6 prefixes. Non-NAT66 routable
prefixes are temporarily advertised with zero lifetime so clients stop using
them while NAT66 owns the downstream IPv6 mode. Current NAT66 RAs and suppressed
prefix withdrawals use the actual downstream MTU when available, falling back to
the daemon default when MTU lookup fails.
Config replacement can change the deterministic NAT66 gateway. The runtime
records old gateways that need later withdrawal, but only while the process is
alive. Clean still relies on deterministic prefix reconstruction, not this
in-memory list.
Failure Boundaries
NAT66 startup is best effort across these pieces:
- Per-MAC TCP listener setup or routing failure is nonfatal; NAT66 continues
without that MAC/protocol interception when other capabilities started. - Per-MAC UDP listener setup or routing failure is nonfatal; NAT66 continues
without that MAC/protocol interception when other capabilities started. - RA task setup failure is nonfatal; existing NAT66 interception may continue,
but clients may need other configuration to discover the gateway. - RA transmit
ENOBUFSis downstream link backpressure, such as a full
USB/NCM tether transmit queue, and is logged to stderr instead of reported as
a structured nonfatal. Other RA send failures remain structured nonfatals
because they can indicate socket, privilege, or platform state problems. - Current or solicited RA send
EADDRNOTAVAILmeans the downstream link-local
router address disappeared between lookup and socket setup or transmission,
such as during tethering teardown. It is logged as a skipped advertisement
rather than reported as a structured nonfatal. - RA withdrawal socket bind
EADDRNOTAVAILmeans the downstream link-local
router address disappeared before cleanup could send a zero-lifetime RA. It
is logged as skipped cleanup rather than reported as a structured nonfatal. - ICMP registration failure is nonfatal; NAT66 continues without ICMP Echo
interception. The known transparent raw IPv6 bindEADDRNOTAVAILfailure is
reported only whenuname.releaseparses as Linux 5.11.14 or newer. - Unattributable ICMP NFQUEUE packets are dropped and reported as nonfatal
background state. - ICMP error-queue setup failure is reported, but Echo and UDP may continue
without that error translation path. - Runtime packet parse failures, unmapped errors, and ordinary remote failures
should be dropped or logged without stopping the session.
Session startup treats total NAT66 startup failure as optional at the session
level: if clients are present and NAT66 commits no TCP or UDP listener, it
disables ipv6_nat in the shared config and starts routing without NAT66
interception. If there are no clients yet, NAT66 remains eligible for a later
replacement instead of being treated as failed.
Cleanup
NAT66 cleanup has two layers:
- session stop cancels runtime tasks and attempts to withdraw advertised
prefixes; - routing cleanup removes deterministic routes, addresses, rules, and firewall
state.
Per-MAC runtime removal cancels that MAC's TCP tasks, UDP associations, Echo
allocations, and UDP error registrations, then preserves final source counters
long enough for one structured traffic-counter read.
Reply sockets are process-owned file descriptors rather than routing or
firewall mutations. Session stop releases listener anchors and association
leases. A retained listener swaps its anchor on the first DNS query for a new
gateway after reconfiguration, while overlapping detached tasks keep their
sockets live until they finish. The last lease closes each socket, and process
death closes any remaining descriptors. Normal stop and Clean require no
additional kernel-state cleanup for the reply socket pool.
Do not add persisted NAT66 cleanup state. If state can leak after process death,
Clean must be able to reconstruct and delete it from deterministic identifiers,
current interfaces, and the prefix seed.
File: docs/vpnhotspotd/README.md
vpnhotspotd Internals
vpnhotspotd is the root-side daemon used by VPNHotspot intended for all long-running background root-side work, including but not limited to routing state, DNS proxying, neighbour monitoring, and IPv6 NAT mode.
The split is designed this way since root-side JVM daemon is much more expensive and should be avoided as much as possible.
These docs describe the daemon's internal ownership model and cleanup invariants.
IPC documentations not included and should refer to mobile/src/main/proto/daemon.proto.
Source Map
control.rsowns the
daemon control loop, active calls, event-style (kotlin Flow) calls that
remain active for sessions or monitors, session slots, and the neighbour
monitor slot.session.rs
composes one session for one downstream interface from DNS, optional NAT66,
and routing runtimes.routing.rsandrouting/own
reversible route, rule, address, firewall, forwarding, and static-address
mutations.nat66/owns the IPv6
NAT proxy runtimes and helper protocol state.dns.rsowns the daemon
DNS listeners and Android resolver handoff.traffic.rsowns
daemon traffic-counter reads and the daemon-to-Kotlin counter reporting
boundary.netlink.rsowns the
owner-scoped rtnetlink request and multicast-only event connections.neighbour.rs
owns neighbour-monitor connections and converts netlink neighbour and bridge
topology state into daemon events.ipsec.rsowns the
optional Android 12+ IPsec forwarding-policy probe and emits session events
for the Kotlin routing owner to perform the hidden Netd write only when
needed.report.rsandshared/protocol.rs
build structured daemon reports.
The Kotlin side of the same subsystem lives undermobile/src/main/java/be/mygod/vpnhotspot/root/daemon/.
Kotlin starts the binary, frames control messages, owns call IDs, and turns
daemon reports into app-visible exceptions or nonfatal warnings.
Documents
lifecycle.md: daemon startup, control-loop ownership, call
lifetime, session lifetime, cancellation, and shutdown.routing.md: desired routing state, reversible mutations,
Clean behavior, and system mutation ownership.nat66.md: IPv6 NAT runtime boundaries for TCP, UDP, ICMPv6,
router advertisements, marks, and cleanup.dns.md: DNS listener ownership, resolver handoff, config snapshot
semantics, and nonblocking assumptions.traffic.md: MAC-facing traffic accounting, blocking scope,
counter sources, recorder chain boundaries, and persistence mapping.errors.md: terminal errors, nonfatal reports, context/detail
requirements, and background-task failure policy.invariants.md: cross-module ownership, interception,
cleanup, configuration, error, and platform-assumption rules.
Maintenance Rule
Keep these docs in sync with daemon behavior. A change tomobile/src/main/rust/vpnhotspotd, mobile/src/main/proto/daemon.proto, or
the Kotlin daemon controller should update docs/vpnhotspotd when it changes
internal ownership, lifecycle, cleanup, NAT66, DNS, routing, neighbour
monitoring, or error-reporting semantics.
Do not summarize away external side effects. If a change adds, removes, or
changes any mutation to kernel, netfilter, netd, resolver, socket, file
descriptor, process, or Android system state, the relevant doc must name:
- when the mutation happens;
- the exact external state or command shape;
- what owns rollback or normal stop cleanup;
- what Clean or process-death cleanup does, if the state can outlive the
runtime; - which missing-state or failure cases are expected.
For routing changes, routing.md is a mutation catalog. Every
route, policy rule, address, iptables/ip6tables rule or chain, ndc request,
and Clean mutation must be listed there.
If a daemon-adjacent change does not affect those documented contracts, say so
in the change description or final response. Do not duplicate the protobuf
schema here; update daemon.proto comments if the wire-level contract itself
needs documentation.
Platform and compatibility assumptions that affect the public app contract stay
in the root README.md, especially the Other andSystem/root command assumptions sections. These daemon docs may link to those
assumptions, but should not create a second compatibility index.
File: docs/vpnhotspotd/routing.md
Routing
Routing code owns root-side system mutations for a session. It consumes the
ports and capabilities produced by DNS and NAT66 startup, then turnsSessionConfig into concrete kernel, netfilter, and netd state.
This document is a mutation catalog. Every route, rule, address, firewall, andndc mutation made by routing.rs and routing/ should be listed here.
Mutation Model
routing.rs
represents session routing state as RoutingMutation values:
| Mutation | External apply | Session rollback |
|---|---|---|
EnsureIptablesChain |
iptables-restore/ip6tables-restore -N <chain>; after a nonzero exit, verify the chain with -L <chain> -n |
no-op; chains are scaffold state |
Iptables |
iptables-restore/ip6tables-restore -I <chain> ... |
delete the same rule with -D <chain> ... |
IpForward |
ndc ipfwd enable vpnhotspot_<downstream>, or /proc/sys/net/ipv4/ip_forward = 1 fallback |
ndc ipfwd disable vpnhotspot_<downstream> only |
Ip |
rtnetlink rule, route, or address replace | rtnetlink delete for the same rule, route, or address |
NetdNat |
ndc nat enable <downstream> 0 |
no-op; netd has no app-owned token |
Runtime::reconcile deletes applied mutations that are no longer desired, then
applies new desired mutations one at a time. Apply failures are structured
nonfatal reports; successful mutations stay applied and are not rolled back only
because a later best-effort mutation failed. A failed desired mutation is not
recorded in applied, so a later reconcile can try it again.
Creating an existing iptables chain normally exits nonzero. That result is only
treated as success after the read-only list command verifies that the chain is
present. If creation and verification both fail, the verification stderr is
reported as a structured ensure failure; reconciliation remains best effort and
can retry the ensure later.
The applied list is only the current process rollback list. It is not
persisted and is not a Clean source of truth.
routing::Runtime owns one rtnetlink request connection for the session
lifetime. Startup transfers the connection used for downstream discovery;
replacement and normal stop reuse it. Request failures are reported and later
operations remain best effort; the runtime does not reconnect.
Session Desired Mutations
routing/desired.rs
builds this desired state. The order below follows the producer order.
IP Forwarding
Condition: SessionConfig.ip_forward.
External mutation:
ndc ipfwd enable vpnhotspot_<downstream>
Fallback external mutation:
- if
ndc ipfwd enablefails, write1to/proc/sys/net/ipv4/ip_forward
Rollback:
ndc ipfwd disable vpnhotspot_<downstream>
The sysctl fallback is not reset by session rollback or Clean. It is a
persistent kernel-state fallback used only after the named ndc ipfwd enable
path fails.
IPv4 DNS Redirect
Condition: the owned nat chain and PREROUTING jump are present for a started
session with downstream IPv4. Per-MAC redirect rules are present per committed
client MAC and protocol when DNS startup produced that MAC/protocol listener
port and routing can also install the matching direct-port guard. DNS listener
ports are daemon-owned sockets bound to 0.0.0.0; the downstream IPv4 address is
owned by routing and appears only in packet matching and conntrack validation.
External mutations:
- ensure
iptables -t natchainvpnhotspot_dns_nat iptables -t nat -I PREROUTING -j vpnhotspot_dns_natiptables -t nat -I vpnhotspot_dns_nat -i <downstream> -p tcp -m mac --mac-source <mac> -d <downstream-ipv4> --dport 53 -j DNAT --to-destination :<dns-tcp-port-for-mac>iptables -t nat -I vpnhotspot_dns_nat -i <downstream> -p udp -m mac --mac-source <mac> -d <downstream-ipv4> --dport 53 -j DNAT --to-destination :<dns-udp-port-for-mac>iptables -t filter -I vpnhotspot_dns_input -i <downstream> -p tcp -d <downstream-ipv4> --dport <dns-tcp-port-for-mac> -m conntrack --ctorigdst <downstream-ipv4> --ctorigdstport 53 -j RETURNiptables -t filter -I vpnhotspot_dns_input -p tcp --dport <dns-tcp-port-for-mac> -j REJECT --reject-with tcp-resetiptables -t filter -I vpnhotspot_dns_input -i <downstream> -p udp -d <downstream-ipv4> --dport <dns-udp-port-for-mac> -m conntrack --ctorigdst <downstream-ipv4> --ctorigdstport 53 -j RETURNiptables -t filter -I vpnhotspot_dns_input -p udp --dport <dns-udp-port-for-mac> -j REJECT --reject-with icmp-port-unreachable
Effective order for a listener port is the conntrack original-destinationRETURN before the direct-port REJECT. The guard makes the ephemeral listener
port reachable only as the post-DNAT target of a packet originally addressed to
the downstream gateway on port 53. Because listeners are wildcard-bound, the
direct-port REJECT is intentionally not scoped to the downstream interface or
gateway address. If routing cannot install or validate the conntrack
original-destination guard, it must omit that MAC/protocol DNS capability
instead of exposing the listener port directly.
The guard lines above are the required effective order. Because iptables -I
inserts at the head by default, implementation must either insert with explicit
positions or apply paired guard mutations in the reverse order that produces the
effective chain order.
Rollback:
- delete the PREROUTING jump and the same MAC/protocol redirect and guard rules.
- no session rollback for the
vpnhotspot_dns_natchain itself.
Routing only redirects packets. MAC ownership and DNS upstream selection belong
to dns.rs. A DNS
listener is not committed unless both the listener and matching MAC redirect
and direct-port guard rules exist.
IPv4 Gateway DNS Denial
Condition: always present for a started session with downstream IPv4.
External mutations:
- ensure
iptables -t filterchainvpnhotspot_dns_input iptables -t filter -I INPUT -j vpnhotspot_dns_inputiptables -t filter -I vpnhotspot_dns_input -i <downstream> -p tcp -d <downstream-ipv4> --dport 53 -j REJECT --reject-with tcp-resetiptables -t filter -I vpnhotspot_dns_input -i <downstream> -p udp -d <downstream-ipv4> --dport 53 -j REJECT --reject-with icmp-port-unreachable
Rollback:
- delete the INPUT jump and downstream reject rules.
- no session rollback for the chain itself.
Clean:
- delete the INPUT jump, then flush and delete
vpnhotspot_dns_input. - delete the PREROUTING jump, then flush and delete
vpnhotspot_dns_nat.
Allowed DNS packets have already been DNATed to per-MAC daemon listener ports invpnhotspot_dns_nat, so these rules catch blocked clients and missing DNS
capability cases that remain addressed to the gateway on port 53. They do not
block manually configured external DNS except through the normal upstream
admission rules.
IPv4 Downstream Block Rule
Condition: always present for a started session.
External mutation:
- replace IPv4 policy rule:
iif <downstream> priority unreachable
Rollback:
- delete IPv4 policy rule at ``.
This prevents downstream traffic from escaping through system-owned fallback
routing when VPNHotspot has not allowed a matching upstream path.
IPv4 Forwarding Chains
Condition: always present for a started session.
External mutations:
- ensure
iptables -t filterchainvpnhotspot_acl - ensure
iptables -t filterchainvpnhotspot_stats
Session rollback:
- none for the chains themselves.
Clean:
- flush and delete these chains after removing base jumps.
IPv4 Forwarding Rules
Condition: always present for a started session.
External mutations:
iptables -t filter -I FORWARD -j vpnhotspot_acliptables -t filter -I vpnhotspot_acl -i <downstream> ! -o <downstream> -j REJECTiptables -t filter -I vpnhotspot_acl -o <downstream> -m state --state ESTABLISHED,RELATED -j ACCEPTiptables -t filter -I vpnhotspot_acl -o <downstream> -m state --state ESTABLISHED,RELATED -j vpnhotspot_stats
Rollback:
- delete the same rules.
Client-specific allow and stats rules are inserted into these chains from the
client snapshot, described below.
Simple IPv4 Masquerade
Condition: SessionConfig.masquerade == MASQUERADE_MODE_SIMPLE.
External mutations:
- ensure
iptables -t natchainvpnhotspot_masquerade iptables -t nat -I POSTROUTING -j vpnhotspot_masquerade
For each unique resolved upstream interface:
iptables -t nat -I vpnhotspot_masquerade -s <downstream-ipv4-subnet> -o -j MASQUERADE
Rollback:
- delete the jump and upstream MASQUERADE rules.
- no session rollback for the chain itself.
Clean:
- delete the POSTROUTING jump, then flush and delete
vpnhotspot_masquerade.
Upstream Policy Rules
Condition: for each unique upstream interface inprimary_upstream_interfaces and fallback_upstream_interfaces.
External mutations:
- primary upstream: replace IPv4 policy rule
iif <downstream> priority lookup <1000 + upstream-ifindex> - fallback upstream: replace IPv4 policy rule
iif <downstream> priority <fallback-priority> lookup <1000 + upstream-ifindex>
Rollback:
- delete the corresponding IPv4 policy rule by full rule shape.
Missing upstream links are skipped because upstream snapshots can race interface
churn. Other netlink lookup errors are reported as structured nonfatals and
that upstream interface is skipped for the current best-effort reconcile.
Netd Masquerade
Condition: SessionConfig.masquerade == MASQUERADE_MODE_NETD, for each unique
resolved upstream interface.
External mutation:
ndc nat enable <downstream> 0
Rollback:
- none.
Netd NAT/forwarding state is globally keyed by interface pair without an
app-owned token. Disabling it during session rollback could tear down
platform-owned state, so this daemon intentionally does not issuendc nat disable.
IPv6 Block Mode
Condition: SessionConfig.ipv6_block.
External mutations:
- ensure
ip6tables -t filterchainvpnhotspot_filter ip6tables -t filter -I INPUT -j vpnhotspot_filterip6tables -t filter -I FORWARD -j vpnhotspot_filterip6tables -t filter -I OUTPUT -j vpnhotspot_filterip6tables -t filter -I vpnhotspot_filter -i <downstream> -j REJECTip6tables -t filter -I vpnhotspot_filter -o <downstream> -j REJECT
Rollback:
- delete the five inserted rules.
- no session rollback for the chain itself.
Clean:
- delete the base jumps, then flush and delete
vpnhotspot_filter.
NAT66 Routes, Address, And Policy Rule
Condition: SessionConfig.ipv6_nat != null and NAT66 startup committed at
least one TCP or UDP runtime capability.
External mutations:
- replace IPv6 unicast route in table 97:
<nat66-prefix> dev <downstream> table local_network - replace IPv6 address on the downstream:
<nat66-gateway>/ dev <downstream> - replace IPv6 local route in table 900:
local ::/0 dev lo table 900 - NAT66 daemon policy-rule priority is
20600on API 31+ and17600on
API 29..30. - in protocol-rule mode, replace one IPv6 policy rule per active NAT66
listener protocol:- TCP listener present:
iif <downstream> priority <nat66-daemon-priority> ipproto tcp lookup 900 - UDP listener present:
iif <downstream> priority <nat66-daemon-priority> ipproto udp lookup 900
- TCP listener present:
- in fwmark fallback mode, replace one IPv6 policy rule:
iif <downstream> priority <nat66-daemon-priority> fwmark 0x10000000/0x10000000 lookup 900
Rollback:
- delete the same route, address, local route, and policy rule.
Clean:
- flush IPv6 routes from table 900.
- reconstruct
<nat66-prefix>and<nat66-gateway>for every current
interface from the Clean prefix seed, then delete the gateway address and
table 97 route for each interface.
Clean never flushes table 97 because it is Android's shared local_network
table.
Before routing the first candidate NAT66 TCP/UDP listener ports, routing probes
kernel FRA_IP_PROTO support through rtnetlink once per daemon process and
reuses the cached result for later NAT66 sessions. NAT66-enabled sessions with
no candidate TCP/UDP listener ports do not probe yet because there is no
listener interception rule to choose. The probe adds a temporary
detached-interface rule at <nat66-daemon-priority>:iif vpnhs_probe0 priority <nat66-daemon-priority> ipproto tcp lookup 900. Routing
then dumps IPv6 rules and requires the echoed rule to include ipproto tcp.
Normal probe cleanup deletes at most one rule: the exact protocol form when the
dump echoed it, or a selector without ipproto otherwise. The protocol-less
selector is also the best-effort rollback when the add or dump outcome is
uncertain. Missing state is accepted; other deletion failures are reported.
Repeated deletion of stale or duplicate rules is reserved for Clean. This
detached interface is intentional: kernels without FRA_IP_PROTO can silently
ignore the unknown attribute and accept a bare iif ... lookup 900 rule, so
probing with the real downstream would create a transient or leaked
traffic-affecting rule.
If the probe fails, routing uses fwmark fallback mode. When uname.release
parses as Linux 4.17 or newer, the fallback is also reported as a structured
nonfatal warning tied to the start-session call because upstream Linux has
supported FRA_IP_PROTO since 4.17. Older or unparsable releases use fallback
without that warning.
NAT66 Firewall Chains
Condition: SessionConfig.ipv6_nat != null and NAT66 startup committed at
least one TCP or UDP runtime capability.
External mutations:
- ensure
ip6tables -t filterchainvpnhotspot_v6_input - ensure
ip6tables -t filterchainvpnhotspot_v6_forward - ensure
ip6tables -t filterchainvpnhotspot_v6_output - ensure
ip6tables -t manglechainvpnhotspot_acl - ensure
ip6tables -t manglechainvpnhotspot_v6_acl_gate - ensure
ip6tables -t manglechainvpnhotspot_v6_protocols - ensure
ip6tables -t manglechainvpnhotspot_v6_tproxy
Session rollback:
- none for the chains themselves.
Clean:
- delete base jumps, then flush and delete these chains.
NAT66 Filter Rules
Condition: SessionConfig.ipv6_nat != null and NAT66 startup committed at
least one TCP or UDP runtime capability.
External mutations:
ip6tables -t filter -I vpnhotspot_v6_input -i <downstream> -j REJECTip6tables -t filter -I vpnhotspot_v6_input -i <downstream> -m socket --transparent -j ACCEPTip6tables -t filter -I vpnhotspot_v6_input -i <downstream> -p icmpv6 -j ACCEPTip6tables -t filter -I vpnhotspot_v6_forward -o <downstream> -j REJECTip6tables -t filter -I vpnhotspot_v6_forward -i <downstream> -j REJECTip6tables -t filter -I vpnhotspot_v6_output -o <downstream> -p icmpv6 --icmpv6-type 134 -j REJECTip6tables -t filter -I vpnhotspot_v6_output -o <downstream> -p icmpv6 --icmpv6-type 134 -m mark --mark 0x00030063/0x0003ffff -j ACCEPT
Rollback:
- delete the same rules.
These rules reject non-daemon IPv6 forwarding while allowing daemon-owned
transparent sockets and daemon-marked router advertisements. Because rules are
inserted with -I, rule order must be reviewed together with insertion order
when changing this list.
The mark value is DAEMON_REPLY_MARK/DAEMON_REPLY_MARK_MASK.
NAT66 ACL Gate
Condition: SessionConfig.ipv6_nat != null and NAT66 startup committed at
least one TCP or UDP runtime capability.
External mutation:
ip6tables -t mangle -I vpnhotspot_v6_acl_gate -i <downstream> -j vpnhotspot_acl
Rollback:
- delete the same rule.
The process-wide NAT66 base rule in vpnhotspot_acl drops packets that do not
return from the ACL chain through a client allow rule.
NAT66 ICMP Echo NFQUEUE
Condition: SessionConfig.ipv6_nat != null and NAT66 runtime reportedicmp_echo = true.
External mutation:
ip6tables -t mangle -I vpnhotspot_v6_protocols -i <downstream> -p icmpv6 --icmpv6-type echo-request ! -d <nat66-gateway> -j NFQUEUE --queue-num 30000
Rollback:
- delete the same rule.
Routing must omit this rule when ICMP registration failed. Packets must not be
queued unless the process-wide ICMP dispatcher has a live session registration
for the downstream interface. The rule is session-level rather than per-MAC
because NAT66 ACL admission has already run before protocol interception.
ICMP Echo interception intentionally uses NFQUEUE rather than TCP/UDP-style
TPROXY. ICMP has no destination port for a transparent listener, and the daemon
must drop the original queued Echo Request after copying it so it cannot continue
through another forwarding path alongside the daemon's translated probe.
The dispatcher attributes queued packets from NFQA_HWADDR. Missing
hardware-address metadata, non-six-byte hardware addresses, and MACs outside
the committed client set are dropped and reported as structured nonfatals. The
dispatcher must not fall back to source IPv6 neighbour lookup.
NAT66 TCP/UDP TPROXY
Condition: SessionConfig.ipv6_nat != null and NAT66 startup committed at
least one TCP or UDP runtime capability. Gateway DNS preludes and local/special
destination returns are session-level rules. Listener TPROXY rules are per
committed client MAC/protocol listener port. NAT66 gateway DNS and upstream
proxying share the same per-MAC listener; the daemon distinguishes gateway DNS
by the original destination. Local or special destinations should not enter the
daemon-owned upstream proxy path.
External mutations:
- gateway DNS TCP ACL/protocol prelude:
ip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -p tcp -d <nat66-gateway> --dport 53 -j vpnhotspot_v6_acl_gateip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -p tcp -d <nat66-gateway> --dport 53 -j vpnhotspot_v6_protocols
- gateway DNS UDP ACL/protocol prelude:
ip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -p udp -d <nat66-gateway> --dport 53 -j vpnhotspot_v6_acl_gateip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -p udp -d <nat66-gateway> --dport 53 -j vpnhotspot_v6_protocols
- local/special destination returns before the generic upstream ACL/proxy path:
ip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -d <nat66-prefix> -j RETURNip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -d fe80::/10 -j RETURNip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -d ff00::/8 -j RETURNip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -d ::/127 -j RETURN
- TCP listener:
ip6tables -t mangle -I vpnhotspot_v6_protocols -i <downstream> -p tcp -m mac --mac-source <mac> -j TPROXY --on-ip ::1 --on-port <nat66-tcp-port-for-mac>
- UDP listener:
ip6tables -t mangle -I vpnhotspot_v6_protocols -i <downstream> -p udp -m mac --mac-source <mac> -j TPROXY --on-ip ::1 --on-port <nat66-udp-port-for-mac>
Effective mangle order is gateway DNS :53 ACL gate, gateway DNS :53
protocol interception, local/special destination returns, generic upstream ACL
gate, then broad per-MAC listener TPROXY for the remaining TCP or UDP traffic.
Gateway DNS enters the same listener as ordinary upstream traffic, but the
daemon routes it to DNS handling from the original destination. This keeps local
traffic outside NAT66 admission while still applying the allow/block decision to
daemon-owned DNS and upstream proxying.
The command list above is the required effective order. Because iptables -I
inserts at the head by default, implementation must either insert with explicit
positions or apply rule mutations in the reverse order that produces the
effective chain order.
In fwmark fallback mode, the TPROXY rules also append--tproxy-mark 0x10000000/0x10000000.
Rollback:
- delete the same rules.
The TCP and UDP rules use --on-ip ::1, and the listeners bind to ::1.
Listener ports are internal TPROXY endpoints rather than downstream-reachable
service ports. Direct downstream local/special traffic to those ports does not
match the listener and falls through the base input reject path.
A per-MAC TCP or UDP listener is not committed unless the daemon listener,
required session-level local/special exclusions, base input filter rules,
gateway DNS preludes, and matching MAC-scoped listener TPROXY rule all exist.
If routing fails the rules required for that MAC/protocol, the staged listener
is cancelled and that MAC/protocol capability is omitted from the committed
session.
NAT66 ICMPv6 Control Returns
Condition: SessionConfig.ipv6_nat != null and NAT66 startup committed at
least one TCP or UDP runtime capability.
External mutations:
ip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -p icmpv6 --icmpv6-type 133 -j RETURNip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -p icmpv6 --icmpv6-type 135 -j RETURNip6tables -t mangle -I vpnhotspot_v6_tproxy -i <downstream> -p icmpv6 --icmpv6-type 136 -j RETURN
Rollback:
- delete the same rules.
These are Router Solicitation, Neighbor Solicitation, and Neighbor
Advertisement. They are local-link control traffic, not upstream NAT66 payload.
NAT66 Filter Base Jumps
Condition: SessionConfig.ipv6_nat != null and NAT66 startup committed at
least one TCP or UDP runtime capability.
External mutations:
ip6tables -t filter -I INPUT -j vpnhotspot_v6_inputip6tables -t filter -I FORWARD -j vpnhotspot_v6_forwardip6tables -t filter -I OUTPUT -j vpnhotspot_v6_output
Rollback:
- delete the same rules.
Clean deletes these repeatedly before flushing and deleting the target chains.
NAT66 TPROXY Base Jump
Condition: SessionConfig.ipv6_nat != null and NAT66 startup returned at least
one runtime capability.
External mutation:
ip6tables -t mangle -I PREROUTING -j vpnhotspot_v6_tproxy
Rollback:
- delete the same rule.
Clean deletes this repeatedly before flushing and deleting NAT66 mangle chains.
Client IPv4 Allow And Stats Rules
Condition: for each committed unique (client MAC, client IPv4 address) pair
in SessionConfig.clients.
External mutations:
iptables -t filter -I vpnhotspot_acl -i <downstream> -m mac --mac-source <mac> -s <client-ipv4> -j vpnhotspot_stats
Rollback:
- delete the same rule.
Condition: for each committed unique (client MAC, client IPv4 address) pair
in SessionConfig.clients.
External mutations:
iptables -t filter -I vpnhotspot_stats -i <downstream> -m mac --mac-source <mac> -s <client-ipv4> -j ACCEPTiptables -t filter -I vpnhotspot_stats -o <downstream> -d <client-ipv4> -j ACCEPT
Rollback:
- delete the same rules.
The IPv4 forwarding admission whitelist is the committed (MAC, IPv4) pair.
Sent-direction packets must match both the client MAC and IPv4 source before
entering the stats chain, and the stats leaf that increments the counter is also
the rule that accepts the packet. Reply-direction packets are counted by
destination IPv4 after the generic ESTABLISHED,RELATED ACL path sends them
through vpnhotspot_stats; the client MAC is not available as the Ethernet
source on replies. A committed MAC with no IPv4 address leaves has no IPv4
forwarding capability and can still be authorized for DNS or NAT66.
During session replacement, if a client IPv4 address remains committed but its
owning MAC changes, routing first deletes that address's twovpnhotspot_stats rules from the applied mutation set. Normal reconciliation
then reinserts the same rule shape for the new committed config, which resets
the kernel iptables counters before Kotlin associates the source with the new
MAC. Missing rules only clear the current process applied entry; command
execution failures are reported and the rest of reconciliation continues.
Client NAT66 Allow Rules
Condition: SessionConfig.ipv6_nat != null, NAT66 startup committed at least
one TCP or UDP runtime capability, and for each committed unique client MAC inSessionConfig.clients.
External mutation:
ip6tables -t mangle -I vpnhotspot_acl -m mac --mac-source <mac> -j RETURN
Rollback:
- delete the same rule.
The NAT66 base ACL drop rule is process-wide; client allow rules return before
that drop. NAT66 TCP/UDP rules still match MAC again to select that MAC's
listener port. NAT66 ICMPv6 uses the ACL result for admission, then the
dispatcher verifies the queued packet's NFQA_HWADDR against the same committed
MAC set before proxying or counting it.
Process-Wide NAT66 Firewall Base
control.rs callsensure_ipv6_nat_firewall_base before starting the first session that requests
NAT66. This is outside per-session desired state. Failure is reported as a
structured nonfatal tied to the start-session call and disables NAT66 for that
session start; the IPv4 session may still continue.
External mutations:
- ensure
ip6tables -t manglechainvpnhotspot_acl - ensure
ip6tables -t manglechainvpnhotspot_v6_acl_gate - ensure
ip6tables -t manglechainvpnhotspot_v6_protocols - ensure
ip6tables -t manglechainvpnhotspot_v6_tproxy ip6tables -t mangle -I vpnhotspot_acl -j DROPip6tables -t mangle -I vpnhotspot_v6_tproxy -j vpnhotspot_v6_protocolsip6tables -t mangle -I vpnhotspot_v6_tproxy -j vpnhotspot_v6_acl_gate
Because these rules are inserted with -I, session rules must preserve the
effective vpnhotspot_v6_tproxy order documented above: local-link ICMPv6
control returns, gateway DNS ACL/protocol handling, local/special destination
returns, generic ACL gate, then protocol interception. That ordering is part of
the NAT66 blocking contract: blocked MACs hit the base ACL drop before
daemon-owned DNS, upstream TCP/UDP TPROXY, or ICMPv6 NFQUEUE, while local or
special destinations outside those daemon-owned paths do not use the ACL as an
admission gate.
Process/session cleanup:
delete_ipv6_nat_firewall_basedeletes those three base rules in reverse
order.- Clean also flushes and deletes the NAT66 mangle chains after removing base
jumps.
Static Address Replacement
ReplaceStaticAddressesCommand is not session routing, but it is implemented
under routing/ and mutates interface addresses.
External mutations:
- for each requested address: replace address
<address>/ondevusing rtnetlink. - after dumping current addresses for the interface: delete every non-loopback
address whose(address, prefix_len)is not in the requested set.
Expected benign errors:
EEXISTwhile replacing a requested address is accepted.- missing address while deleting stale addresses is accepted.
This command reconciles one interface to the requested address set. It is not a
session rollback mechanism.
Clean Mutations
CleanRoutingCommand is deterministic cleanup. It does not use session memory.
Policy Rules And Routes
External mutations:
- repeatedly delete IPv6 policy rules at the NAT66 daemon priority, including
protocol rules, fwmark fallback rules, and any interrupted detached probe
rule. - repeatedly delete IPv4 policy rules at ``.
- repeatedly delete IPv4 policy rules at
<fallback-priority>. - repeatedly delete IPv4 policy rules at ``.
- flush IPv6 routes from table 900.
For every current interface name:
- reconstruct the deterministic NAT66 prefix from the Clean prefix seed and the
interface name. - delete address
<nat66-gateway>/from that interface. - delete table 97 unicast route
<nat66-prefix> dev.
Missing address or route is expected. For address deletion, this includes an
interface that still exists but has no IPv6 address state. Other errors are
reported as structured nonfatal cleanup reports.
IPv4 Firewall Cleanup
External mutations:
- repeatedly delete
iptables -t mangle PREROUTING -j vpnhotspot_dns_tproxy. - repeatedly delete
iptables -t filter INPUT -j vpnhotspot_dns_input. - repeatedly delete
iptables -t filter FORWARD -j vpnhotspot_acl. - repeatedly delete
iptables -t nat PREROUTING -j vpnhotspot_dns_nat. - repeatedly delete
iptables -t nat POSTROUTING -j vpnhotspot_masquerade. - restore IPv4 mangle table input that flushes or creates
vpnhotspot_dns_tproxy, then deletes it. - restore IPv4 filter table input that flushes or creates
vpnhotspot_dns_input,vpnhotspot_acl, andvpnhotspot_stats, then
deletes them. - restore IPv4 nat table input that flushes or creates
vpnhotspot_dns_nat
andvpnhotspot_masquerade, then deletes them.
The IPv4 mangle vpnhotspot_dns_tproxy cleanup is legacy-shaped cleanup kept
in Clean. IPv4 nat cleanup must stay scoped to app-owned jumps and chains.
IPv6 Firewall Cleanup
External mutations:
- repeatedly delete IPv6 block jumps:
ip6tables -t filter INPUT -j vpnhotspot_filterip6tables -t filter FORWARD -j vpnhotspot_filterip6tables -t filter OUTPUT -j vpnhotspot_filter
- repeatedly delete NAT66 filter jumps:
ip6tables -t filter INPUT -j vpnhotspot_v6_inputip6tables -t filter FORWARD -j vpnhotspot_v6_forwardip6tables -t filter OUTPUT -j vpnhotspot_v6_output
- repeatedly delete NAT66 mangle jump:
ip6tables -t mangle PREROUTING -j vpnhotspot_v6_tproxy
- restore IPv6 filter table input that flushes or creates, then deletes:
vpnhotspot_filtervpnhotspot_v6_inputvpnhotspot_v6_forwardvpnhotspot_v6_output
- restore IPv6 mangle table input that flushes or creates, then deletes:
vpnhotspot_aclvpnhotspot_v6_acl_gatevpnhotspot_v6_protocolsvpnhotspot_v6_tproxy
Missing rules or chains are expected after partial startup or prior cleanup.
Unexpected restore failures are reported.
Priority And Table Model
VPNHotspot policy rules live in the gap between AOSP local-network and
tethering rules. The code names four base priorities:
| Role | Android 12+ | Android 10/11 |
|---|---|---|
| NAT66 daemon table lookup | 20600 |
17600 |
| Primary upstream table lookup | 20700 |
17700 |
| Fallback upstream table lookup | 20800 |
17800 |
| Downstream unreachable guard | 20900 |
17900 |
Android 10 and 11 use the same bases minus 3000 because AOSP local-network
and tethering priorities were lower before Android 12.
The daemon uses these table conventions:
- table 900 is VPNHotspot's daemon table for NAT66 local interception;
- table 97 is Android's shared
local_networkroute table; - upstream interface tables use Android's
ifindex + 1000convention.
Table 900 may be flushed by Clean because it is reserved by VPNHotspot. Table
97 must not be flushed; delete only reconstructed VPNHotspot NAT66 routes from
it.
Guardrails
- Do not add a route, rule, address, mark, chain, or firewall rule without
adding it to this mutation catalog. - Do not make cleanup depend on app preferences, databases, caches, or daemon
memory for state that can outlive the process. - Do not install interception for a DNS or NAT66 runtime capability that failed
to start. - Do not publish a per-MAC DNS or NAT66 capability unless both the daemon
resource and matching routing rule committed. Cancel staged resources whose
rules failed. - Do not disable netd NAT unless the daemon has an app-owned ownership token for
the exact state being disabled. - Do not flush shared platform tables or shared firewall base chains.
File: docs/vpnhotspotd/traffic.md
Traffic Accounting And Blocking
Traffic control is MAC-facing. IP addresses are implementation metadata, not
the product identity.
This document describes the daemon-side accounting and admission model. The
database schema still stores TrafficRecord.ip and TrafficRecord.upstream for
compatibility, but the live identity used for blocking, accounting, and UI
aggregation is:
- client MAC address;
- downstream interface;
- counter source.
Scope
Blocking and counters apply only at VPNHotspot-owned upstream boundaries:
- IPv4 forwarding and NAT rules installed by VPNHotspot;
- DNS queries intercepted by VPNHotspot and sent through Android's resolver API;
- NAT66 traffic proxied by the Rust daemon.
Traffic that stays local to the downstream network is unrestricted and
uncounted by this feature. Native or system-owned IPv6 forwarding outside the
daemon NAT66 proxy is also uncounted.
Client authorization is whitelist based. A downstream MAC that is not in the
committed client set must not enter an upstream forwarding, DNS proxy, or NAT66
proxy path. The daemon must not recover client identity by looking up source IP
addresses in neighbour state.
Client Identity
ClientConfig entries are keyed by MAC and may have an empty IPv4 list. A
client can be admitted for DNS and NAT66 from a valid ARP or NDP neighbour that
has a six-byte link-layer address; an IPv4 address is not required for IPv6
NAT66 proxying or accounting.
IPv4 forwarding still uses real client IPv4 addresses as hidden kernel counter
leaves. A committed (MAC, IPv4) pair is required for sent-direction IPv4
forwarding, and the IPv4 address keeps reply-direction counters accurate
without using Android connmark bits. Neither value becomes the UI identity.
Daemon-owned MAC-only sources persist with TrafficRecord.ip = 0.0.0.0 and a
reserved source marker in TrafficRecord.upstream:
| Source | Stored upstream marker |
|---|---|
| DNS resolver proxy | /dns |
| NAT66 TCP proxy | /nat66/tcp |
| NAT66 UDP proxy | /nat66/udp |
| NAT66 ICMPv6 Echo/error proxy | /nat66/icmpv6 |
These markers are opaque compatibility payloads. They are not upstream
interface names and must not be shown as user-visible upstreams.
Admission Handoff
DNS and NAT66 TCP/UDP use per-MAC listener ownership:
- each allowed MAC gets its own daemon resources for each started protocol
capability; - routing installs MAC-matched DNAT or TPROXY rules to that MAC's listener
ports; - accepted DNS queries, TCP connections, UDP associations, and NAT66 gateway
DNS special cases inherit the MAC from the listener they entered through.
The listener port is not a client-controlled identity token. IPv4 DNS listener
ports accept only packets whose conntrack original destination was the
downstream gateway on port 53. NAT66 listener ports are internal ::1 TPROXY
endpoints reached only through the MAC-matched TPROXY path; direct local or
special-destination traffic to those ports does not reach the listener and
falls through the input reject path.
NAT66 ICMPv6 Echo uses one process-wide NFQUEUE path. The queue number is30000. Routing queues eligible Echo Requests only after the NAT66 ACL has
admitted the source MAC. The ICMP dispatcher then requires NFQA_HWADDR to
carry exactly six source-MAC bytes, and that MAC must still belong to the
committed session client set.
If queued ICMP lacks usable hardware-address metadata, carries a non-six-byte
address, or names a MAC that is no longer allowed, the daemon drops the packet,
reports a structured nonfatal, and does not count it. There is no source-IPv6
fallback.
Counter Sources
The daemon reports cumulative structured counters. Kotlin maps each counter
source to the existing persistence shape:
- IPv4 forwarding: store the real IPv4 address and no source marker in
upstream; - daemon-owned DNS or NAT66 source: store
ip = 0.0.0.0and the reserved source
marker above.
The structured counter source is the stable active-recorder key. The persistedupstream marker is only the current no-migration storage representation.
Routing keeps the recorder's active client set in sync with allowed MACs, while
IPv4 neighbour entries only add or remove IPv4 forwarding rows. A client losing
its IPv4 neighbour therefore loses IPv4 forwarding until ARP returns, but does
not stop DNS or NAT66 polling while the MAC is still allowed.
When duplicate IPv4 forwarding counter rules exist after interrupted cleanup,
the daemon keeps the first matching iptables rule per direction because that is
the rule whose counters the kernel updates.
Packet counters are meaningful only for sources with discrete messages:
- DNS UDP: one sent packet per resolver query and one received packet per
resolver response; - DNS TCP: one sent or received unit per framed DNS message;
- NAT66 UDP: one sent or received packet per UDP datagram;
- NAT66 ICMPv6: one sent or received packet per daemon-owned Echo or upstream
ICMPv6 error message; - NAT66 TCP: sent packet count is a connection count for successfully opened
upstream TCP connections; byte counters carry the actual stream volume.
Byte counters are authoritative for all daemon-owned sources. DNS counts the
payload bytes handed to and returned from Android's resolver API. NAT66 counts
daemon-owned upstream socket I/O at the proxy boundary; it does not estimate IP
headers, TCP handshakes, segmentation, retransmits, DNS-over-TLS transport, or
physical egress behavior below Android's resolver/network APIs.
Client-visible DNS errors generated by the daemon are not resolver API return
traffic. They are not counted unless a query was actually handed to Android's
resolver; in that case the sent query side may be counted without a received
response side.
Recorder Chain Boundaries
Traffic records are chained only while Kotlin keeps an active recorder entry for
the same MAC, downstream interface, and counter source. previousId links each
new cumulative sample to the previous row in that active chain.
Kotlin ends a chain by flushing the latest counters and removing the active
recorder entry. If the same MAC/downstream/source appears later, the recorder
starts a new unlinked chain. Query aggregation sums terminal rows, so historical
totals remain visible without attaching reset cumulative counters to old
history.
Removed per-MAC runtime sources move their final counters into a retired
snapshot. A traffic counter read returns active plus retired counters and then
drops the retired entries. Retired delivery is at-most-once; normal stop,
replacement, and routing teardown paths must still read counters before
destroying daemon-owned state.
The control path enqueues counter reads while holding the session control slot.
Stop and Clean take the same slot before enqueuing stop, so any read that has
observed the session as active is ordered in the session owner before a later
stop can destroy counters.
Failure Semantics
Per-MAC DNS and NAT66 TCP/UDP setup is capability scoped. If one MAC/protocol
listener or its matching routing rule fails, the daemon reports a structured
nonfatal with downstream, MAC, and capability, cancels any staged resource for
that failed capability, and keeps the rest of the session running.
A per-MAC capability is committed only after the daemon resource and matching
routing rule both exist. The committed session must not keep a hidden listener
with no reachable rule, and routing must not keep a rule whose daemon resource
was rolled back.
IPv4 client removal removes allow rules and hidden IPv4 counter leaves. If an
IPv4 address remains present but moves to a different MAC, routing deletes the
address's hidden counter leaves before reconciliation so the reinserted leaves
start fresh kernel counters for the new owner. The daemon does not manage
conntrack state. DNS and NAT66 are daemon-owned, so removing a MAC cancels that
MAC's DNS children, TCP tasks, UDP associations, ICMP Echo allocations, and
ICMP/UDP error registrations.
If IPv4 forwarding counter readout fails, the daemon reports a structured
nonfatal tied to the traffic-counter call and still replies with any
daemon-owned DNS/NAT66 counters it already snapshotted. IPv4 forwarding counters
live in kernel rules, so later reads can recover them while those rules still
exist; daemon-owned retired counters are more volatile and must not be discarded
because the IPv4 read failed.
File: external/aosp/README.md
AOSP submodules
hardware/interfaces is pinned to AOSP commit0162af698935100a590b7359581ac8b1b80693e5, selected by theandroid-latest-release manifest for android17-release.
VPNHotspot uses its frozen AIDL snapshots as source for a generated client-side
Binder proxy:
wifi/supplicant/aidl/aidl_api/android.hardware.wifi.supplicant/5wifi/common/aidl/aidl_api/android.hardware.wifi.common/2
Refresh this submodule to the latest frozen VINTF version only when the runtime
path intentionally starts using newer transactions.