Watch
0
0
Fork
You've already forked hyperhive
0

refuse tls hellos that carry encrypted client hello before squid sees them

This commit is contained in:
atlas 2026-11-13 06:23:42 +01:00 • committed by mara
commit dff662c6b3
2 changed files with 1537 additions and 43 deletions

View file

@ -13,10 +13,10 @@
# the host routes from the agent bridge to anywhere else is logged and
# counted, never blocked. `intercept` (`egress.intercept.enable`, set by
# hive-squid.nix): the web traffic of the bridge's agents is redirected into
# squid's intercept ports. The traffic of the ports in the set `direct_mac` is
# left alone: the service containers and the unrestricted agents. `hive-priv
# egress-pin` and `egress-load` fill that set from the bindings the bridge
# table uses, so a MAC in no binding is intercepted, and squid refuses the flow.
# squid's intercept ports (port 443 through the ClientHello relay). The ports in
# `direct_mac` are left alone: the service containers and the unrestricted
# agents. `hive-priv egress-pin` and `egress-load` fill that set from the bridge
# table's bindings, so a MAC in no binding is intercepted, and squid refuses it.
#
# The `bridge` table checks the source address of every frame a port of the
# agent bridge sends (`egress.antiSpoof`). It is a `bridge`-family table because
@ -42,6 +42,19 @@ let
# The mesh interface (swarm-wireguard.nix): inter-hive traffic is not egress.
meshInterface = "wg-hive";
# With `refuseEch`, the redirect of port 443 ends at `hive-priv
# egress-ech-relay`, which refuses a ClientHello that carries the ECH
# extension and sends the rest on to squid's HTTPS intercept port. The relay
# connects to the address the agent dialed, bound to a source address of the
# pool and carrying the mark; the `output` chain DNATs those connections to
# squid. `egress_ech.rs` derives the pool address from the agent's and
# `egress-whois` maps it back, so the pool is the one defined there.
intercept = cfg.egress.intercept;
refuseEch = intercept.enable && intercept.refuseEch;
relayPool = "198.18.0.0/15";
relayMark = "0x45434800";
relayCommand = "${hivePriv} egress-ech-relay ${cfg.bridgeIp}:${toString intercept.relayPort} ${relayMark}";
# The sub-commands of hive-priv that fill the bridge table's sets. The path is
# the one every platform container's unit uses (hive-priv.nix points it at the
# current build).
@ -85,9 +98,9 @@ let
# the interface the route to the destination leaves by: the bridge (another
# container), `lo` (an address of the host: `fib daddr type local`) and the
# mesh interface are not egress. The redirect rewrites the destination to
# the bridge address and the port of squid's intercept listener, which reads
# the original destination from conntrack. IPv6 is left alone: the agent
# bridge has no IPv6 addressing.
# the bridge address and the port of squid's intercept listener (for port 443
# with `refuseEch`, of the relay), which reads the original destination from
# conntrack. IPv6 is left alone: the agent bridge has no IPv6 addressing.
interceptChain = ''
chain intercept {
type nat hook prerouting priority dstnat; policy accept;
@ -95,7 +108,16 @@ let
iifname "${br}" meta nfproto ipv4 fib daddr type local return
iifname "${br}" meta nfproto ipv4 fib daddr oifname { "${br}", "${meshInterface}" } return
iifname "${br}" meta nfproto ipv4 tcp dport 80 counter redirect to :${toString cfg.egress.intercept.httpPort}
iifname "${br}" meta nfproto ipv4 tcp dport 443 counter redirect to :${toString cfg.egress.intercept.httpsPort}
iifname "${br}" meta nfproto ipv4 tcp dport 443 counter redirect to :${
toString (if refuseEch then intercept.relayPort else intercept.httpsPort)
}
}
''
+ lib.optionalString refuseEch ''
chain intercept_relay {
type nat hook output priority dstnat; policy accept;
meta nfproto ipv4 ip saddr ${relayPool} meta mark ${relayMark} tcp dport 443 counter dnat ip to ${cfg.bridgeIp}:${toString intercept.httpsPort}
}
'';
@ -206,6 +228,7 @@ let
type filter hook input priority filter - 10; policy accept;
iifname "${br}" ip daddr 127.0.0.0/8 counter drop
iifname "${br}" ip6 daddr ::1 counter drop
${lib.optionalString refuseEch ''iifname "${br}" ip saddr ${relayPool} counter drop''}
}
set direct_mac { type ether_addr; }
${lib.optionalString cfg.egress.intercept.enable interceptChain}
@ -295,8 +318,55 @@ in
type = lib.types.port;
default = 3130;
description = ''
The port on the bridge address where squid listens for the redirected
TCP port 443 traffic.
The port on the bridge address where squid listens for TCP port 443
traffic: what the ClientHello relay sends on, or with `refuseEch` off
the redirected traffic itself.
'';
};
relayPort = lib.mkOption {
type = lib.types.port;
default = 3131;
description = ''
The port on the bridge address where `hive-priv egress-ech-relay`
listens for the redirected TCP port 443 traffic (`refuseEch`).
'';
};
refuseEch = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Refuse a TLS ClientHello that carries the `encrypted_client_hello`
extension on the redirected port 443 traffic. Squid 7.6 sees only the
outer name of such a hello, so without this an agent that holds an ECH
configuration could dial an allowed name and reach another site behind
the same address.
`hive-priv egress-ech-relay` reads the first TLS record of each
redirected connection and, if the hello is clean, sends the connection
on to squid. The extension of a client with no configuration (GREASE,
which Chrome and Firefox send by default) looks the same as a real one on
the wire, so it is refused too: those clients fail on the redirected
path and work through the proxy variables. An agent in `log` mode is
observed instead: its hello goes on and the relay logs
`hive-egress-ech-would-refuse`. Turn this off to send port 443
straight to squid, with ECH unchecked.
'';
};
agentAllowFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The squid include `hive-c0re` renders with each agent's own rules, in
which an agent in `log` mode has an `acl agent_log_<name> proxy_auth
<name>` line. The ClientHello relay (`refuseEch`) reads it to tell a
`log` agent, whose ECH hello is let through and logged as
`hive-egress-ech-would-refuse`, from a restricted one, whose hello is
refused. Squid reads the same file for the same split. `null` or an
unreadable file lists no `log` agent, so every agent's ECH hello is
refused.
'';
};
@ -349,6 +419,14 @@ in
Use "log" or "accept".
'';
}
{
assertion = !refuseEch || lib.toInt (lib.last (lib.splitString "/" cfg.networkCidr)) >= 15;
message = ''
services.hyperhive.network.bridgePrefixLength must be 15 or more while
egress.intercept.refuseEch is on: the relay names a flow's source with
the low 17 bits of the agent's address.
'';
}
{
assertion = !(lib.any (k: isOn (config.boot.kernel.sysctl.${k} or 0)) routeLocalnetKeys);
message = ''
@ -360,6 +438,53 @@ in
}
];
services.hyperhive.network.exposeHostPorts = lib.mkIf refuseEch [ intercept.relayPort ];
# The relay is not part of the tables: with it down, the redirect of port 443
# reaches a closed port and the agent's connection is refused.
systemd.services.hive-egress-ech-relay = lib.mkIf refuseEch {
description = "refuse TLS hellos with encrypted client hello on the way to squid's HTTPS intercept port";
wantedBy = [ "multi-user.target" ];
wants = [ "hive-egress.service" ];
after = [
"hive-egress.service"
"systemd-tmpfiles-setup.service"
"systemd-tmpfiles-resetup.service"
];
environment = lib.optionalAttrs (intercept.agentAllowFile != null) {
HIVE_EGRESS_AGENT_ALLOW = toString intercept.agentAllowFile;
};
serviceConfig = {
# The pool is local, so the relay can bind to a source address in it.
ExecStartPre = "+${pkgs.iproute2}/bin/ip route replace local ${relayPool} dev lo";
ExecStart = relayCommand;
ExecStopPost = "+-${pkgs.iproute2}/bin/ip route del local ${relayPool} dev lo";
# The bridge address may not be assigned yet at first start.
Restart = "on-failure";
RestartSec = 5;
# The packet mark needs CAP_NET_ADMIN. The include is `squid`'s group's.
DynamicUser = true;
SupplementaryGroups = lib.optional (intercept.agentAllowFile != null) "squid";
AmbientCapabilities = "CAP_NET_ADMIN";
CapabilityBoundingSet = "CAP_NET_ADMIN";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
RestrictAddressFamilies = "AF_INET";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
SystemCallArchitectures = "native";
};
};
systemd.services.hive-egress = {
description = "hive nftables tables (agent bridge egress log and source check, host loopback drop)";
wantedBy = [ "multi-user.target" ];

View file

@ -25,6 +25,8 @@
# `hive_agent`); a source it cannot name is refused. The listed-names and deny
# rules are the same ones, matched on the identity from that helper. HTTPS is
# peeked at for its SNI and spliced: squid checks the name and never decrypts.
# Squid cannot see an ECH extension, so the flows to port 443 reach it through
# `hive-priv egress-ech-relay`, which refuses a hello that carries one.
{
pkgs,
lib,
@ -551,7 +553,7 @@ in
];
services.hyperhive.network.egress.intercept = {
enable = lib.mkDefault true;
inherit (cfg) unrestrictedFile;
inherit (cfg) unrestrictedFile agentAllowFile;
};
services.hyperhive.network.enable = lib.mkDefault true;