feat(ssh): bump restty and follow upstream tailscale ssh

This commit is contained in:
Aarnav Tale
2026-08-27 14:59:47 -07:00
parent b9aa99c45e
commit 29afac60f6
31 changed files with 1653 additions and 1814 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
FROM --platform=$BUILDPLATFORM golang:1.26.6 AS go-base
WORKDIR /run
RUN apt-get update && apt-get install -y --no-install-recommends patch && rm -rf /var/lib/apt/lists/*
+54 -45
View File
@@ -3,8 +3,6 @@ import { Restty } from "restty";
import type { GhosttyTheme } from "restty";
import type { PtyTransport } from "restty/internal";
import type { HeadplaneSSH, TunnelSession } from "./wasm.client";
const FONT_BASE = `${__PREFIX__}/fonts`;
// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black.
@@ -18,36 +16,50 @@ const HEADPLANE_THEME: GhosttyTheme = {
raw: {},
};
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
let session: TunnelSession | null = null;
function createSSHTransport(
ipn: IPN,
ipAddress: string,
username: string,
onConnected: () => void,
): PtyTransport {
let session: IPNSSHSession | null = null;
let writeInput: ((data: string) => void) | null = null;
return {
connect(options) {
session = ssh.openTunnel({
ipAddress,
username,
onData: (data) => options.callbacks.onData?.(data),
onConnect: () => options.callbacks.onConnect?.(),
onDisconnect: () => {
session = ipn.ssh(ipAddress, username, {
writeFn: (data) => options.callbacks.onData?.(data),
writeErrorFn: (error) => options.callbacks.onData?.(error),
setReadFn: (readFn) => {
writeInput = readFn;
},
rows: options.rows ?? 24,
cols: options.cols ?? 80,
termType: "xterm-256color",
timeoutSeconds: 30,
onConnectionProgress: () => {},
onConnected: () => {
options.callbacks.onConnect?.();
onConnected();
},
onDone: () => {
options.callbacks.onDisconnect?.();
session = null;
writeInput = null;
},
});
if (options.cols && options.rows) {
session.resize(options.cols, options.rows);
}
},
disconnect() {
session?.close();
session = null;
},
sendInput(data) {
session?.writeInput(data);
writeInput?.(data);
return session != null;
},
// Restty passes cols first, the Tailscale session takes rows first.
resize(cols, rows) {
session?.resize(cols, rows);
session?.resize(rows, cols);
return session != null;
},
isConnected() {
@@ -61,66 +73,63 @@ function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: stri
}
interface GhosttyProps {
ssh: HeadplaneSSH;
ipn: IPN;
ipAddress: string;
username: string;
onConnected: () => void;
}
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
export default function Ghostty({ ipn, ipAddress, username, onConnected }: GhosttyProps) {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!divRef.current) return;
const transport = createSSHTransport(ssh, ipAddress, username);
const transport = createSSHTransport(ipn, ipAddress, username, onConnected);
const restty = new Restty({
root: divRef.current,
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
surface: {
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
},
},
appOptions: {
terminal: {
fontSize: 20,
ligatures: true,
fontPreset: "none",
fontSources: [
fonts: [
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
label: "JetBrains Mono Nerd Font",
name: "JetBrains Mono Nerd Font",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
label: "JetBrains Mono Nerd Font Bold",
name: "JetBrains Mono Nerd Font Bold",
weight: 700,
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
label: "JetBrains Mono Nerd Font Italic",
name: "JetBrains Mono Nerd Font Italic",
style: "italic",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
label: "JetBrains Mono Nerd Font Bold Italic",
name: "JetBrains Mono Nerd Font Bold Italic",
weight: 700,
style: "italic",
},
{
type: "url",
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
label: "Symbols Nerd Font",
name: "Symbols Nerd Font",
},
],
},
services: {
ptyTransport: transport,
callbacks: {
onPtyStatus: (status) => {
if (status === "connected") onConnected();
},
},
},
});
@@ -131,7 +140,7 @@ export default function Ghostty({ ssh, ipAddress, username, onConnected }: Ghost
return () => {
restty.destroy();
};
}, [ssh, ipAddress, username]);
}, [ipn, ipAddress, username, onConnected]);
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
}
+24 -37
View File
@@ -18,8 +18,7 @@ import type { Route } from "./+types/page";
import { isSSHError, SSHErrorBoundary, sshErrors } from "./errors";
import Ghostty from "./ghostty.client";
import UserPrompt from "./user-prompt";
import type { HeadplaneSSH } from "./wasm.client";
import { loadHeadplaneWASM } from "./wasm.client";
import { connectTailnet } from "./wasm.client";
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
@@ -217,48 +216,36 @@ function SSHConsole({
username: string;
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
}) {
const [ssh, setSsh] = useState<HeadplaneSSH | null>(null);
const [ipn, setIpn] = useState<IPN | null>(null);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState("Starting tunnel…");
const [status, setStatus] = useState("Joining Tailnet…");
useEffect(() => {
let cancelled = false;
console.log("[ssh] Loading WASM factory");
loadHeadplaneWASM().then((create) => {
console.log("[ssh] Factory loaded, creating IPN", create);
if (cancelled) {
return;
}
setStatus("Joining Tailnet…");
const instance = create({
controlURL: node.controlURL,
preAuthKey: node.preAuthKey,
hostname: node.ephemeralHostname,
onReady: () => {
console.log("[ssh] IPN ready (Running)");
if (!cancelled) {
setStatus(`Connecting to ${hostname}`);
setSsh(instance);
}
},
onError: (msg) => {
console.error("[ssh] IPN error:", msg);
if (!cancelled) {
setStatus(`Failed to join Tailnet: ${msg}`);
}
},
});
console.log("[ssh] IPN instance created", instance);
});
connectTailnet({
controlURL: node.controlURL,
authKey: node.preAuthKey,
hostname: node.ephemeralHostname,
onPanic: (error) => {
if (!cancelled) setStatus(`Tailnet node stopped: ${error}`);
},
}).then(
(instance) => {
if (cancelled) return;
setStatus(`Connecting to ${hostname}`);
setIpn(instance);
},
(error: unknown) => {
if (cancelled) return;
setStatus(`Failed to join Tailnet: ${error instanceof Error ? error.message : error}`);
},
);
return () => {
cancelled = true;
};
}, [node]);
}, [node, hostname]);
return (
<div className="fixed inset-0 flex flex-col bg-black">
@@ -271,9 +258,9 @@ function SSHConsole({
</div>
)}
{ssh && (
{ipn && (
<Ghostty
ssh={ssh}
ipn={ipn}
username={username}
ipAddress={node.ipAddress}
onConnected={() => setConnected(true)}
+41 -54
View File
@@ -1,56 +1,21 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined;
var Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
};
};
}
interface HeadplaneSSHConfig {
export interface TailnetConfig {
controlURL: string;
preAuthKey: string;
authKey: string;
hostname: string;
onReady: () => void;
onError?: (message: string) => void;
onPanic: (error: string) => void;
}
export interface HeadplaneSSH {
openTunnel(config: TunnelConfig): TunnelSession;
}
interface TunnelConfig {
ipAddress: string;
username: string;
timeout?: number;
onData: (data: string) => void;
onConnect: () => void;
onDisconnect: () => void;
}
export interface TunnelSession {
writeInput(data: string): void;
resize(cols: number, rows: number): void;
close(): void;
}
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
let goHelper: Promise<void> | null = null;
function loadGoHelper(): Promise<void> {
if (typeof globalThis.Go !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
goHelper ??= new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = WASM_HELPER_URL;
script.crossOrigin = "anonymous";
@@ -58,25 +23,47 @@ function loadGoHelper(): Promise<void> {
script.onerror = () => reject(new Error("Failed to load Go WASM helper"));
document.head.appendChild(script);
});
return goHelper;
}
/**
* One-shot function that loads the Go WASM binary and returns the SSH factory.
* Automatically loads the Go JS helper if it hasn't been loaded yet.
* Boots the Tailscale WASM node and resolves once it has joined the Tailnet.
* Rejects if the pre-auth key is refused or the Go runtime panics.
*/
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
if (!resolvedFactory) {
await loadGoHelper();
export async function connectTailnet(config: TailnetConfig): Promise<IPN> {
await loadGoHelper();
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
const go = new Go();
const module = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
globalThis.__hp_ssh_resolve = resolve;
// The Go process parks on a channel forever, so returning means it died.
go.run(module.instance).then(() => config.onPanic("Unexpected shutdown"));
const ipn = newIPN({
controlURL: config.controlURL,
authKey: config.authKey,
hostname: config.hostname,
});
let loginStarted = false;
return new Promise((resolve, reject) => {
ipn.run({
notifyState: (state) => {
if (state === "Running") resolve(ipn);
// The backend parks at NeedsLogin until login starts. With an auth key
// set this consumes it rather than opening an interactive flow.
if (state === "NeedsLogin" && !loginStarted) {
loginStarted = true;
ipn.login();
}
},
notifyNetMap: () => {},
// Only reached when the auth key was refused and the node wants a human.
notifyBrowseToURL: () => reject(new Error("Headscale rejected the pre-auth key")),
notifyPanicRecover: (error) => reject(new Error(error)),
});
go.run(result.instance);
}
return resolvedFactory;
});
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
/**
* @fileoverview Type definitions for types exported by the wasm_js.go Go
* module.
*
* Vendored from tailscale.com/cmd/tsconnect/src/types/wasm_js.d.ts; see
* cmd/hp_ssh/wasm_js.go for the upstream ref. Local changes live in
* patches/tsconnect-types.patch and are already applied here.
*/
declare global {
function newIPN(config: IPNConfig): IPN;
var Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
};
};
interface IPN {
run(callbacks: IPNCallbacks): void;
login(): void;
logout(): void;
ssh(
host: string,
username: string,
termConfig: {
writeFn: (data: string) => void;
writeErrorFn: (err: string) => void;
setReadFn: (readFn: (data: string) => void) => void;
rows: number;
cols: number;
/** Defaults to "xterm" */
termType?: string;
/** Defaults to 5 seconds */
timeoutSeconds?: number;
onConnectionProgress: (message: string) => void;
onConnected: () => void;
onDone: () => void;
},
): IPNSSHSession;
fetch(url: string): Promise<{
status: number;
statusText: string;
text: () => Promise<string>;
}>;
}
interface IPNSSHSession {
resize(rows: number, cols: number): boolean;
close(): boolean;
}
interface IPNStateStorage {
setState(id: string, value: string): void;
getState(id: string): string;
}
type IPNConfig = {
stateStorage?: IPNStateStorage;
authKey?: string;
controlURL?: string;
hostname?: string;
};
type IPNCallbacks = {
notifyState: (state: IPNState) => void;
notifyNetMap: (netMapStr: string) => void;
notifyBrowseToURL: (url: string) => void;
notifyPanicRecover: (err: string) => void;
};
type IPNNetMap = {
self: IPNNetMapSelfNode;
peers: IPNNetMapPeerNode[];
lockedOut: boolean;
};
type IPNNetMapNode = {
name: string;
addresses: string[];
machineKey: string;
nodeKey: string;
};
type IPNNetMapSelfNode = IPNNetMapNode & {
machineStatus: IPNMachineStatus;
};
type IPNNetMapPeerNode = IPNNetMapNode & {
online?: boolean;
tailscaleSSHEnabled: boolean;
};
/** Mirrors values from ipn/backend.go */
type IPNState =
| "NoState"
| "InUseOtherUser"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running";
/** Mirrors values from MachineStatus in tailcfg.go */
type IPNMachineStatus =
| "MachineUnknown"
| "MachineUnauthorized"
| "MachineAuthorized"
| "MachineInvalid";
}
export {};
+10 -10
View File
@@ -137,20 +137,20 @@ build_wasm() {
cat "$(go env GOROOT)/lib/wasm/wasm_exec.js" >> \
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
# Vendor dependencies and apply the DERP port patch.
# Tailscale's browser WebSocket and netcheck URL builders ignore
# DERPPort, which breaks WASM connections to non-443 DERP servers.
WASM_TAGS=$(cat "$ROOT_DIR/cmd/hp_ssh/build-tags.txt") || die "missing wasm build tags"
# Tailscale's netcheck still builds its browser DERP probe URL from the
# hostname alone, so a DERP server on a non-443 port never gets a home
# relay. Vendor the tree so we can patch it before building.
echo "==> Vendoring Go dependencies for WASM patch"
go mod vendor
DERP_PATCH="$ROOT_DIR/patches/tailscale-derp-port.patch"
if [ -f "$DERP_PATCH" ]; then
echo "==> Applying DERP port patch"
patch -d vendor/tailscale.com -p1 < "$DERP_PATCH" || \
die "failed to apply DERP port patch"
fi
echo "==> Applying netcheck DERP port patch"
patch -d vendor/tailscale.com -p1 < "$ROOT_DIR/patches/tailscale-netcheck-derp-port.patch" || \
die "failed to apply netcheck DERP port patch"
GOOS=js GOARCH=wasm go build -mod=vendor -o "$WASM_OUTPUT" ./cmd/hp_ssh
GOOS=js GOARCH=wasm go build -mod=vendor -tags "$WASM_TAGS" \
-trimpath -ldflags "-s -w" -o "$WASM_OUTPUT" ./cmd/hp_ssh
rm -rf vendor
}
+1
View File
@@ -0,0 +1 @@
netgo,omitidna,omitpemdecrypt,osusergo,ts_omit_ace,ts_omit_acme,ts_omit_advertiseexitnode,ts_omit_advertiseroutes,ts_omit_appconnectors,ts_omit_aws,ts_omit_bakedroots,ts_omit_bird,ts_omit_cachenetmap,ts_omit_captiveportal,ts_omit_capture,ts_omit_cliconndiag,ts_omit_clientmetrics,ts_omit_clientupdate,ts_omit_cloud,ts_omit_colorable,ts_omit_completion,ts_omit_completion_scripts,ts_omit_conn25,ts_omit_dbus,ts_omit_debug,ts_omit_debugeventbus,ts_omit_debugportmapper,ts_omit_desktop_sessions,ts_omit_doctor,ts_omit_drive,ts_omit_flashappliance,ts_omit_gro,ts_omit_hujsonconf,ts_omit_identityfederation,ts_omit_iptables,ts_omit_kube,ts_omit_linkspeed,ts_omit_linuxdnsfight,ts_omit_listenrawdisco,ts_omit_netlog,ts_omit_networkmanager,ts_omit_oauthkey,ts_omit_osrouter,ts_omit_outboundproxy,ts_omit_peerapiclient,ts_omit_peerapiserver,ts_omit_portlist,ts_omit_portmapper,ts_omit_posture,ts_omit_qrcodes,ts_omit_relayserver,ts_omit_remoteconfig,ts_omit_resolved,ts_omit_routecheck,ts_omit_runtimemetrics,ts_omit_sdnotify,ts_omit_serve,ts_omit_serviceclientprefs,ts_omit_ssh,ts_omit_synology,ts_omit_syslog,ts_omit_syspolicy,ts_omit_systray,ts_omit_taildrop,ts_omit_tailnetlock,ts_omit_tap,ts_omit_tpm,ts_omit_tundevstats,ts_omit_unixsocketidentity,ts_omit_useexitnode,ts_omit_useproxy,ts_omit_usermetrics,ts_omit_useroutes,ts_omit_wakeonlan,ts_omit_webbrowser,ts_omit_webclient
-94
View File
@@ -1,94 +0,0 @@
//go:build js && wasm
package main
import (
"context"
"log"
"syscall/js"
"github.com/tale/headplane/internal/hp_ipn"
)
func main() {
log.Printf("Loading WASM Headplane SSH module")
factory := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: create(config)")
return nil
}
config, err := hp_ipn.ParseIPNConfig(args[0])
if err != nil {
log.Printf("Error parsing config: %v", err)
return nil
}
callbacks := hp_ipn.ParseIPNCallbacks(args[0])
ipn, err := hp_ipn.NewTsWasmIpn(config, callbacks)
if err != nil {
callbacks.OnError(err.Error())
return nil
}
go func() {
if err := ipn.Start(context.Background()); err != nil {
callbacks.OnError(err.Error())
}
}()
return map[string]any{
"openTunnel": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: openTunnel(config)")
return nil
}
tunnelConfig, err := hp_ipn.ParseTunnelConfig(args[0])
if err != nil {
log.Printf("Error parsing tunnel config: %v", err)
return nil
}
session := ipn.NewSSHSession(tunnelConfig)
go session.ConnectAndRun()
return map[string]any{
"writeInput": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) == 1 {
session.WriteInput(args[0].String())
}
return nil
}),
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
return nil
}
session.Resize(args[0].Int(), args[1].Int())
return nil
}),
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
session.Close()
return nil
}),
}
}),
}
})
resolve := js.Global().Get("__hp_ssh_resolve")
if resolve.Type() != js.TypeFunction {
log.Printf("__hp_ssh_resolve is not set, cannot initialize")
return
}
resolve.Invoke(factory)
js.Global().Delete("__hp_ssh_resolve")
log.Printf("WASM Headplane SSH module loaded successfully")
<-make(chan bool)
}
+718
View File
@@ -0,0 +1,718 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Vendored from tailscale.com/cmd/tsconnect/wasm/wasm_js.go.
// Upstream ref: fb27d87e02c7358e44a063668902a183216b72ae
//
// Local changes live in patches/tsconnect-term-type.patch and are already
// applied here. Run scripts/sync-tsconnect.sh to move to a newer upstream.
// The wasm package builds a WebAssembly module that provides a subset of
// Tailscale APIs to JavaScript.
//
// When run in the browser, a newIPN(config) function is added to the global JS
// namespace. When called it returns an ipn object with the methods
// run(callbacks), login(), logout(), and ssh(...).
package main
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math/rand/v2"
"net"
"net/http"
"net/netip"
"strings"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/types/views"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/words"
)
// ControlURL defines the URL to be used for connection to Control.
var ControlURL = ipn.DefaultControlURL
func main() {
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal("Usage: newIPN(config)")
return nil
}
return newIPN(args[0])
}))
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets
// called.
<-make(chan bool)
}
func newIPN(jsConfig js.Value) map[string]any {
netns.SetEnabled(false)
var store ipn.StateStore
if jsStateStorage := jsConfig.Get("stateStorage"); !jsStateStorage.IsUndefined() {
store = &jsStateStore{jsStateStorage}
} else {
store = new(mem.Store)
}
controlURL := ControlURL
if jsControlURL := jsConfig.Get("controlURL"); jsControlURL.Type() == js.TypeString {
controlURL = jsControlURL.String()
}
var authKey string
if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString {
authKey = jsAuthKey.String()
}
var hostname string
if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString {
hostname = jsHostname.String()
} else {
hostname = generateHostname()
}
lpc := getOrCreateLogPolicyConfig(store)
c := logtail.Config{
Collection: lpc.Collection,
PrivateID: lpc.PrivateID,
// Compressed requests set HTTP headers that are not supported by the
// no-cors fetching mode:
CompressLogs: false,
HTTPC: &http.Client{Transport: &noCORSTransport{http.DefaultTransport}},
}
logtail := logtail.NewLogger(c, log.Printf)
logf := logtail.Logf
sys := tsd.NewSystem()
sys.Set(store)
dialer := &tsdial.Dialer{Logf: logf}
dialer.SetBus(sys.Bus.Get())
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
HealthTracker: sys.HealthTracker.Get(),
ExtraRootCAs: sys.ExtraRootCAs,
Metrics: sys.UserMetricsRegistry(),
EventBus: sys.Bus.Get(),
})
if err != nil {
log.Fatal(err)
}
sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
log.Fatalf("netstack.Create: %v", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = true
ns.ProcessSubnets = true
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
return true
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
// Note: don't just return ns.DialContextTCP or we'll return
// *gonet.TCPConn(nil) instead of a nil interface which trips up
// callers.
tcpConn, err := ns.DialContextTCP(ctx, dst)
if err != nil {
return nil, err
}
return tcpConn, nil
}
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
// Note: don't just return ns.DialContextUDP or we'll return
// *gonet.UDPConn(nil) instead of a nil interface which trips up
// callers.
udpConn, err := ns.DialContextUDP(ctx, dst)
if err != nil {
return nil, err
}
return udpConn, nil
}
sys.NetstackRouter.Set(true)
sys.Tun.Get().Start()
logid := lpc.PublicID
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil {
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
}
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
controlURL: controlURL,
authKey: authKey,
hostname: hostname,
}
return map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal(`Usage: run({
notifyState(state: int): void,
notifyNetMap(netMap: object): void,
notifyBrowseToURL(url: string): void,
notifyPanicRecover(err: string): void,
})`)
return nil
}
jsIPN.run(args[0])
return nil
}),
"login": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 0 {
log.Printf("Usage: login()")
return nil
}
jsIPN.login()
return nil
}),
"logout": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 0 {
log.Printf("Usage: logout()")
return nil
}
jsIPN.logout()
return nil
}),
"ssh": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: ssh(hostname, userName, termConfig)")
return nil
}
return jsIPN.ssh(
args[0].String(),
args[1].String(),
args[2])
}),
"fetch": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: fetch(url)")
return nil
}
url := args[0].String()
return jsIPN.fetch(url)
}),
}
}
type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
controlURL string
authKey string
hostname string
}
var jsIPNState = map[ipn.State]string{
ipn.NoState: "NoState",
ipn.InUseOtherUser: "InUseOtherUser",
ipn.NeedsLogin: "NeedsLogin",
ipn.NeedsMachineAuth: "NeedsMachineAuth",
ipn.Stopped: "Stopped",
ipn.Starting: "Starting",
ipn.Running: "Running",
}
var jsMachineStatus = map[tailcfg.MachineStatus]string{
tailcfg.MachineUnknown: "MachineUnknown",
tailcfg.MachineUnauthorized: "MachineUnauthorized",
tailcfg.MachineAuthorized: "MachineAuthorized",
tailcfg.MachineInvalid: "MachineInvalid",
}
func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState := func(state ipn.State) {
jsCallbacks.Call("notifyState", jsIPNState[state])
}
notifyState(ipn.NoState)
i.lb.SetNotifyCallback(func(n ipn.Notify) {
// Panics in the notify callback are likely due to be due to bugs in
// this bridging module (as opposed to actual bugs in Tailscale) and
// thus may be recoverable. Let the UI know, and allow the user to
// choose if they want to reload the page.
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic recovered:", r)
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
}
}()
log.Printf("NOTIFY: %+v", n)
if n.State != nil {
notifyState(*n.State)
}
if n.SelfChange != nil {
// Self changed: rebuild the JS-side NetMap snapshot. Peers
// don't ride on the bus anymore, so fetch them on demand
// from LocalBackend.
nm := i.lb.NetMapWithPeers()
if nm != nil {
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.SelfName(),
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
name := p.Name()
if name == "" {
// In practice this should only happen for Hello.
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for i, ap := range p.Addresses().All() {
addrs[i] = ap.Addr().String()
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
}
if n.BrowseToURL != nil {
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
}
})
go func() {
err := i.lb.Start(ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: i.controlURL,
RouteAll: false,
WantRunning: true,
Hostname: i.hostname,
},
AuthKey: i.authKey,
})
if err != nil {
log.Printf("Start error: %v", err)
}
}()
go func() {
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
err = i.srv.Run(context.Background(), ln)
log.Fatalf("ipnserver.Run exited: %v", err)
}()
}
func (i *jsIPN) login() {
go i.lb.StartLoginInteractive(context.Background())
}
func (i *jsIPN) logout() {
if i.lb.State() == ipn.NoState {
log.Printf("Backend not running")
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
i.lb.Logout(ctx, ipnauth.Self)
}()
}
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
jsSSHSession := &jsSSHSession{
jsIPN: i,
host: host,
username: username,
termConfig: termConfig,
}
go jsSSHSession.Run()
return map[string]any{
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsSSHSession.Close() != nil
}),
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
rows := args[0].Int()
cols := args[1].Int()
return jsSSHSession.Resize(rows, cols) != nil
}),
}
}
type jsSSHSession struct {
jsIPN *jsIPN
host string
username string
termConfig js.Value
session *ssh.Session
pendingResizeRows int
pendingResizeCols int
}
func (s *jsSSHSession) Run() {
writeFn := s.termConfig.Get("writeFn")
writeErrorFn := s.termConfig.Get("writeErrorFn")
setReadFn := s.termConfig.Get("setReadFn")
rows := s.termConfig.Get("rows").Int()
cols := s.termConfig.Get("cols").Int()
timeoutSeconds := 5.0
if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber {
timeoutSeconds = jsTimeoutSeconds.Float()
}
onConnectionProgress := s.termConfig.Get("onConnectionProgress")
onConnected := s.termConfig.Get("onConnected")
onDone := s.termConfig.Get("onDone")
defer onDone.Invoke()
writeError := func(label string, err error) {
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
}
reportProgress := func(message string) {
onConnectionProgress.Invoke(message)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second)))
defer cancel()
reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0]))
c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22"))
if err != nil {
writeError("Dial", err)
return
}
defer c.Close()
config := &ssh.ClientConfig{
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// Host keys are not used with Tailscale SSH, but we can use this
// callback to know that the connection has been established.
reportProgress("SSH connection established…")
return nil
},
User: s.username,
}
reportProgress("Starting SSH client…")
sshConn, _, _, err := ssh.NewClientConn(c, s.host, config)
if err != nil {
writeError("SSH Connection", err)
return
}
defer sshConn.Close()
sshClient := ssh.NewClient(sshConn, nil, nil)
defer sshClient.Close()
session, err := sshClient.NewSession()
if err != nil {
writeError("SSH Session", err)
return
}
s.session = session
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
writeError("SSH Stdin", err)
return
}
session.Stdout = termWriter{writeFn}
session.Stderr = termWriter{writeFn}
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
input := args[0].String()
_, err := stdin.Write([]byte(input))
if err != nil {
writeError("Write Input", err)
}
return nil
}))
// We might have gotten a resize notification since we started opening the
// session, pick up the latest size.
if s.pendingResizeRows != 0 {
rows = s.pendingResizeRows
}
if s.pendingResizeCols != 0 {
cols = s.pendingResizeCols
}
termType := "xterm"
if v := s.termConfig.Get("termType"); v.Type() == js.TypeString {
termType = v.String()
}
err = session.RequestPty(termType, rows, cols, ssh.TerminalModes{
ssh.ECHO: 1, ssh.ICANON: 1, ssh.ISIG: 1, ssh.ICRNL: 1, ssh.IUTF8: 1,
ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400,
})
if err != nil {
writeError("Pseudo Terminal", err)
return
}
err = session.Shell()
if err != nil {
writeError("Shell", err)
return
}
onConnected.Invoke()
err = session.Wait()
if err != nil {
writeError("Wait", err)
return
}
}
func (s *jsSSHSession) Close() error {
if s.session == nil {
// We never had a chance to open the session, ignore the close request.
return nil
}
return s.session.Close()
}
func (s *jsSSHSession) Resize(rows, cols int) error {
if s.session == nil {
s.pendingResizeRows = rows
s.pendingResizeCols = cols
return nil
}
return s.session.WindowChange(rows, cols)
}
func (i *jsIPN) fetch(url string) js.Value {
return makePromise(func() (any, error) {
c := &http.Client{
Transport: &http.Transport{
DialContext: i.dialer.UserDial,
},
}
res, err := c.Get(url)
if err != nil {
return nil, err
}
return map[string]any{
"status": res.StatusCode,
"statusText": res.Status,
"text": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
defer res.Body.Close()
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(res.Body); err != nil {
return nil, err
}
return buf.String(), nil
})
}),
// TODO: populate a more complete JS Response object
}, nil
})
}
type termWriter struct {
f js.Value
}
func (w termWriter) Write(p []byte) (n int, err error) {
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
w.f.Invoke(string(r))
return len(p), nil
}
type jsNetMap struct {
Self jsNetMapSelfNode `json:"self"`
Peers []jsNetMapPeerNode `json:"peers"`
LockedOut bool `json:"lockedOut"`
}
type jsNetMapNode struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
}
type jsNetMapSelfNode struct {
jsNetMapNode
MachineStatus string `json:"machineStatus"`
}
type jsNetMapPeerNode struct {
jsNetMapNode
Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
}
type jsStateStore struct {
jsStateStorage js.Value
}
func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) {
jsValue := s.jsStateStorage.Call("getState", string(id))
if jsValue.String() == "" {
return nil, ipn.ErrStateNotExist
}
return hex.DecodeString(jsValue.String())
}
func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs))
return nil
}
func mapSlice[T any, M any](a []T, f func(T) M) []M {
n := make([]M, len(a))
for i, e := range a {
n[i] = f(e)
}
return n
}
func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M {
n := make([]M, a.Len())
for i, v := range a.All() {
n[i] = f(v)
}
return n
}
func filterSlice[T any](a []T, f func(T) bool) []T {
n := make([]T, 0, len(a))
for _, e := range a {
if f(e) {
n = append(n, e)
}
}
return n
}
func generateHostname() string {
tails := words.Tails()
scales := words.Scales()
if rand.IntN(2) == 0 {
// JavaScript
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") })
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") })
} else {
// WebAssembly
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") })
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") })
}
tail := tails[rand.IntN(len(tails))]
scale := scales[rand.IntN(len(scales))]
return fmt.Sprintf("%s-%s", tail, scale)
}
// makePromise handles the boilerplate of wrapping goroutines with JS promises.
// f is run on a goroutine and its return value is used to resolve the promise
// (or reject it if an error is returned).
func makePromise(f func() (any, error)) js.Value {
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
resolve := args[0]
reject := args[1]
go func() {
if res, err := f(); err == nil {
resolve.Invoke(res)
} else {
reject.Invoke(err.Error())
}
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
const logPolicyStateKey = "log-policy"
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
if configBytes, err := state.ReadState(logPolicyStateKey); err == nil {
if config, err := logpolicy.ConfigFromBytes(configBytes); err == nil {
return config
} else {
log.Printf("Could not parse log policy config: %v", err)
}
} else if err != ipn.ErrStateNotExist {
log.Printf("Could not get log policy config from state store: %v", err)
}
config := logpolicy.NewConfig(logtail.CollectionNode)
if err := state.WriteState(logPolicyStateKey, config.ToBytes()); err != nil {
log.Printf("Could not save log policy config to state store: %v", err)
}
return config
}
// noCORSTransport wraps a RoundTripper and forces the no-cors mode on requests,
// so that we can use it with non-CORS-aware servers.
type noCORSTransport struct {
http.RoundTripper
}
func (t *noCORSTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("js.fetch:mode", "no-cors")
resp, err := t.RoundTripper.RoundTrip(req)
if err == nil {
// In no-cors mode no response properties are returned. Populate just
// the status so that callers do not think this was an error.
resp.StatusCode = http.StatusOK
resp.Status = http.StatusText(http.StatusOK)
}
return resp, err
}
+1 -1
View File
@@ -24,7 +24,7 @@ services:
- "./test/caddy/config:/config"
- "./test/caddy/certs:/certs"
headscale:
image: "headscale/headscale:0.29.0"
image: "headscale/headscale:0.29.2"
container_name: "headscale"
labels:
me.tale.headplane.target: headscale
+17 -6
View File
@@ -26,13 +26,19 @@ rec {
inherit system;
overlays = [ devshell.overlays.default ];
};
# nixpkgs' default go is 1.26.5; go.mod (via tailscale) needs >= 1.26.6.
buildGoModule = pkgs.buildGoModule.override {go = pkgs.go_1_27;};
in rec {
formatter = pkgs.alejandra;
packages = {
headplane = pkgs.callPackage ./nix/package.nix {headplane-ssh-wasm = packages.headplane-ssh-wasm;};
headplane-agent = pkgs.callPackage ./nix/agent.nix {};
headplane-agent = pkgs.callPackage ./nix/agent.nix {inherit buildGoModule;};
headplane-nixos-docs = pkgs.callPackage ./nix/docs.nix {};
headplane-ssh-wasm = pkgs.callPackage ./nix/ssh-wasm.nix {};
headplane-ssh-wasm = pkgs.callPackage ./nix/ssh-wasm.nix {
inherit buildGoModule;
go = pkgs.go_1_27;
};
};
checks.default = pkgs.symlinkJoin {
name = "headplane-with-agent";
@@ -53,7 +59,7 @@ rec {
${providedPackages}
'';
packages = [
pkgs.go
pkgs.go_1_27
pkgs.nodejs-slim_24
pkgs.pnpm_10
pkgs.typescript-language-server
@@ -64,11 +70,16 @@ rec {
};
})
// {
overlays.default = final: prev: {
overlays.default = final: prev: let
buildGoModule = final.buildGoModule.override {go = final.go_1_27;};
in {
headplane = final.callPackage ./nix/package.nix {headplane-ssh-wasm = final.headplane-ssh-wasm;};
headplane-agent = final.callPackage ./nix/agent.nix {};
headplane-agent = final.callPackage ./nix/agent.nix {inherit buildGoModule;};
headplane-nixos-docs = final.callPackage ./nix/docs.nix {};
headplane-ssh-wasm = final.callPackage ./nix/ssh-wasm.nix {};
headplane-ssh-wasm = final.callPackage ./nix/ssh-wasm.nix {
inherit buildGoModule;
go = final.go_1_27;
};
};
nixosModules.headplane = import ./nix/module.nix;
};
+29 -55
View File
@@ -1,79 +1,53 @@
module github.com/tale/headplane
go 1.25.1
go 1.26.6
require (
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/crypto v0.41.0
tailscale.com v1.88.2
golang.org/x/crypto v0.54.0
tailscale.com v1.102.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/aws/aws-sdk-go-v2 v1.36.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/creachadair/msync v0.8.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gaissmai/bart v0.18.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/illarion/gonotify/v3 v3.0.2 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.1 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/sdnotify v1.0.0 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/miekg/dns v1.1.58 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.35.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
)
+128 -126
View File
@@ -1,86 +1,95 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk=
github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 h1:IOdss+igJDFdic9w3WKwxGCmHqUxydvIhJOm9LJ32Dk=
github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/mds v0.25.13 h1:PsSUHV6zsfPd29k4kvm1rMoee1YFia7JyNGeMPmDcPM=
github.com/creachadair/mds v0.25.13/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM=
github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo=
github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8=
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
@@ -89,20 +98,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914 h1:kD8PseueGeYiid/Mmcv17Q0Qqicc4F46jcX22L/e/Hs=
github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE=
github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
@@ -127,34 +136,32 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s=
github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed h1:uyvHhX1FQada0vVk8CSHa4tJT96EEAkTypaYz8Tq5Nc=
github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
@@ -163,8 +170,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
@@ -173,7 +180,6 @@ github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -182,53 +188,49 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k=
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I=
honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.88.2 h1:S8S+gt/Vx4KDlVjNHk7spcyGihTcJflKMroSnwjp5kQ=
tailscale.com v1.88.2/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw=
tailscale.com v1.102.3 h1:M1czCAtMuIcg+2Z+FBPbJyAk3ZEQGEFKnvHthtE1c6M=
tailscale.com v1.102.3/go.mod h1:47bv91Xbg4K1p5wti7F1dmKvUVWV5BXF78d9EWJ+d6c=
-134
View File
@@ -1,134 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"context"
"fmt"
"log"
"net"
"net/netip"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
)
type TsWasmIpn struct {
options *IPNConfig
dialer *tsdial.Dialer
server *ipnserver.Server
backend *ipnlocal.LocalBackend
}
func NewTsWasmIpn(options *IPNConfig, callbacks *IPNCallbacks) (*TsWasmIpn, error) {
logf := log.Printf
netns.SetEnabled(false)
sys := tsd.NewSystem()
sys.Set(new(mem.Store))
dialer := &tsdial.Dialer{Logf: logf}
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(),
EventBus: sys.Bus.Get(),
})
if err != nil {
return nil, fmt.Errorf("failed to create userspace engine: %w", err)
}
sys.Set(engine)
tun := sys.Tun.Get()
msock := sys.MagicSock.Get()
dnsman := sys.DNSManager.Get()
proxymap := sys.ProxyMapper()
wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap)
if err != nil {
return nil, fmt.Errorf("failed to create netstack: %w", err)
}
sys.Set(wgstack)
wgstack.ProcessLocalIPs = true
wgstack.ProcessSubnets = true
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
return true
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextTCP(ctx, dst)
}
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextUDP(ctx, dst)
}
logID := logid.PublicID{}
sys.NetstackRouter.Set(true)
sys.Tun.Get().Start()
server := ipnserver.New(logf, logID, sys.NetMon.Get())
backend, err := ipnlocal.NewLocalBackend(logf, logID, sys, controlclient.LoginEphemeral)
if err != nil {
return nil, fmt.Errorf("failed to create local backend: %w", err)
}
if err := wgstack.Start(backend); err != nil {
return nil, fmt.Errorf("failed to start netstack: %w", err)
}
server.SetLocalBackend(backend)
registerNotifyCallback(callbacks, backend)
return &TsWasmIpn{
options: options,
dialer: dialer,
server: server,
backend: backend,
}, nil
}
func (t *TsWasmIpn) Start(ctx context.Context) error {
listener, err := safesocket.Listen("")
if err != nil {
return fmt.Errorf("failed to create safesocket listener: %w", err)
}
go func() {
if err := t.server.Run(ctx, listener); err != nil {
log.Printf("Tailscale server exited: %v", err)
}
}()
err = t.backend.Start(ipn.Options{
AuthKey: t.options.PreAuthKey,
UpdatePrefs: &ipn.Prefs{
ControlURL: t.options.ControlURL,
Hostname: t.options.Hostname,
WantRunning: true,
RunWebClient: false,
LoggedOut: false,
},
})
if err != nil {
return fmt.Errorf("failed to start Tailscale backend: %w", err)
}
return nil
}
-43
View File
@@ -1,43 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"syscall/js"
"tailscale.com/ipn"
)
type IPNCallbacks struct {
OnReady func()
OnError func(string)
}
func ParseIPNCallbacks(obj js.Value) *IPNCallbacks {
cb := &IPNCallbacks{
OnReady: func() {},
OnError: func(string) {},
}
onReady := obj.Get("onReady")
if onReady.Type() == js.TypeFunction {
cb.OnReady = func() { onReady.Invoke() }
}
onError := obj.Get("onError")
if onError.Type() == js.TypeFunction {
cb.OnError = func(msg string) { onError.Invoke(msg) }
}
return cb
}
var BackendState = map[ipn.State]string{
ipn.NoState: "NoState",
ipn.Stopped: "Stopped",
ipn.Starting: "Starting",
ipn.Running: "Running",
ipn.InUseOtherUser: "InUseOtherUser",
ipn.NeedsMachineAuth: "NeedsMachineAuth",
ipn.NeedsLogin: "NeedsLogin",
}
-118
View File
@@ -1,118 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"errors"
"syscall/js"
)
type IPNConfig struct {
ControlURL string
PreAuthKey string
Hostname string
}
func ParseIPNConfig(obj js.Value) (*IPNConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("config cannot be undefined or null")
}
controlURL := safeString("controlURL", obj)
preAuthKey := safeString("preAuthKey", obj)
hostname := safeString("hostname", obj)
if controlURL == "" || preAuthKey == "" || hostname == "" {
return nil, errors.New("missing required fields: controlURL, preAuthKey, hostname")
}
return &IPNConfig{
ControlURL: controlURL,
PreAuthKey: preAuthKey,
Hostname: hostname,
}, nil
}
type TunnelConfig struct {
IPAddress string
Username string
Timeout int
OnData func(data string)
OnConnect func()
OnDisconnect func()
}
func ParseTunnelConfig(obj js.Value) (*TunnelConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("tunnel config cannot be undefined or null")
}
ipAddress := safeString("ipAddress", obj)
username := safeString("username", obj)
if ipAddress == "" || username == "" {
return nil, errors.New("missing required fields: ipAddress, username")
}
timeout := safeInt("timeout", obj)
if timeout <= 0 {
timeout = 30
}
config := &TunnelConfig{
IPAddress: ipAddress,
Username: username,
Timeout: timeout,
}
onData := obj.Get("onData")
if onData.IsUndefined() || onData.IsNull() || onData.Type() != js.TypeFunction {
return nil, errors.New("`onData` is required and must be a function")
}
config.OnData = func(data string) {
onData.Invoke(data)
}
onConnect := obj.Get("onConnect")
if onConnect.IsUndefined() || onConnect.IsNull() || onConnect.Type() != js.TypeFunction {
return nil, errors.New("`onConnect` is required and must be a function")
}
config.OnConnect = func() {
onConnect.Invoke()
}
onDisconnect := obj.Get("onDisconnect")
if onDisconnect.IsUndefined() || onDisconnect.IsNull() || onDisconnect.Type() != js.TypeFunction {
return nil, errors.New("`onDisconnect` is required and must be a function")
}
config.OnDisconnect = func() {
onDisconnect.Invoke()
}
return config, nil
}
func safeString(key string, obj js.Value) string {
if obj.IsUndefined() || obj.IsNull() {
return ""
}
val := obj.Get(key)
if val.IsUndefined() || val.IsNull() {
return ""
}
return val.String()
}
func safeInt(key string, obj js.Value) int {
if obj.IsUndefined() || obj.IsNull() {
return 0
}
val := obj.Get(key)
if val.IsUndefined() || val.IsNull() {
return 0
}
return val.Int()
}
-41
View File
@@ -1,41 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"context"
"fmt"
"log"
"sync"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
)
func registerNotifyCallback(callbacks *IPNCallbacks, lb *ipnlocal.LocalBackend) {
var readyOnce sync.Once
lb.SetNotifyCallback(func(n ipn.Notify) {
defer func() {
if rec := recover(); rec != nil {
callbacks.OnError(fmt.Sprint(rec))
}
}()
if n.State != nil {
if *n.State == ipn.Running {
readyOnce.Do(callbacks.OnReady)
}
if *n.State == ipn.NeedsLogin {
go forceInteractiveLogin(lb)
}
}
})
}
func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
if err := lb.StartLoginInteractive(context.Background()); err != nil {
log.Printf("Error starting interactive login: %v", err)
}
}
-186
View File
@@ -1,186 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"context"
"fmt"
"io"
"log"
"net"
"time"
"golang.org/x/crypto/ssh"
)
type SSHSession struct {
IPAddress string
Username string
Config *TunnelConfig
Ipn *TsWasmIpn
Pty *ssh.Session
stdin io.Writer
resizeCols int
resizeRows int
cancel context.CancelFunc
}
func (i *TsWasmIpn) NewSSHSession(config *TunnelConfig) *SSHSession {
return &SSHSession{
IPAddress: config.IPAddress,
Username: config.Username,
Config: config,
Ipn: i,
}
}
func (s *SSHSession) ConnectAndRun() {
defer s.Config.OnDisconnect()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.Config.Timeout)*time.Second)
s.cancel = cancel
defer cancel()
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.IPAddress, "22"))
if err != nil {
s.writeError("Dial", err)
return
}
defer conn.Close()
// In Go WASM, gVisor's netstack conn.Read blocks indefinitely without
// a deadline because the single-threaded goroutine scheduler needs the
// deadline machinery to yield to the browser event loop and process
// inbound WireGuard packets. We set a deadline that covers the entire
// SSH handshake and clear it once the session is established.
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
sshConf := &ssh.ClientConfig{
User: s.Username,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, s.IPAddress, sshConf)
if err != nil {
s.writeError("SSH", err)
return
}
defer sshConn.Close()
conn.SetReadDeadline(time.Time{})
sshClient := ssh.NewClient(sshConn, chans, reqs)
defer sshClient.Close()
pty, err := sshClient.NewSession()
if err != nil {
s.writeError("SSH", err)
return
}
defer pty.Close()
s.Pty = pty
rows := 24
if s.resizeRows != 0 {
rows = s.resizeRows
}
cols := 80
if s.resizeCols != 0 {
cols = s.resizeCols
}
err = pty.RequestPty("xterm-256color", rows, cols, ssh.TerminalModes{
ssh.ECHO: 1,
ssh.ICANON: 1,
ssh.ISIG: 1,
ssh.ICRNL: 1,
ssh.IUTF8: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
})
if err != nil {
s.writeError("SSH", err)
return
}
stdin, err := pty.StdinPipe()
if err != nil {
s.writeError("SSH", err)
return
}
s.stdin = stdin
stdout, err := pty.StdoutPipe()
if err != nil {
s.writeError("SSH", err)
return
}
stderr, err := pty.StderrPipe()
if err != nil {
s.writeError("SSH", err)
return
}
go io.Copy(DataPipe{s.Config.OnData}, stdout)
go io.Copy(DataPipe{s.Config.OnData}, stderr)
err = pty.Shell()
if err != nil {
s.writeError("SSH", err)
return
}
s.Config.OnConnect()
if err := pty.Wait(); err != nil {
log.Printf("SSH session ended: %v", err)
}
}
func (s *SSHSession) WriteInput(data string) {
if s.stdin != nil {
s.stdin.Write([]byte(data))
}
}
// Resize takes cols and rows (JS convention: cols first, rows second)
// and translates to SSH's WindowChange(rows, cols) order.
func (s *SSHSession) Resize(cols, rows int) error {
if s.Pty == nil {
s.resizeCols = cols
s.resizeRows = rows
return nil
}
return s.Pty.WindowChange(rows, cols)
}
func (s *SSHSession) Close() error {
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
if s.Pty != nil {
return s.Pty.Close()
}
return nil
}
func (s *SSHSession) writeError(label string, err error) {
s.Config.OnData(fmt.Sprintf("%s error: %v\r\n", label, err))
}
type DataPipe struct {
Send func(data string)
}
func (p DataPipe) Write(data []byte) (int, error) {
p.Send(string(data))
return len(data), nil
}
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
go = "1.25.1"
go = "1.26.6"
pnpm = "10.4.0"
node = "24.2"
+1 -1
View File
@@ -3,7 +3,7 @@ buildGoModule {
pname = "hp_agent";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = ../.;
vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA=";
vendorHash = "sha256-Q5lRDbx7bg3WsrF+ukVPl7rTSJcqKFhYM9lWtqfiIw4=";
ldflags = ["-s" "-w"];
env.CGO_ENABLED = 0;
}
+1 -1
View File
@@ -33,7 +33,7 @@ in
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
pnpm = pnpm_10;
hash = "sha256-2F7DplZ+PAMkDepsoeUxS04+IefWy3ARgE8G6Fz+YnQ=";
hash = "sha256-+J36edYqr+qt6C0fU8Q8visrEH5+R6Ww3VuRcykHgZY=";
};
buildPhase = ''
+8 -8
View File
@@ -14,7 +14,7 @@ in
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = ../.;
subPackages = ["cmd/hp_ssh"];
vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA=";
vendorHash = "sha256-Q5lRDbx7bg3WsrF+ukVPl7rTSJcqKFhYM9lWtqfiIw4=";
env.CGO_ENABLED = 0;
nativeBuildInputs = [go];
@@ -23,14 +23,14 @@ in
export GOOS=js
export GOARCH=wasm
# Patch Tailscale's derphttp to include DERPPort in WebSocket URLs.
# Without this, DERP servers on non-443 ports fail in WASM builds.
if [ -f patches/tailscale-derp-port.patch ]; then
chmod -R +w vendor/tailscale.com
patch -d vendor/tailscale.com -p1 < patches/tailscale-derp-port.patch
fi
# Tailscale's netcheck builds its browser DERP probe URL from the
# hostname alone, so a DERP server on a non-443 port never gets a
# home relay.
chmod -R +w vendor/tailscale.com
patch -d vendor/tailscale.com -p1 < patches/tailscale-netcheck-derp-port.patch
go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh
go build -mod=vendor -tags "$(cat cmd/hp_ssh/build-tags.txt)" \
-trimpath -ldflags "-s -w" -o hp_ssh.wasm ./cmd/hp_ssh
'';
installPhase = ''
+2 -2
View File
@@ -30,7 +30,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.3.0",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@kubernetes/client-node": "^2.0.0",
"@lezer/highlight": "^1.2.3",
"@react-router/node": "^8.3.0",
"@uiw/react-codemirror": "4.25.11",
@@ -48,7 +48,7 @@
"react-dom": "19.2.8",
"react-error-boundary": "^6.1.3",
"react-router": "^8.3.0",
"restty": "^0.1.35",
"restty": "0.2.6",
"tailwind-merge": "3.6.0",
"ulidx": "2.4.1",
"undici": "8.10.0",
-46
View File
@@ -1,46 +0,0 @@
Fix DERP browser URLs to include non-standard ports.
Tailscale's browser DERP paths ignore DERPPort when building WebSocket
and HTTP-only netcheck probe URLs, causing connections to fail when DERP
servers run on non-443 ports (e.g. :8443). The TCP dial path correctly
handles DERPPort but the browser paths used by WASM builds do not.
--- a/derp/derphttp/derphttp_client.go
+++ b/derp/derphttp/derphttp_client.go
@@ -279,10 +279,19 @@
return c.url.String()
}
proto := "https"
+ var port string
if debugUseDERPHTTP() {
proto = "http"
+ port = "3340"
}
- return fmt.Sprintf("%s://%s/derp", proto, node.HostName)
+ if node != nil && node.DERPPort != 0 {
+ port = fmt.Sprint(node.DERPPort)
+ }
+ host := node.HostName
+ if port != "" {
+ host = net.JoinHostPort(node.HostName, port)
+ }
+ return fmt.Sprintf("%s://%s/derp", proto, host)
}
// AddressFamilySelector decides whether IPv6 is preferred for
--- a/net/netcheck/netcheck.go
+++ b/net/netcheck/netcheck.go
@@ -1103,7 +1103,11 @@
go func() {
defer wg.Done()
node := rg.Nodes[0]
- req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://"+node.HostName+"/derp/probe", nil)
+ host := node.HostName
+ if node.DERPPort != 0 {
+ host = net.JoinHostPort(node.HostName, fmt.Sprint(node.DERPPort))
+ }
+ req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://"+host+"/derp/probe", nil)
// One warm-up one to get HTTP connection set
// up and get a connection from the browser's
// pool.
@@ -0,0 +1,25 @@
netcheck: include DERPPort in the browser HTTPS probe URL
The js/wasm netcheck path probes each DERP region over HTTPS to measure
latency and pick a home relay. It builds that URL from HostName alone, so
a DERP server on a non-443 port is never reachable and the client ends up
with no home DERP.
derphttp's urlString already handles DERPPort; this is the same fix for
the one remaining browser path that does not.
--- a/net/netcheck/netcheck.go
+++ b/net/netcheck/netcheck.go
@@ -1075,7 +1075,11 @@
}
wg.Go(func() {
node := rg.Nodes[0]
- req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://"+node.HostName+"/derp/probe", nil)
+ host := node.HostName
+ if node.DERPPort != 0 && node.DERPPort != 443 {
+ host = net.JoinHostPort(host, fmt.Sprint(node.DERPPort))
+ }
+ req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://"+host+"/derp/probe", nil)
// One warm-up one to get HTTP connection set
// up and get a connection from the browser's
// pool.
+26
View File
@@ -0,0 +1,26 @@
tsconnect: let the caller choose the PTY terminal type and modes
Upstream requests a bare "xterm" PTY with no terminal modes. Headplane
renders with Ghostty and ships a Nerd Font, so it needs xterm-256color
and sane modes for anything colour-aware on the far side.
Applied to cmd/hp_ssh/wasm_js.go by scripts/sync-tsconnect.sh.
--- a/wasm/wasm_js.go
+++ b/wasm/wasm_js.go
@@ -484,7 +484,14 @@
if s.pendingResizeCols != 0 {
cols = s.pendingResizeCols
}
- err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
+ termType := "xterm"
+ if v := s.termConfig.Get("termType"); v.Type() == js.TypeString {
+ termType = v.String()
+ }
+ err = session.RequestPty(termType, rows, cols, ssh.TerminalModes{
+ ssh.ECHO: 1, ssh.ICANON: 1, ssh.ISIG: 1, ssh.ICRNL: 1, ssh.IUTF8: 1,
+ ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400,
+ })
if err != nil {
writeError("Pseudo Terminal", err)
return
+38
View File
@@ -0,0 +1,38 @@
tsconnect: type the termType option and the wasm_exec Go global
termType matches patches/tsconnect-term-type.patch. Go is declared by the
wasm_exec.js helper the Go toolchain ships, which we load ourselves rather
than through @tailscale/connect.
@@ -4,11 +4,22 @@
/**
* @fileoverview Type definitions for types exported by the wasm_js.go Go
* module.
+ *
+ * Vendored from tailscale.com/cmd/tsconnect/src/types/wasm_js.d.ts; see
+ * cmd/hp_ssh/wasm_js.go for the upstream ref. Local changes live in
+ * patches/tsconnect-types.patch and are already applied here.
*/
declare global {
function newIPN(config: IPNConfig): IPN
+ var Go: {
+ new (): {
+ importObject: WebAssembly.Imports
+ run(instance: WebAssembly.Instance): Promise<void>
+ }
+ }
+
interface IPN {
run(callbacks: IPNCallbacks): void
login(): void
@@ -22,6 +33,8 @@
setReadFn: (readFn: (data: string) => void) => void
rows: number
cols: number
+ /** Defaults to "xterm" */
+ termType?: string
/** Defaults to 5 seconds */
timeoutSeconds?: number
onConnectionProgress: (message: string) => void
+298 -797
View File
File diff suppressed because it is too large Load Diff
+18 -7
View File
@@ -91,17 +91,28 @@ function createStaticHandler(opts: StaticOptions) {
if (!st.isFile()) return false;
const isAsset = pathname.startsWith(assetsPrefix);
res.setHeader(
"Cache-Control",
isAsset && opts.immutableAssets
? "public, max-age=31536000, immutable"
: "public, max-age=3600",
);
// Dev serves unhashed files that are rewritten in place, so a rebuild is
// invisible to anything holding a copy. Revalidate instead of pinning.
let cacheControl = "no-cache";
if (opts.immutableAssets) {
cacheControl = isAsset ? "public, max-age=31536000, immutable" : "public, max-age=3600";
}
res.setHeader("Cache-Control", cacheControl);
res.setHeader("Last-Modified", st.mtime.toUTCString());
// HTTP dates carry one-second resolution, so floor mtime before comparing.
const modifiedSince = Date.parse(req.headers["if-modified-since"] ?? "");
if (!Number.isNaN(modifiedSince) && Math.floor(st.mtimeMs / 1000) * 1000 <= modifiedSince) {
res.statusCode = 304;
res.end();
return true;
}
const mimeType = mime.getType(extname(file)) ?? "application/octet-stream";
res.setHeader("Content-Type", mimeType);
res.setHeader("Content-Length", String(st.size));
res.setHeader("Last-Modified", st.mtime.toUTCString());
res.statusCode = 200;
if (req.method === "HEAD") {
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
# Resyncs cmd/hp_ssh/wasm_js.go with tailscale.com/cmd/tsconnect and reapplies
# the local patch. Pass the upstream ref to move to; defaults to the ref the
# vendored file currently records.
#
# After a successful sync, bump tailscale.com in go.mod to a release that
# contains the ref, or the file will not compile.
set -eu
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR" || exit 1
TARGET="cmd/hp_ssh/wasm_js.go"
TYPES="app/routes/ssh/wasm_js.d.ts"
PATCH="patches/tsconnect-term-type.patch"
TYPES_PATCH="patches/tsconnect-types.patch"
RAW="https://raw.githubusercontent.com/tailscale/tailscale"
die() { echo "error: $*" >&2; exit 1; }
REF=${1:-$(sed -n 's/^\/\/ Upstream ref: //p' "$TARGET")}
[ -n "$REF" ] || die "no upstream ref given and none recorded in $TARGET"
echo "==> Syncing tsconnect at $REF"
command -v go >/dev/null 2>&1 || die "go not installed"
HEADER=$(mktemp)
trap 'rm -f "$HEADER" "$HEADER.go"' EXIT
cat > "$HEADER" <<HDR
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Vendored from tailscale.com/cmd/tsconnect/wasm/wasm_js.go.
// Upstream ref: $REF
//
// Local changes live in $PATCH and are already
// applied here. Run scripts/sync-tsconnect.sh to move to a newer upstream.
HDR
curl -fsSL "$RAW/$REF/cmd/tsconnect/wasm/wasm_js.go" > "$HEADER.go" ||
die "failed to fetch wasm_js.go at $REF"
# Upstream's first three lines are the license header we replace.
cat "$HEADER" > "$TARGET"
tail -n +4 "$HEADER.go" >> "$TARGET"
echo "==> Applying $PATCH"
patch --no-backup-if-mismatch "$TARGET" < "$PATCH" || die "patch conflict — resolve by hand, then rewrite $PATCH"
echo "==> Refreshing $TYPES"
curl -fsSL "$RAW/$REF/cmd/tsconnect/src/types/wasm_js.d.ts" > "$TYPES" ||
die "failed to fetch wasm_js.d.ts at $REF"
echo "==> Applying $TYPES_PATCH"
patch --no-backup-if-mismatch "$TYPES" < "$TYPES_PATCH" || die "patch conflict — resolve by hand, then rewrite $TYPES_PATCH"
echo "==> Regenerating build tags"
GOFLAGS=-mod=mod go run scripts/wasm-tags.go > cmd/hp_ssh/build-tags.txt
gofmt -w "$TARGET"
echo "==> Done. Rebuild with ./build.sh --wasm"
+31
View File
@@ -0,0 +1,31 @@
//go:build ignore
// Regenerates cmd/hp_ssh/build-tags.txt, the -tags value for the SSH WASM
// build. Run via scripts/sync-tsconnect.sh, not at build time: wasmbuild is
// not an import of this module, so it is absent from a vendored tree.
//
// Tailscale computes this list from its own feature registry, so it tracks
// upstream automatically. We drop tailscale_go because it needs Tailscale's
// forked Go toolchain (runtime.TailscaleCurrentP); everything else is a
// ts_omit_* tag that strips server-only features from the browser bundle.
package main
import (
"fmt"
"strings"
"tailscale.com/cmd/tsconnect/wasmbuild"
)
func main() {
tagList := strings.Split(wasmbuild.Tags(), ",")
keptList := make([]string, 0, len(tagList))
for _, tag := range tagList {
if tag != "tailscale_go" {
keptList = append(keptList, tag)
}
}
fmt.Println(strings.Join(keptList, ","))
}