Compare commits
29 Commits
bradfitz/c
...
danderson/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5e2cce4f | ||
|
|
18e7520c63 | ||
|
|
6b395f6385 | ||
|
|
9e63bf5fd6 | ||
|
|
611e0a5bcc | ||
|
|
1af7f5b549 | ||
|
|
5aa7687b21 | ||
|
|
afacf2e368 | ||
|
|
128d3ad1a9 | ||
|
|
e1d0d26686 | ||
|
|
a8647b3c37 | ||
|
|
17501ea31a | ||
|
|
66471710f8 | ||
|
|
2c1f14d9e6 | ||
|
|
dd8bc9ba03 | ||
|
|
4f80f403be | ||
|
|
2fa219440b | ||
|
|
f867392970 | ||
|
|
fd22145b52 | ||
|
|
c4855fe0ea | ||
|
|
b88929edf8 | ||
|
|
e7cad78b00 | ||
|
|
fc8488fac0 | ||
|
|
42dc843a87 | ||
|
|
f0613ab606 | ||
|
|
3402998c1c | ||
|
|
38ea8f8c9c | ||
|
|
2dc0645368 | ||
|
|
e75be017e4 |
8
.github/workflows/test.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
client/web/src/assets/fonts/Inter.var.latin.woff2
Normal file
|
Before Width: | Height: | Size: 324 B After Width: | Height: | Size: 324 B |
|
Before Width: | Height: | Size: 522 B After Width: | Height: | Size: 522 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 236 B After Width: | Height: | Size: 236 B |
|
Before Width: | Height: | Size: 203 B After Width: | Height: | Size: 203 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 738 B After Width: | Height: | Size: 738 B |
|
Before Width: | Height: | Size: 500 B After Width: | Height: | Size: 500 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 635 B After Width: | Height: | Size: 635 B |
|
Before Width: | Height: | Size: 506 B After Width: | Height: | Size: 506 B |
@@ -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">
|
||||
|
||||
@@ -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: "🇿🇼",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 →
|
||||
</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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
199
client/web/src/hooks/exit-nodes.ts
Normal 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…",
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
33
client/web/src/ui/collapsible.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
client/web/src/ui/input.tsx
Normal 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
|
||||
28
client/web/src/ui/search-input.tsx
Normal 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
|
||||
@@ -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"] &',
|
||||
])
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
50
cmd/k8s-operator/sts_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
12
flake.nix
@@ -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
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-rb58ayy6g6JYDTNLaNzaM98njzEzDZEVX/BrSZaRm9A=
|
||||
sha256-Y7Z72ZwTcsdeI8DTqc6kDBlYNvQjNsRgD4D3fTsBoiQ=
|
||||
|
||||
49
go.sum
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
28
ipn/prefs.go
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
) {
|
||||
src = ./.;
|
||||
}).shellNix
|
||||
# nix-direnv cache busting line: sha256-rb58ayy6g6JYDTNLaNzaM98njzEzDZEVX/BrSZaRm9A=
|
||||
# nix-direnv cache busting line: sha256-Y7Z72ZwTcsdeI8DTqc6kDBlYNvQjNsRgD4D3fTsBoiQ=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${CI:-}" == "true" ]]; then
|
||||
if [[ "${CI:-}" == "true" && "${NOBASHDEBUG:-}" != "true" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -320,3 +320,5 @@ vimba
|
||||
wahoo
|
||||
zebra
|
||||
coelacanth
|
||||
gila
|
||||
monster
|
||||
|
||||
@@ -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
|
||||
|
||||