salmon-ops-recipes-0.2.0.0: src/SreBox/WireGuardMesh.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{- | A WireGuard mesh among hosts that have stable endpoints, declared once and
unfolded per host. See @specs\/wireguard-mesh.md@ for the whole design.
= Shape
The /seed/ ('MeshSeed') is the one declaration: peers (a declared address, a
public key collected out of band, an optional endpoint, groups), policies
(group to group, protocol, ports) and routers (a peer that also carries a
network, or the default route). 'genHost' is a __pure__ fan-out of that seed
to one host's view ('HostSpec'): its interface, its peers with their
allowed-ips, its routes and its nft rules. 'hostOp' turns a 'HostSpec' into
an 'Op'. Nothing in between touches the network or the filesystem, so the
table of seeds to host specs is a Layer 0 test.
= Delivery
'hostDocument' wraps a host's 'HostSpec' as a pull-mode document (one inline
directive), to be signed and published under a label named for the host and
followed with @run serve --follow ... --label HOST@. A peer added to or
removed from the seed is then a small diff of nodes the ledger brings up and
down, which is why "Salmon.Builtin.Nodes.WireGuard"'s @peer@ has a @down@.
= What this recipe does not do
* It ships no secret. Each host generates its own private key where it is
(@WG.privateKey@, kept, never sent); a public key is not a secret and is
carried inline in the seed.
* A peer with no endpoint dials out and sets a persistent keepalive; it is
reached only through peers that have one. Two peers that both lack an
endpoint have no link. There is no NAT traversal and no roaming.
* An endpoint given as a host name is resolved by WireGuard when the peer is
configured. Following a changed address is left to the peer node's
@check@ and the tending loop, not done here.
* Policies are enforced on the input of the mesh interface (traffic addressed
to the host). Traffic a router forwards between the mesh and elsewhere is
governed by the router's forward chain (mesh-interface traffic only), not
by policies.
* IPv4 only. An exit router's endpoint must be an IPv4 literal, because the
route pinning it off the tunnel needs an address, not a name.
-}
module SreBox.WireGuardMesh (
-- * The declaration
MeshSeed (..),
PeerDecl (..),
Policy (..),
Proto (..),
PortRange (..),
RouterDecl (..),
-- * The per-host view
HostSpec (..),
PeerLink (..),
NftRuleSpec (..),
MeshError (..),
renderError,
validate,
genHost,
genAll,
-- * Delivery
hostDocument,
-- * The node
hostOp,
Report,
Binaries (..),
-- * Exposed for tests
parseIpv4,
parseCidr,
inSubnet,
) where
import Data.Aeson (FromJSON, ToJSON, toJSON)
import Data.Bits (complement, shiftL, (.&.), (.|.))
import Data.List (nub, sort)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Word (Word32)
import GHC.Generics (Generic)
import Text.Read (readMaybe)
import Salmon.Actions.Follow (Document (..), Entry (..))
import Salmon.Builtin.Extension
import qualified Salmon.Builtin.Nodes.Netfilter as Nft
import qualified Salmon.Builtin.Nodes.Routes as IpRoute
import qualified Salmon.Builtin.Nodes.Sysctl as Sysctl
import qualified Salmon.Builtin.Nodes.WireGuard as WG
import Salmon.Op.OpGraph (inject)
import Salmon.Op.Track
import Salmon.Reporter
import SreBox.WireGuardVpn (Binaries (..), Report (..), pinHostRoute)
-------------------------------------------------------------------------------
-- the declaration
data MeshSeed = MeshSeed
{ mesh_subnet :: Text
-- ^ the mesh's network, e.g. @10.66.0.0/24@
, mesh_iface :: Text
, mesh_port :: Int
-- ^ the UDP port every host listens on
, mesh_privkey_path :: FilePath
-- ^ where each host keeps (and, if absent, generates) its private key
, mesh_peers :: [PeerDecl]
, mesh_policies :: [Policy]
, mesh_routers :: [RouterDecl]
}
deriving (Eq, Show, Generic)
instance FromJSON MeshSeed
instance ToJSON MeshSeed
data PeerDecl = PeerDecl
{ peer_name :: Text
, peer_addr :: Text
-- ^ a bare IPv4 address inside the subnet, declared and never derived from
-- position, so adding or removing a peer renumbers nobody
, peer_pubkey :: Text
-- ^ base64, collected out of band
, peer_endpoint :: Maybe Text
-- ^ @host:port@, host a literal or a name
, peer_groups :: [Text]
}
deriving (Eq, Show, Generic)
instance FromJSON PeerDecl
instance ToJSON PeerDecl
data Proto = Tcp | Udp | Icmp | AnyProto
deriving (Eq, Show, Generic)
instance FromJSON Proto
instance ToJSON Proto
data PortRange = PortRange {port_from :: Int, port_to :: Int}
deriving (Eq, Show, Generic)
instance FromJSON PortRange
instance ToJSON PortRange
data Policy = Policy
{ policy_from :: [Text]
-- ^ groups
, policy_to :: [Text]
-- ^ groups
, policy_protocol :: Proto
, policy_ports :: [PortRange]
-- ^ only for 'Tcp' and 'Udp'; none means every port
}
deriving (Eq, Show, Generic)
instance FromJSON Policy
instance ToJSON Policy
{- | A peer that also reaches a network. @0.0.0.0/0@ makes it the mesh's exit
node; at most one router may be that.
-}
data RouterDecl = RouterDecl
{ router_peer :: Text
, router_network :: Text
}
deriving (Eq, Show, Generic)
instance FromJSON RouterDecl
instance ToJSON RouterDecl
-------------------------------------------------------------------------------
-- the per-host view
-- | One peer as a host configures it.
data PeerLink = PeerLink
{ link_name :: Text
, link_pubkey :: Text
, link_endpoint :: Maybe Text
, link_allowed_ips :: [Text]
, link_keepalive :: Maybe Int
}
deriving (Eq, Show, Generic)
instance FromJSON PeerLink
instance ToJSON PeerLink
-- | A rule in the host's mesh input chain, as the words handed to @nft@.
newtype NftRuleSpec = NftRuleSpec {rule_words :: [Text]}
deriving (Eq, Show, Generic)
instance FromJSON NftRuleSpec
instance ToJSON NftRuleSpec
data HostSpec = HostSpec
{ host_name :: Text
, host_iface :: Text
, host_addr :: Text
-- ^ CIDR, the host's address with the mesh's prefix length
, host_port :: Int
, host_privkey_path :: FilePath
, host_peers :: [PeerLink]
, host_routes :: [Text]
-- ^ networks to send over the interface (besides the mesh itself)
, host_exit_pins :: [Text]
-- ^ addresses kept off the tunnel (the endpoints of exit routers)
, host_forwards :: Maybe Text
-- ^ for a router, the mesh subnet it masquerades when it leaves elsewhere
, host_input_rules :: [NftRuleSpec]
-- ^ accepted traffic on the mesh interface, in order, after
-- established/related and before the final drop
}
deriving (Eq, Show, Generic)
instance FromJSON HostSpec
instance ToJSON HostSpec
data MeshError
= BadSubnet Text
| BadAddress Text Text
| AddressOutsideSubnet Text Text
| DuplicatePeerName Text
| DuplicateAddress Text [Text]
| DuplicatePubkey [Text]
| BadEndpoint Text Text
| UnknownGroup Int Text
| BadPorts Int Text
| UnknownRouterPeer Text
| BadNetwork Text Text
| DuplicateRouterNetwork Text
| SeveralExitRouters [Text]
| ExitEndpointNotLiteral Text
| UnknownHost Text
deriving (Eq, Show)
renderError :: MeshError -> Text
renderError e = case e of
BadSubnet s -> "mesh subnet is not an IPv4 CIDR: " <> s
BadAddress p a -> "peer " <> p <> " has an address that is not IPv4: " <> a
AddressOutsideSubnet p a -> "peer " <> p <> " has address " <> a <> " outside the mesh subnet"
DuplicatePeerName p -> "two peers are named " <> p
DuplicateAddress a ps -> "address " <> a <> " is declared by several peers: " <> Text.intercalate ", " ps
DuplicatePubkey ps -> "peers share one public key: " <> Text.intercalate ", " ps
BadEndpoint p ep -> "peer " <> p <> " has an endpoint that is not host:port: " <> ep
UnknownGroup i g -> "policy #" <> tshow i <> " names a group no peer belongs to: " <> g
BadPorts i why -> "policy #" <> tshow i <> ": " <> why
UnknownRouterPeer p -> "router names an unknown peer: " <> p
BadNetwork p n -> "router " <> p <> " has a network that is not an IPv4 CIDR: " <> n
DuplicateRouterNetwork n -> "network " <> n <> " is carried by several routers"
SeveralExitRouters ps -> "several peers are the exit node: " <> Text.intercalate ", " ps
ExitEndpointNotLiteral p -> "exit router " <> p <> " needs an endpoint that is an IPv4 literal (its route is pinned by address)"
UnknownHost h -> "no such peer in the seed: " <> h
tshow :: Show a => a -> Text
tshow = Text.pack . show
-------------------------------------------------------------------------------
-- pure IPv4
parseIpv4 :: Text -> Maybe Word32
parseIpv4 t = case traverse readOctet (Text.splitOn "." t) of
Just [a, b, c, d] -> Just ((a `shiftL` 24) .|. (b `shiftL` 16) .|. (c `shiftL` 8) .|. d)
_ -> Nothing
where
readOctet o
| Text.null o || Text.any (`notElem` ['0' .. '9']) o || Text.length o > 3 = Nothing
| otherwise = case readMaybe (Text.unpack o) :: Maybe Word32 of
Just n | n <= 255 -> Just n
_ -> Nothing
-- | An address and a prefix length.
parseCidr :: Text -> Maybe (Word32, Int)
parseCidr t = case Text.splitOn "/" t of
[a, p] -> do
addr <- parseIpv4 a
n <- if Text.all (`elem` ['0' .. '9']) p && not (Text.null p) then readMaybe (Text.unpack p) else Nothing
if n >= 0 && n <= 32 then Just (addr, n) else Nothing
_ -> Nothing
mask :: Int -> Word32
mask 0 = 0
mask n = complement 0 `shiftL` (32 - n)
-- | Is the address inside the (address, prefix) network?
inSubnet :: (Word32, Int) -> Word32 -> Bool
inSubnet (net, n) a = (a .&. mask n) == (net .&. mask n)
-- | @host:port@ with a port in range.
parseEndpoint :: Text -> Maybe (Text, Int)
parseEndpoint t =
let (h, rest) = Text.breakOnEnd ":" t
host = Text.dropEnd 1 h
in do
port <- if Text.all (`elem` ['0' .. '9']) rest && not (Text.null rest) then readMaybe (Text.unpack rest) else Nothing
if not (Text.null host) && port >= 1 && port <= 65535 then Just (host, port) else Nothing
-------------------------------------------------------------------------------
-- validation
duplicates :: Ord a => [a] -> [a]
duplicates xs = Map.keys (Map.filter (> (1 :: Int)) (Map.fromListWith (+) [(x, 1) | x <- xs]))
-- | Every refusal at once; empty when the seed is sound.
validate :: MeshSeed -> [MeshError]
validate s = subnetErrs <> nameErrs <> addrErrs <> keyErrs <> endpointErrs <> policyErrs <> routerErrs
where
peers = s.mesh_peers
msubnet = parseCidr s.mesh_subnet
subnetErrs = [BadSubnet s.mesh_subnet | Nothing <- [msubnet]]
nameErrs = DuplicatePeerName <$> duplicates (peer_name <$> peers)
addrErrs =
concat
[ case parseIpv4 p.peer_addr of
Nothing -> [BadAddress p.peer_name p.peer_addr]
Just a -> [AddressOutsideSubnet p.peer_name p.peer_addr | Just net <- [msubnet], not (inSubnet net a)]
| p <- peers
]
<> [DuplicateAddress a [p.peer_name | p <- peers, p.peer_addr == a] | a <- duplicates (peer_addr <$> peers)]
keyErrs = [DuplicatePubkey [p.peer_name | p <- peers, p.peer_pubkey == k] | k <- duplicates (peer_pubkey <$> peers)]
endpointErrs = [BadEndpoint p.peer_name ep | p <- peers, Just ep <- [p.peer_endpoint], Nothing <- [parseEndpoint ep]]
groups = nub (concatMap peer_groups peers)
policyErrs =
concat
[ [UnknownGroup i g | g <- pol.policy_from <> pol.policy_to, g `notElem` groups]
<> portErrs i pol
| (i, pol) <- zip [1 ..] s.mesh_policies
]
portErrs i pol =
[BadPorts i "ports given for a protocol without ports" | pol.policy_protocol `elem` [Icmp, AnyProto], not (null pol.policy_ports)]
<> [ BadPorts i ("bad port range " <> tshow lo <> "-" <> tshow hi)
| PortRange lo hi <- pol.policy_ports
, lo < 1 || hi > 65535 || lo > hi
]
<> [BadPorts i "a policy needs at least one group on each side" | null pol.policy_from || null pol.policy_to]
routerErrs =
concat
[ [UnknownRouterPeer r.router_peer | r.router_peer `notElem` (peer_name <$> peers)]
<> [BadNetwork r.router_peer r.router_network | Nothing <- [parseCidr r.router_network]]
| r <- s.mesh_routers
]
<> (DuplicateRouterNetwork <$> duplicates (router_network <$> s.mesh_routers))
<> [SeveralExitRouters exits | let exits = [r.router_peer | r <- s.mesh_routers, isExit r.router_network], length exits > 1]
<> [ ExitEndpointNotLiteral r.router_peer
| r <- s.mesh_routers
, isExit r.router_network
, Just p <- [lookupPeer r.router_peer]
, not (maybe False (isLiteral . fst) (p.peer_endpoint >>= parseEndpoint))
]
lookupPeer n = case [p | p <- peers, p.peer_name == n] of (p : _) -> Just p; [] -> Nothing
isLiteral h = parseIpv4 h /= Nothing
isExit :: Text -> Bool
isExit n = n == "0.0.0.0/0"
-------------------------------------------------------------------------------
-- fan-out
-- | The view of every host, or every refusal.
genAll :: MeshSeed -> Either [MeshError] [HostSpec]
genAll s = case validate s of
[] -> Right [genChecked s p | p <- s.mesh_peers]
errs -> Left errs
-- | One host's view, or every refusal (an unknown host included).
genHost :: MeshSeed -> Text -> Either [MeshError] HostSpec
genHost s name = case validate s of
[] -> case [p | p <- s.mesh_peers, p.peer_name == name] of
(p : _) -> Right (genChecked s p)
[] -> Left [UnknownHost name]
errs -> Left errs
-- | Fan-out for a seed already validated.
genChecked :: MeshSeed -> PeerDecl -> HostSpec
genChecked s me =
HostSpec
{ host_name = me.peer_name
, host_iface = s.mesh_iface
, host_addr = me.peer_addr <> "/" <> prefixLen
, host_port = s.mesh_port
, host_privkey_path = s.mesh_privkey_path
, host_peers = links
, host_routes = routes
, host_exit_pins = pins
, host_forwards = if myRouters /= [] then Just s.mesh_subnet else Nothing
, host_input_rules = inputRules
}
where
prefixLen = maybe "32" (tshow . snd) (parseCidr s.mesh_subnet)
others = [p | p <- s.mesh_peers, p.peer_name /= me.peer_name]
routerNets p = [r.router_network | r <- s.mesh_routers, r.router_peer == p.peer_name]
myRouters = routerNets me
-- a link exists when at least one side has an endpoint to dial
linked p = me.peer_endpoint /= Nothing || p.peer_endpoint /= Nothing
links =
[ PeerLink
{ link_name = p.peer_name
, link_pubkey = p.peer_pubkey
, link_endpoint = p.peer_endpoint
, link_allowed_ips = (p.peer_addr <> "/32") : routerNets p
, -- whoever has no endpoint is the one dialling, and keeps the path open
link_keepalive = if me.peer_endpoint == Nothing then Just 25 else Nothing
}
| p <- others
, linked p
]
reachedRouters = [(p, n) | l <- links, Just p <- [byName l.link_name], n <- routerNets p]
byName n = case [p | p <- s.mesh_peers, p.peer_name == n] of (p : _) -> Just p; [] -> Nothing
routes =
concat
[ if isExit n then ["0.0.0.0/1", "128.0.0.0/1"] else [n]
| (_, n) <- reachedRouters
]
pins = [h | (p, n) <- reachedRouters, isExit n, Just (h, _) <- [p.peer_endpoint >>= parseEndpoint]]
-- policies whose destination includes this host
inputRules =
[ NftRuleSpec (policyWords srcs pol)
| pol <- s.mesh_policies
, any (`elem` me.peer_groups) pol.policy_to
, let srcs = sort (nub [p.peer_addr | p <- s.mesh_peers, any (`elem` p.peer_groups) pol.policy_from])
]
ifaceQuoted = "\"" <> s.mesh_iface <> "\""
policyWords srcs pol =
["iifname", ifaceQuoted, "ip", "saddr", set srcs]
<> protoWords pol
protoWords pol = case pol.policy_protocol of
AnyProto -> ["accept"]
Icmp -> ["ip", "protocol", "icmp", "accept"]
Tcp -> portWords "tcp" pol.policy_ports
Udp -> portWords "udp" pol.policy_ports
portWords proto [] = ["meta", "l4proto", proto, "accept"]
portWords proto ranges = [proto, "dport", set (renderRange <$> ranges), "accept"]
renderRange (PortRange lo hi) = if lo == hi then tshow lo else tshow lo <> "-" <> tshow hi
-- nft prints a one-element set without braces, and the rule's check
-- matches the rendered text, so render it the way nft lists it
set [x] = x
set xs = "{ " <> Text.intercalate ", " xs <> " }"
-------------------------------------------------------------------------------
-- delivery
{- | A host's pull-mode document: one inline directive. @docid@ is the
publisher's name for this revision (it is what @history@ records).
-}
hostDocument :: Text -> HostSpec -> Document
hostDocument docid h = Document{docId = docid, docSeeds = [SeedDirective (toJSON h)], docPublished = Nothing}
-------------------------------------------------------------------------------
-- the node
{- | The Op for one host: the interface with its private key (generated here
if absent, never sent anywhere), the peers, the routes, and the forwarding
and nft rules the 'HostSpec' asks for.
-}
hostOp :: Reporter Report -> Binaries -> HostSpec -> Op
hostOp r bins h =
op "wireguard-mesh-host" (deps [routing, forwarding, filtering]) $ \actions ->
actions{help = "wireguard mesh host " <> h.host_name}
where
wg_r = contramap RunWireGuard r
nft_r = contramap RunNft r
sysctl_r = contramap RunSysctl r
ip_r = contramap RunIpRoute r
(_, plen) = fromMaybe (0, 32) (parseCidr h.host_addr)
netCidr = WG.Ipv4Cidr (Text.takeWhile (/= '/') h.host_addr) plen
ifaceTrack :: Track' WG.WgName
ifaceTrack = Track $ \name -> WG.iface wg_r bins.binIp name netCidr
privkeyTrack :: Track' FilePath
privkeyTrack = Track $ WG.privateKey bins.binWg
serverIface :: Op
serverIface = WG.server wg_r bins.binWg privkeyTrack ifaceTrack h.host_iface h.host_privkey_path h.host_port
peerOp :: PeerLink -> Op
peerOp l =
WG.peerWith
wg_r
bins.binWg
ifaceTrack
ignoreTrack
h.host_iface
(WG.PeerKeyValue l.link_pubkey)
l.link_endpoint
(Text.intercalate "," l.link_allowed_ips)
l.link_keepalive
-- Each peer is injected the interface's key and port, not only the
-- grouping node: that one has no effect, and what is injected onto it
-- alone is a sibling of the peers, not something they wait for.
peers :: Op
peers = op "wireguard-mesh-peers" (deps ((`inject` serverIface) . peerOp <$> h.host_peers)) id `inject` serverIface
pinOps = [pinHostRoute ip_r bins a `inject` peers | a <- h.host_exit_pins]
-- every pin precedes every route, or the tunnel's own traffic loops
routing :: Op
routing =
op "wireguard-mesh-routes" (deps routeOps) id
`inject` peers
where
routeOps =
[ foldl inject (IpRoute.route ip_r bins.binIp (IpRoute.Route (IpRoute.RawNetwork n) h.host_iface Nothing)) (peers : pinOps)
| n <- h.host_routes
]
table :: Nft.Table
table = Nft.Table "filter" Nft.Inet
forwarding :: Op
forwarding = case h.host_forwards of
Nothing -> noop "no forwarding"
Just subnet ->
Sysctl.set sysctl_r bins.binSysctl (Sysctl.Setting "net.ipv4.ip_forward" "1")
`inject` forwardChain
`inject` natRule subnet
ifaceQuoted = "\"" <> h.host_iface <> "\""
forwardChain :: Op
forwardChain =
Nft.rule nft_r bins.binNft fchain (Nft.RawRule ["ct", "state", "established,related", "accept"])
`inject` Nft.rule nft_r bins.binNft fchain (Nft.RawRule ["iifname", ifaceQuoted, "accept"])
where
fchain = Nft.baseChain "forward" table (Nft.BaseChainSpec Nft.FilterChain Nft.Forward 0 Nft.Drop)
natRule subnet =
Nft.rule
nft_r
bins.binNft
(Nft.baseChain "postrouting" (Nft.Table "nat" Nft.Inet) (Nft.BaseChainSpec Nft.NatChain Nft.PostRouting 100 Nft.Accept))
(Nft.RawRule ["ip", "saddr", subnet, "oifname", "!=", ifaceQuoted, "masquerade"])
-- an input chain of its own (policy accept: only the mesh interface is
-- restricted, ssh and everything else is untouched): established, then one
-- accept per policy, then drop whatever else arrives over the mesh
filtering :: Op
filtering = case h.host_input_rules of
[] | null h.host_peers -> noop "no mesh filter"
rules ->
let rs =
[Nft.RawRule ["iifname", ifaceQuoted, "ct", "state", "established,related", "accept"]]
<> [Nft.RawRule w | NftRuleSpec w <- rules]
<> [Nft.RawRule ["iifname", ifaceQuoted, "drop"]]
-- `x inject y` puts y before x, so each rule injects its predecessor
ops = Nft.rule nft_r bins.binNft inchain <$> rs
in foldl1 (\prev next -> next `inject` prev) ops
where
inchain = Nft.baseChain ("mesh-" <> h.host_iface) (Nft.Table ("mesh_" <> h.host_iface) Nft.Inet) (Nft.BaseChainSpec Nft.FilterChain Nft.Input 0 Nft.Accept)