refuse tls hellos that carry encrypted client hello before squid sees them
This commit is contained in:
parent
de43dac33c
commit
dff662c6b3
2 changed files with 1537 additions and 43 deletions
|
|
@ -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" ];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue