Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion go/lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ import (
var LogLevel int32 // default 0 (silent)

var (
mu sync.Mutex // protects srv and store
mu sync.Mutex // protects srv, store, and the cached start config below

srv *tsnet.Server
store *SQLiteStore // package-owned; tsnet.Server doesn't close its Store, so we do in stopLocked

// lastHostname and lastControlURL remember the most recent successful Start
// so Logout can re-establish a transient client to revoke the node key even
// when the caller already stopped the server. An empty lastControlURL just
// means we fall back to a local-only wipe.
lastHostname string
lastControlURL string
)

// HasState checks if the state directory contains a valid machine key.
Expand Down Expand Up @@ -61,9 +68,17 @@ func Logout(stateDir string) error {

mu.Lock()
s := srv
host := lastHostname
ctrlURL := lastControlURL
mu.Unlock()
if s != nil {
revokeNodeKey(s)
} else if ctrlURL != "" && HasState(stateDir) {
// The caller stopped the server before logging out, so there's no live
// client to revoke through. Briefly bring the persisted node back up to
// expire its key at the control plane — a logout must clear the node
// from the tailnet, not just wipe the local credential.
revokeStoppedNode(host, ctrlURL, stateDir)
}

Stop()
Expand Down Expand Up @@ -232,6 +247,8 @@ func Start(hostname, authKey, controlURL, stateDir string, ephemeral bool) (err
// Commit to package state only after every allocation succeeded.
srv = newSrv
store = newStore
lastHostname = hostname
lastControlURL = controlURL
return nil
}

Expand Down Expand Up @@ -305,6 +322,45 @@ func revokeNodeKey(s *tsnet.Server) {
}
}

// revokeStoppedNode best-effort revokes the node key when Logout is called with
// no running server. It briefly brings the persisted node back up from local
// state (reusing the existing node key, no auth key) so the LocalAPI logout can
// reach the control plane, then tears the transient server down. Failures are
// logged and swallowed; the caller wipes local state regardless.
func revokeStoppedNode(hostname, controlURL, stateDir string) {
st, err := NewSQLiteStore(stateDir + "/state.db")
if err != nil {
logInfo("logout: cannot open persisted state to revoke stopped node: %v", err)
return
}
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
Dir: stateDir,
Store: st,
Logf: func(format string, args ...any) {
if atomic.LoadInt32(&LogLevel) >= 2 {
log.Printf("TSNET: "+format, args...)
}
},
}
defer func() {
s.Close()
st.Close()
}()

ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// Up reconnects using the persisted node key (no auth key needed) so the
// control plane can act on the logout; bounded by ctx so an unreachable
// control server can't hang teardown.
if _, err := s.Up(ctx); err != nil {
logInfo("logout: transient bring-up to revoke stopped node failed: %v", err)
return
}
revokeNodeKey(s)
}

func jsonError(err error) string {
m := map[string]string{"error": err.Error()}
b, _ := json.Marshal(m)
Expand Down
39 changes: 39 additions & 0 deletions go/lib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"

"tailscale.com/ipn"
"tailscale.com/tsnet"
Expand Down Expand Up @@ -76,6 +77,44 @@ func TestLogout_RemovesDir(t *testing.T) {
}
}

func TestLogout_NoCachedControlURLWipesWithoutHang(t *testing.T) {
// Persisted machine key but no cached control URL (e.g. Logout without a
// prior Start this process). Logout must still wipe, and must NOT attempt a
// transient revoke that could block on an unreachable control plane.
mu.Lock()
srv = nil
lastControlURL = ""
mu.Unlock()

dir := t.TempDir()
store, err := NewSQLiteStore(filepath.Join(dir, "state.db"))
if err != nil {
t.Fatal(err)
}
if err := store.WriteState(ipn.MachineKeyStateKey, []byte("fake-machine-key")); err != nil {
t.Fatal(err)
}
store.Close()
if !HasState(dir) {
t.Fatal("precondition: state should exist")
}

done := make(chan error, 1)
go func() { done <- Logout(dir) }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Logout returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Logout hung — without a cached control URL it must skip the transient revoke")
}

if HasState(dir) {
t.Error("Logout must wipe state even when it cannot revoke")
}
}

// --- jsonError tests ---

func TestJsonError_SimpleMessage(t *testing.T) {
Expand Down
Loading