ipn/ipnlocal, feature/ssh: move SSH code out of LocalBackend to feature
This makes tsnet apps not depend on x/crypto/ssh and locks that in with a test. It also paves the wave for tsnet apps to opt-in to SSH support via a blank feature import in the future. Updates #12614 Change-Id: Ica85628f89c8f015413b074f5001b82b27c953a9 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
99e3e9af51
commit
f905871fb1
@@ -1000,10 +1000,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||||
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
||||||
LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
|
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
|
||||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/ssh+
|
|
||||||
golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+
|
||||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/ssh+
|
golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+
|
||||||
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
|
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
|
||||||
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
|
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
|
||||||
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
||||||
@@ -1011,8 +1010,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||||
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
||||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||||
LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal
|
|
||||||
LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh
|
|
||||||
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
|
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
|
||||||
golang.org/x/exp/maps from sigs.k8s.io/controller-runtime/pkg/cache+
|
golang.org/x/exp/maps from sigs.k8s.io/controller-runtime/pkg/cache+
|
||||||
golang.org/x/exp/slices from tailscale.com/cmd/k8s-operator+
|
golang.org/x/exp/slices from tailscale.com/cmd/k8s-operator+
|
||||||
@@ -1078,7 +1075,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
crypto/aes from crypto/tls+
|
crypto/aes from crypto/tls+
|
||||||
crypto/cipher from crypto/aes+
|
crypto/cipher from crypto/aes+
|
||||||
crypto/des from crypto/tls+
|
crypto/des from crypto/tls+
|
||||||
crypto/dsa from crypto/x509+
|
crypto/dsa from crypto/x509
|
||||||
crypto/ecdh from crypto/ecdsa+
|
crypto/ecdh from crypto/ecdsa+
|
||||||
crypto/ecdsa from crypto/tls+
|
crypto/ecdsa from crypto/tls+
|
||||||
crypto/ed25519 from crypto/tls+
|
crypto/ed25519 from crypto/tls+
|
||||||
@@ -1127,9 +1124,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
crypto/internal/randutil from crypto/internal/rand
|
crypto/internal/randutil from crypto/internal/rand
|
||||||
crypto/internal/sysrand from crypto/internal/fips140/drbg
|
crypto/internal/sysrand from crypto/internal/fips140/drbg
|
||||||
crypto/md5 from crypto/tls+
|
crypto/md5 from crypto/tls+
|
||||||
crypto/mlkem from golang.org/x/crypto/ssh+
|
crypto/mlkem from crypto/hpke+
|
||||||
crypto/rand from crypto/ed25519+
|
crypto/rand from crypto/ed25519+
|
||||||
crypto/rc4 from crypto/tls+
|
crypto/rc4 from crypto/tls
|
||||||
crypto/rsa from crypto/tls+
|
crypto/rsa from crypto/tls+
|
||||||
crypto/sha1 from crypto/tls+
|
crypto/sha1 from crypto/tls+
|
||||||
crypto/sha256 from crypto/tls+
|
crypto/sha256 from crypto/tls+
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
tailscale.com/feature/posture from tailscale.com/feature/condregister
|
tailscale.com/feature/posture from tailscale.com/feature/condregister
|
||||||
tailscale.com/feature/relayserver from tailscale.com/feature/condregister
|
tailscale.com/feature/relayserver from tailscale.com/feature/condregister
|
||||||
L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister
|
L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister
|
||||||
|
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
|
||||||
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
|
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
|
||||||
tailscale.com/feature/taildrop from tailscale.com/feature/condregister
|
tailscale.com/feature/taildrop from tailscale.com/feature/condregister
|
||||||
L tailscale.com/feature/tap from tailscale.com/feature/condregister
|
L tailscale.com/feature/tap from tailscale.com/feature/condregister
|
||||||
@@ -387,7 +388,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
tailscale.com/proxymap from tailscale.com/tsd+
|
tailscale.com/proxymap from tailscale.com/tsd+
|
||||||
💣 tailscale.com/safesocket from tailscale.com/client/local+
|
💣 tailscale.com/safesocket from tailscale.com/client/local+
|
||||||
LD tailscale.com/sessionrecording from tailscale.com/ssh/tailssh
|
LD tailscale.com/sessionrecording from tailscale.com/ssh/tailssh
|
||||||
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/feature/ssh
|
||||||
tailscale.com/syncs from tailscale.com/cmd/tailscaled+
|
tailscale.com/syncs from tailscale.com/cmd/tailscaled+
|
||||||
tailscale.com/tailcfg from tailscale.com/client/local+
|
tailscale.com/tailcfg from tailscale.com/client/local+
|
||||||
tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal
|
tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
// Force registration of tailssh with LocalBackend.
|
// Register implementations of various SSH hooks.
|
||||||
import _ "tailscale.com/ssh/tailssh"
|
import _ "tailscale.com/feature/ssh"
|
||||||
|
|||||||
@@ -401,8 +401,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||||
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
||||||
LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
|
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
|
||||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+
|
|
||||||
golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+
|
||||||
golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+
|
||||||
golang.org/x/crypto/ed25519 from gopkg.in/square/go-jose.v2
|
golang.org/x/crypto/ed25519 from gopkg.in/square/go-jose.v2
|
||||||
@@ -414,8 +413,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
golang.org/x/crypto/pbkdf2 from gopkg.in/square/go-jose.v2
|
golang.org/x/crypto/pbkdf2 from gopkg.in/square/go-jose.v2
|
||||||
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
||||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||||
LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal
|
|
||||||
LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh
|
|
||||||
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
|
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
|
||||||
golang.org/x/exp/maps from tailscale.com/ipn/store/mem+
|
golang.org/x/exp/maps from tailscale.com/ipn/store/mem+
|
||||||
golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||||
@@ -476,7 +473,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
crypto/aes from crypto/tls+
|
crypto/aes from crypto/tls+
|
||||||
crypto/cipher from crypto/aes+
|
crypto/cipher from crypto/aes+
|
||||||
crypto/des from crypto/tls+
|
crypto/des from crypto/tls+
|
||||||
crypto/dsa from crypto/x509+
|
crypto/dsa from crypto/x509
|
||||||
crypto/ecdh from crypto/ecdsa+
|
crypto/ecdh from crypto/ecdsa+
|
||||||
crypto/ecdsa from crypto/tls+
|
crypto/ecdsa from crypto/tls+
|
||||||
crypto/ed25519 from crypto/tls+
|
crypto/ed25519 from crypto/tls+
|
||||||
@@ -525,9 +522,9 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
crypto/internal/randutil from crypto/internal/rand
|
crypto/internal/randutil from crypto/internal/rand
|
||||||
crypto/internal/sysrand from crypto/internal/fips140/drbg
|
crypto/internal/sysrand from crypto/internal/fips140/drbg
|
||||||
crypto/md5 from crypto/tls+
|
crypto/md5 from crypto/tls+
|
||||||
crypto/mlkem from golang.org/x/crypto/ssh+
|
crypto/mlkem from crypto/hpke+
|
||||||
crypto/rand from crypto/ed25519+
|
crypto/rand from crypto/ed25519+
|
||||||
crypto/rc4 from crypto/tls+
|
crypto/rc4 from crypto/tls
|
||||||
crypto/rsa from crypto/tls+
|
crypto/rsa from crypto/tls+
|
||||||
crypto/sha1 from crypto/tls+
|
crypto/sha1 from crypto/tls+
|
||||||
crypto/sha256 from crypto/tls+
|
crypto/sha256 from crypto/tls+
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ func TPMAvailable() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HookGetSSHHostKeyPublicStrings is a hook for the ssh/hostkeys package to
|
||||||
|
// provide SSH host key public strings to ipn/ipnlocal without ipnlocal needing
|
||||||
|
// to import golang.org/x/crypto/ssh.
|
||||||
|
var HookGetSSHHostKeyPublicStrings Hook[func(varRoot string, logf logger.Logf) ([]string, error)]
|
||||||
|
|
||||||
// HookHardwareAttestationAvailable is a hook that reports whether hardware
|
// HookHardwareAttestationAvailable is a hook that reports whether hardware
|
||||||
// attestation is supported and available.
|
// attestation is supported and available.
|
||||||
var HookHardwareAttestationAvailable Hook[func() bool]
|
var HookHardwareAttestationAvailable Hook[func() bool]
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9) && !ts_omit_ssh
|
||||||
|
|
||||||
|
// Package ssh registers the Tailscale SSH feature, including host key
|
||||||
|
// management and the SSH server.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
// Register implementations of various SSH hooks.
|
||||||
|
import _ "tailscale.com/ssh/tailssh"
|
||||||
@@ -44,9 +44,6 @@ func init() {
|
|||||||
// several candidate nodes is reachable and actually alive.
|
// several candidate nodes is reachable and actually alive.
|
||||||
RegisterC2N("/echo", handleC2NEcho)
|
RegisterC2N("/echo", handleC2NEcho)
|
||||||
}
|
}
|
||||||
if buildfeatures.HasSSH {
|
|
||||||
RegisterC2N("/ssh/usernames", handleC2NSSHUsernames)
|
|
||||||
}
|
|
||||||
if buildfeatures.HasLogTail {
|
if buildfeatures.HasLogTail {
|
||||||
RegisterC2N("POST /logtail/flush", handleC2NLogtailFlush)
|
RegisterC2N("POST /logtail/flush", handleC2NLogtailFlush)
|
||||||
}
|
}
|
||||||
@@ -290,26 +287,6 @@ func handleC2NPprof(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|||||||
c2nPprof(w, r, profile)
|
c2nPprof(w, r, profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleC2NSSHUsernames(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !buildfeatures.HasSSH {
|
|
||||||
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req tailcfg.C2NSSHUsernamesRequest
|
|
||||||
if r.Method == "POST" {
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res, err := b.getSSHUsernames(&req)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
if b.sockstatLogger == nil {
|
if b.sockstatLogger == nil {
|
||||||
|
|||||||
@@ -5646,10 +5646,12 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
|
|||||||
// TODO(bradfitz): this is called with b.mu held. Not ideal.
|
// TODO(bradfitz): this is called with b.mu held. Not ideal.
|
||||||
// If the filesystem gets wedged or something we could block for
|
// If the filesystem gets wedged or something we could block for
|
||||||
// a long time. But probably fine.
|
// a long time. But probably fine.
|
||||||
var err error
|
if f, ok := feature.HookGetSSHHostKeyPublicStrings.GetOk(); ok {
|
||||||
sshHostKeys, err = b.getSSHHostKeyPublicStrings()
|
var err error
|
||||||
if err != nil {
|
sshHostKeys, err = f(b.TailscaleVarRoot(), b.logf)
|
||||||
b.logf("warning: unable to get SSH host keys, SSH will appear as disabled for this node: %v", err)
|
if err != nil {
|
||||||
|
b.logf("warning: unable to get SSH host keys, SSH will appear as disabled for this node: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hi.SSH_HostKeys = sshHostKeys
|
hi.SSH_HostKeys = sshHostKeys
|
||||||
@@ -6439,9 +6441,9 @@ func (b *LocalBackend) maybeSentHostinfoIfChangedLocked(prefs ipn.PrefsView) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// operatorUserName returns the current pref's OperatorUser's name, or the
|
// OperatorUserName returns the current pref's OperatorUser's name, or the
|
||||||
// empty string if none.
|
// empty string if none.
|
||||||
func (b *LocalBackend) operatorUserName() string {
|
func (b *LocalBackend) OperatorUserName() string {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
prefs := b.pm.CurrentPrefs()
|
prefs := b.pm.CurrentPrefs()
|
||||||
@@ -6454,7 +6456,7 @@ func (b *LocalBackend) operatorUserName() string {
|
|||||||
// OperatorUserID returns the current pref's OperatorUser's ID (in
|
// OperatorUserID returns the current pref's OperatorUser's ID (in
|
||||||
// os/user.User.Uid string form), or the empty string if none.
|
// os/user.User.Uid string form), or the empty string if none.
|
||||||
func (b *LocalBackend) OperatorUserID() string {
|
func (b *LocalBackend) OperatorUserID() string {
|
||||||
opUserName := b.operatorUserName()
|
opUserName := b.OperatorUserName()
|
||||||
if opUserName == "" {
|
if opUserName == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9) && !ts_omit_ssh
|
|
||||||
|
|
||||||
package ipnlocal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"go4.org/mem"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/util/lineiter"
|
|
||||||
"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"}
|
|
||||||
|
|
||||||
// getSSHUsernames discovers and returns the list of usernames that are
|
|
||||||
// potential Tailscale SSH user targets.
|
|
||||||
//
|
|
||||||
// Invariant: must not be called with b.mu held.
|
|
||||||
func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
|
|
||||||
res := new(tailcfg.C2NSSHUsernamesResponse)
|
|
||||||
if !b.tailscaleSSHEnabled() {
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
max := 10
|
|
||||||
if req != nil && req.Max != 0 {
|
|
||||||
max = req.Max
|
|
||||||
}
|
|
||||||
|
|
||||||
add := func(u string) {
|
|
||||||
if req != nil && req.Exclude[u] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch u {
|
|
||||||
case "nobody", "daemon", "sync":
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if slices.Contains(res.Usernames, u) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(res.Usernames) > max {
|
|
||||||
// Enough for a hint.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res.Usernames = append(res.Usernames, u)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opUser := b.operatorUserName(); opUser != "" {
|
|
||||||
add(opUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check popular usernames and see if they exist with a real shell.
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "darwin":
|
|
||||||
out, err := exec.Command("dscl", ".", "list", "/Users").Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for line := range lineiter.Bytes(out) {
|
|
||||||
line = bytes.TrimSpace(line)
|
|
||||||
if len(line) == 0 || line[0] == '_' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
add(string(line))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
for lr := range lineiter.File("/etc/passwd") {
|
|
||||||
line, err := lr.Value()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
line = bytes.TrimSpace(line)
|
|
||||||
if len(line) == 0 || line[0] == '#' || line[0] == '_' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if mem.HasSuffix(mem.B(line), mem.S("/nologin")) ||
|
|
||||||
mem.HasSuffix(mem.B(line), mem.S("/false")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
before, _, ok := bytes.Cut(line, []byte{':'})
|
|
||||||
if ok {
|
|
||||||
add(string(before))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *LocalBackend) GetSSH_HostKeys() (keys []ssh.Signer, err error) {
|
|
||||||
var existing map[string]ssh.Signer
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
existing = b.getSystemSSH_HostKeys()
|
|
||||||
}
|
|
||||||
return b.getTailscaleSSH_HostKeys(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTailscaleSSH_HostKeys returns the three (rsa, ecdsa, ed25519) SSH host
|
|
||||||
// keys, reusing the provided ones in existing if present in the map.
|
|
||||||
func (b *LocalBackend) getTailscaleSSH_HostKeys(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 == "" {
|
|
||||||
root := b.TailscaleVarRoot()
|
|
||||||
if root == "" {
|
|
||||||
return nil, errors.New("no var root for ssh keys")
|
|
||||||
}
|
|
||||||
keyDir = filepath.Join(root, "ssh")
|
|
||||||
if err := os.MkdirAll(keyDir, 0700); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hostKey, err := b.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
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyGenMu sync.Mutex
|
|
||||||
|
|
||||||
func (b *LocalBackend) 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *LocalBackend) getSystemSSH_HostKeys() (ret map[string]ssh.Signer) {
|
|
||||||
for _, typ := range keyTypes {
|
|
||||||
filename := "/etc/ssh/ssh_host_" + typ + "_key"
|
|
||||||
hostKey, err := os.ReadFile(filename)
|
|
||||||
if err != nil || len(bytes.TrimSpace(hostKey)) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
signer, err := ssh.ParsePrivateKey(hostKey)
|
|
||||||
if err != nil {
|
|
||||||
b.logf("warning: error reading host key %s: %v (generating one instead)", filename, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mak.Set(&ret, typ, signer)
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) {
|
|
||||||
signers, err := b.GetSSH_HostKeys()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// tailscaleSSHEnabled reports whether Tailscale SSH is currently enabled based
|
|
||||||
// on prefs. It returns false if there are no prefs set.
|
|
||||||
func (b *LocalBackend) tailscaleSSHEnabled() bool {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
p := b.pm.CurrentPrefs()
|
|
||||||
return p.Valid() && p.RunSSH()
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
//go:build ts_omit_ssh || ios || android || (!linux && !darwin && !freebsd && !openbsd && !plan9)
|
|
||||||
|
|
||||||
package ipnlocal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *LocalBackend) getSSHUsernames(*tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
|
|
||||||
return nil, errors.New("not implemented")
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
//go:build linux || (darwin && !ios)
|
|
||||||
|
|
||||||
package ipnlocal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"tailscale.com/health"
|
|
||||||
"tailscale.com/ipn/store/mem"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/util/eventbus/eventbustest"
|
|
||||||
"tailscale.com/util/must"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSSHKeyGen(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
lb := &LocalBackend{varRoot: dir}
|
|
||||||
keys, err := lb.getTailscaleSSH_HostKeys(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := map[string]bool{}
|
|
||||||
for _, k := range keys {
|
|
||||||
got[k.PublicKey().Type()] = true
|
|
||||||
}
|
|
||||||
want := map[string]bool{
|
|
||||||
"ssh-rsa": true,
|
|
||||||
"ecdsa-sha2-nistp256": true,
|
|
||||||
"ssh-ed25519": true,
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("keys = %v; want %v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
keys2, err := lb.getTailscaleSSH_HostKeys(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(keys, keys2) {
|
|
||||||
t.Errorf("got different keys on second call")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeSSHServer struct {
|
|
||||||
SSHServer
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSSHUsernames(t *testing.T) {
|
|
||||||
pm := must.Get(newProfileManager(new(mem.Store), t.Logf, health.NewTracker(eventbustest.NewBus(t))))
|
|
||||||
b := &LocalBackend{pm: pm, store: pm.Store()}
|
|
||||||
b.sshServer = fakeSSHServer{}
|
|
||||||
res, err := b.getSSHUsernames(new(tailcfg.C2NSSHUsernamesRequest))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Logf("Got: %s", must.Get(json.Marshal(res)))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
|
||||||
|
|
||||||
|
package tailssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"go4.org/mem"
|
||||||
|
"tailscale.com/ipn/ipnlocal"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/util/lineiter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleC2NSSHUsernames(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req tailcfg.C2NSSHUsernamesRequest
|
||||||
|
if r.Method == "POST" {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := getSSHUsernames(b, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSSHUsernames discovers and returns the list of usernames that are
|
||||||
|
// potential Tailscale SSH user targets.
|
||||||
|
func getSSHUsernames(b *ipnlocal.LocalBackend, req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
|
||||||
|
res := new(tailcfg.C2NSSHUsernamesResponse)
|
||||||
|
if b == nil || !b.ShouldRunSSH() {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
max := 10
|
||||||
|
if req != nil && req.Max != 0 {
|
||||||
|
max = req.Max
|
||||||
|
}
|
||||||
|
|
||||||
|
add := func(u string) {
|
||||||
|
if req != nil && req.Exclude[u] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch u {
|
||||||
|
case "nobody", "daemon", "sync":
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if slices.Contains(res.Usernames, u) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(res.Usernames) > max {
|
||||||
|
// Enough for a hint.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.Usernames = append(res.Usernames, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opUser := b.OperatorUserName(); opUser != "" {
|
||||||
|
add(opUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check popular usernames and see if they exist with a real shell.
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
out, err := exec.Command("dscl", ".", "list", "/Users").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for line := range lineiter.Bytes(out) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 || line[0] == '_' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(string(line))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
for lr := range lineiter.File("/etc/passwd") {
|
||||||
|
line, err := lr.Value()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 || line[0] == '#' || line[0] == '_' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mem.HasSuffix(mem.B(line), mem.S("/nologin")) ||
|
||||||
|
mem.HasSuffix(mem.B(line), mem.S("/false")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before, _, ok := bytes.Cut(line, []byte{':'})
|
||||||
|
if ok {
|
||||||
|
add(string(before))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
gossh "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 system keys when running as root
|
||||||
|
// and generating Tailscale-specific keys as needed.
|
||||||
|
func getHostKeys(varRoot string, logf logger.Logf) ([]gossh.Signer, error) {
|
||||||
|
var existing map[string]gossh.Signer
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
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(gossh.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]gossh.Signer) (keys []gossh.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 := gossh.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
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSystemHostKeys(logf logger.Logf) (ret map[string]gossh.Signer) {
|
||||||
|
for _, typ := range keyTypes {
|
||||||
|
filename := "/etc/ssh/ssh_host_" + typ + "_key"
|
||||||
|
hostKey, err := os.ReadFile(filename)
|
||||||
|
if err != nil || len(bytes.TrimSpace(hostKey)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signer, err := gossh.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build linux || (darwin && !ios)
|
||||||
|
|
||||||
|
package tailssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHKeyGen(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keys, err := getTailscaleHostKeys(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, k := range keys {
|
||||||
|
got[k.PublicKey().Type()] = true
|
||||||
|
}
|
||||||
|
want := map[string]bool{
|
||||||
|
"ssh-rsa": true,
|
||||||
|
"ecdsa-sha2-nistp256": true,
|
||||||
|
"ssh-ed25519": true,
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("keys = %v; want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys2, err := getTailscaleHostKeys(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(keys, keys2) {
|
||||||
|
t.Errorf("got different keys on second call")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,6 @@ const (
|
|||||||
// ipnLocalBackend is the subset of ipnlocal.LocalBackend that we use.
|
// ipnLocalBackend is the subset of ipnlocal.LocalBackend that we use.
|
||||||
// It is used for testing.
|
// It is used for testing.
|
||||||
type ipnLocalBackend interface {
|
type ipnLocalBackend interface {
|
||||||
GetSSH_HostKeys() ([]gossh.Signer, error)
|
|
||||||
ShouldRunSSH() bool
|
ShouldRunSSH() bool
|
||||||
NetMap() *netmap.NetworkMap
|
NetMap() *netmap.NetworkMap
|
||||||
WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
||||||
@@ -107,6 +106,8 @@ func (srv *server) now() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
feature.HookGetSSHHostKeyPublicStrings.Set(getHostKeyPublicStrings)
|
||||||
|
ipnlocal.RegisterC2N("/ssh/usernames", handleC2NSSHUsernames)
|
||||||
ipnlocal.RegisterNewSSHServer(func(logf logger.Logf, lb *ipnlocal.LocalBackend) (ipnlocal.SSHServer, error) {
|
ipnlocal.RegisterNewSSHServer(func(logf logger.Logf, lb *ipnlocal.LocalBackend) (ipnlocal.SSHServer, error) {
|
||||||
tsd, err := os.Executable()
|
tsd, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -504,7 +505,7 @@ func (srv *server) newConn() (*conn, error) {
|
|||||||
maps.Copy(ss.RequestHandlers, ssh.DefaultRequestHandlers)
|
maps.Copy(ss.RequestHandlers, ssh.DefaultRequestHandlers)
|
||||||
maps.Copy(ss.ChannelHandlers, ssh.DefaultChannelHandlers)
|
maps.Copy(ss.ChannelHandlers, ssh.DefaultChannelHandlers)
|
||||||
maps.Copy(ss.SubsystemHandlers, ssh.DefaultSubsystemHandlers)
|
maps.Copy(ss.SubsystemHandlers, ssh.DefaultSubsystemHandlers)
|
||||||
keys, err := srv.lb.GetSSH_HostKeys()
|
keys, err := getHostKeys(srv.lb.TailscaleVarRoot(), srv.logf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import (
|
|||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/pkg/sftp"
|
"github.com/pkg/sftp"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
|
||||||
"golang.org/x/crypto/ssh/agent"
|
"golang.org/x/crypto/ssh/agent"
|
||||||
"tailscale.com/net/tsdial"
|
"tailscale.com/net/tsdial"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -631,28 +630,6 @@ type testBackend struct {
|
|||||||
allowSendEnv bool
|
allowSendEnv bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) {
|
|
||||||
var result []gossh.Signer
|
|
||||||
var priv any
|
|
||||||
var err error
|
|
||||||
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
|
|
||||||
}
|
|
||||||
hostKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
|
|
||||||
signer, err := gossh.ParsePrivateKey(hostKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, signer)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tb *testBackend) ShouldRunSSH() bool {
|
func (tb *testBackend) ShouldRunSSH() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-23
@@ -9,7 +9,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -34,7 +33,6 @@ import (
|
|||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
gossh "golang.org/x/crypto/ssh"
|
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
"golang.org/x/net/http2/h2c"
|
"golang.org/x/net/http2/h2c"
|
||||||
"tailscale.com/cmd/testwrapper/flakytest"
|
"tailscale.com/cmd/testwrapper/flakytest"
|
||||||
@@ -381,6 +379,7 @@ func TestEvalSSHPolicy(t *testing.T) {
|
|||||||
type localState struct {
|
type localState struct {
|
||||||
sshEnabled bool
|
sshEnabled bool
|
||||||
matchingRule *tailcfg.SSHRule
|
matchingRule *tailcfg.SSHRule
|
||||||
|
varRoot string // if empty, TailscaleVarRoot returns ""
|
||||||
|
|
||||||
// serverActions is a map of the action name to the action.
|
// serverActions is a map of the action name to the action.
|
||||||
// It is served for paths like https://unused/ssh-action/<action-name>.
|
// It is served for paths like https://unused/ssh-action/<action-name>.
|
||||||
@@ -388,31 +387,12 @@ type localState struct {
|
|||||||
serverActions map[string]*tailcfg.SSHAction
|
serverActions map[string]*tailcfg.SSHAction
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var currentUser = os.Getenv("USER") // Use the current user for the test.
|
||||||
currentUser = os.Getenv("USER") // Use the current user for the test.
|
|
||||||
testSigner gossh.Signer
|
|
||||||
testSignerOnce sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
func (ts *localState) Dialer() *tsdial.Dialer {
|
func (ts *localState) Dialer() *tsdial.Dialer {
|
||||||
return &tsdial.Dialer{}
|
return &tsdial.Dialer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *localState) GetSSH_HostKeys() ([]gossh.Signer, error) {
|
|
||||||
testSignerOnce.Do(func() {
|
|
||||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
s, err := gossh.NewSignerFromSigner(priv)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
testSigner = s
|
|
||||||
})
|
|
||||||
return []gossh.Signer{testSigner}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *localState) ShouldRunSSH() bool {
|
func (ts *localState) ShouldRunSSH() bool {
|
||||||
return ts.sshEnabled
|
return ts.sshEnabled
|
||||||
}
|
}
|
||||||
@@ -468,7 +448,7 @@ func (ts *localState) DoNoiseRequest(req *http.Request) (*http.Response, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ts *localState) TailscaleVarRoot() string {
|
func (ts *localState) TailscaleVarRoot() string {
|
||||||
return ""
|
return ts.varRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *localState) NodeKey() key.NodePublic {
|
func (ts *localState) NodeKey() key.NodePublic {
|
||||||
@@ -505,6 +485,7 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
|
|||||||
logf: tstest.WhileTestRunningLogger(t),
|
logf: tstest.WhileTestRunningLogger(t),
|
||||||
lb: &localState{
|
lb: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: t.TempDir(),
|
||||||
matchingRule: newSSHRule(
|
matchingRule: newSSHRule(
|
||||||
&tailcfg.SSHAction{
|
&tailcfg.SSHAction{
|
||||||
Accept: true,
|
Accept: true,
|
||||||
@@ -633,6 +614,7 @@ func TestMultipleRecorders(t *testing.T) {
|
|||||||
logf: tstest.WhileTestRunningLogger(t),
|
logf: tstest.WhileTestRunningLogger(t),
|
||||||
lb: &localState{
|
lb: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: t.TempDir(),
|
||||||
matchingRule: newSSHRule(
|
matchingRule: newSSHRule(
|
||||||
&tailcfg.SSHAction{
|
&tailcfg.SSHAction{
|
||||||
Accept: true,
|
Accept: true,
|
||||||
@@ -724,6 +706,7 @@ func TestSSHRecordingNonInteractive(t *testing.T) {
|
|||||||
logf: tstest.WhileTestRunningLogger(t),
|
logf: tstest.WhileTestRunningLogger(t),
|
||||||
lb: &localState{
|
lb: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: t.TempDir(),
|
||||||
matchingRule: newSSHRule(
|
matchingRule: newSSHRule(
|
||||||
&tailcfg.SSHAction{
|
&tailcfg.SSHAction{
|
||||||
Accept: true,
|
Accept: true,
|
||||||
@@ -792,6 +775,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||||
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
|
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
varRoot := t.TempDir()
|
||||||
acceptRule := newSSHRule(&tailcfg.SSHAction{
|
acceptRule := newSSHRule(&tailcfg.SSHAction{
|
||||||
Accept: true,
|
Accept: true,
|
||||||
Message: "Welcome to Tailscale SSH!",
|
Message: "Welcome to Tailscale SSH!",
|
||||||
@@ -818,6 +802,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
name: "no-policy",
|
name: "no-policy",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
},
|
},
|
||||||
authErr: true,
|
authErr: true,
|
||||||
wantBanners: []string{"tailscale: tailnet policy does not permit you to SSH to this node\n"},
|
wantBanners: []string{"tailscale: tailnet policy does not permit you to SSH to this node\n"},
|
||||||
@@ -826,6 +811,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
name: "user-mismatch",
|
name: "user-mismatch",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
matchingRule: bobRule,
|
matchingRule: bobRule,
|
||||||
},
|
},
|
||||||
authErr: true,
|
authErr: true,
|
||||||
@@ -835,6 +821,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
name: "accept",
|
name: "accept",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
matchingRule: acceptRule,
|
matchingRule: acceptRule,
|
||||||
},
|
},
|
||||||
wantBanners: []string{"Welcome to Tailscale SSH!"},
|
wantBanners: []string{"Welcome to Tailscale SSH!"},
|
||||||
@@ -843,6 +830,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
name: "reject",
|
name: "reject",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
matchingRule: rejectRule,
|
matchingRule: rejectRule,
|
||||||
},
|
},
|
||||||
wantBanners: []string{"Go Away!"},
|
wantBanners: []string{"Go Away!"},
|
||||||
@@ -852,6 +840,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
name: "simple-check",
|
name: "simple-check",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
matchingRule: newSSHRule(&tailcfg.SSHAction{
|
matchingRule: newSSHRule(&tailcfg.SSHAction{
|
||||||
HoldAndDelegate: "https://unused/ssh-action/accept",
|
HoldAndDelegate: "https://unused/ssh-action/accept",
|
||||||
}),
|
}),
|
||||||
@@ -865,6 +854,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
name: "multi-check",
|
name: "multi-check",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
matchingRule: newSSHRule(&tailcfg.SSHAction{
|
matchingRule: newSSHRule(&tailcfg.SSHAction{
|
||||||
Message: "First",
|
Message: "First",
|
||||||
HoldAndDelegate: "https://unused/ssh-action/check1",
|
HoldAndDelegate: "https://unused/ssh-action/check1",
|
||||||
@@ -883,6 +873,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
name: "check-reject",
|
name: "check-reject",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
matchingRule: newSSHRule(&tailcfg.SSHAction{
|
matchingRule: newSSHRule(&tailcfg.SSHAction{
|
||||||
Message: "First",
|
Message: "First",
|
||||||
HoldAndDelegate: "https://unused/ssh-action/reject",
|
HoldAndDelegate: "https://unused/ssh-action/reject",
|
||||||
@@ -899,6 +890,7 @@ func TestSSHAuthFlow(t *testing.T) {
|
|||||||
sshUser: "alice+password",
|
sshUser: "alice+password",
|
||||||
state: &localState{
|
state: &localState{
|
||||||
sshEnabled: true,
|
sshEnabled: true,
|
||||||
|
varRoot: varRoot,
|
||||||
matchingRule: acceptRule,
|
matchingRule: acceptRule,
|
||||||
},
|
},
|
||||||
usesPassword: true,
|
usesPassword: true,
|
||||||
|
|||||||
+4
-7
@@ -396,8 +396,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
|
|||||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||||
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
||||||
LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
|
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
|
||||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+
|
|
||||||
golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+
|
||||||
golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+
|
||||||
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
|
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
|
||||||
@@ -407,8 +406,6 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
|
|||||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||||
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device
|
||||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||||
LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal
|
|
||||||
LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh
|
|
||||||
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
|
golang.org/x/exp/constraints from tailscale.com/tsweb/varz+
|
||||||
golang.org/x/exp/maps from tailscale.com/ipn/store/mem+
|
golang.org/x/exp/maps from tailscale.com/ipn/store/mem+
|
||||||
golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||||
@@ -469,7 +466,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
|
|||||||
crypto/aes from crypto/tls+
|
crypto/aes from crypto/tls+
|
||||||
crypto/cipher from crypto/aes+
|
crypto/cipher from crypto/aes+
|
||||||
crypto/des from crypto/tls+
|
crypto/des from crypto/tls+
|
||||||
crypto/dsa from crypto/x509+
|
crypto/dsa from crypto/x509
|
||||||
crypto/ecdh from crypto/ecdsa+
|
crypto/ecdh from crypto/ecdsa+
|
||||||
crypto/ecdsa from crypto/tls+
|
crypto/ecdsa from crypto/tls+
|
||||||
crypto/ed25519 from crypto/tls+
|
crypto/ed25519 from crypto/tls+
|
||||||
@@ -518,9 +515,9 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
|
|||||||
crypto/internal/randutil from crypto/internal/rand
|
crypto/internal/randutil from crypto/internal/rand
|
||||||
crypto/internal/sysrand from crypto/internal/fips140/drbg
|
crypto/internal/sysrand from crypto/internal/fips140/drbg
|
||||||
crypto/md5 from crypto/tls+
|
crypto/md5 from crypto/tls+
|
||||||
crypto/mlkem from golang.org/x/crypto/ssh+
|
crypto/mlkem from crypto/hpke+
|
||||||
crypto/rand from crypto/ed25519+
|
crypto/rand from crypto/ed25519+
|
||||||
crypto/rc4 from crypto/tls+
|
crypto/rc4 from crypto/tls
|
||||||
crypto/rsa from crypto/tls+
|
crypto/rsa from crypto/tls+
|
||||||
crypto/sha1 from crypto/tls+
|
crypto/sha1 from crypto/tls+
|
||||||
crypto/sha256 from crypto/tls+
|
crypto/sha256 from crypto/tls+
|
||||||
|
|||||||
@@ -2631,6 +2631,10 @@ func TestDeps(t *testing.T) {
|
|||||||
deptest.DepChecker{
|
deptest.DepChecker{
|
||||||
GOOS: "linux",
|
GOOS: "linux",
|
||||||
GOARCH: "amd64",
|
GOARCH: "amd64",
|
||||||
|
BadDeps: map[string]string{
|
||||||
|
"golang.org/x/crypto/ssh": "tsnet should not depend on SSH",
|
||||||
|
"golang.org/x/crypto/ssh/internal/bcrypt_pbkdf": "tsnet should not depend on SSH",
|
||||||
|
},
|
||||||
OnDep: func(dep string) {
|
OnDep: func(dep string) {
|
||||||
if strings.Contains(dep, "portlist") {
|
if strings.Contains(dep, "portlist") {
|
||||||
t.Errorf("unexpected dep: %q", dep)
|
t.Errorf("unexpected dep: %q", dep)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
_ "tailscale.com/feature"
|
_ "tailscale.com/feature"
|
||||||
_ "tailscale.com/feature/buildfeatures"
|
_ "tailscale.com/feature/buildfeatures"
|
||||||
_ "tailscale.com/feature/condregister"
|
_ "tailscale.com/feature/condregister"
|
||||||
|
_ "tailscale.com/feature/ssh"
|
||||||
_ "tailscale.com/health"
|
_ "tailscale.com/health"
|
||||||
_ "tailscale.com/hostinfo"
|
_ "tailscale.com/hostinfo"
|
||||||
_ "tailscale.com/ipn"
|
_ "tailscale.com/ipn"
|
||||||
@@ -40,7 +41,6 @@ import (
|
|||||||
_ "tailscale.com/net/tstun"
|
_ "tailscale.com/net/tstun"
|
||||||
_ "tailscale.com/paths"
|
_ "tailscale.com/paths"
|
||||||
_ "tailscale.com/safesocket"
|
_ "tailscale.com/safesocket"
|
||||||
_ "tailscale.com/ssh/tailssh"
|
|
||||||
_ "tailscale.com/syncs"
|
_ "tailscale.com/syncs"
|
||||||
_ "tailscale.com/tailcfg"
|
_ "tailscale.com/tailcfg"
|
||||||
_ "tailscale.com/tsd"
|
_ "tailscale.com/tsd"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
_ "tailscale.com/feature"
|
_ "tailscale.com/feature"
|
||||||
_ "tailscale.com/feature/buildfeatures"
|
_ "tailscale.com/feature/buildfeatures"
|
||||||
_ "tailscale.com/feature/condregister"
|
_ "tailscale.com/feature/condregister"
|
||||||
|
_ "tailscale.com/feature/ssh"
|
||||||
_ "tailscale.com/health"
|
_ "tailscale.com/health"
|
||||||
_ "tailscale.com/hostinfo"
|
_ "tailscale.com/hostinfo"
|
||||||
_ "tailscale.com/ipn"
|
_ "tailscale.com/ipn"
|
||||||
@@ -40,7 +41,6 @@ import (
|
|||||||
_ "tailscale.com/net/tstun"
|
_ "tailscale.com/net/tstun"
|
||||||
_ "tailscale.com/paths"
|
_ "tailscale.com/paths"
|
||||||
_ "tailscale.com/safesocket"
|
_ "tailscale.com/safesocket"
|
||||||
_ "tailscale.com/ssh/tailssh"
|
|
||||||
_ "tailscale.com/syncs"
|
_ "tailscale.com/syncs"
|
||||||
_ "tailscale.com/tailcfg"
|
_ "tailscale.com/tailcfg"
|
||||||
_ "tailscale.com/tsd"
|
_ "tailscale.com/tsd"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
_ "tailscale.com/feature"
|
_ "tailscale.com/feature"
|
||||||
_ "tailscale.com/feature/buildfeatures"
|
_ "tailscale.com/feature/buildfeatures"
|
||||||
_ "tailscale.com/feature/condregister"
|
_ "tailscale.com/feature/condregister"
|
||||||
|
_ "tailscale.com/feature/ssh"
|
||||||
_ "tailscale.com/health"
|
_ "tailscale.com/health"
|
||||||
_ "tailscale.com/hostinfo"
|
_ "tailscale.com/hostinfo"
|
||||||
_ "tailscale.com/ipn"
|
_ "tailscale.com/ipn"
|
||||||
@@ -40,7 +41,6 @@ import (
|
|||||||
_ "tailscale.com/net/tstun"
|
_ "tailscale.com/net/tstun"
|
||||||
_ "tailscale.com/paths"
|
_ "tailscale.com/paths"
|
||||||
_ "tailscale.com/safesocket"
|
_ "tailscale.com/safesocket"
|
||||||
_ "tailscale.com/ssh/tailssh"
|
|
||||||
_ "tailscale.com/syncs"
|
_ "tailscale.com/syncs"
|
||||||
_ "tailscale.com/tailcfg"
|
_ "tailscale.com/tailcfg"
|
||||||
_ "tailscale.com/tsd"
|
_ "tailscale.com/tsd"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
_ "tailscale.com/feature"
|
_ "tailscale.com/feature"
|
||||||
_ "tailscale.com/feature/buildfeatures"
|
_ "tailscale.com/feature/buildfeatures"
|
||||||
_ "tailscale.com/feature/condregister"
|
_ "tailscale.com/feature/condregister"
|
||||||
|
_ "tailscale.com/feature/ssh"
|
||||||
_ "tailscale.com/health"
|
_ "tailscale.com/health"
|
||||||
_ "tailscale.com/hostinfo"
|
_ "tailscale.com/hostinfo"
|
||||||
_ "tailscale.com/ipn"
|
_ "tailscale.com/ipn"
|
||||||
@@ -40,7 +41,6 @@ import (
|
|||||||
_ "tailscale.com/net/tstun"
|
_ "tailscale.com/net/tstun"
|
||||||
_ "tailscale.com/paths"
|
_ "tailscale.com/paths"
|
||||||
_ "tailscale.com/safesocket"
|
_ "tailscale.com/safesocket"
|
||||||
_ "tailscale.com/ssh/tailssh"
|
|
||||||
_ "tailscale.com/syncs"
|
_ "tailscale.com/syncs"
|
||||||
_ "tailscale.com/tailcfg"
|
_ "tailscale.com/tailcfg"
|
||||||
_ "tailscale.com/tsd"
|
_ "tailscale.com/tsd"
|
||||||
|
|||||||
Reference in New Issue
Block a user