Add core modules (SSH args parser, cache, resolver, NetBox client) with tests
Release / release (push) Failing after 51s
Release / release (push) Failing after 51s
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package ssh
|
||||
|
||||
import "strings"
|
||||
|
||||
// flagsWithArg lists all SSH flags that consume the following argument.
|
||||
var flagsWithArg = map[byte]bool{
|
||||
'b': true, 'c': true, 'D': true, 'E': true, 'e': true,
|
||||
'F': true, 'I': true, 'i': true, 'J': true, 'L': true,
|
||||
'l': true, 'm': true, 'o': true, 'O': true, 'p': true,
|
||||
'Q': true, 'R': true, 'S': true, 'w': true, 'W': true,
|
||||
}
|
||||
|
||||
// ParsedArgs holds the result of parsing SSH arguments.
|
||||
type ParsedArgs struct {
|
||||
Host string // hostname without the user@ prefix
|
||||
User string // empty if not specified
|
||||
DestIdx int // index in Args where [user@]host sits
|
||||
Args []string
|
||||
}
|
||||
|
||||
// Parse scans SSH arguments and extracts the destination ([user@]host).
|
||||
// Returns nil if no destination is found.
|
||||
func Parse(args []string) *ParsedArgs {
|
||||
i := 0
|
||||
for i < len(args) {
|
||||
arg := args[i]
|
||||
|
||||
// "--" ends option processing
|
||||
if arg == "--" {
|
||||
i++
|
||||
break
|
||||
}
|
||||
|
||||
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
||||
flag := arg[1]
|
||||
if flagsWithArg[flag] {
|
||||
if len(arg) > 2 {
|
||||
// argument is attached, e.g. -p2222
|
||||
i++
|
||||
} else {
|
||||
// argument is the next element, e.g. -p 2222
|
||||
i += 2
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// first non-flag argument is the destination
|
||||
host, user := splitUserHost(arg)
|
||||
return &ParsedArgs{
|
||||
Host: host,
|
||||
User: user,
|
||||
DestIdx: i,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
// handle arguments after "--"
|
||||
if i < len(args) {
|
||||
host, user := splitUserHost(args[i])
|
||||
return &ParsedArgs{
|
||||
Host: host,
|
||||
User: user,
|
||||
DestIdx: i,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceHost returns a copy of args with the destination replaced by newHost,
|
||||
// preserving any user@ prefix.
|
||||
func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
||||
result := make([]string, len(args))
|
||||
copy(result, args)
|
||||
|
||||
original := args[destIdx]
|
||||
if at := strings.Index(original, "@"); at != -1 {
|
||||
result[destIdx] = original[:at+1] + newHost
|
||||
} else {
|
||||
result[destIdx] = newHost
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HasUserFlag reports whether a user was specified via -l in args.
|
||||
// Used to avoid overriding an explicit -l with the configured default user.
|
||||
func HasUserFlag(args []string) bool {
|
||||
for i, a := range args {
|
||||
if a == "-l" && i+1 < len(args) {
|
||||
return true
|
||||
}
|
||||
// handle attached form: -lroot
|
||||
if len(a) > 2 && a[0] == '-' && a[1] == 'l' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitUserHost(dest string) (host, user string) {
|
||||
if at := strings.Index(dest, "@"); at != -1 {
|
||||
return dest[at+1:], dest[:at]
|
||||
}
|
||||
return dest, ""
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse_BareHostname(t *testing.T) {
|
||||
got := Parse([]string{"myhost"})
|
||||
assertParsed(t, got, "myhost", "", 0)
|
||||
}
|
||||
|
||||
func TestParse_UserAtHost(t *testing.T) {
|
||||
got := Parse([]string{"admin@myhost"})
|
||||
assertParsed(t, got, "myhost", "admin", 0)
|
||||
}
|
||||
|
||||
func TestParse_PortFlag_Separated(t *testing.T) {
|
||||
got := Parse([]string{"-p", "2222", "myhost"})
|
||||
assertParsed(t, got, "myhost", "", 2)
|
||||
}
|
||||
|
||||
func TestParse_PortFlag_Attached(t *testing.T) {
|
||||
got := Parse([]string{"-p2222", "myhost"})
|
||||
assertParsed(t, got, "myhost", "", 1)
|
||||
}
|
||||
|
||||
func TestParse_IdentityFlag(t *testing.T) {
|
||||
got := Parse([]string{"-i", "/path/to/key", "user@myhost", "ls"})
|
||||
assertParsed(t, got, "myhost", "user", 2)
|
||||
}
|
||||
|
||||
func TestParse_VerboseFlag(t *testing.T) {
|
||||
got := Parse([]string{"-v", "myhost"})
|
||||
assertParsed(t, got, "myhost", "", 1)
|
||||
}
|
||||
|
||||
func TestParse_OptionFlag(t *testing.T) {
|
||||
got := Parse([]string{"-o", "StrictHostKeyChecking=no", "myhost"})
|
||||
assertParsed(t, got, "myhost", "", 2)
|
||||
}
|
||||
|
||||
func TestParse_JumpHost(t *testing.T) {
|
||||
got := Parse([]string{"-J", "jumphost", "-p", "22", "target"})
|
||||
assertParsed(t, got, "target", "", 4)
|
||||
}
|
||||
|
||||
func TestParse_MultipleFlags(t *testing.T) {
|
||||
got := Parse([]string{"-v", "-p", "22", "-i", "key", "root@host", "uptime"})
|
||||
assertParsed(t, got, "host", "root", 5)
|
||||
}
|
||||
|
||||
func TestParse_DoubleDash(t *testing.T) {
|
||||
got := Parse([]string{"--", "myhost"})
|
||||
assertParsed(t, got, "myhost", "", 1)
|
||||
}
|
||||
|
||||
func TestParse_DoubleDash_WithFlags(t *testing.T) {
|
||||
// flags after -- should be treated as destination
|
||||
got := Parse([]string{"-v", "--", "-not-a-flag"})
|
||||
assertParsed(t, got, "-not-a-flag", "", 2)
|
||||
}
|
||||
|
||||
func TestParse_NoDestination(t *testing.T) {
|
||||
got := Parse([]string{"-v", "-p", "2222"})
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for args without destination, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EmptyArgs(t *testing.T) {
|
||||
got := Parse([]string{})
|
||||
if got != nil {
|
||||
t.Error("empty args should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_OnlyDoubleDash(t *testing.T) {
|
||||
got := Parse([]string{"--"})
|
||||
if got != nil {
|
||||
t.Error("only -- with no destination should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHost_PlainHost(t *testing.T) {
|
||||
args := []string{"myhost"}
|
||||
result := ReplaceHost(args, 0, "10.0.0.1")
|
||||
if result[0] != "10.0.0.1" {
|
||||
t.Errorf("got %q, want %q", result[0], "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHost_PreservesUserPrefix(t *testing.T) {
|
||||
args := []string{"-p", "22", "admin@myhost", "ls"}
|
||||
result := ReplaceHost(args, 2, "10.0.0.1")
|
||||
if result[2] != "admin@10.0.0.1" {
|
||||
t.Errorf("got %q, want %q", result[2], "admin@10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHost_DoesNotMutateOriginal(t *testing.T) {
|
||||
args := []string{"myhost"}
|
||||
_ = ReplaceHost(args, 0, "10.0.0.1")
|
||||
if args[0] != "myhost" {
|
||||
t.Error("ReplaceHost must not mutate the original slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHost_OtherArgsUnchanged(t *testing.T) {
|
||||
args := []string{"-p", "22", "myhost"}
|
||||
result := ReplaceHost(args, 2, "10.0.0.1")
|
||||
if result[0] != "-p" || result[1] != "22" {
|
||||
t.Errorf("other args should be unchanged: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUserFlag_FlagSeparated(t *testing.T) {
|
||||
if !HasUserFlag([]string{"-l", "admin", "host"}) {
|
||||
t.Error("should detect -l <user>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUserFlag_FlagAttached(t *testing.T) {
|
||||
if !HasUserFlag([]string{"-ladmin", "host"}) {
|
||||
t.Error("should detect -l<user> (attached form)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUserFlag_NotPresent(t *testing.T) {
|
||||
if HasUserFlag([]string{"-p", "22", "host"}) {
|
||||
t.Error("should not detect user flag when absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUserFlag_EmptyArgs(t *testing.T) {
|
||||
if HasUserFlag([]string{}) {
|
||||
t.Error("empty args should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUserFlag_LFlagAtEnd(t *testing.T) {
|
||||
// -l at the very end with no value — should not panic
|
||||
if HasUserFlag([]string{"-l"}) {
|
||||
t.Error("-l with no value should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatal("Parse returned nil")
|
||||
}
|
||||
if got.Host != host {
|
||||
t.Errorf("host: got %q, want %q", got.Host, host)
|
||||
}
|
||||
if got.User != user {
|
||||
t.Errorf("user: got %q, want %q", got.User, user)
|
||||
}
|
||||
if got.DestIdx != destIdx {
|
||||
t.Errorf("destIdx: got %d, want %d", got.DestIdx, destIdx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Exec replaces the current process with the native ssh client via syscall.Exec.
|
||||
// All existing SSH configs, keys, and agent forwarding remain intact.
|
||||
func Exec(args []string) error {
|
||||
sshPath, err := exec.LookPath("ssh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh not found in PATH: %w", err)
|
||||
}
|
||||
argv := append([]string{"ssh"}, args...)
|
||||
return syscall.Exec(sshPath, argv, os.Environ())
|
||||
}
|
||||
Reference in New Issue
Block a user