mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 18:56:52 +01:00
smoke-extra / freebsd-amd64 (push) Failing after 14s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 13s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 15s
smoke-extra / linux-386 (push) Failing after 14s
smoke / Run multi node smoke test (push) Failing after 1m46s
Build and test / Static checks (push) Successful in 2m38s
Build and test / Test linux (push) Failing after 1m12s
Build and test / Test linux-pkcs11 (push) Failing after 2m6s
Build and test / Test linux-boringcrypto (push) Failing after 3m0s
Build and test / Test linux-fips140 (push) Failing after 2m56s
Build and test / Cross-build linux-arm (push) Successful in 3m10s
Build and test / Cross-build linux-mips (push) Successful in 3m55s
Build and test / Cross-build linux-other (push) Successful in 3m15s
Build and test / Cross-build windows (push) Successful in 1m5s
Build and test / Cross-build freebsd (push) Successful in 1m37s
Build and test / Cross-build netbsd (push) Successful in 1m36s
Build and test / Cross-build openbsd (push) Successful in 1m35s
Build and test / Cross-build mobile (push) Successful in 3m32s
smoke-extra / Run windows smoke test (push) Canceled after 0s
smoke / Run self traffic smoke test on macOS (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s
Report ssh command failures to the surface (#1959) The ssh debug commands told the user when something went wrong but never told the surface that ran them, so `ssh host cmd` always exited 0 and a few failures were not reported at all. A failed command now returns its error, a failure the user has already heard about is a commands.Failure, and the ssh exec path exits 1 for either. The command bodies also lose their ssh prefixes and the duplication they carried over from ssh.go; both goldens pin that nothing else changed. - Dispatch returns the callback's error, or shlex's; the ssh session logs it at debug, and not at all for a Failure the user already saw - commands.Fail writes a message and returns it as a Failure in one step; an unknown command, `help` for a command that is not there, a failed reload and an unbalanced quote are failures - ssh exec exit-status is 1 for a command, flag or failure error, 0 otherwise; the e2e golden records the exit status for every case - Clone rebinds help to the copy, so an ssh session's help lists logout and `help logout` and `logout -h` answer; sshd.LogoutCommand is the session's logout, exported for tests - "did not understand" names the first word only, the one looked up; DispatchArgs takes just the tokens - log-format writes its refusal to the user, as log-level does - print-relays emits the relay error as a string and omits the key when there is none, where it printed {} or "Error":null - list-hostmap and list-lighthouse-addrmap -json return the encode error; print-cert reports a certificate that will not marshal - a flags type that is not the one registered is an internal error, not a silent success - command functions and flag types are <name>Command and <name>Flags; parseVpnAddr and listHostMapFlagSet replace the repeated parse block and flag set; close-tunnel sends through sendCloseTunnel Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
282 lines
9.2 KiB
Go
282 lines
9.2 KiB
Go
package nebula
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/slackhq/nebula/commands"
|
|
"github.com/slackhq/nebula/config"
|
|
"github.com/slackhq/nebula/sshd"
|
|
"github.com/slackhq/nebula/test"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
type sshdReloadHarness struct {
|
|
c *config.C
|
|
reg *commands.Registry
|
|
server *sshd.SSHServer
|
|
keyFile string
|
|
signer ssh.Signer
|
|
listen string
|
|
users []string
|
|
extra string
|
|
}
|
|
|
|
func newSSHDReloadHarness(t *testing.T) *sshdReloadHarness {
|
|
t.Helper()
|
|
l := test.NewLogger()
|
|
h := &sshdReloadHarness{keyFile: filepath.Join(t.TempDir(), "host_key"), listen: "127.0.0.1:0", users: []string{"test"}}
|
|
h.writeHostKey(t, newEd25519Key(t))
|
|
|
|
_, userKey, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
h.signer, err = ssh.NewSignerFromKey(userKey)
|
|
require.NoError(t, err)
|
|
|
|
h.c = config.NewC(l)
|
|
require.NoError(t, h.c.LoadString(h.config()))
|
|
h.reg = commands.New()
|
|
h.server, err = sshd.NewSSHServer(t.Context(), l, h.reg)
|
|
require.NoError(t, err)
|
|
wireSSHReload(l, h.server, h.c)
|
|
run, err := configSSH(l, h.server, h.c, nil)
|
|
require.NoError(t, err)
|
|
go run()
|
|
t.Cleanup(h.server.Stop)
|
|
require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
|
|
return h
|
|
}
|
|
|
|
func newEd25519Key(t *testing.T) crypto.Signer {
|
|
_, key, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
return key
|
|
}
|
|
|
|
// writeHostKey replaces the host key file and returns the public key a client should now see
|
|
func (h *sshdReloadHarness) writeHostKey(t *testing.T, key crypto.Signer) ssh.PublicKey {
|
|
t.Helper()
|
|
block, err := ssh.MarshalPrivateKey(key, "")
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(h.keyFile, pem.EncodeToMemory(block), 0o600))
|
|
pub, err := ssh.NewPublicKey(key.Public())
|
|
require.NoError(t, err)
|
|
return pub
|
|
}
|
|
|
|
func (h *sshdReloadHarness) config() string {
|
|
users := ""
|
|
for _, u := range h.users {
|
|
users += fmt.Sprintf(" - user: %s\n keys: [%q]\n", u, string(ssh.MarshalAuthorizedKey(h.signer.PublicKey())))
|
|
}
|
|
return fmt.Sprintf("sshd:\n enabled: true\n listen: %s\n host_key: %s\n authorized_users:\n%s%s",
|
|
h.listen, h.keyFile, users, h.extra)
|
|
}
|
|
|
|
// dial connects to wherever the server listens now, a restart on :0 moves it. hostKeyAlgorithms limits which host keys
|
|
// the client accepts, none means any
|
|
func (h *sshdReloadHarness) dial(hostKeyAlgorithms ...string) (*ssh.Client, ssh.PublicKey, error) {
|
|
addr := h.server.Addr()
|
|
if addr == nil {
|
|
return nil, nil, fmt.Errorf("not listening")
|
|
}
|
|
var hostKey ssh.PublicKey
|
|
c, err := ssh.Dial("tcp", addr.String(), &ssh.ClientConfig{
|
|
User: "test",
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(h.signer)},
|
|
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
|
hostKey = key
|
|
return nil
|
|
},
|
|
HostKeyAlgorithms: hostKeyAlgorithms,
|
|
Timeout: time.Second,
|
|
})
|
|
return c, hostKey, err
|
|
}
|
|
|
|
func (h *sshdReloadHarness) connect(t *testing.T) (*ssh.Client, ssh.PublicKey) {
|
|
t.Helper()
|
|
var client *ssh.Client
|
|
var hostKey ssh.PublicKey
|
|
require.Eventually(t, func() bool {
|
|
var err error
|
|
client, hostKey, err = h.dial()
|
|
return err == nil
|
|
}, 5*time.Second, 20*time.Millisecond)
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
return client, hostKey
|
|
}
|
|
|
|
// dropped is true when the server closed the client's connection within wait
|
|
func dropped(client *ssh.Client, wait time.Duration) bool {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = client.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
return true
|
|
case <-time.After(wait):
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Restarting sshd drops every open session, the one that asked for the reload included. A reload that touches nothing
|
|
// sshd reads leaves it alone
|
|
func TestSSHDReload_UnchangedKeepsSessions(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
client, _ := h.connect(t)
|
|
|
|
require.NoError(t, h.c.ReloadConfigString(h.config()+"punchy:\n punch: true\n").Err)
|
|
assert.False(t, dropped(client, 500*time.Millisecond), "an unchanged sshd was restarted")
|
|
}
|
|
|
|
// Every key the server reads restarts it when it changes
|
|
func TestSSHDReload_ChangedConfigRestarts(t *testing.T) {
|
|
cases := map[string]func(t *testing.T, h *sshdReloadHarness){
|
|
"listen": func(t *testing.T, h *sshdReloadHarness) { h.listen = "localhost:0" },
|
|
"authorized_users": func(t *testing.T, h *sshdReloadHarness) { h.users = append(h.users, "other") },
|
|
"trusted_cas": func(t *testing.T, h *sshdReloadHarness) { h.extra = " trusted_cas: []\n" },
|
|
"host_key": func(t *testing.T, h *sshdReloadHarness) {
|
|
h.keyFile = filepath.Join(t.TempDir(), "other_host_key")
|
|
h.writeHostKey(t, newEd25519Key(t))
|
|
},
|
|
}
|
|
for name, change := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
client, _ := h.connect(t)
|
|
|
|
change(t, h)
|
|
require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
|
|
assert.True(t, dropped(client, 5*time.Second), "a changed sshd config was not applied")
|
|
require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
|
|
h.connect(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
// The config can stay the same while the host key file it names changes. New connections get the new key, open
|
|
// sessions are left alone
|
|
func TestSSHDReload_ChangedHostKeyFile(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
client, _ := h.connect(t)
|
|
|
|
want := h.writeHostKey(t, newEd25519Key(t))
|
|
require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
|
|
_, got := h.connect(t)
|
|
assert.Equal(t, want.Marshal(), got.Marshal(), "a new host key was not applied")
|
|
assert.False(t, dropped(client, 500*time.Millisecond), "a new host key restarted sshd")
|
|
}
|
|
|
|
// A host key of another algorithm replaces the old one rather than being offered next to it
|
|
func TestSSHDReload_HostKeyAlgorithmChange(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
h.connect(t)
|
|
|
|
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
want := h.writeHostKey(t, rsaKey)
|
|
require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
|
|
|
|
_, got := h.connect(t)
|
|
assert.Equal(t, want.Marshal(), got.Marshal())
|
|
_, _, err = h.dial(ssh.KeyAlgoED25519)
|
|
assert.Error(t, err, "the old ed25519 host key is still offered")
|
|
}
|
|
|
|
// A server that isn't running, say its port was busy, is tried again on every reload as before
|
|
func TestSSHDReload_NotRunningRestarts(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
h.server.Stop()
|
|
require.Eventually(t, func() bool { return !h.server.Running() }, 5*time.Second, 10*time.Millisecond)
|
|
|
|
require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
|
|
require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
|
|
h.connect(t)
|
|
}
|
|
|
|
// A host key that no longer loads stops sshd, the same as a config that doesn't
|
|
func TestSSHDReload_BadHostKeyFileStops(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
h.connect(t)
|
|
|
|
require.NoError(t, os.WriteFile(h.keyFile, []byte("not a key"), 0o600))
|
|
assert.True(t, h.c.ReloadConfigString(h.config()).Failed)
|
|
require.Eventually(t, func() bool { return !h.server.Running() }, 5*time.Second, 10*time.Millisecond)
|
|
}
|
|
|
|
// A trusted CA or authorized user that is skipped with a warning fails the reload, the valid ones are still used
|
|
func TestSSHDReload_SkippedEntriesFail(t *testing.T) {
|
|
cases := map[string]string{
|
|
"bad trusted ca": " trusted_cas: [nope]\n",
|
|
"user entry not a map": " - nope\n",
|
|
"user missing": " - keys: [nope]\n",
|
|
"bad key": " - user: other\n keys: nope\n",
|
|
"key in a list not a string": " - user: other\n keys: [5]\n",
|
|
"bad key in a list": " - user: other\n keys: [nope]\n",
|
|
"keys missing": " - user: other\n",
|
|
}
|
|
for name, extra := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
h.extra = extra
|
|
res := h.c.ReloadConfigString(h.config())
|
|
require.NoError(t, res.Err)
|
|
assert.True(t, res.Failed)
|
|
require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
|
|
h.connect(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSSHExecExitStatusReflectsTheCommand: an exec request exits 1 when the command
|
|
// returned an error and 0 otherwise, with the command's output either way.
|
|
func TestSSHExecExitStatusReflectsTheCommand(t *testing.T) {
|
|
h := newSSHDReloadHarness(t)
|
|
h.reg.Register(&commands.Command{
|
|
Name: "fails",
|
|
Callback: func(fs any, a []string, w commands.StringWriter) error {
|
|
_ = w.WriteLine("nope")
|
|
return errors.New("failed")
|
|
},
|
|
})
|
|
client, _ := h.connect(t)
|
|
|
|
sess, err := client.NewSession()
|
|
require.NoError(t, err)
|
|
out, err := sess.CombinedOutput("fails")
|
|
var exit *ssh.ExitError
|
|
require.ErrorAs(t, err, &exit)
|
|
assert.Equal(t, 1, exit.ExitStatus())
|
|
assert.Equal(t, "nope\n", string(out))
|
|
|
|
sess, err = client.NewSession()
|
|
require.NoError(t, err)
|
|
out, err = sess.CombinedOutput("help fails")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "fails - \n", string(out))
|
|
|
|
// A word that is not a command is a failure too, with the command list as before.
|
|
sess, err = client.NewSession()
|
|
require.NoError(t, err)
|
|
out, err = sess.CombinedOutput("nope")
|
|
require.ErrorAs(t, err, &exit)
|
|
assert.Equal(t, 1, exit.ExitStatus())
|
|
assert.True(t, strings.HasPrefix(string(out), "did not understand: nope\nAvailable commands:\n"), string(out))
|
|
}
|