cmd/k8s-operator,k8s-operator: allow to optionally configure proxies to --accept-routes

A new ProxyClass.tailscaledConfig.acceptRoutes field (defaults to false)
can be used to configure proxies created by the operator to be ran with
--accept-routes via the declarative config.

Updates tailscale/tailscale#10684

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
Irbe Krumina 4 months ago
parent 7912d76da0
commit d481ec4560

@ -73,7 +73,7 @@ func TestConnector(t *testing.T) {
hostname: "test-connector", hostname: "test-connector",
isExitNode: true, isExitNode: true,
subnetRoutes: "10.40.0.0/14", subnetRoutes: "10.40.0.0/14",
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904", confFileHash: "9c66c269f3aa779e4e63ca414c147fd419caa62f9220a535da619f21b6bdf2b9",
} }
expectEqual(t, fc, expectedSecret(t, opts)) expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -83,7 +83,7 @@ func TestConnector(t *testing.T) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"} conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"}
}) })
opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20" opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20"
opts.confFileHash = "fb6c4daf67425f983985750cd8d6f2beae77e614fcb34176604571f5623d6862" opts.confFileHash = "ff6e3c6ca4188a1eeab248397cd8155f08b5530ae0bf1bf0fcc7f28bdfee3217"
expectReconciled(t, cr, "", "test") expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -93,7 +93,7 @@ func TestConnector(t *testing.T) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.44.0.0/20"} conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.44.0.0/20"}
}) })
opts.subnetRoutes = "10.44.0.0/20" opts.subnetRoutes = "10.44.0.0/20"
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb" opts.confFileHash = "2e06dc5fc2b05207007dcc89a5b31b9f4362299af24aa2110d16481dba5d338b"
expectReconciled(t, cr, "", "test") expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -102,7 +102,7 @@ func TestConnector(t *testing.T) {
conn.Spec.SubnetRouter = nil conn.Spec.SubnetRouter = nil
}) })
opts.subnetRoutes = "" opts.subnetRoutes = ""
opts.confFileHash = "7c421a99128eb80e79a285a82702f19f8f720615542a15bd794858a6275d8079" opts.confFileHash = "4697dc19021cf7ce95feeaa49af81f1ac81420ef0023acdd1689df272d134c60"
expectReconciled(t, cr, "", "test") expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -113,7 +113,7 @@ func TestConnector(t *testing.T) {
} }
}) })
opts.subnetRoutes = "10.44.0.0/20" opts.subnetRoutes = "10.44.0.0/20"
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb" opts.confFileHash = "2e06dc5fc2b05207007dcc89a5b31b9f4362299af24aa2110d16481dba5d338b"
expectReconciled(t, cr, "", "test") expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -156,7 +156,7 @@ func TestConnector(t *testing.T) {
parentType: "connector", parentType: "connector",
subnetRoutes: "10.40.0.0/14", subnetRoutes: "10.40.0.0/14",
hostname: "test-connector", hostname: "test-connector",
confFileHash: "57d922331890c9b1c8c6ae664394cb254334c551d9cd9db14537b5d9da9fb17e", confFileHash: "341dc67b44be0c81a0f31f2d3b9ae67084e88435894a0543b04d7fd97bfedf24",
} }
expectEqual(t, fc, expectedSecret(t, opts)) expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -166,7 +166,7 @@ func TestConnector(t *testing.T) {
conn.Spec.ExitNode = true conn.Spec.ExitNode = true
}) })
opts.isExitNode = true opts.isExitNode = true
opts.confFileHash = "1499b591fd97a50f0330db6ec09979792c49890cf31f5da5bb6a3f50dba1e77a" opts.confFileHash = "8f6a5d5895cc8a1ff7896d25ac5794b44f4d3c0e74588cab4674ecb323a676dc"
expectReconciled(t, cr, "", "test") expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -243,7 +243,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
hostname: "test-connector", hostname: "test-connector",
isExitNode: true, isExitNode: true,
subnetRoutes: "10.40.0.0/14", subnetRoutes: "10.40.0.0/14",
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904", confFileHash: "9c66c269f3aa779e4e63ca414c147fd419caa62f9220a535da619f21b6bdf2b9",
} }
expectEqual(t, fc, expectedSecret(t, opts)) expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -272,7 +272,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
// We lose the auth key on second reconcile, because in code it's set to // We lose the auth key on second reconcile, because in code it's set to
// StringData, but is actually read from Data. This works with a real // StringData, but is actually read from Data. This works with a real
// API server, but not with our test setup here. // API server, but not with our test setup here.
opts.confFileHash = "1499b591fd97a50f0330db6ec09979792c49890cf31f5da5bb6a3f50dba1e77a" opts.confFileHash = "8f6a5d5895cc8a1ff7896d25ac5794b44f4d3c0e74588cab4674ecb323a676dc"
expectReconciled(t, cr, "", "test") expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))

@ -35,15 +35,13 @@ spec:
type: object type: object
spec: spec:
type: object type: object
required:
- statefulSet
properties: properties:
statefulSet: statefulSet:
description: Proxy's StatefulSet spec. description: Proxy's StatefulSet spec.
type: object type: object
properties: properties:
annotations: annotations:
description: Annotations that will be added to the StatefulSet created for the proxy. Any Annotations specified here will be merged with the default annotations applied to the StatefulSet by the Tailscale Kubernetes operator as well as any other annotations that might have been applied by other actors. Annotations must be valid Kubernetes annotations. https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set description: Annotations that will be added to the StatefulSet created for the proxy. Any annotations specified here will be merged with the default annotations applied to the StatefulSet by the Tailscale Kubernetes operator as well as any other annotations that might have been applied by other actors. Annotations must be valid Kubernetes annotations. https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
@ -452,6 +450,16 @@ spec:
value: value:
description: Value is the taint value the toleration matches to. If the operator is Exists, the value should be empty, otherwise just a regular string. description: Value is the taint value the toleration matches to. If the operator is Exists, the value should be empty, otherwise just a regular string.
type: string type: string
tailscaledConfig:
description: Configuration for tailscaled running in the proxy.
type: object
properties:
acceptRoutes:
description: AcceptRoutes can be set to "true" to configure the proxy to accept routes advertized by by other nodes on your tailnet, such as subnet routers and app connectors. This is equivalent of running 'tailscale up --accept-routes'. https://tailscale.com/kb/1072/client-preferences#use-tailscale-subnets The value of this field must be a string ("true" or "false"), defaults to "false".
type: string
x-kubernetes-validations:
- rule: type(self) == string && (self=='true' || self=='false')
message: acceptRoutes must be set to a string value. Accepted values are 'true' and 'false'
status: status:
type: object type: object
properties: properties:

@ -196,7 +196,7 @@ spec:
annotations: annotations:
additionalProperties: additionalProperties:
type: string type: string
description: Annotations that will be added to the StatefulSet created for the proxy. Any Annotations specified here will be merged with the default annotations applied to the StatefulSet by the Tailscale Kubernetes operator as well as any other annotations that might have been applied by other actors. Annotations must be valid Kubernetes annotations. https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set description: Annotations that will be added to the StatefulSet created for the proxy. Any annotations specified here will be merged with the default annotations applied to the StatefulSet by the Tailscale Kubernetes operator as well as any other annotations that might have been applied by other actors. Annotations must be valid Kubernetes annotations. https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
type: object type: object
labels: labels:
additionalProperties: additionalProperties:
@ -604,8 +604,16 @@ spec:
type: array type: array
type: object type: object
type: object type: object
required: tailscaledConfig:
- statefulSet description: Configuration for tailscaled running in the proxy.
properties:
acceptRoutes:
description: AcceptRoutes can be set to true to make the proxy to accept routes. from subnet routers and route traffic via exit nodes (defaults to false). https://tailscale.com/kb/1019/subnets
type: string
x-kubernetes-validations:
- message: acceptRoutes must be set to a string value. Accepted values are 'true' and 'false'
rule: type(self) == string && (self=='true' || self=='false')
type: object
type: object type: object
status: status:
properties: properties:

@ -93,7 +93,7 @@ func TestTailscaleIngress(t *testing.T) {
namespace: "default", namespace: "default",
parentType: "ingress", parentType: "ingress",
hostname: "default-test", hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
serveConfig := &ipn.ServeConfig{ serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
@ -128,7 +128,7 @@ func TestTailscaleIngress(t *testing.T) {
opts.shouldEnableForwardingClusterTrafficViaIngress = true opts.shouldEnableForwardingClusterTrafficViaIngress = true
// configfile hash changed at this point in test env only because we // configfile hash changed at this point in test env only because we
// lost auth key due to how changes are applied in test client. // lost auth key due to how changes are applied in test client.
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d" opts.confFileHash = "5c7ed263a3bde718d485eb836f307d76f8778bda1f0303b0ea554d69b5829d80"
expectReconciled(t, ingR, "default", "test") expectReconciled(t, ingR, "default", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -228,7 +228,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
namespace: "default", namespace: "default",
parentType: "ingress", parentType: "ingress",
hostname: "default-test", hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
serveConfig := &ipn.ServeConfig{ serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
@ -263,7 +263,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
opts.proxyClass = pc.Name opts.proxyClass = pc.Name
// configfile hash changed at this point in test env only because we // configfile hash changed at this point in test env only because we
// lost auth key due to how changes are applied in test client. // lost auth key due to how changes are applied in test client.
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d" opts.confFileHash = "5c7ed263a3bde718d485eb836f307d76f8778bda1f0303b0ea554d69b5829d80"
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts)) expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
// 4. tailscale.com/proxy-class label is removed from the Ingress, the // 4. tailscale.com/proxy-class label is removed from the Ingress, the

@ -71,7 +71,7 @@ func TestLoadBalancerClass(t *testing.T) {
parentType: "svc", parentType: "svc",
hostname: "default-test", hostname: "default-test",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSecret(t, opts)) expectEqual(t, fc, expectedSecret(t, opts))
@ -213,7 +213,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
parentType: "svc", parentType: "svc",
tailnetTargetFQDN: tailnetTargetFQDN, tailnetTargetFQDN: tailnetTargetFQDN,
hostname: "default-test", hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSecret(t, o)) expectEqual(t, fc, expectedSecret(t, o))
@ -324,7 +324,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
parentType: "svc", parentType: "svc",
tailnetTargetIP: tailnetTargetIP, tailnetTargetIP: tailnetTargetIP,
hostname: "default-test", hostname: "default-test",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSecret(t, o)) expectEqual(t, fc, expectedSecret(t, o))
@ -432,7 +432,7 @@ func TestAnnotations(t *testing.T) {
parentType: "svc", parentType: "svc",
hostname: "default-test", hostname: "default-test",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSecret(t, o)) expectEqual(t, fc, expectedSecret(t, o))
@ -541,7 +541,7 @@ func TestAnnotationIntoLB(t *testing.T) {
parentType: "svc", parentType: "svc",
hostname: "default-test", hostname: "default-test",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSecret(t, o)) expectEqual(t, fc, expectedSecret(t, o))
@ -591,7 +591,7 @@ func TestAnnotationIntoLB(t *testing.T) {
expectReconciled(t, sr, "default", "test") expectReconciled(t, sr, "default", "test")
// None of the proxy machinery should have changed... // None of the proxy machinery should have changed...
// (although configfile hash will change in test env only because we lose auth key due to out test not syncing secret.StringData -> secret.Data) // (although configfile hash will change in test env only because we lose auth key due to out test not syncing secret.StringData -> secret.Data)
o.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d" o.confFileHash = "5c7ed263a3bde718d485eb836f307d76f8778bda1f0303b0ea554d69b5829d80"
expectEqual(t, fc, expectedHeadlessService(shortName, "svc")) expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
expectEqual(t, fc, expectedSTS(t, fc, o)) expectEqual(t, fc, expectedSTS(t, fc, o))
// ... but the service should have a LoadBalancer status. // ... but the service should have a LoadBalancer status.
@ -675,7 +675,7 @@ func TestLBIntoAnnotation(t *testing.T) {
parentType: "svc", parentType: "svc",
hostname: "default-test", hostname: "default-test",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSecret(t, o)) expectEqual(t, fc, expectedSecret(t, o))
@ -745,7 +745,7 @@ func TestLBIntoAnnotation(t *testing.T) {
// configfile hash changes on a re-apply in this case in tests only as // configfile hash changes on a re-apply in this case in tests only as
// we lose the auth key due to the test apply not syncing // we lose the auth key due to the test apply not syncing
// secret.StringData -> Data. // secret.StringData -> Data.
o.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d" o.confFileHash = "5c7ed263a3bde718d485eb836f307d76f8778bda1f0303b0ea554d69b5829d80"
expectEqual(t, fc, expectedHeadlessService(shortName, "svc")) expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
expectEqual(t, fc, expectedSTS(t, fc, o)) expectEqual(t, fc, expectedSTS(t, fc, o))
@ -821,7 +821,7 @@ func TestCustomHostname(t *testing.T) {
parentType: "svc", parentType: "svc",
hostname: "reindeer-flotilla", hostname: "reindeer-flotilla",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "42376226c7d76ed6d6318315dc6c402f7d993bc0b01a5b0e6c8a833106b7509e", confFileHash: "37426401f55a3e9e48d7e076a2d859386df39138858f8e2e34280555759fb4d8",
} }
expectEqual(t, fc, expectedSecret(t, o)) expectEqual(t, fc, expectedSecret(t, o))
@ -937,7 +937,7 @@ func TestCustomPriorityClassName(t *testing.T) {
hostname: "tailscale-critical", hostname: "tailscale-critical",
priorityClassName: "custom-priority-class-name", priorityClassName: "custom-priority-class-name",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "13cdef0d5f6f0f2406af028710ea1e0f99f65aba4021e4e70ac75a73cf141fd1", confFileHash: "3bf08bbd3c5ef664ce6c25f1ff6d307c411db4c8c5e05ba2aeefb81d7c9de79d",
} }
expectEqual(t, fc, expectedSTS(t, fc, o)) expectEqual(t, fc, expectedSTS(t, fc, o))
@ -1000,7 +1000,7 @@ func TestProxyClassForService(t *testing.T) {
parentType: "svc", parentType: "svc",
hostname: "default-test", hostname: "default-test",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSecret(t, opts)) expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedHeadlessService(shortName, "svc")) expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
@ -1030,7 +1030,7 @@ func TestProxyClassForService(t *testing.T) {
// configfile hash changes on a second apply in test env only because we // configfile hash changes on a second apply in test env only because we
// lose auth key due to out test not syncing secret.StringData -> // lose auth key due to out test not syncing secret.StringData ->
// secret.Data // secret.Data
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d" opts.confFileHash = "5c7ed263a3bde718d485eb836f307d76f8778bda1f0303b0ea554d69b5829d80"
expectReconciled(t, sr, "default", "test") expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts)) expectEqual(t, fc, expectedSTS(t, fc, opts))
@ -1094,7 +1094,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
parentType: "svc", parentType: "svc",
hostname: "default-test", hostname: "default-test",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
} }
expectEqual(t, fc, expectedSTS(t, fc, o)) expectEqual(t, fc, expectedSTS(t, fc, o))
} }
@ -1148,7 +1148,79 @@ func TestProxyFirewallMode(t *testing.T) {
hostname: "default-test", hostname: "default-test",
firewallMode: "nftables", firewallMode: "nftables",
clusterTargetIP: "10.20.30.40", clusterTargetIP: "10.20.30.40",
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549", confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
}
expectEqual(t, fc, expectedSTS(t, fc, o))
}
func TestAcceptRoutes(t *testing.T) {
pc := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{Name: "accept-routes"},
Spec: tsapi.ProxyClassSpec{TailscaledConfig: &tsapi.TailscaledConfig{AcceptRoutes: "true"}}}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pc).
WithStatusSubresource(pc).
Build()
mustUpdateStatus(t, fc, "", "accept-routes", func(pc *tsapi.ProxyClass) {
pc.Status = tsapi.ProxyClassStatus{
Conditions: []tsapi.ConnectorCondition{{
Status: metav1.ConditionTrue,
Type: tsapi.ProxyClassready,
ObservedGeneration: pc.Generation,
}}}
})
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Labels: map[string]string{
LabelProxyClass: "accept-routes",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeLoadBalancer,
LoadBalancerClass: ptr.To("tailscale"),
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
o := configOpts{
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "svc",
hostname: "default-test",
clusterTargetIP: "10.20.30.40",
proxyClass: "accept-routes",
acceptRoutes: true,
confFileHash: "5ec91709575f0f51af26f0ab6f86c7ebb3d6007ec59186c72da67a292d11f268",
} }
expectEqual(t, fc, expectedSTS(t, fc, o)) expectEqual(t, fc, expectedSTS(t, fc, o))

@ -55,18 +55,19 @@ const (
FinalizerName = "tailscale.com/finalizer" FinalizerName = "tailscale.com/finalizer"
// Annotations settable by users on services. // Annotations settable by users on tailscale Services.
AnnotationExpose = "tailscale.com/expose" AnnotationExpose = "tailscale.com/expose"
AnnotationTags = "tailscale.com/tags"
AnnotationHostname = "tailscale.com/hostname" AnnotationHostname = "tailscale.com/hostname"
annotationTailnetTargetIPOld = "tailscale.com/ts-tailnet-target-ip" annotationTailnetTargetIPOld = "tailscale.com/ts-tailnet-target-ip"
AnnotationTailnetTargetIP = "tailscale.com/tailnet-ip" AnnotationTailnetTargetIP = "tailscale.com/tailnet-ip"
//MagicDNS name of tailnet node. AnnotationTailnetTargetFQDN = "tailscale.com/tailnet-fqdn"
AnnotationTailnetTargetFQDN = "tailscale.com/tailnet-fqdn"
// Annotations settable by users on ingresses. // Annotations settable by users on tailscale Ingresses.
AnnotationFunnel = "tailscale.com/funnel" AnnotationFunnel = "tailscale.com/funnel"
// Annotations settable by users on tailscale Ingresses and Services.
AnnotationTags = "tailscale.com/tags"
// If set to true, set up iptables/nftables rules in the proxy forward // If set to true, set up iptables/nftables rules in the proxy forward
// cluster traffic to the tailnet IP of that proxy. This can only be set // cluster traffic to the tailnet IP of that proxy. This can only be set
// on an Ingress. This is useful in cases where a cluster target needs // on an Ingress. This is useful in cases where a cluster target needs
@ -134,6 +135,7 @@ type connector struct {
// isExitNode defines whether this Connector should act as an exit node. // isExitNode defines whether this Connector should act as an exit node.
isExitNode bool isExitNode bool
} }
type tsnetServer interface { type tsnetServer interface {
CertDomains() []string CertDomains() []string
} }
@ -171,11 +173,22 @@ func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.Suga
return nil, fmt.Errorf("failed to reconcile headless service: %w", err) return nil, fmt.Errorf("failed to reconcile headless service: %w", err)
} }
secretName, tsConfigHash, err := a.createOrGetSecret(ctx, logger, sts, hsvc) proxyClass := new(tsapi.ProxyClass)
if sts.ProxyClass != "" {
if err := a.Get(ctx, types.NamespacedName{Name: sts.ProxyClass}, proxyClass); err != nil {
return nil, fmt.Errorf("failed to get ProxyClass: %w", err)
}
if !tsoperator.ProxyClassIsReady(proxyClass) {
logger.Infof("ProxyClass %s specified for the proxy, but it is not (yet) in a ready state, waiting..")
return nil, nil
}
}
secretName, tsConfigHash, err := a.createOrGetSecret(ctx, logger, sts, hsvc, proxyClass)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create or get API key secret: %w", err) return nil, fmt.Errorf("failed to create or get API key secret: %w", err)
} }
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName, tsConfigHash) _, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName, tsConfigHash, proxyClass)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to reconcile statefulset: %w", err) return nil, fmt.Errorf("failed to reconcile statefulset: %w", err)
} }
@ -288,7 +301,7 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec }) return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
} }
func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service) (string, string, error) { func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service, pc *tsapi.ProxyClass) (string, string, error) {
secret := &corev1.Secret{ secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
// Hardcode a -0 suffix so that in future, if we support // Hardcode a -0 suffix so that in future, if we support
@ -336,7 +349,7 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
return "", "", err return "", "", err
} }
} }
confFileBytes, h, err := tailscaledConfig(stsC, authKey, orig) confFileBytes, h, err := tailscaledConfig(stsC, authKey, orig, pc)
if err != nil { if err != nil {
return "", "", fmt.Errorf("error creating tailscaled config: %w", err) return "", "", fmt.Errorf("error creating tailscaled config: %w", err)
} }
@ -417,7 +430,7 @@ var proxyYaml []byte
//go:embed deploy/manifests/userspace-proxy.yaml //go:embed deploy/manifests/userspace-proxy.yaml
var userspaceProxyYaml []byte var userspaceProxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string) (*appsv1.StatefulSet, error) { func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string, proxyClass *tsapi.ProxyClass) (*appsv1.StatefulSet, error) {
ss := new(appsv1.StatefulSet) ss := new(appsv1.StatefulSet)
if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding
if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil { if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil {
@ -437,16 +450,6 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
} }
pod := &ss.Spec.Template pod := &ss.Spec.Template
container := &pod.Spec.Containers[0] container := &pod.Spec.Containers[0]
proxyClass := new(tsapi.ProxyClass)
if sts.ProxyClass != "" {
if err := a.Get(ctx, types.NamespacedName{Name: sts.ProxyClass}, proxyClass); err != nil {
return nil, fmt.Errorf("failed to get ProxyClass: %w", err)
}
if !tsoperator.ProxyClassIsReady(proxyClass) {
logger.Infof("ProxyClass %s specified for the proxy, but it is not (yet) in a ready state, waiting..")
return nil, nil
}
}
container.Image = a.proxyImage container.Image = a.proxyImage
ss.ObjectMeta = metav1.ObjectMeta{ ss.ObjectMeta = metav1.ObjectMeta{
Name: headlessSvc.Name, Name: headlessSvc.Name,
@ -648,12 +651,16 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet)
// tailscaledConfig takes a proxy config, a newly generated auth key if // tailscaledConfig takes a proxy config, a newly generated auth key if
// generated and a Secret with the previous proxy state and auth key and // generated and a Secret with the previous proxy state and auth key and
// produces returns tailscaled configuration and a hash of that configuration. // produces returns tailscaled configuration and a hash of that configuration.
func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret) ([]byte, string, error) { func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret, proxyClass *tsapi.ProxyClass) ([]byte, string, error) {
conf := ipn.ConfigVAlpha{ conf := ipn.ConfigVAlpha{
Version: "alpha0", Version: "alpha0",
AcceptDNS: "false", AcceptDNS: "false",
Locked: "false", Locked: "false",
Hostname: &stsC.Hostname, Hostname: &stsC.Hostname,
AcceptRoutes: opt.NewBool(false), // always set it explicitly
}
if proxyClass != nil && proxyClass.Spec.TailscaledConfig != nil {
conf.AcceptRoutes = opt.Bool(proxyClass.Spec.TailscaledConfig.AcceptRoutes)
} }
if stsC.Connector != nil { if stsC.Connector != nil {
routes, err := netutil.CalcAdvertiseRoutes(stsC.Connector.routes, stsC.Connector.isExitNode) routes, err := netutil.CalcAdvertiseRoutes(stsC.Connector.routes, stsC.Connector.isExitNode)

@ -25,6 +25,7 @@ import (
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/ipn" "tailscale.com/ipn"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1" tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/types/opt"
"tailscale.com/types/ptr" "tailscale.com/types/ptr"
"tailscale.com/util/mak" "tailscale.com/util/mak"
) )
@ -48,6 +49,7 @@ type configOpts struct {
serveConfig *ipn.ServeConfig serveConfig *ipn.ServeConfig
shouldEnableForwardingClusterTrafficViaIngress bool shouldEnableForwardingClusterTrafficViaIngress bool
proxyClass string // configuration from the named ProxyClass should be applied to proxy resources proxyClass string // configuration from the named ProxyClass should be applied to proxy resources
acceptRoutes bool // --accept-routes
} }
func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet { func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet {
@ -339,11 +341,12 @@ func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
mak.Set(&s.StringData, "serve-config", string(serveConfigBs)) mak.Set(&s.StringData, "serve-config", string(serveConfigBs))
} }
conf := &ipn.ConfigVAlpha{ conf := &ipn.ConfigVAlpha{
Version: "alpha0", Version: "alpha0",
AcceptDNS: "false", AcceptDNS: "false",
Hostname: &opts.hostname, Hostname: &opts.hostname,
Locked: "false", Locked: "false",
AuthKey: ptr.To("secret-authkey"), AuthKey: ptr.To("secret-authkey"),
AcceptRoutes: opt.NewBool(opts.acceptRoutes),
} }
var routes []netip.Prefix var routes []netip.Prefix
if opts.subnetRoutes != "" || opts.isExitNode { if opts.subnetRoutes != "" || opts.isExitNode {

@ -335,7 +335,14 @@ ConnectorCondition contains condition information for a Connector.
<td> <td>
Proxy's StatefulSet spec.<br/> Proxy's StatefulSet spec.<br/>
</td> </td>
<td>true</td> <td>false</td>
</tr><tr>
<td><b><a href="#proxyclassspectailscaledconfig">tailscaledConfig</a></b></td>
<td>object</td>
<td>
Configuration for tailscaled running in the proxy.<br/>
</td>
<td>false</td>
</tr></tbody> </tr></tbody>
</table> </table>
@ -360,7 +367,7 @@ Proxy's StatefulSet spec.
<td><b>annotations</b></td> <td><b>annotations</b></td>
<td>map[string]string</td> <td>map[string]string</td>
<td> <td>
Annotations that will be added to the StatefulSet created for the proxy. Any Annotations specified here will be merged with the default annotations applied to the StatefulSet by the Tailscale Kubernetes operator as well as any other annotations that might have been applied by other actors. Annotations must be valid Kubernetes annotations. https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set<br/> Annotations that will be added to the StatefulSet created for the proxy. Any annotations specified here will be merged with the default annotations applied to the StatefulSet by the Tailscale Kubernetes operator as well as any other annotations that might have been applied by other actors. Annotations must be valid Kubernetes annotations. https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set<br/>
</td> </td>
<td>false</td> <td>false</td>
</tr><tr> </tr><tr>
@ -1551,6 +1558,35 @@ The pod this Toleration is attached to tolerates any taint that matches the trip
</table> </table>
### ProxyClass.spec.tailscaledConfig
<sup><sup>[↩ Parent](#proxyclassspec)</sup></sup>
Configuration for tailscaled running in the proxy.
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Required</th>
</tr>
</thead>
<tbody><tr>
<td><b>acceptRoutes</b></td>
<td>string</td>
<td>
AcceptRoutes can be set to "true" to configure the proxy to accept routes advertized by by other nodes on your tailnet, such as subnet routers and app connectors. This is equivalent of running 'tailscale up --accept-routes'. https://tailscale.com/kb/1072/client-preferences#use-tailscale-subnets The value of this field must be a string ("true" or "false"), defaults to "false".<br/>
<br/>
<i>Validations</i>:<li>type(self) == string && (self=='true' || self=='false'): acceptRoutes must be set to a string value. Accepted values are 'true' and 'false'</li>
</td>
<td>false</td>
</tr></tbody>
</table>
### ProxyClass.status ### ProxyClass.status
<sup><sup>[↩ Parent](#proxyclass)</sup></sup> <sup><sup>[↩ Parent](#proxyclass)</sup></sup>

@ -36,10 +36,28 @@ type ProxyClassList struct {
} }
type ProxyClassSpec struct { type ProxyClassSpec struct {
// Configuration for tailscaled running in the proxy.
// +optional
TailscaledConfig *TailscaledConfig `json:"tailscaledConfig,omitempty"`
// Proxy's StatefulSet spec. // Proxy's StatefulSet spec.
StatefulSet *StatefulSet `json:"statefulSet"` // +optional
StatefulSet *StatefulSet `json:"statefulSet,omitempty"`
} }
type TailscaledConfig struct {
// AcceptRoutes can be set to "true" to configure the proxy to accept
// routes advertised by other nodes on your tailnet, such as subnet
// routers and app connectors.
// This is equivalent of running 'tailscale up --accept-routes'.
// https://tailscale.com/kb/1072/client-preferences#use-tailscale-subnets
// The value of this field must be a string ("true" or "false"),
// defaults to "false".
AcceptRoutes Bool `json:"acceptRoutes,omitempty"`
}
// +kubebuilder:validation:XValidation:rule="type(self) == string && (self=='true' || self=='false')",message="acceptRoutes must be set to a string value. Accepted values are 'true' and 'false'"
type Bool string
type StatefulSet struct { type StatefulSet struct {
// Labels that will be added to the StatefulSet created for the proxy. // Labels that will be added to the StatefulSet created for the proxy.
// Any labels specified here will be merged with the default labels // Any labels specified here will be merged with the default labels
@ -51,7 +69,7 @@ type StatefulSet struct {
// +optional // +optional
Labels map[string]string `json:"labels,omitempty"` Labels map[string]string `json:"labels,omitempty"`
// Annotations that will be added to the StatefulSet created for the proxy. // Annotations that will be added to the StatefulSet created for the proxy.
// Any Annotations specified here will be merged with the default annotations // Any annotations specified here will be merged with the default annotations
// applied to the StatefulSet by the Tailscale Kubernetes operator as // applied to the StatefulSet by the Tailscale Kubernetes operator as
// well as any other annotations that might have been applied by other // well as any other annotations that might have been applied by other
// actors. // actors.

@ -283,6 +283,11 @@ func (in *ProxyClassList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProxyClassSpec) DeepCopyInto(out *ProxyClassSpec) { func (in *ProxyClassSpec) DeepCopyInto(out *ProxyClassSpec) {
*out = *in *out = *in
if in.TailscaledConfig != nil {
in, out := &in.TailscaledConfig, &out.TailscaledConfig
*out = new(TailscaledConfig)
**out = **in
}
if in.StatefulSet != nil { if in.StatefulSet != nil {
in, out := &in.StatefulSet, &out.StatefulSet in, out := &in.StatefulSet, &out.StatefulSet
*out = new(StatefulSet) *out = new(StatefulSet)
@ -413,3 +418,18 @@ func (in Tags) DeepCopy() Tags {
in.DeepCopyInto(out) in.DeepCopyInto(out)
return *out return *out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TailscaledConfig) DeepCopyInto(out *TailscaledConfig) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TailscaledConfig.
func (in *TailscaledConfig) DeepCopy() *TailscaledConfig {
if in == nil {
return nil
}
out := new(TailscaledConfig)
in.DeepCopyInto(out)
return out
}

Loading…
Cancel
Save