Compare commits

...

29 Commits

Author SHA1 Message Date
David Anderson
1d5e2cce4f wip 2023-11-25 16:15:22 -08:00
David Anderson
18e7520c63 flake.nix: use vendorHash instead of vendorSha256
vendorSha256 is getting retired, and throws warning in builds.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-11-23 20:30:40 -08:00
Flakes Updater
6b395f6385 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-23 20:16:43 -08:00
Charlotte Brandhorst-Satzkorn
9e63bf5fd6 words: crikey! what a beauty of a list
If I have to add a tail, or a scale, mate, I will add it.

Updates tailscale/corp#14698

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2023-11-22 21:17:05 -08:00
Tom DNetto
611e0a5bcc appc,ipn/local: support wildcard when matching app-connectors
Updates: ENG-2453
Signed-off-by: Tom DNetto <tom@tailscale.com>
2023-11-22 14:47:44 -08:00
Jordan Whited
1af7f5b549 wgengine/magicsock: fix typo in Conn.handlePingLocked() (#10365)
Updates #cleanup

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-22 14:33:12 -08:00
Andrew Dunham
5aa7687b21 util/httpm: don't run test if .git doesn't exist
Updates #9635

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
Change-Id: I9089200f9327605036c88fc12834acece0c11694
2023-11-22 12:09:59 -05:00
Claire Wang
afacf2e368 containerboot: Add TS_ACCEPT_ROUTES (#10176)
Fixes tailscale/corp#15596

Signed-off-by: Claire Wang <claire@tailscale.com>
2023-11-22 11:45:44 -05:00
Gabriel Martinez
128d3ad1a9 cmd/k8s-operator: helm chart add missing keys (#10296)
* cmd/k8s-operator: add missing keys to Helm values file

Updates  #10182

Signed-off-by: Gabriel Martinez <gabrielmartinez@sisti.pt>
2023-11-22 11:02:54 +00:00
Jordan Whited
e1d0d26686 go.mod: bump wireguard-go (#10352)
This pulls in tailscale/wireguard-go@8cc8b8b and
tailscale/wireguard-go@cc193a0, which improve throughput and latency
under load.

Updates tailscale/corp#11061

Signed-off-by: Jordan Whited <jordan@tailscale.com>
2023-11-21 11:21:39 -08:00
Cole Helbling
a8647b3c37 flake: fixup version embedding (#9997)
It looks like `gitCommitStamp` is the new "entrypoint" for setting this
information.

Fixes #9996.

Signed-off-by: Cole Helbling <cole.helbling@determinate.systems>
2023-11-21 12:49:34 -05:00
Percy Wegmann
17501ea31a ci: report test coverage to coveralls.io
This records test coverage for the amd64 no race tests and uploads the
results to coveralls.io.

Updates #cleanup

Signed-off-by: Ox Cart <ox.to.a.cart@gmail.com>
2023-11-21 09:08:37 -06:00
Irbe Krumina
66471710f8 cmd/k8s-operator: truncate long StatefulSet name prefixes (#10343)
Kubernetes can generate StatefulSet names that are too long and result in invalid Pod revision hash label values.
Calculate whether a StatefulSet name generated for a Service or Ingress
will be too long and if so, truncate it.

Updates tailscale/tailscale#10284

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-21 10:20:37 +00:00
Andrew Lytvynov
2c1f14d9e6 util/set: implement json.Marshaler/Unmarshaler (#10308)
Marshal as a JSON list instead of a map. Because set elements are
`comparable` and not `cmp.Ordered`, we cannot easily sort the items
before marshaling.

Updates #cleanup

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
2023-11-20 08:00:31 -08:00
Irbe Krumina
dd8bc9ba03 cmd/k8s-operator: log user/group impersonated by apiserver proxy (#10334)
Updates tailscale/tailscale#10127

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-20 15:41:18 +00:00
Irbe Krumina
4f80f403be cmd/k8s-operator: fix chart syntax error (#10333)
Updates #9222

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2023-11-20 10:39:14 +00:00
License Updater
2fa219440b licenses: update android licenses
Signed-off-by: License Updater <noreply+license-updater@tailscale.com>
2023-11-18 16:39:05 -08:00
David Anderson
f867392970 cmd/tailscale/cli: add debug function to print the netmap
It's possible to do this with a combination of watch-ipn and jq, but looking
at the netmap while debugging is quite common, so it's nice to have a one-shot
command to get it.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-11-18 15:04:36 -08:00
David Anderson
fd22145b52 cmd/tailscale/cli: make 'debug watch-ipn' play nice with jq
jq doens't like non-json output in the json stream, and works more happily
when the input stream EOFs at some point. Move non-json words to stderr, and
add a parameter to stop watching and exit after some number of objects.

Updates #cleanup

Signed-off-by: David Anderson <danderson@tailscale.com>
2023-11-18 14:34:59 -08:00
Ryan Petris
c4855fe0ea Fix Empty Resolver Set
Config.singleResolverSet returns true if all routes have the same resolvers,
even if the routes have no resolvers. If none of the routes have a specific
resolver, the default should be used instead. Therefore, check for more than
0 instead of nil.

Signed-off-by: Ryan Petris <ryan@petris.net>
2023-11-18 14:22:26 -08:00
Uri Gorelik
b88929edf8 Fix potential goroutine leak in syncs/watchdog.go
Depending on how the preemption will occur, in some scenarios sendc
would have blocked indefinitely even after cancelling the context.

Fixes #10315

Signed-off-by: Uri Gorelik <uri.gore@gmail.com>
2023-11-18 10:37:29 -08:00
Flakes Updater
e7cad78b00 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-17 18:55:53 -08:00
OSS Updater
fc8488fac0 go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2023-11-17 18:48:20 -08:00
Will Norris
42dc843a87 client/web: add advanced login options
This adds an expandable section of the login view to allow users to
specify an auth key and an alternate control URL.

Input and Collapsible components and accompanying styles were brought
over from the adminpanel.

Updates #10261

Signed-off-by: Will Norris <will@tailscale.com>
2023-11-17 18:39:02 -08:00
Flakes Updater
f0613ab606 go.mod.sri: update SRI hash for go.mod changes
Signed-off-by: Flakes Updater <noreply+flakes-updater@tailscale.com>
2023-11-17 15:23:16 -08:00
OSS Updater
3402998c1c go.mod: update web-client-prebuilt module
Signed-off-by: OSS Updater <noreply+oss-updater@tailscale.com>
2023-11-17 17:34:41 -05:00
Sonia Appasamy
38ea8f8c9c client/web: add Inter font
Adds Inter font and uses it as the default for the web UI.
Creates a new /assets folder to house the /fonts, and moves /icons
to live here too.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-17 17:09:37 -05:00
Marwan Sulaiman
2dc0645368 ipn/ipnlocal,cmd/tailscale: persist tailnet name in user profile
This PR starts to persist the NetMap tailnet name in SetPrefs so that tailscaled
clients can use this value to disambiguate fast user switching from one tailnet
to another that are under the same exact login. We will also try to backfill
this information during backend starts and profile switches so that users don't
have to re-authenticate their profile. The first client to use this new
information is the CLI in 'tailscale switch -list' which now uses text/tabwriter
to display the ID, Tailnet, and Account. Since account names are ambiguous, we
allow the user to pass 'tailscale switch ID' to specify the exact tailnet they
want to switch to.

Updates #9286

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
2023-11-17 17:00:11 -05:00
Sonia Appasamy
e75be017e4 client/web: add exit node selector
Add exit node selector (in full management client only) that allows
for advertising as an exit node, or selecting another exit node on
the Tailnet for use.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-17 16:45:33 -05:00
69 changed files with 1646 additions and 208 deletions

View File

@@ -64,6 +64,7 @@ jobs:
matrix:
include:
- goarch: amd64
coverflags: "-coverprofile=/tmp/coverage.out"
- goarch: amd64
buildflags: "-race"
shard: '1/3'
@@ -118,10 +119,15 @@ jobs:
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ${{matrix.coverflags}} ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
TS_TEST_SHARD: ${{ matrix.shard }}
- name: Publish to coveralls.io
if: matrix.coverflags != '' # only publish results if we've tracked coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: /tmp/coverage.out
- name: bench all
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env:

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
<link rel="stylesheet" type="text/css" href="/src/index.css" />
<link rel="preload" as="font" href="/src/assets/fonts/Inter.var.latin.woff2" type="font/woff2" crossorigin />
</head>
<body>
<noscript>

View File

@@ -9,6 +9,7 @@
"private": true,
"dependencies": {
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-collapsible": "^1.0.3",
"classnames": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 324 B

After

Width:  |  Height:  |  Size: 324 B

View File

Before

Width:  |  Height:  |  Size: 522 B

After

Width:  |  Height:  |  Size: 522 B

View File

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 704 B

View File

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

View File

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 203 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 738 B

View File

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 500 B

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 635 B

View File

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -1,5 +1,6 @@
import cx from "classnames"
import React, { useEffect } from "react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import LoginToggle from "src/components/login-toggle"
import DeviceDetailsView from "src/components/views/device-details-view"
import HomeView from "src/components/views/home-view"
@@ -8,7 +9,6 @@ import SSHView from "src/components/views/ssh-view"
import { UpdatingView } from "src/components/views/updating-view"
import useAuth, { AuthResponse } from "src/hooks/auth"
import useNodeData, { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import { Link, Route, Router, Switch, useLocation } from "wouter"
export default function App() {
@@ -55,6 +55,7 @@ function WebClient({
readonly={!auth.canManageNode}
node={data}
updateNode={updateNode}
updatePrefs={updatePrefs}
/>
</Route>
<Route path="/details">

View File

@@ -1,57 +1,82 @@
import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as Check } from "src/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Search } from "src/icons/search.svg"
const noExitNode = "None"
const runAsExitNode = "Run as exit node…"
import React, { useCallback, useMemo, useState } from "react"
import { ReactComponent as Check } from "src/assets/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import useExitNodes, {
ExitNode,
noExitNode,
runAsExitNode,
trimDNSSuffix,
} from "src/hooks/exit-nodes"
import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data"
import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input"
export default function ExitNodeSelector({
className,
node,
updateNode,
updatePrefs,
disabled,
}: {
className?: string
node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined
updatePrefs: (p: PrefsUpdate) => Promise<void>
disabled?: boolean
}) {
const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState(
node.AdvertiseExitNode ? runAsExitNode : noExitNode
)
useEffect(() => {
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
}, [node])
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
const handleSelect = useCallback(
(item: string) => {
(n: ExitNode) => {
setOpen(false)
if (item === selected) {
if (n.ID === selected.ID) {
return // no update
}
const old = selected
setSelected(item)
var update: NodeUpdate = {}
switch (item) {
case noExitNode:
// turn off exit node
update = { AdvertiseExitNode: false }
setSelected(n) // optimistic UI update
const reset = () => setSelected(old)
switch (n.ID) {
case noExitNode.ID: {
if (old === runAsExitNode) {
// stop advertising as exit node
updateNode({ AdvertiseExitNode: false })?.catch(reset)
} else {
// stop using exit node
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }).catch(reset)
}
break
case runAsExitNode:
// turn on exit node
update = { AdvertiseExitNode: true }
}
case runAsExitNode.ID: {
const update = () =>
updateNode({ AdvertiseExitNode: true })?.catch(reset)
if (old !== noExitNode) {
// stop using exit node first
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" })
.catch(reset)
.then(update)
} else {
update()
}
break
}
default: {
const update = () =>
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: n.ID }).catch(reset)
if (old === runAsExitNode) {
// stop advertising as exit node first
updateNode({ AdvertiseExitNode: false })?.catch(reset).then(update)
} else {
update()
}
}
}
updateNode(update)?.catch(() => setSelected(old))
},
[setOpen, selected, setSelected]
)
// TODO: close on click outside
// TODO(sonia): allow choosing to use another exit node
const [
none, // not using exit nodes
@@ -59,15 +84,30 @@ export default function ExitNodeSelector({
using, // using another exit node
] = useMemo(
() => [
selected === noExitNode,
selected === runAsExitNode,
selected !== noExitNode && selected !== runAsExitNode,
selected.ID === noExitNode.ID,
selected.ID === runAsExitNode.ID,
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
],
[selected]
)
return (
<>
<Popover
open={disabled ? false : open}
onOpenChange={setOpen}
side="bottom"
sideOffset={5}
align="start"
alignOffset={8}
content={
<ExitNodeSelectorInner
node={node}
selected={selected}
onSelect={handleSelect}
/>
}
asChild
>
<div
className={cx(
"p-1.5 rounded-md border flex items-stretch gap-1.5",
@@ -103,7 +143,14 @@ export default function ExitNodeSelector({
"text-white": advertising || using,
})}
>
{selected === runAsExitNode ? "Running as exit node" : "None"}
{selected.Location && (
<>
<CountryFlag code={selected.Location.CountryCode} />{" "}
</>
)}
{selected === runAsExitNode
? "Running as exit node"
: selected.Name}
</p>
<ChevronDown
className={cx("ml-1", {
@@ -131,47 +178,384 @@ export default function ExitNodeSelector({
</button>
)}
</div>
{open && (
<div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
<div className="w-full px-4 py-2 flex items-center gap-2.5">
<Search />
<input
className="flex-1 leading-snug"
placeholder="Search exit nodes…"
/>
</div>
<DropdownSection
items={[noExitNode, runAsExitNode]}
selected={selected}
onSelect={handleSelect}
/>
</div>
)}
</>
</Popover>
)
}
function DropdownSection({
items,
function toSelectedExitNode(data: NodeData): ExitNode {
if (data.AdvertiseExitNode) {
return runAsExitNode
}
if (data.ExitNodeStatus) {
// TODO(sonia): also use online status
const node = { ...data.ExitNodeStatus }
if (node.Location) {
// For mullvad nodes, use location as name.
node.Name = `${node.Location.Country}: ${node.Location.City}`
} else {
// Otherwise use node name w/o DNS suffix.
node.Name = trimDNSSuffix(node.Name, data.TailnetName)
}
return node
}
return noExitNode
}
function ExitNodeSelectorInner({
node,
selected,
onSelect,
}: {
items: string[]
selected?: string
onSelect: (item: string) => void
node: NodeData
selected: ExitNode
onSelect: (node: ExitNode) => void
}) {
const [filter, setFilter] = useState<string>("")
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
const hasNodes = useMemo(
() => exitNodes.find((n) => n.nodes.length > 0),
[exitNodes]
)
return (
<div className="w-full mt-1 pt-1 border-t border-gray-200">
{items.map((v) => (
<button
key={v}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={() => onSelect(v)}
>
<div className="leading-snug">{v}</div>
{selected == v && <Check />}
</button>
))}
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
<SearchInput
name="exit-node-search"
inputClassName="w-full px-4 py-2"
autoCorrect="off"
autoComplete="off"
autoCapitalize="off"
placeholder="Search exit nodes…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
<div className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll">
{hasNodes ? (
exitNodes.map(
(group) =>
group.nodes.length > 0 && (
<div
key={group.id}
className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
>
{group.name && (
<div className="px-4 py-2 text-neutral-500 text-xs font-medium uppercase tracking-wide">
{group.name}
</div>
)}
{group.nodes.map((n) => (
<ExitNodeSelectorItem
key={`${n.ID}-${n.Name}`}
node={n}
onSelect={() => onSelect(n)}
isSelected={selected.ID == n.ID}
/>
))}
</div>
)
)
) : (
<div className="text-center truncate text-gray-500 p-5">
{filter
? `No exit nodes matching “${filter}`
: "No exit nodes available"}
</div>
)}
</div>
</div>
)
}
function ExitNodeSelectorItem({
node,
isSelected,
onSelect,
}: {
node: ExitNode
isSelected: boolean
onSelect: () => void
}) {
return (
<button
key={node.ID}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={onSelect}
>
<div>
{node.Location && (
<>
<CountryFlag code={node.Location.CountryCode} />{" "}
</>
)}
<span className="leading-snug">{node.Name}</span>
</div>
{isSelected && <Check />}
</button>
)
}
function CountryFlag({ code }: { code: string }) {
return (
countryFlags[code.toLowerCase()] || (
<span className="font-medium text-gray-500 text-xs">
{code.toUpperCase()}
</span>
)
)
}
const countryFlags: { [countryCode: string]: string } = {
ad: "🇦🇩",
ae: "🇦🇪",
af: "🇦🇫",
ag: "🇦🇬",
ai: "🇦🇮",
al: "🇦🇱",
am: "🇦🇲",
ao: "🇦🇴",
aq: "🇦🇶",
ar: "🇦🇷",
as: "🇦🇸",
at: "🇦🇹",
au: "🇦🇺",
aw: "🇦🇼",
ax: "🇦🇽",
az: "🇦🇿",
ba: "🇧🇦",
bb: "🇧🇧",
bd: "🇧🇩",
be: "🇧🇪",
bf: "🇧🇫",
bg: "🇧🇬",
bh: "🇧🇭",
bi: "🇧🇮",
bj: "🇧🇯",
bl: "🇧🇱",
bm: "🇧🇲",
bn: "🇧🇳",
bo: "🇧🇴",
bq: "🇧🇶",
br: "🇧🇷",
bs: "🇧🇸",
bt: "🇧🇹",
bv: "🇧🇻",
bw: "🇧🇼",
by: "🇧🇾",
bz: "🇧🇿",
ca: "🇨🇦",
cc: "🇨🇨",
cd: "🇨🇩",
cf: "🇨🇫",
cg: "🇨🇬",
ch: "🇨🇭",
ci: "🇨🇮",
ck: "🇨🇰",
cl: "🇨🇱",
cm: "🇨🇲",
cn: "🇨🇳",
co: "🇨🇴",
cr: "🇨🇷",
cu: "🇨🇺",
cv: "🇨🇻",
cw: "🇨🇼",
cx: "🇨🇽",
cy: "🇨🇾",
cz: "🇨🇿",
de: "🇩🇪",
dj: "🇩🇯",
dk: "🇩🇰",
dm: "🇩🇲",
do: "🇩🇴",
dz: "🇩🇿",
ec: "🇪🇨",
ee: "🇪🇪",
eg: "🇪🇬",
eh: "🇪🇭",
er: "🇪🇷",
es: "🇪🇸",
et: "🇪🇹",
eu: "🇪🇺",
fi: "🇫🇮",
fj: "🇫🇯",
fk: "🇫🇰",
fm: "🇫🇲",
fo: "🇫🇴",
fr: "🇫🇷",
ga: "🇬🇦",
gb: "🇬🇧",
gd: "🇬🇩",
ge: "🇬🇪",
gf: "🇬🇫",
gg: "🇬🇬",
gh: "🇬🇭",
gi: "🇬🇮",
gl: "🇬🇱",
gm: "🇬🇲",
gn: "🇬🇳",
gp: "🇬🇵",
gq: "🇬🇶",
gr: "🇬🇷",
gs: "🇬🇸",
gt: "🇬🇹",
gu: "🇬🇺",
gw: "🇬🇼",
gy: "🇬🇾",
hk: "🇭🇰",
hm: "🇭🇲",
hn: "🇭🇳",
hr: "🇭🇷",
ht: "🇭🇹",
hu: "🇭🇺",
id: "🇮🇩",
ie: "🇮🇪",
il: "🇮🇱",
im: "🇮🇲",
in: "🇮🇳",
io: "🇮🇴",
iq: "🇮🇶",
ir: "🇮🇷",
is: "🇮🇸",
it: "🇮🇹",
je: "🇯🇪",
jm: "🇯🇲",
jo: "🇯🇴",
jp: "🇯🇵",
ke: "🇰🇪",
kg: "🇰🇬",
kh: "🇰🇭",
ki: "🇰🇮",
km: "🇰🇲",
kn: "🇰🇳",
kp: "🇰🇵",
kr: "🇰🇷",
kw: "🇰🇼",
ky: "🇰🇾",
kz: "🇰🇿",
la: "🇱🇦",
lb: "🇱🇧",
lc: "🇱🇨",
li: "🇱🇮",
lk: "🇱🇰",
lr: "🇱🇷",
ls: "🇱🇸",
lt: "🇱🇹",
lu: "🇱🇺",
lv: "🇱🇻",
ly: "🇱🇾",
ma: "🇲🇦",
mc: "🇲🇨",
md: "🇲🇩",
me: "🇲🇪",
mf: "🇲🇫",
mg: "🇲🇬",
mh: "🇲🇭",
mk: "🇲🇰",
ml: "🇲🇱",
mm: "🇲🇲",
mn: "🇲🇳",
mo: "🇲🇴",
mp: "🇲🇵",
mq: "🇲🇶",
mr: "🇲🇷",
ms: "🇲🇸",
mt: "🇲🇹",
mu: "🇲🇺",
mv: "🇲🇻",
mw: "🇲🇼",
mx: "🇲🇽",
my: "🇲🇾",
mz: "🇲🇿",
na: "🇳🇦",
nc: "🇳🇨",
ne: "🇳🇪",
nf: "🇳🇫",
ng: "🇳🇬",
ni: "🇳🇮",
nl: "🇳🇱",
no: "🇳🇴",
np: "🇳🇵",
nr: "🇳🇷",
nu: "🇳🇺",
nz: "🇳🇿",
om: "🇴🇲",
pa: "🇵🇦",
pe: "🇵🇪",
pf: "🇵🇫",
pg: "🇵🇬",
ph: "🇵🇭",
pk: "🇵🇰",
pl: "🇵🇱",
pm: "🇵🇲",
pn: "🇵🇳",
pr: "🇵🇷",
ps: "🇵🇸",
pt: "🇵🇹",
pw: "🇵🇼",
py: "🇵🇾",
qa: "🇶🇦",
re: "🇷🇪",
ro: "🇷🇴",
rs: "🇷🇸",
ru: "🇷🇺",
rw: "🇷🇼",
sa: "🇸🇦",
sb: "🇸🇧",
sc: "🇸🇨",
sd: "🇸🇩",
se: "🇸🇪",
sg: "🇸🇬",
sh: "🇸🇭",
si: "🇸🇮",
sj: "🇸🇯",
sk: "🇸🇰",
sl: "🇸🇱",
sm: "🇸🇲",
sn: "🇸🇳",
so: "🇸🇴",
sr: "🇸🇷",
ss: "🇸🇸",
st: "🇸🇹",
sv: "🇸🇻",
sx: "🇸🇽",
sy: "🇸🇾",
sz: "🇸🇿",
tc: "🇹🇨",
td: "🇹🇩",
tf: "🇹🇫",
tg: "🇹🇬",
th: "🇹🇭",
tj: "🇹🇯",
tk: "🇹🇰",
tl: "🇹🇱",
tm: "🇹🇲",
tn: "🇹🇳",
to: "🇹🇴",
tr: "🇹🇷",
tt: "🇹🇹",
tv: "🇹🇻",
tw: "🇹🇼",
tz: "🇹🇿",
ua: "🇺🇦",
ug: "🇺🇬",
um: "🇺🇲",
us: "🇺🇸",
uy: "🇺🇾",
uz: "🇺🇿",
va: "🇻🇦",
vc: "🇻🇨",
ve: "🇻🇪",
vg: "🇻🇬",
vi: "🇻🇮",
vn: "🇻🇳",
vu: "🇻🇺",
wf: "🇼🇫",
ws: "🇼🇸",
xk: "🇽🇰",
ye: "🇾🇪",
yt: "🇾🇹",
za: "🇿🇦",
zm: "🇿🇲",
zw: "🇿🇼",
}

View File

@@ -1,10 +1,10 @@
import cx from "classnames"
import React, { useCallback, useEffect, useState } from "react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
import { ReactComponent as User } from "src/assets/icons/user.svg"
import { AuthResponse, AuthType } from "src/hooks/auth"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Eye } from "src/icons/eye.svg"
import { ReactComponent as User } from "src/icons/user.svg"
import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic"

View File

@@ -1,19 +1,21 @@
import cx from "classnames"
import React from "react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data"
import { Link } from "wouter"
export default function HomeView({
readonly,
node,
updateNode,
updatePrefs,
}: {
readonly: boolean
node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined
updatePrefs: (p: PrefsUpdate) => Promise<void>
}) {
return (
<div className="mb-12 w-full">
@@ -36,6 +38,7 @@ export default function HomeView({
className="mb-5"
node={node}
updateNode={updateNode}
updatePrefs={updatePrefs}
disabled={readonly}
/>
<Link

View File

@@ -1,7 +1,9 @@
import React, { useCallback } from "react"
import React, { useCallback, useState } from "react"
import { apiFetch } from "src/api"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import Collapsible from "src/ui/collapsible"
import Input from "src/ui/input"
/**
* LoginView is rendered when the client is not authenticated
@@ -14,6 +16,9 @@ export default function LoginView({
data: NodeData
refreshData: () => void
}) {
const [controlURL, setControlURL] = useState<string>("")
const [authKey, setAuthKey] = useState<string>("")
const login = useCallback(
(opt: TailscaleUpOptions) => {
tailscaleUp(opt).then(refreshData)
@@ -76,11 +81,44 @@ export default function LoginView({
</p>
</div>
<button
onClick={() => login({ Reauthenticate: true })}
onClick={() =>
login({
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
})
}
className="button button-blue w-full mb-4"
>
Log In
</button>
<Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500">
Connect with a pre-authenticated key.{" "}
<a
href="https://tailscale.com/kb/1085/auth-keys/"
className="link"
target="_blank"
>
Learn more &rarr;
</a>
</p>
<Input
className="mt-2"
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder="tskey-auth-XXX"
/>
<h4 className="font-medium mt-3 mb-1">Server URL</h4>
<p className="text-sm text-gray-500">Base URL of control server.</p>
<Input
className="mt-2"
value={controlURL}
onChange={(e) => setControlURL(e.target.value)}
placeholder="https://login.tailscale.com/"
/>
</Collapsible>
</>
)}
</div>
@@ -89,6 +127,8 @@ export default function LoginView({
type TailscaleUpOptions = {
Reauthenticate?: boolean // force reauthentication
ControlURL?: string
AuthKey?: string
}
function tailscaleUp(options: TailscaleUpOptions) {

View File

@@ -1,12 +1,12 @@
import React from "react"
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
import { ChangelogText } from "src/components/update-available"
import {
UpdateState,
useInstallUpdate,
VersionInfo,
} from "src/hooks/self-update"
import { ReactComponent as CheckCircleIcon } from "src/icons/check-circle.svg"
import { ReactComponent as XCircleIcon } from "src/icons/x-circle.svg"
import Spinner from "src/ui/spinner"
import { Link } from "wouter"

View File

@@ -0,0 +1,199 @@
import { useEffect, useMemo, useState } from "react"
import { apiFetch } from "src/api"
export type ExitNode = {
ID: string
Name: string
Location?: ExitNodeLocation
}
type ExitNodeLocation = {
Country: string
CountryCode: CountryCode
City: string
CityCode: CityCode
Priority: number
}
type CountryCode = string
type CityCode = string
export type ExitNodeGroup = {
id: string
name?: string
nodes: ExitNode[]
}
export default function useExitNodes(tailnetName: string, filter?: string) {
const [data, setData] = useState<ExitNode[]>([])
useEffect(() => {
apiFetch("/exit-nodes", "GET")
.then((r) => r.json())
.then((r) => setData(r))
.catch((err) => {
alert("Failed operation: " + err.message)
})
}, [])
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
// First going through exit nodes and splitting them into two groups:
// 1. tailnetNodes: exit nodes advertised by tailnet's own nodes
// 2. locationNodes: exit nodes advertised by non-tailnet Mullvad nodes
let tailnetNodes: ExitNode[] = []
const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
data?.forEach((n) => {
const loc = n.Location
if (!loc) {
// 2023-11-15: Currently, if the node doesn't have
// location information, it is owned by the tailnet.
// Only Mullvad exit nodes have locations filled.
tailnetNodes.push({
...n,
Name: trimDNSSuffix(n.Name, tailnetName),
})
return
}
const countryNodes =
locationNodes.get(loc.CountryCode) || new Map<CityCode, ExitNode[]>()
const cityNodes = countryNodes.get(loc.CityCode) || []
countryNodes.set(loc.CityCode, [...cityNodes, n])
locationNodes.set(loc.CountryCode, countryNodes)
})
return {
tailnetNodesSorted: tailnetNodes.sort(compareByName),
locationNodesMap: locationNodes,
}
}, [data, tailnetName])
const mullvadNodesSorted = useMemo(() => {
const nodes: ExitNode[] = []
// addBestMatchNode adds the node with the "higest priority"
// match from a list of exit node `options` to `nodes`.
const addBestMatchNode = (
options: ExitNode[],
name: (l: ExitNodeLocation) => string
) => {
const bestNode = highestPriorityNode(options)
if (!bestNode || !bestNode.Location) {
return // not possible, doing this for type safety
}
nodes.push({
ID: bestNode.ID,
Name: name(bestNode.Location),
Location: bestNode.Location,
})
}
if (!Boolean(filter)) {
// When nothing is searched, only show a single best-matching
// exit node per-country.
//
// There's too many location-based nodes to display all of them.
locationNodesMap.forEach(
// add one node per country
(countryNodes) =>
addBestMatchNode(flattenMap(countryNodes), (l) => l.Country)
)
} else {
// Otherwise, show the best match on a city-level,
// with a "Country: Best Match" node at top.
//
// i.e. We allow for discovering cities through searching.
locationNodesMap.forEach((countryNodes) => {
countryNodes.forEach(
// add one node per city
(cityNodes) =>
addBestMatchNode(cityNodes, (l) => `${l.Country}: ${l.City}`)
)
// add the "Country: Best Match" node
addBestMatchNode(
flattenMap(countryNodes),
(l) => `${l.Country}: Best Match`
)
})
}
return nodes.sort(compareByName)
}, [locationNodesMap, Boolean(filter)])
// Ordered and filtered grouping of exit nodes.
const exitNodeGroups = useMemo(() => {
const filterLower = !filter ? undefined : filter.toLowerCase()
return [
{ id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] },
{
id: "tailnet",
nodes: filterLower
? tailnetNodesSorted.filter((n) =>
n.Name.toLowerCase().includes(filterLower)
)
: tailnetNodesSorted,
},
{
id: "mullvad",
name: "Mullvad VPN",
nodes: filterLower
? mullvadNodesSorted.filter((n) =>
n.Name.toLowerCase().includes(filterLower)
)
: mullvadNodesSorted,
},
]
}, [tailnetNodesSorted, mullvadNodesSorted, filter])
return { data: exitNodeGroups }
}
// highestPriorityNode finds the highest priority node for use
// (the "best match" node) from a list of exit nodes.
// Nodes with equal priorities are picked between arbitrarily.
function highestPriorityNode(nodes: ExitNode[]): ExitNode | undefined {
return nodes.length === 0
? undefined
: nodes.sort(
(a, b) => (b.Location?.Priority || 0) - (a.Location?.Priority || 0)
)[0]
}
// compareName compares two exit nodes alphabetically by name.
function compareByName(a: ExitNode, b: ExitNode): number {
if (a.Location && b.Location && a.Location.Country == b.Location.Country) {
// Always put "<Country>: Best Match" node at top of country list.
if (a.Name.includes(": Best Match")) {
return -1
} else if (b.Name.includes(": Best Match")) {
return 1
}
}
return a.Name.localeCompare(b.Name)
}
function flattenMap<T, V>(m: Map<T, V[]>): V[] {
return Array.from(m.values()).reduce((prev, curr) => [...prev, ...curr])
}
// trimDNSSuffix trims the tailnet dns name from s, leaving no
// trailing dots.
//
// trimDNSSuffix("hello.ts.net", "ts.net") = "hello"
// trimDNSSuffix("hello", "ts.net") = "hello"
export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
if (s.endsWith(".")) {
s = s.slice(0, -1)
}
if (s.endsWith("." + tailnetDNSName)) {
s = s.replace("." + tailnetDNSName, "")
}
return s
}
export const noExitNode: ExitNode = { ID: "NONE", Name: "None" }
export const runAsExitNode: ExitNode = {
ID: "RUNNING",
Name: "Run as exit node…",
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setUnraidCsrfToken } from "src/api"
import { ExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update"
export type NodeData = {
@@ -28,6 +29,7 @@ export type NodeData = {
IsTagged: boolean
Tags: string[]
RunningSSHServer: boolean
ExitNodeStatus?: ExitNode & { Online: boolean }
}
type NodeState =
@@ -52,6 +54,8 @@ export type NodeUpdate = {
export type PrefsUpdate = {
RunSSHSet?: boolean
RunSSH?: boolean
ExitNodeIDSet?: boolean
ExitNodeID?: string
}
// useNodeData returns basic data about the current node.

View File

@@ -3,6 +3,18 @@
@tailwind utilities;
@layer base {
@font-face {
font-family: "Inter";
font-weight: 100 900;
font-style: normal;
font-display: swap;
src: url("./assets/fonts/Inter.var.latin.woff2") format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
U+02BB-02BC, U+2000-206F, U+2122, U+2190-2199, U+2212, U+2215, U+FEFF,
U+FFFD, U+E06B-E080, U+02E2, U+02E2, U+02B0, U+1D34, U+1D57, U+1D40,
U+207F, U+1D3A, U+1D48, U+1D30, U+02B3, U+1D3F;
}
h1 {
@apply text-neutral-800 text-[22px] font-medium leading-[30.80px];
}
@@ -33,7 +45,7 @@
}
.description {
@apply text-neutral-500 leading-snug
@apply text-neutral-500 leading-snug;
}
/**
@@ -132,6 +144,48 @@
.toggle-small:checked:enabled:active::after {
@apply w-[0.675rem] translate-x-[0.55rem];
}
/**
* .input defines default text input field styling. These styles should
* correspond to .button, sharing a similar height and rounding, since .input
* and .button are commonly used together.
*/
.input,
.input-wrapper {
@apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors w-full h-input;
}
.input {
@apply px-3;
}
.input::placeholder,
.input-wrapper::placeholder {
@apply text-gray-400;
}
.input:disabled,
.input-wrapper:disabled {
@apply border-gray-300;
@apply bg-gray-0;
@apply cursor-not-allowed;
}
.input:focus,
.input-wrapper:focus-within {
@apply outline-none ring border-gray-400;
}
.input-error {
@apply border-red-200;
}
}
@layer utilities {
.h-input {
@apply h-[2.375rem];
}
}
/**

View File

@@ -0,0 +1,33 @@
import * as Primitive from "@radix-ui/react-collapsible"
import React, { useState } from "react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
type CollapsibleProps = {
trigger?: string
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export default function Collapsible(props: CollapsibleProps) {
const { children, trigger, onOpenChange } = props
const [open, setOpen] = useState(props.open)
return (
<Primitive.Root
open={open}
onOpenChange={(open) => {
setOpen(open)
onOpenChange?.(open)
}}
>
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
</span>
{trigger}
</Primitive.Trigger>
<Primitive.Content className="mt-2">{children}</Primitive.Content>
</Primitive.Root>
)
}

View File

@@ -0,0 +1,41 @@
import cx from "classnames"
import React, { InputHTMLAttributes } from "react"
type Props = {
className?: string
inputClassName?: string
error?: boolean
suffix?: JSX.Element
} & InputHTMLAttributes<HTMLInputElement>
// Input is styled in a way that only works for text inputs.
const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const {
className,
inputClassName,
error,
prefix,
suffix,
disabled,
...rest
} = props
return (
<div className={cx("relative", className)}>
<input
ref={ref}
className={cx("input z-10", inputClassName, {
"input-error": error,
})}
disabled={disabled}
{...rest}
/>
{suffix ? (
<div className="bg-white top-1 bottom-1 right-1 rounded-r-md absolute flex items-center">
{suffix}
</div>
) : null}
</div>
)
})
export default Input

View File

@@ -0,0 +1,28 @@
import cx from "classnames"
import React, { forwardRef, InputHTMLAttributes } from "react"
import { ReactComponent as Search } from "src/assets/icons/search.svg"
type Props = {
className?: string
inputClassName?: string
} & InputHTMLAttributes<HTMLInputElement>
/**
* SearchInput is a standard input with a search icon.
*/
const SearchInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
const { className, inputClassName, ...rest } = props
return (
<div className={cx("relative", className)}>
<Search className="absolute w-[1.25em] h-full ml-2" />
<input
type="text"
className={cx("input px-8", inputClassName)}
ref={ref}
{...rest}
/>
</div>
)
})
SearchInput.displayName = "SearchInput"
export default SearchInput

View File

@@ -1,12 +1,46 @@
const plugin = require("tailwindcss/plugin")
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
fontFamily: {
sans: [
"Inter",
"-apple-system",
"BlinkMacSystemFont",
"Helvetica",
"Arial",
"sans-serif",
],
mono: [
"SFMono-Regular",
"SFMono Regular",
"Consolas",
"Liberation Mono",
"Menlo",
"Courier",
"monospace",
],
},
fontWeight: {
normal: "400",
medium: "500",
semibold: "600",
bold: "700",
},
extend: {},
},
plugins: [],
plugins: [
plugin(function ({ addVariant }) {
addVariant("state-open", [
'&[data-state="open"]',
'[data-state="open"] &',
])
addVariant("state-closed", [
'&[data-state="closed"]',
'[data-state="closed"] &',
])
}),
],
}

View File

@@ -528,6 +528,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
case path == "/exit-nodes" && r.Method == httpm.GET:
s.serveGetExitNodes(w, r)
return
case strings.HasPrefix(path, "/local/"):
s.proxyRequestToLocalAPI(w, r)
return
@@ -560,6 +563,7 @@ type nodeData struct {
UnraidToken string
URLPrefix string // if set, the URL prefix the client is served behind
ExitNodeStatus *exitNodeWithStatus
AdvertiseExitNode bool
AdvertiseRoutes string
RunningSSHServer bool
@@ -634,9 +638,62 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
data.AdvertiseRoutes += r.String()
}
}
if e := st.ExitNodeStatus; e != nil {
data.ExitNodeStatus = &exitNodeWithStatus{
exitNode: exitNode{ID: e.ID},
Online: e.Online,
}
for _, ps := range st.Peer {
if ps.ID == e.ID {
data.ExitNodeStatus.Name = ps.DNSName
data.ExitNodeStatus.Location = ps.Location
break
}
}
if data.ExitNodeStatus.Name == "" {
// Falling back to TailscaleIP/StableNodeID when the peer
// is no longer included in status.
if len(e.TailscaleIPs) > 0 {
data.ExitNodeStatus.Name = e.TailscaleIPs[0].Addr().String()
} else {
data.ExitNodeStatus.Name = string(e.ID)
}
}
}
writeJSON(w, *data)
}
type exitNode struct {
ID tailcfg.StableNodeID
Name string
Location *tailcfg.Location
}
type exitNodeWithStatus struct {
exitNode
Online bool
}
func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
st, err := s.lc.Status(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var exitNodes []*exitNode
for _, ps := range st.Peer {
if !ps.ExitNodeOption {
continue
}
exitNodes = append(exitNodes, &exitNode{
ID: ps.ID,
Name: ps.DNSName,
Location: ps.Location,
})
}
writeJSON(w, exitNodes)
}
type nodeUpdate struct {
AdvertiseRoutes string
AdvertiseExitNode bool
@@ -733,10 +790,18 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tails
go func() {
if !isRunning {
s.lc.Start(ctx, ipn.Options{})
ipnOptions := ipn.Options{AuthKey: opt.AuthKey}
if opt.ControlURL != "" {
ipnOptions.UpdatePrefs = &ipn.Prefs{ControlURL: opt.ControlURL}
}
if err := s.lc.Start(ctx, ipnOptions); err != nil {
s.logf("start: %v", err)
}
}
if opt.Reauthenticate {
s.lc.StartLoginInteractive(ctx)
if err := s.lc.StartLoginInteractive(ctx); err != nil {
s.logf("startLogin: %v", err)
}
}
}()
@@ -745,6 +810,9 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tails
if err != nil {
return "", err
}
if n.State != nil && *n.State == ipn.Running {
return "", nil
}
if n.ErrMessage != nil {
msg := *n.ErrMessage
return "", fmt.Errorf("backend error: %v", msg)
@@ -759,6 +827,9 @@ type tailscaleUpOptions struct {
// If true, force reauthentication of the client.
// Otherwise simply reconnect, the same as running `tailscale up`.
Reauthenticate bool
ControlURL string
AuthKey string
}
// serveTailscaleUp serves requests to /api/up.

View File

@@ -478,6 +478,21 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-collapsible@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81"
integrity sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"

View File

@@ -13,7 +13,7 @@
//
// - TS_AUTHKEY: the authkey to use for login.
// - TS_HOSTNAME: the hostname to request for the node.
// - TS_ROUTES: subnet routes to advertise.
// - TS_ROUTES: subnet routes to advertise. To accept routes, use TS_EXTRA_ARGS to pass in --accept-routes.
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
// destination.
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given

View File

@@ -575,6 +575,22 @@ func TestContainerBoot(t *testing.T) {
},
},
},
{
Name: "extra_args_accept_routes",
Env: map[string]string{
"TS_EXTRA_ARGS": "--accept-routes",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --accept-routes",
},
}, {
Notify: runningNotify,
},
},
},
{
Name: "hostname",
Env: map[string]string{

View File

@@ -29,7 +29,7 @@ spec:
serviceAccountName: operator
{{- with .Values.operatorConfig.podSecurityContext }}
securityContext:
{{- toYaml .Values.operatorConfig.podSecurityContext | nindent 8 }}
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: oauth

View File

@@ -4,9 +4,9 @@
# Operator oauth credentials. If set a Kubernetes Secret with the provided
# values will be created in the operator namespace. If unset a Secret named
# operator-oauth must be precreated.
# oauth:
# clientId: ""
# clientSecret: ""
oauth: {}
# clientId: ""
# clientSecret: ""
operatorConfig:
image:
@@ -15,11 +15,23 @@ operatorConfig:
# used.
tag: ""
digest: ""
pullPolicy: Always
logging: "info"
hostname: "tailscale-operator"
nodeSelector:
kubernetes.io/os: linux
resources: {}
podAnnotations: {}
tolerations: []
affinity: {}
podSecurityContext: {}
securityContext: {}
# proxyConfig contains configuraton that will be applied to any ingress/egress
# proxies created by the operator.
@@ -43,3 +55,5 @@ proxyConfig:
# https://tailscale.com/kb/1236/kubernetes-operator/#accessing-the-kubernetes-control-plane-using-an-api-server-proxy
apiServerProxyConfig:
mode: "false" # "true", "false", "noauth"
imagePullSecrets: []

View File

@@ -23,7 +23,6 @@ import (
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
@@ -109,21 +108,21 @@ func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config,
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy").Infof, mode)
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode)
}
// apiserverProxy is an http.Handler that authenticates requests using the Tailscale
// LocalAPI and then proxies them to the Kubernetes API.
type apiserverProxy struct {
logf logger.Logf
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
log *zap.SugaredLogger
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
}
func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
h.logf("failed to authenticate caller: %v", err)
h.log.Errorf("failed to authenticate caller: %v", err)
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
return
}
@@ -145,7 +144,7 @@ func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// are passed through to the Kubernetes API.
//
// It never returns.
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf, mode apiServerProxyMode) {
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode) {
if mode == apiserverProxyModeDisabled {
return
}
@@ -163,8 +162,8 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
log.Fatalf("could not get local client: %v", err)
}
ap := &apiserverProxy{
logf: logf,
lc: lc,
log: log,
lc: lc,
rp: &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
// Replace the URL with the Kubernetes APIServer.
@@ -196,7 +195,7 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
}
// Now add the impersonation headers that we want.
if err := addImpersonationHeaders(r.Out); err != nil {
if err := addImpersonationHeaders(r.Out, log); err != nil {
panic("failed to add impersonation headers: " + err.Error())
}
},
@@ -213,6 +212,7 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
Handler: ap,
}
log.Infof("listening on %s", ln.Addr())
if err := hs.ServeTLS(ln, "", ""); err != nil {
log.Fatalf("runAPIServerProxy: failed to serve %v", err)
}
@@ -235,7 +235,8 @@ type impersonateRule struct {
// addImpersonationHeaders adds the appropriate headers to r to impersonate the
// caller when proxying to the Kubernetes API. It uses the WhoIsResponse stashed
// in the context by the apiserverProxy.
func addImpersonationHeaders(r *http.Request) error {
func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
log = log.With("remote", r.RemoteAddr)
who := whoIsFromRequest(r)
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, capabilityName)
if err != nil {
@@ -253,21 +254,26 @@ func addImpersonationHeaders(r *http.Request) error {
}
r.Header.Add("Impersonate-Group", group)
groupsAdded.Add(group)
log.Debugf("adding group impersonation header for user group %s", group)
}
}
if !who.Node.IsTagged() {
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
log.Debugf("adding user impersonation header for user %s", who.UserProfile.LoginName)
return nil
}
// "Impersonate-Group" requires "Impersonate-User" to be set, so we set it
// to the node FQDN for tagged nodes.
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
nodeName := strings.TrimSuffix(who.Node.Name, ".")
r.Header.Set("Impersonate-User", nodeName)
log.Debugf("adding user impersonation header for node name %s", nodeName)
// For legacy behavior (before caps), set the groups to the nodes tags.
if groupsAdded.Slice().Len() == 0 {
for _, tag := range who.Node.Tags {
r.Header.Add("Impersonate-Group", tag)
log.Debugf("adding group impersonation header for node tag %s", tag)
}
}
return nil

View File

@@ -10,12 +10,17 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/util/must"
)
func TestImpersonationHeaders(t *testing.T) {
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
emailish string
@@ -100,7 +105,7 @@ func TestImpersonationHeaders(t *testing.T) {
},
CapMap: tc.capMap,
})
addImpersonationHeaders(r)
addImpersonationHeaders(r, zl.Sugar())
if d := cmp.Diff(tc.wantHeaders, r.Header); d != "" {
t.Errorf("unexpected header (-want +got):\n%s", d)

View File

@@ -21,6 +21,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apiserver/pkg/storage/names"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
"tailscale.com/client/tailscale"
@@ -176,10 +177,39 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.Sugare
return true, nil
}
// maxStatefulSetNameLength is maximum length the StatefulSet name can
// have to NOT result in a too long value for controller-revision-hash
// label value (see https://github.com/kubernetes/kubernetes/issues/64023).
// controller-revision-hash label value consists of StatefulSet's name + hyphen + revision hash.
// Maximum label value length is 63 chars. Length of revision hash is 10 chars.
// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/pkg/controller/history/controller_history.go#L90-L104
const maxStatefulSetNameLength = 63 - 10 - 1
// statefulSetNameBase accepts name of parent resource and returns a string in
// form ts-<portion-of-parentname>- that, when passed to Kubernetes name
// generation will NOT result in a StatefulSet name longer than 52 chars.
// This is done because of https://github.com/kubernetes/kubernetes/issues/64023.
func statefulSetNameBase(parent string) string {
base := fmt.Sprintf("ts-%s-", parent)
// Calculate what length name GenerateName returns for this base.
generator := names.SimpleNameGenerator
generatedName := generator.GenerateName(base)
if excess := len(generatedName) - maxStatefulSetNameLength; excess > 0 {
base = base[:len(base)-excess-1] // take extra char off to make space for hyphen
base = base + "-" // re-instate hyphen
}
return base
}
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
nameBase := statefulSetNameBase(sts.ParentResourceName)
hsvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "ts-" + sts.ParentResourceName + "-",
GenerateName: nameBase,
Namespace: a.operatorNamespace,
Labels: sts.ChildResourceLabels,
},

View File

@@ -0,0 +1,50 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"testing"
)
// Test_statefulSetNameBase tests that parent name portion in a StatefulSet name
// base will be truncated if the parent name is longer than 43 chars to ensure
// that the total does not exceed 52 chars.
// How many chars need to be cut off parent name depends on an internal var in
// kube name generation code that can change at which point this test will break
// and need to be changed. This is okay as we do not rely on that value in
// code whilst being aware when it changes might still be useful.
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/staging/src/k8s.io/apiserver/pkg/storage/names/generate.go#L45.
// https://github.com/kubernetes/kubernetes/pull/116430
func Test_statefulSetNameBase(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{
name: "43 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "44 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xbo",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "42 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := statefulSetNameBase(tt.in); got != tt.out {
t.Errorf("stsNamePrefix(%s) = %q, want %s", tt.in, got, tt.out)
}
})
}
}

View File

@@ -189,6 +189,17 @@ var debugCmd = &ffcli.Command{
fs.BoolVar(&watchIPNArgs.netmap, "netmap", true, "include netmap in messages")
fs.BoolVar(&watchIPNArgs.initial, "initial", false, "include initial status")
fs.BoolVar(&watchIPNArgs.showPrivateKey, "show-private-key", false, "include node private key in printed netmap")
fs.IntVar(&watchIPNArgs.count, "count", 0, "exit after printing this many statuses, or 0 to keep going forever")
return fs
})(),
},
{
Name: "netmap",
Exec: runNetmap,
ShortHelp: "print the current network map",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("netmap")
fs.BoolVar(&netmapArgs.showPrivateKey, "show-private-key", false, "include node private key in printed netmap")
return fs
})(),
},
@@ -416,6 +427,7 @@ var watchIPNArgs struct {
netmap bool
initial bool
showPrivateKey bool
count int
}
func runWatchIPN(ctx context.Context, args []string) error {
@@ -431,8 +443,8 @@ func runWatchIPN(ctx context.Context, args []string) error {
return err
}
defer watcher.Close()
printf("Connected.\n")
for {
fmt.Fprintf(os.Stderr, "Connected.\n")
for seen := 0; watchIPNArgs.count == 0 || seen < watchIPNArgs.count; seen++ {
n, err := watcher.Next()
if err != nil {
return err
@@ -441,8 +453,36 @@ func runWatchIPN(ctx context.Context, args []string) error {
n.NetMap = nil
}
j, _ := json.MarshalIndent(n, "", "\t")
printf("%s\n", j)
fmt.Printf("%s\n", j)
}
return nil
}
var netmapArgs struct {
showPrivateKey bool
}
func runNetmap(ctx context.Context, args []string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var mask ipn.NotifyWatchOpt = ipn.NotifyInitialNetMap
if !netmapArgs.showPrivateKey {
mask |= ipn.NotifyNoPrivateKeys
}
watcher, err := localClient.WatchIPNBus(ctx, mask)
if err != nil {
return err
}
defer watcher.Close()
n, err := watcher.Next()
if err != nil {
return err
}
j, _ := json.MarshalIndent(n.NetMap, "", "\t")
fmt.Printf("%s\n", j)
return nil
}
func runDERPMap(ctx context.Context, args []string) error {

View File

@@ -8,6 +8,8 @@ import (
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
@@ -25,10 +27,14 @@ var switchCmd = &ffcli.Command{
Exec: switchProfile,
UsageFunc: func(*ffcli.Command) string {
return `USAGE
switch <name>
switch <id>
switch --list
"tailscale switch" switches between logged in accounts.
"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
can use the Tailnet or the account names to switch as well.
This command is currently in alpha and may change in the future.`
},
}
@@ -42,12 +48,22 @@ func listProfiles(ctx context.Context) error {
if err != nil {
return err
}
tw := tabwriter.NewWriter(os.Stdout, 2, 2, 2, ' ', 0)
defer tw.Flush()
printRow := func(vals ...string) {
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
printRow("ID", "Tailnet", "Account")
for _, prof := range all {
name := prof.Name
if prof.ID == curP.ID {
fmt.Printf("%s *\n", prof.Name)
} else {
fmt.Println(prof.Name)
name += "*"
}
printRow(
string(prof.ID),
prof.NetworkProfile.DomainName,
name,
)
}
return nil
}
@@ -66,12 +82,30 @@ func switchProfile(ctx context.Context, args []string) error {
os.Exit(1)
}
var profID ipn.ProfileID
// Allow matching by ID, Tailnet, or Account
// in that order.
for _, p := range all {
if p.Name == args[0] {
if p.ID == ipn.ProfileID(args[0]) {
profID = p.ID
break
}
}
if profID == "" {
for _, p := range all {
if p.NetworkProfile.DomainName == args[0] {
profID = p.ID
break
}
}
}
if profID == "" {
for _, p := range all {
if p.Name == args[0] {
profID = p.ID
break
}
}
}
if profID == "" {
errf("No profile named %q\n", args[0])
os.Exit(1)

View File

@@ -22,12 +22,20 @@ import (
"sort"
"strings"
"time"
"unicode"
"github.com/dave/courtney/scanner"
"github.com/dave/courtney/shared"
"github.com/dave/courtney/tester"
"github.com/dave/patsy"
"github.com/dave/patsy/vos"
xmaps "golang.org/x/exp/maps"
"tailscale.com/cmd/testwrapper/flakytest"
)
const maxAttempts = 3
const (
maxAttempts = 3
)
type testAttempt struct {
pkg string // "tailscale.com/types/key"
@@ -223,6 +231,30 @@ func main() {
fmt.Printf("%s\t%s\n", outcome, pkg)
}
// Check for -coverprofile argument and filter it out
combinedCoverageFilename := ""
filteredGoTestArgs := make([]string, 0, len(goTestArgs))
preceededByCoverProfile := false
for _, arg := range goTestArgs {
if arg == "-coverprofile" {
preceededByCoverProfile = true
} else if preceededByCoverProfile {
combinedCoverageFilename = strings.TrimSpace(arg)
preceededByCoverProfile = false
} else {
filteredGoTestArgs = append(filteredGoTestArgs, arg)
}
}
goTestArgs = filteredGoTestArgs
runningWithCoverage := combinedCoverageFilename != ""
if runningWithCoverage {
fmt.Printf("Will log coverage to %v\n", combinedCoverageFilename)
}
// Keep track of all test coverage files. With each retry, we'll end up
// with additional coverage files that will be combined when we finish.
coverageFiles := make([]string, 0)
for len(toRun) > 0 {
var thisRun *nextRun
thisRun, toRun = toRun[0], toRun[1:]
@@ -236,13 +268,26 @@ func main() {
fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\nflakytest failures JSON: %s\n\n", thisRun.attempt, j)
}
goTestArgsWithCoverage := testArgs
if runningWithCoverage {
coverageFile := fmt.Sprintf("/tmp/coverage_%d.out", thisRun.attempt)
coverageFiles = append(coverageFiles, coverageFile)
goTestArgsWithCoverage = make([]string, len(goTestArgs), len(goTestArgs)+2)
copy(goTestArgsWithCoverage, goTestArgs)
goTestArgsWithCoverage = append(
goTestArgsWithCoverage,
fmt.Sprintf("-coverprofile=%v", coverageFile),
"-covermode=set",
)
}
toRetry := make(map[string][]*testAttempt) // pkg -> tests to retry
for _, pt := range thisRun.tests {
ch := make(chan *testAttempt)
runErr := make(chan error, 1)
go func() {
defer close(runErr)
runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgs, testArgs, ch)
runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgsWithCoverage, testArgs, ch)
}()
var failed bool
@@ -319,4 +364,107 @@ func main() {
}
toRun = append(toRun, nextRun)
}
if runningWithCoverage {
intermediateCoverageFilename := "/tmp/coverage.out_intermediate"
if err := combineCoverageFiles(intermediateCoverageFilename, coverageFiles); err != nil {
fmt.Printf("error combining coverage files: %v\n", err)
os.Exit(2)
}
if err := processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename, testArgs); err != nil {
fmt.Printf("error processing coverage with courtney: %v\n", err)
os.Exit(3)
}
fmt.Printf("Wrote combined coverage to %v\n", combinedCoverageFilename)
}
}
func combineCoverageFiles(intermediateCoverageFilename string, coverageFiles []string) error {
combinedCoverageFile, err := os.OpenFile(intermediateCoverageFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("create /tmp/coverage.out: %w", err)
}
defer combinedCoverageFile.Close()
w := bufio.NewWriter(combinedCoverageFile)
defer w.Flush()
for fileNumber, coverageFile := range coverageFiles {
f, err := os.Open(coverageFile)
if err != nil {
return fmt.Errorf("open %v: %w", coverageFile, err)
}
defer f.Close()
in := bufio.NewReader(f)
line := 0
for {
r, _, err := in.ReadRune()
if err != nil {
if err != io.EOF {
return fmt.Errorf("read %v: %w", coverageFile, err)
}
break
}
// On all but the first coverage file, skip the coverage file header
if fileNumber > 0 && line == 0 {
continue
}
if r == '\n' {
line++
}
// filter for only printable characters because coverage file sometimes includes junk on 2nd line
if unicode.IsPrint(r) || r == '\n' {
if _, err := w.WriteRune(r); err != nil {
return fmt.Errorf("write %v: %w", combinedCoverageFile.Name(), err)
}
}
}
}
return nil
}
// processCoverageWithCourtney post-processes code coverage to exclude less
// meaningful sections like 'if err != nil { return err}', as well as
// anything marked with a '// notest' comment.
//
// instead of running the courtney as a separate program, this embeds
// courtney for easier integration.
func processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename string, testArgs []string) error {
env := vos.Os()
setup := &shared.Setup{
Env: vos.Os(),
Paths: patsy.NewCache(env),
TestArgs: testArgs,
Load: intermediateCoverageFilename,
Output: combinedCoverageFilename,
}
if err := setup.Parse(testArgs); err != nil {
return fmt.Errorf("parse args: %w", err)
}
s := scanner.New(setup)
if err := s.LoadProgram(); err != nil {
return fmt.Errorf("load program: %w", err)
}
if err := s.ScanPackages(); err != nil {
return fmt.Errorf("scan packages: %w", err)
}
t := tester.New(setup)
if err := t.Load(); err != nil {
return fmt.Errorf("load: %w", err)
}
if err := t.ProcessExcludes(s.Excludes); err != nil {
return fmt.Errorf("process excludes: %w", err)
}
if err := t.Save(); err != nil {
return fmt.Errorf("save: %w", err)
}
return nil
}

View File

@@ -44,19 +44,19 @@
# tailscaleRev is the git commit at which this flake was imported,
# or the empty string when building from a local checkout of the
# tailscale repo.
tailscaleRev = if builtins.hasAttr "rev" self then self.rev else "";
tailscaleRev = self.rev or "";
# tailscale takes a nixpkgs package set, and builds Tailscale from
# the same commit as this flake. IOW, it provides "tailscale built
# from HEAD", where HEAD is "whatever commit you imported the
# flake at".
#
# This is currently unfortunately brittle, because we have to
# specify vendorSha256, and that sha changes any time we alter
# specify vendorHash, and that sha changes any time we alter
# go.mod. We don't want to force a nix dependency on everyone
# hacking on Tailscale, so this flake is likely to have broken
# builds periodically until someone comes through and manually
# fixes them up. I sure wish there was a way to express "please
# just trust the local go.mod, vendorSha256 has no benefit here",
# just trust the local go.mod, vendorHash has no benefit here",
# but alas.
#
# So really, this flake is for tailscale devs to dogfood with, if
@@ -66,9 +66,9 @@
name = "tailscale";
src = ./.;
vendorSha256 = pkgs.lib.fileContents ./go.mod.sri;
vendorHash = pkgs.lib.fileContents ./go.mod.sri;
nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.makeWrapper ];
ldflags = ["-X tailscale.com/version.GitCommit=${tailscaleRev}"];
ldflags = ["-X tailscale.com/version.gitCommitStamp=${tailscaleRev}"];
CGO_ENABLED = 0;
subPackages = [ "cmd/tailscale" "cmd/tailscaled" ];
doCheck = false;
@@ -120,4 +120,4 @@
in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
}
# nix-direnv cache busting line: sha256-rb58ayy6g6JYDTNLaNzaM98njzEzDZEVX/BrSZaRm9A=
# nix-direnv cache busting line: sha256-Y7Z72ZwTcsdeI8DTqc6kDBlYNvQjNsRgD4D3fTsBoiQ=

25
go.mod
View File

@@ -17,7 +17,9 @@ require (
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/coreos/go-systemd/v22 v22.5.0
github.com/creack/pty v1.1.18
github.com/dave/courtney v0.4.0
github.com/dave/jennifer v1.7.0
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba
github.com/dblohm7/wingoes v0.0.0-20230929194252-e994401fc077
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e
github.com/dsnet/try v0.0.3
@@ -66,8 +68,8 @@ require (
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
github.com/tailscale/web-client-prebuilt v0.0.0-20231116165222-f819c0d50978
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90
github.com/tailscale/web-client-prebuilt v0.0.0-20231118024040-94653e885f8e
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272
github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0
@@ -77,16 +79,16 @@ require (
go.uber.org/zap v1.26.0
go4.org/mem v0.0.0-20220726221520-4f986261bf13
go4.org/netipx v0.0.0-20230824141953-6213f710f925
golang.org/x/crypto v0.14.0
golang.org/x/crypto v0.15.0
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
golang.org/x/mod v0.12.0
golang.org/x/net v0.17.0
golang.org/x/mod v0.14.0
golang.org/x/net v0.18.0
golang.org/x/oauth2 v0.12.0
golang.org/x/sync v0.3.0
golang.org/x/sys v0.13.0
golang.org/x/term v0.13.0
golang.org/x/sync v0.5.0
golang.org/x/sys v0.14.0
golang.org/x/term v0.14.0
golang.org/x/time v0.3.0
golang.org/x/tools v0.13.0
golang.org/x/tools v0.15.0
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
golang.zx2c4.com/wireguard/windows v0.5.3
gopkg.in/square/go-jose.v2 v2.6.0
@@ -97,6 +99,7 @@ require (
inet.af/wf v0.0.0-20221017222439-36129f591884
k8s.io/api v0.28.2
k8s.io/apimachinery v0.28.2
k8s.io/apiserver v0.28.2
k8s.io/client-go v0.28.2
nhooyr.io/websocket v1.8.7
sigs.k8s.io/controller-runtime v0.16.2
@@ -106,6 +109,8 @@ require (
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 // indirect
github.com/dave/brenda v1.1.0 // indirect
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
)
@@ -345,7 +350,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/image v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/text v0.14.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect

View File

@@ -1 +1 @@
sha256-rb58ayy6g6JYDTNLaNzaM98njzEzDZEVX/BrSZaRm9A=
sha256-Y7Z72ZwTcsdeI8DTqc6kDBlYNvQjNsRgD4D3fTsBoiQ=

49
go.sum
View File

@@ -224,8 +224,16 @@ github.com/curioswitch/go-reassign v0.2.0 h1:G9UZyOcpk/d7Gd6mqYgd8XYWFMw/znxwGDU
github.com/curioswitch/go-reassign v0.2.0/go.mod h1:x6OpXuWvgfQaMGks2BZybTngWjT84hqJfKoO8Tt/Roc=
github.com/daixiang0/gci v0.10.1 h1:eheNA3ljF6SxnPD/vE4lCBusVHmV3Rs3dkKvFrJ7MR0=
github.com/daixiang0/gci v0.10.1/go.mod h1:xtHP9N7AHdNvtRNfcx9gwTDfw7FRJx4bZUsiEfiNNAI=
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 h1:YI1gOOdmMk3xodBao7fehcvoZsEeOyy/cfhlpCSPgM4=
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14/go.mod h1:Sth2QfxfATb/nW4EsrSi2KyJmbcniZ8TgTaji17D6ms=
github.com/dave/brenda v1.1.0 h1:Sl1LlwXnbw7xMhq3y2x11McFu43AjDcwkllxxgZ3EZw=
github.com/dave/brenda v1.1.0/go.mod h1:4wCUr6gSlu5/1Tk7akE5X7UorwiQ8Rij0SKH3/BGMOM=
github.com/dave/courtney v0.4.0 h1:Vb8hi+k3O0h5++BR96FIcX0x3NovRbnhGd/dRr8inBk=
github.com/dave/courtney v0.4.0/go.mod h1:3WSU3yaloZXYAxRuWt8oRyVb9SaRiMBt5Kz/2J227tM=
github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE=
github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba h1:1o36L4EKbZzazMk8iGC4kXpVnZ6TPxR2mZ9qVKjNNAs=
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba/go.mod h1:qfR88CgEGLoiqDaE+xxDCi5QA5v4vUoW0UCX2Nd5Tlc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -884,10 +892,10 @@ github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89 h1:7xU7AFQE83h0wz/
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89/go.mod h1:OGMqrTzDqmJkGumUTtOv44Rp3/4xS+QFbE8Rn0AGlaU=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk=
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/web-client-prebuilt v0.0.0-20231116165222-f819c0d50978 h1:qcDQ02doxKZ2/sUGDM0idC21mKP3G1TEC5IqlsECzv0=
github.com/tailscale/web-client-prebuilt v0.0.0-20231116165222-f819c0d50978/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90 h1:lMGYrokOq9NKDw1UMBH7AsS4boZ41jcduvYaRIdedhE=
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/web-client-prebuilt v0.0.0-20231118024040-94653e885f8e h1:t9hXwYOoKTiMSxgHZC8iX63iJM+mVfxbxSNBYUxItKg=
github.com/tailscale/web-client-prebuilt v0.0.0-20231118024040-94653e885f8e/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 h1:zwsem4CaamMdC3tFoTpzrsUSMDPV0K6rhnQdF7kXekQ=
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
@@ -990,8 +998,8 @@ golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1040,8 +1048,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1086,8 +1094,8 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1113,8 +1121,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1183,8 +1191,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1193,8 +1201,8 @@ golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8=
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1209,8 +1217,9 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1289,8 +1298,8 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8=
golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1449,6 +1458,8 @@ k8s.io/apiextensions-apiserver v0.28.2 h1:J6/QRWIKV2/HwBhHRVITMLYoypCoPY1ftigDM0
k8s.io/apiextensions-apiserver v0.28.2/go.mod h1:5tnkxLGa9nefefYzWuAlWZ7RZYuN/765Au8cWLA6SRg=
k8s.io/apimachinery v0.28.2 h1:KCOJLrc6gu+wV1BYgwik4AF4vXOlVJPdiqn0yAWWwXQ=
k8s.io/apimachinery v0.28.2/go.mod h1:RdzF87y/ngqk9H4z3EL2Rppv5jj95vGS/HaFXrLDApU=
k8s.io/apiserver v0.28.2 h1:rBeYkLvF94Nku9XfXyUIirsVzCzJBs6jMn3NWeHieyI=
k8s.io/apiserver v0.28.2/go.mod h1:f7D5e8wH8MWcKD7azq6Csw9UN+CjdtXIVQUyUhrtb+E=
k8s.io/client-go v0.28.2 h1:DNoYI1vGq0slMBN/SWKMZMw0Rq+0EQW6/AK4v9+3VeY=
k8s.io/client-go v0.28.2/go.mod h1:sMkApowspLuc7omj1FOSUxSoqjr+d5Q0Yc0LOFnYFJY=
k8s.io/component-base v0.28.2 h1:Yc1yU+6AQSlpJZyvehm/NkJBII72rzlEsd6MkBQ+G0E=

View File

@@ -341,7 +341,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
return nil, err
}
p.ApplyEdits(&mp)
if err := pm.SetPrefs(p.View(), ""); err != nil {
if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
return nil, err
}
}
@@ -1105,10 +1105,19 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
prefsChanged = true
}
// Until recently, we did not store the account's tailnet name. So check if this is the case,
// and backfill it on incoming status update.
if b.pm.requiresBackfill() && st.NetMap != nil && st.NetMap.Domain != "" {
prefsChanged = true
}
// Perform all mutations of prefs based on the netmap here.
if prefsChanged {
// Prefs will be written out if stale; this is not safe unless locked or cloned.
if err := b.pm.SetPrefs(prefs.View(), st.NetMap.MagicDNSSuffix()); err != nil {
if err := b.pm.SetPrefs(prefs.View(), ipn.NetworkProfile{
MagicDNSName: st.NetMap.MagicDNSSuffix(),
DomainName: st.NetMap.DomainName(),
}); err != nil {
b.logf("Failed to save new controlclient state: %v", err)
}
}
@@ -1164,7 +1173,10 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
b.mu.Lock()
prefs.WantRunning = false
p := prefs.View()
if err := b.pm.SetPrefs(p, st.NetMap.MagicDNSSuffix()); err != nil {
if err := b.pm.SetPrefs(p, ipn.NetworkProfile{
MagicDNSName: st.NetMap.MagicDNSSuffix(),
DomainName: st.NetMap.DomainName(),
}); err != nil {
b.logf("Failed to save new controlclient state: %v", err)
}
b.mu.Unlock()
@@ -1573,7 +1585,10 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
newPrefs := opts.UpdatePrefs.Clone()
newPrefs.Persist = oldPrefs.Persist().AsStruct()
pv := newPrefs.View()
if err := b.pm.SetPrefs(pv, b.netMap.MagicDNSSuffix()); err != nil {
if err := b.pm.SetPrefs(pv, ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
b.logf("failed to save UpdatePrefs state: %v", err)
}
b.setAtomicValuesFromPrefsLocked(pv)
@@ -2479,7 +2494,10 @@ func (b *LocalBackend) migrateStateLocked(prefs *ipn.Prefs) (err error) {
// Backend owns the state, but frontend is trying to migrate
// state into the backend.
b.logf("importing frontend prefs into backend store; frontend prefs: %s", prefs.Pretty())
if err := b.pm.SetPrefs(prefs.View(), b.netMap.MagicDNSSuffix()); err != nil {
if err := b.pm.SetPrefs(prefs.View(), ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
return fmt.Errorf("store.WriteState: %v", err)
}
}
@@ -3060,7 +3078,10 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
}
prefs := newp.View()
if err := b.pm.SetPrefs(prefs, b.netMap.MagicDNSSuffix()); err != nil {
if err := b.pm.SetPrefs(prefs, ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
b.logf("failed to save new controlclient state: %v", err)
}
b.lastProfileID = b.pm.CurrentProfile().ID
@@ -3292,16 +3313,18 @@ func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs i
return
}
// Geometric cost, assumes that the number of advertised tags is small
selfHasTag := func(attrTags []string) bool {
return nm.SelfNode.Tags().ContainsFunc(func(tag string) bool {
return slices.Contains(attrTags, tag)
})
}
var domains []string
for _, attr := range attrs {
// Geometric cost, assumes that the number of advertised tags is small
if !nm.SelfNode.Tags().ContainsFunc(func(tag string) bool {
return slices.Contains(attr.Connectors, tag)
}) {
continue
if slices.Contains(attr.Connectors, "*") || selfHasTag(attr.Connectors) {
domains = append(domains, attr.Domains...)
}
domains = append(domains, attr.Domains...)
}
slices.Sort(domains)
slices.Compact(domains)

View File

@@ -578,7 +578,10 @@ func (b *LocalBackend) NetworkLockForceLocalDisable() error {
newPrefs := b.pm.CurrentPrefs().AsStruct().Clone() // .Persist should always be initialized here.
newPrefs.Persist.DisallowedTKAStateIDs = append(newPrefs.Persist.DisallowedTKAStateIDs, stateID)
if err := b.pm.SetPrefs(newPrefs.View(), b.netMap.MagicDNSSuffix()); err != nil {
if err := b.pm.SetPrefs(newPrefs.View(), ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
return fmt.Errorf("saving prefs: %w", err)
}

View File

@@ -151,7 +151,7 @@ func TestTKAEnablementFlow(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
b := LocalBackend{
capTailnetLock: true,
varRoot: temp,
@@ -191,7 +191,7 @@ func TestTKADisablementFlow(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
@@ -383,7 +383,7 @@ func TestTKASync(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
// Setup the tka authority on the control plane.
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
@@ -605,7 +605,7 @@ func TestTKADisable(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
@@ -696,7 +696,7 @@ func TestTKASign(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
@@ -785,7 +785,7 @@ func TestTKAForceDisable(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
@@ -880,7 +880,7 @@ func TestTKAAffectedSigs(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
@@ -1013,7 +1013,7 @@ func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
@@ -1104,7 +1104,7 @@ func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
PrivateNodeKey: nodePriv,
NetworkLockKey: cosignPriv,
},
}).View(), ""))
}).View(), ipn.NetworkProfile{}))
b := LocalBackend{
varRoot: temp,
logf: t.Logf,

View File

@@ -657,7 +657,7 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
},
}).View(), "")
}).View(), ipn.NetworkProfile{})
if !h.ps.b.OfferingExitNode() {
t.Fatal("unexpectedly not offering exit node")
}

View File

@@ -207,11 +207,10 @@ func init() {
// It also saves the prefs to the StateStore. It stores a copy of the
// provided prefs, which may be accessed via CurrentPrefs.
//
// If tailnetMagicDNSName is provided non-empty, it will be used to
// enrich the profile with the tailnet's MagicDNS name. The MagicDNS
// name cannot be pulled from prefsIn directly because it is not saved
// on ipn.Prefs (since it's not a field that is configurable by nodes).
func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, tailnetMagicDNSName string) error {
// NetworkProfile stores additional information about the tailnet the user
// is logged into so that we can keep track of things like their domain name
// across user switches to disambiguate the same account but a different tailnet.
func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile) error {
prefs := prefsIn.AsStruct()
newPersist := prefs.Persist
if newPersist == nil || newPersist.NodeID == "" || newPersist.UserProfile.LoginName == "" {
@@ -255,9 +254,7 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, tailnetMagicDNSName st
cp.ControlURL = prefs.ControlURL
cp.UserProfile = newPersist.UserProfile
cp.NodeID = newPersist.NodeID
if tailnetMagicDNSName != "" {
cp.TailnetMagicDNSName = tailnetMagicDNSName
}
cp.NetworkProfile = np
pm.knownProfiles[cp.ID] = cp
pm.currentProfile = cp
if err := pm.writeKnownProfiles(); err != nil {
@@ -601,7 +598,7 @@ func (pm *profileManager) migrateFromLegacyPrefs() error {
return fmt.Errorf("load legacy prefs: %w", err)
}
pm.dlogf("loaded legacy preferences; sentinel=%q", sentinel)
if err := pm.SetPrefs(prefs, ""); err != nil {
if err := pm.SetPrefs(prefs, ipn.NetworkProfile{}); err != nil {
metricMigrationError.Add(1)
return fmt.Errorf("migrating _daemon profile: %w", err)
}
@@ -611,6 +608,12 @@ func (pm *profileManager) migrateFromLegacyPrefs() error {
return nil
}
func (pm *profileManager) requiresBackfill() bool {
return pm != nil &&
pm.currentProfile != nil &&
pm.currentProfile.NetworkProfile.RequiresBackfill()
}
var (
metricNewProfile = clientmetric.NewCounter("profiles_new")
metricSwitchProfile = clientmetric.NewCounter("profiles_switch")

View File

@@ -41,7 +41,7 @@ func TestProfileCurrentUserSwitch(t *testing.T) {
LoginName: loginName,
},
}
if err := pm.SetPrefs(p.View(), ""); err != nil {
if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
return p.View()
@@ -96,7 +96,7 @@ func TestProfileList(t *testing.T) {
LoginName: loginName,
},
}
if err := pm.SetPrefs(p.View(), ""); err != nil {
if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
return p.View()
@@ -157,7 +157,7 @@ func TestProfileDupe(t *testing.T) {
reauth := func(pm *profileManager, p *persist.Persist) {
prefs := ipn.NewPrefs()
prefs.Persist = p
must.Do(pm.SetPrefs(prefs.View(), ""))
must.Do(pm.SetPrefs(prefs.View(), ipn.NetworkProfile{}))
}
login := func(pm *profileManager, p *persist.Persist) {
pm.NewProfile()
@@ -379,7 +379,7 @@ func TestProfileManagement(t *testing.T) {
},
NodeID: nid,
}
if err := pm.SetPrefs(p.View(), ""); err != nil {
if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
return p.View()
@@ -506,7 +506,7 @@ func TestProfileManagementWindows(t *testing.T) {
},
NodeID: tailcfg.StableNodeID(strconv.Itoa(int(id))),
}
if err := pm.SetPrefs(p.View(), ""); err != nil {
if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
return p.View()

View File

@@ -923,7 +923,7 @@ func TestEditPrefsHasNoKeys(t *testing.T) {
LegacyFrontendPrivateMachineKey: key.NewMachine(),
},
}).View(), "")
}).View(), ipn.NetworkProfile{})
if p := b.pm.CurrentPrefs().Persist(); !p.Valid() || p.PrivateNodeKey().IsZero() {
t.Fatalf("PrivateNodeKey not set")
}

View File

@@ -790,6 +790,23 @@ type ProfileID string
// tests.
type WindowsUserID string
// NetworkProfile is a subset of netmap.NetworkMap
// that should be saved with each user profile.
type NetworkProfile struct {
MagicDNSName string
DomainName string
}
// RequiresBackfill returns whether this object does not have all the data
// expected. This is because this struct is a later addition to LoginProfile and
// this method can be checked to see if it's been backfilled to the current
// expectation or not. Note that for now, it just checks if the struct is empty.
// In the future, if we have new optional fields, this method can be changed to
// do more explicit checks to return whether it's apt for a backfill or not.
func (n NetworkProfile) RequiresBackfill() bool {
return n == NetworkProfile{}
}
// LoginProfile represents a single login profile as managed
// by the ProfileManager.
type LoginProfile struct {
@@ -804,13 +821,12 @@ type LoginProfile struct {
// It is filled in from the UserProfile.LoginName field.
Name string
// TailnetMagicDNSName is filled with the MagicDNS suffix for this
// profile's node (even if MagicDNS isn't necessarily in use).
// It will neither start nor end with a period.
// NetworkProfile is a subset of netmap.NetworkMap that we
// store to remember information about the tailnet that this
// profile was logged in with.
//
// TailnetMagicDNSName is only filled from 2023-09-09 forward,
// and will only get backfilled when a profile is the current profile.
TailnetMagicDNSName string
// This field was added on 2023-11-17.
NetworkProfile NetworkProfile
// Key is the StateKey under which the profile is stored.
// It is assigned once at profile creation time and never changes.

View File

@@ -69,7 +69,7 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [github.com/tailscale/hujson](https://pkg.go.dev/github.com/tailscale/hujson) ([BSD-3-Clause](https://github.com/tailscale/hujson/blob/20486734a56a/LICENSE))
- [github.com/tailscale/netlink](https://pkg.go.dev/github.com/tailscale/netlink) ([Apache-2.0](https://github.com/tailscale/netlink/blob/cabfb018fe85/LICENSE))
- [github.com/tailscale/tailscale-android](https://pkg.go.dev/github.com/tailscale/tailscale-android) ([BSD-3-Clause](https://github.com/tailscale/tailscale-android/blob/HEAD/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/777c9efc9f36/LICENSE))
- [github.com/tailscale/web-client-prebuilt](https://pkg.go.dev/github.com/tailscale/web-client-prebuilt) ([BSD-3-Clause](https://github.com/tailscale/web-client-prebuilt/blob/94653e885f8e/LICENSE))
- [github.com/tailscale/wireguard-go](https://pkg.go.dev/github.com/tailscale/wireguard-go) ([MIT](https://github.com/tailscale/wireguard-go/blob/db7604d1aa90/LICENSE))
- [github.com/tcnksm/go-httpstat](https://pkg.go.dev/github.com/tcnksm/go-httpstat) ([MIT](https://github.com/tcnksm/go-httpstat/blob/v0.2.0/LICENSE))
- [github.com/u-root/uio](https://pkg.go.dev/github.com/u-root/uio) ([BSD-3-Clause](https://github.com/u-root/uio/blob/3e8cd9d6bf63/LICENSE))

View File

@@ -228,7 +228,7 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// This bool is used in a couple of places below to implement this
// workaround.
isWindows := runtime.GOOS == "windows"
if cfg.singleResolverSet() != nil && m.os.SupportsSplitDNS() && !isWindows {
if len(cfg.singleResolverSet()) > 0 && m.os.SupportsSplitDNS() && !isWindows {
// Split DNS configuration requested, where all split domains
// go to the same resolvers. We can let the OS do it.
ocfg.Nameservers = toIPsOnly(cfg.singleResolverSet())

View File

@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/netip"
"os"
"reflect"
@@ -1048,6 +1049,8 @@ func (t *Wrapper) filterPacketInboundFromWireGuard(p *packet.Parsed, captHook ca
if t.PostFilterPacketInboundFromWireGaurd != nil {
if res := t.PostFilterPacketInboundFromWireGaurd(p, t); res.IsDrop() {
log.Printf("DDDDDDDDDDDDDDDDD %s transportlen=%d flags=%x", p.String(), len(p.Transport()), p.TCPFlags)
log.Printf("%s", packet.Hexdump(p.Transport()))
return res
}
}

View File

@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-rb58ayy6g6JYDTNLaNzaM98njzEzDZEVX/BrSZaRm9A=
# nix-direnv cache busting line: sha256-Y7Z72ZwTcsdeI8DTqc6kDBlYNvQjNsRgD4D3fTsBoiQ=

View File

@@ -29,7 +29,10 @@ func Watch(ctx context.Context, mu sync.Locker, tick, max time.Duration) chan ti
// Drop values written after c is closed.
return
}
c <- d
select {
case c <- d:
case <-ctx.Done():
}
}
closec := func() {
closemu.Lock()

View File

@@ -8,7 +8,7 @@
set -euo pipefail
if [[ "${CI:-}" == "true" ]]; then
if [[ "${CI:-}" == "true" && "${NOBASHDEBUG:-}" != "true" ]]; then
set -x
fi

View File

@@ -67,6 +67,7 @@ type AppConnectorAttr struct {
// Domains can be of the form: example.com, or *.example.com.
Domains []string `json:"domains,omitempty"`
// Connectors enumerates the app connectors which service these domains.
// These can be any target type supported by Tailscale's ACL language.
// These can either be "*" to match any advertising connector, or a
// tag of the form tag:<tag-name>.
Connectors []string `json:"connectors,omitempty"`
}

View File

@@ -177,6 +177,16 @@ func (nm *NetworkMap) MagicDNSSuffix() string {
return MagicDNSSuffixOfNodeName(nm.Name)
}
// DomainName returns the name of the NetworkMap's
// current tailnet. If the map is nil, it returns
// an empty string.
func (nm *NetworkMap) DomainName() string {
if nm == nil {
return ""
}
return nm.Domain
}
// SelfCapabilities returns SelfNode.Capabilities if nm and nm.SelfNode are
// non-nil. This is a method so we can use it in envknob/logknob without a
// circular dependency.

View File

@@ -12,12 +12,20 @@ import (
)
func TestUsedConsistently(t *testing.T) {
cmd := exec.Command("git", "grep", "-l", "-F", "http.Method")
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
cmd.Dir = filepath.Join(dir, "../..")
rootDir := filepath.Join(dir, "../..")
// If we don't have a .git directory, we're not in a git checkout (e.g.
// a downstream package); skip this test.
if _, err := os.Stat(filepath.Join(rootDir, ".git")); err != nil {
t.Skipf("skipping test since .git doesn't exist: %v", err)
}
cmd := exec.Command("git", "grep", "-l", "-F", "http.Method")
cmd.Dir = rootDir
matches, _ := cmd.Output()
for _, fn := range strings.Split(strings.TrimSpace(string(matches)), "\n") {
switch fn {

View File

@@ -5,6 +5,7 @@
package set
import (
"encoding/json"
"maps"
)
@@ -66,3 +67,16 @@ func (s Set[T]) Len() int { return len(s) }
func (s Set[T]) Equal(other Set[T]) bool {
return maps.Equal(s, other)
}
func (s Set[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(s.Slice())
}
func (s *Set[T]) UnmarshalJSON(buf []byte) error {
var ss []T
if err := json.Unmarshal(buf, &ss); err != nil {
return err
}
*s = SetOf(ss)
return nil
}

View File

@@ -4,6 +4,7 @@
package set
import (
"encoding/json"
"slices"
"testing"
)
@@ -112,3 +113,48 @@ func TestClone(t *testing.T) {
t.Error("clone is not distinct from original")
}
}
func TestSetJSONRoundTrip(t *testing.T) {
tests := []struct {
desc string
strings Set[string]
ints Set[int]
}{
{"empty", make(Set[string]), make(Set[int])},
{"nil", nil, nil},
{"one-item", SetOf([]string{"one"}), SetOf([]int{1})},
{"multiple-items", SetOf([]string{"one", "two", "three"}), SetOf([]int{1, 2, 3})},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
t.Run("strings", func(t *testing.T) {
buf, err := json.Marshal(tt.strings)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
t.Logf("marshaled: %s", buf)
var s Set[string]
if err := json.Unmarshal(buf, &s); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if !s.Equal(tt.strings) {
t.Errorf("set changed after JSON marshal/unmarshal, before: %v, after: %v", tt.strings, s)
}
})
t.Run("ints", func(t *testing.T) {
buf, err := json.Marshal(tt.ints)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
t.Logf("marshaled: %s", buf)
var s Set[int]
if err := json.Unmarshal(buf, &s); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if !s.Equal(tt.ints) {
t.Errorf("set changed after JSON marshal/unmarshal, before: %v, after: %v", tt.ints, s)
}
})
})
}
}

View File

@@ -1514,7 +1514,7 @@ func (c *Conn) handlePingLocked(dm *disco.Ping, src netip.AddrPort, di *discoInf
// mappings to make p2p path discovery faster in simple
// cases. Without this, disco would still work, but would be
// reliant on DERP call-me-maybe to establish the disco<>node
// mapping, and on subsequent disco handlePongLocked to establish
// mapping, and on subsequent disco handlePongConnLocked to establish
// the IP<>disco mapping.
if nk, ok := c.unambiguousNodeKeyOfPingLocked(dm, di.discoKey, derpNodeSrc); ok {
if !isDerp {

View File

@@ -184,6 +184,7 @@ func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine, mc *magi
if tcpipErr != nil {
return nil, fmt.Errorf("could not enable TCP SACK: %v", tcpipErr)
}
log.Printf("XXXXXXXXXXXXXXXX %d", tstun.DefaultTUNMTU())
linkEP := channel.New(512, uint32(tstun.DefaultTUNMTU()), "")
if tcpipProblem := ipstack.CreateNIC(nicID, linkEP); tcpipProblem != nil {
return nil, fmt.Errorf("could not create netstack NIC: %v", tcpipProblem)

View File

@@ -320,3 +320,5 @@ vimba
wahoo
zebra
coelacanth
gila
monster

View File

@@ -582,3 +582,34 @@ woodpecker
nuthatch
flicker
junco
anaconda
binturong
boa
brolga
cassowary
cockatoo
cormorant
curlew
dove
eagle
emu
kingfisher
falcon
gila
monster
goose
magpie
guineafowl
ibis
iguana
kite
lorikeet
macaw
monitor
parrot
pitta
python
rhinoceros
skink
stork
tortoise