feat: add tsrun bootstrap service and deploy
Deploy tsrun / deploy (push) Failing after 15s

This commit is contained in:
2026-07-22 06:23:21 +00:00 Unverified
commit e588b9a527
35 changed files with 2276 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
//go:build linux
package full
import (
"errors"
"log"
"os"
"os/exec"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/tsd"
"tailscale.com/wgengine/router"
)
func Clear() error {
if os.Geteuid() != 0 {
return errors.New("tsrun clear requires root on linux")
}
logf := log.New(os.Stderr, "", log.LstdFlags).Printf
sys := tsd.NewSystem()
netMon, err := netmon.New(sys.Bus.Get(), logf)
if err != nil {
return err
}
defer netMon.Close()
dns.CleanUp(logf, netMon, sys.Bus.Get(), sys.HealthTracker.Get(), "tailscale0")
router.CleanUp(logf, netMon, "tailscale0")
_ = deleteLink("tailscale0")
return nil
}
func deleteLink(name string) error {
ip, err := exec.LookPath("ip")
if err != nil {
return nil
}
cmd := exec.Command(ip, "link", "delete", name)
if err := cmd.Run(); err != nil {
return nil
}
return nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !windows
package full
import "errors"
func Clear() error {
return errors.New("tsrun clear is only available on linux and windows")
}
+27
View File
@@ -0,0 +1,27 @@
//go:build windows
package full
import (
"log"
"os"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/tsd"
"tailscale.com/wgengine/router"
)
func Clear() error {
logf := log.New(os.Stderr, "", log.LstdFlags).Printf
sys := tsd.NewSystem()
netMon, err := netmon.New(sys.Bus.Get(), logf)
if err != nil {
return err
}
defer netMon.Close()
dns.CleanUp(logf, netMon, sys.Bus.Get(), sys.HealthTracker.Get(), "Tailscale")
router.CleanUp(logf, netMon, "Tailscale")
return nil
}
+215
View File
@@ -0,0 +1,215 @@
//go:build linux
package full
import (
"context"
"errors"
"fmt"
"log"
"net/netip"
"os"
"path/filepath"
"time"
_ "tailscale.com/feature/condregister"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/net/tstun"
"tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/wgengine/router"
"tsrun/internal/oauth"
"tsrun/internal/vault"
)
func Run(ctx context.Context, payload vault.Payload) error {
if err := requireRoot(); err != nil {
return err
}
authKey, err := oauth.CreateEphemeralAuthKey(ctx, payload, "tsrun temporary access")
if err != nil {
return err
}
varRoot, err := os.MkdirTemp("", "tsrun-full-*")
if err != nil {
return err
}
defer os.RemoveAll(varRoot)
logf := log.New(os.Stderr, "", log.LstdFlags).Printf
sys := tsd.NewSystem()
netMon, err := netmon.New(sys.Bus.Get(), logf)
if err != nil {
return err
}
defer netMon.Close()
sys.Set(netMon)
dialer := &tsdial.Dialer{Logf: logf}
dialer.SetBus(sys.Bus.Get())
sys.Set(dialer)
netns.SetEnabled(true)
dev, devName, err := tstun.New(logf, "tailscale0")
if err != nil {
tstun.Diagnose(logf, "tailscale0", err)
return fmt.Errorf("creating tun device: %w", err)
}
r, err := router.New(logf, dev, sys.NetMon.Get(), sys.HealthTracker.Get(), sys.Bus.Get())
if err != nil {
return err
}
defer r.Close()
sys.Set(r)
dnsCfg, err := dns.NewOSConfigurator(logf, sys.HealthTracker.Get(), sys.Bus.Get(), sys.PolicyClientOrDefault(), sys.ControlKnobs(), devName)
if err != nil {
return err
}
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: dev,
Router: r,
DNS: dnsCfg,
NetMon: netMon,
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 {
return err
}
defer func() {
eng.Close()
<-eng.Done()
}()
sys.Set(eng)
store := new(mem.Store)
sys.Set(store)
if w, ok := sys.Tun.GetOK(); ok {
w.Start()
}
lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, controlclient.LoginEphemeral|controlclient.LocalBackendStartKeyOSNeutral)
if err != nil {
return err
}
defer lb.Shutdown()
lb.SetVarRoot(filepath.Join(varRoot, "state"))
if w, ok := sys.Tun.GetOK(); ok {
ns, err := netstack.Create(logf, w, eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
return fmt.Errorf("creating netstack: %w", err)
}
defer ns.Close()
sys.Set(ns)
if err := ns.Start(lb); err != nil {
return fmt.Errorf("starting netstack: %w", err)
}
}
prefs := ipn.NewPrefs()
prefs.WantRunning = true
prefs.RouteAll = true
prefs.Hostname = defaultHostname("tsrun")
prefs.AdvertiseTags = payload.Tags
if err := lb.Start(ipn.Options{AuthKey: authKey, UpdatePrefs: prefs}); err != nil {
return err
}
if st := lb.State(); st == ipn.NeedsLogin || st == ipn.NoState {
if err := lb.StartLoginInteractive(ctx); err != nil {
return err
}
}
startupCtx, startupCancel := context.WithTimeout(ctx, 45*time.Second)
defer startupCancel()
if err := waitRunning(startupCtx, lb); err != nil {
return err
}
printStatus(lb)
<-ctx.Done()
logoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = lb.Logout(logoutCtx, ipnauth.Self)
return ctx.Err()
}
func waitRunning(ctx context.Context, lb *ipnlocal.LocalBackend) error {
st := lb.State()
for {
switch st {
case ipn.Running:
return nil
case ipn.NeedsMachineAuth:
return errors.New("device needs manual machine approval")
case ipn.Stopped:
return errors.New("tailscale backend stopped unexpectedly")
}
if err := ctx.Err(); err != nil {
return err
}
lb.WatchNotifications(ctx, ipn.NotifyInitialState, nil, func(n *ipn.Notify) (keepGoing bool) {
if n.State != nil {
st = *n.State
}
return st == ipn.NeedsLogin || st == ipn.Starting || st == ipn.NoState
})
}
}
func printStatus(lb *ipnlocal.LocalBackend) {
nm := lb.NetMapNoPeers()
if nm == nil {
fmt.Println("connected")
return
}
var ips []netip.Addr
for _, p := range nm.GetAddresses().All() {
ips = append(ips, p.Addr())
}
if len(ips) == 0 {
fmt.Println("connected")
return
}
fmt.Printf("connected: %v\n", ips)
}
func requireRoot() error {
if os.Geteuid() != 0 {
return errors.New("full client mode requires root on linux; use sudo or run with --proxy for low-privilege mode")
}
return nil
}
func defaultHostname(prefix string) string {
host, err := os.Hostname()
if err != nil || host == "" {
return prefix
}
return fmt.Sprintf("%s-%s", prefix, host)
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows && !linux
package full
import (
"context"
"errors"
"tsrun/internal/vault"
)
func Run(_ context.Context, _ vault.Payload) error {
return errors.New("full client mode is only available on windows and linux")
}
+235
View File
@@ -0,0 +1,235 @@
//go:build windows
package full
import (
"context"
"errors"
"fmt"
"log"
"net/netip"
"os"
"path/filepath"
"sync"
"time"
"github.com/dblohm7/wingoes/com"
"golang.org/x/sys/windows"
_ "tailscale.com/feature/condregister"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/net/tstun"
"tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/wgengine/router"
"tsrun/internal/oauth"
"tsrun/internal/vault"
)
var startCOMOnce sync.Once
func Run(ctx context.Context, payload vault.Payload) error {
startCOMOnce.Do(func() {
if err := com.StartRuntime(com.ConsoleApp); err != nil {
log.Printf("wingoes.com.StartRuntime failed: %v", err)
}
})
if err := requireElevation(); err != nil {
return err
}
if err := prepareEmbeddedWintun(); err != nil {
return err
}
authKey, err := oauth.CreateEphemeralAuthKey(ctx, payload, "tsrun temporary access")
if err != nil {
return err
}
varRoot, err := os.MkdirTemp("", "tsrun-full-*")
if err != nil {
return err
}
defer os.RemoveAll(varRoot)
logf := log.New(os.Stderr, "", log.LstdFlags).Printf
sys := tsd.NewSystem()
netMon, err := netmon.New(sys.Bus.Get(), logf)
if err != nil {
return err
}
defer netMon.Close()
sys.Set(netMon)
dialer := &tsdial.Dialer{Logf: logf}
dialer.SetBus(sys.Bus.Get())
sys.Set(dialer)
dev, devName, err := tstun.New(logf, "Tailscale")
if err != nil {
return err
}
sys.Set(dev)
r, err := router.New(logf, dev, sys.NetMon.Get(), sys.HealthTracker.Get(), sys.Bus.Get())
if err != nil {
return err
}
defer r.Close()
sys.Set(r)
dnsCfg, err := dns.NewOSConfigurator(logf, sys.HealthTracker.Get(), sys.Bus.Get(), sys.PolicyClientOrDefault(), sys.ControlKnobs(), devName)
if err != nil {
return err
}
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: dev,
Router: r,
DNS: dnsCfg,
NetMon: netMon,
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 {
return err
}
defer func() {
eng.Close()
<-eng.Done()
}()
sys.Set(eng)
store := new(mem.Store)
sys.Set(store)
if w, ok := sys.Tun.GetOK(); ok {
w.Start()
}
lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, controlclient.LoginEphemeral|controlclient.LocalBackendStartKeyOSNeutral)
if err != nil {
return err
}
defer lb.Shutdown()
lb.SetVarRoot(filepath.Join(varRoot, "state"))
if w, ok := sys.Tun.GetOK(); ok {
ns, err := netstack.Create(logf, w, eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
return fmt.Errorf("creating netstack: %w", err)
}
defer ns.Close()
sys.Set(ns)
if err := ns.Start(lb); err != nil {
return fmt.Errorf("starting netstack: %w", err)
}
}
prefs := ipn.NewPrefs()
prefs.WantRunning = true
prefs.RouteAll = true
prefs.Hostname = defaultHostname("tsrun")
prefs.AdvertiseTags = payload.Tags
if err := lb.Start(ipn.Options{AuthKey: authKey, UpdatePrefs: prefs}); err != nil {
return err
}
if st := lb.State(); st == ipn.NeedsLogin || st == ipn.NoState {
if err := lb.StartLoginInteractive(ctx); err != nil {
return err
}
}
startupCtx, startupCancel := context.WithTimeout(ctx, 45*time.Second)
defer startupCancel()
if err := waitRunning(startupCtx, lb); err != nil {
return err
}
printStatus(lb)
<-ctx.Done()
logoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = lb.Logout(logoutCtx, ipnauth.Self)
return ctx.Err()
}
func waitRunning(ctx context.Context, lb *ipnlocal.LocalBackend) error {
st := lb.State()
for {
switch st {
case ipn.Running:
return nil
case ipn.NeedsMachineAuth:
return errors.New("device needs manual machine approval")
case ipn.Stopped:
return errors.New("tailscale backend stopped unexpectedly")
}
if err := ctx.Err(); err != nil {
return err
}
lb.WatchNotifications(ctx, ipn.NotifyInitialState, nil, func(n *ipn.Notify) (keepGoing bool) {
if n.State != nil {
st = *n.State
}
return st == ipn.NeedsLogin || st == ipn.Starting || st == ipn.NoState
})
}
}
func printStatus(lb *ipnlocal.LocalBackend) {
nm := lb.NetMapNoPeers()
if nm == nil {
fmt.Println("connected")
return
}
var ips []netip.Addr
for _, p := range nm.GetAddresses().All() {
ips = append(ips, p.Addr())
}
if len(ips) == 0 {
fmt.Println("connected")
return
}
fmt.Printf("connected: %v\n", ips)
}
func requireElevation() error {
adminSID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return err
}
token := windows.Token(0)
isMember, err := token.IsMember(adminSID)
if err != nil {
return err
}
if !isMember {
return errors.New("full client mode requires an elevated administrator shell; use --proxy for low-privilege mode")
}
return nil
}
func defaultHostname(prefix string) string {
host, err := os.Hostname()
if err != nil || host == "" {
return prefix
}
return fmt.Sprintf("%s-%s", prefix, host)
}
@@ -0,0 +1,5 @@
//go:build !windows
package full
func prepareEmbeddedWintun() error { return nil }
@@ -0,0 +1,62 @@
//go:build windows
package full
import (
"crypto/sha256"
_ "embed"
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/windows"
)
//go:embed wintun.dll
var embeddedWintun []byte
func prepareEmbeddedWintun() error {
if len(embeddedWintun) == 0 {
return fmt.Errorf("embedded wintun.dll missing; place wintun.dll at internal/runtime/full/wintun.dll before building")
}
exePath, err := os.Executable()
if err != nil {
return err
}
dir := filepath.Dir(exePath)
path := filepath.Join(dir, "wintun.dll")
if err := ensureMatchingDLL(path); err != nil {
return err
}
if _, err := windows.LoadDLL(path); err != nil {
return fmt.Errorf("loading embedded wintun.dll: %w", err)
}
return nil
}
func ensureMatchingDLL(path string) error {
wantHash := sha256.Sum256(embeddedWintun)
if existing, err := os.ReadFile(path); err == nil {
haveHash := sha256.Sum256(existing)
if haveHash == wantHash {
return nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
tempPath := path + ".tmp"
if err := os.WriteFile(tempPath, embeddedWintun, 0o600); err != nil {
return err
}
_ = os.Remove(path)
if err := os.Rename(tempPath, path); err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package proxy
import (
"context"
crand "crypto/rand"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"strings"
"tailscale.com/net/proxymux"
"tailscale.com/net/socks5"
"tailscale.com/tsnet"
)
type listenerBundle struct {
addr string
proxyCred string
closeFn func() error
}
func startProxyListener(ctx context.Context, srv *tsnet.Server) (*listenerBundle, error) {
cred, err := randomHex(16)
if err != nil {
return nil, err
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
socksLn, httpLn := proxymux.SplitSOCKSAndHTTP(ln)
socksSrv := &socks5.Server{
Dialer: srv.Dial,
Username: "tsnet",
Password: cred,
}
httpSrv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
handleConnect(w, r, srv)
return
}
handleForward(w, r, srv)
}),
}
go func() {
<-ctx.Done()
_ = httpSrv.Close()
_ = socksLn.Close()
_ = httpLn.Close()
_ = ln.Close()
}()
go func() {
_ = socksSrv.Serve(socksLn)
}()
go func() {
_ = httpSrv.Serve(httpLn)
}()
return &listenerBundle{
addr: ln.Addr().String(),
proxyCred: cred,
closeFn: func() error {
var firstErr error
for _, fn := range []func() error{
httpSrv.Close,
socksLn.Close,
httpLn.Close,
ln.Close,
} {
if err := fn(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
},
}, nil
}
func handleConnect(w http.ResponseWriter, r *http.Request, srv *tsnet.Server) {
dst := canonicalAddr(r.Host)
upstream, err := srv.Dial(r.Context(), "tcp", dst)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
hj, ok := w.(http.Hijacker)
if !ok {
upstream.Close()
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, _, err := hj.Hijack()
if err != nil {
upstream.Close()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go proxyCopy(upstream, clientConn)
go proxyCopy(clientConn, upstream)
}
func handleForward(w http.ResponseWriter, r *http.Request, srv *tsnet.Server) {
if r.URL == nil || r.URL.Scheme == "" || r.URL.Host == "" {
http.Error(w, "configure this as an HTTP proxy; do not browse directly to the proxy address", http.StatusBadRequest)
return
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Header = r.Header.Clone()
req.RequestURI = ""
transport := &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return srv.Dial(ctx, network, addr)
},
}
defer transport.CloseIdleConnections()
resp, err := transport.RoundTrip(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
copyHeader(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
func canonicalAddr(host string) string {
if _, _, err := net.SplitHostPort(host); err == nil {
return host
}
if strings.Contains(host, ":") {
return net.JoinHostPort(host, "443")
}
return net.JoinHostPort(host, "80")
}
func copyHeader(dst, src http.Header) {
for k, vals := range src {
for _, v := range vals {
dst.Add(k, v)
}
}
}
func proxyCopy(dst io.WriteCloser, src io.ReadCloser) {
defer dst.Close()
defer src.Close()
_, _ = io.Copy(dst, src)
}
func directRequestHint() string {
return "Use the single proxy address below as either an HTTP proxy or a SOCKS5 proxy. Do not browse directly to the proxy address itself."
}
func randomHex(size int) (string, error) {
buf := make([]byte, size)
if _, err := crand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func formatProxyURL(addr string) string {
return fmt.Sprintf("http://%s", addr)
}
+62
View File
@@ -0,0 +1,62 @@
package proxy
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
_ "tailscale.com/feature/condregister/oauthkey"
"tailscale.com/tsnet"
"tsrun/internal/vault"
)
func Run(ctx context.Context, payload vault.Payload) error {
baseDir, err := os.MkdirTemp("", "tsrun-proxy-*")
if err != nil {
return err
}
defer os.RemoveAll(baseDir)
hostname := defaultHostname("tsrun-proxy")
srv := &tsnet.Server{
Hostname: hostname,
Dir: filepath.Join(baseDir, "state"),
Ephemeral: true,
ClientSecret: payload.OAuthClientSecret,
AdvertiseTags: payload.Tags,
}
defer srv.Close()
if _, err := srv.Up(ctx); err != nil {
return err
}
proxyListener, err := startProxyListener(ctx, srv)
if err != nil {
return err
}
defer proxyListener.closeFn()
fmt.Printf("proxy mode active\n")
fmt.Printf("%s\n", directRequestHint())
fmt.Printf("Proxy: %s\n", formatProxyURL(proxyListener.addr))
fmt.Printf("Username: tsnet\n")
fmt.Printf("Password: %s\n", proxyListener.proxyCred)
waitCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
<-waitCtx.Done()
return waitCtx.Err()
}
func defaultHostname(prefix string) string {
host, err := os.Hostname()
if err != nil || host == "" {
return prefix
}
return fmt.Sprintf("%s-%s", prefix, host)
}