Files
tailscale/ssh/tailssh/hostkeys.go
Brad Fitzpatrick 547f371657 ssh/tailssh, feature/ssh, cmd/tailscaled: run the Tailscale SSH server on Windows
Add the Windows versions of the per-OS hooks and build the SSH server on
Windows. As LocalSystem (the service), tailscaled logs the target user on
with an S4U logon via util/winutil/s4u and runs the session process with
that user's token, with pipes or, for PTYs, the s4u pseudoconsole relay.
A non-LocalSystem tailscaled can only run sessions as itself. SFTP runs
"tailscaled be-child sftp" as the user.

The shell follows OpenSSH for Windows's DefaultShell registry value, else
pwsh, Windows PowerShell, or cmd.exe. OpenSSH's host keys are reused when
readable. Policy user names match Windows accounts case-insensitively;
"=" treats LocalSystem and the built-in Administrator as root. Agent
forwarding is not supported.

TestTailscaleSSH in tstest/integration covers the same-user path on all
platforms and, with --run-windows-service-tests, the S4U path.

Updates #4697

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I9e3f1a7c5b2d4e6f8a0c2b4d6e8f1a3c5b7d9e2f
2026-09-28 20:46:07 +00:00

159 lines
4.6 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9 || windows
package tailssh
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/ssh"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
)
// keyTypes are the SSH key types that we either try to read from the
// system's OpenSSH keys or try to generate for ourselves when not
// running as root.
var keyTypes = []string{"rsa", "ecdsa", "ed25519"}
// getHostKeys returns the SSH host keys, using the system's OpenSSH keys when
// they are readable (see systemHostKeyFile) and generating Tailscale-specific
// keys as needed.
func getHostKeys(varRoot string, logf logger.Logf) ([]ssh.Signer, error) {
existing := getSystemHostKeys(logf)
return getTailscaleHostKeys(varRoot, existing)
}
// getHostKeyPublicStrings returns the SSH host key public key strings.
func getHostKeyPublicStrings(varRoot string, logf logger.Logf) ([]string, error) {
signers, err := getHostKeys(varRoot, logf)
if err != nil {
return nil, err
}
var keyStrings []string
for _, signer := range signers {
keyStrings = append(keyStrings, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))))
}
return keyStrings, nil
}
// getTailscaleHostKeys returns the three (rsa, ecdsa, ed25519) SSH host
// keys, reusing the provided ones in existing if present in the map.
func getTailscaleHostKeys(varRoot string, existing map[string]ssh.Signer) (keys []ssh.Signer, err error) {
var keyDir string // lazily initialized $TAILSCALE_VAR/ssh dir.
for _, typ := range keyTypes {
if s, ok := existing[typ]; ok {
keys = append(keys, s)
continue
}
if keyDir == "" {
if varRoot == "" {
return nil, errors.New("no var root for ssh keys")
}
keyDir = filepath.Join(varRoot, "ssh")
if err := os.MkdirAll(keyDir, 0700); err != nil {
return nil, err
}
}
hostKey, err := hostKeyFileOrCreate(keyDir, typ)
if err != nil {
return nil, fmt.Errorf("error creating SSH host key type %q in %q: %w", typ, keyDir, err)
}
signer, err := ssh.ParsePrivateKey(hostKey)
if err != nil {
return nil, fmt.Errorf("error parsing SSH host key type %q from %q: %w", typ, keyDir, err)
}
keys = append(keys, signer)
}
return keys, nil
}
// keyGenMu protects concurrent generation of host keys with
// [hostKeyFileOrCreate], making sure two callers don't try to concurrently find
// a missing key and generate it at the same time, returning different keys to
// their callers.
//
// Technically we actually want to have a mutex per directory (the keyDir
// passed), but that's overkill for how rarely keys are loaded or generated.
var keyGenMu sync.Mutex
func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) {
keyGenMu.Lock()
defer keyGenMu.Unlock()
path := filepath.Join(keyDir, "ssh_host_"+typ+"_key")
v, err := os.ReadFile(path)
if err == nil {
return v, nil
}
if !os.IsNotExist(err) {
return nil, err
}
var priv any
switch typ {
default:
return nil, fmt.Errorf("unsupported key type %q", typ)
case "ed25519":
_, priv, err = ed25519.GenerateKey(rand.Reader)
case "ecdsa":
// curve is arbitrary. We pick whatever will at
// least pacify clients as the actual encryption
// doesn't matter: it's all over WireGuard anyway.
curve := elliptic.P256()
priv, err = ecdsa.GenerateKey(curve, rand.Reader)
case "rsa":
// keySize is arbitrary. We pick whatever will at
// least pacify clients as the actual encryption
// doesn't matter: it's all over WireGuard anyway.
const keySize = 2048
priv, err = rsa.GenerateKey(rand.Reader, keySize)
}
if err != nil {
return nil, err
}
mk, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
err = os.WriteFile(path, pemGen, 0700)
return pemGen, err
}
// getSystemHostKeys returns the system's OpenSSH host keys that tailssh can
// read, keyed by type. It returns nil if there are none.
func getSystemHostKeys(logf logger.Logf) (ret map[string]ssh.Signer) {
for _, typ := range keyTypes {
filename := systemHostKeyFile(typ)
if filename == "" {
continue
}
hostKey, err := os.ReadFile(filename)
if err != nil || len(bytes.TrimSpace(hostKey)) == 0 {
continue
}
signer, err := ssh.ParsePrivateKey(hostKey)
if err != nil {
logf("warning: error reading host key %s: %v (generating one instead)", filename, err)
continue
}
mak.Set(&ret, typ, signer)
}
return ret
}