567 lines
15 KiB
Go
567 lines
15 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build windows
|
|
|
|
// Package wf controls the Windows Filtering Platform to change Windows firewall rules.
|
|
package wf
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"os"
|
|
|
|
"github.com/tailscale/wf"
|
|
"golang.org/x/sys/windows"
|
|
"tailscale.com/net/netaddr"
|
|
)
|
|
|
|
// Known addresses.
|
|
var (
|
|
linkLocalRange = netip.MustParsePrefix("ff80::/10")
|
|
linkLocalDHCPMulticast = netip.MustParseAddr("ff02::1:2")
|
|
siteLocalDHCPMulticast = netip.MustParseAddr("ff05::1:3")
|
|
linkLocalRouterMulticast = netip.MustParseAddr("ff02::2")
|
|
|
|
linkLocalMulticastIPv4Range = netip.MustParsePrefix("224.0.0.0/24")
|
|
linkLocalMulticastIPv6Range = netip.MustParsePrefix("ff02::/16")
|
|
)
|
|
|
|
type direction int
|
|
|
|
const (
|
|
directionInbound direction = iota
|
|
directionOutbound
|
|
directionBoth
|
|
)
|
|
|
|
type protocol int
|
|
|
|
const (
|
|
protocolV4 protocol = iota
|
|
protocolV6
|
|
protocolAll
|
|
)
|
|
|
|
// getLayers returns the wf.LayerIDs where the rules should be added based
|
|
// on the protocol and direction.
|
|
func (p protocol) getLayers(d direction) []wf.LayerID {
|
|
var layers []wf.LayerID
|
|
if p == protocolAll || p == protocolV4 {
|
|
if d == directionBoth || d == directionInbound {
|
|
layers = append(layers, wf.LayerALEAuthRecvAcceptV4)
|
|
}
|
|
if d == directionBoth || d == directionOutbound {
|
|
layers = append(layers, wf.LayerALEAuthConnectV4)
|
|
}
|
|
}
|
|
if p == protocolAll || p == protocolV6 {
|
|
if d == directionBoth || d == directionInbound {
|
|
layers = append(layers, wf.LayerALEAuthRecvAcceptV6)
|
|
}
|
|
if d == directionBoth || d == directionOutbound {
|
|
layers = append(layers, wf.LayerALEAuthConnectV6)
|
|
}
|
|
}
|
|
return layers
|
|
}
|
|
|
|
func ruleName(action wf.Action, l wf.LayerID, name string) string {
|
|
switch l {
|
|
case wf.LayerALEAuthConnectV4:
|
|
return fmt.Sprintf("%s outbound %s (IPv4)", action, name)
|
|
case wf.LayerALEAuthConnectV6:
|
|
return fmt.Sprintf("%s outbound %s (IPv6)", action, name)
|
|
case wf.LayerALEAuthRecvAcceptV4:
|
|
return fmt.Sprintf("%s inbound %s (IPv4)", action, name)
|
|
case wf.LayerALEAuthRecvAcceptV6:
|
|
return fmt.Sprintf("%s inbound %s (IPv6)", action, name)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Firewall uses the Windows Filtering Platform to implement a network firewall.
|
|
type Firewall struct {
|
|
luid uint64
|
|
providerID wf.ProviderID
|
|
sublayerID wf.SublayerID
|
|
session *wf.Session
|
|
|
|
permittedRoutes map[netip.Prefix][]*wf.Rule
|
|
}
|
|
|
|
// New returns a new Firewall for the provided interface ID.
|
|
func New(luid uint64) (*Firewall, error) {
|
|
session, err := wf.New(&wf.Options{
|
|
Name: "Tailscale firewall",
|
|
Dynamic: true,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wguid, err := windows.GenerateGUID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
providerID := wf.ProviderID(wguid)
|
|
if err := session.AddProvider(&wf.Provider{
|
|
ID: providerID,
|
|
Name: "Tailscale provider",
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
wguid, err = windows.GenerateGUID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sublayerID := wf.SublayerID(wguid)
|
|
if err := session.AddSublayer(&wf.Sublayer{
|
|
ID: sublayerID,
|
|
Name: "Tailscale permissive and blocking filters",
|
|
Weight: 0,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
f := &Firewall{
|
|
luid: luid,
|
|
session: session,
|
|
providerID: providerID,
|
|
sublayerID: sublayerID,
|
|
permittedRoutes: make(map[netip.Prefix][]*wf.Rule),
|
|
}
|
|
if err := f.enable(); err != nil {
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
type weight uint64
|
|
|
|
const (
|
|
weightTailscaleTraffic weight = 15
|
|
weightKnownTraffic weight = 12
|
|
weightCatchAll weight = 0
|
|
)
|
|
|
|
func (f *Firewall) enable() error {
|
|
if err := f.permitTailscaleService(weightTailscaleTraffic); err != nil {
|
|
return fmt.Errorf("permitTailscaleService failed: %w", err)
|
|
}
|
|
|
|
if err := f.permitTunInterface(weightTailscaleTraffic); err != nil {
|
|
return fmt.Errorf("permitTunInterface failed: %w", err)
|
|
}
|
|
|
|
if err := f.permitDNS(weightTailscaleTraffic); err != nil {
|
|
return fmt.Errorf("permitDNS failed: %w", err)
|
|
}
|
|
|
|
if err := f.permitLoopback(weightTailscaleTraffic); err != nil {
|
|
return fmt.Errorf("permitLoopback failed: %w", err)
|
|
}
|
|
|
|
if err := f.permitDHCPv4(weightKnownTraffic); err != nil {
|
|
return fmt.Errorf("permitDHCPv4 failed: %w", err)
|
|
}
|
|
|
|
if err := f.permitDHCPv6(weightKnownTraffic); err != nil {
|
|
return fmt.Errorf("permitDHCPv6 failed: %w", err)
|
|
}
|
|
|
|
if err := f.permitNDP(weightKnownTraffic); err != nil {
|
|
return fmt.Errorf("permitNDP failed: %w", err)
|
|
}
|
|
|
|
/* TODO: actually evaluate if this does anything and if we need this. It's layer 2; our other rules are layer 3.
|
|
* In other words, if somebody complains, try enabling it. For now, keep it off.
|
|
* TODO(maisem): implement this.
|
|
err = permitHyperV(session, baseObjects, weightKnownTraffic)
|
|
if err != nil {
|
|
return wrapErr(err)
|
|
}
|
|
*/
|
|
|
|
if err := f.blockAll(weightCatchAll); err != nil {
|
|
return fmt.Errorf("blockAll failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdatedPermittedRoutes adds rules to allow incoming and outgoing connections
|
|
// from the provided prefixes. It will also remove rules for routes that were
|
|
// previously added but have been removed.
|
|
func (f *Firewall) UpdatePermittedRoutes(newRoutes []netip.Prefix) error {
|
|
var routesToAdd []netip.Prefix
|
|
routeMap := make(map[netip.Prefix]bool)
|
|
for _, r := range newRoutes {
|
|
routeMap[r] = true
|
|
if _, ok := f.permittedRoutes[r]; !ok {
|
|
routesToAdd = append(routesToAdd, r)
|
|
}
|
|
}
|
|
var routesToRemove []netip.Prefix
|
|
for r := range f.permittedRoutes {
|
|
if !routeMap[r] {
|
|
routesToRemove = append(routesToRemove, r)
|
|
}
|
|
}
|
|
for _, r := range routesToRemove {
|
|
for _, rule := range f.permittedRoutes[r] {
|
|
if err := f.session.DeleteRule(rule.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
delete(f.permittedRoutes, r)
|
|
}
|
|
for _, r := range routesToAdd {
|
|
conditions := []*wf.Match{
|
|
{
|
|
Field: wf.FieldIPRemoteAddress,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: r,
|
|
},
|
|
}
|
|
var p protocol
|
|
if r.Addr().Is4() {
|
|
p = protocolV4
|
|
} else {
|
|
p = protocolV6
|
|
}
|
|
name := "local route - " + r.String()
|
|
rules, err := f.addRules(name, weightKnownTraffic, conditions, wf.ActionPermit, p, directionBoth)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
name = "link-local multicast - " + r.String()
|
|
conditions = matchLinkLocalMulticast(r, false)
|
|
multicastRules, err := f.addRules(name, weightKnownTraffic, conditions, wf.ActionPermit, p, directionOutbound)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rules = append(rules, multicastRules...)
|
|
|
|
conditions = matchLinkLocalMulticast(r, true)
|
|
multicastRules, err = f.addRules(name, weightKnownTraffic, conditions, wf.ActionPermit, p, directionInbound)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rules = append(rules, multicastRules...)
|
|
|
|
f.permittedRoutes[r] = rules
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// matchLinkLocalMulticast returns a list of conditions that match
|
|
// outbound or inbound link-local multicast traffic to or from the
|
|
// specified network.
|
|
func matchLinkLocalMulticast(pfx netip.Prefix, inbound bool) []*wf.Match {
|
|
var linkLocalMulticastRange netip.Prefix
|
|
if pfx.Addr().Is4() {
|
|
linkLocalMulticastRange = linkLocalMulticastIPv4Range
|
|
} else {
|
|
linkLocalMulticastRange = linkLocalMulticastIPv6Range
|
|
}
|
|
var localAddr, remoteAddr netip.Prefix
|
|
if inbound {
|
|
localAddr, remoteAddr = linkLocalMulticastRange, pfx
|
|
} else {
|
|
localAddr, remoteAddr = pfx, linkLocalMulticastRange
|
|
}
|
|
return []*wf.Match{
|
|
{
|
|
Field: wf.FieldIPProtocol,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: wf.IPProtoUDP,
|
|
},
|
|
{
|
|
Field: wf.FieldIPLocalAddress,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: localAddr,
|
|
},
|
|
{
|
|
Field: wf.FieldIPRemoteAddress,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: remoteAddr,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (f *Firewall) newRule(name string, w weight, layer wf.LayerID, conditions []*wf.Match, action wf.Action) (*wf.Rule, error) {
|
|
id, err := windows.GenerateGUID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wf.Rule{
|
|
Name: ruleName(action, layer, name),
|
|
ID: wf.RuleID(id),
|
|
Provider: f.providerID,
|
|
Sublayer: f.sublayerID,
|
|
Layer: layer,
|
|
Weight: uint64(w),
|
|
Conditions: conditions,
|
|
Action: action,
|
|
}, nil
|
|
}
|
|
|
|
func (f *Firewall) addRules(name string, w weight, conditions []*wf.Match, action wf.Action, p protocol, d direction) ([]*wf.Rule, error) {
|
|
var rules []*wf.Rule
|
|
for _, l := range p.getLayers(d) {
|
|
r, err := f.newRule(name, w, l, conditions, action)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := f.session.AddRule(r); err != nil {
|
|
return nil, err
|
|
}
|
|
rules = append(rules, r)
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func (f *Firewall) blockAll(w weight) error {
|
|
_, err := f.addRules("all", w, nil, wf.ActionBlock, protocolAll, directionBoth)
|
|
return err
|
|
}
|
|
|
|
func (f *Firewall) permitNDP(w weight) error {
|
|
// These are aliased according to:
|
|
// https://social.msdn.microsoft.com/Forums/azure/en-US/eb2aa3cd-5f1c-4461-af86-61e7d43ccc23/filtering-icmp-by-type-code?forum=wfp
|
|
fieldICMPType := wf.FieldIPLocalPort
|
|
fieldICMPCode := wf.FieldIPRemotePort
|
|
|
|
var icmpConditions = func(t, c uint16, remoteAddress any) []*wf.Match {
|
|
conditions := []*wf.Match{
|
|
{
|
|
Field: wf.FieldIPProtocol,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: wf.IPProtoICMPV6,
|
|
},
|
|
{
|
|
Field: fieldICMPType,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: t,
|
|
},
|
|
{
|
|
Field: fieldICMPCode,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: c,
|
|
},
|
|
}
|
|
if remoteAddress != nil {
|
|
conditions = append(conditions, &wf.Match{
|
|
Field: wf.FieldIPRemoteAddress,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: linkLocalRouterMulticast,
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
/* TODO: actually handle the hop limit somehow! The rules should vaguely be:
|
|
* - icmpv6 133: must be outgoing, dst must be FF02::2/128, hop limit must be 255
|
|
* - icmpv6 134: must be incoming, src must be FE80::/10, hop limit must be 255
|
|
* - icmpv6 135: either incoming or outgoing, hop limit must be 255
|
|
* - icmpv6 136: either incoming or outgoing, hop limit must be 255
|
|
* - icmpv6 137: must be incoming, src must be FE80::/10, hop limit must be 255
|
|
*/
|
|
|
|
//
|
|
// Router Solicitation Message
|
|
// ICMP type 133, code 0. Outgoing.
|
|
//
|
|
conditions := icmpConditions(133, 0, linkLocalRouterMulticast)
|
|
if _, err := f.addRules("NDP type 133", w, conditions, wf.ActionPermit, protocolV6, directionOutbound); err != nil {
|
|
return err
|
|
}
|
|
|
|
//
|
|
// Router Advertisement Message
|
|
// ICMP type 134, code 0. Incoming.
|
|
//
|
|
conditions = icmpConditions(134, 0, linkLocalRange)
|
|
if _, err := f.addRules("NDP type 134", w, conditions, wf.ActionPermit, protocolV6, directionInbound); err != nil {
|
|
return err
|
|
}
|
|
|
|
//
|
|
// Neighbor Solicitation Message
|
|
// ICMP type 135, code 0. Bi-directional.
|
|
//
|
|
conditions = icmpConditions(135, 0, nil)
|
|
if _, err := f.addRules("NDP type 135", w, conditions, wf.ActionPermit, protocolV6, directionBoth); err != nil {
|
|
return err
|
|
}
|
|
|
|
//
|
|
// Neighbor Advertisement Message
|
|
// ICMP type 136, code 0. Bi-directional.
|
|
//
|
|
conditions = icmpConditions(136, 0, nil)
|
|
if _, err := f.addRules("NDP type 136", w, conditions, wf.ActionPermit, protocolV6, directionBoth); err != nil {
|
|
return err
|
|
}
|
|
|
|
//
|
|
// Redirect Message
|
|
// ICMP type 137, code 0. Incoming.
|
|
//
|
|
conditions = icmpConditions(137, 0, linkLocalRange)
|
|
if _, err := f.addRules("NDP type 137", w, conditions, wf.ActionPermit, protocolV6, directionInbound); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Firewall) permitDHCPv6(w weight) error {
|
|
var dhcpConditions = func(remoteAddrs ...any) []*wf.Match {
|
|
conditions := []*wf.Match{
|
|
{
|
|
Field: wf.FieldIPProtocol,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: wf.IPProtoUDP,
|
|
},
|
|
{
|
|
Field: wf.FieldIPLocalAddress,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: linkLocalRange,
|
|
},
|
|
{
|
|
Field: wf.FieldIPLocalPort,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: uint16(546),
|
|
},
|
|
{
|
|
Field: wf.FieldIPRemotePort,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: uint16(547),
|
|
},
|
|
}
|
|
for _, a := range remoteAddrs {
|
|
conditions = append(conditions, &wf.Match{
|
|
Field: wf.FieldIPRemoteAddress,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: a,
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
conditions := dhcpConditions(linkLocalDHCPMulticast, siteLocalDHCPMulticast)
|
|
if _, err := f.addRules("DHCP request", w, conditions, wf.ActionPermit, protocolV6, directionOutbound); err != nil {
|
|
return err
|
|
}
|
|
conditions = dhcpConditions(linkLocalRange)
|
|
if _, err := f.addRules("DHCP response", w, conditions, wf.ActionPermit, protocolV6, directionInbound); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Firewall) permitDHCPv4(w weight) error {
|
|
var dhcpConditions = func(remoteAddrs ...any) []*wf.Match {
|
|
conditions := []*wf.Match{
|
|
{
|
|
Field: wf.FieldIPProtocol,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: wf.IPProtoUDP,
|
|
},
|
|
{
|
|
Field: wf.FieldIPLocalPort,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: uint16(68),
|
|
},
|
|
{
|
|
Field: wf.FieldIPRemotePort,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: uint16(67),
|
|
},
|
|
}
|
|
for _, a := range remoteAddrs {
|
|
conditions = append(conditions, &wf.Match{
|
|
Field: wf.FieldIPRemoteAddress,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: a,
|
|
})
|
|
}
|
|
return conditions
|
|
}
|
|
conditions := dhcpConditions(netaddr.IPv4(255, 255, 255, 255))
|
|
if _, err := f.addRules("DHCP request", w, conditions, wf.ActionPermit, protocolV4, directionOutbound); err != nil {
|
|
return err
|
|
}
|
|
|
|
conditions = dhcpConditions()
|
|
if _, err := f.addRules("DHCP response", w, conditions, wf.ActionPermit, protocolV4, directionInbound); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Firewall) permitTunInterface(w weight) error {
|
|
condition := []*wf.Match{
|
|
{
|
|
Field: wf.FieldIPLocalInterface,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: f.luid,
|
|
},
|
|
}
|
|
_, err := f.addRules("on TUN", w, condition, wf.ActionPermit, protocolAll, directionBoth)
|
|
return err
|
|
}
|
|
|
|
func (f *Firewall) permitLoopback(w weight) error {
|
|
condition := []*wf.Match{
|
|
{
|
|
Field: wf.FieldFlags,
|
|
Op: wf.MatchTypeFlagsAllSet,
|
|
Value: wf.ConditionFlagIsLoopback,
|
|
},
|
|
}
|
|
_, err := f.addRules("on loopback", w, condition, wf.ActionPermit, protocolAll, directionBoth)
|
|
return err
|
|
}
|
|
|
|
func (f *Firewall) permitDNS(w weight) error {
|
|
conditions := []*wf.Match{
|
|
{
|
|
Field: wf.FieldIPRemotePort,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: uint16(53),
|
|
},
|
|
// Repeat the condition type for logical OR.
|
|
{
|
|
Field: wf.FieldIPProtocol,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: wf.IPProtoUDP,
|
|
},
|
|
{
|
|
Field: wf.FieldIPProtocol,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: wf.IPProtoTCP,
|
|
},
|
|
}
|
|
_, err := f.addRules("DNS", w, conditions, wf.ActionPermit, protocolAll, directionBoth)
|
|
return err
|
|
}
|
|
|
|
func (f *Firewall) permitTailscaleService(w weight) error {
|
|
currentFile, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
appID, err := wf.AppID(currentFile)
|
|
if err != nil {
|
|
return fmt.Errorf("could not get app id for %q: %w", currentFile, err)
|
|
}
|
|
conditions := []*wf.Match{
|
|
{
|
|
Field: wf.FieldALEAppID,
|
|
Op: wf.MatchTypeEqual,
|
|
Value: appID,
|
|
},
|
|
}
|
|
_, err = f.addRules("unrestricted traffic for Tailscale service", w, conditions, wf.ActionPermit, protocolAll, directionBoth)
|
|
return err
|
|
}
|