Compare commits
48 Commits
bradfitz/b
...
andrew/exe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3db001121 | ||
|
|
69bc164c62 | ||
|
|
d69c70ee5b | ||
|
|
05afa31df3 | ||
|
|
450bc9a6b8 | ||
|
|
5e9056a356 | ||
|
|
f0b63d0eec | ||
|
|
f39ee8e520 | ||
|
|
5756bc1704 | ||
|
|
3a39f08735 | ||
|
|
61bea75092 | ||
|
|
f0db47338e | ||
|
|
413fb5b933 | ||
|
|
d6abbc2e61 | ||
|
|
f1710f4a42 | ||
|
|
a00623e8c4 | ||
|
|
3033a96b02 | ||
|
|
1562a6f2f2 | ||
|
|
3fb8a1f6bf | ||
|
|
3dabea0fc2 | ||
|
|
0fa7b4a236 | ||
|
|
d1b378504c | ||
|
|
8b65598614 | ||
|
|
17022ad0e9 | ||
|
|
e4779146b5 | ||
|
|
550923d953 | ||
|
|
0a57051f2e | ||
|
|
ccd1643043 | ||
|
|
8c8750f1b3 | ||
|
|
cb3b1a1dcf | ||
|
|
042ed6bf69 | ||
|
|
150cd30b1d | ||
|
|
e12b2a7267 | ||
|
|
8b9d5fd6bc | ||
|
|
b50d32059f | ||
|
|
2729942638 | ||
|
|
7f3c1932b5 | ||
|
|
51adaec35a | ||
|
|
bcc262269f | ||
|
|
817ba1c300 | ||
|
|
69a985fb1e | ||
|
|
70c7b0d77f | ||
|
|
682c06a0e7 | ||
|
|
33e62a31bd | ||
|
|
174af763eb | ||
|
|
6e3c746942 | ||
|
|
6c30840cac | ||
|
|
c79b736a85 |
3
.github/workflows/golangci-lint.yml
vendored
3
.github/workflows/golangci-lint.yml
vendored
@@ -31,8 +31,7 @@ jobs:
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
# Note: this is the 'v6.1.0' tag as of 2024-08-21
|
||||
uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86
|
||||
uses: golangci/golangci-lint-action@ec5d18412c0aeab7936cb16880d708ba2a64e1ae # v6.2.0
|
||||
with:
|
||||
version: v1.60
|
||||
|
||||
|
||||
8
.github/workflows/govulncheck.yml
vendored
8
.github/workflows/govulncheck.yml
vendored
@@ -24,13 +24,13 @@ jobs:
|
||||
|
||||
- name: Post to slack
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
|
||||
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
|
||||
with:
|
||||
channel-id: 'C05PXRM304B'
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
|
||||
payload: |
|
||||
{
|
||||
"channel": "C05PXRM304B",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
|
||||
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -467,7 +467,7 @@ jobs:
|
||||
run: |
|
||||
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
|
||||
- name: upload crash
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
@@ -569,8 +569,10 @@ jobs:
|
||||
# By having the job always run, but skipping its only step as needed, we
|
||||
# let the CI output collapse nicely in PRs.
|
||||
if: failure() && github.event_name == 'push'
|
||||
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
|
||||
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
@@ -582,9 +584,6 @@ jobs:
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
check_mergeability:
|
||||
if: always()
|
||||
|
||||
2
.github/workflows/update-flake.yml
vendored
2
.github/workflows/update-flake.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
|
||||
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f #v7.0.6
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Send pull request
|
||||
id: pull-request
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
|
||||
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f #v7.0.6
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
|
||||
@@ -374,13 +374,13 @@ func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
|
||||
// response is being returned over the PeerAPI. The response is parsed and
|
||||
// matched against the configured domains, if matched the routeAdvertiser is
|
||||
// advised to advertise the discovered route.
|
||||
func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
func (e *AppConnector) ObserveDNSResponse(res []byte) error {
|
||||
var p dnsmessage.Parser
|
||||
if _, err := p.Start(res); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
if err := p.SkipAllQuestions(); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
|
||||
@@ -399,12 +399,12 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if h.Class != dnsmessage.ClassINET {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -427,7 +427,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
if h.Type == dnsmessage.TypeCNAME {
|
||||
res, err := p.CNAMEResource()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
|
||||
if len(cname) == 0 {
|
||||
@@ -441,20 +441,20 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
case dnsmessage.TypeA:
|
||||
r, err := p.AResource()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
addr := netip.AddrFrom4(r.A)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
case dnsmessage.TypeAAAA:
|
||||
r, err := p.AAAAResource()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
addr := netip.AddrFrom16(r.AAAA)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -485,6 +485,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
e.scheduleAdvertisement(domain, toAdvertise...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// starting from the given domain that resolved to an address, find it, or any
|
||||
|
||||
@@ -69,7 +69,9 @@ func TestUpdateRoutes(t *testing.T) {
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
|
||||
// This route should be collapsed into the range
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
|
||||
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
|
||||
@@ -77,7 +79,9 @@ func TestUpdateRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
// This route should not be collapsed or removed
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
|
||||
@@ -130,7 +134,9 @@ func TestDomainRoutes(t *testing.T) {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(context.Background())
|
||||
|
||||
want := map[string][]netip.Addr{
|
||||
@@ -155,7 +161,9 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
@@ -163,7 +171,9 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
@@ -172,7 +182,9 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// matches a routed domain.
|
||||
a.updateDomains([]string{"www.example.com", "example.com"})
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
|
||||
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
@@ -181,7 +193,9 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// even if only found in the middle of the chain
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
|
||||
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org.")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
@@ -190,14 +204,18 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// don't re-advertise routes that have already been advertised
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
@@ -207,7 +225,9 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
pfx := netip.MustParsePrefix("192.0.2.0/24")
|
||||
a.updateRoutes([]netip.Prefix{pfx})
|
||||
wantRoutes = append(wantRoutes, pfx)
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
@@ -230,7 +250,9 @@ func TestWildcardDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
|
||||
if err := a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
|
||||
t.Errorf("routes: got %v; want %v", got, want)
|
||||
@@ -438,10 +460,16 @@ func TestUpdateDomainRouteRemoval(t *testing.T) {
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3"))
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4"))
|
||||
for _, res := range [][]byte{
|
||||
dnsResponse("a.example.com.", "1.2.3.1"),
|
||||
dnsResponse("a.example.com.", "1.2.3.2"),
|
||||
dnsResponse("b.example.com.", "1.2.3.3"),
|
||||
dnsResponse("b.example.com.", "1.2.3.4"),
|
||||
} {
|
||||
if err := a.ObserveDNSResponse(res); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
}
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
@@ -487,10 +515,16 @@ func TestUpdateWildcardRouteRemoval(t *testing.T) {
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
|
||||
a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3"))
|
||||
a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4"))
|
||||
for _, res := range [][]byte{
|
||||
dnsResponse("a.example.com.", "1.2.3.1"),
|
||||
dnsResponse("a.example.com.", "1.2.3.2"),
|
||||
dnsResponse("1.b.example.com.", "1.2.3.3"),
|
||||
dnsResponse("2.b.example.com.", "1.2.3.4"),
|
||||
} {
|
||||
if err := a.ObserveDNSResponse(res); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
}
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
|
||||
@@ -37,7 +37,7 @@ while [ "$#" -gt 1 ]; do
|
||||
--extra-small)
|
||||
shift
|
||||
ldflags="$ldflags -w -s"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan"
|
||||
;;
|
||||
--box)
|
||||
shift
|
||||
|
||||
@@ -65,6 +65,10 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
|
||||
if err != nil {
|
||||
log.Fatalf("serve proxy: failed to read serve config: %v", err)
|
||||
}
|
||||
if sc == nil {
|
||||
log.Printf("serve proxy: no serve config at %q, skipping", path)
|
||||
continue
|
||||
}
|
||||
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
|
||||
continue
|
||||
}
|
||||
@@ -131,6 +135,9 @@ func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
|
||||
}
|
||||
j, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Serve config can be provided by users as well as the Kubernetes Operator (for its proxies). User-provided
|
||||
|
||||
@@ -99,6 +99,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
|
||||
tailscale.com/kube/kubetypes from tailscale.com/envknob
|
||||
tailscale.com/metrics from tailscale.com/cmd/derper+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
|
||||
@@ -77,6 +77,8 @@ var (
|
||||
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
|
||||
// tcpUserTimeout is intentionally short, so that hung connections are cleaned up promptly. DERPs should be nearby users.
|
||||
tcpUserTimeout = flag.Duration("tcp-user-timeout", 15*time.Second, "TCP user timeout")
|
||||
// tcpWriteTimeout is the timeout for writing to client TCP connections. It does not apply to mesh connections.
|
||||
tcpWriteTimeout = flag.Duration("tcp-write-timeout", derp.DefaultTCPWiteTimeout, "TCP write timeout; 0 results in no timeout being set on writes")
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -173,6 +175,7 @@ func main() {
|
||||
s.SetVerifyClient(*verifyClients)
|
||||
s.SetVerifyClientURL(*verifyClientURL)
|
||||
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
|
||||
s.SetTCPWriteTimeout(*tcpWriteTimeout)
|
||||
|
||||
if *meshPSKFile != "" {
|
||||
b, err := os.ReadFile(*meshPSKFile)
|
||||
|
||||
@@ -140,7 +140,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
github.com/gorilla/securecookie from github.com/gorilla/csrf
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
|
||||
L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns
|
||||
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
|
||||
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/feature/tap
|
||||
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
|
||||
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
|
||||
L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4
|
||||
@@ -156,7 +156,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
|
||||
github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe
|
||||
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
|
||||
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
|
||||
github.com/kortschak/wol from tailscale.com/feature/wakeonlan
|
||||
github.com/mailru/easyjson/buffer from github.com/mailru/easyjson/jwriter
|
||||
💣 github.com/mailru/easyjson/jlexer from github.com/go-openapi/swag
|
||||
github.com/mailru/easyjson/jwriter from github.com/go-openapi/swag
|
||||
@@ -302,7 +302,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/feature/tap+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
@@ -801,6 +801,10 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/drive from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob/featureknob from tailscale.com/client/web+
|
||||
tailscale.com/feature from tailscale.com/feature/wakeonlan+
|
||||
tailscale.com/feature/condregister from tailscale.com/tsnet
|
||||
L tailscale.com/feature/tap from tailscale.com/feature/condregister
|
||||
tailscale.com/feature/wakeonlan from tailscale.com/feature/condregister
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
@@ -835,6 +839,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+
|
||||
tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+
|
||||
tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/connstats from tailscale.com/net/tstun+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
|
||||
569
cmd/k8s-operator/ingress-for-pg.go
Normal file
569
cmd/k8s-operator/ingress-for-pg.go
Normal file
@@ -0,0 +1,569 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
serveConfigKey = "serve-config.json"
|
||||
VIPSvcOwnerRef = "tailscale.com/k8s-operator:owned-by:%s"
|
||||
// FinalizerNamePG is the finalizer used by the IngressPGReconciler
|
||||
FinalizerNamePG = "tailscale.com/ingress-pg-finalizer"
|
||||
)
|
||||
|
||||
var gaugePGIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressPGResourceCount)
|
||||
|
||||
// IngressPGReconciler is a controller that reconciles Tailscale Ingresses should be exposed on an ingress ProxyGroup
|
||||
// (in HA mode).
|
||||
type IngressPGReconciler struct {
|
||||
client.Client
|
||||
|
||||
recorder record.EventRecorder
|
||||
logger *zap.SugaredLogger
|
||||
tsClient tsClient
|
||||
tsnetServer tsnetServer
|
||||
tsNamespace string
|
||||
lc localClient
|
||||
defaultTags []string
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
// managedIngresses is a set of all ingress resources that we're currently
|
||||
// managing. This is only used for metrics.
|
||||
managedIngresses set.Slice[types.UID]
|
||||
}
|
||||
|
||||
// Reconcile reconciles Ingresses that should be exposed over Tailscale in HA mode (on a ProxyGroup). It looks at all
|
||||
// Ingresses with tailscale.com/proxy-group annotation. For each such Ingress, it ensures that a VIPService named after
|
||||
// the hostname of the Ingress exists and is up to date. It also ensures that the serve config for the ingress
|
||||
// ProxyGroup is updated to route traffic for the VIPService to the Ingress's backend Services.
|
||||
// When an Ingress is deleted or unexposed, the VIPService and the associated serve config are cleaned up.
|
||||
// Ingress hostname change also results in the VIPService for the previous hostname being cleaned up and a new VIPService
|
||||
// being created for the new hostname.
|
||||
func (a *IngressPGReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := a.logger.With("Ingress", req.NamespacedName)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
ing := new(networkingv1.Ingress)
|
||||
err = a.Get(ctx, req.NamespacedName, ing)
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Request object not found, could have been deleted after reconcile request.
|
||||
logger.Debugf("Ingress not found, assuming it was deleted")
|
||||
return res, nil
|
||||
} else if err != nil {
|
||||
return res, fmt.Errorf("failed to get Ingress: %w", err)
|
||||
}
|
||||
|
||||
// hostname is the name of the VIPService that will be created for this Ingress as well as the first label in
|
||||
// the MagicDNS name of the Ingress.
|
||||
hostname := hostnameForIngress(ing)
|
||||
logger = logger.With("hostname", hostname)
|
||||
|
||||
if !ing.DeletionTimestamp.IsZero() || !a.shouldExpose(ing) {
|
||||
return res, a.maybeCleanup(ctx, hostname, ing, logger)
|
||||
}
|
||||
|
||||
if err := a.maybeProvision(ctx, hostname, ing, logger); err != nil {
|
||||
return res, fmt.Errorf("failed to provision: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// maybeProvision ensures that the VIPService and serve config for the Ingress are created or updated.
|
||||
func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
|
||||
if err := validateIngressClass(ctx, a.Client); err != nil {
|
||||
logger.Infof("error validating tailscale IngressClass: %v.", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get and validate ProxyGroup readiness
|
||||
pgName := ing.Annotations[AnnotationProxyGroup]
|
||||
if pgName == "" {
|
||||
logger.Infof("[unexpected] no ProxyGroup annotation, skipping VIPService provisioning")
|
||||
return nil
|
||||
}
|
||||
pg := &tsapi.ProxyGroup{}
|
||||
if err := a.Get(ctx, client.ObjectKey{Name: pgName}, pg); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
logger.Infof("ProxyGroup %q does not exist", pgName)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("getting ProxyGroup %q: %w", pgName, err)
|
||||
}
|
||||
if !tsoperator.ProxyGroupIsReady(pg) {
|
||||
// TODO(irbekrm): we need to reconcile ProxyGroup Ingresses on ProxyGroup changes to not miss the status update
|
||||
// in this case.
|
||||
logger.Infof("ProxyGroup %q is not ready", pgName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate Ingress configuration
|
||||
if err := a.validateIngress(ing, pg); err != nil {
|
||||
logger.Infof("invalid Ingress configuration: %v", err)
|
||||
a.recorder.Event(ing, corev1.EventTypeWarning, "InvalidIngressConfiguration", err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
if !IsHTTPSEnabledOnTailnet(a.tsnetServer) {
|
||||
a.recorder.Event(ing, corev1.EventTypeWarning, "HTTPSNotEnabled", "HTTPS is not enabled on the tailnet; ingress may not work")
|
||||
}
|
||||
|
||||
logger = logger.With("proxy-group", pg)
|
||||
|
||||
if !slices.Contains(ing.Finalizers, FinalizerNamePG) {
|
||||
// This log line is printed exactly once during initial provisioning,
|
||||
// because once the finalizer is in place this block gets skipped. So,
|
||||
// this is a nice place to tell the operator that the high level,
|
||||
// multi-reconcile operation is underway.
|
||||
logger.Infof("exposing Ingress over tailscale")
|
||||
ing.Finalizers = append(ing.Finalizers, FinalizerNamePG)
|
||||
if err := a.Update(ctx, ing); err != nil {
|
||||
return fmt.Errorf("failed to add finalizer: %w", err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.managedIngresses.Add(ing.UID)
|
||||
gaugePGIngressResources.Set(int64(a.managedIngresses.Len()))
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// 1. Ensure that if Ingress' hostname has changed, any VIPService resources corresponding to the old hostname
|
||||
// are cleaned up.
|
||||
// In practice, this function will ensure that any VIPServices that are associated with the provided ProxyGroup
|
||||
// and no longer owned by an Ingress are cleaned up. This is fine- it is not expensive and ensures that in edge
|
||||
// cases (a single update changed both hostname and removed ProxyGroup annotation) the VIPService is more likely
|
||||
// to be (eventually) removed.
|
||||
if err := a.maybeCleanupProxyGroup(ctx, pgName, logger); err != nil {
|
||||
return fmt.Errorf("failed to cleanup VIPService resources for ProxyGroup: %w", err)
|
||||
}
|
||||
|
||||
// 2. Ensure that there isn't a VIPService with the same hostname already created and not owned by this Ingress.
|
||||
// TODO(irbekrm): perhaps in future we could have record names being stored on VIPServices. I am not certain if
|
||||
// there might not be edge cases (custom domains, etc?) where attempting to determine the DNS name of the
|
||||
// VIPService in this way won't be incorrect.
|
||||
tcd, err := a.tailnetCertDomain(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error determining DNS name base: %w", err)
|
||||
}
|
||||
dnsName := hostname + "." + tcd
|
||||
existingVIPSvc, err := a.tsClient.getVIPServiceByName(ctx, hostname)
|
||||
// TODO(irbekrm): here and when creating the VIPService, verify if the error is not terminal (and therefore
|
||||
// should not be reconciled). For example, if the hostname is already a hostname of a Tailscale node, the GET
|
||||
// here will fail.
|
||||
if err != nil {
|
||||
errResp := &tailscale.ErrResponse{}
|
||||
if ok := errors.As(err, errResp); ok && errResp.Status != http.StatusNotFound {
|
||||
return fmt.Errorf("error getting VIPService %q: %w", hostname, err)
|
||||
}
|
||||
}
|
||||
if existingVIPSvc != nil && !isVIPServiceForIngress(existingVIPSvc, ing) {
|
||||
logger.Infof("VIPService %q for MagicDNS name %q already exists, but is not owned by this Ingress. Please delete it manually and recreate this Ingress to proceed or create an Ingress for a different MagicDNS name", hostname, dnsName)
|
||||
a.recorder.Event(ing, corev1.EventTypeWarning, "ConflictingVIPServiceExists", fmt.Sprintf("VIPService %q for MagicDNS name %q already exists, but is not owned by this Ingress. Please delete it manually to proceed or create an Ingress for a different MagicDNS name", hostname, dnsName))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. Ensure that the serve config for the ProxyGroup contains the VIPService
|
||||
cm, cfg, err := a.proxyGroupServeConfig(ctx, pgName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting ingress serve config: %w", err)
|
||||
}
|
||||
if cm == nil {
|
||||
logger.Infof("no ingress serve config ConfigMap found, unable to update serve config. Ensure that ProxyGroup is healthy.")
|
||||
return nil
|
||||
}
|
||||
ep := ipn.HostPort(fmt.Sprintf("%s:443", dnsName))
|
||||
handlers, err := handlersForIngress(ctx, ing, a.Client, a.recorder, dnsName, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get handlers for ingress: %w", err)
|
||||
}
|
||||
ingCfg := &ipn.ServiceConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
HTTPS: true,
|
||||
},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
ep: {
|
||||
Handlers: handlers,
|
||||
},
|
||||
},
|
||||
}
|
||||
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
||||
var gotCfg *ipn.ServiceConfig
|
||||
if cfg != nil && cfg.Services != nil {
|
||||
gotCfg = cfg.Services[serviceName]
|
||||
}
|
||||
if !reflect.DeepEqual(gotCfg, ingCfg) {
|
||||
logger.Infof("Updating serve config")
|
||||
mak.Set(&cfg.Services, serviceName, ingCfg)
|
||||
cfgBytes, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling serve config: %w", err)
|
||||
}
|
||||
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
|
||||
if err := a.Update(ctx, cm); err != nil {
|
||||
return fmt.Errorf("error updating serve config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Ensure that the VIPService exists and is up to date.
|
||||
tags := a.defaultTags
|
||||
if tstr, ok := ing.Annotations[AnnotationTags]; ok {
|
||||
tags = strings.Split(tstr, ",")
|
||||
}
|
||||
|
||||
vipSvc := &VIPService{
|
||||
Name: hostname,
|
||||
Tags: tags,
|
||||
Ports: []string{"443"}, // always 443 for Ingress
|
||||
Comment: fmt.Sprintf(VIPSvcOwnerRef, ing.UID),
|
||||
}
|
||||
if existingVIPSvc != nil {
|
||||
vipSvc.Addrs = existingVIPSvc.Addrs
|
||||
}
|
||||
if existingVIPSvc == nil || !reflect.DeepEqual(vipSvc.Tags, existingVIPSvc.Tags) {
|
||||
logger.Infof("Ensuring VIPService %q exists and is up to date", hostname)
|
||||
if err := a.tsClient.createOrUpdateVIPServiceByName(ctx, vipSvc); err != nil {
|
||||
logger.Infof("error creating VIPService: %v", err)
|
||||
return fmt.Errorf("error creating VIPService: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update Ingress status
|
||||
oldStatus := ing.Status.DeepCopy()
|
||||
// TODO(irbekrm): once we have ingress ProxyGroup, we can determine if instances are ready to route traffic to the VIPService
|
||||
ing.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{
|
||||
{
|
||||
Hostname: dnsName,
|
||||
Ports: []networkingv1.IngressPortStatus{
|
||||
{
|
||||
Protocol: "TCP",
|
||||
Port: 443,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if apiequality.Semantic.DeepEqual(oldStatus, ing.Status) {
|
||||
return nil
|
||||
}
|
||||
if err := a.Status().Update(ctx, ing); err != nil {
|
||||
return fmt.Errorf("failed to update Ingress status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeCleanupProxyGroup ensures that if an Ingress hostname has changed, any VIPService resources created for the
|
||||
// Ingress' ProxyGroup corresponding to the old hostname are cleaned up. A run of this function will ensure that any
|
||||
// VIPServices that are associated with the provided ProxyGroup and no longer owned by an Ingress are cleaned up.
|
||||
func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyGroupName string, logger *zap.SugaredLogger) error {
|
||||
// Get serve config for the ProxyGroup
|
||||
cm, cfg, err := a.proxyGroupServeConfig(ctx, proxyGroupName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting serve config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil // ProxyGroup does not have any VIPServices
|
||||
}
|
||||
|
||||
ingList := &networkingv1.IngressList{}
|
||||
if err := a.List(ctx, ingList); err != nil {
|
||||
return fmt.Errorf("listing Ingresses: %w", err)
|
||||
}
|
||||
serveConfigChanged := false
|
||||
// For each VIPService in serve config...
|
||||
for vipHostname := range cfg.Services {
|
||||
// ...check if there is currently an Ingress with this hostname
|
||||
found := false
|
||||
for _, i := range ingList.Items {
|
||||
ingressHostname := hostnameForIngress(&i)
|
||||
if ingressHostname == vipHostname.WithoutPrefix() {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
logger.Infof("VIPService %q is not owned by any Ingress, cleaning up", vipHostname)
|
||||
svc, err := a.getVIPService(ctx, vipHostname.WithoutPrefix(), logger)
|
||||
if err != nil {
|
||||
errResp := &tailscale.ErrResponse{}
|
||||
if errors.As(err, &errResp) && errResp.Status == http.StatusNotFound {
|
||||
delete(cfg.Services, vipHostname)
|
||||
serveConfigChanged = true
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if isVIPServiceForAnyIngress(svc) {
|
||||
logger.Infof("cleaning up orphaned VIPService %q", vipHostname)
|
||||
if err := a.tsClient.deleteVIPServiceByName(ctx, vipHostname.WithoutPrefix()); err != nil {
|
||||
errResp := &tailscale.ErrResponse{}
|
||||
if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound {
|
||||
return fmt.Errorf("deleting VIPService %q: %w", vipHostname, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(cfg.Services, vipHostname)
|
||||
serveConfigChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
if serveConfigChanged {
|
||||
cfgBytes, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling serve config: %w", err)
|
||||
}
|
||||
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
|
||||
if err := a.Update(ctx, cm); err != nil {
|
||||
return fmt.Errorf("updating serve config: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeCleanup ensures that any resources, such as a VIPService created for this Ingress, are cleaned up when the
|
||||
// Ingress is being deleted or is unexposed.
|
||||
func (a *IngressPGReconciler) maybeCleanup(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
|
||||
logger.Debugf("Ensuring any resources for Ingress are cleaned up")
|
||||
ix := slices.Index(ing.Finalizers, FinalizerNamePG)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.managedIngresses.Remove(ing.UID)
|
||||
gaugePGIngressResources.Set(int64(a.managedIngresses.Len()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Check if there is a VIPService created for this Ingress.
|
||||
pg := ing.Annotations[AnnotationProxyGroup]
|
||||
cm, cfg, err := a.proxyGroupServeConfig(ctx, pg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting ProxyGroup serve config: %w", err)
|
||||
}
|
||||
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
||||
// VIPService is always first added to serve config and only then created in the Tailscale API, so if it is not
|
||||
// found in the serve config, we can assume that there is no VIPService. TODO(irbekrm): once we have ingress
|
||||
// ProxyGroup, we will probably add currently exposed VIPServices to its status. At that point, we can use the
|
||||
// status rather than checking the serve config each time.
|
||||
if cfg == nil || cfg.Services == nil || cfg.Services[serviceName] == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Infof("Ensuring that VIPService %q configuration is cleaned up", hostname)
|
||||
|
||||
// 2. Delete the VIPService.
|
||||
if err := a.deleteVIPServiceIfExists(ctx, hostname, ing, logger); err != nil {
|
||||
return fmt.Errorf("error deleting VIPService: %w", err)
|
||||
}
|
||||
|
||||
// 3. Remove the VIPService from the serve config for the ProxyGroup.
|
||||
logger.Infof("Removing VIPService %q from serve config for ProxyGroup %q", hostname, pg)
|
||||
delete(cfg.Services, serviceName)
|
||||
cfgBytes, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling serve config: %w", err)
|
||||
}
|
||||
mak.Set(&cm.BinaryData, serveConfigKey, cfgBytes)
|
||||
if err := a.Update(ctx, cm); err != nil {
|
||||
return fmt.Errorf("error updating ConfigMap %q: %w", cm.Name, err)
|
||||
}
|
||||
|
||||
if err := a.deleteFinalizer(ctx, ing, logger); err != nil {
|
||||
return fmt.Errorf("failed to remove finalizer: %w", err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.managedIngresses.Remove(ing.UID)
|
||||
gaugePGIngressResources.Set(int64(a.managedIngresses.Len()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *IngressPGReconciler) deleteFinalizer(ctx context.Context, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
|
||||
found := false
|
||||
ing.Finalizers = slices.DeleteFunc(ing.Finalizers, func(f string) bool {
|
||||
found = true
|
||||
return f == FinalizerNamePG
|
||||
})
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
logger.Debug("ensure %q finalizer is removed", FinalizerNamePG)
|
||||
|
||||
if err := a.Update(ctx, ing); err != nil {
|
||||
return fmt.Errorf("failed to remove finalizer %q: %w", FinalizerNamePG, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pgIngressCMName(pg string) string {
|
||||
return fmt.Sprintf("%s-ingress-config", pg)
|
||||
}
|
||||
|
||||
func (a *IngressPGReconciler) proxyGroupServeConfig(ctx context.Context, pg string) (cm *corev1.ConfigMap, cfg *ipn.ServeConfig, err error) {
|
||||
name := pgIngressCMName(pg)
|
||||
cm = &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: a.tsNamespace,
|
||||
},
|
||||
}
|
||||
if err := a.Get(ctx, client.ObjectKeyFromObject(cm), cm); err != nil && !apierrors.IsNotFound(err) {
|
||||
return nil, nil, fmt.Errorf("error retrieving ingress serve config ConfigMap %s: %v", name, err)
|
||||
}
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
cfg = &ipn.ServeConfig{}
|
||||
if len(cm.BinaryData[serveConfigKey]) != 0 {
|
||||
if err := json.Unmarshal(cm.BinaryData[serveConfigKey], cfg); err != nil {
|
||||
return nil, nil, fmt.Errorf("error unmarshaling ingress serve config %v: %w", cm.BinaryData[serveConfigKey], err)
|
||||
}
|
||||
}
|
||||
return cm, cfg, nil
|
||||
}
|
||||
|
||||
type localClient interface {
|
||||
StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error)
|
||||
}
|
||||
|
||||
// tailnetCertDomain returns the base domain (TCD) of the current tailnet.
|
||||
func (a *IngressPGReconciler) tailnetCertDomain(ctx context.Context) (string, error) {
|
||||
st, err := a.lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting tailscale status: %w", err)
|
||||
}
|
||||
return st.CurrentTailnet.MagicDNSSuffix, nil
|
||||
}
|
||||
|
||||
// shouldExpose returns true if the Ingress should be exposed over Tailscale in HA mode (on a ProxyGroup)
|
||||
func (a *IngressPGReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
|
||||
isTSIngress := ing != nil &&
|
||||
ing.Spec.IngressClassName != nil &&
|
||||
*ing.Spec.IngressClassName == tailscaleIngressClassName
|
||||
pgAnnot := ing.Annotations[AnnotationProxyGroup]
|
||||
return isTSIngress && pgAnnot != ""
|
||||
}
|
||||
|
||||
func (a *IngressPGReconciler) getVIPService(ctx context.Context, hostname string, logger *zap.SugaredLogger) (*VIPService, error) {
|
||||
svc, err := a.tsClient.getVIPServiceByName(ctx, hostname)
|
||||
if err != nil {
|
||||
errResp := &tailscale.ErrResponse{}
|
||||
if ok := errors.As(err, errResp); ok && errResp.Status != http.StatusNotFound {
|
||||
logger.Infof("error getting VIPService %q: %v", hostname, err)
|
||||
return nil, fmt.Errorf("error getting VIPService %q: %w", hostname, err)
|
||||
}
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func isVIPServiceForIngress(svc *VIPService, ing *networkingv1.Ingress) bool {
|
||||
if svc == nil || ing == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(svc.Comment, fmt.Sprintf(VIPSvcOwnerRef, ing.UID))
|
||||
}
|
||||
|
||||
func isVIPServiceForAnyIngress(svc *VIPService) bool {
|
||||
if svc == nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(svc.Comment, "tailscale.com/k8s-operator:owned-by:")
|
||||
}
|
||||
|
||||
// validateIngress validates that the Ingress is properly configured.
|
||||
// Currently validates:
|
||||
// - Any tags provided via tailscale.com/tags annotation are valid Tailscale ACL tags
|
||||
// - The derived hostname is a valid DNS label
|
||||
// - The referenced ProxyGroup exists and is of type 'ingress'
|
||||
// - Ingress' TLS block is invalid
|
||||
func (a *IngressPGReconciler) validateIngress(ing *networkingv1.Ingress, pg *tsapi.ProxyGroup) error {
|
||||
var errs []error
|
||||
|
||||
// Validate tags if present
|
||||
if tstr, ok := ing.Annotations[AnnotationTags]; ok {
|
||||
tags := strings.Split(tstr, ",")
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if err := tailcfg.CheckTag(tag); err != nil {
|
||||
errs = append(errs, fmt.Errorf("tailscale.com/tags annotation contains invalid tag %q: %w", tag, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TLS configuration
|
||||
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && (len(ing.Spec.TLS) > 1 || len(ing.Spec.TLS[0].Hosts) > 1) {
|
||||
errs = append(errs, fmt.Errorf("Ingress contains invalid TLS block %v: only a single TLS entry with a single host is allowed", ing.Spec.TLS))
|
||||
}
|
||||
|
||||
// Validate that the hostname will be a valid DNS label
|
||||
hostname := hostnameForIngress(ing)
|
||||
if err := dnsname.ValidLabel(hostname); err != nil {
|
||||
errs = append(errs, fmt.Errorf("invalid hostname %q: %w. Ensure that the hostname is a valid DNS label", hostname, err))
|
||||
}
|
||||
|
||||
// Validate ProxyGroup type
|
||||
if pg.Spec.Type != tsapi.ProxyGroupTypeIngress {
|
||||
errs = append(errs, fmt.Errorf("ProxyGroup %q is of type %q but must be of type %q",
|
||||
pg.Name, pg.Spec.Type, tsapi.ProxyGroupTypeIngress))
|
||||
}
|
||||
|
||||
// Validate ProxyGroup readiness
|
||||
if !tsoperator.ProxyGroupIsReady(pg) {
|
||||
errs = append(errs, fmt.Errorf("ProxyGroup %q is not ready", pg.Name))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// deleteVIPServiceIfExists attempts to delete the VIPService if it exists and is owned by the given Ingress.
|
||||
func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
|
||||
svc, err := a.getVIPService(ctx, name, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting VIPService: %w", err)
|
||||
}
|
||||
|
||||
// isVIPServiceForIngress handles nil svc, so we don't need to check it here
|
||||
if !isVIPServiceForIngress(svc, ing) {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("Deleting VIPService %q", name)
|
||||
if err = a.tsClient.deleteVIPServiceByName(ctx, name); err != nil {
|
||||
return fmt.Errorf("error deleting VIPService: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
337
cmd/k8s-operator/ingress-for-pg_test.go
Normal file
337
cmd/k8s-operator/ingress-for-pg_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"slices"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
func TestIngressPGReconciler(t *testing.T) {
|
||||
tsIngressClass := &networkingv1.IngressClass{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
|
||||
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
|
||||
}
|
||||
|
||||
// Pre-create the ProxyGroup
|
||||
pg := &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pg",
|
||||
Generation: 1,
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupTypeIngress,
|
||||
},
|
||||
}
|
||||
|
||||
// Pre-create the ConfigMap for the ProxyGroup
|
||||
pgConfigMap := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pg-ingress-config",
|
||||
Namespace: "operator-ns",
|
||||
},
|
||||
BinaryData: map[string][]byte{
|
||||
"serve-config.json": []byte(`{"Services":{}}`),
|
||||
},
|
||||
}
|
||||
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(pg, pgConfigMap, tsIngressClass).
|
||||
WithStatusSubresource(pg).
|
||||
Build()
|
||||
mustUpdateStatus(t, fc, "", pg.Name, func(pg *tsapi.ProxyGroup) {
|
||||
pg.Status.Conditions = []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.ProxyGroupReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
ObservedGeneration: 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
ft := &fakeTSClient{}
|
||||
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lc := &fakeLocalClient{
|
||||
status: &ipnstate.Status{
|
||||
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||
MagicDNSSuffix: "ts.net",
|
||||
},
|
||||
},
|
||||
}
|
||||
ingPGR := &IngressPGReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
tsnetServer: fakeTsnetServer,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
tsNamespace: "operator-ns",
|
||||
logger: zl.Sugar(),
|
||||
recorder: record.NewFakeRecorder(10),
|
||||
lc: lc,
|
||||
}
|
||||
|
||||
// Test 1: Default tags
|
||||
ing := &networkingv1.Ingress{
|
||||
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-ingress",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/proxy-group": "test-pg",
|
||||
},
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{
|
||||
IngressClassName: ptr.To("tailscale"),
|
||||
DefaultBackend: &networkingv1.IngressBackend{
|
||||
Service: &networkingv1.IngressServiceBackend{
|
||||
Name: "test",
|
||||
Port: networkingv1.ServiceBackendPort{
|
||||
Number: 8080,
|
||||
},
|
||||
},
|
||||
},
|
||||
TLS: []networkingv1.IngressTLS{
|
||||
{Hosts: []string{"my-svc.tailnetxyz.ts.net"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, ing)
|
||||
|
||||
// Verify initial reconciliation
|
||||
expectReconciled(t, ingPGR, "default", "test-ingress")
|
||||
|
||||
// Get and verify the ConfigMap was updated
|
||||
cm := &corev1.ConfigMap{}
|
||||
if err := fc.Get(context.Background(), types.NamespacedName{
|
||||
Name: "test-pg-ingress-config",
|
||||
Namespace: "operator-ns",
|
||||
}, cm); err != nil {
|
||||
t.Fatalf("getting ConfigMap: %v", err)
|
||||
}
|
||||
|
||||
cfg := &ipn.ServeConfig{}
|
||||
if err := json.Unmarshal(cm.BinaryData[serveConfigKey], cfg); err != nil {
|
||||
t.Fatalf("unmarshaling serve config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Services["svc:my-svc"] == nil {
|
||||
t.Error("expected serve config to contain VIPService configuration")
|
||||
}
|
||||
|
||||
// Verify VIPService uses default tags
|
||||
vipSvc, err := ft.getVIPServiceByName(context.Background(), "my-svc")
|
||||
if err != nil {
|
||||
t.Fatalf("getting VIPService: %v", err)
|
||||
}
|
||||
if vipSvc == nil {
|
||||
t.Fatal("VIPService not created")
|
||||
}
|
||||
wantTags := []string{"tag:k8s"} // default tags
|
||||
if !slices.Equal(vipSvc.Tags, wantTags) {
|
||||
t.Errorf("incorrect VIPService tags: got %v, want %v", vipSvc.Tags, wantTags)
|
||||
}
|
||||
|
||||
// Test 2: Custom tags
|
||||
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
|
||||
ing.Annotations["tailscale.com/tags"] = "tag:custom,tag:test"
|
||||
})
|
||||
expectReconciled(t, ingPGR, "default", "test-ingress")
|
||||
|
||||
// Verify VIPService uses custom tags
|
||||
vipSvc, err = ft.getVIPServiceByName(context.Background(), "my-svc")
|
||||
if err != nil {
|
||||
t.Fatalf("getting VIPService: %v", err)
|
||||
}
|
||||
if vipSvc == nil {
|
||||
t.Fatal("VIPService not created")
|
||||
}
|
||||
wantTags = []string{"tag:custom", "tag:test"} // custom tags only
|
||||
gotTags := slices.Clone(vipSvc.Tags)
|
||||
slices.Sort(gotTags)
|
||||
slices.Sort(wantTags)
|
||||
if !slices.Equal(gotTags, wantTags) {
|
||||
t.Errorf("incorrect VIPService tags: got %v, want %v", gotTags, wantTags)
|
||||
}
|
||||
|
||||
// Delete the Ingress and verify cleanup
|
||||
if err := fc.Delete(context.Background(), ing); err != nil {
|
||||
t.Fatalf("deleting Ingress: %v", err)
|
||||
}
|
||||
|
||||
expectReconciled(t, ingPGR, "default", "test-ingress")
|
||||
|
||||
// Verify the ConfigMap was cleaned up
|
||||
cm = &corev1.ConfigMap{}
|
||||
if err := fc.Get(context.Background(), types.NamespacedName{
|
||||
Name: "test-pg-ingress-config",
|
||||
Namespace: "operator-ns",
|
||||
}, cm); err != nil {
|
||||
t.Fatalf("getting ConfigMap: %v", err)
|
||||
}
|
||||
|
||||
cfg = &ipn.ServeConfig{}
|
||||
if err := json.Unmarshal(cm.BinaryData[serveConfigKey], cfg); err != nil {
|
||||
t.Fatalf("unmarshaling serve config: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Services) > 0 {
|
||||
t.Error("serve config not cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIngress(t *testing.T) {
|
||||
baseIngress := &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-ingress",
|
||||
Namespace: "default",
|
||||
},
|
||||
}
|
||||
|
||||
readyProxyGroup := &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pg",
|
||||
Generation: 1,
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupTypeIngress,
|
||||
},
|
||||
Status: tsapi.ProxyGroupStatus{
|
||||
Conditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.ProxyGroupReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
ObservedGeneration: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ing *networkingv1.Ingress
|
||||
pg *tsapi.ProxyGroup
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid_ingress_with_hostname",
|
||||
ing: &networkingv1.Ingress{
|
||||
ObjectMeta: baseIngress.ObjectMeta,
|
||||
Spec: networkingv1.IngressSpec{
|
||||
TLS: []networkingv1.IngressTLS{
|
||||
{Hosts: []string{"test.example.com"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
pg: readyProxyGroup,
|
||||
},
|
||||
{
|
||||
name: "valid_ingress_with_default_hostname",
|
||||
ing: baseIngress,
|
||||
pg: readyProxyGroup,
|
||||
},
|
||||
{
|
||||
name: "invalid_tags",
|
||||
ing: &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: baseIngress.Name,
|
||||
Namespace: baseIngress.Namespace,
|
||||
Annotations: map[string]string{
|
||||
AnnotationTags: "tag:invalid!",
|
||||
},
|
||||
},
|
||||
},
|
||||
pg: readyProxyGroup,
|
||||
wantErr: "tailscale.com/tags annotation contains invalid tag \"tag:invalid!\": tag names can only contain numbers, letters, or dashes",
|
||||
},
|
||||
{
|
||||
name: "multiple_TLS_entries",
|
||||
ing: &networkingv1.Ingress{
|
||||
ObjectMeta: baseIngress.ObjectMeta,
|
||||
Spec: networkingv1.IngressSpec{
|
||||
TLS: []networkingv1.IngressTLS{
|
||||
{Hosts: []string{"test1.example.com"}},
|
||||
{Hosts: []string{"test2.example.com"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
pg: readyProxyGroup,
|
||||
wantErr: "Ingress contains invalid TLS block [{[test1.example.com] } {[test2.example.com] }]: only a single TLS entry with a single host is allowed",
|
||||
},
|
||||
{
|
||||
name: "multiple_hosts_in_TLS_entry",
|
||||
ing: &networkingv1.Ingress{
|
||||
ObjectMeta: baseIngress.ObjectMeta,
|
||||
Spec: networkingv1.IngressSpec{
|
||||
TLS: []networkingv1.IngressTLS{
|
||||
{Hosts: []string{"test1.example.com", "test2.example.com"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
pg: readyProxyGroup,
|
||||
wantErr: "Ingress contains invalid TLS block [{[test1.example.com test2.example.com] }]: only a single TLS entry with a single host is allowed",
|
||||
},
|
||||
{
|
||||
name: "wrong_proxy_group_type",
|
||||
ing: baseIngress,
|
||||
pg: &tsapi.ProxyGroup{
|
||||
ObjectMeta: readyProxyGroup.ObjectMeta,
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupType("foo"),
|
||||
},
|
||||
Status: readyProxyGroup.Status,
|
||||
},
|
||||
wantErr: "ProxyGroup \"test-pg\" is of type \"foo\" but must be of type \"ingress\"",
|
||||
},
|
||||
{
|
||||
name: "proxy_group_not_ready",
|
||||
ing: baseIngress,
|
||||
pg: &tsapi.ProxyGroup{
|
||||
ObjectMeta: readyProxyGroup.ObjectMeta,
|
||||
Spec: readyProxyGroup.Spec,
|
||||
Status: tsapi.ProxyGroupStatus{
|
||||
Conditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.ProxyGroupReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
ObservedGeneration: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "ProxyGroup \"test-pg\" is not ready",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := &IngressPGReconciler{}
|
||||
err := r.validateIngress(tt.ing, tt.pg)
|
||||
if (err == nil && tt.wantErr != "") || (err != nil && err.Error() != tt.wantErr) {
|
||||
t.Errorf("validateIngress() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
@@ -58,7 +59,7 @@ var (
|
||||
)
|
||||
|
||||
func (a *IngressReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
|
||||
logger := a.logger.With("ingress-ns", req.Namespace, "ingress-name", req.Name)
|
||||
logger := a.logger.With("Ingress", req.NamespacedName)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
@@ -128,9 +129,8 @@ func (a *IngressReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
|
||||
// This function adds a finalizer to ing, ensuring that we can handle orderly
|
||||
// deprovisioning later.
|
||||
func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, ing *networkingv1.Ingress) error {
|
||||
if err := a.validateIngressClass(ctx); err != nil {
|
||||
if err := validateIngressClass(ctx, a.Client); err != nil {
|
||||
logger.Warnf("error validating tailscale IngressClass: %v. In future this might be a terminal error.", err)
|
||||
|
||||
}
|
||||
if !slices.Contains(ing.Finalizers, FinalizerName) {
|
||||
// This log line is printed exactly once during initial provisioning,
|
||||
@@ -159,7 +159,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
gaugeIngressResources.Set(int64(a.managedIngresses.Len()))
|
||||
a.mu.Unlock()
|
||||
|
||||
if !a.ssr.IsHTTPSEnabledOnTailnet() {
|
||||
if !IsHTTPSEnabledOnTailnet(a.ssr.tsnetServer) {
|
||||
a.recorder.Event(ing, corev1.EventTypeWarning, "HTTPSNotEnabled", "HTTPS is not enabled on the tailnet; ingress may not work")
|
||||
}
|
||||
|
||||
@@ -185,73 +185,16 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
}
|
||||
|
||||
web := sc.Web[magic443]
|
||||
addIngressBackend := func(b *networkingv1.IngressBackend, path string) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if b.Service == nil {
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q is missing service", path)
|
||||
return
|
||||
}
|
||||
var svc corev1.Service
|
||||
if err := a.Get(ctx, types.NamespacedName{Namespace: ing.Namespace, Name: b.Service.Name}, &svc); err != nil {
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "failed to get service %q for path %q: %v", b.Service.Name, path, err)
|
||||
return
|
||||
}
|
||||
if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" {
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q has invalid ClusterIP", path)
|
||||
return
|
||||
}
|
||||
var port int32
|
||||
if b.Service.Port.Name != "" {
|
||||
for _, p := range svc.Spec.Ports {
|
||||
if p.Name == b.Service.Port.Name {
|
||||
port = p.Port
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
port = b.Service.Port.Number
|
||||
}
|
||||
if port == 0 {
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q has invalid port", path)
|
||||
return
|
||||
}
|
||||
proto := "http://"
|
||||
if port == 443 || b.Service.Port.Name == "https" {
|
||||
proto = "https+insecure://"
|
||||
}
|
||||
web.Handlers[path] = &ipn.HTTPHandler{
|
||||
Proxy: proto + svc.Spec.ClusterIP + ":" + fmt.Sprint(port) + path,
|
||||
}
|
||||
}
|
||||
addIngressBackend(ing.Spec.DefaultBackend, "/")
|
||||
|
||||
var tlsHost string // hostname or FQDN or empty
|
||||
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
|
||||
tlsHost = ing.Spec.TLS[0].Hosts[0]
|
||||
}
|
||||
for _, rule := range ing.Spec.Rules {
|
||||
// Host is optional, but if it's present it must match the TLS host
|
||||
// otherwise we ignore the rule.
|
||||
if rule.Host != "" && rule.Host != tlsHost {
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "rule with host %q ignored, unsupported", rule.Host)
|
||||
continue
|
||||
}
|
||||
for _, p := range rule.HTTP.Paths {
|
||||
// Send a warning if folks use Exact path type - to make
|
||||
// it easier for us to support Exact path type matching
|
||||
// in the future if needed.
|
||||
// https://kubernetes.io/docs/concepts/services-networking/ingress/#path-types
|
||||
if *p.PathType == networkingv1.PathTypeExact {
|
||||
msg := "Exact path type strict matching is currently not supported and requests will be routed as for Prefix path type. This behaviour might change in the future."
|
||||
logger.Warnf(fmt.Sprintf("Unsupported Path type exact for path %s. %s", p.Path, msg))
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "UnsupportedPathTypeExact", msg)
|
||||
}
|
||||
addIngressBackend(&p.Backend, p.Path)
|
||||
}
|
||||
handlers, err := handlersForIngress(ctx, ing, a.Client, a.recorder, tlsHost, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get handlers for ingress: %w", err)
|
||||
}
|
||||
|
||||
web.Handlers = handlers
|
||||
if len(web.Handlers) == 0 {
|
||||
logger.Warn("Ingress contains no valid backends")
|
||||
a.recorder.Eventf(ing, corev1.EventTypeWarning, "NoValidBackends", "no valid backends")
|
||||
@@ -263,10 +206,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
if tstr, ok := ing.Annotations[AnnotationTags]; ok {
|
||||
tags = strings.Split(tstr, ",")
|
||||
}
|
||||
hostname := ing.Namespace + "-" + ing.Name + "-ingress"
|
||||
if tlsHost != "" {
|
||||
hostname, _, _ = strings.Cut(tlsHost, ".")
|
||||
}
|
||||
hostname := hostnameForIngress(ing)
|
||||
|
||||
sts := &tailscaleSTSConfig{
|
||||
Hostname: hostname,
|
||||
@@ -322,28 +262,106 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
func (a *IngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
|
||||
return ing != nil &&
|
||||
ing.Spec.IngressClassName != nil &&
|
||||
*ing.Spec.IngressClassName == tailscaleIngressClassName
|
||||
*ing.Spec.IngressClassName == tailscaleIngressClassName &&
|
||||
ing.Annotations[AnnotationProxyGroup] == ""
|
||||
}
|
||||
|
||||
// validateIngressClass attempts to validate that 'tailscale' IngressClass
|
||||
// included in Tailscale installation manifests exists and has not been modified
|
||||
// to attempt to enable features that we do not support.
|
||||
func (a *IngressReconciler) validateIngressClass(ctx context.Context) error {
|
||||
func validateIngressClass(ctx context.Context, cl client.Client) error {
|
||||
ic := &networkingv1.IngressClass{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: tailscaleIngressClassName,
|
||||
},
|
||||
}
|
||||
if err := a.Get(ctx, client.ObjectKeyFromObject(ic), ic); apierrors.IsNotFound(err) {
|
||||
return errors.New("Tailscale IngressClass not found in cluster. Latest installation manifests include a tailscale IngressClass - please update")
|
||||
if err := cl.Get(ctx, client.ObjectKeyFromObject(ic), ic); apierrors.IsNotFound(err) {
|
||||
return errors.New("'tailscale' IngressClass not found in cluster.")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("error retrieving 'tailscale' IngressClass: %w", err)
|
||||
}
|
||||
if ic.Spec.Controller != tailscaleIngressControllerName {
|
||||
return fmt.Errorf("Tailscale Ingress class controller name %s does not match tailscale Ingress controller name %s. Ensure that you are using 'tailscale' IngressClass from latest Tailscale installation manifests", ic.Spec.Controller, tailscaleIngressControllerName)
|
||||
return fmt.Errorf("'tailscale' Ingress class controller name %s does not match tailscale Ingress controller name %s. Ensure that you are using 'tailscale' IngressClass from latest Tailscale installation manifests", ic.Spec.Controller, tailscaleIngressControllerName)
|
||||
}
|
||||
if ic.GetAnnotations()[ingressClassDefaultAnnotation] != "" {
|
||||
return fmt.Errorf("%s annotation is set on 'tailscale' IngressClass, but Tailscale Ingress controller does not support default Ingress class. Ensure that you are using 'tailscale' IngressClass from latest Tailscale installation manifests", ingressClassDefaultAnnotation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlersForIngress(ctx context.Context, ing *networkingv1.Ingress, cl client.Client, rec record.EventRecorder, tlsHost string, logger *zap.SugaredLogger) (handlers map[string]*ipn.HTTPHandler, err error) {
|
||||
addIngressBackend := func(b *networkingv1.IngressBackend, path string) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if b.Service == nil {
|
||||
rec.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q is missing service", path)
|
||||
return
|
||||
}
|
||||
var svc corev1.Service
|
||||
if err := cl.Get(ctx, types.NamespacedName{Namespace: ing.Namespace, Name: b.Service.Name}, &svc); err != nil {
|
||||
rec.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "failed to get service %q for path %q: %v", b.Service.Name, path, err)
|
||||
return
|
||||
}
|
||||
if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" {
|
||||
rec.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q has invalid ClusterIP", path)
|
||||
return
|
||||
}
|
||||
var port int32
|
||||
if b.Service.Port.Name != "" {
|
||||
for _, p := range svc.Spec.Ports {
|
||||
if p.Name == b.Service.Port.Name {
|
||||
port = p.Port
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
port = b.Service.Port.Number
|
||||
}
|
||||
if port == 0 {
|
||||
rec.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q has invalid port", path)
|
||||
return
|
||||
}
|
||||
proto := "http://"
|
||||
if port == 443 || b.Service.Port.Name == "https" {
|
||||
proto = "https+insecure://"
|
||||
}
|
||||
mak.Set(&handlers, path, &ipn.HTTPHandler{
|
||||
Proxy: proto + svc.Spec.ClusterIP + ":" + fmt.Sprint(port) + path,
|
||||
})
|
||||
}
|
||||
addIngressBackend(ing.Spec.DefaultBackend, "/")
|
||||
for _, rule := range ing.Spec.Rules {
|
||||
// Host is optional, but if it's present it must match the TLS host
|
||||
// otherwise we ignore the rule.
|
||||
if rule.Host != "" && rule.Host != tlsHost {
|
||||
rec.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "rule with host %q ignored, unsupported", rule.Host)
|
||||
continue
|
||||
}
|
||||
for _, p := range rule.HTTP.Paths {
|
||||
// Send a warning if folks use Exact path type - to make
|
||||
// it easier for us to support Exact path type matching
|
||||
// in the future if needed.
|
||||
// https://kubernetes.io/docs/concepts/services-networking/ingress/#path-types
|
||||
if *p.PathType == networkingv1.PathTypeExact {
|
||||
msg := "Exact path type strict matching is currently not supported and requests will be routed as for Prefix path type. This behaviour might change in the future."
|
||||
logger.Warnf(fmt.Sprintf("Unsupported Path type exact for path %s. %s", p.Path, msg))
|
||||
rec.Eventf(ing, corev1.EventTypeWarning, "UnsupportedPathTypeExact", msg)
|
||||
}
|
||||
addIngressBackend(&p.Backend, p.Path)
|
||||
}
|
||||
}
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
// hostnameForIngress returns the hostname for an Ingress resource.
|
||||
// If the Ingress has TLS configured with a host, it returns the first component of that host.
|
||||
// Otherwise, it returns a hostname derived from the Ingress name and namespace.
|
||||
func hostnameForIngress(ing *networkingv1.Ingress) string {
|
||||
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
|
||||
h := ing.Spec.TLS[0].Hosts[0]
|
||||
hostname, _, _ := strings.Cut(h, ".")
|
||||
return hostname
|
||||
}
|
||||
return ing.Namespace + "-" + ing.Name + "-ingress"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/go-logr/zapr"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
@@ -107,14 +106,14 @@ func main() {
|
||||
hostinfo.SetApp(kubetypes.AppAPIServerProxy)
|
||||
}
|
||||
|
||||
s, tsClient := initTSNet(zlog)
|
||||
s, tsc := initTSNet(zlog)
|
||||
defer s.Close()
|
||||
restConfig := config.GetConfigOrDie()
|
||||
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
|
||||
rOpts := reconcilerOpts{
|
||||
log: zlog,
|
||||
tsServer: s,
|
||||
tsClient: tsClient,
|
||||
tsClient: tsc,
|
||||
tailscaleNamespace: tsNamespace,
|
||||
restConfig: restConfig,
|
||||
proxyImage: image,
|
||||
@@ -130,7 +129,7 @@ func main() {
|
||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
|
||||
// CLIENT_ID_FILE and CLIENT_SECRET_FILE environment variables to authenticate
|
||||
// with Tailscale.
|
||||
func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
|
||||
func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, tsClient) {
|
||||
var (
|
||||
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
|
||||
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
|
||||
@@ -142,23 +141,10 @@ func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
|
||||
if clientIDPath == "" || clientSecretPath == "" {
|
||||
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set")
|
||||
}
|
||||
clientID, err := os.ReadFile(clientIDPath)
|
||||
tsc, err := newTSClient(context.Background(), clientIDPath, clientSecretPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client ID %q: %v", clientIDPath, err)
|
||||
startlog.Fatalf("error creating Tailscale client: %v", err)
|
||||
}
|
||||
clientSecret, err := os.ReadFile(clientSecretPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client secret %q: %v", clientSecretPath, err)
|
||||
}
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: string(clientID),
|
||||
ClientSecret: string(clientSecret),
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
}
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.UserAgent = "tailscale-k8s-operator"
|
||||
tsClient.HTTPClient = credentials.Client(context.Background())
|
||||
|
||||
s := &tsnet.Server{
|
||||
Hostname: hostname,
|
||||
Logf: zlog.Named("tailscaled").Debugf,
|
||||
@@ -211,7 +197,7 @@ waitOnline:
|
||||
},
|
||||
},
|
||||
}
|
||||
authkey, _, err := tsClient.CreateKey(ctx, caps)
|
||||
authkey, _, err := tsc.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
startlog.Fatalf("creating operator authkey: %v", err)
|
||||
}
|
||||
@@ -235,7 +221,7 @@ waitOnline:
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return s, tsClient
|
||||
return s, tsc
|
||||
}
|
||||
|
||||
// runReconcilers starts the controller-runtime manager and registers the
|
||||
@@ -329,6 +315,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
For(&networkingv1.Ingress{}).
|
||||
Named("ingress-reconciler").
|
||||
Watches(&appsv1.StatefulSet{}, ingressChildFilter).
|
||||
Watches(&corev1.Secret{}, ingressChildFilter).
|
||||
Watches(&corev1.Service{}, svcHandlerForIngress).
|
||||
@@ -343,6 +330,28 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create ingress reconciler: %v", err)
|
||||
}
|
||||
lc, err := opts.tsServer.LocalClient()
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not get local client: %v", err)
|
||||
}
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
For(&networkingv1.Ingress{}).
|
||||
Named("ingress-pg-reconciler").
|
||||
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog))).
|
||||
Complete(&IngressPGReconciler{
|
||||
recorder: eventRecorder,
|
||||
tsClient: opts.tsClient,
|
||||
tsnetServer: opts.tsServer,
|
||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||
Client: mgr.GetClient(),
|
||||
logger: opts.log.Named("ingress-pg-reconciler"),
|
||||
lc: lc,
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create ingress-pg-reconciler: %v", err)
|
||||
}
|
||||
|
||||
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("connector"))
|
||||
// If a ProxyClassChanges, enqueue all Connectors that have
|
||||
@@ -350,6 +359,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
proxyClassFilterForConnector := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForConnector(mgr.GetClient(), startlog))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.Connector{}).
|
||||
Named("connector-reconciler").
|
||||
Watches(&appsv1.StatefulSet{}, connectorFilter).
|
||||
Watches(&corev1.Secret{}, connectorFilter).
|
||||
Watches(&tsapi.ProxyClass{}, proxyClassFilterForConnector).
|
||||
@@ -369,6 +379,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver"))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.DNSConfig{}).
|
||||
Named("nameserver-reconciler").
|
||||
Watches(&appsv1.Deployment{}, nameserverFilter).
|
||||
Watches(&corev1.ConfigMap{}, nameserverFilter).
|
||||
Watches(&corev1.Service{}, nameserverFilter).
|
||||
@@ -448,6 +459,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
serviceMonitorFilter := handler.EnqueueRequestsFromMapFunc(proxyClassesWithServiceMonitor(mgr.GetClient(), opts.log))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.ProxyClass{}).
|
||||
Named("proxyclass-reconciler").
|
||||
Watches(&apiextensionsv1.CustomResourceDefinition{}, serviceMonitorFilter).
|
||||
Complete(&ProxyClassReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
@@ -491,6 +503,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
recorderFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.Recorder{})
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.Recorder{}).
|
||||
Named("recorder-reconciler").
|
||||
Watches(&appsv1.StatefulSet{}, recorderFilter).
|
||||
Watches(&corev1.ServiceAccount{}, recorderFilter).
|
||||
Watches(&corev1.Secret{}, recorderFilter).
|
||||
@@ -513,7 +526,9 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
proxyClassFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForProxyGroup(mgr.GetClient(), startlog))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.ProxyGroup{}).
|
||||
Named("proxygroup-reconciler").
|
||||
Watches(&appsv1.StatefulSet{}, ownedByProxyGroupFilter).
|
||||
Watches(&corev1.ConfigMap{}, ownedByProxyGroupFilter).
|
||||
Watches(&corev1.ServiceAccount{}, ownedByProxyGroupFilter).
|
||||
Watches(&corev1.Secret{}, ownedByProxyGroupFilter).
|
||||
Watches(&rbacv1.Role{}, ownedByProxyGroupFilter).
|
||||
@@ -545,7 +560,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
type reconcilerOpts struct {
|
||||
log *zap.SugaredLogger
|
||||
tsServer *tsnet.Server
|
||||
tsClient *tailscale.Client
|
||||
tsClient tsClient
|
||||
tailscaleNamespace string // namespace in which operator resources will be deployed
|
||||
restConfig *rest.Config // config for connecting to the kube API server
|
||||
proxyImage string // <proxy-image-repo>:<proxy-image-tag>
|
||||
@@ -670,12 +685,6 @@ func dnsRecordsReconcilerIngressHandler(ns string, isDefaultLoadBalancer bool, c
|
||||
}
|
||||
}
|
||||
|
||||
type tsClient interface {
|
||||
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
||||
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
|
||||
DeleteDevice(ctx context.Context, nodeStableID string) error
|
||||
}
|
||||
|
||||
func isManagedResource(o client.Object) bool {
|
||||
ls := o.GetLabels()
|
||||
return ls[LabelManaged] == "true"
|
||||
@@ -811,6 +820,10 @@ func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handl
|
||||
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
|
||||
return nil
|
||||
}
|
||||
if hasProxyGroupAnnotation(&ing) {
|
||||
// We don't want to reconcile backend Services for Ingresses for ProxyGroups.
|
||||
continue
|
||||
}
|
||||
if ing.Spec.DefaultBackend != nil && ing.Spec.DefaultBackend.Service != nil && ing.Spec.DefaultBackend.Service.Name == o.GetName() {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
|
||||
}
|
||||
@@ -1094,3 +1107,44 @@ func indexEgressServices(o client.Object) []string {
|
||||
}
|
||||
return []string{o.GetAnnotations()[AnnotationProxyGroup]}
|
||||
}
|
||||
|
||||
// serviceHandlerForIngressPG returns a handler for Service events that ensures that if the Service
|
||||
// associated with an event is a backend Service for a tailscale Ingress with ProxyGroup annotation,
|
||||
// the associated Ingress gets reconciled.
|
||||
func serviceHandlerForIngressPG(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
ingList := networkingv1.IngressList{}
|
||||
if err := cl.List(ctx, &ingList, client.InNamespace(o.GetNamespace())); err != nil {
|
||||
logger.Debugf("error listing Ingresses: %v", err)
|
||||
return nil
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, ing := range ingList.Items {
|
||||
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
|
||||
continue
|
||||
}
|
||||
if !hasProxyGroupAnnotation(&ing) {
|
||||
continue
|
||||
}
|
||||
if ing.Spec.DefaultBackend != nil && ing.Spec.DefaultBackend.Service != nil && ing.Spec.DefaultBackend.Service.Name == o.GetName() {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
|
||||
}
|
||||
for _, rule := range ing.Spec.Rules {
|
||||
if rule.HTTP == nil {
|
||||
continue
|
||||
}
|
||||
for _, path := range rule.HTTP.Paths {
|
||||
if path.Backend.Service != nil && path.Backend.Service.Name == o.GetName() {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
}
|
||||
|
||||
func hasProxyGroupAnnotation(obj client.Object) bool {
|
||||
ing := obj.(*networkingv1.Ingress)
|
||||
return ing.Annotations[AnnotationProxyGroup] != ""
|
||||
}
|
||||
|
||||
@@ -258,7 +258,16 @@ func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, pg *tsapi.Pro
|
||||
existing.ObjectMeta.Labels = cm.ObjectMeta.Labels
|
||||
existing.ObjectMeta.OwnerReferences = cm.ObjectMeta.OwnerReferences
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error provisioning ConfigMap: %w", err)
|
||||
return fmt.Errorf("error provisioning egress ConfigMap %q: %w", cm.Name, err)
|
||||
}
|
||||
}
|
||||
if pg.Spec.Type == tsapi.ProxyGroupTypeIngress {
|
||||
cm := pgIngressCM(pg, r.tsNamespace)
|
||||
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, cm, func(existing *corev1.ConfigMap) {
|
||||
existing.ObjectMeta.Labels = cm.ObjectMeta.Labels
|
||||
existing.ObjectMeta.OwnerReferences = cm.ObjectMeta.OwnerReferences
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error provisioning ingress ConfigMap %q: %w", cm.Name, err)
|
||||
}
|
||||
}
|
||||
ss, err := pgStatefulSet(pg, r.tsNamespace, r.proxyImage, r.tsFirewallMode)
|
||||
|
||||
@@ -56,6 +56,10 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
|
||||
}
|
||||
tmpl.Spec.ServiceAccountName = pg.Name
|
||||
tmpl.Spec.InitContainers[0].Image = image
|
||||
proxyConfigVolName := pgEgressCMName(pg.Name)
|
||||
if pg.Spec.Type == tsapi.ProxyGroupTypeIngress {
|
||||
proxyConfigVolName = pgIngressCMName(pg.Name)
|
||||
}
|
||||
tmpl.Spec.Volumes = func() []corev1.Volume {
|
||||
var volumes []corev1.Volume
|
||||
for i := range pgReplicas(pg) {
|
||||
@@ -69,18 +73,16 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
|
||||
})
|
||||
}
|
||||
|
||||
if pg.Spec.Type == tsapi.ProxyGroupTypeEgress {
|
||||
volumes = append(volumes, corev1.Volume{
|
||||
Name: pgEgressCMName(pg.Name),
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: pgEgressCMName(pg.Name),
|
||||
},
|
||||
volumes = append(volumes, corev1.Volume{
|
||||
Name: proxyConfigVolName,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: proxyConfigVolName,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return volumes
|
||||
}()
|
||||
@@ -102,13 +104,11 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
|
||||
})
|
||||
}
|
||||
|
||||
if pg.Spec.Type == tsapi.ProxyGroupTypeEgress {
|
||||
mounts = append(mounts, corev1.VolumeMount{
|
||||
Name: pgEgressCMName(pg.Name),
|
||||
MountPath: "/etc/proxies",
|
||||
ReadOnly: true,
|
||||
})
|
||||
}
|
||||
mounts = append(mounts, corev1.VolumeMount{
|
||||
Name: proxyConfigVolName,
|
||||
MountPath: "/etc/proxies",
|
||||
ReadOnly: true,
|
||||
})
|
||||
|
||||
return mounts
|
||||
}()
|
||||
@@ -154,11 +154,15 @@ func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode string
|
||||
Value: kubetypes.AppProxyGroupEgress,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
} else { // ingress
|
||||
envs = append(envs, corev1.EnvVar{
|
||||
Name: "TS_INTERNAL_APP",
|
||||
Value: kubetypes.AppProxyGroupIngress,
|
||||
})
|
||||
},
|
||||
corev1.EnvVar{
|
||||
Name: "TS_SERVE_CONFIG",
|
||||
Value: fmt.Sprintf("/etc/proxies/%s", serveConfigKey),
|
||||
})
|
||||
}
|
||||
return append(c.Env, envs...)
|
||||
}()
|
||||
@@ -264,6 +268,16 @@ func pgEgressCM(pg *tsapi.ProxyGroup, namespace string) *corev1.ConfigMap {
|
||||
},
|
||||
}
|
||||
}
|
||||
func pgIngressCM(pg *tsapi.ProxyGroup, namespace string) *corev1.ConfigMap {
|
||||
return &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pgIngressCMName(pg.Name),
|
||||
Namespace: namespace,
|
||||
Labels: pgLabels(pg.Name, nil),
|
||||
OwnerReferences: pgOwnerReference(pg),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pgSecretLabels(pgName, typ string) map[string]string {
|
||||
return pgLabels(pgName, map[string]string{
|
||||
|
||||
@@ -332,7 +332,8 @@ func TestProxyGroupTypes(t *testing.T) {
|
||||
UID: "test-ingress-uid",
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupTypeIngress,
|
||||
Type: tsapi.ProxyGroupTypeIngress,
|
||||
Replicas: ptr.To[int32](0),
|
||||
},
|
||||
}
|
||||
if err := fc.Create(context.Background(), pg); err != nil {
|
||||
@@ -347,6 +348,34 @@ func TestProxyGroupTypes(t *testing.T) {
|
||||
t.Fatalf("failed to get StatefulSet: %v", err)
|
||||
}
|
||||
verifyEnvVar(t, sts, "TS_INTERNAL_APP", kubetypes.AppProxyGroupIngress)
|
||||
verifyEnvVar(t, sts, "TS_SERVE_CONFIG", "/etc/proxies/serve-config.json")
|
||||
|
||||
// Verify ConfigMap volume mount
|
||||
cmName := fmt.Sprintf("%s-ingress-config", pg.Name)
|
||||
expectedVolume := corev1.Volume{
|
||||
Name: cmName,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: cmName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expectedVolumeMount := corev1.VolumeMount{
|
||||
Name: cmName,
|
||||
MountPath: "/etc/proxies",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
if diff := cmp.Diff([]corev1.Volume{expectedVolume}, sts.Spec.Template.Spec.Volumes); diff != "" {
|
||||
t.Errorf("unexpected volumes (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff([]corev1.VolumeMount{expectedVolumeMount}, sts.Spec.Template.Spec.Containers[0].VolumeMounts); diff != "" {
|
||||
t.Errorf("unexpected volume mounts (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ func (sts tailscaleSTSReconciler) validate() error {
|
||||
}
|
||||
|
||||
// IsHTTPSEnabledOnTailnet reports whether HTTPS is enabled on the tailnet.
|
||||
func (a *tailscaleSTSReconciler) IsHTTPSEnabledOnTailnet() bool {
|
||||
return len(a.tsnetServer.CertDomains()) > 0
|
||||
func IsHTTPSEnabledOnTailnet(tsnetServer tsnetServer) bool {
|
||||
return len(tsnetServer.CertDomains()) > 0
|
||||
}
|
||||
|
||||
// Provision ensures that the StatefulSet for the given service is running and
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -737,6 +739,7 @@ type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
deleted []string
|
||||
vipServices map[string]*VIPService
|
||||
}
|
||||
type fakeTSNetServer struct {
|
||||
certDomains []string
|
||||
@@ -842,3 +845,50 @@ func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) getVIPServiceByName(ctx context.Context, name string) (*VIPService, error) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.vipServices == nil {
|
||||
return nil, &tailscale.ErrResponse{Status: http.StatusNotFound}
|
||||
}
|
||||
svc, ok := c.vipServices[name]
|
||||
if !ok {
|
||||
return nil, &tailscale.ErrResponse{Status: http.StatusNotFound}
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.vipServices == nil {
|
||||
c.vipServices = make(map[string]*VIPService)
|
||||
}
|
||||
c.vipServices[svc.Name] = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) deleteVIPServiceByName(ctx context.Context, name string) error {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.vipServices != nil {
|
||||
delete(c.vipServices, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeLocalClient struct {
|
||||
status *ipnstate.Status
|
||||
}
|
||||
|
||||
func (f *fakeLocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
if f.status == nil {
|
||||
return &ipnstate.Status{
|
||||
Self: &ipnstate.PeerStatus{
|
||||
DNSName: "test-node.test.ts.net.",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
185
cmd/k8s-operator/tsclient.go
Normal file
185
cmd/k8s-operator/tsclient.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// defaultTailnet is a value that can be used in Tailscale API calls instead of tailnet name to indicate that the API
|
||||
// call should be performed on the default tailnet for the provided credentials.
|
||||
const (
|
||||
defaultTailnet = "-"
|
||||
defaultBaseURL = "https://api.tailscale.com"
|
||||
)
|
||||
|
||||
func newTSClient(ctx context.Context, clientIDPath, clientSecretPath string) (tsClient, error) {
|
||||
clientID, err := os.ReadFile(clientIDPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading client ID %q: %w", clientIDPath, err)
|
||||
}
|
||||
clientSecret, err := os.ReadFile(clientSecretPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading client secret %q: %w", clientSecretPath, err)
|
||||
}
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: string(clientID),
|
||||
ClientSecret: string(clientSecret),
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
}
|
||||
c := tailscale.NewClient(defaultTailnet, nil)
|
||||
c.UserAgent = "tailscale-k8s-operator"
|
||||
c.HTTPClient = credentials.Client(ctx)
|
||||
tsc := &tsClientImpl{
|
||||
Client: c,
|
||||
baseURL: defaultBaseURL,
|
||||
tailnet: defaultTailnet,
|
||||
}
|
||||
return tsc, nil
|
||||
}
|
||||
|
||||
type tsClient interface {
|
||||
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
||||
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
|
||||
DeleteDevice(ctx context.Context, nodeStableID string) error
|
||||
getVIPServiceByName(ctx context.Context, name string) (*VIPService, error)
|
||||
createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error
|
||||
deleteVIPServiceByName(ctx context.Context, name string) error
|
||||
}
|
||||
|
||||
type tsClientImpl struct {
|
||||
*tailscale.Client
|
||||
baseURL string
|
||||
tailnet string
|
||||
}
|
||||
|
||||
// VIPService is a Tailscale VIPService with Tailscale API JSON representation.
|
||||
type VIPService struct {
|
||||
// Name is the leftmost label of the DNS name of the VIP service.
|
||||
// Name is required.
|
||||
Name string `json:"name,omitempty"`
|
||||
// Addrs are the IP addresses of the VIP Service. There are two addresses:
|
||||
// the first is IPv4 and the second is IPv6.
|
||||
// When creating a new VIP Service, the IP addresses are optional: if no
|
||||
// addresses are specified then they will be selected. If an IPv4 address is
|
||||
// specified at index 0, then that address will attempt to be used. An IPv6
|
||||
// address can not be specified upon creation.
|
||||
Addrs []string `json:"addrs,omitempty"`
|
||||
// Comment is an optional text string for display in the admin panel.
|
||||
Comment string `json:"comment,omitempty"`
|
||||
// Ports are the ports of a VIPService that will be configured via Tailscale serve config.
|
||||
// If set, any node wishing to advertise this VIPService must have this port configured via Tailscale serve.
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
// Tags are optional ACL tags that will be applied to the VIPService.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// GetVIPServiceByName retrieves a VIPService by its name. It returns 404 if the VIPService is not found.
|
||||
func (c *tsClientImpl) getVIPServiceByName(ctx context.Context, name string) (*VIPService, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(name))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.GET, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making Tailsale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
svc := &VIPService{}
|
||||
if err := json.Unmarshal(b, svc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateVIPServiceByName creates or updates a VIPService by its name. Caller must ensure that, if the
|
||||
// VIPService already exists, the VIPService is fetched first to ensure that any auto-allocated IP addresses are not
|
||||
// lost during the update. If the VIPService was created without any IP addresses explicitly set (so that they were
|
||||
// auto-allocated by Tailscale) any subsequent request to this function that does not set any IP addresses will error.
|
||||
func (c *tsClientImpl) createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error {
|
||||
data, err := json.Marshal(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(svc.Name))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.PUT, path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making Tailscale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteVIPServiceByName deletes a VIPService by its name. It returns an error if the VIPService
|
||||
// does not exist or if the deletion fails.
|
||||
func (c *tsClientImpl) deleteVIPServiceByName(ctx context.Context, name string) error {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(name))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating new HTTP request: %w", err)
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making Tailscale API request: %w", err)
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *tsClientImpl) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, fmt.Errorf("error actually doing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
return b, resp, err
|
||||
}
|
||||
|
||||
// handleErrorResponse decodes the error message from the server and returns
|
||||
// an ErrResponse from it.
|
||||
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||
var errResp tailscale.ErrResponse
|
||||
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||
return err
|
||||
}
|
||||
errResp.Status = resp.StatusCode
|
||||
return errResp
|
||||
}
|
||||
@@ -21,23 +21,21 @@ var advertiseArgs struct {
|
||||
|
||||
// TODO(naman): This flag may move to set.go or serve_v2.go after the WIPCode
|
||||
// envknob is not needed.
|
||||
var advertiseCmd = &ffcli.Command{
|
||||
Name: "advertise",
|
||||
ShortUsage: "tailscale advertise --services=<services>",
|
||||
ShortHelp: "Advertise this node as a destination for a service",
|
||||
Exec: runAdvertise,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("advertise")
|
||||
fs.StringVar(&advertiseArgs.services, "services", "", "comma-separated services to advertise; each must start with \"svc:\" (e.g. \"svc:idp,svc:nas,svc:database\")")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
func maybeAdvertiseCmd() []*ffcli.Command {
|
||||
func advertiseCmd() *ffcli.Command {
|
||||
if !envknob.UseWIPCode() {
|
||||
return nil
|
||||
}
|
||||
return []*ffcli.Command{advertiseCmd}
|
||||
return &ffcli.Command{
|
||||
Name: "advertise",
|
||||
ShortUsage: "tailscale advertise --services=<services>",
|
||||
ShortHelp: "Advertise this node as a destination for a service",
|
||||
Exec: runAdvertise,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("advertise")
|
||||
fs.StringVar(&advertiseArgs.services, "services", "", "comma-separated services to advertise; each must start with \"svc:\" (e.g. \"svc:idp,svc:nas,svc:database\")")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
func runAdvertise(ctx context.Context, args []string) error {
|
||||
@@ -68,7 +66,7 @@ func parseServiceNames(servicesArg string) ([]string, error) {
|
||||
if servicesArg != "" {
|
||||
services = strings.Split(servicesArg, ",")
|
||||
for _, svc := range services {
|
||||
err := tailcfg.CheckServiceName(svc)
|
||||
err := tailcfg.ServiceName(svc).Validate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service %q: %s", svc, err)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"tailscale.com/cmd/tailscale/cli/ffcomplete"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/util/slicesx"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
@@ -182,14 +183,14 @@ For help on subcommands, add --help after: "tailscale status --help".
|
||||
This CLI is still under active development. Commands and flags will
|
||||
change in the future.
|
||||
`),
|
||||
Subcommands: append([]*ffcli.Command{
|
||||
Subcommands: nonNilCmds(
|
||||
upCmd,
|
||||
downCmd,
|
||||
setCmd,
|
||||
loginCmd,
|
||||
logoutCmd,
|
||||
switchCmd,
|
||||
configureCmd,
|
||||
configureCmd(),
|
||||
syspolicyCmd,
|
||||
netcheckCmd,
|
||||
ipCmd,
|
||||
@@ -214,7 +215,9 @@ change in the future.
|
||||
debugCmd,
|
||||
driveCmd,
|
||||
idTokenCmd,
|
||||
}, maybeAdvertiseCmd()...),
|
||||
advertiseCmd(),
|
||||
configureHostCmd(),
|
||||
),
|
||||
FlagSet: rootfs,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
@@ -224,10 +227,6 @@ change in the future.
|
||||
},
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
|
||||
}
|
||||
|
||||
walkCommands(rootCmd, func(w cmdWalk) bool {
|
||||
if w.UsageFunc == nil {
|
||||
w.UsageFunc = usageFunc
|
||||
@@ -239,6 +238,10 @@ change in the future.
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func nonNilCmds(cmds ...*ffcli.Command) []*ffcli.Command {
|
||||
return slicesx.AppendNonzero(cmds[:0], cmds)
|
||||
}
|
||||
|
||||
func fatalf(format string, a ...any) {
|
||||
if Fatalf != nil {
|
||||
Fatalf(format, a...)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/ipn"
|
||||
@@ -1525,3 +1526,45 @@ func TestHelpAlias(t *testing.T) {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocs(t *testing.T) {
|
||||
root := newRootCmd()
|
||||
check := func(t *testing.T, c *ffcli.Command) {
|
||||
shortVerb, _, ok := strings.Cut(c.ShortHelp, " ")
|
||||
if !ok || shortVerb == "" {
|
||||
t.Errorf("couldn't find verb+space in ShortHelp")
|
||||
} else {
|
||||
if strings.HasSuffix(shortVerb, ".") {
|
||||
t.Errorf("ShortHelp shouldn't end in period; got %q", c.ShortHelp)
|
||||
}
|
||||
if b := shortVerb[0]; b >= 'a' && b <= 'z' {
|
||||
t.Errorf("ShortHelp should start with upper-case letter; got %q", c.ShortHelp)
|
||||
}
|
||||
if strings.HasSuffix(shortVerb, "s") && shortVerb != "Does" {
|
||||
t.Errorf("verb %q ending in 's' is unexpected, from %q", shortVerb, c.ShortHelp)
|
||||
}
|
||||
}
|
||||
|
||||
name := t.Name()
|
||||
wantPfx := strings.ReplaceAll(strings.TrimPrefix(name, "TestDocs/"), "/", " ")
|
||||
switch name {
|
||||
case "TestDocs/tailscale/completion/bash",
|
||||
"TestDocs/tailscale/completion/zsh":
|
||||
wantPfx = "" // special-case exceptions
|
||||
}
|
||||
if !strings.HasPrefix(c.ShortUsage, wantPfx) {
|
||||
t.Errorf("ShortUsage should start with %q; got %q", wantPfx, c.ShortUsage)
|
||||
}
|
||||
}
|
||||
|
||||
var walk func(t *testing.T, c *ffcli.Command)
|
||||
walk = func(t *testing.T, c *ffcli.Command) {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
check(t, c)
|
||||
for _, sub := range c.Subcommands {
|
||||
walk(t, sub)
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(t, root)
|
||||
}
|
||||
|
||||
@@ -20,33 +20,31 @@ import (
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configureCmd.Subcommands = append(configureCmd.Subcommands, configureKubeconfigCmd)
|
||||
}
|
||||
|
||||
var configureKubeconfigCmd = &ffcli.Command{
|
||||
Name: "kubeconfig",
|
||||
ShortHelp: "[ALPHA] Connect to a Kubernetes cluster using a Tailscale Auth Proxy",
|
||||
ShortUsage: "tailscale configure kubeconfig <hostname-or-fqdn>",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
func configureKubeconfigCmd() *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "kubeconfig",
|
||||
ShortHelp: "[ALPHA] Connect to a Kubernetes cluster using a Tailscale Auth Proxy",
|
||||
ShortUsage: "tailscale configure kubeconfig <hostname-or-fqdn>",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
Run this command to configure kubectl to connect to a Kubernetes cluster over Tailscale.
|
||||
|
||||
The hostname argument should be set to the Tailscale hostname of the peer running as an auth proxy in the cluster.
|
||||
|
||||
See: https://tailscale.com/s/k8s-auth-proxy
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("kubeconfig")
|
||||
return fs
|
||||
})(),
|
||||
Exec: runConfigureKubeconfig,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("kubeconfig")
|
||||
return fs
|
||||
})(),
|
||||
Exec: runConfigureKubeconfig,
|
||||
}
|
||||
}
|
||||
|
||||
// kubeconfigPath returns the path to the kubeconfig file for the current user.
|
||||
func kubeconfigPath() (string, error) {
|
||||
if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" {
|
||||
if version.IsSandboxedMacOS() {
|
||||
return "", errors.New("$KUBECONFIG is incompatible with the App Store version")
|
||||
return "", errors.New("cannot read $KUBECONFIG on GUI builds of the macOS client: this requires the open-source tailscaled distribution")
|
||||
}
|
||||
var out string
|
||||
for _, out = range filepath.SplitList(kubeconfig) {
|
||||
|
||||
13
cmd/tailscale/cli/configure-kube_omit.go
Normal file
13
cmd/tailscale/cli/configure-kube_omit.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build ts_omit_kube
|
||||
|
||||
package cli
|
||||
|
||||
import "github.com/peterbourgon/ff/v3/ffcli"
|
||||
|
||||
func configureKubeconfigCmd() *ffcli.Command {
|
||||
// omitted from the build when the ts_omit_kube build tag is set
|
||||
return nil
|
||||
}
|
||||
@@ -22,22 +22,27 @@ import (
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
var synologyConfigureCertCmd = &ffcli.Command{
|
||||
Name: "synology-cert",
|
||||
Exec: runConfigureSynologyCert,
|
||||
ShortHelp: "Configure Synology with a TLS certificate for your tailnet",
|
||||
ShortUsage: "synology-cert [--domain <domain>]",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
func synologyConfigureCertCmd() *ffcli.Command {
|
||||
if runtime.GOOS != "linux" || distro.Get() != distro.Synology {
|
||||
return nil
|
||||
}
|
||||
return &ffcli.Command{
|
||||
Name: "synology-cert",
|
||||
Exec: runConfigureSynologyCert,
|
||||
ShortHelp: "Configure Synology with a TLS certificate for your tailnet",
|
||||
ShortUsage: "synology-cert [--domain <domain>]",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
This command is intended to run periodically as root on a Synology device to
|
||||
create or refresh the TLS certificate for the tailnet domain.
|
||||
|
||||
See: https://tailscale.com/kb/1153/enabling-https
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("synology-cert")
|
||||
fs.StringVar(&synologyConfigureCertArgs.domain, "domain", "", "Tailnet domain to create or refresh certificates for. Ignored if only one domain exists.")
|
||||
return fs
|
||||
})(),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("synology-cert")
|
||||
fs.StringVar(&synologyConfigureCertArgs.domain, "domain", "", "Tailnet domain to create or refresh certificates for. Ignored if only one domain exists.")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
var synologyConfigureCertArgs struct {
|
||||
|
||||
@@ -21,34 +21,49 @@ import (
|
||||
// configureHostCmd is the "tailscale configure-host" command which was once
|
||||
// used to configure Synology devices, but is now a compatibility alias to
|
||||
// "tailscale configure synology".
|
||||
var configureHostCmd = &ffcli.Command{
|
||||
Name: "configure-host",
|
||||
Exec: runConfigureSynology,
|
||||
ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage,
|
||||
ShortHelp: synologyConfigureCmd.ShortHelp,
|
||||
LongHelp: hidden + synologyConfigureCmd.LongHelp,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("configure-host")
|
||||
return fs
|
||||
})(),
|
||||
//
|
||||
// It returns nil if the actual "tailscale configure synology" command is not
|
||||
// available.
|
||||
func configureHostCmd() *ffcli.Command {
|
||||
synologyConfigureCmd := synologyConfigureCmd()
|
||||
if synologyConfigureCmd == nil {
|
||||
// No need to offer this compatibility alias if the actual command is not available.
|
||||
return nil
|
||||
}
|
||||
return &ffcli.Command{
|
||||
Name: "configure-host",
|
||||
Exec: runConfigureSynology,
|
||||
ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage,
|
||||
ShortHelp: synologyConfigureCmd.ShortHelp,
|
||||
LongHelp: hidden + synologyConfigureCmd.LongHelp,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("configure-host")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
var synologyConfigureCmd = &ffcli.Command{
|
||||
Name: "synology",
|
||||
Exec: runConfigureSynology,
|
||||
ShortUsage: "tailscale configure synology",
|
||||
ShortHelp: "Configure Synology to enable outbound connections",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
func synologyConfigureCmd() *ffcli.Command {
|
||||
if runtime.GOOS != "linux" || distro.Get() != distro.Synology {
|
||||
return nil
|
||||
}
|
||||
return &ffcli.Command{
|
||||
Name: "synology",
|
||||
Exec: runConfigureSynology,
|
||||
ShortUsage: "tailscale configure synology",
|
||||
ShortHelp: "Configure Synology to enable outbound connections",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
This command is intended to run at boot as root on a Synology device to
|
||||
create the /dev/net/tun device and give the tailscaled binary permission
|
||||
to use it.
|
||||
|
||||
See: https://tailscale.com/s/synology-outbound
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("synology")
|
||||
return fs
|
||||
})(),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("synology")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
func runConfigureSynology(ctx context.Context, args []string) error {
|
||||
|
||||
@@ -5,32 +5,41 @@ package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
var configureCmd = &ffcli.Command{
|
||||
Name: "configure",
|
||||
ShortUsage: "tailscale configure <subcommand>",
|
||||
ShortHelp: "[ALPHA] Configure the host to enable more Tailscale features",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
func configureCmd() *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "configure",
|
||||
ShortUsage: "tailscale configure <subcommand>",
|
||||
ShortHelp: "Configure the host to enable more Tailscale features",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
The 'configure' set of commands are intended to provide a way to enable different
|
||||
services on the host to use Tailscale in more ways.
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("configure")
|
||||
return fs
|
||||
})(),
|
||||
Subcommands: configureSubcommands(),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("configure")
|
||||
return fs
|
||||
})(),
|
||||
Subcommands: nonNilCmds(
|
||||
configureKubeconfigCmd(),
|
||||
synologyConfigureCmd(),
|
||||
synologyConfigureCertCmd(),
|
||||
ccall(maybeSysExtCmd),
|
||||
ccall(maybeVPNConfigCmd),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func configureSubcommands() (out []*ffcli.Command) {
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
|
||||
out = append(out, synologyConfigureCmd)
|
||||
out = append(out, synologyConfigureCertCmd)
|
||||
// ccall calls the function f if it is non-nil, and returns its result.
|
||||
//
|
||||
// It returns the zero value of the type T if f is nil.
|
||||
func ccall[T any](f func() T) T {
|
||||
var zero T
|
||||
if f == nil {
|
||||
return zero
|
||||
}
|
||||
return out
|
||||
return f()
|
||||
}
|
||||
|
||||
11
cmd/tailscale/cli/configure_apple-all.go
Normal file
11
cmd/tailscale/cli/configure_apple-all.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import "github.com/peterbourgon/ff/v3/ffcli"
|
||||
|
||||
var (
|
||||
maybeSysExtCmd func() *ffcli.Command // non-nil only on macOS, see configure_apple.go
|
||||
maybeVPNConfigCmd func() *ffcli.Command // non-nil only on macOS, see configure_apple.go
|
||||
)
|
||||
97
cmd/tailscale/cli/configure_apple.go
Normal file
97
cmd/tailscale/cli/configure_apple.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
maybeSysExtCmd = sysExtCmd
|
||||
maybeVPNConfigCmd = vpnConfigCmd
|
||||
}
|
||||
|
||||
// Functions in this file provide a dummy Exec function that only prints an error message for users of the open-source
|
||||
// tailscaled distribution. On GUI builds, the Swift code in the macOS client handles these commands by not passing the
|
||||
// flow of execution to the CLI.
|
||||
|
||||
// sysExtCmd returns a command for managing the Tailscale system extension on macOS
|
||||
// (for the Standalone variant of the client only).
|
||||
func sysExtCmd() *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "sysext",
|
||||
ShortUsage: "tailscale configure sysext [activate|deactivate|status]",
|
||||
ShortHelp: "Manage the system extension for macOS (Standalone variant)",
|
||||
LongHelp: "The sysext set of commands provides a way to activate, deactivate, or manage the state of the Tailscale system extension on macOS. " +
|
||||
"This is only relevant if you are running the Standalone variant of the Tailscale client for macOS. " +
|
||||
"To access more detailed information about system extensions installed on this Mac, run 'systemextensionsctl list'.",
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "activate",
|
||||
ShortUsage: "tailscale configure sysext activate",
|
||||
ShortHelp: "Register the Tailscale system extension with macOS.",
|
||||
LongHelp: "This command registers the Tailscale system extension with macOS. To run Tailscale, you'll also need to install the VPN configuration separately (run `tailscale configure vpn-config install`). After running this command, you need to approve the extension in System Settings > Login Items and Extensions > Network Extensions.",
|
||||
Exec: requiresStandalone,
|
||||
},
|
||||
{
|
||||
Name: "deactivate",
|
||||
ShortUsage: "tailscale configure sysext deactivate",
|
||||
ShortHelp: "Deactivate the Tailscale system extension on macOS",
|
||||
LongHelp: "This command deactivates the Tailscale system extension on macOS. To completely remove Tailscale, you'll also need to delete the VPN configuration separately (use `tailscale configure vpn-config uninstall`).",
|
||||
Exec: requiresStandalone,
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
ShortUsage: "tailscale configure sysext status",
|
||||
ShortHelp: "Print the enablement status of the Tailscale system extension",
|
||||
LongHelp: "This command prints the enablement status of the Tailscale system extension. If the extension is not enabled, run `tailscale sysext activate` to enable it.",
|
||||
Exec: requiresStandalone,
|
||||
},
|
||||
},
|
||||
Exec: requiresStandalone,
|
||||
}
|
||||
}
|
||||
|
||||
// vpnConfigCmd returns a command for managing the Tailscale VPN configuration on macOS
|
||||
// (the entry that appears in System Settings > VPN).
|
||||
func vpnConfigCmd() *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "mac-vpn",
|
||||
ShortUsage: "tailscale configure mac-vpn [install|uninstall]",
|
||||
ShortHelp: "Manage the VPN configuration on macOS (App Store and Standalone variants)",
|
||||
LongHelp: "The vpn-config set of commands provides a way to add or remove the Tailscale VPN configuration from the macOS settings. This is the entry that appears in System Settings > VPN.",
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "install",
|
||||
ShortUsage: "tailscale configure mac-vpn install",
|
||||
ShortHelp: "Write the Tailscale VPN configuration to the macOS settings",
|
||||
LongHelp: "This command writes the Tailscale VPN configuration to the macOS settings. This is the entry that appears in System Settings > VPN. If you are running the Standalone variant of the client, you'll also need to install the system extension separately (run `tailscale configure sysext activate`).",
|
||||
Exec: requiresGUI,
|
||||
},
|
||||
{
|
||||
Name: "uninstall",
|
||||
ShortUsage: "tailscale configure mac-vpn uninstall",
|
||||
ShortHelp: "Delete the Tailscale VPN configuration from the macOS settings",
|
||||
LongHelp: "This command removes the Tailscale VPN configuration from the macOS settings. This is the entry that appears in System Settings > VPN. If you are running the Standalone variant of the client, you'll also need to deactivate the system extension separately (run `tailscale configure sysext deactivate`).",
|
||||
Exec: requiresGUI,
|
||||
},
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
return errors.New("unsupported command: requires a GUI build of the macOS client")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func requiresStandalone(ctx context.Context, args []string) error {
|
||||
return errors.New("unsupported command: requires the Standalone (.pkg installer) GUI build of the client")
|
||||
}
|
||||
|
||||
func requiresGUI(ctx context.Context, args []string) error {
|
||||
return errors.New("unsupported command: requires a GUI build of the macOS client")
|
||||
}
|
||||
@@ -289,7 +289,7 @@ var debugCmd = &ffcli.Command{
|
||||
Name: "capture",
|
||||
ShortUsage: "tailscale debug capture",
|
||||
Exec: runCapture,
|
||||
ShortHelp: "Streams pcaps for debugging",
|
||||
ShortHelp: "Stream pcaps for debugging",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("capture")
|
||||
fs.StringVar(&captureArgs.outFile, "o", "", "path to stream the pcap (or - for stdout), leave empty to start wireshark")
|
||||
@@ -315,13 +315,13 @@ var debugCmd = &ffcli.Command{
|
||||
Name: "peer-endpoint-changes",
|
||||
ShortUsage: "tailscale debug peer-endpoint-changes <hostname-or-IP>",
|
||||
Exec: runPeerEndpointChanges,
|
||||
ShortHelp: "Prints debug information about a peer's endpoint changes",
|
||||
ShortHelp: "Print debug information about a peer's endpoint changes",
|
||||
},
|
||||
{
|
||||
Name: "dial-types",
|
||||
ShortUsage: "tailscale debug dial-types <hostname-or-IP> <port>",
|
||||
Exec: runDebugDialTypes,
|
||||
ShortHelp: "Prints debug information about connecting to a given host or IP",
|
||||
ShortHelp: "Print debug information about connecting to a given host or IP",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("dial-types")
|
||||
fs.StringVar(&debugDialTypesArgs.network, "network", "tcp", `network type to dial ("tcp", "udp", etc.)`)
|
||||
@@ -342,7 +342,7 @@ var debugCmd = &ffcli.Command{
|
||||
{
|
||||
Name: "go-buildinfo",
|
||||
ShortUsage: "tailscale debug go-buildinfo",
|
||||
ShortHelp: "Prints Go's runtime/debug.BuildInfo",
|
||||
ShortHelp: "Print Go's runtime/debug.BuildInfo",
|
||||
Exec: runGoBuildInfo,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var dnsCmd = &ffcli.Command{
|
||||
Name: "status",
|
||||
ShortUsage: "tailscale dns status [--all]",
|
||||
Exec: runDNSStatus,
|
||||
ShortHelp: "Prints the current DNS status and configuration",
|
||||
ShortHelp: "Print the current DNS status and configuration",
|
||||
LongHelp: dnsStatusLongHelp(),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("status")
|
||||
|
||||
@@ -41,7 +41,7 @@ func exitNodeCmd() *ffcli.Command {
|
||||
{
|
||||
Name: "suggest",
|
||||
ShortUsage: "tailscale exit-node suggest",
|
||||
ShortHelp: "Suggests the best available exit node",
|
||||
ShortHelp: "Suggest the best available exit node",
|
||||
Exec: runExitNodeSuggest,
|
||||
}},
|
||||
(func() []*ffcli.Command {
|
||||
|
||||
@@ -33,13 +33,13 @@ https://tailscale.com/s/client-metrics
|
||||
Name: "print",
|
||||
ShortUsage: "tailscale metrics print",
|
||||
Exec: runMetricsPrint,
|
||||
ShortHelp: "Prints current metric values in the Prometheus text exposition format",
|
||||
ShortHelp: "Print current metric values in Prometheus text format",
|
||||
},
|
||||
{
|
||||
Name: "write",
|
||||
ShortUsage: "tailscale metrics write <path>",
|
||||
Exec: runMetricsWrite,
|
||||
ShortHelp: "Writes metric values to a file",
|
||||
ShortHelp: "Write metric values to a file",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
|
||||
The 'tailscale metrics write' command writes metric values to a text file provided as its
|
||||
|
||||
@@ -191,8 +191,7 @@ var nlStatusArgs struct {
|
||||
var nlStatusCmd = &ffcli.Command{
|
||||
Name: "status",
|
||||
ShortUsage: "tailscale lock status",
|
||||
ShortHelp: "Outputs the state of tailnet lock",
|
||||
LongHelp: "Outputs the state of tailnet lock",
|
||||
ShortHelp: "Output the state of tailnet lock",
|
||||
Exec: runNetworkLockStatus,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("lock status")
|
||||
@@ -293,8 +292,7 @@ func runNetworkLockStatus(ctx context.Context, args []string) error {
|
||||
var nlAddCmd = &ffcli.Command{
|
||||
Name: "add",
|
||||
ShortUsage: "tailscale lock add <public-key>...",
|
||||
ShortHelp: "Adds one or more trusted signing keys to tailnet lock",
|
||||
LongHelp: "Adds one or more trusted signing keys to tailnet lock",
|
||||
ShortHelp: "Add one or more trusted signing keys to tailnet lock",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
return runNetworkLockModify(ctx, args, nil)
|
||||
},
|
||||
@@ -307,8 +305,7 @@ var nlRemoveArgs struct {
|
||||
var nlRemoveCmd = &ffcli.Command{
|
||||
Name: "remove",
|
||||
ShortUsage: "tailscale lock remove [--re-sign=false] <public-key>...",
|
||||
ShortHelp: "Removes one or more trusted signing keys from tailnet lock",
|
||||
LongHelp: "Removes one or more trusted signing keys from tailnet lock",
|
||||
ShortHelp: "Remove one or more trusted signing keys from tailnet lock",
|
||||
Exec: runNetworkLockRemove,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("lock remove")
|
||||
@@ -448,7 +445,7 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err
|
||||
var nlSignCmd = &ffcli.Command{
|
||||
Name: "sign",
|
||||
ShortUsage: "tailscale lock sign <node-key> [<rotation-key>]\ntailscale lock sign <auth-key>",
|
||||
ShortHelp: "Signs a node or pre-approved auth key",
|
||||
ShortHelp: "Sign a node or pre-approved auth key",
|
||||
LongHelp: `Either:
|
||||
- signs a node key and transmits the signature to the coordination
|
||||
server, or
|
||||
@@ -510,7 +507,7 @@ func runNetworkLockSign(ctx context.Context, args []string) error {
|
||||
var nlDisableCmd = &ffcli.Command{
|
||||
Name: "disable",
|
||||
ShortUsage: "tailscale lock disable <disablement-secret>",
|
||||
ShortHelp: "Consumes a disablement secret to shut down tailnet lock for the tailnet",
|
||||
ShortHelp: "Consume a disablement secret to shut down tailnet lock for the tailnet",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
|
||||
The 'tailscale lock disable' command uses the specified disablement
|
||||
@@ -539,7 +536,7 @@ func runNetworkLockDisable(ctx context.Context, args []string) error {
|
||||
var nlLocalDisableCmd = &ffcli.Command{
|
||||
Name: "local-disable",
|
||||
ShortUsage: "tailscale lock local-disable",
|
||||
ShortHelp: "Disables tailnet lock for this node only",
|
||||
ShortHelp: "Disable tailnet lock for this node only",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
|
||||
The 'tailscale lock local-disable' command disables tailnet lock for only
|
||||
@@ -561,8 +558,8 @@ func runNetworkLockLocalDisable(ctx context.Context, args []string) error {
|
||||
var nlDisablementKDFCmd = &ffcli.Command{
|
||||
Name: "disablement-kdf",
|
||||
ShortUsage: "tailscale lock disablement-kdf <hex-encoded-disablement-secret>",
|
||||
ShortHelp: "Computes a disablement value from a disablement secret (advanced users only)",
|
||||
LongHelp: "Computes a disablement value from a disablement secret (advanced users only)",
|
||||
ShortHelp: "Compute a disablement value from a disablement secret (advanced users only)",
|
||||
LongHelp: "Compute a disablement value from a disablement secret (advanced users only)",
|
||||
Exec: runNetworkLockDisablementKDF,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
var switchCmd = &ffcli.Command{
|
||||
Name: "switch",
|
||||
ShortUsage: "tailscale switch <id>",
|
||||
ShortHelp: "Switches to a different Tailscale account",
|
||||
ShortHelp: "Switch to a different Tailscale account",
|
||||
LongHelp: `"tailscale switch" switches between logged in accounts. You can
|
||||
use the ID that's returned from 'tailnet switch -list'
|
||||
to pick which profile you want to switch to. Alternatively, you
|
||||
|
||||
@@ -31,7 +31,7 @@ var syspolicyCmd = &ffcli.Command{
|
||||
Name: "list",
|
||||
ShortUsage: "tailscale syspolicy list",
|
||||
Exec: runSysPolicyList,
|
||||
ShortHelp: "Prints effective policy settings",
|
||||
ShortHelp: "Print effective policy settings",
|
||||
LongHelp: "The 'tailscale syspolicy list' subcommand displays the effective policy settings and their sources (e.g., MDM or environment variables).",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("syspolicy list")
|
||||
@@ -43,7 +43,7 @@ var syspolicyCmd = &ffcli.Command{
|
||||
Name: "reload",
|
||||
ShortUsage: "tailscale syspolicy reload",
|
||||
Exec: runSysPolicyReload,
|
||||
ShortHelp: "Forces a reload of policy settings, even if no changes are detected, and prints the result",
|
||||
ShortHelp: "Force a reload of policy settings, even if no changes are detected, and prints the result",
|
||||
LongHelp: "The 'tailscale syspolicy reload' subcommand forces a reload of policy settings, even if no changes are detected, and prints the result.",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("syspolicy reload")
|
||||
|
||||
@@ -97,6 +97,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/kube/kubetypes from tailscale.com/envknob
|
||||
tailscale.com/licenses from tailscale.com/client/web+
|
||||
tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/captivedetection from tailscale.com/net/netcheck
|
||||
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
|
||||
tailscale.com/net/dnscache from tailscale.com/control/controlhttp+
|
||||
|
||||
@@ -112,7 +112,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
github.com/gorilla/securecookie from github.com/gorilla/csrf
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
|
||||
L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns
|
||||
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
|
||||
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/feature/tap
|
||||
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
|
||||
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
|
||||
L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4
|
||||
@@ -127,7 +127,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
|
||||
github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe
|
||||
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
|
||||
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
|
||||
github.com/kortschak/wol from tailscale.com/feature/wakeonlan
|
||||
LD github.com/kr/fs from github.com/pkg/sftp
|
||||
L github.com/mdlayher/genetlink from tailscale.com/net/tstun
|
||||
L 💣 github.com/mdlayher/netlink from github.com/google/nftables+
|
||||
@@ -214,7 +214,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/feature/tap+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
@@ -259,6 +259,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/drive/driveimpl/shared from tailscale.com/drive/driveimpl+
|
||||
tailscale.com/envknob from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob/featureknob from tailscale.com/client/web+
|
||||
tailscale.com/feature from tailscale.com/feature/wakeonlan+
|
||||
tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled
|
||||
L tailscale.com/feature/tap from tailscale.com/feature/condregister
|
||||
tailscale.com/feature/wakeonlan from tailscale.com/feature/condregister
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
@@ -286,6 +290,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+
|
||||
tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/connstats from tailscale.com/net/tstun+
|
||||
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/drive/driveimpl"
|
||||
"tailscale.com/envknob"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
|
||||
@@ -55,6 +55,7 @@ import (
|
||||
"tailscale.com/util/osdiag"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/util/winutil/gp"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/wf"
|
||||
)
|
||||
@@ -70,6 +71,22 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// permitPolicyLocks is a function to be called to lift the restriction on acquiring
|
||||
// [gp.PolicyLock]s once the service is running.
|
||||
// It is safe to be called multiple times.
|
||||
var permitPolicyLocks = func() {}
|
||||
|
||||
func init() {
|
||||
if isWindowsService() {
|
||||
// We prevent [gp.PolicyLock]s from being acquired until the service enters the running state.
|
||||
// Otherwise, if tailscaled starts due to a GPSI policy installing Tailscale, it may deadlock
|
||||
// while waiting for the write counterpart of the GP lock to be released by Group Policy,
|
||||
// which is itself waiting for the installation to complete and tailscaled to start.
|
||||
// See tailscale/tailscale#14416 for more information.
|
||||
permitPolicyLocks = gp.RestrictPolicyLocks()
|
||||
}
|
||||
}
|
||||
|
||||
const serviceName = "Tailscale"
|
||||
|
||||
// Application-defined command codes between 128 and 255
|
||||
@@ -109,13 +126,13 @@ func tstunNewWithWindowsRetries(logf logger.Logf, tunName string) (_ tun.Device,
|
||||
}
|
||||
}
|
||||
|
||||
func isWindowsService() bool {
|
||||
var isWindowsService = sync.OnceValue(func() bool {
|
||||
v, err := svc.IsWindowsService()
|
||||
if err != nil {
|
||||
log.Fatalf("svc.IsWindowsService failed: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
})
|
||||
|
||||
// syslogf is a logger function that writes to the Windows event log (ie, the
|
||||
// one that you see in the Windows Event Viewer). tailscaled may optionally
|
||||
@@ -180,6 +197,10 @@ func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, ch
|
||||
changes <- svc.Status{State: svc.Running, Accepts: svcAccepts}
|
||||
syslogf("Service running")
|
||||
|
||||
// It is safe to allow GP locks to be acquired now that the service
|
||||
// is running.
|
||||
permitPolicyLocks()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-doneCh:
|
||||
|
||||
@@ -90,11 +90,11 @@ binary-extensions@^2.0.0:
|
||||
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
|
||||
|
||||
braces@^3.0.2, braces@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
||||
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
fill-range "^7.1.1"
|
||||
|
||||
camelcase-css@^2.0.1:
|
||||
version "2.0.1"
|
||||
@@ -231,10 +231,10 @@ fastq@^1.6.0:
|
||||
dependencies:
|
||||
reusify "^1.0.4"
|
||||
|
||||
fill-range@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
|
||||
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
|
||||
fill-range@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
|
||||
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
@@ -349,9 +349,9 @@ minimist@^1.2.6:
|
||||
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
|
||||
|
||||
nanoid@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||
version "3.3.8"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
|
||||
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
|
||||
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
|
||||
@@ -195,6 +195,10 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
|
||||
ms.updateStateFromResponse(resp)
|
||||
|
||||
// Occasionally clean up old userprofile if it grows too much
|
||||
// from e.g. ephemeral tagged nodes.
|
||||
ms.cleanLastUserProfile()
|
||||
|
||||
if ms.tryHandleIncrementally(resp) {
|
||||
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
|
||||
return nil
|
||||
@@ -292,7 +296,6 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
|
||||
for _, up := range resp.UserProfiles {
|
||||
ms.lastUserProfile[up.ID] = up
|
||||
}
|
||||
// TODO(bradfitz): clean up old user profiles? maybe not worth it.
|
||||
|
||||
if dm := resp.DERPMap; dm != nil {
|
||||
ms.vlogf("netmap: new map contains DERP map")
|
||||
@@ -532,6 +535,32 @@ func (ms *mapSession) addUserProfile(nm *netmap.NetworkMap, userID tailcfg.UserI
|
||||
}
|
||||
}
|
||||
|
||||
// cleanLastUserProfile deletes any entries from lastUserProfile
|
||||
// that are not referenced by any peer or the self node.
|
||||
//
|
||||
// This is expensive enough that we don't do this on every message
|
||||
// from the server, but only when it's grown enough to matter.
|
||||
func (ms *mapSession) cleanLastUserProfile() {
|
||||
if len(ms.lastUserProfile) < len(ms.peers)*2 {
|
||||
// Hasn't grown enough to be worth cleaning.
|
||||
return
|
||||
}
|
||||
|
||||
keep := set.Set[tailcfg.UserID]{}
|
||||
if node := ms.lastNode; node.Valid() {
|
||||
keep.Add(node.User())
|
||||
}
|
||||
for _, n := range ms.peers {
|
||||
keep.Add(n.User())
|
||||
keep.Add(n.Sharer())
|
||||
}
|
||||
for userID := range ms.lastUserProfile {
|
||||
if !keep.Contains(userID) {
|
||||
delete(ms.lastUserProfile, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var debugPatchifyPeer = envknob.RegisterBool("TS_DEBUG_PATCHIFY_PEER")
|
||||
|
||||
// patchifyPeersChanged mutates resp to promote PeersChanged entries to PeersChangedPatch
|
||||
|
||||
19
derp/derp.go
19
derp/derp.go
@@ -18,6 +18,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -79,8 +80,7 @@ const (
|
||||
// framePeerGone to B so B can forget that a reverse path
|
||||
// exists on that connection to get back to A. It is also sent
|
||||
// if A tries to send a CallMeMaybe to B and the server has no
|
||||
// record of B (which currently would only happen if there was
|
||||
// a bug).
|
||||
// record of B
|
||||
framePeerGone = frameType(0x08) // 32B pub key of peer that's gone + 1 byte reason
|
||||
|
||||
// framePeerPresent is like framePeerGone, but for other members of the DERP
|
||||
@@ -131,8 +131,8 @@ const (
|
||||
type PeerGoneReasonType byte
|
||||
|
||||
const (
|
||||
PeerGoneReasonDisconnected = PeerGoneReasonType(0x00) // peer disconnected from this server
|
||||
PeerGoneReasonNotHere = PeerGoneReasonType(0x01) // server doesn't know about this peer, unexpected
|
||||
PeerGoneReasonDisconnected = PeerGoneReasonType(0x00) // is only sent when a peer disconnects from this server
|
||||
PeerGoneReasonNotHere = PeerGoneReasonType(0x01) // server doesn't know about this peer
|
||||
PeerGoneReasonMeshConnBroke = PeerGoneReasonType(0xf0) // invented by Client.RunWatchConnectionLoop on disconnect; not sent on the wire
|
||||
)
|
||||
|
||||
@@ -255,3 +255,14 @@ func writeFrame(bw *bufio.Writer, t frameType, b []byte) error {
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
// Conn is the subset of the underlying net.Conn the DERP Server needs.
|
||||
// It is a defined type so that non-net connections can be used.
|
||||
type Conn interface {
|
||||
io.WriteCloser
|
||||
LocalAddr() net.Addr
|
||||
// The *Deadline methods follow the semantics of net.Conn.
|
||||
SetDeadline(time.Time) error
|
||||
SetReadDeadline(time.Time) error
|
||||
SetWriteDeadline(time.Time) error
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"math"
|
||||
"math/big"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -85,7 +84,7 @@ func init() {
|
||||
|
||||
const (
|
||||
defaultPerClientSendQueueDepth = 32 // default packets buffered for sending
|
||||
writeTimeout = 2 * time.Second
|
||||
DefaultTCPWiteTimeout = 2 * time.Second
|
||||
privilegedWriteTimeout = 30 * time.Second // for clients with the mesh key
|
||||
)
|
||||
|
||||
@@ -202,6 +201,8 @@ type Server struct {
|
||||
// Sets the client send queue depth for the server.
|
||||
perClientSendQueueDepth int
|
||||
|
||||
tcpWriteTimeout time.Duration
|
||||
|
||||
clock tstime.Clock
|
||||
}
|
||||
|
||||
@@ -341,22 +342,17 @@ type PacketForwarder interface {
|
||||
String() string
|
||||
}
|
||||
|
||||
// Conn is the subset of the underlying net.Conn the DERP Server needs.
|
||||
// It is a defined type so that non-net connections can be used.
|
||||
type Conn interface {
|
||||
io.WriteCloser
|
||||
LocalAddr() net.Addr
|
||||
// The *Deadline methods follow the semantics of net.Conn.
|
||||
SetDeadline(time.Time) error
|
||||
SetReadDeadline(time.Time) error
|
||||
SetWriteDeadline(time.Time) error
|
||||
}
|
||||
|
||||
var packetsDropped = metrics.NewMultiLabelMap[dropReasonKindLabels](
|
||||
"derp_packets_dropped",
|
||||
"counter",
|
||||
"DERP packets dropped by reason and by kind")
|
||||
|
||||
var bytesDropped = metrics.NewMultiLabelMap[dropReasonKindLabels](
|
||||
"derp_bytes_dropped",
|
||||
"counter",
|
||||
"DERP bytes dropped by reason and by kind",
|
||||
)
|
||||
|
||||
// NewServer returns a new DERP server. It doesn't listen on its own.
|
||||
// Connections are given to it via Server.Accept.
|
||||
func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
|
||||
@@ -383,18 +379,19 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
|
||||
bufferedWriteFrames: metrics.NewHistogram([]float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 50, 100}),
|
||||
keyOfAddr: map[netip.AddrPort]key.NodePublic{},
|
||||
clock: tstime.StdClock{},
|
||||
tcpWriteTimeout: DefaultTCPWiteTimeout,
|
||||
}
|
||||
s.initMetacert()
|
||||
s.packetsRecvDisco = s.packetsRecvByKind.Get(string(packetKindDisco))
|
||||
s.packetsRecvOther = s.packetsRecvByKind.Get(string(packetKindOther))
|
||||
|
||||
genPacketsDroppedCounters()
|
||||
genDroppedCounters()
|
||||
|
||||
s.perClientSendQueueDepth = getPerClientSendQueueDepth()
|
||||
return s
|
||||
}
|
||||
|
||||
func genPacketsDroppedCounters() {
|
||||
func genDroppedCounters() {
|
||||
initMetrics := func(reason dropReason) {
|
||||
packetsDropped.Add(dropReasonKindLabels{
|
||||
Kind: string(packetKindDisco),
|
||||
@@ -404,6 +401,14 @@ func genPacketsDroppedCounters() {
|
||||
Kind: string(packetKindOther),
|
||||
Reason: string(reason),
|
||||
}, 0)
|
||||
bytesDropped.Add(dropReasonKindLabels{
|
||||
Kind: string(packetKindDisco),
|
||||
Reason: string(reason),
|
||||
}, 0)
|
||||
bytesDropped.Add(dropReasonKindLabels{
|
||||
Kind: string(packetKindOther),
|
||||
Reason: string(reason),
|
||||
}, 0)
|
||||
}
|
||||
getMetrics := func(reason dropReason) []expvar.Var {
|
||||
return []expvar.Var{
|
||||
@@ -415,6 +420,14 @@ func genPacketsDroppedCounters() {
|
||||
Kind: string(packetKindOther),
|
||||
Reason: string(reason),
|
||||
}),
|
||||
bytesDropped.Get(dropReasonKindLabels{
|
||||
Kind: string(packetKindDisco),
|
||||
Reason: string(reason),
|
||||
}),
|
||||
bytesDropped.Get(dropReasonKindLabels{
|
||||
Kind: string(packetKindOther),
|
||||
Reason: string(reason),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,12 +444,14 @@ func genPacketsDroppedCounters() {
|
||||
for _, dr := range dropReasons {
|
||||
initMetrics(dr)
|
||||
m := getMetrics(dr)
|
||||
if len(m) != 2 {
|
||||
if len(m) != 4 {
|
||||
panic("dropReason metrics out of sync")
|
||||
}
|
||||
|
||||
if m[0] == nil || m[1] == nil {
|
||||
panic("dropReason metrics out of sync")
|
||||
for _, v := range m {
|
||||
if v == nil {
|
||||
panic("dropReason metrics out of sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,6 +484,13 @@ func (s *Server) SetVerifyClientURLFailOpen(v bool) {
|
||||
s.verifyClientsURLFailOpen = v
|
||||
}
|
||||
|
||||
// SetTCPWriteTimeout sets the timeout for writing to connected clients.
|
||||
// This timeout does not apply to mesh connections.
|
||||
// Defaults to 2 seconds.
|
||||
func (s *Server) SetTCPWriteTimeout(d time.Duration) {
|
||||
s.tcpWriteTimeout = d
|
||||
}
|
||||
|
||||
// HasMeshKey reports whether the server is configured with a mesh key.
|
||||
func (s *Server) HasMeshKey() bool { return s.meshKey != "" }
|
||||
|
||||
@@ -1207,6 +1229,7 @@ func (s *Server) recordDrop(packetBytes []byte, srcKey, dstKey key.NodePublic, r
|
||||
labels.Kind = string(packetKindOther)
|
||||
}
|
||||
packetsDropped.Add(labels, 1)
|
||||
bytesDropped.Add(labels, int64(len(packetBytes)))
|
||||
|
||||
if verboseDropKeys[dstKey] {
|
||||
// Preformat the log string prior to calling limitedLogf. The
|
||||
@@ -1792,7 +1815,7 @@ func (c *sclient) sendLoop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *sclient) setWriteDeadline() {
|
||||
d := writeTimeout
|
||||
d := c.s.tcpWriteTimeout
|
||||
if c.canMesh {
|
||||
// Trusted peers get more tolerance.
|
||||
//
|
||||
@@ -1804,7 +1827,10 @@ func (c *sclient) setWriteDeadline() {
|
||||
// of connected peers.
|
||||
d = privilegedWriteTimeout
|
||||
}
|
||||
c.nc.SetWriteDeadline(time.Now().Add(d))
|
||||
// Ignore the error from setting the write deadline. In practice,
|
||||
// setting the deadline will only fail if the connection is closed
|
||||
// or closing, so the subsequent Write() will fail anyway.
|
||||
_ = c.nc.SetWriteDeadline(time.Now().Add(d))
|
||||
}
|
||||
|
||||
// sendKeepAlive sends a keep-alive frame, without flushing.
|
||||
|
||||
7
feature/condregister/condregister.go
Normal file
7
feature/condregister/condregister.go
Normal file
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The condregister package registers all conditional features guarded
|
||||
// by build tags. It is one central package that callers can empty import
|
||||
// to ensure all conditional features are registered.
|
||||
package condregister
|
||||
8
feature/condregister/maybe_tap.go
Normal file
8
feature/condregister/maybe_tap.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux && !ts_omit_tap
|
||||
|
||||
package condregister
|
||||
|
||||
import _ "tailscale.com/feature/tap"
|
||||
8
feature/condregister/maybe_wakeonlan.go
Normal file
8
feature/condregister/maybe_wakeonlan.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_wakeonlan
|
||||
|
||||
package condregister
|
||||
|
||||
import _ "tailscale.com/feature/wakeonlan"
|
||||
54
feature/feature.go
Normal file
54
feature/feature.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package feature tracks which features are linked into the binary.
|
||||
package feature
|
||||
|
||||
import "reflect"
|
||||
|
||||
var in = map[string]bool{}
|
||||
|
||||
// Register notes that the named feature is linked into the binary.
|
||||
func Register(name string) {
|
||||
if _, ok := in[name]; ok {
|
||||
panic("duplicate feature registration for " + name)
|
||||
}
|
||||
in[name] = true
|
||||
}
|
||||
|
||||
// Hook is a func that can only be set once.
|
||||
//
|
||||
// It is not safe for concurrent use.
|
||||
type Hook[Func any] struct {
|
||||
f Func
|
||||
ok bool
|
||||
}
|
||||
|
||||
// IsSet reports whether the hook has been set.
|
||||
func (h *Hook[Func]) IsSet() bool {
|
||||
return h.ok
|
||||
}
|
||||
|
||||
// Set sets the hook function, panicking if it's already been set
|
||||
// or f is the zero value.
|
||||
//
|
||||
// It's meant to be called in init.
|
||||
func (h *Hook[Func]) Set(f Func) {
|
||||
if h.ok {
|
||||
panic("Set on already-set feature hook")
|
||||
}
|
||||
if reflect.ValueOf(f).IsZero() {
|
||||
panic("Set with zero value")
|
||||
}
|
||||
h.f = f
|
||||
h.ok = true
|
||||
}
|
||||
|
||||
// Get returns the hook function, or panics if it hasn't been set.
|
||||
// Use IsSet to check if it's been set.
|
||||
func (h *Hook[Func]) Get() Func {
|
||||
if !h.ok {
|
||||
panic("Get on unset feature hook, without IsSet")
|
||||
}
|
||||
return h.f
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_tap
|
||||
|
||||
package tstun
|
||||
// Package tap registers Tailscale's experimental (demo) Linux TAP (Layer 2) support.
|
||||
package tap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -26,6 +25,7 @@ import (
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/net/packet"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/tstun"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/logger"
|
||||
@@ -38,7 +38,11 @@ import (
|
||||
// For now just hard code it.
|
||||
var ourMAC = net.HardwareAddr{0x30, 0x2D, 0x66, 0xEC, 0x7A, 0x93}
|
||||
|
||||
func init() { createTAP = createTAPLinux }
|
||||
const tapDebug = tstun.TAPDebug
|
||||
|
||||
func init() {
|
||||
tstun.CreateTAP.Set(createTAPLinux)
|
||||
}
|
||||
|
||||
func createTAPLinux(logf logger.Logf, tapName, bridgeName string) (tun.Device, error) {
|
||||
fd, err := unix.Open("/dev/net/tun", unix.O_RDWR, 0)
|
||||
@@ -87,7 +91,10 @@ var (
|
||||
etherTypeIPv6 = etherType{0x86, 0xDD}
|
||||
)
|
||||
|
||||
const ipv4HeaderLen = 20
|
||||
const (
|
||||
ipv4HeaderLen = 20
|
||||
ethernetFrameSize = 14 // 2 six byte MACs, 2 bytes ethertype
|
||||
)
|
||||
|
||||
const (
|
||||
consumePacket = true
|
||||
@@ -186,6 +193,11 @@ var (
|
||||
cgnatNetMask = net.IPMask(net.ParseIP("255.192.0.0").To4())
|
||||
)
|
||||
|
||||
// parsedPacketPool holds a pool of Parsed structs for use in filtering.
|
||||
// This is needed because escape analysis cannot see that parsed packets
|
||||
// do not escape through {Pre,Post}Filter{In,Out}.
|
||||
var parsedPacketPool = sync.Pool{New: func() any { return new(packet.Parsed) }}
|
||||
|
||||
// handleDHCPRequest handles receiving a raw TAP ethernet frame and reports whether
|
||||
// it's been handled as a DHCP request. That is, it reports whether the frame should
|
||||
// be ignored by the caller and not passed on.
|
||||
@@ -392,7 +404,7 @@ type tapDevice struct {
|
||||
destMACAtomic syncs.AtomicValue[[6]byte]
|
||||
}
|
||||
|
||||
var _ setIPer = (*tapDevice)(nil)
|
||||
var _ tstun.SetIPer = (*tapDevice)(nil)
|
||||
|
||||
func (t *tapDevice) SetIP(ipV4, ipV6TODO netip.Addr) error {
|
||||
t.clientIPv4.Store(ipV4.String())
|
||||
243
feature/wakeonlan/wakeonlan.go
Normal file
243
feature/wakeonlan/wakeonlan.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package wakeonlan registers the Wake-on-LAN feature.
|
||||
package wakeonlan
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kortschak/wol"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/feature"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/clientmetric"
|
||||
)
|
||||
|
||||
func init() {
|
||||
feature.Register("wakeonlan")
|
||||
ipnlocal.RegisterC2N("POST /wol", handleC2NWoL)
|
||||
ipnlocal.RegisterPeerAPIHandler("/v0/wol", handlePeerAPIWakeOnLAN)
|
||||
hostinfo.RegisterHostinfoNewHook(func(h *tailcfg.Hostinfo) {
|
||||
h.WoLMACs = getWoLMACs()
|
||||
})
|
||||
}
|
||||
|
||||
func handleC2NWoL(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
var macs []net.HardwareAddr
|
||||
for _, macStr := range r.Form["mac"] {
|
||||
mac, err := net.ParseMAC(macStr)
|
||||
if err != nil {
|
||||
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
macs = append(macs, mac)
|
||||
}
|
||||
var res struct {
|
||||
SentTo []string
|
||||
Errors []string
|
||||
}
|
||||
st := b.NetMon().InterfaceState()
|
||||
if st == nil {
|
||||
res.Errors = append(res.Errors, "no interface state")
|
||||
writeJSON(w, &res)
|
||||
return
|
||||
}
|
||||
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
|
||||
for _, mac := range macs {
|
||||
for ifName, ips := range st.InterfaceIPs {
|
||||
for _, ip := range ips {
|
||||
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
|
||||
continue
|
||||
}
|
||||
local := &net.UDPAddr{
|
||||
IP: ip.Addr().AsSlice(),
|
||||
Port: 0,
|
||||
}
|
||||
remote := &net.UDPAddr{
|
||||
IP: net.IPv4bcast,
|
||||
Port: 0,
|
||||
}
|
||||
if err := wol.Wake(mac, password, local, remote); err != nil {
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
} else {
|
||||
res.SentTo = append(res.SentTo, ifName)
|
||||
}
|
||||
break // one per interface is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(res.SentTo)
|
||||
writeJSON(w, &res)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func canWakeOnLAN(h ipnlocal.PeerAPIHandler) bool {
|
||||
if h.Peer().UnsignedPeerAPIOnly() {
|
||||
return false
|
||||
}
|
||||
return h.IsSelfUntagged() || h.PeerCaps().HasCapability(tailcfg.PeerCapabilityWakeOnLAN)
|
||||
}
|
||||
|
||||
var metricWakeOnLANCalls = clientmetric.NewCounter("peerapi_wol")
|
||||
|
||||
func handlePeerAPIWakeOnLAN(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
|
||||
metricWakeOnLANCalls.Add(1)
|
||||
if !canWakeOnLAN(h) {
|
||||
http.Error(w, "no WoL access", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "bad method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
macStr := r.FormValue("mac")
|
||||
if macStr == "" {
|
||||
http.Error(w, "missing 'mac' param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mac, err := net.ParseMAC(macStr)
|
||||
if err != nil {
|
||||
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
|
||||
st := h.LocalBackend().NetMon().InterfaceState()
|
||||
if st == nil {
|
||||
http.Error(w, "failed to get interfaces state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var res struct {
|
||||
SentTo []string
|
||||
Errors []string
|
||||
}
|
||||
for ifName, ips := range st.InterfaceIPs {
|
||||
for _, ip := range ips {
|
||||
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
|
||||
continue
|
||||
}
|
||||
local := &net.UDPAddr{
|
||||
IP: ip.Addr().AsSlice(),
|
||||
Port: 0,
|
||||
}
|
||||
remote := &net.UDPAddr{
|
||||
IP: net.IPv4bcast,
|
||||
Port: 0,
|
||||
}
|
||||
if err := wol.Wake(mac, password, local, remote); err != nil {
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
} else {
|
||||
res.SentTo = append(res.SentTo, ifName)
|
||||
}
|
||||
break // one per interface is enough
|
||||
}
|
||||
}
|
||||
sort.Strings(res.SentTo)
|
||||
writeJSON(w, res)
|
||||
}
|
||||
|
||||
// TODO(bradfitz): this is all too simplistic and static. It needs to run
|
||||
// continuously in response to netmon events (USB ethernet adapters might get
|
||||
// plugged in) and look for the media type/status/etc. Right now on macOS it
|
||||
// still detects a half dozen "up" en0, en1, en2, en3 etc interfaces that don't
|
||||
// have any media. We should only report the one that's actually connected.
|
||||
// But it works for now (2023-10-05) for fleshing out the rest.
|
||||
|
||||
var wakeMAC = envknob.RegisterString("TS_WAKE_MAC") // mac address, "false" or "auto". for https://github.com/tailscale/tailscale/issues/306
|
||||
|
||||
// getWoLMACs returns up to 10 MAC address of the local machine to send
|
||||
// wake-on-LAN packets to in order to wake it up. The returned MACs are in
|
||||
// lowercase hex colon-separated form ("xx:xx:xx:xx:xx:xx").
|
||||
//
|
||||
// If TS_WAKE_MAC=auto, it tries to automatically find the MACs based on the OS
|
||||
// type and interface properties. (TODO(bradfitz): incomplete) If TS_WAKE_MAC is
|
||||
// set to a MAC address, that sole MAC address is returned.
|
||||
func getWoLMACs() (macs []string) {
|
||||
switch runtime.GOOS {
|
||||
case "ios", "android":
|
||||
return nil
|
||||
}
|
||||
if s := wakeMAC(); s != "" {
|
||||
switch s {
|
||||
case "auto":
|
||||
ifs, _ := net.Interfaces()
|
||||
for _, iface := range ifs {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if iface.Flags&net.FlagBroadcast == 0 ||
|
||||
iface.Flags&net.FlagRunning == 0 ||
|
||||
iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if keepMAC(iface.Name, iface.HardwareAddr) {
|
||||
macs = append(macs, iface.HardwareAddr.String())
|
||||
}
|
||||
if len(macs) == 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return macs
|
||||
case "false", "off": // fast path before ParseMAC error
|
||||
return nil
|
||||
}
|
||||
mac, err := net.ParseMAC(s)
|
||||
if err != nil {
|
||||
log.Printf("invalid MAC %q", s)
|
||||
return nil
|
||||
}
|
||||
return []string{mac.String()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var ignoreWakeOUI = map[[3]byte]bool{
|
||||
{0x00, 0x15, 0x5d}: true, // Hyper-V
|
||||
{0x00, 0x50, 0x56}: true, // VMware
|
||||
{0x00, 0x1c, 0x14}: true, // VMware
|
||||
{0x00, 0x05, 0x69}: true, // VMware
|
||||
{0x00, 0x0c, 0x29}: true, // VMware
|
||||
{0x00, 0x1c, 0x42}: true, // Parallels
|
||||
{0x08, 0x00, 0x27}: true, // VirtualBox
|
||||
{0x00, 0x21, 0xf6}: true, // VirtualBox
|
||||
{0x00, 0x14, 0x4f}: true, // VirtualBox
|
||||
{0x00, 0x0f, 0x4b}: true, // VirtualBox
|
||||
{0x52, 0x54, 0x00}: true, // VirtualBox/Vagrant
|
||||
}
|
||||
|
||||
func keepMAC(ifName string, mac []byte) bool {
|
||||
if len(mac) != 6 {
|
||||
return false
|
||||
}
|
||||
base := strings.TrimRightFunc(ifName, unicode.IsNumber)
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
switch base {
|
||||
case "llw", "awdl", "utun", "bridge", "lo", "gif", "stf", "anpi", "ap":
|
||||
return false
|
||||
}
|
||||
}
|
||||
if mac[0] == 0x02 && mac[1] == 0x42 {
|
||||
// Docker container.
|
||||
return false
|
||||
}
|
||||
oui := [3]byte{mac[0], mac[1], mac[2]}
|
||||
if ignoreWakeOUI[oui] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -49,7 +49,7 @@ require (
|
||||
github.com/goreleaser/nfpm/v2 v2.33.1
|
||||
github.com/hdevalence/ed25519consensus v0.2.0
|
||||
github.com/illarion/gonotify/v2 v2.0.3
|
||||
github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c
|
||||
github.com/inetaf/tcpproxy v0.0.0-20250121183218-48c7e53d7ac4
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0
|
||||
github.com/jsimonetti/rtnetlink v1.4.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -572,8 +572,8 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c h1:gYfYE403/nlrGNYj6BEOs9ucLCAGB9gstlSk92DttTg=
|
||||
github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI=
|
||||
github.com/inetaf/tcpproxy v0.0.0-20250121183218-48c7e53d7ac4 h1:5u/LhBmv8Y+BhTTADTuh8ma0DcZ3zzx+GINbMeMG9nM=
|
||||
github.com/inetaf/tcpproxy v0.0.0-20250121183218-48c7e53d7ac4/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
|
||||
@@ -32,11 +32,19 @@ import (
|
||||
|
||||
var started = time.Now()
|
||||
|
||||
var newHooks []func(*tailcfg.Hostinfo)
|
||||
|
||||
// RegisterHostinfoNewHook registers a callback to be called on a non-nil
|
||||
// [tailcfg.Hostinfo] before it is returned by [New].
|
||||
func RegisterHostinfoNewHook(f func(*tailcfg.Hostinfo)) {
|
||||
newHooks = append(newHooks, f)
|
||||
}
|
||||
|
||||
// New returns a partially populated Hostinfo for the current host.
|
||||
func New() *tailcfg.Hostinfo {
|
||||
hostname, _ := os.Hostname()
|
||||
hostname = dnsname.FirstLabel(hostname)
|
||||
return &tailcfg.Hostinfo{
|
||||
hi := &tailcfg.Hostinfo{
|
||||
IPNVersion: version.Long(),
|
||||
Hostname: hostname,
|
||||
App: appTypeCached(),
|
||||
@@ -57,8 +65,11 @@ func New() *tailcfg.Hostinfo {
|
||||
Cloud: string(cloudenv.Get()),
|
||||
NoLogsNoSupport: envknob.NoLogsNoSupport(),
|
||||
AllowsUpdate: envknob.AllowsRemoteUpdate(),
|
||||
WoLMACs: getWoLMACs(),
|
||||
}
|
||||
for _, f := range newHooks {
|
||||
f(hi)
|
||||
}
|
||||
return hi
|
||||
}
|
||||
|
||||
// non-nil on some platforms
|
||||
|
||||
106
hostinfo/wol.go
106
hostinfo/wol.go
@@ -1,106 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package hostinfo
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"tailscale.com/envknob"
|
||||
)
|
||||
|
||||
// TODO(bradfitz): this is all too simplistic and static. It needs to run
|
||||
// continuously in response to netmon events (USB ethernet adapaters might get
|
||||
// plugged in) and look for the media type/status/etc. Right now on macOS it
|
||||
// still detects a half dozen "up" en0, en1, en2, en3 etc interfaces that don't
|
||||
// have any media. We should only report the one that's actually connected.
|
||||
// But it works for now (2023-10-05) for fleshing out the rest.
|
||||
|
||||
var wakeMAC = envknob.RegisterString("TS_WAKE_MAC") // mac address, "false" or "auto". for https://github.com/tailscale/tailscale/issues/306
|
||||
|
||||
// getWoLMACs returns up to 10 MAC address of the local machine to send
|
||||
// wake-on-LAN packets to in order to wake it up. The returned MACs are in
|
||||
// lowercase hex colon-separated form ("xx:xx:xx:xx:xx:xx").
|
||||
//
|
||||
// If TS_WAKE_MAC=auto, it tries to automatically find the MACs based on the OS
|
||||
// type and interface properties. (TODO(bradfitz): incomplete) If TS_WAKE_MAC is
|
||||
// set to a MAC address, that sole MAC address is returned.
|
||||
func getWoLMACs() (macs []string) {
|
||||
switch runtime.GOOS {
|
||||
case "ios", "android":
|
||||
return nil
|
||||
}
|
||||
if s := wakeMAC(); s != "" {
|
||||
switch s {
|
||||
case "auto":
|
||||
ifs, _ := net.Interfaces()
|
||||
for _, iface := range ifs {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if iface.Flags&net.FlagBroadcast == 0 ||
|
||||
iface.Flags&net.FlagRunning == 0 ||
|
||||
iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if keepMAC(iface.Name, iface.HardwareAddr) {
|
||||
macs = append(macs, iface.HardwareAddr.String())
|
||||
}
|
||||
if len(macs) == 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return macs
|
||||
case "false", "off": // fast path before ParseMAC error
|
||||
return nil
|
||||
}
|
||||
mac, err := net.ParseMAC(s)
|
||||
if err != nil {
|
||||
log.Printf("invalid MAC %q", s)
|
||||
return nil
|
||||
}
|
||||
return []string{mac.String()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var ignoreWakeOUI = map[[3]byte]bool{
|
||||
{0x00, 0x15, 0x5d}: true, // Hyper-V
|
||||
{0x00, 0x50, 0x56}: true, // VMware
|
||||
{0x00, 0x1c, 0x14}: true, // VMware
|
||||
{0x00, 0x05, 0x69}: true, // VMware
|
||||
{0x00, 0x0c, 0x29}: true, // VMware
|
||||
{0x00, 0x1c, 0x42}: true, // Parallels
|
||||
{0x08, 0x00, 0x27}: true, // VirtualBox
|
||||
{0x00, 0x21, 0xf6}: true, // VirtualBox
|
||||
{0x00, 0x14, 0x4f}: true, // VirtualBox
|
||||
{0x00, 0x0f, 0x4b}: true, // VirtualBox
|
||||
{0x52, 0x54, 0x00}: true, // VirtualBox/Vagrant
|
||||
}
|
||||
|
||||
func keepMAC(ifName string, mac []byte) bool {
|
||||
if len(mac) != 6 {
|
||||
return false
|
||||
}
|
||||
base := strings.TrimRightFunc(ifName, unicode.IsNumber)
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
switch base {
|
||||
case "llw", "awdl", "utun", "bridge", "lo", "gif", "stf", "anpi", "ap":
|
||||
return false
|
||||
}
|
||||
}
|
||||
if mac[0] == 0x02 && mac[1] == 0x42 {
|
||||
// Docker container.
|
||||
return false
|
||||
}
|
||||
oui := [3]byte{mac[0], mac[1], mac[2]}
|
||||
if ignoreWakeOUI[oui] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func (src *ServeConfig) Clone() *ServeConfig {
|
||||
}
|
||||
}
|
||||
if dst.Services != nil {
|
||||
dst.Services = map[string]*ServiceConfig{}
|
||||
dst.Services = map[tailcfg.ServiceName]*ServiceConfig{}
|
||||
for k, v := range src.Services {
|
||||
if v == nil {
|
||||
dst.Services[k] = nil
|
||||
@@ -133,7 +133,7 @@ func (src *ServeConfig) Clone() *ServeConfig {
|
||||
var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct {
|
||||
TCP map[uint16]*TCPPortHandler
|
||||
Web map[HostPort]*WebServerConfig
|
||||
Services map[string]*ServiceConfig
|
||||
Services map[tailcfg.ServiceName]*ServiceConfig
|
||||
AllowFunnel map[HostPort]bool
|
||||
Foreground map[string]*ServeConfig
|
||||
ETag string
|
||||
|
||||
@@ -195,7 +195,7 @@ func (v ServeConfigView) Web() views.MapFn[HostPort, *WebServerConfig, WebServer
|
||||
})
|
||||
}
|
||||
|
||||
func (v ServeConfigView) Services() views.MapFn[string, *ServiceConfig, ServiceConfigView] {
|
||||
func (v ServeConfigView) Services() views.MapFn[tailcfg.ServiceName, *ServiceConfig, ServiceConfigView] {
|
||||
return views.MapFnOf(v.ж.Services, func(t *ServiceConfig) ServiceConfigView {
|
||||
return t.View()
|
||||
})
|
||||
@@ -216,7 +216,7 @@ func (v ServeConfigView) ETag() string { return v.ж.ETag }
|
||||
var _ServeConfigViewNeedsRegeneration = ServeConfig(struct {
|
||||
TCP map[uint16]*TCPPortHandler
|
||||
Web map[HostPort]*WebServerConfig
|
||||
Services map[string]*ServiceConfig
|
||||
Services map[tailcfg.ServiceName]*ServiceConfig
|
||||
AllowFunnel map[HostPort]bool
|
||||
Foreground map[string]*ServeConfig
|
||||
ETag string
|
||||
|
||||
@@ -10,19 +10,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kortschak/wol"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
@@ -66,9 +63,6 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
|
||||
req("GET /update"): handleC2NUpdateGet,
|
||||
req("POST /update"): handleC2NUpdatePost,
|
||||
|
||||
// Wake-on-LAN.
|
||||
req("POST /wol"): handleC2NWoL,
|
||||
|
||||
// Device posture.
|
||||
req("GET /posture/identity"): handleC2NPostureIdentityGet,
|
||||
|
||||
@@ -82,6 +76,18 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
|
||||
req("GET /vip-services"): handleC2NVIPServicesGet,
|
||||
}
|
||||
|
||||
// RegisterC2N registers a new c2n handler for the given pattern.
|
||||
//
|
||||
// A pattern is like "GET /foo" (specific to an HTTP method) or "/foo" (all
|
||||
// methods). It panics if the pattern is already registered.
|
||||
func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
|
||||
k := req(pattern)
|
||||
if _, ok := c2nHandlers[k]; ok {
|
||||
panic(fmt.Sprintf("c2n: duplicate handler for %q", pattern))
|
||||
}
|
||||
c2nHandlers[k] = h
|
||||
}
|
||||
|
||||
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
|
||||
|
||||
type methodAndPath struct {
|
||||
@@ -503,55 +509,6 @@ func regularFileExists(path string) bool {
|
||||
return err == nil && fi.Mode().IsRegular()
|
||||
}
|
||||
|
||||
func handleC2NWoL(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
var macs []net.HardwareAddr
|
||||
for _, macStr := range r.Form["mac"] {
|
||||
mac, err := net.ParseMAC(macStr)
|
||||
if err != nil {
|
||||
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
macs = append(macs, mac)
|
||||
}
|
||||
var res struct {
|
||||
SentTo []string
|
||||
Errors []string
|
||||
}
|
||||
st := b.sys.NetMon.Get().InterfaceState()
|
||||
if st == nil {
|
||||
res.Errors = append(res.Errors, "no interface state")
|
||||
writeJSON(w, &res)
|
||||
return
|
||||
}
|
||||
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
|
||||
for _, mac := range macs {
|
||||
for ifName, ips := range st.InterfaceIPs {
|
||||
for _, ip := range ips {
|
||||
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
|
||||
continue
|
||||
}
|
||||
local := &net.UDPAddr{
|
||||
IP: ip.Addr().AsSlice(),
|
||||
Port: 0,
|
||||
}
|
||||
remote := &net.UDPAddr{
|
||||
IP: net.IPv4bcast,
|
||||
Port: 0,
|
||||
}
|
||||
if err := wol.Wake(mac, password, local, remote); err != nil {
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
} else {
|
||||
res.SentTo = append(res.SentTo, ifName)
|
||||
}
|
||||
break // one per interface is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(res.SentTo)
|
||||
writeJSON(w, &res)
|
||||
}
|
||||
|
||||
// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the
|
||||
// provided domain. This can be called by the controlplane to clean up DNS TXT
|
||||
// records when they're no longer needed by LetsEncrypt.
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/ipn/store"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/net/bakedroots"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/testenv"
|
||||
"tailscale.com/version"
|
||||
@@ -555,6 +556,7 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
|
||||
}
|
||||
|
||||
logf("requesting cert...")
|
||||
traceACME(csr)
|
||||
der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CreateOrder: %v", err)
|
||||
@@ -577,10 +579,10 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger
|
||||
}
|
||||
|
||||
// certRequest generates a CSR for the given common name cn and optional SANs.
|
||||
func certRequest(key crypto.Signer, cn string, ext []pkix.Extension, san ...string) ([]byte, error) {
|
||||
func certRequest(key crypto.Signer, name string, ext []pkix.Extension) ([]byte, error) {
|
||||
req := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: san,
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
DNSNames: []string{name},
|
||||
ExtraExtensions: ext,
|
||||
}
|
||||
return x509.CreateCertificateRequest(rand.Reader, req, key)
|
||||
@@ -665,7 +667,7 @@ func acmeClient(cs certStore) (*acme.Client, error) {
|
||||
// validCertPEM reports whether the given certificate is valid for domain at now.
|
||||
//
|
||||
// If roots != nil, it is used instead of the system root pool. This is meant
|
||||
// to support testing, and production code should pass roots == nil.
|
||||
// to support testing; production code should pass roots == nil.
|
||||
func validCertPEM(domain string, keyPEM, certPEM []byte, roots *x509.CertPool, now time.Time) bool {
|
||||
if len(keyPEM) == 0 || len(certPEM) == 0 {
|
||||
return false
|
||||
@@ -688,15 +690,29 @@ func validCertPEM(domain string, keyPEM, certPEM []byte, roots *x509.CertPool, n
|
||||
intermediates.AddCert(cert)
|
||||
}
|
||||
}
|
||||
return validateLeaf(leaf, intermediates, domain, now, roots)
|
||||
}
|
||||
|
||||
// validateLeaf is a helper for [validCertPEM].
|
||||
//
|
||||
// If called with roots == nil, it will use the system root pool as well as the
|
||||
// baked-in roots. If non-nil, only those roots are used.
|
||||
func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain string, now time.Time, roots *x509.CertPool) bool {
|
||||
if leaf == nil {
|
||||
return false
|
||||
}
|
||||
_, err = leaf.Verify(x509.VerifyOptions{
|
||||
_, err := leaf.Verify(x509.VerifyOptions{
|
||||
DNSName: domain,
|
||||
CurrentTime: now,
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
})
|
||||
if err != nil && roots == nil {
|
||||
// If validation failed and they specified nil for roots (meaning to use
|
||||
// the system roots), then give it another chance to validate using the
|
||||
// binary's baked-in roots (LetsEncrypt). See tailscale/tailscale#14690.
|
||||
return validateLeaf(leaf, intermediates, domain, now, bakedroots.Get())
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
|
||||
@@ -228,10 +228,11 @@ type LocalBackend struct {
|
||||
// is never called.
|
||||
getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn))
|
||||
|
||||
filterAtomic atomic.Pointer[filter.Filter]
|
||||
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool]
|
||||
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool]
|
||||
numClientStatusCalls atomic.Uint32
|
||||
filterAtomic atomic.Pointer[filter.Filter]
|
||||
containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool]
|
||||
shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool]
|
||||
shouldInterceptVIPServicesTCPPortAtomic syncs.AtomicValue[func(netip.AddrPort) bool]
|
||||
numClientStatusCalls atomic.Uint32
|
||||
|
||||
// goTracker accounts for all goroutines started by LocalBacked, primarily
|
||||
// for testing and graceful shutdown purposes.
|
||||
@@ -317,8 +318,9 @@ type LocalBackend struct {
|
||||
offlineAutoUpdateCancel func()
|
||||
|
||||
// ServeConfig fields. (also guarded by mu)
|
||||
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
|
||||
serveConfig ipn.ServeConfigView // or !Valid if none
|
||||
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
|
||||
serveConfig ipn.ServeConfigView // or !Valid if none
|
||||
ipVIPServiceMap netmap.IPServiceMappings // map of VIPService IPs to their corresponding service names
|
||||
|
||||
webClient webClient
|
||||
webClientListeners map[netip.AddrPort]*localListener // listeners for local web client traffic
|
||||
@@ -523,6 +525,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
b.e.SetJailedFilter(noneFilter)
|
||||
|
||||
b.setTCPPortsIntercepted(nil)
|
||||
b.setVIPServicesTCPPortsIntercepted(nil)
|
||||
|
||||
b.statusChanged = sync.NewCond(&b.statusLock)
|
||||
b.e.SetStatusCallback(b.setWgengineStatus)
|
||||
@@ -1703,6 +1706,37 @@ func applySysPolicy(prefs *ipn.Prefs, lastSuggestedExitNode tailcfg.StableNodeID
|
||||
anyChange = true
|
||||
}
|
||||
|
||||
const sentinel = "HostnameDefaultValue"
|
||||
hostnameFromPolicy, _ := syspolicy.GetString(syspolicy.Hostname, sentinel)
|
||||
switch hostnameFromPolicy {
|
||||
case sentinel:
|
||||
// An empty string for this policy value means that the admin wants to delete
|
||||
// the hostname stored in the ipn.Prefs. To make that work, we need to
|
||||
// distinguish between an empty string and a policy that was not set.
|
||||
// We cannot do that with the current implementation of syspolicy.GetString.
|
||||
// It currently does not return an error if a policy was not configured.
|
||||
// Instead, it returns the default value provided as the second argument.
|
||||
// This behavior makes it impossible to distinguish between a policy that
|
||||
// was not set and a policy that was set to an empty default value.
|
||||
// Checking for sentinel here is a workaround to distinguish between
|
||||
// the two cases. If we get it, we do nothing because the policy was not set.
|
||||
//
|
||||
// TODO(angott,nickkhyl): clean up this behavior once syspolicy.GetString starts
|
||||
// properly returning errors.
|
||||
case "":
|
||||
// The policy was set to an empty string, which means the admin intends
|
||||
// to clear the hostname stored in preferences.
|
||||
prefs.Hostname = ""
|
||||
anyChange = true
|
||||
default:
|
||||
// The policy was set to a non-empty string, which means the admin wants
|
||||
// to override the hostname stored in preferences.
|
||||
if prefs.Hostname != hostnameFromPolicy {
|
||||
prefs.Hostname = hostnameFromPolicy
|
||||
anyChange = true
|
||||
}
|
||||
}
|
||||
|
||||
if exitNodeIDStr, _ := syspolicy.GetString(syspolicy.ExitNodeID, ""); exitNodeIDStr != "" {
|
||||
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
|
||||
if shouldAutoExitNode() && lastSuggestedExitNode != "" {
|
||||
@@ -3331,10 +3365,7 @@ func (b *LocalBackend) clearMachineKeyLocked() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setTCPPortsIntercepted populates b.shouldInterceptTCPPortAtomic with an
|
||||
// efficient func for ShouldInterceptTCPPort to use, which is called on every
|
||||
// incoming packet.
|
||||
func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
|
||||
func generateInterceptTCPPortFunc(ports []uint16) func(uint16) bool {
|
||||
slices.Sort(ports)
|
||||
ports = slices.Compact(ports)
|
||||
var f func(uint16) bool
|
||||
@@ -3365,7 +3396,63 @@ func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
|
||||
}
|
||||
}
|
||||
}
|
||||
b.shouldInterceptTCPPortAtomic.Store(f)
|
||||
return f
|
||||
}
|
||||
|
||||
// setTCPPortsIntercepted populates b.shouldInterceptTCPPortAtomic with an
|
||||
// efficient func for ShouldInterceptTCPPort to use, which is called on every
|
||||
// incoming packet.
|
||||
func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
|
||||
b.shouldInterceptTCPPortAtomic.Store(generateInterceptTCPPortFunc(ports))
|
||||
}
|
||||
|
||||
func generateInterceptVIPServicesTCPPortFunc(svcAddrPorts map[netip.Addr]func(uint16) bool) func(netip.AddrPort) bool {
|
||||
return func(ap netip.AddrPort) bool {
|
||||
if f, ok := svcAddrPorts[ap.Addr()]; ok {
|
||||
return f(ap.Port())
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// setVIPServicesTCPPortsIntercepted populates b.shouldInterceptVIPServicesTCPPortAtomic with an
|
||||
// efficient func for ShouldInterceptTCPPort to use, which is called on every incoming packet.
|
||||
func (b *LocalBackend) setVIPServicesTCPPortsIntercepted(svcPorts map[tailcfg.ServiceName][]uint16) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.setVIPServicesTCPPortsInterceptedLocked(svcPorts)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) setVIPServicesTCPPortsInterceptedLocked(svcPorts map[tailcfg.ServiceName][]uint16) {
|
||||
if len(svcPorts) == 0 {
|
||||
b.shouldInterceptVIPServicesTCPPortAtomic.Store(func(netip.AddrPort) bool { return false })
|
||||
return
|
||||
}
|
||||
nm := b.netMap
|
||||
if nm == nil {
|
||||
b.logf("can't set intercept function for Service TCP Ports, netMap is nil")
|
||||
return
|
||||
}
|
||||
vipServiceIPMap := nm.GetVIPServiceIPMap()
|
||||
if len(vipServiceIPMap) == 0 {
|
||||
// No approved VIP Services
|
||||
return
|
||||
}
|
||||
|
||||
svcAddrPorts := make(map[netip.Addr]func(uint16) bool)
|
||||
// Only set the intercept function if the service has been assigned a VIP.
|
||||
for svcName, ports := range svcPorts {
|
||||
addrs, ok := vipServiceIPMap[svcName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
interceptFn := generateInterceptTCPPortFunc(ports)
|
||||
for _, addr := range addrs {
|
||||
svcAddrPorts[addr] = interceptFn
|
||||
}
|
||||
}
|
||||
|
||||
b.shouldInterceptVIPServicesTCPPortAtomic.Store(generateInterceptVIPServicesTCPPortFunc(svcAddrPorts))
|
||||
}
|
||||
|
||||
// setAtomicValuesFromPrefsLocked populates sshAtomicBool, containsViaIPFuncAtomic,
|
||||
@@ -3378,6 +3465,7 @@ func (b *LocalBackend) setAtomicValuesFromPrefsLocked(p ipn.PrefsView) {
|
||||
if !p.Valid() {
|
||||
b.containsViaIPFuncAtomic.Store(ipset.FalseContainsIPFunc())
|
||||
b.setTCPPortsIntercepted(nil)
|
||||
b.setVIPServicesTCPPortsInterceptedLocked(nil)
|
||||
b.lastServeConfJSON = mem.B(nil)
|
||||
b.serveConfig = ipn.ServeConfigView{}
|
||||
} else {
|
||||
@@ -3957,6 +4045,12 @@ func (b *LocalBackend) wantIngressLocked() bool {
|
||||
return b.serveConfig.Valid() && b.serveConfig.HasAllowFunnel()
|
||||
}
|
||||
|
||||
// hasIngressEnabledLocked reports whether the node has any funnel endpoint enabled. This bool is sent to control (in
|
||||
// Hostinfo.IngressEnabled) to determine whether 'Funnel' badge should be displayed on this node in the admin panel.
|
||||
func (b *LocalBackend) hasIngressEnabledLocked() bool {
|
||||
return b.serveConfig.Valid() && b.serveConfig.IsFunnelOn()
|
||||
}
|
||||
|
||||
// setPrefsLockedOnEntry requires b.mu be held to call it, but it
|
||||
// unlocks b.mu when done. newp ownership passes to this function.
|
||||
// It returns a read-only copy of the new prefs.
|
||||
@@ -4122,6 +4216,11 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(tailscale/corp#26001): Get handler for VIP services and Local IPs using
|
||||
// the same function.
|
||||
if handler := b.tcpHandlerForVIPService(dst, src); handler != nil {
|
||||
return handler, opts
|
||||
}
|
||||
// Then handle external connections to the local IP.
|
||||
if !b.isLocalIP(dst.Addr()) {
|
||||
return nil, nil
|
||||
@@ -4320,12 +4419,20 @@ func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs i
|
||||
}
|
||||
|
||||
func (b *LocalBackend) readvertiseAppConnectorRoutes() {
|
||||
var domainRoutes map[string][]netip.Addr
|
||||
// Note: we should never call b.appConnector methods while holding b.mu.
|
||||
// This can lead to a deadlock, like
|
||||
// https://github.com/tailscale/corp/issues/25965.
|
||||
//
|
||||
// Grab a copy of the field, since b.mu only guards access to the
|
||||
// b.appConnector field itself.
|
||||
b.mu.Lock()
|
||||
if b.appConnector != nil {
|
||||
domainRoutes = b.appConnector.DomainRoutes()
|
||||
}
|
||||
appConnector := b.appConnector
|
||||
b.mu.Unlock()
|
||||
|
||||
if appConnector == nil {
|
||||
return
|
||||
}
|
||||
domainRoutes := appConnector.DomainRoutes()
|
||||
if domainRoutes == nil {
|
||||
return
|
||||
}
|
||||
@@ -5055,7 +5162,12 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
|
||||
// if this is accidentally false, then control may not configure DNS
|
||||
// properly. This exists as an optimization to control to program fewer DNS
|
||||
// records that have ingress enabled but are not actually being used.
|
||||
// TODO(irbekrm): once control knows that if hostinfo.IngressEnabled is true,
|
||||
// then wireIngress can be considered true, don't send wireIngress in that case.
|
||||
hi.WireIngress = b.wantIngressLocked()
|
||||
// The Hostinfo.IngressEnabled field is used to communicate to control whether
|
||||
// the funnel is actually enabled.
|
||||
hi.IngressEnabled = b.hasIngressEnabledLocked()
|
||||
hi.AppConnector.Set(prefs.AppConnector().Advertise)
|
||||
}
|
||||
|
||||
@@ -5662,6 +5774,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
netns.SetDisableBindConnToInterface(nm.HasCap(tailcfg.CapabilityDebugDisableBindConnToInterface))
|
||||
|
||||
b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs())
|
||||
b.ipVIPServiceMap = nm.GetIPVIPServiceMap()
|
||||
if nm == nil {
|
||||
b.nodeByAddr = nil
|
||||
|
||||
@@ -5948,6 +6061,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
|
||||
// b.mu must be held.
|
||||
func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.PrefsView) {
|
||||
handlePorts := make([]uint16, 0, 4)
|
||||
var vipServicesPorts map[tailcfg.ServiceName][]uint16
|
||||
|
||||
if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
|
||||
handlePorts = append(handlePorts, 22)
|
||||
@@ -5971,6 +6085,20 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
|
||||
}
|
||||
handlePorts = append(handlePorts, servePorts...)
|
||||
|
||||
for svc, cfg := range b.serveConfig.Services().All() {
|
||||
servicePorts := make([]uint16, 0, 3)
|
||||
for port := range cfg.TCP().All() {
|
||||
if port > 0 {
|
||||
servicePorts = append(servicePorts, uint16(port))
|
||||
}
|
||||
}
|
||||
if _, ok := vipServicesPorts[svc]; !ok {
|
||||
mak.Set(&vipServicesPorts, svc, servicePorts)
|
||||
} else {
|
||||
mak.Set(&vipServicesPorts, svc, append(vipServicesPorts[svc], servicePorts...))
|
||||
}
|
||||
}
|
||||
|
||||
b.setServeProxyHandlersLocked()
|
||||
|
||||
// don't listen on netmap addresses if we're in userspace mode
|
||||
@@ -5978,14 +6106,38 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
|
||||
b.updateServeTCPPortNetMapAddrListenersLocked(servePorts)
|
||||
}
|
||||
}
|
||||
// Kick off a Hostinfo update to control if WireIngress changed.
|
||||
if wire := b.wantIngressLocked(); b.hostinfo != nil && b.hostinfo.WireIngress != wire {
|
||||
|
||||
// Update funnel info in hostinfo and kick off control update if needed.
|
||||
b.updateIngressLocked()
|
||||
b.setTCPPortsIntercepted(handlePorts)
|
||||
b.setVIPServicesTCPPortsInterceptedLocked(vipServicesPorts)
|
||||
}
|
||||
|
||||
// updateIngressLocked updates the hostinfo.WireIngress and hostinfo.IngressEnabled fields and kicks off a Hostinfo
|
||||
// update if the values have changed.
|
||||
// TODO(irbekrm): once control knows that if hostinfo.IngressEnabled is true, then wireIngress can be considered true,
|
||||
// we can stop sending hostinfo.WireIngress in that case.
|
||||
//
|
||||
// b.mu must be held.
|
||||
func (b *LocalBackend) updateIngressLocked() {
|
||||
if b.hostinfo == nil {
|
||||
return
|
||||
}
|
||||
hostInfoChanged := false
|
||||
if wire := b.wantIngressLocked(); b.hostinfo.WireIngress != wire {
|
||||
b.logf("Hostinfo.WireIngress changed to %v", wire)
|
||||
b.hostinfo.WireIngress = wire
|
||||
hostInfoChanged = true
|
||||
}
|
||||
if ie := b.hasIngressEnabledLocked(); b.hostinfo.IngressEnabled != ie {
|
||||
b.logf("Hostinfo.IngressEnabled changed to %v", ie)
|
||||
b.hostinfo.IngressEnabled = ie
|
||||
hostInfoChanged = true
|
||||
}
|
||||
// Kick off a Hostinfo update to control if ingress status has changed.
|
||||
if hostInfoChanged {
|
||||
b.goTracker.Go(b.doSetHostinfoFilterServices)
|
||||
}
|
||||
|
||||
b.setTCPPortsIntercepted(handlePorts)
|
||||
}
|
||||
|
||||
// setServeProxyHandlersLocked ensures there is an http proxy handler for each
|
||||
@@ -6817,6 +6969,12 @@ func (b *LocalBackend) ShouldInterceptTCPPort(port uint16) bool {
|
||||
return b.shouldInterceptTCPPortAtomic.Load()(port)
|
||||
}
|
||||
|
||||
// ShouldInterceptVIPServiceTCPPort reports whether the given TCP port number
|
||||
// to a VIP service should be intercepted by Tailscaled and handled in-process.
|
||||
func (b *LocalBackend) ShouldInterceptVIPServiceTCPPort(ap netip.AddrPort) bool {
|
||||
return b.shouldInterceptVIPServicesTCPPortAtomic.Load()(ap)
|
||||
}
|
||||
|
||||
// SwitchProfile switches to the profile with the given id.
|
||||
// It will restart the backend on success.
|
||||
// If the profile is not known, it returns an errProfileNotFound.
|
||||
@@ -7118,17 +7276,17 @@ func (b *LocalBackend) DoSelfUpdate() {
|
||||
|
||||
// ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to the
|
||||
// App Connector to enable route discovery.
|
||||
func (b *LocalBackend) ObserveDNSResponse(res []byte) {
|
||||
func (b *LocalBackend) ObserveDNSResponse(res []byte) error {
|
||||
var appConnector *appc.AppConnector
|
||||
b.mu.Lock()
|
||||
if b.appConnector == nil {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
return nil
|
||||
}
|
||||
appConnector = b.appConnector
|
||||
b.mu.Unlock()
|
||||
|
||||
appConnector.ObserveDNSResponse(res)
|
||||
return appConnector.ObserveDNSResponse(res)
|
||||
}
|
||||
|
||||
// ErrDisallowedAutoRoute is returned by AdvertiseRoute when a route that is not allowed is requested.
|
||||
@@ -7694,7 +7852,7 @@ func (b *LocalBackend) vipServiceHash(services []*tailcfg.VIPService) string {
|
||||
|
||||
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
|
||||
// keyed by service name
|
||||
var services map[string]*tailcfg.VIPService
|
||||
var services map[tailcfg.ServiceName]*tailcfg.VIPService
|
||||
if !b.serveConfig.Valid() {
|
||||
return nil
|
||||
}
|
||||
@@ -7707,12 +7865,13 @@ func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcf
|
||||
}
|
||||
|
||||
for _, s := range prefs.AdvertiseServices().All() {
|
||||
if services == nil || services[s] == nil {
|
||||
mak.Set(&services, s, &tailcfg.VIPService{
|
||||
Name: s,
|
||||
sn := tailcfg.ServiceName(s)
|
||||
if services == nil || services[sn] == nil {
|
||||
mak.Set(&services, sn, &tailcfg.VIPService{
|
||||
Name: sn,
|
||||
})
|
||||
}
|
||||
services[s].Active = true
|
||||
services[sn].Active = true
|
||||
}
|
||||
|
||||
return slicesx.MapValues(services)
|
||||
|
||||
@@ -1372,7 +1372,9 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
// ensure no error when no app connector is configured
|
||||
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if err := b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
|
||||
rc := &appctest.RouteCollector{}
|
||||
if shouldStore {
|
||||
@@ -1383,7 +1385,9 @@ func TestObserveDNSResponse(t *testing.T) {
|
||||
b.appConnector.UpdateDomains([]string{"example.com"})
|
||||
b.appConnector.Wait(context.Background())
|
||||
|
||||
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if err := b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
b.appConnector.Wait(context.Background())
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
@@ -2662,6 +2666,150 @@ func TestOnTailnetDefaultAutoUpdate(t *testing.T) {
|
||||
|
||||
func TestTCPHandlerForDst(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
tests := []struct {
|
||||
desc string
|
||||
dst string
|
||||
intercept bool
|
||||
}{
|
||||
{
|
||||
desc: "intercept port 80 (Web UI) on quad100 IPv4",
|
||||
dst: "100.100.100.100:80",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 80 (Web UI) on quad100 IPv6",
|
||||
dst: "[fd7a:115c:a1e0::53]:80",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 80 on local ip",
|
||||
dst: "100.100.103.100:80",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 8080 (Taildrive) on quad100 IPv4",
|
||||
dst: "[fd7a:115c:a1e0::53]:8080",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 8080 on local ip",
|
||||
dst: "100.100.103.100:8080",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 9080 on quad100 IPv4",
|
||||
dst: "100.100.100.100:9080",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 9080 on quad100 IPv6",
|
||||
dst: "[fd7a:115c:a1e0::53]:9080",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 9080 on local ip",
|
||||
dst: "100.100.103.100:9080",
|
||||
intercept: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.dst, func(t *testing.T) {
|
||||
t.Log(tt.desc)
|
||||
src := netip.MustParseAddrPort("100.100.102.100:51234")
|
||||
h, _ := b.TCPHandlerForDst(src, netip.MustParseAddrPort(tt.dst))
|
||||
if !tt.intercept && h != nil {
|
||||
t.Error("intercepted traffic we shouldn't have")
|
||||
} else if tt.intercept && h == nil {
|
||||
t.Error("failed to intercept traffic we should have")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPHandlerForDstWithVIPService(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
svcIPMap := tailcfg.ServiceIPMappings{
|
||||
"svc:foo": []netip.Addr{
|
||||
netip.MustParseAddr("100.101.101.101"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:6565:6565"),
|
||||
},
|
||||
"svc:bar": []netip.Addr{
|
||||
netip.MustParseAddr("100.99.99.99"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:626b:628b"),
|
||||
},
|
||||
"svc:baz": []netip.Addr{
|
||||
netip.MustParseAddr("100.133.133.133"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:8585:8585"),
|
||||
},
|
||||
}
|
||||
svcIPMapJSON, err := json.Marshal(svcIPMap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.setNetMapLocked(
|
||||
&netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Name: "example.ts.net",
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{tailcfg.RawMessage(svcIPMapJSON)},
|
||||
},
|
||||
}).View(),
|
||||
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||
tailcfg.UserID(1): {
|
||||
LoginName: "someone@example.com",
|
||||
DisplayName: "Some One",
|
||||
ProfilePicURL: "https://example.com/photo.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
err = b.setServeConfigLocked(
|
||||
&ipn.ServeConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:foo": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
882: {HTTP: true},
|
||||
883: {HTTPS: true},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.example.ts.net:882": {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
},
|
||||
},
|
||||
"foo.example.ts.net:883": {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Text: "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"svc:bar": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
990: {TCPForward: "127.0.0.1:8443"},
|
||||
991: {TCPForward: "127.0.0.1:5432", TerminateTLS: "bar.test.ts.net"},
|
||||
},
|
||||
},
|
||||
"svc:qux": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
600: {HTTPS: true},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"qux.example.ts.net:600": {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Text: "qux"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
@@ -2713,6 +2861,77 @@ func TestTCPHandlerForDst(t *testing.T) {
|
||||
dst: "100.100.103.100:9080",
|
||||
intercept: false,
|
||||
},
|
||||
// VIP service destinations
|
||||
{
|
||||
desc: "intercept port 882 (HTTP) on service foo IPv4",
|
||||
dst: "100.101.101.101:882",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 882 (HTTP) on service foo IPv6",
|
||||
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:882",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 883 (HTTPS) on service foo IPv4",
|
||||
dst: "100.101.101.101:883",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 883 (HTTPS) on service foo IPv6",
|
||||
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:883",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 990 (TCPForward) on service bar IPv4",
|
||||
dst: "100.99.99.99:990",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 990 (TCPForward) on service bar IPv6",
|
||||
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:990",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 991 (TCPForward with TerminateTLS) on service bar IPv4",
|
||||
dst: "100.99.99.99:990",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 991 (TCPForward with TerminateTLS) on service bar IPv6",
|
||||
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:990",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 4444 on service foo IPv4",
|
||||
dst: "100.101.101.101:4444",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 4444 on service foo IPv6",
|
||||
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:4444",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 600 on unknown service IPv4",
|
||||
dst: "100.22.22.22:883",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 600 on unknown service IPv6",
|
||||
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:883",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 600 (HTTPS) on service baz IPv4",
|
||||
dst: "100.133.133.133:600",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 600 (HTTPS) on service baz IPv6",
|
||||
dst: "[fd7a:115c:a1e0:ab12:4843:cd96:8585:8585]:600",
|
||||
intercept: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -4579,7 +4798,7 @@ func TestGetVIPServices(t *testing.T) {
|
||||
"served-only",
|
||||
[]string{},
|
||||
&ipn.ServeConfig{
|
||||
Services: map[string]*ipn.ServiceConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:abc": {Tun: true},
|
||||
},
|
||||
},
|
||||
@@ -4594,7 +4813,7 @@ func TestGetVIPServices(t *testing.T) {
|
||||
"served-and-advertised",
|
||||
[]string{"svc:abc"},
|
||||
&ipn.ServeConfig{
|
||||
Services: map[string]*ipn.ServiceConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:abc": {Tun: true},
|
||||
},
|
||||
},
|
||||
@@ -4610,7 +4829,7 @@ func TestGetVIPServices(t *testing.T) {
|
||||
"served-and-advertised-different-service",
|
||||
[]string{"svc:def"},
|
||||
&ipn.ServeConfig{
|
||||
Services: map[string]*ipn.ServiceConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:abc": {Tun: true},
|
||||
},
|
||||
},
|
||||
@@ -4629,7 +4848,7 @@ func TestGetVIPServices(t *testing.T) {
|
||||
"served-with-port-ranges-one-range-single",
|
||||
[]string{},
|
||||
&ipn.ServeConfig{
|
||||
Services: map[string]*ipn.ServiceConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTPS: true},
|
||||
}},
|
||||
@@ -4646,7 +4865,7 @@ func TestGetVIPServices(t *testing.T) {
|
||||
"served-with-port-ranges-one-range-multiple",
|
||||
[]string{},
|
||||
&ipn.ServeConfig{
|
||||
Services: map[string]*ipn.ServiceConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTPS: true},
|
||||
81: {HTTPS: true},
|
||||
@@ -4665,7 +4884,7 @@ func TestGetVIPServices(t *testing.T) {
|
||||
"served-with-port-ranges-multiple-ranges",
|
||||
[]string{},
|
||||
&ipn.ServeConfig{
|
||||
Services: map[string]*ipn.ServiceConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:abc": {TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTPS: true},
|
||||
81: {HTTPS: true},
|
||||
@@ -4698,7 +4917,7 @@ func TestGetVIPServices(t *testing.T) {
|
||||
}
|
||||
got := lb.vipServicesFromPrefsLocked(prefs.View())
|
||||
slices.SortFunc(got, func(a, b *tailcfg.VIPService) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
return strings.Compare(a.Name.String(), b.Name.String())
|
||||
})
|
||||
if !reflect.DeepEqual(tt.want, got) {
|
||||
t.Logf("want:")
|
||||
@@ -4838,3 +5057,154 @@ func TestUpdatePrefsOnSysPolicyChange(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateIngressLocked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hi *tailcfg.Hostinfo
|
||||
sc *ipn.ServeConfig
|
||||
wantIngress bool
|
||||
wantWireIngress bool
|
||||
wantControlUpdate bool
|
||||
}{
|
||||
{
|
||||
name: "no_hostinfo_no_serve_config",
|
||||
hi: nil,
|
||||
},
|
||||
{
|
||||
name: "empty_hostinfo_no_serve_config",
|
||||
hi: &tailcfg.Hostinfo{},
|
||||
},
|
||||
{
|
||||
name: "empty_hostinfo_funnel_enabled",
|
||||
hi: &tailcfg.Hostinfo{},
|
||||
sc: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{
|
||||
"tailnet.xyz:443": true,
|
||||
},
|
||||
},
|
||||
wantIngress: true,
|
||||
wantWireIngress: true,
|
||||
wantControlUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "empty_hostinfo_funnel_disabled",
|
||||
hi: &tailcfg.Hostinfo{},
|
||||
sc: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{
|
||||
"tailnet.xyz:443": false,
|
||||
},
|
||||
},
|
||||
wantWireIngress: true, // true if there is any AllowFunnel block
|
||||
wantControlUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "empty_hostinfo_no_funnel",
|
||||
hi: &tailcfg.Hostinfo{},
|
||||
sc: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "funnel_enabled_no_change",
|
||||
hi: &tailcfg.Hostinfo{
|
||||
IngressEnabled: true,
|
||||
WireIngress: true,
|
||||
},
|
||||
sc: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{
|
||||
"tailnet.xyz:443": true,
|
||||
},
|
||||
},
|
||||
wantIngress: true,
|
||||
wantWireIngress: true,
|
||||
},
|
||||
{
|
||||
name: "funnel_disabled_no_change",
|
||||
hi: &tailcfg.Hostinfo{
|
||||
WireIngress: true,
|
||||
},
|
||||
sc: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{
|
||||
"tailnet.xyz:443": false,
|
||||
},
|
||||
},
|
||||
wantWireIngress: true, // true if there is any AllowFunnel block
|
||||
},
|
||||
{
|
||||
name: "funnel_changes_to_disabled",
|
||||
hi: &tailcfg.Hostinfo{
|
||||
IngressEnabled: true,
|
||||
WireIngress: true,
|
||||
},
|
||||
sc: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{
|
||||
"tailnet.xyz:443": false,
|
||||
},
|
||||
},
|
||||
wantWireIngress: true, // true if there is any AllowFunnel block
|
||||
wantControlUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "funnel_changes_to_enabled",
|
||||
hi: &tailcfg.Hostinfo{
|
||||
WireIngress: true,
|
||||
},
|
||||
sc: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{
|
||||
"tailnet.xyz:443": true,
|
||||
},
|
||||
},
|
||||
wantWireIngress: true,
|
||||
wantIngress: true,
|
||||
wantControlUpdate: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := newTestLocalBackend(t)
|
||||
b.hostinfo = tt.hi
|
||||
b.serveConfig = tt.sc.View()
|
||||
allDone := make(chan bool, 1)
|
||||
defer b.goTracker.AddDoneCallback(func() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.goTracker.RunningGoroutines() > 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case allDone <- true:
|
||||
default:
|
||||
}
|
||||
})()
|
||||
|
||||
was := b.goTracker.StartedGoroutines()
|
||||
b.updateIngressLocked()
|
||||
|
||||
if tt.hi != nil {
|
||||
if tt.hi.IngressEnabled != tt.wantIngress {
|
||||
t.Errorf("IngressEnabled = %v, want %v", tt.hi.IngressEnabled, tt.wantIngress)
|
||||
}
|
||||
if tt.hi.WireIngress != tt.wantWireIngress {
|
||||
t.Errorf("WireIngress = %v, want %v", tt.hi.WireIngress, tt.wantWireIngress)
|
||||
}
|
||||
}
|
||||
|
||||
startedGoroutine := b.goTracker.StartedGoroutines() != was
|
||||
if startedGoroutine != tt.wantControlUpdate {
|
||||
t.Errorf("control update triggered = %v, want %v", startedGoroutine, tt.wantControlUpdate)
|
||||
}
|
||||
|
||||
if startedGoroutine {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for goroutine to finish")
|
||||
case <-allDone:
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,11 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kortschak/wol"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
"tailscale.com/drive"
|
||||
@@ -226,6 +224,23 @@ type peerAPIHandler struct {
|
||||
peerUser tailcfg.UserProfile // profile of peerNode
|
||||
}
|
||||
|
||||
// PeerAPIHandler is the interface implemented by [peerAPIHandler] and needed by
|
||||
// module features registered via tailscale.com/feature/*.
|
||||
type PeerAPIHandler interface {
|
||||
Peer() tailcfg.NodeView
|
||||
PeerCaps() tailcfg.PeerCapMap
|
||||
Self() tailcfg.NodeView
|
||||
LocalBackend() *LocalBackend
|
||||
IsSelfUntagged() bool // whether the peer is untagged and the same as this user
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) IsSelfUntagged() bool {
|
||||
return !h.selfNode.IsTagged() && !h.peerNode.IsTagged() && h.isSelf
|
||||
}
|
||||
func (h *peerAPIHandler) Peer() tailcfg.NodeView { return h.peerNode }
|
||||
func (h *peerAPIHandler) Self() tailcfg.NodeView { return h.selfNode }
|
||||
func (h *peerAPIHandler) LocalBackend() *LocalBackend { return h.ps.b }
|
||||
|
||||
func (h *peerAPIHandler) logf(format string, a ...any) {
|
||||
h.ps.b.logf("peerapi: "+format, a...)
|
||||
}
|
||||
@@ -302,6 +317,20 @@ func peerAPIRequestShouldGetSecurityHeaders(r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// RegisterPeerAPIHandler registers a PeerAPI handler.
|
||||
//
|
||||
// The path should be of the form "/v0/foo".
|
||||
//
|
||||
// It panics if the path is already registered.
|
||||
func RegisterPeerAPIHandler(path string, f func(PeerAPIHandler, http.ResponseWriter, *http.Request)) {
|
||||
if _, ok := peerAPIHandlers[path]; ok {
|
||||
panic(fmt.Sprintf("duplicate PeerAPI handler %q", path))
|
||||
}
|
||||
peerAPIHandlers[path] = f
|
||||
}
|
||||
|
||||
var peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path
|
||||
|
||||
func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.validatePeerAPIRequest(r); err != nil {
|
||||
metricInvalidRequests.Add(1)
|
||||
@@ -346,10 +375,6 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case "/v0/dnsfwd":
|
||||
h.handleServeDNSFwd(w, r)
|
||||
return
|
||||
case "/v0/wol":
|
||||
metricWakeOnLANCalls.Add(1)
|
||||
h.handleWakeOnLAN(w, r)
|
||||
return
|
||||
case "/v0/interfaces":
|
||||
h.handleServeInterfaces(w, r)
|
||||
return
|
||||
@@ -364,6 +389,10 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleServeIngress(w, r)
|
||||
return
|
||||
}
|
||||
if ph, ok := peerAPIHandlers[r.URL.Path]; ok {
|
||||
ph(h, w, r)
|
||||
return
|
||||
}
|
||||
who := h.peerUser.DisplayName
|
||||
fmt.Fprintf(w, `<html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -624,14 +653,6 @@ func (h *peerAPIHandler) canDebug() bool {
|
||||
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityDebugPeer)
|
||||
}
|
||||
|
||||
// canWakeOnLAN reports whether h can send a Wake-on-LAN packet from this node.
|
||||
func (h *peerAPIHandler) canWakeOnLAN() bool {
|
||||
if h.peerNode.UnsignedPeerAPIOnly() {
|
||||
return false
|
||||
}
|
||||
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityWakeOnLAN)
|
||||
}
|
||||
|
||||
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
|
||||
|
||||
// canIngress reports whether h can send ingress requests to this node.
|
||||
@@ -640,10 +661,10 @@ func (h *peerAPIHandler) canIngress() bool {
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
|
||||
return h.peerCaps().HasCapability(wantCap)
|
||||
return h.PeerCaps().HasCapability(wantCap)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) peerCaps() tailcfg.PeerCapMap {
|
||||
func (h *peerAPIHandler) PeerCaps() tailcfg.PeerCapMap {
|
||||
return h.ps.b.PeerCaps(h.remoteAddr.Addr())
|
||||
}
|
||||
|
||||
@@ -817,61 +838,6 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques
|
||||
dh.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canWakeOnLAN() {
|
||||
http.Error(w, "no WoL access", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "bad method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
macStr := r.FormValue("mac")
|
||||
if macStr == "" {
|
||||
http.Error(w, "missing 'mac' param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mac, err := net.ParseMAC(macStr)
|
||||
if err != nil {
|
||||
http.Error(w, "bad 'mac' param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
|
||||
st := h.ps.b.sys.NetMon.Get().InterfaceState()
|
||||
if st == nil {
|
||||
http.Error(w, "failed to get interfaces state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var res struct {
|
||||
SentTo []string
|
||||
Errors []string
|
||||
}
|
||||
for ifName, ips := range st.InterfaceIPs {
|
||||
for _, ip := range ips {
|
||||
if ip.Addr().IsLoopback() || ip.Addr().Is6() {
|
||||
continue
|
||||
}
|
||||
local := &net.UDPAddr{
|
||||
IP: ip.Addr().AsSlice(),
|
||||
Port: 0,
|
||||
}
|
||||
remote := &net.UDPAddr{
|
||||
IP: net.IPv4bcast,
|
||||
Port: 0,
|
||||
}
|
||||
if err := wol.Wake(mac, password, local, remote); err != nil {
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
} else {
|
||||
res.SentTo = append(res.SentTo, ifName)
|
||||
}
|
||||
break // one per interface is enough
|
||||
}
|
||||
}
|
||||
sort.Strings(res.SentTo)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) replyToDNSQueries() bool {
|
||||
if h.isSelf {
|
||||
// If the peer is owned by the same user, just allow it
|
||||
@@ -966,7 +932,11 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
|
||||
// instead to avoid re-parsing the DNS response for improved performance in
|
||||
// the future.
|
||||
if h.ps.b.OfferingAppConnector() {
|
||||
h.ps.b.ObserveDNSResponse(res)
|
||||
if err := h.ps.b.ObserveDNSResponse(res); err != nil {
|
||||
h.logf("ObserveDNSResponse error: %v", err)
|
||||
// This is not fatal, we probably just failed to parse the upstream
|
||||
// response. Return it to the caller anyway.
|
||||
}
|
||||
}
|
||||
|
||||
if pretty {
|
||||
@@ -1150,7 +1120,7 @@ func (h *peerAPIHandler) handleServeDrive(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
capsMap := h.peerCaps()
|
||||
capsMap := h.PeerCaps()
|
||||
driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
|
||||
if !ok {
|
||||
h.logf("taildrive: not permitted")
|
||||
@@ -1274,8 +1244,7 @@ var (
|
||||
metricInvalidRequests = clientmetric.NewCounter("peerapi_invalid_requests")
|
||||
|
||||
// Non-debug PeerAPI endpoints.
|
||||
metricPutCalls = clientmetric.NewCounter("peerapi_put")
|
||||
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
|
||||
metricWakeOnLANCalls = clientmetric.NewCounter("peerapi_wol")
|
||||
metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
|
||||
metricPutCalls = clientmetric.NewCounter("peerapi_put")
|
||||
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
|
||||
metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
|
||||
)
|
||||
|
||||
@@ -54,8 +54,9 @@ var ErrETagMismatch = errors.New("etag mismatch")
|
||||
var serveHTTPContextKey ctxkey.Key[*serveHTTPContext]
|
||||
|
||||
type serveHTTPContext struct {
|
||||
SrcAddr netip.AddrPort
|
||||
DestPort uint16
|
||||
SrcAddr netip.AddrPort
|
||||
ForVIPService tailcfg.ServiceName // "" means local
|
||||
DestPort uint16
|
||||
|
||||
// provides funnel-specific context, nil if not funneled
|
||||
Funnel *funnelFlow
|
||||
@@ -275,6 +276,12 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
|
||||
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
|
||||
}
|
||||
|
||||
if config != nil {
|
||||
if err := config.CheckValidServicesConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
nm := b.netMap
|
||||
if nm == nil {
|
||||
return errors.New("netMap is nil")
|
||||
@@ -432,6 +439,105 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
|
||||
handler(c)
|
||||
}
|
||||
|
||||
// tcpHandlerForVIPService returns a handler for a TCP connection to a VIP service
|
||||
// that is being served via the ipn.ServeConfig. It returns nil if the destination
|
||||
// address is not a VIP service or if the VIP service does not have a TCP handler set.
|
||||
func (b *LocalBackend) tcpHandlerForVIPService(dstAddr, srcAddr netip.AddrPort) (handler func(net.Conn) error) {
|
||||
b.mu.Lock()
|
||||
sc := b.serveConfig
|
||||
ipVIPServiceMap := b.ipVIPServiceMap
|
||||
b.mu.Unlock()
|
||||
|
||||
if !sc.Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
dport := dstAddr.Port()
|
||||
|
||||
dstSvc, ok := ipVIPServiceMap[dstAddr.Addr()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
tcph, ok := sc.FindServiceTCP(dstSvc, dstAddr.Port())
|
||||
if !ok {
|
||||
b.logf("The destination service doesn't have a TCP handler set.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if tcph.HTTPS() || tcph.HTTP() {
|
||||
hs := &http.Server{
|
||||
Handler: http.HandlerFunc(b.serveWebHandler),
|
||||
BaseContext: func(_ net.Listener) context.Context {
|
||||
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
|
||||
SrcAddr: srcAddr,
|
||||
ForVIPService: dstSvc,
|
||||
DestPort: dport,
|
||||
})
|
||||
},
|
||||
}
|
||||
if tcph.HTTPS() {
|
||||
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
|
||||
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
|
||||
// in the hostname. How to store the TLS cert is still being discussed.
|
||||
hs.TLSConfig = &tls.Config{
|
||||
GetCertificate: b.getTLSServeCertForPort(dport, dstSvc),
|
||||
}
|
||||
return func(c net.Conn) error {
|
||||
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
|
||||
}
|
||||
}
|
||||
|
||||
return func(c net.Conn) error {
|
||||
return hs.Serve(netutil.NewOneConnListener(c, nil))
|
||||
}
|
||||
}
|
||||
|
||||
if backDst := tcph.TCPForward(); backDst != "" {
|
||||
return func(conn net.Conn) error {
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
|
||||
return nil
|
||||
}
|
||||
defer backConn.Close()
|
||||
if sni := tcph.TerminateTLS(); sni != "" {
|
||||
conn = tls.Server(conn, &tls.Config{
|
||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
pair, err := b.GetCertPEM(ctx, sni)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.Copy(backConn, conn)
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(conn, backConn)
|
||||
errc <- err
|
||||
}()
|
||||
return <-errc
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tcpHandlerForServe returns a handler for a TCP connection to be served via
|
||||
// the ipn.ServeConfig. The funnelFlow can be nil if this is not a funneled
|
||||
// connection.
|
||||
@@ -462,7 +568,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
|
||||
}
|
||||
if tcph.HTTPS() {
|
||||
hs.TLSConfig = &tls.Config{
|
||||
GetCertificate: b.getTLSServeCertForPort(dport),
|
||||
GetCertificate: b.getTLSServeCertForPort(dport, ""),
|
||||
}
|
||||
return func(c net.Conn) error {
|
||||
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
|
||||
@@ -542,7 +648,7 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
|
||||
b.logf("[unexpected] localbackend: no serveHTTPContext in request")
|
||||
return z, "", false
|
||||
}
|
||||
wsc, ok := b.webServerConfig(hostname, sctx.DestPort)
|
||||
wsc, ok := b.webServerConfig(hostname, sctx.ForVIPService, sctx.DestPort)
|
||||
if !ok {
|
||||
return z, "", false
|
||||
}
|
||||
@@ -900,7 +1006,7 @@ func allNumeric(s string) bool {
|
||||
return s != ""
|
||||
}
|
||||
|
||||
func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebServerConfigView, ok bool) {
|
||||
func (b *LocalBackend) webServerConfig(hostname string, forVIPService tailcfg.ServiceName, port uint16) (c ipn.WebServerConfigView, ok bool) {
|
||||
key := ipn.HostPort(fmt.Sprintf("%s:%v", hostname, port))
|
||||
|
||||
b.mu.Lock()
|
||||
@@ -909,15 +1015,18 @@ func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebS
|
||||
if !b.serveConfig.Valid() {
|
||||
return c, false
|
||||
}
|
||||
if forVIPService != "" {
|
||||
return b.serveConfig.FindServiceWeb(forVIPService, key)
|
||||
}
|
||||
return b.serveConfig.FindWeb(key)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg.ServiceName) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hi == nil || hi.ServerName == "" {
|
||||
return nil, errors.New("no SNI ServerName")
|
||||
}
|
||||
_, ok := b.webServerConfig(hi.ServerName, port)
|
||||
_, ok := b.webServerConfig(hi.ServerName, forVIPService, port)
|
||||
if !ok {
|
||||
return nil, errors.New("no webserver configured for name/port")
|
||||
}
|
||||
|
||||
@@ -296,6 +296,203 @@ func TestServeConfigForeground(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeConfigServices tests the side effects of setting the
|
||||
// Services field in a ServeConfig. The Services field is a map
|
||||
// of all services the current service host is serving. Unlike what we
|
||||
// serve for node itself, there is no foreground and no local handlers
|
||||
// for the services. So the only things we need to test are if the
|
||||
// services configured are valid and if they correctly set intercept
|
||||
// functions for netStack.
|
||||
func TestServeConfigServices(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
svcIPMap := tailcfg.ServiceIPMappings{
|
||||
"svc:foo": []netip.Addr{
|
||||
netip.MustParseAddr("100.101.101.101"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:6565:6565"),
|
||||
},
|
||||
"svc:bar": []netip.Addr{
|
||||
netip.MustParseAddr("100.99.99.99"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:626b:628b"),
|
||||
},
|
||||
}
|
||||
svcIPMapJSON, err := json.Marshal(svcIPMap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b.netMap = &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Name: "example.ts.net",
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{tailcfg.RawMessage(svcIPMapJSON)},
|
||||
},
|
||||
}).View(),
|
||||
UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||
tailcfg.UserID(1): {
|
||||
LoginName: "someone@example.com",
|
||||
DisplayName: "Some One",
|
||||
ProfilePicURL: "https://example.com/photo.jpg",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
conf *ipn.ServeConfig
|
||||
expectedErr error
|
||||
packetDstAddrPort []netip.AddrPort
|
||||
intercepted bool
|
||||
}{
|
||||
{
|
||||
name: "no-services",
|
||||
conf: &ipn.ServeConfig{},
|
||||
packetDstAddrPort: []netip.AddrPort{
|
||||
netip.MustParseAddrPort("100.101.101.101:443"),
|
||||
},
|
||||
intercepted: false,
|
||||
},
|
||||
{
|
||||
name: "one-incorrectly-configured-service",
|
||||
conf: &ipn.ServeConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:foo": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
},
|
||||
Tun: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: ipn.ErrServiceConfigHasBothTCPAndTun,
|
||||
},
|
||||
{
|
||||
// one correctly configured service with packet should be intercepted
|
||||
name: "one-service-intercept-packet",
|
||||
conf: &ipn.ServeConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:foo": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
81: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
packetDstAddrPort: []netip.AddrPort{
|
||||
netip.MustParseAddrPort("100.101.101.101:80"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:80"),
|
||||
},
|
||||
intercepted: true,
|
||||
},
|
||||
{
|
||||
// one correctly configured service with packet should not be intercepted
|
||||
name: "one-service-not-intercept-packet",
|
||||
conf: &ipn.ServeConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:foo": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
81: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
packetDstAddrPort: []netip.AddrPort{
|
||||
netip.MustParseAddrPort("100.99.99.99:80"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"),
|
||||
netip.MustParseAddrPort("100.101.101.101:82"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:82"),
|
||||
},
|
||||
intercepted: false,
|
||||
},
|
||||
{
|
||||
// multiple correctly configured service with packet should be intercepted
|
||||
name: "multiple-service-intercept-packet",
|
||||
conf: &ipn.ServeConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:foo": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
81: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
"svc:bar": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
81: {HTTPS: true},
|
||||
82: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
packetDstAddrPort: []netip.AddrPort{
|
||||
netip.MustParseAddrPort("100.99.99.99:80"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"),
|
||||
netip.MustParseAddrPort("100.101.101.101:81"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:81"),
|
||||
},
|
||||
intercepted: true,
|
||||
},
|
||||
{
|
||||
// multiple correctly configured service with packet should not be intercepted
|
||||
name: "multiple-service-not-intercept-packet",
|
||||
conf: &ipn.ServeConfig{
|
||||
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
||||
"svc:foo": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
81: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
"svc:bar": {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
81: {HTTPS: true},
|
||||
82: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
packetDstAddrPort: []netip.AddrPort{
|
||||
// ips in capmap but port is not hosting service
|
||||
netip.MustParseAddrPort("100.99.99.99:77"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:77"),
|
||||
netip.MustParseAddrPort("100.101.101.101:85"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:85"),
|
||||
// ips not in capmap
|
||||
netip.MustParseAddrPort("100.102.102.102:80"),
|
||||
netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:6666:6666]:80"),
|
||||
},
|
||||
intercepted: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := b.SetServeConfig(tt.conf, "")
|
||||
if err != nil && tt.expectedErr != nil {
|
||||
if !errors.Is(err, tt.expectedErr) {
|
||||
t.Fatalf("expected error %v,\n got %v", tt.expectedErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, addrPort := range tt.packetDstAddrPort {
|
||||
if tt.intercepted != b.ShouldInterceptVIPServiceTCPPort(addrPort) {
|
||||
if tt.intercepted {
|
||||
t.Fatalf("expected packet to be intercepted")
|
||||
} else {
|
||||
t.Fatalf("expected packet not to be intercepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestServeConfigETag(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
|
||||
76
ipn/serve.go
76
ipn/serve.go
@@ -55,20 +55,20 @@ type ServeConfig struct {
|
||||
// keyed by mount point ("/", "/foo", etc)
|
||||
Web map[HostPort]*WebServerConfig `json:",omitempty"`
|
||||
|
||||
// Services maps from service name to a ServiceConfig. Which describes the
|
||||
// L3, L4, and L7 forwarding information for the service.
|
||||
Services map[string]*ServiceConfig `json:",omitempty"`
|
||||
// Services maps from service name (in the form "svc:dns-label") to a ServiceConfig.
|
||||
// Which describes the L3, L4, and L7 forwarding information for the service.
|
||||
Services map[tailcfg.ServiceName]*ServiceConfig `json:",omitempty"`
|
||||
|
||||
// AllowFunnel is the set of SNI:port values for which funnel
|
||||
// traffic is allowed, from trusted ingress peers.
|
||||
AllowFunnel map[HostPort]bool `json:",omitempty"`
|
||||
|
||||
// Foreground is a map of an IPN Bus session ID to an alternate foreground
|
||||
// serve config that's valid for the life of that WatchIPNBus session ID.
|
||||
// This. This allows the config to specify ephemeral configs that are
|
||||
// used in the CLI's foreground mode to ensure ungraceful shutdowns
|
||||
// of either the client or the LocalBackend does not expose ports
|
||||
// that users are not aware of.
|
||||
// Foreground is a map of an IPN Bus session ID to an alternate foreground serve config that's valid for the
|
||||
// life of that WatchIPNBus session ID. This allows the config to specify ephemeral configs that are used
|
||||
// in the CLI's foreground mode to ensure ungraceful shutdowns of either the client or the LocalBackend does not
|
||||
// expose ports that users are not aware of. In practice this contains any serve config set via 'tailscale
|
||||
// serve' command run without the '--bg' flag. ServeConfig contained by Foreground is not expected itself to contain
|
||||
// another Foreground block.
|
||||
Foreground map[string]*ServeConfig `json:",omitempty"`
|
||||
|
||||
// ETag is the checksum of the serve config that's populated
|
||||
@@ -389,8 +389,7 @@ func (sc *ServeConfig) RemoveTCPForwarding(port uint16) {
|
||||
// View version of ServeConfig.IsFunnelOn.
|
||||
func (v ServeConfigView) IsFunnelOn() bool { return v.ж.IsFunnelOn() }
|
||||
|
||||
// IsFunnelOn reports whether if ServeConfig is currently allowing funnel
|
||||
// traffic for any host:port.
|
||||
// IsFunnelOn reports whether any funnel endpoint is currently enabled for this node.
|
||||
func (sc *ServeConfig) IsFunnelOn() bool {
|
||||
if sc == nil {
|
||||
return false
|
||||
@@ -400,6 +399,11 @@ func (sc *ServeConfig) IsFunnelOn() bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, conf := range sc.Foreground {
|
||||
if conf.IsFunnelOn() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -603,9 +607,34 @@ func (v ServeConfigView) Webs() iter.Seq2[HostPort, WebServerConfigView] {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, service := range v.Services().All() {
|
||||
for k, v := range service.Web().All() {
|
||||
if !yield(k, v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FindServiceTCP return the TCPPortHandlerView for the given service name and port.
|
||||
func (v ServeConfigView) FindServiceTCP(svcName tailcfg.ServiceName, port uint16) (res TCPPortHandlerView, ok bool) {
|
||||
svcCfg, ok := v.Services().GetOk(svcName)
|
||||
if !ok {
|
||||
return res, ok
|
||||
}
|
||||
return svcCfg.TCP().GetOk(port)
|
||||
}
|
||||
|
||||
func (v ServeConfigView) FindServiceWeb(svcName tailcfg.ServiceName, hp HostPort) (res WebServerConfigView, ok bool) {
|
||||
if svcCfg, ok := v.Services().GetOk(svcName); ok {
|
||||
if res, ok := svcCfg.Web().GetOk(hp); ok {
|
||||
return res, ok
|
||||
}
|
||||
}
|
||||
return res, ok
|
||||
}
|
||||
|
||||
// FindTCP returns the first TCP that matches with the given port. It
|
||||
// prefers a foreground match first followed by a background search if none
|
||||
// existed.
|
||||
@@ -658,6 +687,17 @@ func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckValidServicesConfig reports whether the ServeConfig has
|
||||
// invalid service configurations.
|
||||
func (sc *ServeConfig) CheckValidServicesConfig() error {
|
||||
for svcName, service := range sc.Services {
|
||||
if err := service.checkValidConfig(); err != nil {
|
||||
return fmt.Errorf("invalid service configuration for %q: %w", svcName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServicePortRange returns the list of tailcfg.ProtoPortRange that represents
|
||||
// the proto/ports pairs that are being served by the service.
|
||||
//
|
||||
@@ -695,3 +735,17 @@ func (v ServiceConfigView) ServicePortRange() []tailcfg.ProtoPortRange {
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// ErrServiceConfigHasBothTCPAndTun signals that a service
|
||||
// in Tun mode cannot also has TCP or Web handlers set.
|
||||
var ErrServiceConfigHasBothTCPAndTun = errors.New("the VIP Service configuration can not set TUN at the same time as TCP or Web")
|
||||
|
||||
// checkValidConfig checks if the service configuration is valid.
|
||||
// Currently, the only invalid configuration is when the service is in Tun mode
|
||||
// and has TCP or Web handlers.
|
||||
func (v *ServiceConfig) checkValidConfig() error {
|
||||
if v.Tun && (len(v.TCP) > 0 || len(v.Web) > 0) {
|
||||
return ErrServiceConfigHasBothTCPAndTun
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -182,3 +182,88 @@ func TestExpandProxyTargetDev(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFunnelOn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sc *ServeConfig
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil_config",
|
||||
},
|
||||
{
|
||||
name: "empty_config",
|
||||
sc: &ServeConfig{},
|
||||
},
|
||||
{
|
||||
name: "funnel_enabled_in_background",
|
||||
sc: &ServeConfig{
|
||||
AllowFunnel: map[HostPort]bool{
|
||||
"tailnet.xyz:443": true,
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "funnel_disabled_in_background",
|
||||
sc: &ServeConfig{
|
||||
AllowFunnel: map[HostPort]bool{
|
||||
"tailnet.xyz:443": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "funnel_enabled_in_foreground",
|
||||
sc: &ServeConfig{
|
||||
Foreground: map[string]*ServeConfig{
|
||||
"abc123": {
|
||||
AllowFunnel: map[HostPort]bool{
|
||||
"tailnet.xyz:443": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "funnel_disabled_in_both",
|
||||
sc: &ServeConfig{
|
||||
AllowFunnel: map[HostPort]bool{
|
||||
"tailnet.xyz:443": false,
|
||||
},
|
||||
Foreground: map[string]*ServeConfig{
|
||||
"abc123": {
|
||||
AllowFunnel: map[HostPort]bool{
|
||||
"tailnet.xyz:8443": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "funnel_enabled_in_both",
|
||||
sc: &ServeConfig{
|
||||
AllowFunnel: map[HostPort]bool{
|
||||
"tailnet.xyz:443": true,
|
||||
},
|
||||
Foreground: map[string]*ServeConfig{
|
||||
"abc123": {
|
||||
AllowFunnel: map[HostPort]bool{
|
||||
"tailnet.xyz:8443": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.sc.IsFunnelOn(); got != tt.want {
|
||||
t.Errorf("ServeConfig.IsFunnelOn() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ const (
|
||||
AppProxyGroupIngress = "k8s-operator-proxygroup-ingress"
|
||||
|
||||
// Clientmetrics for Tailscale Kubernetes Operator components
|
||||
MetricIngressProxyCount = "k8s_ingress_proxies" // L3
|
||||
MetricIngressResourceCount = "k8s_ingress_resources" // L7
|
||||
MetricIngressProxyCount = "k8s_ingress_proxies" // L3
|
||||
MetricIngressResourceCount = "k8s_ingress_resources" // L7
|
||||
MetricIngressPGResourceCount = "k8s_ingress_pg_resources" // L7 on ProxyGroup
|
||||
MetricEgressProxyCount = "k8s_egress_proxies"
|
||||
MetricConnectorResourceCount = "k8s_connector_resources"
|
||||
MetricConnectorWithSubnetRouterCount = "k8s_connector_subnetrouter_resources"
|
||||
|
||||
149
net/bakedroots/bakedroots.go
Normal file
149
net/bakedroots/bakedroots.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package bakedroots contains WebPKI CA roots we bake into the tailscaled binary,
|
||||
// lest the system's CA roots be missing them (or entirely empty).
|
||||
package bakedroots
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"sync"
|
||||
|
||||
"tailscale.com/util/testenv"
|
||||
)
|
||||
|
||||
// Get returns the baked-in roots.
|
||||
//
|
||||
// As of 2025-01-21, this includes only the LetsEncrypt ISRG Root X1 root.
|
||||
func Get() *x509.CertPool {
|
||||
roots.once.Do(func() {
|
||||
roots.parsePEM(append(
|
||||
[]byte(letsEncryptX1),
|
||||
letsEncryptX2...,
|
||||
))
|
||||
})
|
||||
return roots.p
|
||||
}
|
||||
|
||||
// testingTB is a subset of testing.TB needed
|
||||
// to verify the caller isn't in a parallel test.
|
||||
type testingTB interface {
|
||||
// Setenv panics if it's in a parallel test.
|
||||
Setenv(k, v string)
|
||||
}
|
||||
|
||||
// ResetForTest resets the cached roots for testing,
|
||||
// optionally setting them to caPEM if non-nil.
|
||||
func ResetForTest(tb testingTB, caPEM []byte) {
|
||||
if !testenv.InTest() {
|
||||
panic("not in test")
|
||||
}
|
||||
tb.Setenv("ASSERT_NOT_PARALLEL_TEST", "1") // panics if tb's Parallel was called
|
||||
|
||||
roots = rootsOnce{}
|
||||
if caPEM != nil {
|
||||
roots.once.Do(func() { roots.parsePEM(caPEM) })
|
||||
}
|
||||
}
|
||||
|
||||
var roots rootsOnce
|
||||
|
||||
type rootsOnce struct {
|
||||
once sync.Once
|
||||
p *x509.CertPool
|
||||
}
|
||||
|
||||
func (r *rootsOnce) parsePEM(caPEM []byte) {
|
||||
p := x509.NewCertPool()
|
||||
if !p.AppendCertsFromPEM(caPEM) {
|
||||
panic("bogus PEM")
|
||||
}
|
||||
r.p = p
|
||||
}
|
||||
|
||||
/*
|
||||
letsEncryptX1 is the LetsEncrypt X1 root:
|
||||
|
||||
Certificate:
|
||||
|
||||
Data:
|
||||
Version: 3 (0x2)
|
||||
Serial Number:
|
||||
82:10:cf:b0:d2:40:e3:59:44:63:e0:bb:63:82:8b:00
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Issuer: C = US, O = Internet Security Research Group, CN = ISRG Root X1
|
||||
Validity
|
||||
Not Before: Jun 4 11:04:38 2015 GMT
|
||||
Not After : Jun 4 11:04:38 2035 GMT
|
||||
Subject: C = US, O = Internet Security Research Group, CN = ISRG Root X1
|
||||
Subject Public Key Info:
|
||||
Public Key Algorithm: rsaEncryption
|
||||
RSA Public-Key: (4096 bit)
|
||||
|
||||
We bake it into the binary as a fallback verification root,
|
||||
in case the system we're running on doesn't have it.
|
||||
(Tailscale runs on some ancient devices.)
|
||||
|
||||
To test that this code is working on Debian/Ubuntu:
|
||||
|
||||
$ sudo mv /usr/share/ca-certificates/mozilla/ISRG_Root_X1.crt{,.old}
|
||||
$ sudo update-ca-certificates
|
||||
|
||||
Then restart tailscaled. To also test dnsfallback's use of it, nuke
|
||||
your /etc/resolv.conf and it should still start & run fine.
|
||||
*/
|
||||
const letsEncryptX1 = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
// letsEncryptX2 is the ISRG Root X2.
|
||||
//
|
||||
// Subject: O = Internet Security Research Group, CN = ISRG Root X2
|
||||
// Key type: ECDSA P-384
|
||||
// Validity: until 2035-09-04 (generated 2020-09-04)
|
||||
const letsEncryptX2 = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
|
||||
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
|
||||
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
|
||||
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
|
||||
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
|
||||
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
|
||||
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
|
||||
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
|
||||
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
|
||||
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
|
||||
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
|
||||
/q4AaOeMSQ+2b1tbFfLn
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
32
net/bakedroots/bakedroots_test.go
Normal file
32
net/bakedroots/bakedroots_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package bakedroots
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBakedInRoots(t *testing.T) {
|
||||
ResetForTest(t, nil)
|
||||
p := Get()
|
||||
got := p.Subjects()
|
||||
if len(got) != 2 {
|
||||
t.Errorf("subjects = %v; want 2", len(got))
|
||||
}
|
||||
|
||||
// TODO(bradfitz): is there a way to easily make this test prettier without
|
||||
// writing a DER decoder? I'm not seeing how.
|
||||
var name []string
|
||||
for _, der := range got {
|
||||
name = append(name, string(der))
|
||||
}
|
||||
want := []string{
|
||||
"0O1\v0\t\x06\x03U\x04\x06\x13\x02US1)0'\x06\x03U\x04\n\x13 Internet Security Research Group1\x150\x13\x06\x03U\x04\x03\x13\fISRG Root X1",
|
||||
"0O1\v0\t\x06\x03U\x04\x06\x13\x02US1)0'\x06\x03U\x04\n\x13 Internet Security Research Group1\x150\x13\x06\x03U\x04\x03\x13\fISRG Root X2",
|
||||
}
|
||||
if !slices.Equal(name, want) {
|
||||
t.Errorf("subjects = %q; want %q", name, want)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/net/bakedroots"
|
||||
"tailscale.com/net/tlsdial/blockblame"
|
||||
)
|
||||
|
||||
@@ -154,7 +155,7 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
|
||||
// Always verify with our baked-in Let's Encrypt certificate,
|
||||
// so we can log an informational message. This is useful for
|
||||
// detecting SSL MiTM.
|
||||
opts.Roots = bakedInRoots()
|
||||
opts.Roots = bakedroots.Get()
|
||||
_, bakedErr := cs.PeerCertificates[0].Verify(opts)
|
||||
if debug() {
|
||||
log.Printf("tlsdial(bake %q): %v", host, bakedErr)
|
||||
@@ -233,7 +234,7 @@ func SetConfigExpectedCert(c *tls.Config, certDNSName string) {
|
||||
if errSys == nil {
|
||||
return nil
|
||||
}
|
||||
opts.Roots = bakedInRoots()
|
||||
opts.Roots = bakedroots.Get()
|
||||
_, err := certs[0].Verify(opts)
|
||||
if debug() {
|
||||
log.Printf("tlsdial(bake %q/%q): %v", c.ServerName, certDNSName, err)
|
||||
@@ -260,84 +261,3 @@ func NewTransport() *http.Transport {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
letsEncryptX1 is the LetsEncrypt X1 root:
|
||||
|
||||
Certificate:
|
||||
|
||||
Data:
|
||||
Version: 3 (0x2)
|
||||
Serial Number:
|
||||
82:10:cf:b0:d2:40:e3:59:44:63:e0:bb:63:82:8b:00
|
||||
Signature Algorithm: sha256WithRSAEncryption
|
||||
Issuer: C = US, O = Internet Security Research Group, CN = ISRG Root X1
|
||||
Validity
|
||||
Not Before: Jun 4 11:04:38 2015 GMT
|
||||
Not After : Jun 4 11:04:38 2035 GMT
|
||||
Subject: C = US, O = Internet Security Research Group, CN = ISRG Root X1
|
||||
Subject Public Key Info:
|
||||
Public Key Algorithm: rsaEncryption
|
||||
RSA Public-Key: (4096 bit)
|
||||
|
||||
We bake it into the binary as a fallback verification root,
|
||||
in case the system we're running on doesn't have it.
|
||||
(Tailscale runs on some ancient devices.)
|
||||
|
||||
To test that this code is working on Debian/Ubuntu:
|
||||
|
||||
$ sudo mv /usr/share/ca-certificates/mozilla/ISRG_Root_X1.crt{,.old}
|
||||
$ sudo update-ca-certificates
|
||||
|
||||
Then restart tailscaled. To also test dnsfallback's use of it, nuke
|
||||
your /etc/resolv.conf and it should still start & run fine.
|
||||
*/
|
||||
const letsEncryptX1 = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
var bakedInRootsOnce struct {
|
||||
sync.Once
|
||||
p *x509.CertPool
|
||||
}
|
||||
|
||||
func bakedInRoots() *x509.CertPool {
|
||||
bakedInRootsOnce.Do(func() {
|
||||
p := x509.NewCertPool()
|
||||
if !p.AppendCertsFromPEM([]byte(letsEncryptX1)) {
|
||||
panic("bogus PEM")
|
||||
}
|
||||
bakedInRootsOnce.p = p
|
||||
})
|
||||
return bakedInRootsOnce.p
|
||||
}
|
||||
|
||||
@@ -4,37 +4,22 @@
|
||||
package tlsdial
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/net/bakedroots"
|
||||
)
|
||||
|
||||
func resetOnce() {
|
||||
rv := reflect.ValueOf(&bakedInRootsOnce).Elem()
|
||||
rv.Set(reflect.Zero(rv.Type()))
|
||||
}
|
||||
|
||||
func TestBakedInRoots(t *testing.T) {
|
||||
resetOnce()
|
||||
p := bakedInRoots()
|
||||
got := p.Subjects()
|
||||
if len(got) != 1 {
|
||||
t.Errorf("subjects = %v; want 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackRootWorks(t *testing.T) {
|
||||
defer resetOnce()
|
||||
defer bakedroots.ResetForTest(t, nil)
|
||||
|
||||
const debug = false
|
||||
if runtime.GOOS != "linux" {
|
||||
@@ -69,14 +54,7 @@ func TestFallbackRootWorks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resetOnce()
|
||||
bakedInRootsOnce.Do(func() {
|
||||
p := x509.NewCertPool()
|
||||
if !p.AppendCertsFromPEM(caPEM) {
|
||||
t.Fatal("failed to add")
|
||||
}
|
||||
bakedInRootsOnce.p = p
|
||||
})
|
||||
bakedroots.ResetForTest(t, caPEM)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/wireguard-go/tun"
|
||||
"tailscale.com/feature"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// createTAP is non-nil on Linux.
|
||||
var createTAP func(logf logger.Logf, tapName, bridgeName string) (tun.Device, error)
|
||||
// CrateTAP is the hook set by feature/tap.
|
||||
var CreateTAP feature.Hook[func(logf logger.Logf, tapName, bridgeName string) (tun.Device, error)]
|
||||
|
||||
// New returns a tun.Device for the requested device name, along with
|
||||
// the OS-dependent name that was allocated to the device.
|
||||
@@ -29,7 +30,7 @@ func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil, "", errors.New("tap only works on Linux")
|
||||
}
|
||||
if createTAP == nil { // if the ts_omit_tap tag is used
|
||||
if !CreateTAP.IsSet() { // if the ts_omit_tap tag is used
|
||||
return nil, "", errors.New("tap is not supported in this build")
|
||||
}
|
||||
f := strings.Split(tunName, ":")
|
||||
@@ -42,7 +43,7 @@ func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
|
||||
default:
|
||||
return nil, "", errors.New("bogus tap argument")
|
||||
}
|
||||
dev, err = createTAP(logf, tapName, bridgeName)
|
||||
dev, err = CreateTAP.Get()(logf, tapName, bridgeName)
|
||||
} else {
|
||||
dev, err = tun.CreateTUN(tunName, int(DefaultTUNMTU()))
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ const PacketStartOffset = device.MessageTransportHeaderSize
|
||||
// of a packet that can be injected into a tstun.Wrapper.
|
||||
const MaxPacketSize = device.MaxContentSize
|
||||
|
||||
const tapDebug = false // for super verbose TAP debugging
|
||||
// TAPDebug is whether super verbose TAP debugging is enabled.
|
||||
const TAPDebug = false
|
||||
|
||||
var (
|
||||
// ErrClosed is returned when attempting an operation on a closed Wrapper.
|
||||
@@ -459,7 +460,7 @@ func (t *Wrapper) pollVector() {
|
||||
return
|
||||
}
|
||||
n, err = reader(t.vectorBuffer[:], sizes, readOffset)
|
||||
if t.isTAP && tapDebug {
|
||||
if t.isTAP && TAPDebug {
|
||||
s := fmt.Sprintf("% x", t.vectorBuffer[0][:])
|
||||
for strings.HasSuffix(s, " 00") {
|
||||
s = strings.TrimSuffix(s, " 00")
|
||||
@@ -792,7 +793,9 @@ func (pc *peerConfigTable) outboundPacketIsJailed(p *packet.Parsed) bool {
|
||||
return c.jailed
|
||||
}
|
||||
|
||||
type setIPer interface {
|
||||
// SetIPer is the interface expected to be implemented by the TAP implementation
|
||||
// of tun.Device.
|
||||
type SetIPer interface {
|
||||
// SetIP sets the IP addresses of the TAP device.
|
||||
SetIP(ipV4, ipV6 netip.Addr) error
|
||||
}
|
||||
@@ -800,7 +803,7 @@ type setIPer interface {
|
||||
// SetWGConfig is called when a new NetworkMap is received.
|
||||
func (t *Wrapper) SetWGConfig(wcfg *wgcfg.Config) {
|
||||
if t.isTAP {
|
||||
if sip, ok := t.tdev.(setIPer); ok {
|
||||
if sip, ok := t.tdev.(SetIPer); ok {
|
||||
sip.SetIP(findV4(wcfg.Addresses), findV6(wcfg.Addresses))
|
||||
}
|
||||
}
|
||||
@@ -874,12 +877,13 @@ func (t *Wrapper) filterPacketOutboundToWireGuard(p *packet.Parsed, pc *peerConf
|
||||
return filter.Drop, gro
|
||||
}
|
||||
|
||||
if filt.RunOut(p, t.filterFlags) != filter.Accept {
|
||||
if resp, reason := filt.RunOut(p, t.filterFlags); resp != filter.Accept {
|
||||
metricPacketOutDropFilter.Add(1)
|
||||
// TODO(#14280): increment a t.metrics.outboundDroppedPacketsTotal here
|
||||
// once we figure out & document what labels to use for multicast,
|
||||
// link-local-unicast, IP fragments, etc. But they're not
|
||||
// usermetric.ReasonACL.
|
||||
if reason != "" {
|
||||
t.metrics.outboundDroppedPacketsTotal.Add(usermetric.DropLabels{
|
||||
Reason: reason,
|
||||
}, 1)
|
||||
}
|
||||
return filter.Drop, gro
|
||||
}
|
||||
|
||||
|
||||
@@ -1048,6 +1048,7 @@ func derpProbeBandwidthTUN(ctx context.Context, transferTimeSeconds, totalBytesT
|
||||
readConn, err := l.Accept()
|
||||
if err != nil {
|
||||
readFinishedC <- err
|
||||
return
|
||||
}
|
||||
defer readConn.Close()
|
||||
deadline, ok := ctx.Deadline()
|
||||
@@ -1055,6 +1056,7 @@ func derpProbeBandwidthTUN(ctx context.Context, transferTimeSeconds, totalBytesT
|
||||
// Don't try reading past our context's deadline.
|
||||
if err := readConn.SetReadDeadline(deadline); err != nil {
|
||||
readFinishedC <- fmt.Errorf("unable to set read deadline: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
n, err := io.CopyN(io.Discard, readConn, size)
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/slicesx"
|
||||
"tailscale.com/util/vizerror"
|
||||
)
|
||||
|
||||
// CapabilityVersion represents the client's capability level. That
|
||||
@@ -155,7 +156,8 @@ type CapabilityVersion int
|
||||
// - 110: 2024-12-12: removed never-before-used Tailscale SSH public key support (#14373)
|
||||
// - 111: 2025-01-14: Client supports a peer having Node.HomeDERP (issue #14636)
|
||||
// - 112: 2025-01-14: Client interprets AllowedIPs of nil as meaning same as Addresses
|
||||
const CurrentCapabilityVersion CapabilityVersion = 112
|
||||
// - 113: 2025-01-20: Client communicates to control whether funnel is enabled by sending Hostinfo.IngressEnabled (#14688)
|
||||
const CurrentCapabilityVersion CapabilityVersion = 113
|
||||
|
||||
// ID is an integer ID for a user, node, or login allocated by the
|
||||
// control plane.
|
||||
@@ -716,21 +718,6 @@ func CheckTag(tag string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckServiceName validates svc for use as a service name.
|
||||
// We only allow valid DNS labels, since the expectation is that these will be
|
||||
// used as parts of domain names.
|
||||
func CheckServiceName(svc string) error {
|
||||
var ok bool
|
||||
svc, ok = strings.CutPrefix(svc, "svc:")
|
||||
if !ok {
|
||||
return errors.New("services must start with 'svc:'")
|
||||
}
|
||||
if svc == "" {
|
||||
return errors.New("service names must not be empty")
|
||||
}
|
||||
return dnsname.ValidLabel(svc)
|
||||
}
|
||||
|
||||
// CheckRequestTags checks that all of h.RequestTags are valid.
|
||||
func (h *Hostinfo) CheckRequestTags() error {
|
||||
if h == nil {
|
||||
@@ -869,6 +856,7 @@ type Hostinfo struct {
|
||||
ShareeNode bool `json:",omitempty"` // indicates this node exists in netmap because it's owned by a shared-to user
|
||||
NoLogsNoSupport bool `json:",omitempty"` // indicates that the user has opted out of sending logs and support
|
||||
WireIngress bool `json:",omitempty"` // indicates that the node wants the option to receive ingress connections
|
||||
IngressEnabled bool `json:",omitempty"` // if the node has any funnel endpoint enabled
|
||||
AllowsUpdate bool `json:",omitempty"` // indicates that the node has opted-in to admin-console-drive remote updates
|
||||
Machine string `json:",omitempty"` // the current host's machine type (uname -m)
|
||||
GoArch string `json:",omitempty"` // GOARCH value (of the built binary)
|
||||
@@ -895,16 +883,51 @@ type Hostinfo struct {
|
||||
// require changes to Hostinfo.Equal.
|
||||
}
|
||||
|
||||
// ServiceName is the name of a service, of the form `svc:dns-label`. Services
|
||||
// represent some kind of application provided for users of the tailnet with a
|
||||
// MagicDNS name and possibly dedicated IP addresses. Currently (2024-01-21),
|
||||
// the only type of service is [VIPService].
|
||||
// This is not related to the older [Service] used in [Hostinfo.Services].
|
||||
type ServiceName string
|
||||
|
||||
// Validate validates if the service name is formatted correctly.
|
||||
// We only allow valid DNS labels, since the expectation is that these will be
|
||||
// used as parts of domain names. All errors are [vizerror.Error].
|
||||
func (sn ServiceName) Validate() error {
|
||||
bareName, ok := strings.CutPrefix(string(sn), "svc:")
|
||||
if !ok {
|
||||
return vizerror.Errorf("%q is not a valid service name: must start with 'svc:'", sn)
|
||||
}
|
||||
if bareName == "" {
|
||||
return vizerror.Errorf("%q is not a valid service name: must not be empty after the 'svc:' prefix", sn)
|
||||
}
|
||||
return dnsname.ValidLabel(bareName)
|
||||
}
|
||||
|
||||
// String implements [fmt.Stringer].
|
||||
func (sn ServiceName) String() string {
|
||||
return string(sn)
|
||||
}
|
||||
|
||||
// WithoutPrefix is the name of the service without the `svc:` prefix, used for
|
||||
// DNS names. If the name does not include the prefix (which means
|
||||
// [ServiceName.Validate] would return an error) then it returns "".
|
||||
func (sn ServiceName) WithoutPrefix() string {
|
||||
bareName, ok := strings.CutPrefix(string(sn), "svc:")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return bareName
|
||||
}
|
||||
|
||||
// VIPService represents a service created on a tailnet from the
|
||||
// perspective of a node providing that service. These services
|
||||
// have an virtual IP (VIP) address pair distinct from the node's IPs.
|
||||
type VIPService struct {
|
||||
// Name is the name of the service, of the form `svc:dns-label`.
|
||||
// See CheckServiceName for a validation func.
|
||||
// Name uniquely identifies a service on a particular tailnet,
|
||||
// and so also corresponds uniquely to the pair of IP addresses
|
||||
// belonging to the VIP service.
|
||||
Name string
|
||||
// Name is the name of the service. The Name uniquely identifies a service
|
||||
// on a particular tailnet, and so also corresponds uniquely to the pair of
|
||||
// IP addresses belonging to the VIP service.
|
||||
Name ServiceName
|
||||
|
||||
// Ports specify which ProtoPorts are made available by this node
|
||||
// on the service's IPs.
|
||||
@@ -925,14 +948,6 @@ func (hi *Hostinfo) TailscaleSSHEnabled() bool {
|
||||
|
||||
func (v HostinfoView) TailscaleSSHEnabled() bool { return v.ж.TailscaleSSHEnabled() }
|
||||
|
||||
// TailscaleFunnelEnabled reports whether or not this node has explicitly
|
||||
// enabled Funnel.
|
||||
func (hi *Hostinfo) TailscaleFunnelEnabled() bool {
|
||||
return hi != nil && hi.WireIngress
|
||||
}
|
||||
|
||||
func (v HostinfoView) TailscaleFunnelEnabled() bool { return v.ж.TailscaleFunnelEnabled() }
|
||||
|
||||
// NetInfo contains information about the host's network state.
|
||||
type NetInfo struct {
|
||||
// MappingVariesByDestIP says whether the host's NAT mappings
|
||||
@@ -2978,10 +2993,10 @@ type EarlyNoise struct {
|
||||
// vs NodeKey)
|
||||
const LBHeader = "Ts-Lb"
|
||||
|
||||
// ServiceIPMappings maps service names (strings that conform to
|
||||
// [CheckServiceName]) to lists of IP addresses. This is used as the value of
|
||||
// the [NodeAttrServiceHost] capability, to inform service hosts what IP
|
||||
// addresses they need to listen on for each service that they are advertising.
|
||||
// ServiceIPMappings maps ServiceName to lists of IP addresses. This is used
|
||||
// as the value of the [NodeAttrServiceHost] capability, to inform service hosts
|
||||
// what IP addresses they need to listen on for each service that they are
|
||||
// advertising.
|
||||
//
|
||||
// This is of the form:
|
||||
//
|
||||
@@ -2994,4 +3009,4 @@ const LBHeader = "Ts-Lb"
|
||||
// provided in AllowedIPs, but this lets the client know which services
|
||||
// correspond to those IPs. Any services that don't correspond to a service
|
||||
// this client is hosting can be ignored.
|
||||
type ServiceIPMappings map[string][]netip.Addr
|
||||
type ServiceIPMappings map[ServiceName][]netip.Addr
|
||||
|
||||
@@ -166,6 +166,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
|
||||
ShareeNode bool
|
||||
NoLogsNoSupport bool
|
||||
WireIngress bool
|
||||
IngressEnabled bool
|
||||
AllowsUpdate bool
|
||||
Machine string
|
||||
GoArch string
|
||||
|
||||
@@ -51,6 +51,7 @@ func TestHostinfoEqual(t *testing.T) {
|
||||
"ShareeNode",
|
||||
"NoLogsNoSupport",
|
||||
"WireIngress",
|
||||
"IngressEnabled",
|
||||
"AllowsUpdate",
|
||||
"Machine",
|
||||
"GoArch",
|
||||
@@ -251,6 +252,26 @@ func TestHostinfoEqual(t *testing.T) {
|
||||
&Hostinfo{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
&Hostinfo{IngressEnabled: true},
|
||||
&Hostinfo{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
&Hostinfo{IngressEnabled: true},
|
||||
&Hostinfo{IngressEnabled: true},
|
||||
true,
|
||||
},
|
||||
{
|
||||
&Hostinfo{IngressEnabled: false},
|
||||
&Hostinfo{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
&Hostinfo{IngressEnabled: false},
|
||||
&Hostinfo{IngressEnabled: true},
|
||||
false,
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
got := tt.a.Equal(tt.b)
|
||||
|
||||
@@ -283,6 +283,7 @@ func (v HostinfoView) ShieldsUp() bool { return v.ж.Shie
|
||||
func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode }
|
||||
func (v HostinfoView) NoLogsNoSupport() bool { return v.ж.NoLogsNoSupport }
|
||||
func (v HostinfoView) WireIngress() bool { return v.ж.WireIngress }
|
||||
func (v HostinfoView) IngressEnabled() bool { return v.ж.IngressEnabled }
|
||||
func (v HostinfoView) AllowsUpdate() bool { return v.ж.AllowsUpdate }
|
||||
func (v HostinfoView) Machine() string { return v.ж.Machine }
|
||||
func (v HostinfoView) GoArch() string { return v.ж.GoArch }
|
||||
@@ -324,6 +325,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
|
||||
ShareeNode bool
|
||||
NoLogsNoSupport bool
|
||||
WireIngress bool
|
||||
IngressEnabled bool
|
||||
AllowsUpdate bool
|
||||
Machine string
|
||||
GoArch string
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/envknob"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
@@ -169,9 +170,41 @@ func (s *Server) Dial(ctx context.Context, network, address string) (net.Conn, e
|
||||
if err := s.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.awaitRunning(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.dialer.UserDial(ctx, network, address)
|
||||
}
|
||||
|
||||
// awaitRunning waits until the backend is in state Running.
|
||||
// If the backend is in state Starting, it blocks until it reaches
|
||||
// a terminal state (such as Stopped, NeedsMachineAuth)
|
||||
// or the context expires.
|
||||
func (s *Server) awaitRunning(ctx context.Context) error {
|
||||
st := s.lb.State()
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch st {
|
||||
case ipn.Running:
|
||||
return nil
|
||||
case ipn.NeedsLogin, ipn.Starting:
|
||||
// Even after LocalBackend.Start, the state machine is still briefly
|
||||
// in the "NeedsLogin" state. So treat that as also "Starting" and
|
||||
// wait for us to get out of that state.
|
||||
s.lb.WatchNotifications(ctx, ipn.NotifyInitialState, nil, func(n *ipn.Notify) (keepGoing bool) {
|
||||
if n.State != nil {
|
||||
st = *n.State
|
||||
}
|
||||
return st == ipn.NeedsLogin || st == ipn.Starting
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("tsnet: backend in state %v", st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPClient returns an HTTP client that is configured to connect over Tailscale.
|
||||
//
|
||||
// This is useful if you need to have your tsnet services connect to other devices on
|
||||
@@ -1180,7 +1213,8 @@ func (s *Server) listen(network, addr string, lnOn listenOn) (net.Listener, erro
|
||||
keys: keys,
|
||||
addr: addr,
|
||||
|
||||
conn: make(chan net.Conn),
|
||||
closedc: make(chan struct{}),
|
||||
conn: make(chan net.Conn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, key := range keys {
|
||||
@@ -1243,11 +1277,12 @@ type listenKey struct {
|
||||
}
|
||||
|
||||
type listener struct {
|
||||
s *Server
|
||||
keys []listenKey
|
||||
addr string
|
||||
conn chan net.Conn
|
||||
closed bool // guarded by s.mu
|
||||
s *Server
|
||||
keys []listenKey
|
||||
addr string
|
||||
conn chan net.Conn // unbuffered, never closed
|
||||
closedc chan struct{} // closed on [listener.Close]
|
||||
closed bool // guarded by s.mu
|
||||
}
|
||||
|
||||
func (ln *listener) Accept() (net.Conn, error) {
|
||||
@@ -1277,21 +1312,22 @@ func (ln *listener) closeLocked() error {
|
||||
delete(ln.s.listeners, key)
|
||||
}
|
||||
}
|
||||
close(ln.conn)
|
||||
close(ln.closedc)
|
||||
ln.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ln *listener) handle(c net.Conn) {
|
||||
t := time.NewTimer(time.Second)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case ln.conn <- c:
|
||||
case <-t.C:
|
||||
return
|
||||
case <-ln.closedc:
|
||||
case <-ln.s.shutdownCtx.Done():
|
||||
case <-time.After(time.Second):
|
||||
// TODO(bradfitz): this isn't ideal. Think about how
|
||||
// we how we want to do pushback.
|
||||
c.Close()
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
|
||||
// Server returns the tsnet Server associated with the listener.
|
||||
|
||||
@@ -232,6 +232,46 @@ func startServer(t *testing.T, ctx context.Context, controlURL, hostname string)
|
||||
return s, status.TailscaleIPs[0], status.Self.PublicKey
|
||||
}
|
||||
|
||||
func TestDialBlocks(t *testing.T) {
|
||||
tstest.ResourceCheck(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL, _ := startControl(t)
|
||||
|
||||
// Make one tsnet that blocks until it's up.
|
||||
s1, _, _ := startServer(t, ctx, controlURL, "s1")
|
||||
|
||||
ln, err := s1.Listen("tcp", ":8080")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Then make another tsnet node that will only be woken up
|
||||
// upon the first dial.
|
||||
tmp := filepath.Join(t.TempDir(), "s2")
|
||||
os.MkdirAll(tmp, 0755)
|
||||
s2 := &Server{
|
||||
Dir: tmp,
|
||||
ControlURL: controlURL,
|
||||
Hostname: "s2",
|
||||
Store: new(mem.Store),
|
||||
Ephemeral: true,
|
||||
getCertForTesting: testCertRoot.getCert,
|
||||
}
|
||||
if *verboseNodes {
|
||||
s2.Logf = log.Printf
|
||||
}
|
||||
t.Cleanup(func() { s2.Close() })
|
||||
|
||||
c, err := s2.Dial(ctx, "tcp", "s1:8080")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
}
|
||||
|
||||
func TestConn(t *testing.T) {
|
||||
tstest.ResourceCheck(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -494,6 +534,25 @@ func TestListenerCleanup(t *testing.T) {
|
||||
if err := ln.Close(); !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("second ln.Close error: %v, want net.ErrClosed", err)
|
||||
}
|
||||
|
||||
// Verify that handling a connection from gVisor (from a packet arriving)
|
||||
// after a listener closed doesn't panic (previously: sending on a closed
|
||||
// channel) or hang.
|
||||
c := &closeTrackConn{}
|
||||
ln.(*listener).handle(c)
|
||||
if !c.closed {
|
||||
t.Errorf("c.closed = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
type closeTrackConn struct {
|
||||
net.Conn
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (wc *closeTrackConn) Close() error {
|
||||
wc.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// tests https://github.com/tailscale/tailscale/issues/6973 -- that we can start a tsnet server,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
_ "tailscale.com/drive/driveimpl"
|
||||
_ "tailscale.com/envknob"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
_ "tailscale.com/health"
|
||||
_ "tailscale.com/hostinfo"
|
||||
_ "tailscale.com/ipn"
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
_ "tailscale.com/drive/driveimpl"
|
||||
_ "tailscale.com/envknob"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
_ "tailscale.com/health"
|
||||
_ "tailscale.com/hostinfo"
|
||||
_ "tailscale.com/ipn"
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
_ "tailscale.com/drive/driveimpl"
|
||||
_ "tailscale.com/envknob"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
_ "tailscale.com/health"
|
||||
_ "tailscale.com/hostinfo"
|
||||
_ "tailscale.com/ipn"
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
_ "tailscale.com/drive/driveimpl"
|
||||
_ "tailscale.com/envknob"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
_ "tailscale.com/health"
|
||||
_ "tailscale.com/hostinfo"
|
||||
_ "tailscale.com/ipn"
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
_ "tailscale.com/drive/driveimpl"
|
||||
_ "tailscale.com/envknob"
|
||||
_ "tailscale.com/feature/condregister"
|
||||
_ "tailscale.com/health"
|
||||
_ "tailscale.com/hostinfo"
|
||||
_ "tailscale.com/ipn"
|
||||
@@ -59,6 +60,7 @@ import (
|
||||
_ "tailscale.com/util/osshare"
|
||||
_ "tailscale.com/util/syspolicy"
|
||||
_ "tailscale.com/util/winutil"
|
||||
_ "tailscale.com/util/winutil/gp"
|
||||
_ "tailscale.com/version"
|
||||
_ "tailscale.com/version/distro"
|
||||
_ "tailscale.com/wf"
|
||||
|
||||
@@ -52,15 +52,15 @@ func Debugger(mux *http.ServeMux) *DebugHandler {
|
||||
ret.KV("Version", version.Long())
|
||||
ret.Handle("vars", "Metrics (Go)", expvar.Handler())
|
||||
ret.Handle("varz", "Metrics (Prometheus)", http.HandlerFunc(promvarz.Handler))
|
||||
|
||||
// pprof.Index serves everything that runtime/pprof.Lookup finds:
|
||||
// goroutine, threadcreate, heap, allocs, block, mutex
|
||||
ret.Handle("pprof/", "pprof (index)", http.HandlerFunc(pprof.Index))
|
||||
// the CPU profile handler is special because it responds
|
||||
// streamily, unlike every other pprof handler. This means it's
|
||||
// not made available through pprof.Index the way all the other
|
||||
// pprof types are, you have to register the CPU profile handler
|
||||
// separately. Use HandleSilent for that to not pollute the human
|
||||
// debug list with a link that produces streaming line noise if
|
||||
// you click it.
|
||||
// But register the other ones from net/http/pprof directly:
|
||||
ret.HandleSilent("pprof/cmdline", http.HandlerFunc(pprof.Cmdline))
|
||||
ret.HandleSilent("pprof/profile", http.HandlerFunc(pprof.Profile))
|
||||
ret.HandleSilent("pprof/symbol", http.HandlerFunc(pprof.Symbol))
|
||||
ret.HandleSilent("pprof/trace", http.HandlerFunc(pprof.Trace))
|
||||
ret.URL("/debug/pprof/goroutine?debug=1", "Goroutines (collapsed)")
|
||||
ret.URL("/debug/pprof/goroutine?debug=2", "Goroutines (full)")
|
||||
ret.Handle("gc", "force GC", http.HandlerFunc(gcHandler))
|
||||
|
||||
@@ -101,6 +101,54 @@ func (nm *NetworkMap) GetAddresses() views.Slice[netip.Prefix] {
|
||||
return nm.SelfNode.Addresses()
|
||||
}
|
||||
|
||||
// GetVIPServiceIPMap returns a map of service names to the slice of
|
||||
// VIP addresses that correspond to the service. The service names are
|
||||
// with the prefix "svc:".
|
||||
//
|
||||
// TODO(tailscale/corp##25997): cache the result of decoding the capmap so that
|
||||
// we don't have to decode it multiple times after each netmap update.
|
||||
func (nm *NetworkMap) GetVIPServiceIPMap() tailcfg.ServiceIPMappings {
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
if !nm.SelfNode.Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ipMaps, err := tailcfg.UnmarshalNodeCapJSON[tailcfg.ServiceIPMappings](nm.SelfNode.CapMap().AsMap(), tailcfg.NodeAttrServiceHost)
|
||||
if len(ipMaps) != 1 || err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ipMaps[0]
|
||||
}
|
||||
|
||||
// GetIPVIPServiceMap returns a map of VIP addresses to the service
|
||||
// names that has the VIP address. The service names are with the
|
||||
// prefix "svc:".
|
||||
func (nm *NetworkMap) GetIPVIPServiceMap() IPServiceMappings {
|
||||
var res IPServiceMappings
|
||||
if nm == nil {
|
||||
return res
|
||||
}
|
||||
|
||||
if !nm.SelfNode.Valid() {
|
||||
return res
|
||||
}
|
||||
|
||||
serviceIPMap := nm.GetVIPServiceIPMap()
|
||||
if serviceIPMap == nil {
|
||||
return res
|
||||
}
|
||||
res = make(IPServiceMappings)
|
||||
for svc, addrs := range serviceIPMap {
|
||||
for _, addr := range addrs {
|
||||
res[addr] = svc
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// AnyPeersAdvertiseRoutes reports whether any peer is advertising non-exit node routes.
|
||||
func (nm *NetworkMap) AnyPeersAdvertiseRoutes() bool {
|
||||
for _, p := range nm.Peers {
|
||||
@@ -377,3 +425,19 @@ const (
|
||||
_ WGConfigFlags = 1 << iota
|
||||
AllowSubnetRoutes
|
||||
)
|
||||
|
||||
// IPServiceMappings maps IP addresses to service names. This is the inverse of
|
||||
// [tailcfg.ServiceIPMappings], and is used to inform track which service a VIP
|
||||
// is associated with. This is set to b.ipVIPServiceMap every time the netmap is
|
||||
// updated. This is used to reduce the cost for looking up the service name for
|
||||
// the dst IP address in the netStack packet processing workflow.
|
||||
//
|
||||
// This is of the form:
|
||||
//
|
||||
// {
|
||||
// "100.65.32.1": "svc:samba",
|
||||
// "fd7a:115c:a1e0::1234": "svc:samba",
|
||||
// "100.102.42.3": "svc:web",
|
||||
// "fd7a:115c:a1e0::abcd": "svc:web",
|
||||
// }
|
||||
type IPServiceMappings map[netip.Addr]tailcfg.ServiceName
|
||||
|
||||
@@ -270,7 +270,7 @@ func (c *AggregateCounter) UnregisterAll() {
|
||||
// a sum of expvar variables registered with it.
|
||||
func NewAggregateCounter(name string) *AggregateCounter {
|
||||
c := &AggregateCounter{counters: set.Set[*expvar.Int]{}}
|
||||
NewGaugeFunc(name, c.Value)
|
||||
NewCounterFunc(name, c.Value)
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,8 @@ func (f FQDN) Contains(other FQDN) bool {
|
||||
return strings.HasSuffix(other.WithTrailingDot(), cmp)
|
||||
}
|
||||
|
||||
// ValidLabel reports whether label is a valid DNS label.
|
||||
// ValidLabel reports whether label is a valid DNS label. All errors are
|
||||
// [vizerror.Error].
|
||||
func ValidLabel(label string) error {
|
||||
if len(label) == 0 {
|
||||
return vizerror.New("empty DNS label")
|
||||
|
||||
@@ -7,7 +7,11 @@ package execqueue
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExecQueue struct {
|
||||
@@ -16,9 +20,36 @@ type ExecQueue struct {
|
||||
inFlight bool // whether a goroutine is running q.run
|
||||
doneWaiter chan struct{} // non-nil if waiter is waiting, then closed
|
||||
queue []func()
|
||||
|
||||
// metrics follow
|
||||
metricsRegisterOnce sync.Once
|
||||
metricInserts expvar.Int
|
||||
metricRemovals expvar.Int
|
||||
metricQueueLastDrain expvar.Int // unix millis
|
||||
}
|
||||
|
||||
// This is extremely silly but is for debugging
|
||||
var metricsCounter atomic.Int64
|
||||
|
||||
// registerMetrics registers the queue's metrics with expvar, using a unique name.
|
||||
func (q *ExecQueue) registerMetrics() {
|
||||
q.metricsRegisterOnce.Do(func() {
|
||||
m := new(expvar.Map).Init()
|
||||
m.Set("inserts", &q.metricInserts)
|
||||
m.Set("removals", &q.metricRemovals)
|
||||
m.Set("length", expvar.Func(func() any {
|
||||
return q.metricInserts.Value() - q.metricRemovals.Value()
|
||||
}))
|
||||
m.Set("last_drain", &q.metricQueueLastDrain)
|
||||
|
||||
name := fmt.Sprintf("execqueue-%d", metricsCounter.Add(1))
|
||||
expvar.Publish(name, m)
|
||||
})
|
||||
}
|
||||
|
||||
func (q *ExecQueue) Add(f func()) {
|
||||
q.registerMetrics()
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.closed {
|
||||
@@ -26,6 +57,7 @@ func (q *ExecQueue) Add(f func()) {
|
||||
}
|
||||
if q.inFlight {
|
||||
q.queue = append(q.queue, f)
|
||||
q.metricInserts.Add(1)
|
||||
} else {
|
||||
q.inFlight = true
|
||||
go q.run(f)
|
||||
@@ -35,6 +67,8 @@ func (q *ExecQueue) Add(f func()) {
|
||||
// RunSync waits for the queue to be drained and then synchronously runs f.
|
||||
// It returns an error if the queue is closed before f is run or ctx expires.
|
||||
func (q *ExecQueue) RunSync(ctx context.Context, f func()) error {
|
||||
q.registerMetrics()
|
||||
|
||||
for {
|
||||
if err := q.Wait(ctx); err != nil {
|
||||
return err
|
||||
@@ -61,11 +95,13 @@ func (q *ExecQueue) run(f func()) {
|
||||
f := q.queue[0]
|
||||
q.queue[0] = nil
|
||||
q.queue = q.queue[1:]
|
||||
q.metricRemovals.Add(1)
|
||||
q.mu.Unlock()
|
||||
f()
|
||||
q.mu.Lock()
|
||||
}
|
||||
q.inFlight = false
|
||||
q.metricQueueLastDrain.Set(int64(time.Now().UnixMilli()))
|
||||
q.queue = nil
|
||||
if q.doneWaiter != nil {
|
||||
close(q.doneWaiter)
|
||||
@@ -76,6 +112,8 @@ func (q *ExecQueue) run(f func()) {
|
||||
|
||||
// Shutdown asynchronously signals the queue to stop.
|
||||
func (q *ExecQueue) Shutdown() {
|
||||
q.registerMetrics()
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.closed = true
|
||||
@@ -83,6 +121,8 @@ func (q *ExecQueue) Shutdown() {
|
||||
|
||||
// Wait waits for the queue to be empty.
|
||||
func (q *ExecQueue) Wait(ctx context.Context) error {
|
||||
q.registerMetrics()
|
||||
|
||||
q.mu.Lock()
|
||||
waitCh := q.doneWaiter
|
||||
if q.inFlight && waitCh == nil {
|
||||
|
||||
@@ -95,6 +95,17 @@ func Filter[S ~[]T, T any](dst, src S, fn func(T) bool) S {
|
||||
return dst
|
||||
}
|
||||
|
||||
// AppendNonzero appends all non-zero elements of src to dst.
|
||||
func AppendNonzero[S ~[]T, T comparable](dst, src S) S {
|
||||
var zero T
|
||||
for _, v := range src {
|
||||
if v != zero {
|
||||
dst = append(dst, v)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// AppendMatching appends elements in ps to dst if f(x) is true.
|
||||
func AppendMatching[T any](dst, ps []T, f func(T) bool) []T {
|
||||
for _, p := range ps {
|
||||
|
||||
@@ -137,6 +137,19 @@ func TestFilterNoAllocations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendNonzero(t *testing.T) {
|
||||
v := []string{"one", "two", "", "four"}
|
||||
got := AppendNonzero(nil, v)
|
||||
want := []string{"one", "two", "four"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
got = AppendNonzero(v[:0], v)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendMatching(t *testing.T) {
|
||||
v := []string{"one", "two", "three", "four"}
|
||||
got := AppendMatching(v[:0], v, func(s string) bool { return len(s) > 3 })
|
||||
|
||||
@@ -289,7 +289,7 @@ func newSettingMetric(key setting.Key, scope setting.Scope, suffix string, typ c
|
||||
}
|
||||
|
||||
func newMetric(nameParts []string, typ clientmetric.Type) metric {
|
||||
name := strings.Join(slicesx.Filter([]string{internal.OS(), "syspolicy"}, nameParts, isNonEmpty), "_")
|
||||
name := strings.Join(slicesx.AppendNonzero([]string{internal.OS(), "syspolicy"}, nameParts), "_")
|
||||
switch {
|
||||
case !ShouldReport():
|
||||
return &funcMetric{name: name, typ: typ}
|
||||
@@ -304,8 +304,6 @@ func newMetric(nameParts []string, typ clientmetric.Type) metric {
|
||||
}
|
||||
}
|
||||
|
||||
func isNonEmpty(s string) bool { return s != "" }
|
||||
|
||||
func metricScopeName(scope setting.Scope) string {
|
||||
switch scope {
|
||||
case setting.DeviceSetting:
|
||||
|
||||
@@ -123,6 +123,11 @@ const (
|
||||
// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
|
||||
MachineCertificateSubject Key = "MachineCertificateSubject"
|
||||
|
||||
// Hostname is the hostname of the device that is running Tailscale.
|
||||
// When this policy is set, it overrides the hostname that the client
|
||||
// would otherwise obtain from the OS, e.g. by calling os.Hostname().
|
||||
Hostname Key = "Hostname"
|
||||
|
||||
// Keys with a string array value.
|
||||
// AllowedSuggestedExitNodes's string array value is a list of exit node IDs that restricts which exit nodes are considered when generating suggestions for exit nodes.
|
||||
AllowedSuggestedExitNodes Key = "AllowedSuggestedExitNodes"
|
||||
@@ -148,6 +153,7 @@ var implicitDefinitions = []*setting.Definition{
|
||||
setting.NewDefinition(ExitNodeID, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(ExitNodeIP, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(FlushDNSOnSessionUnlock, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(Hostname, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(LogSCMInteractions, setting.DeviceSetting, setting.BooleanValue),
|
||||
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),
|
||||
setting.NewDefinition(MachineCertificateSubject, setting.DeviceSetting, setting.StringValue),
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/syspolicy/internal/loggerx"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
"tailscale.com/util/winutil/gp"
|
||||
)
|
||||
@@ -29,6 +30,18 @@ var (
|
||||
_ Expirable = (*PlatformPolicyStore)(nil)
|
||||
)
|
||||
|
||||
// lockableCloser is a [Lockable] that can also be closed.
|
||||
// It is implemented by [gp.PolicyLock] and [optionalPolicyLock].
|
||||
type lockableCloser interface {
|
||||
Lockable
|
||||
Close() error
|
||||
}
|
||||
|
||||
var (
|
||||
_ lockableCloser = (*gp.PolicyLock)(nil)
|
||||
_ lockableCloser = (*optionalPolicyLock)(nil)
|
||||
)
|
||||
|
||||
// PlatformPolicyStore implements [Store] by providing read access to
|
||||
// Registry-based Tailscale policies, such as those configured via Group Policy or MDM.
|
||||
// For better performance and consistency, it is recommended to lock it when
|
||||
@@ -55,7 +68,7 @@ type PlatformPolicyStore struct {
|
||||
// they are being read.
|
||||
//
|
||||
// When both policyLock and mu need to be taken, mu must be taken before policyLock.
|
||||
policyLock *gp.PolicyLock
|
||||
policyLock lockableCloser
|
||||
|
||||
mu sync.Mutex
|
||||
tsKeys []registry.Key // or nil if the [PlatformPolicyStore] hasn't been locked.
|
||||
@@ -108,7 +121,7 @@ func newPlatformPolicyStore(scope gp.Scope, softwareKey registry.Key, policyLock
|
||||
scope: scope,
|
||||
softwareKey: softwareKey,
|
||||
done: make(chan struct{}),
|
||||
policyLock: policyLock,
|
||||
policyLock: &optionalPolicyLock{PolicyLock: policyLock},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,3 +461,68 @@ func tailscaleKeyNamesFor(scope gp.Scope) []string {
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
type gpLockState int
|
||||
|
||||
const (
|
||||
gpUnlocked = gpLockState(iota)
|
||||
gpLocked
|
||||
gpLockRestricted // the lock could not be acquired due to a restriction in place
|
||||
)
|
||||
|
||||
// optionalPolicyLock is a wrapper around [gp.PolicyLock] that locks
|
||||
// and unlocks the underlying [gp.PolicyLock].
|
||||
//
|
||||
// If the [gp.PolicyLock.Lock] returns [gp.ErrLockRestricted], the error is ignored,
|
||||
// and calling [optionalPolicyLock.Unlock] is a no-op.
|
||||
//
|
||||
// The underlying GP lock is kinda optional: it is safe to read policy settings
|
||||
// from the Registry without acquiring it, but it is recommended to lock it anyway
|
||||
// when reading multiple policy settings to avoid potentially inconsistent results.
|
||||
//
|
||||
// It is not safe for concurrent use.
|
||||
type optionalPolicyLock struct {
|
||||
*gp.PolicyLock
|
||||
state gpLockState
|
||||
}
|
||||
|
||||
// Lock acquires the underlying [gp.PolicyLock], returning an error on failure.
|
||||
// If the lock cannot be acquired due to a restriction in place
|
||||
// (e.g., attempting to acquire a lock while the service is starting),
|
||||
// the lock is considered to be held, the method returns nil, and a subsequent
|
||||
// call to [Unlock] is a no-op.
|
||||
// It is a runtime error to call Lock when the lock is already held.
|
||||
func (o *optionalPolicyLock) Lock() error {
|
||||
if o.state != gpUnlocked {
|
||||
panic("already locked")
|
||||
}
|
||||
switch err := o.PolicyLock.Lock(); err {
|
||||
case nil:
|
||||
o.state = gpLocked
|
||||
return nil
|
||||
case gp.ErrLockRestricted:
|
||||
loggerx.Errorf("GP lock not acquired: %v", err)
|
||||
o.state = gpLockRestricted
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock releases the underlying [gp.PolicyLock], if it was previously acquired.
|
||||
// It is a runtime error to call Unlock when the lock is not held.
|
||||
func (o *optionalPolicyLock) Unlock() {
|
||||
switch o.state {
|
||||
case gpLocked:
|
||||
o.PolicyLock.Unlock()
|
||||
case gpLockRestricted:
|
||||
// The GP lock wasn't acquired due to a restriction in place
|
||||
// when [optionalPolicyLock.Lock] was called. Unlock is a no-op.
|
||||
case gpUnlocked:
|
||||
panic("not locked")
|
||||
default:
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
o.state = gpUnlocked
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ const (
|
||||
// ReasonACL means that the packet was not permitted by ACL.
|
||||
ReasonACL DropReason = "acl"
|
||||
|
||||
// ReasonMulticast means that the packet was dropped because it was a multicast packet.
|
||||
ReasonMulticast DropReason = "multicast"
|
||||
|
||||
// ReasonLinkLocalUnicast means that the packet was dropped because it was a link-local unicast packet.
|
||||
ReasonLinkLocalUnicast DropReason = "link_local_unicast"
|
||||
|
||||
// ReasonTooShort means that the packet was dropped because it was a bad packet,
|
||||
// this could be due to a short packet.
|
||||
ReasonTooShort DropReason = "too_short"
|
||||
|
||||
// ReasonFragment means that the packet was dropped because it was an IP fragment.
|
||||
ReasonFragment DropReason = "fragment"
|
||||
|
||||
// ReasonUnknownProtocol means that the packet was dropped because it was an unknown protocol.
|
||||
ReasonUnknownProtocol DropReason = "unknown_protocol"
|
||||
|
||||
// ReasonError means that the packet was dropped because of an error.
|
||||
ReasonError DropReason = "error"
|
||||
)
|
||||
|
||||
@@ -48,10 +48,35 @@ type policyLockResult struct {
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidLockState is returned by (*PolicyLock).Lock if the lock has a zero value or has already been closed.
|
||||
// ErrInvalidLockState is returned by [PolicyLock.Lock] if the lock has a zero value or has already been closed.
|
||||
ErrInvalidLockState = errors.New("the lock has not been created or has already been closed")
|
||||
// ErrLockRestricted is returned by [PolicyLock.Lock] if the lock cannot be acquired due to a restriction in place,
|
||||
// such as when [RestrictPolicyLocks] has been called.
|
||||
ErrLockRestricted = errors.New("the lock cannot be acquired due to a restriction in place")
|
||||
)
|
||||
|
||||
var policyLockRestricted atomic.Int32
|
||||
|
||||
// RestrictPolicyLocks forces all [PolicyLock.Lock] calls to return [ErrLockRestricted]
|
||||
// until the returned function is called to remove the restriction.
|
||||
//
|
||||
// It is safe to call the returned function multiple times, but the restriction will only
|
||||
// be removed once. If [RestrictPolicyLocks] is called multiple times, each call must be
|
||||
// matched by a corresponding call to the returned function to fully remove the restrictions.
|
||||
//
|
||||
// It is primarily used to prevent certain deadlocks, such as when tailscaled attempts to acquire
|
||||
// a policy lock during startup. If the service starts due to Tailscale being installed by GPSI,
|
||||
// the write lock will be held by the Group Policy service throughout the installation,
|
||||
// preventing tailscaled from acquiring the read lock. Since Group Policy waits for the installation
|
||||
// to complete, and therefore for tailscaled to start, before releasing the write lock, this scenario
|
||||
// would result in a deadlock. See tailscale/tailscale#14416 for more information.
|
||||
func RestrictPolicyLocks() (removeRestriction func()) {
|
||||
policyLockRestricted.Add(1)
|
||||
return sync.OnceFunc(func() {
|
||||
policyLockRestricted.Add(-1)
|
||||
})
|
||||
}
|
||||
|
||||
// NewMachinePolicyLock creates a PolicyLock that facilitates pausing the
|
||||
// application of computer policy. To avoid deadlocks when acquiring both
|
||||
// machine and user locks, acquire the user lock before the machine lock.
|
||||
@@ -103,13 +128,18 @@ func NewUserPolicyLock(token windows.Token) (*PolicyLock, error) {
|
||||
}
|
||||
|
||||
// Lock locks l.
|
||||
// It returns ErrNotInitialized if l has a zero value or has already been closed,
|
||||
// or an Errno if the underlying Group Policy lock cannot be acquired.
|
||||
// It returns [ErrInvalidLockState] if l has a zero value or has already been closed,
|
||||
// [ErrLockRestricted] if the lock cannot be acquired due to a restriction in place,
|
||||
// or a [syscall.Errno] if the underlying Group Policy lock cannot be acquired.
|
||||
//
|
||||
// As a special case, it fails with windows.ERROR_ACCESS_DENIED
|
||||
// As a special case, it fails with [windows.ERROR_ACCESS_DENIED]
|
||||
// if l is a user policy lock, and the corresponding user is not logged in
|
||||
// interactively at the time of the call.
|
||||
func (l *PolicyLock) Lock() error {
|
||||
if policyLockRestricted.Load() > 0 {
|
||||
return ErrLockRestricted
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.lockCnt.Add(2)&1 == 0 {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
"tailscale.com/util/usermetric"
|
||||
"tailscale.com/wgengine/filter/filtertype"
|
||||
)
|
||||
|
||||
@@ -410,7 +411,7 @@ func (f *Filter) ShieldsUp() bool { return f.shieldsUp }
|
||||
// Tailscale peer.
|
||||
func (f *Filter) RunIn(q *packet.Parsed, rf RunFlags) Response {
|
||||
dir := in
|
||||
r := f.pre(q, rf, dir)
|
||||
r, _ := f.pre(q, rf, dir)
|
||||
if r == Accept || r == Drop {
|
||||
// already logged
|
||||
return r
|
||||
@@ -431,16 +432,16 @@ func (f *Filter) RunIn(q *packet.Parsed, rf RunFlags) Response {
|
||||
|
||||
// RunOut determines whether this node is allowed to send q to a
|
||||
// Tailscale peer.
|
||||
func (f *Filter) RunOut(q *packet.Parsed, rf RunFlags) Response {
|
||||
func (f *Filter) RunOut(q *packet.Parsed, rf RunFlags) (Response, usermetric.DropReason) {
|
||||
dir := out
|
||||
r := f.pre(q, rf, dir)
|
||||
r, reason := f.pre(q, rf, dir)
|
||||
if r == Accept || r == Drop {
|
||||
// already logged
|
||||
return r
|
||||
return r, reason
|
||||
}
|
||||
r, why := f.runOut(q)
|
||||
f.logRateLimit(rf, q, dir, r, why)
|
||||
return r
|
||||
return r, ""
|
||||
}
|
||||
|
||||
var unknownProtoStringCache sync.Map // ipproto.Proto -> string
|
||||
@@ -610,33 +611,38 @@ var gcpDNSAddr = netaddr.IPv4(169, 254, 169, 254)
|
||||
|
||||
// pre runs the direction-agnostic filter logic. dir is only used for
|
||||
// logging.
|
||||
func (f *Filter) pre(q *packet.Parsed, rf RunFlags, dir direction) Response {
|
||||
func (f *Filter) pre(q *packet.Parsed, rf RunFlags, dir direction) (Response, usermetric.DropReason) {
|
||||
if len(q.Buffer()) == 0 {
|
||||
// wireguard keepalive packet, always permit.
|
||||
return Accept
|
||||
return Accept, ""
|
||||
}
|
||||
if len(q.Buffer()) < 20 {
|
||||
f.logRateLimit(rf, q, dir, Drop, "too short")
|
||||
return Drop
|
||||
return Drop, usermetric.ReasonTooShort
|
||||
}
|
||||
|
||||
if q.IPProto == ipproto.Unknown {
|
||||
f.logRateLimit(rf, q, dir, Drop, "unknown proto")
|
||||
return Drop, usermetric.ReasonUnknownProtocol
|
||||
}
|
||||
|
||||
if q.Dst.Addr().IsMulticast() {
|
||||
f.logRateLimit(rf, q, dir, Drop, "multicast")
|
||||
return Drop
|
||||
return Drop, usermetric.ReasonMulticast
|
||||
}
|
||||
if q.Dst.Addr().IsLinkLocalUnicast() && q.Dst.Addr() != gcpDNSAddr {
|
||||
f.logRateLimit(rf, q, dir, Drop, "link-local-unicast")
|
||||
return Drop
|
||||
return Drop, usermetric.ReasonLinkLocalUnicast
|
||||
}
|
||||
|
||||
if q.IPProto == ipproto.Fragment {
|
||||
// Fragments after the first always need to be passed through.
|
||||
// Very small fragments are considered Junk by Parsed.
|
||||
f.logRateLimit(rf, q, dir, Accept, "fragment")
|
||||
return Accept
|
||||
return Accept, ""
|
||||
}
|
||||
|
||||
return noVerdict
|
||||
return noVerdict, ""
|
||||
}
|
||||
|
||||
// loggingAllowed reports whether p can appear in logs at all.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user