mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-20 23:44:59 +02:00
Move the kittens Go code into the kittens folder
This commit is contained in:
107
kittens/ssh/askpass.go
Normal file
107
kittens/ssh/askpass.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/utils/shm"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func fatal(err error) {
|
||||
cli.ShowError(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func trigger_ask(name string) {
|
||||
term, err := tty.OpenControllingTerm()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer term.Close()
|
||||
_, err = term.WriteString("\x1bP@kitty-ask|" + name + "\x1b\\")
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func RunSSHAskpass() {
|
||||
msg := os.Args[len(os.Args)-1]
|
||||
prompt := os.Getenv("SSH_ASKPASS_PROMPT")
|
||||
is_confirm := prompt == "confirm"
|
||||
q_type := "get_line"
|
||||
if is_confirm {
|
||||
q_type = "confirm"
|
||||
}
|
||||
is_fingerprint_check := strings.Contains(msg, "(yes/no/[fingerprint])")
|
||||
q := map[string]any{
|
||||
"message": msg,
|
||||
"type": q_type,
|
||||
"is_password": !is_fingerprint_check,
|
||||
}
|
||||
data, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
data_shm, err := shm.CreateTemp("askpass-*", uint64(len(data)+32))
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to create SHM file with error: %w", err))
|
||||
}
|
||||
defer data_shm.Close()
|
||||
defer data_shm.Unlink()
|
||||
|
||||
data_shm.Slice()[0] = 0
|
||||
shm.WriteWithSize(data_shm, data, 1)
|
||||
err = data_shm.Flush()
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to flush SHM file with error: %w", err))
|
||||
}
|
||||
trigger_ask(data_shm.Name())
|
||||
for {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if data_shm.Slice()[0] == 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
data, err = shm.ReadWithSize(data_shm, 1)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to read from SHM file with error: %w", err))
|
||||
}
|
||||
response := ""
|
||||
if is_confirm {
|
||||
var ok bool
|
||||
err = json.Unmarshal(data, &ok)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to parse response data: %#v with error: %w", string(data), err))
|
||||
}
|
||||
response = "no"
|
||||
if ok {
|
||||
response = "yes"
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(data, &response)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to parse response data: %#v with error: %w", string(data), err))
|
||||
}
|
||||
if is_fingerprint_check {
|
||||
response = strings.ToLower(response)
|
||||
if response == "y" {
|
||||
response = "yes"
|
||||
} else if response == "n" {
|
||||
response = "no"
|
||||
}
|
||||
}
|
||||
}
|
||||
if response != "" {
|
||||
fmt.Println(response)
|
||||
}
|
||||
}
|
||||
399
kittens/ssh/config.go
Normal file
399
kittens/ssh/config.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/paths"
|
||||
"kitty/tools/utils/shlex"
|
||||
|
||||
"github.com/bmatcuk/doublestar"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type EnvInstruction struct {
|
||||
key, val string
|
||||
delete_on_remote, copy_from_local, literal_quote bool
|
||||
}
|
||||
|
||||
func quote_for_sh(val string, literal_quote bool) string {
|
||||
if literal_quote {
|
||||
return utils.QuoteStringForSH(val)
|
||||
}
|
||||
// See https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
|
||||
b := strings.Builder{}
|
||||
b.Grow(len(val) + 16)
|
||||
b.WriteRune('"')
|
||||
runes := []rune(val)
|
||||
for i, ch := range runes {
|
||||
if ch == '\\' || ch == '`' || ch == '"' || (ch == '$' && i+1 < len(runes) && runes[i+1] == '(') {
|
||||
// special chars are escaped
|
||||
// $( is escaped to prevent execution
|
||||
b.WriteRune('\\')
|
||||
}
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
b.WriteRune('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (self *EnvInstruction) Serialize(for_python bool, get_local_env func(string) (string, bool)) string {
|
||||
var unset func() string
|
||||
var export func(string) string
|
||||
if for_python {
|
||||
dumps := func(x ...any) string {
|
||||
ans, _ := json.Marshal(x)
|
||||
return utils.UnsafeBytesToString(ans)
|
||||
}
|
||||
export = func(val string) string {
|
||||
if val == "" {
|
||||
return fmt.Sprintf("export %s", dumps(self.key))
|
||||
}
|
||||
return fmt.Sprintf("export %s", dumps(self.key, val, self.literal_quote))
|
||||
}
|
||||
unset = func() string {
|
||||
return fmt.Sprintf("unset %s", dumps(self.key))
|
||||
}
|
||||
} else {
|
||||
kq := utils.QuoteStringForSH(self.key)
|
||||
unset = func() string {
|
||||
return fmt.Sprintf("unset %s", kq)
|
||||
}
|
||||
export = func(val string) string {
|
||||
return fmt.Sprintf("export %s=%s", kq, quote_for_sh(val, self.literal_quote))
|
||||
}
|
||||
}
|
||||
if self.delete_on_remote {
|
||||
return unset()
|
||||
}
|
||||
if self.copy_from_local {
|
||||
val, found := get_local_env(self.key)
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
return export(val)
|
||||
}
|
||||
return export(self.val)
|
||||
}
|
||||
|
||||
func final_env_instructions(for_python bool, get_local_env func(string) (string, bool), env ...*EnvInstruction) string {
|
||||
seen := make(map[string]int, len(env))
|
||||
ans := make([]string, 0, len(env))
|
||||
for _, ei := range env {
|
||||
q := ei.Serialize(for_python, get_local_env)
|
||||
if q != "" {
|
||||
if pos, found := seen[ei.key]; found {
|
||||
ans[pos] = q
|
||||
} else {
|
||||
seen[ei.key] = len(ans)
|
||||
ans = append(ans, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(ans, "\n")
|
||||
}
|
||||
|
||||
type CopyInstruction struct {
|
||||
local_path, arcname string
|
||||
exclude_patterns []string
|
||||
}
|
||||
|
||||
func ParseEnvInstruction(spec string) (ans []*EnvInstruction, err error) {
|
||||
const COPY_FROM_LOCAL string = "_kitty_copy_env_var_"
|
||||
ei := &EnvInstruction{}
|
||||
found := false
|
||||
ei.key, ei.val, found = strings.Cut(spec, "=")
|
||||
ei.key = strings.TrimSpace(ei.key)
|
||||
if found {
|
||||
ei.val = strings.TrimSpace(ei.val)
|
||||
if ei.val == COPY_FROM_LOCAL {
|
||||
ei.val = ""
|
||||
ei.copy_from_local = true
|
||||
}
|
||||
} else {
|
||||
ei.delete_on_remote = true
|
||||
}
|
||||
if ei.key == "" {
|
||||
err = fmt.Errorf("The env directive must not be empty")
|
||||
}
|
||||
ans = []*EnvInstruction{ei}
|
||||
return
|
||||
}
|
||||
|
||||
var paths_ctx *paths.Ctx
|
||||
|
||||
func resolve_file_spec(spec string, is_glob bool) ([]string, error) {
|
||||
if paths_ctx == nil {
|
||||
paths_ctx = &paths.Ctx{}
|
||||
}
|
||||
ans := os.ExpandEnv(paths_ctx.ExpandHome(spec))
|
||||
if !filepath.IsAbs(ans) {
|
||||
ans = paths_ctx.AbspathFromHome(ans)
|
||||
}
|
||||
if is_glob {
|
||||
files, err := doublestar.Glob(ans)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s is not a valid glob pattern with error: %w", spec, err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("%s matches no files", spec)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
err := unix.Access(ans, unix.R_OK)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%s does not exist", spec)
|
||||
}
|
||||
return nil, fmt.Errorf("Cannot read from: %s with error: %w", spec, err)
|
||||
}
|
||||
return []string{ans}, nil
|
||||
}
|
||||
|
||||
func get_arcname(loc, dest, home string) (arcname string) {
|
||||
if dest != "" {
|
||||
arcname = dest
|
||||
} else {
|
||||
arcname = filepath.Clean(loc)
|
||||
if filepath.HasPrefix(arcname, home) {
|
||||
ra, err := filepath.Rel(home, arcname)
|
||||
if err == nil {
|
||||
arcname = ra
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix := "home/"
|
||||
if strings.HasPrefix(arcname, "/") {
|
||||
prefix = "root"
|
||||
}
|
||||
return prefix + arcname
|
||||
}
|
||||
|
||||
func ParseCopyInstruction(spec string) (ans []*CopyInstruction, err error) {
|
||||
args, err := shlex.Split("copy " + spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts, args, err := parse_copy_args(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
locs, err := resolve_file_spec(arg, opts.Glob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations = append(locations, locs...)
|
||||
}
|
||||
if len(locations) == 0 {
|
||||
return nil, fmt.Errorf("No files to copy specified")
|
||||
}
|
||||
if len(locations) > 1 && opts.Dest != "" {
|
||||
return nil, fmt.Errorf("Specifying a remote location with more than one file is not supported")
|
||||
}
|
||||
home := paths_ctx.HomePath()
|
||||
ans = make([]*CopyInstruction, 0, len(locations))
|
||||
for _, loc := range locations {
|
||||
ci := CopyInstruction{local_path: loc, exclude_patterns: opts.Exclude}
|
||||
if opts.SymlinkStrategy != "preserve" {
|
||||
ci.local_path, err = filepath.EvalSymlinks(loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to resolve symlinks in %#v with error: %w", loc, err)
|
||||
}
|
||||
}
|
||||
if opts.SymlinkStrategy == "resolve" {
|
||||
ci.arcname = get_arcname(ci.local_path, opts.Dest, home)
|
||||
} else {
|
||||
ci.arcname = get_arcname(loc, opts.Dest, home)
|
||||
}
|
||||
ans = append(ans, &ci)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type file_unique_id struct {
|
||||
dev, inode uint64
|
||||
}
|
||||
|
||||
func excluded(pattern, path string) bool {
|
||||
if !strings.ContainsRune(pattern, '/') {
|
||||
path = filepath.Base(path)
|
||||
}
|
||||
if matched, err := doublestar.PathMatch(pattern, path); matched && err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func get_file_data(callback func(h *tar.Header, data []byte) error, seen map[file_unique_id]string, local_path, arcname string, exclude_patterns []string) error {
|
||||
s, err := os.Lstat(local_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, ok := s.Sys().(unix.Stat_t)
|
||||
cb := func(h *tar.Header, data []byte, arcname string) error {
|
||||
h.Name = arcname
|
||||
if h.Typeflag == tar.TypeDir {
|
||||
h.Name = strings.TrimRight(h.Name, "/") + "/"
|
||||
}
|
||||
h.Size = int64(len(data))
|
||||
h.Mode = int64(s.Mode().Perm())
|
||||
h.ModTime = s.ModTime()
|
||||
h.Format = tar.FormatPAX
|
||||
if ok {
|
||||
h.AccessTime = time.Unix(0, u.Atim.Nano())
|
||||
h.ChangeTime = time.Unix(0, u.Ctim.Nano())
|
||||
}
|
||||
return callback(h, data)
|
||||
}
|
||||
// we only copy regular files, directories and symlinks
|
||||
switch s.Mode().Type() {
|
||||
case fs.ModeSymlink:
|
||||
target, err := os.Readlink(local_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cb(&tar.Header{
|
||||
Typeflag: tar.TypeSymlink,
|
||||
Linkname: target,
|
||||
}, nil, arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case fs.ModeDir:
|
||||
local_path = filepath.Clean(local_path)
|
||||
type entry struct {
|
||||
path, arcname string
|
||||
}
|
||||
stack := []entry{{local_path, arcname}}
|
||||
for len(stack) > 0 {
|
||||
x := stack[0]
|
||||
stack = stack[1:]
|
||||
entries, err := os.ReadDir(x.path)
|
||||
if err != nil {
|
||||
if x.path == local_path {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
err = cb(&tar.Header{Typeflag: tar.TypeDir}, nil, x.arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
entry_path := filepath.Join(x.path, e.Name())
|
||||
aname := path.Join(x.arcname, e.Name())
|
||||
ok := true
|
||||
for _, pat := range exclude_patterns {
|
||||
if excluded(pat, entry_path) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if e.IsDir() {
|
||||
stack = append(stack, entry{entry_path, aname})
|
||||
} else {
|
||||
err = get_file_data(callback, seen, entry_path, aname, exclude_patterns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case 0: // Regular file
|
||||
fid := file_unique_id{dev: uint64(u.Dev), inode: uint64(u.Ino)}
|
||||
if prev, ok := seen[fid]; ok { // Hard link
|
||||
err = cb(&tar.Header{Typeflag: tar.TypeLink, Linkname: prev}, nil, arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seen[fid] = arcname
|
||||
data, err := os.ReadFile(local_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cb(&tar.Header{Typeflag: tar.TypeReg}, data, arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ci *CopyInstruction) get_file_data(callback func(h *tar.Header, data []byte) error, seen map[file_unique_id]string) (err error) {
|
||||
ep := ci.exclude_patterns
|
||||
for _, folder_name := range []string{"__pycache__", ".DS_Store"} {
|
||||
ep = append(ep, "**/"+folder_name, "**/"+folder_name+"/**")
|
||||
}
|
||||
return get_file_data(callback, seen, ci.local_path, ci.arcname, ep)
|
||||
}
|
||||
|
||||
type ConfigSet struct {
|
||||
all_configs []*Config
|
||||
}
|
||||
|
||||
func config_for_hostname(hostname_to_match, username_to_match string, cs *ConfigSet) *Config {
|
||||
matcher := func(q *Config) bool {
|
||||
for _, pat := range strings.Split(q.Hostname, " ") {
|
||||
upat := "*"
|
||||
if strings.Contains(pat, "@") {
|
||||
upat, pat, _ = strings.Cut(pat, "@")
|
||||
}
|
||||
var host_matched, user_matched bool
|
||||
if matched, err := filepath.Match(pat, hostname_to_match); matched && err == nil {
|
||||
host_matched = true
|
||||
}
|
||||
if matched, err := filepath.Match(upat, username_to_match); matched && err == nil {
|
||||
user_matched = true
|
||||
}
|
||||
if host_matched && user_matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, c := range utils.Reversed(cs.all_configs) {
|
||||
if matcher(c) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return cs.all_configs[0]
|
||||
}
|
||||
|
||||
func (self *ConfigSet) line_handler(key, val string) error {
|
||||
c := self.all_configs[len(self.all_configs)-1]
|
||||
if key == "hostname" {
|
||||
c = NewConfig()
|
||||
self.all_configs = append(self.all_configs, c)
|
||||
}
|
||||
return c.Parse(key, val)
|
||||
}
|
||||
|
||||
func load_config(hostname_to_match string, username_to_match string, overrides []string, paths ...string) (*Config, []config.ConfigLine, error) {
|
||||
ans := &ConfigSet{all_configs: []*Config{NewConfig()}}
|
||||
p := config.ConfigParser{LineHandler: ans.line_handler}
|
||||
err := p.LoadConfig("ssh.conf", paths, overrides)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return config_for_hostname(hostname_to_match, username_to_match, ans), p.BadLines(), nil
|
||||
}
|
||||
110
kittens/ssh/config_test.go
Normal file
110
kittens/ssh/config_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"kitty/tools/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestSSHConfigParsing(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
hostname := "unmatched"
|
||||
username := ""
|
||||
conf := ""
|
||||
for_python := false
|
||||
cf := filepath.Join(tdir, "ssh.conf")
|
||||
rt := func(expected_env ...string) {
|
||||
os.WriteFile(cf, []byte(conf), 0o600)
|
||||
c, bad_lines, err := load_config(hostname, username, nil, cf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bad_lines) != 0 {
|
||||
t.Fatalf("Bad config line: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err)
|
||||
}
|
||||
actual := final_env_instructions(for_python, func(key string) (string, bool) {
|
||||
if key == "LOCAL_ENV" {
|
||||
return "LOCAL_VAL", true
|
||||
}
|
||||
return "", false
|
||||
}, c.Env...)
|
||||
if expected_env == nil {
|
||||
expected_env = []string{}
|
||||
}
|
||||
diff := cmp.Diff(expected_env, utils.Splitlines(actual))
|
||||
if diff != "" {
|
||||
t.Fatalf("Unexpected env for\nhostname: %#v\nusername: %#v\nconf: %s\n%s", hostname, username, conf, diff)
|
||||
}
|
||||
}
|
||||
rt()
|
||||
conf = "env a=b"
|
||||
rt(`export 'a'="b"`)
|
||||
conf = "env a=b\nhostname 2\nenv a=c\nenv b=b"
|
||||
rt(`export 'a'="b"`)
|
||||
hostname = "2"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
conf = "env a="
|
||||
rt(`export 'a'=""`)
|
||||
conf = "env a"
|
||||
rt(`unset 'a'`)
|
||||
conf = "env a=b\nhostname test@2\nenv a=c\nenv b=b"
|
||||
hostname = "unmatched"
|
||||
rt(`export 'a'="b"`)
|
||||
hostname = "2"
|
||||
rt(`export 'a'="b"`)
|
||||
username = "test"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
conf = "env a=b\nhostname 1 2\nenv a=c\nenv b=b"
|
||||
username = ""
|
||||
hostname = "unmatched"
|
||||
rt(`export 'a'="b"`)
|
||||
hostname = "1"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
hostname = "2"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
for_python = true
|
||||
rt(`export ["a","c",false]`, `export ["b","b",false]`)
|
||||
conf = "env a="
|
||||
rt(`export ["a"]`)
|
||||
conf = "env a"
|
||||
rt(`unset ["a"]`)
|
||||
conf = "env LOCAL_ENV=_kitty_copy_env_var_"
|
||||
rt(`export ["LOCAL_ENV","LOCAL_VAL",false]`)
|
||||
|
||||
ci, err := ParseCopyInstruction("--exclude moose --dest=target " + cf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff := cmp.Diff("home/target", ci[0].arcname)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect arcname:\n%s", diff)
|
||||
}
|
||||
diff = cmp.Diff(cf, ci[0].local_path)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect local_path:\n%s", diff)
|
||||
}
|
||||
diff = cmp.Diff([]string{"moose"}, ci[0].exclude_patterns)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect excludes:\n%s", diff)
|
||||
}
|
||||
ci, err = ParseCopyInstruction("--glob " + filepath.Join(filepath.Dir(cf), "*.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff = cmp.Diff(cf, ci[0].local_path)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect local_path:\n%s", diff)
|
||||
}
|
||||
if len(ci) != 1 {
|
||||
t.Fatal(ci)
|
||||
}
|
||||
|
||||
}
|
||||
69
kittens/ssh/data.go
Normal file
69
kittens/ssh/data.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"kitty/tools/utils"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
//go:embed data_generated.bin
|
||||
var embedded_data string
|
||||
|
||||
type Entry struct {
|
||||
metadata *tar.Header
|
||||
data []byte
|
||||
}
|
||||
|
||||
type Container map[string]Entry
|
||||
|
||||
var Data = (&utils.Once[Container]{Run: func() Container {
|
||||
tr := tar.NewReader(utils.ReaderForCompressedEmbeddedData(embedded_data))
|
||||
ans := make(Container, 64)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
data, err := utils.ReadAll(tr, int(hdr.Size))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ans[hdr.Name] = Entry{hdr, data}
|
||||
}
|
||||
return ans
|
||||
}}).Get
|
||||
|
||||
func (self Container) files_matching(prefix string, exclude_patterns ...string) []string {
|
||||
ans := make([]string, 0, len(self))
|
||||
patterns := make([]*regexp.Regexp, len(exclude_patterns))
|
||||
for i, exp := range exclude_patterns {
|
||||
patterns[i] = regexp.MustCompile(exp)
|
||||
}
|
||||
for name := range self {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
excluded := false
|
||||
for _, pat := range patterns {
|
||||
if matched := pat.FindString(name); matched != "" {
|
||||
excluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !excluded {
|
||||
ans = append(ans, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ans
|
||||
}
|
||||
800
kittens/ssh/main.go
Normal file
800
kittens/ssh/main.go
Normal file
@@ -0,0 +1,800 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"kitty"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/themes"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/secrets"
|
||||
"kitty/tools/utils/shm"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func get_destination(hostname string) (username, hostname_for_match string) {
|
||||
u, err := user.Current()
|
||||
if err == nil {
|
||||
username = u.Username
|
||||
}
|
||||
hostname_for_match = hostname
|
||||
if strings.HasPrefix(hostname, "ssh://") {
|
||||
p, err := url.Parse(hostname)
|
||||
if err == nil {
|
||||
hostname_for_match = p.Hostname()
|
||||
if p.User.Username() != "" {
|
||||
username = p.User.Username()
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(hostname, "@") && hostname[0] != '@' {
|
||||
username, hostname_for_match, _ = strings.Cut(hostname, "@")
|
||||
}
|
||||
if strings.Contains(hostname, "@") && hostname[0] != '@' {
|
||||
_, hostname_for_match, _ = strings.Cut(hostname_for_match, "@")
|
||||
}
|
||||
hostname_for_match, _, _ = strings.Cut(hostname_for_match, ":")
|
||||
return
|
||||
}
|
||||
|
||||
func read_data_from_shared_memory(shm_name string) ([]byte, error) {
|
||||
data, err := shm.ReadWithSizeAndUnlink(shm_name, func(s fs.FileInfo) error {
|
||||
if stat, ok := s.Sys().(unix.Stat_t); ok {
|
||||
if os.Getuid() != int(stat.Uid) || os.Getgid() != int(stat.Gid) {
|
||||
return fmt.Errorf("Incorrect owner on SHM file")
|
||||
}
|
||||
}
|
||||
if s.Mode().Perm() != 0o600 {
|
||||
return fmt.Errorf("Incorrect permissions on SHM file")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return data, err
|
||||
}
|
||||
|
||||
func add_cloned_env(val string) (ans map[string]string, err error) {
|
||||
data, err := read_data_from_shared_memory(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(data, &ans)
|
||||
return ans, err
|
||||
}
|
||||
|
||||
func parse_kitten_args(found_extra_args []string, username, hostname_for_match string) (overrides []string, literal_env map[string]string, ferr error) {
|
||||
literal_env = make(map[string]string)
|
||||
overrides = make([]string, 0, 4)
|
||||
for i, a := range found_extra_args {
|
||||
if i%2 == 0 {
|
||||
continue
|
||||
}
|
||||
if key, val, found := strings.Cut(a, "="); found {
|
||||
if key == "clone_env" {
|
||||
le, err := add_cloned_env(val)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, ferr
|
||||
}
|
||||
} else if le != nil {
|
||||
literal_env = le
|
||||
}
|
||||
} else if key != "hostname" {
|
||||
overrides = append(overrides, key+"="+val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
overrides = append([]string{"hostname " + username + "@" + hostname_for_match}, overrides...)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func connection_sharing_args(kitty_pid int) ([]string, error) {
|
||||
rd := utils.RuntimeDir()
|
||||
// Bloody OpenSSH generates a 40 char hash and in creating the socket
|
||||
// appends a 27 char temp suffix to it. Socket max path length is approx
|
||||
// ~104 chars. And on idiotic Apple the path length to the runtime dir
|
||||
// (technically the cache dir since Apple has no runtime dir and thinks it's
|
||||
// a great idea to delete files in /tmp) is ~48 chars.
|
||||
if len(rd) > 35 {
|
||||
idiotic_design := fmt.Sprintf("/tmp/kssh-rdir-%d", os.Geteuid())
|
||||
if err := utils.AtomicCreateSymlink(rd, idiotic_design); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rd = idiotic_design
|
||||
}
|
||||
cp := strings.Replace(kitty.SSHControlMasterTemplate, "{kitty_pid}", strconv.Itoa(kitty_pid), 1)
|
||||
cp = strings.Replace(cp, "{ssh_placeholder}", "%C", 1)
|
||||
return []string{
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=" + filepath.Join(rd, cp),
|
||||
"-o", "ControlPersist=yes",
|
||||
"-o", "ServerAliveInterval=60",
|
||||
"-o", "ServerAliveCountMax=5",
|
||||
"-o", "TCPKeepAlive=no",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func set_askpass() (need_to_request_data bool) {
|
||||
need_to_request_data = true
|
||||
sentinel := filepath.Join(utils.CacheDir(), "openssh-is-new-enough-for-askpass")
|
||||
_, err := os.Stat(sentinel)
|
||||
sentinel_exists := err == nil
|
||||
if sentinel_exists || GetSSHVersion().SupportsAskpassRequire() {
|
||||
if !sentinel_exists {
|
||||
os.WriteFile(sentinel, []byte{0}, 0o644)
|
||||
}
|
||||
need_to_request_data = false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
os.Setenv("SSH_ASKPASS", exe)
|
||||
os.Setenv("KITTY_KITTEN_RUN_MODULE", "ssh_askpass")
|
||||
if !need_to_request_data {
|
||||
os.Setenv("SSH_ASKPASS_REQUIRE", "force")
|
||||
}
|
||||
} else {
|
||||
need_to_request_data = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type connection_data struct {
|
||||
remote_args []string
|
||||
host_opts *Config
|
||||
hostname_for_match string
|
||||
username string
|
||||
echo_on bool
|
||||
request_data bool
|
||||
literal_env map[string]string
|
||||
test_script string
|
||||
dont_create_shm bool
|
||||
|
||||
shm_name string
|
||||
script_type string
|
||||
rcmd []string
|
||||
replacements map[string]string
|
||||
request_id string
|
||||
bootstrap_script string
|
||||
}
|
||||
|
||||
func get_effective_ksi_env_var(x string) string {
|
||||
parts := strings.Split(strings.TrimSpace(strings.ToLower(x)), " ")
|
||||
current := utils.NewSetWithItems(parts...)
|
||||
if current.Has("disabled") {
|
||||
return ""
|
||||
}
|
||||
allowed := utils.NewSetWithItems(kitty.AllowedShellIntegrationValues...)
|
||||
if !current.IsSubsetOf(allowed) {
|
||||
return RelevantKittyOpts().Shell_integration
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func serialize_env(cd *connection_data, get_local_env func(string) (string, bool)) (string, string) {
|
||||
ksi := ""
|
||||
if cd.host_opts.Shell_integration == "inherited" {
|
||||
ksi = get_effective_ksi_env_var(RelevantKittyOpts().Shell_integration)
|
||||
} else {
|
||||
ksi = get_effective_ksi_env_var(cd.host_opts.Shell_integration)
|
||||
}
|
||||
env := make([]*EnvInstruction, 0, 8)
|
||||
add_env := func(key, val string, fallback ...string) *EnvInstruction {
|
||||
if val == "" && len(fallback) > 0 {
|
||||
val = fallback[0]
|
||||
}
|
||||
if val != "" {
|
||||
env = append(env, &EnvInstruction{key: key, val: val, literal_quote: true})
|
||||
return env[len(env)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
add_non_literal_env := func(key, val string, fallback ...string) *EnvInstruction {
|
||||
ans := add_env(key, val, fallback...)
|
||||
if ans != nil {
|
||||
ans.literal_quote = false
|
||||
}
|
||||
return ans
|
||||
}
|
||||
for k, v := range cd.literal_env {
|
||||
add_env(k, v)
|
||||
}
|
||||
add_env("TERM", os.Getenv("TERM"), RelevantKittyOpts().Term)
|
||||
add_env("COLORTERM", "truecolor")
|
||||
env = append(env, cd.host_opts.Env...)
|
||||
add_env("KITTY_WINDOW_ID", os.Getenv("KITTY_WINDOW_ID"))
|
||||
add_env("WINDOWID", os.Getenv("WINDOWID"))
|
||||
if ksi != "" {
|
||||
add_env("KITTY_SHELL_INTEGRATION", ksi)
|
||||
} else {
|
||||
env = append(env, &EnvInstruction{key: "KITTY_SHELL_INTEGRATION", delete_on_remote: true})
|
||||
}
|
||||
add_non_literal_env("KITTY_SSH_KITTEN_DATA_DIR", cd.host_opts.Remote_dir)
|
||||
add_non_literal_env("KITTY_LOGIN_SHELL", cd.host_opts.Login_shell)
|
||||
add_non_literal_env("KITTY_LOGIN_CWD", cd.host_opts.Cwd)
|
||||
if cd.host_opts.Remote_kitty != Remote_kitty_no {
|
||||
add_env("KITTY_REMOTE", cd.host_opts.Remote_kitty.String())
|
||||
}
|
||||
add_env("KITTY_PUBLIC_KEY", os.Getenv("KITTY_PUBLIC_KEY"))
|
||||
return final_env_instructions(cd.script_type == "py", get_local_env, env...), ksi
|
||||
}
|
||||
|
||||
func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool)) ([]byte, error) {
|
||||
env_script, ksi := serialize_env(cd, get_local_env)
|
||||
w := bytes.Buffer{}
|
||||
w.Grow(64 * 1024)
|
||||
gw, err := gzip.NewWriterLevel(&w, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tw := tar.NewWriter(gw)
|
||||
rd := strings.TrimRight(cd.host_opts.Remote_dir, "/")
|
||||
seen := make(map[file_unique_id]string, 32)
|
||||
add := func(h *tar.Header, data []byte) (err error) {
|
||||
// some distro's like nix mess with installed file permissions so ensure
|
||||
// files are at least readable and writable by owning user
|
||||
h.Mode |= 0o600
|
||||
err = tw.WriteHeader(h)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if data != nil {
|
||||
_, err := tw.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, ci := range cd.host_opts.Copy {
|
||||
err = ci.get_file_data(add, seen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
type fe struct {
|
||||
arcname string
|
||||
data []byte
|
||||
}
|
||||
now := time.Now()
|
||||
add_data := func(items ...fe) error {
|
||||
for _, item := range items {
|
||||
err := add(
|
||||
&tar.Header{
|
||||
Typeflag: tar.TypeReg, Name: item.arcname, Format: tar.FormatPAX, Size: int64(len(item.data)),
|
||||
Mode: 0o644, ModTime: now, ChangeTime: now, AccessTime: now,
|
||||
}, item.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
add_entries := func(prefix string, items ...Entry) error {
|
||||
for _, item := range items {
|
||||
err := add(
|
||||
&tar.Header{
|
||||
Typeflag: item.metadata.Typeflag, Name: path.Join(prefix, path.Base(item.metadata.Name)), Format: tar.FormatPAX,
|
||||
Size: int64(len(item.data)), Mode: item.metadata.Mode, ModTime: item.metadata.ModTime,
|
||||
AccessTime: item.metadata.AccessTime, ChangeTime: item.metadata.ChangeTime,
|
||||
}, item.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
add_data(fe{"data.sh", utils.UnsafeStringToBytes(env_script)})
|
||||
if cd.script_type == "sh" {
|
||||
add_data(fe{"bootstrap-utils.sh", Data()[path.Join("shell-integration/ssh/bootstrap-utils.sh")].data})
|
||||
}
|
||||
if ksi != "" {
|
||||
for _, fname := range Data().files_matching(
|
||||
"shell-integration/",
|
||||
"shell-integration/ssh/.+", // bootstrap files are sent as command line args
|
||||
"shell-integration/zsh/kitty.zsh", // backward compat file not needed by ssh kitten
|
||||
) {
|
||||
arcname := path.Join("home/", rd, "/", path.Dir(fname))
|
||||
err = add_entries(arcname, Data()[fname])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if cd.host_opts.Remote_kitty != Remote_kitty_no {
|
||||
arcname := path.Join("home/", rd, "/kitty")
|
||||
err = add_data(fe{arcname + "/version", utils.UnsafeStringToBytes(kitty.VersionString)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range []string{"kitty", "kitten"} {
|
||||
err = add_entries(path.Join(arcname, "bin"), Data()[path.Join("shell-integration", "ssh", x)])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
err = add_entries(path.Join("home", ".terminfo"), Data()["terminfo/kitty.terminfo"])
|
||||
if err == nil {
|
||||
err = add_entries(path.Join("home", ".terminfo", "x"), Data()["terminfo/x/xterm-kitty"])
|
||||
}
|
||||
if err == nil {
|
||||
err = tw.Close()
|
||||
if err == nil {
|
||||
err = gw.Close()
|
||||
}
|
||||
}
|
||||
return w.Bytes(), err
|
||||
}
|
||||
|
||||
func prepare_home_command(cd *connection_data) string {
|
||||
is_python := cd.script_type == "py"
|
||||
homevar := ""
|
||||
for _, ei := range cd.host_opts.Env {
|
||||
if ei.key == "HOME" && !ei.delete_on_remote {
|
||||
if ei.copy_from_local {
|
||||
homevar = os.Getenv("HOME")
|
||||
} else {
|
||||
homevar = ei.val
|
||||
}
|
||||
}
|
||||
}
|
||||
export_home_cmd := ""
|
||||
if homevar != "" {
|
||||
if is_python {
|
||||
export_home_cmd = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(homevar))
|
||||
} else {
|
||||
export_home_cmd = fmt.Sprintf("export HOME=%s; cd \"$HOME\"", utils.QuoteStringForSH(homevar))
|
||||
}
|
||||
}
|
||||
return export_home_cmd
|
||||
}
|
||||
|
||||
func prepare_exec_cmd(cd *connection_data) string {
|
||||
// ssh simply concatenates multiple commands using a space see
|
||||
// line 1129 of ssh.c and on the remote side sshd.c runs the
|
||||
// concatenated command as shell -c cmd
|
||||
if cd.script_type == "py" {
|
||||
return base64.RawStdEncoding.EncodeToString(utils.UnsafeStringToBytes(strings.Join(cd.remote_args, " ")))
|
||||
}
|
||||
args := make([]string, len(cd.remote_args))
|
||||
for i, arg := range cd.remote_args {
|
||||
args[i] = strings.ReplaceAll(arg, "'", "'\"'\"'")
|
||||
}
|
||||
return "unset KITTY_SHELL_INTEGRATION; exec \"$login_shell\" -c '" + strings.Join(args, " ") + "'"
|
||||
}
|
||||
|
||||
var data_shm shm.MMap
|
||||
|
||||
func prepare_script(script string, replacements map[string]string) string {
|
||||
if _, found := replacements["EXEC_CMD"]; !found {
|
||||
replacements["EXEC_CMD"] = ""
|
||||
}
|
||||
if _, found := replacements["EXPORT_HOME_CMD"]; !found {
|
||||
replacements["EXPORT_HOME_CMD"] = ""
|
||||
}
|
||||
keys := maps.Keys(replacements)
|
||||
for i, key := range keys {
|
||||
keys[i] = "\\b" + key + "\\b"
|
||||
}
|
||||
pat := regexp.MustCompile(strings.Join(keys, "|"))
|
||||
return pat.ReplaceAllStringFunc(script, func(key string) string { return replacements[key] })
|
||||
}
|
||||
|
||||
func bootstrap_script(cd *connection_data) (err error) {
|
||||
if cd.request_id == "" {
|
||||
cd.request_id = os.Getenv("KITTY_PID") + "-" + os.Getenv("KITTY_WINDOW_ID")
|
||||
}
|
||||
export_home_cmd := prepare_home_command(cd)
|
||||
exec_cmd := ""
|
||||
if len(cd.remote_args) > 0 {
|
||||
exec_cmd = prepare_exec_cmd(cd)
|
||||
}
|
||||
pw, err := secrets.TokenHex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tfd, err := make_tarfile(cd, os.LookupEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := map[string]string{
|
||||
"tarfile": base64.StdEncoding.EncodeToString(tfd),
|
||||
"pw": pw,
|
||||
"hostname": cd.hostname_for_match, "username": cd.username,
|
||||
}
|
||||
encoded_data, err := json.Marshal(data)
|
||||
if err == nil && !cd.dont_create_shm {
|
||||
data_shm, err = shm.CreateTemp(fmt.Sprintf("kssh-%d-", os.Getpid()), uint64(len(encoded_data)+8))
|
||||
if err == nil {
|
||||
err = shm.WriteWithSize(data_shm, encoded_data, 0)
|
||||
if err == nil {
|
||||
err = data_shm.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cd.dont_create_shm {
|
||||
cd.shm_name = data_shm.Name()
|
||||
}
|
||||
sensitive_data := map[string]string{"REQUEST_ID": cd.request_id, "DATA_PASSWORD": pw, "PASSWORD_FILENAME": cd.shm_name}
|
||||
replacements := map[string]string{
|
||||
"EXPORT_HOME_CMD": export_home_cmd,
|
||||
"EXEC_CMD": exec_cmd,
|
||||
"TEST_SCRIPT": cd.test_script,
|
||||
}
|
||||
add_bool := func(ok bool, key string) {
|
||||
if ok {
|
||||
replacements[key] = "1"
|
||||
} else {
|
||||
replacements[key] = "0"
|
||||
}
|
||||
}
|
||||
add_bool(cd.request_data, "REQUEST_DATA")
|
||||
add_bool(cd.echo_on, "ECHO_ON")
|
||||
sd := maps.Clone(replacements)
|
||||
if cd.request_data {
|
||||
maps.Copy(sd, sensitive_data)
|
||||
}
|
||||
maps.Copy(replacements, sensitive_data)
|
||||
cd.replacements = replacements
|
||||
cd.bootstrap_script = utils.UnsafeBytesToString(Data()["shell-integration/ssh/bootstrap."+cd.script_type].data)
|
||||
cd.bootstrap_script = prepare_script(cd.bootstrap_script, sd)
|
||||
return err
|
||||
}
|
||||
|
||||
func wrap_bootstrap_script(cd *connection_data) {
|
||||
// sshd will execute the command we pass it by join all command line
|
||||
// arguments with a space and passing it as a single argument to the users
|
||||
// login shell with -c. If the user has a non POSIX login shell it might
|
||||
// have different escaping semantics and syntax, so the command it should
|
||||
// execute has to be as simple as possible, basically of the form
|
||||
// interpreter -c unwrap_script escaped_bootstrap_script
|
||||
// The unwrap_script is responsible for unescaping the bootstrap script and
|
||||
// executing it.
|
||||
encoded_script := ""
|
||||
unwrap_script := ""
|
||||
if cd.script_type == "py" {
|
||||
encoded_script = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(cd.bootstrap_script))
|
||||
unwrap_script = `"import base64, sys; eval(compile(base64.standard_b64decode(sys.argv[-1]), 'bootstrap.py', 'exec'))"`
|
||||
} else {
|
||||
// We cant rely on base64 being available on the remote system, so instead
|
||||
// we quote the bootstrap script by replacing ' and \ with \v and \f
|
||||
// also replacing \n and ! with \r and \b for tcsh
|
||||
// finally surrounding with '
|
||||
encoded_script = "'" + strings.NewReplacer("'", "\v", "\\", "\f", "\n", "\r", "!", "\b").Replace(cd.bootstrap_script) + "'"
|
||||
unwrap_script = `'eval "$(echo "$0" | tr \\\v\\\f\\\r\\\b \\\047\\\134\\\n\\\041)"' `
|
||||
}
|
||||
cd.rcmd = []string{"exec", cd.host_opts.Interpreter, "-c", unwrap_script, encoded_script}
|
||||
}
|
||||
|
||||
func get_remote_command(cd *connection_data) error {
|
||||
interpreter := cd.host_opts.Interpreter
|
||||
q := strings.ToLower(path.Base(interpreter))
|
||||
is_python := strings.Contains(q, "python")
|
||||
cd.script_type = "sh"
|
||||
if is_python {
|
||||
cd.script_type = "py"
|
||||
}
|
||||
err := bootstrap_script(cd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrap_bootstrap_script(cd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func drain_potential_tty_garbage(term *tty.Term) {
|
||||
err := term.ApplyOperations(tty.TCSANOW, tty.SetNoEcho)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
canary, err := secrets.TokenBase64()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dcs, err := tui.DCSToKitty("echo", canary+"\n\r")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = term.WriteAllString(dcs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
q := utils.UnsafeStringToBytes(canary)
|
||||
data := make([]byte, 0)
|
||||
give_up_at := time.Now().Add(2 * time.Second)
|
||||
buf := make([]byte, 0, 8192)
|
||||
for !bytes.Contains(data, q) {
|
||||
buf = buf[:cap(buf)]
|
||||
timeout := give_up_at.Sub(time.Now())
|
||||
if timeout < 0 {
|
||||
break
|
||||
}
|
||||
n, err := term.ReadWithTimeout(buf, timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data = append(data, buf[:n]...)
|
||||
}
|
||||
}
|
||||
|
||||
func change_colors(color_scheme string) (ans string, err error) {
|
||||
if color_scheme == "" {
|
||||
return
|
||||
}
|
||||
var theme *themes.Theme
|
||||
if !strings.HasSuffix(color_scheme, ".conf") {
|
||||
cs := os.ExpandEnv(color_scheme)
|
||||
tc, closer, err := themes.LoadThemes(-1)
|
||||
if err != nil && errors.Is(err, themes.ErrNoCacheFound) {
|
||||
tc, closer, err = themes.LoadThemes(time.Hour * 24)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer closer.Close()
|
||||
theme = tc.ThemeByName(cs)
|
||||
if theme == nil {
|
||||
return "", fmt.Errorf("No theme named %#v found", cs)
|
||||
}
|
||||
} else {
|
||||
theme, err = themes.ThemeFromFile(utils.ResolveConfPath(color_scheme))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
ans, err = theme.AsEscapeCodes()
|
||||
if err == nil {
|
||||
ans = "\033[#P" + ans
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err error) {
|
||||
go Data()
|
||||
go RelevantKittyOpts()
|
||||
defer func() {
|
||||
if data_shm != nil {
|
||||
data_shm.Close()
|
||||
data_shm.Unlink()
|
||||
}
|
||||
}()
|
||||
cmd := append([]string{SSHExe()}, ssh_args...)
|
||||
cd := connection_data{remote_args: server_args[1:]}
|
||||
hostname := server_args[0]
|
||||
if len(cd.remote_args) == 0 {
|
||||
cmd = append(cmd, "-t")
|
||||
}
|
||||
insertion_point := len(cmd)
|
||||
cmd = append(cmd, "--", hostname)
|
||||
uname, hostname_for_match := get_destination(hostname)
|
||||
overrides, literal_env, err := parse_kitten_args(found_extra_args, uname, hostname_for_match)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
host_opts, bad_lines, err := load_config(hostname_for_match, uname, overrides)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if len(bad_lines) > 0 {
|
||||
for _, x := range bad_lines {
|
||||
fmt.Fprintf(os.Stderr, "Ignoring bad config line: %s:%d with error: %s", filepath.Base(x.Src_file), x.Line_number, x.Err)
|
||||
}
|
||||
}
|
||||
if host_opts.Share_connections {
|
||||
kpid, err := strconv.Atoi(os.Getenv("KITTY_PID"))
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("Invalid KITTY_PID env var not an integer: %#v", os.Getenv("KITTY_PID"))
|
||||
}
|
||||
cpargs, err := connection_sharing_args(kpid)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
cmd = slices.Insert(cmd, insertion_point, cpargs...)
|
||||
}
|
||||
use_kitty_askpass := host_opts.Askpass == Askpass_native || (host_opts.Askpass == Askpass_unless_set && os.Getenv("SSH_ASKPASS") == "")
|
||||
need_to_request_data := true
|
||||
if use_kitty_askpass {
|
||||
need_to_request_data = set_askpass()
|
||||
}
|
||||
if need_to_request_data && host_opts.Share_connections {
|
||||
check_cmd := slices.Insert(cmd, 1, "-O", "check")
|
||||
err = exec.Command(check_cmd[0], check_cmd[1:]...).Run()
|
||||
if err == nil {
|
||||
need_to_request_data = false
|
||||
}
|
||||
}
|
||||
term, err := tty.OpenControllingTerm(tty.SetNoEcho)
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("Failed to open controlling terminal with error: %w", err)
|
||||
}
|
||||
cd.echo_on = term.WasEchoOnOriginally()
|
||||
cd.host_opts, cd.literal_env = host_opts, literal_env
|
||||
cd.request_data = need_to_request_data
|
||||
cd.hostname_for_match, cd.username = hostname_for_match, uname
|
||||
escape_codes_to_set_colors, err := change_colors(cd.host_opts.Color_scheme)
|
||||
if err == nil {
|
||||
err = term.WriteAllString(escape_codes_to_set_colors + loop.SAVE_PRIVATE_MODE_VALUES + loop.HANDLE_TERMIOS_SIGNALS.EscapeCodeToSet())
|
||||
}
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
restore_escape_codes := loop.RESTORE_PRIVATE_MODE_VALUES
|
||||
if escape_codes_to_set_colors != "" {
|
||||
restore_escape_codes += "\x1b[#Q"
|
||||
}
|
||||
defer func() {
|
||||
term.WriteAllString(restore_escape_codes)
|
||||
term.RestoreAndClose()
|
||||
}()
|
||||
err = get_remote_command(&cd)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
cmd = append(cmd, cd.rcmd...)
|
||||
c := exec.Command(cmd[0], cmd[1:]...)
|
||||
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
err = c.Start()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if !cd.request_data {
|
||||
rq := fmt.Sprintf("id=%s:pwfile=%s:pw=%s", cd.replacements["REQUEST_ID"], cd.replacements["PASSWORD_FILENAME"], cd.replacements["DATA_PASSWORD"])
|
||||
err := term.ApplyOperations(tty.TCSANOW, tty.SetNoEcho)
|
||||
if err == nil {
|
||||
var dcs string
|
||||
dcs, err = tui.DCSToKitty("ssh", rq)
|
||||
if err == nil {
|
||||
err = term.WriteAllString(dcs)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
c.Process.Kill()
|
||||
c.Wait()
|
||||
return 1, err
|
||||
}
|
||||
}
|
||||
err = c.Wait()
|
||||
drain_potential_tty_garbage(term)
|
||||
if err != nil {
|
||||
var exit_err *exec.ExitError
|
||||
if errors.As(err, &exit_err) {
|
||||
return exit_err.ExitCode(), nil
|
||||
}
|
||||
return 1, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) {
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "use-python":
|
||||
args = args[1:] // backwards compat from when we had a python implementation
|
||||
case "-h", "--help":
|
||||
cmd.ShowHelp()
|
||||
return
|
||||
}
|
||||
}
|
||||
ssh_args, server_args, passthrough, found_extra_args, err := ParseSSHArgs(args, "--kitten")
|
||||
if err != nil {
|
||||
var invargs *ErrInvalidSSHArgs
|
||||
switch {
|
||||
case errors.As(err, &invargs):
|
||||
if invargs.Msg != "" {
|
||||
fmt.Fprintln(os.Stderr, invargs.Msg)
|
||||
}
|
||||
return 1, unix.Exec(SSHExe(), []string{"ssh"}, os.Environ())
|
||||
}
|
||||
return 1, err
|
||||
}
|
||||
if passthrough {
|
||||
if len(found_extra_args) > 0 {
|
||||
return 1, fmt.Errorf("The SSH kitten cannot work with the options: %s", strings.Join(maps.Keys(PassthroughArgs()), " "))
|
||||
}
|
||||
return 1, unix.Exec(SSHExe(), append([]string{"ssh"}, args...), os.Environ())
|
||||
}
|
||||
if os.Getenv("KITTY_WINDOW_ID") == "" || os.Getenv("KITTY_PID") == "" {
|
||||
return 1, fmt.Errorf("The SSH kitten is meant to run inside a kitty window")
|
||||
}
|
||||
if !tty.IsTerminal(os.Stdin.Fd()) {
|
||||
return 1, fmt.Errorf("The SSH kitten is meant for interactive use only, STDIN must be a terminal")
|
||||
}
|
||||
return run_ssh(ssh_args, server_args, found_extra_args)
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, main)
|
||||
}
|
||||
|
||||
func specialize_command(ssh *cli.Command) {
|
||||
ssh.Usage = "arguments for the ssh command"
|
||||
ssh.ShortDescription = "Truly convenient SSH"
|
||||
ssh.HelpText = "The ssh kitten is a thin wrapper around the ssh command. It automatically enables shell integration on the remote host, re-uses existing connections to reduce latency, makes the kitty terminfo database available, etc. It's invocation is identical to the ssh command. For details on its usage, see :doc:`/kittens/ssh`."
|
||||
ssh.IgnoreAllArgs = true
|
||||
ssh.OnlyArgsAllowed = true
|
||||
ssh.ArgCompleter = cli.CompletionForWrapper("ssh")
|
||||
}
|
||||
|
||||
func test_integration_with_python(args []string) (rc int, err error) {
|
||||
f, err := os.CreateTemp("", "*.conf")
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
defer func() {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
}()
|
||||
_, err = io.Copy(f, os.Stdin)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
cd := &connection_data{
|
||||
request_id: "testing", remote_args: []string{},
|
||||
username: "testuser", hostname_for_match: "host.test", request_data: true,
|
||||
test_script: args[0], echo_on: true,
|
||||
}
|
||||
opts, bad_lines, err := load_config(cd.hostname_for_match, cd.username, nil, f.Name())
|
||||
if err == nil {
|
||||
if len(bad_lines) > 0 {
|
||||
return 1, fmt.Errorf("Bad config lines: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err)
|
||||
}
|
||||
cd.host_opts = opts
|
||||
err = get_remote_command(cd)
|
||||
}
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
data, err := json.Marshal(map[string]any{"cmd": cd.rcmd, "shm_name": cd.shm_name})
|
||||
if err == nil {
|
||||
_, err = os.Stdout.Write(data)
|
||||
os.Stdout.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestEntryPoint(root *cli.Command) {
|
||||
root.AddSubCommand(&cli.Command{
|
||||
Name: "ssh",
|
||||
OnlyArgsAllowed: true,
|
||||
Run: func(cmd *cli.Command, args []string) (rc int, err error) {
|
||||
return test_integration_with_python(args)
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
155
kittens/ssh/main_test.go
Normal file
155
kittens/ssh/main_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"kitty/tools/utils/shm"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestCloneEnv(t *testing.T) {
|
||||
env := map[string]string{"a": "1", "b": "2"}
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mmap, err := shm.CreateTemp("", 128)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mmap.Unlink()
|
||||
copy(mmap.Slice()[4:], data)
|
||||
binary.BigEndian.PutUint32(mmap.Slice(), uint32(len(data)))
|
||||
mmap.Close()
|
||||
x, err := add_cloned_env(mmap.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff := cmp.Diff(env, x)
|
||||
if diff != "" {
|
||||
t.Fatalf("Failed to deserialize env\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func basic_connection_data(overrides ...string) *connection_data {
|
||||
ans := &connection_data{
|
||||
script_type: "sh", request_id: "123-123", remote_args: []string{},
|
||||
username: "testuser", hostname_for_match: "host.test",
|
||||
dont_create_shm: true,
|
||||
}
|
||||
opts, bad_lines, err := load_config(ans.hostname_for_match, ans.username, overrides)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if len(bad_lines) != 0 {
|
||||
panic(fmt.Sprintf("Bad config lines: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err))
|
||||
}
|
||||
ans.host_opts = opts
|
||||
return ans
|
||||
}
|
||||
|
||||
func TestSSHBootstrapScriptLimit(t *testing.T) {
|
||||
cd := basic_connection_data()
|
||||
err := get_remote_command(cd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total := 0
|
||||
for _, x := range cd.rcmd {
|
||||
total += len(x)
|
||||
}
|
||||
if total > 9000 {
|
||||
t.Fatalf("Bootstrap script too large: %d bytes", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHTarfile(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
cd := basic_connection_data()
|
||||
data, err := make_tarfile(cd, func(key string) (val string, found bool) { return })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("tar", "xpzf", "-", "-C", tdir)
|
||||
cmd.Stderr = os.Stderr
|
||||
inp, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = inp.Write(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inp.Close()
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
err = filepath.WalkDir(tdir, func(name string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
rname, werr := filepath.Rel(tdir, name)
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
rname = strings.ReplaceAll(rname, "\\", "/")
|
||||
if rname == "." {
|
||||
return nil
|
||||
}
|
||||
fi, werr := d.Info()
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if fi.Mode().Perm()&0o600 == 0 {
|
||||
return fmt.Errorf("%s is not rw for its owner. Actual permissions: %s", rname, fi.Mode().String())
|
||||
}
|
||||
seen[rname] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !seen["data.sh"] {
|
||||
t.Fatalf("data.sh missing")
|
||||
}
|
||||
for _, x := range []string{".terminfo/kitty.terminfo", ".terminfo/x/xterm-kitty"} {
|
||||
if !seen["home/"+x] {
|
||||
t.Fatalf("%s missing", x)
|
||||
}
|
||||
}
|
||||
for _, x := range []string{"shell-integration/bash/kitty.bash", "shell-integration/fish/vendor_completions.d/kitty.fish"} {
|
||||
if !seen[path.Join("home", cd.host_opts.Remote_dir, x)] {
|
||||
t.Fatalf("%s missing", x)
|
||||
}
|
||||
}
|
||||
for _, x := range []string{"kitty", "kitten"} {
|
||||
p := filepath.Join(tdir, "home", cd.host_opts.Remote_dir, "kitty", "bin", x)
|
||||
if err = unix.Access(p, unix.X_OK); err != nil {
|
||||
t.Fatalf("Cannot execute %s with error: %s", x, err)
|
||||
}
|
||||
}
|
||||
if seen[path.Join("home", cd.host_opts.Remote_dir, "shell-integration", "ssh", "kitten")] {
|
||||
t.Fatalf("Contents of shell-integration/ssh not excluded")
|
||||
}
|
||||
}
|
||||
240
kittens/ssh/utils.go
Normal file
240
kittens/ssh/utils.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"kitty"
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/utils"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
var SSHExe = (&utils.Once[string]{Run: func() string {
|
||||
return utils.FindExe("ssh")
|
||||
}}).Get
|
||||
|
||||
var SSHOptions = (&utils.Once[map[string]string]{Run: func() (ssh_options map[string]string) {
|
||||
defer func() {
|
||||
if ssh_options == nil {
|
||||
ssh_options = map[string]string{
|
||||
"4": "", "6": "", "A": "", "a": "", "C": "", "f": "", "G": "", "g": "", "K": "", "k": "",
|
||||
"M": "", "N": "", "n": "", "q": "", "s": "", "T": "", "t": "", "V": "", "v": "", "X": "",
|
||||
"x": "", "Y": "", "y": "", "B": "bind_interface", "b": "bind_address", "c": "cipher_spec",
|
||||
"D": "[bind_address:]port", "E": "log_file", "e": "escape_char", "F": "configfile", "I": "pkcs11",
|
||||
"i": "identity_file", "J": "[user@]host[:port]", "L": "address", "l": "login_name", "m": "mac_spec",
|
||||
"O": "ctl_cmd", "o": "option", "p": "port", "Q": "query_option", "R": "address",
|
||||
"S": "ctl_path", "W": "host:port", "w": "local_tun[:remote_tun]",
|
||||
}
|
||||
}
|
||||
}()
|
||||
cmd := exec.Command(SSHExe())
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(stderr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text := utils.UnsafeBytesToString(raw)
|
||||
ssh_options = make(map[string]string, 32)
|
||||
for {
|
||||
pos := strings.IndexByte(text, '[')
|
||||
if pos < 0 {
|
||||
break
|
||||
}
|
||||
num := 1
|
||||
epos := pos
|
||||
for num > 0 {
|
||||
epos++
|
||||
switch text[epos] {
|
||||
case '[':
|
||||
num += 1
|
||||
case ']':
|
||||
num -= 1
|
||||
}
|
||||
}
|
||||
q := text[pos+1 : epos]
|
||||
text = text[epos:]
|
||||
if len(q) < 2 || !strings.HasPrefix(q, "-") {
|
||||
continue
|
||||
}
|
||||
opt, desc, found := strings.Cut(q, " ")
|
||||
if found {
|
||||
ssh_options[opt[1:]] = desc
|
||||
} else {
|
||||
for _, ch := range opt[1:] {
|
||||
ssh_options[string(ch)] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}}).Get
|
||||
|
||||
func GetSSHCLI() (boolean_ssh_args *utils.Set[string], other_ssh_args *utils.Set[string]) {
|
||||
other_ssh_args, boolean_ssh_args = utils.NewSet[string](32), utils.NewSet[string](32)
|
||||
for k, v := range SSHOptions() {
|
||||
k = "-" + k
|
||||
if v == "" {
|
||||
boolean_ssh_args.Add(k)
|
||||
} else {
|
||||
other_ssh_args.Add(k)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func is_extra_arg(arg string, extra_args []string) string {
|
||||
for _, x := range extra_args {
|
||||
if arg == x || strings.HasPrefix(arg, x+"=") {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ErrInvalidSSHArgs struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (self *ErrInvalidSSHArgs) Error() string {
|
||||
return self.Msg
|
||||
}
|
||||
|
||||
func PassthroughArgs() map[string]bool {
|
||||
return map[string]bool{"-N": true, "-n": true, "-f": true, "-G": true, "-T": true}
|
||||
}
|
||||
|
||||
func ParseSSHArgs(args []string, extra_args ...string) (ssh_args []string, server_args []string, passthrough bool, found_extra_args []string, err error) {
|
||||
if extra_args == nil {
|
||||
extra_args = []string{}
|
||||
}
|
||||
if len(args) == 0 {
|
||||
passthrough = true
|
||||
return
|
||||
}
|
||||
passthrough_args := PassthroughArgs()
|
||||
boolean_ssh_args, other_ssh_args := GetSSHCLI()
|
||||
ssh_args, server_args, found_extra_args = make([]string, 0, 16), make([]string, 0, 16), make([]string, 0, 16)
|
||||
expecting_option_val := false
|
||||
stop_option_processing := false
|
||||
expecting_extra_val := ""
|
||||
for _, argument := range args {
|
||||
if len(server_args) > 1 || stop_option_processing {
|
||||
server_args = append(server_args, argument)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(argument, "-") && !expecting_option_val {
|
||||
if argument == "--" {
|
||||
stop_option_processing = true
|
||||
continue
|
||||
}
|
||||
if len(extra_args) > 0 {
|
||||
matching_ex := is_extra_arg(argument, extra_args)
|
||||
if matching_ex != "" {
|
||||
_, exval, found := strings.Cut(argument, "=")
|
||||
if found {
|
||||
found_extra_args = append(found_extra_args, matching_ex, exval)
|
||||
} else {
|
||||
expecting_extra_val = matching_ex
|
||||
expecting_option_val = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
// could be a multi-character option
|
||||
all_args := []rune(argument[1:])
|
||||
for i, ch := range all_args {
|
||||
arg := "-" + string(ch)
|
||||
if passthrough_args[arg] {
|
||||
passthrough = true
|
||||
}
|
||||
if boolean_ssh_args.Has(arg) {
|
||||
ssh_args = append(ssh_args, arg)
|
||||
continue
|
||||
}
|
||||
if other_ssh_args.Has(arg) {
|
||||
ssh_args = append(ssh_args, arg)
|
||||
if i+1 < len(all_args) {
|
||||
ssh_args = append(ssh_args, string(all_args[i+1:]))
|
||||
} else {
|
||||
expecting_option_val = true
|
||||
}
|
||||
break
|
||||
}
|
||||
err = &ErrInvalidSSHArgs{Msg: "unknown option -- " + arg[1:]}
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if expecting_option_val {
|
||||
if expecting_extra_val != "" {
|
||||
found_extra_args = append(found_extra_args, expecting_extra_val, argument)
|
||||
} else {
|
||||
ssh_args = append(ssh_args, argument)
|
||||
}
|
||||
expecting_option_val = false
|
||||
continue
|
||||
}
|
||||
server_args = append(server_args, argument)
|
||||
}
|
||||
if len(server_args) == 0 && !passthrough {
|
||||
err = &ErrInvalidSSHArgs{Msg: ""}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type SSHVersion struct{ Major, Minor int }
|
||||
|
||||
func (self SSHVersion) SupportsAskpassRequire() bool {
|
||||
return self.Major > 8 || (self.Major == 8 && self.Minor >= 4)
|
||||
}
|
||||
|
||||
var GetSSHVersion = (&utils.Once[SSHVersion]{Run: func() SSHVersion {
|
||||
b, err := exec.Command(SSHExe(), "-V").CombinedOutput()
|
||||
if err != nil {
|
||||
return SSHVersion{}
|
||||
}
|
||||
m := regexp.MustCompile(`OpenSSH_(\d+).(\d+)`).FindSubmatch(b)
|
||||
if len(m) == 3 {
|
||||
maj, _ := strconv.Atoi(utils.UnsafeBytesToString(m[1]))
|
||||
min, _ := strconv.Atoi(utils.UnsafeBytesToString(m[2]))
|
||||
return SSHVersion{Major: maj, Minor: min}
|
||||
}
|
||||
return SSHVersion{}
|
||||
}}).Get
|
||||
|
||||
type KittyOpts struct {
|
||||
Term, Shell_integration string
|
||||
}
|
||||
|
||||
func read_relevant_kitty_opts(path string) KittyOpts {
|
||||
ans := KittyOpts{Term: kitty.KittyConfigDefaults.Term, Shell_integration: kitty.KittyConfigDefaults.Shell_integration}
|
||||
handle_line := func(key, val string) error {
|
||||
switch key {
|
||||
case "term":
|
||||
ans.Term = strings.TrimSpace(val)
|
||||
case "shell_integration":
|
||||
ans.Shell_integration = strings.TrimSpace(val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cp := config.ConfigParser{LineHandler: handle_line}
|
||||
cp.ParseFiles(path)
|
||||
return ans
|
||||
}
|
||||
|
||||
var RelevantKittyOpts = (&utils.Once[KittyOpts]{Run: func() KittyOpts {
|
||||
return read_relevant_kitty_opts(filepath.Join(utils.ConfigDir(), "kitty.conf"))
|
||||
}}).Get
|
||||
68
kittens/ssh/utils_test.go
Normal file
68
kittens/ssh/utils_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"kitty/tools/utils/shlex"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestGetSSHOptions(t *testing.T) {
|
||||
m := SSHOptions()
|
||||
if m["w"] != "local_tun[:remote_tun]" {
|
||||
t.Fatalf("Unexpected set of SSH options: %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHArgs(t *testing.T) {
|
||||
split := func(x string) []string {
|
||||
ans, err := shlex.Split(x)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
p := func(args, expected_ssh_args, expected_server_args, expected_extra_args string, expected_passthrough bool) {
|
||||
ssh_args, server_args, passthrough, extra_args, err := ParseSSHArgs(split(args), "--kitten")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(a, b any) {
|
||||
diff := cmp.Diff(a, b)
|
||||
if diff != "" {
|
||||
t.Fatalf("Unexpected value for args: %s\n%s", args, diff)
|
||||
}
|
||||
}
|
||||
check(split(expected_ssh_args), ssh_args)
|
||||
check(split(expected_server_args), server_args)
|
||||
check(split(expected_extra_args), extra_args)
|
||||
check(expected_passthrough, passthrough)
|
||||
}
|
||||
p(`localhost`, ``, `localhost`, ``, false)
|
||||
p(`-- localhost`, ``, `localhost`, ``, false)
|
||||
p(`-46p23 localhost sh -c "a b"`, `-4 -6 -p 23`, `localhost sh -c "a b"`, ``, false)
|
||||
p(`-46p23 -S/moose -W x:6 -- localhost sh -c "a b"`, `-4 -6 -p 23 -S /moose -W x:6`, `localhost sh -c "a b"`, ``, false)
|
||||
p(`--kitten=abc -np23 --kitten xyz host`, `-n -p 23`, `host`, `--kitten abc --kitten xyz`, true)
|
||||
}
|
||||
|
||||
func TestRelevantKittyOpts(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
path := filepath.Join(tdir, "kitty.conf")
|
||||
os.WriteFile(path, []byte("term XXX\nshell_integration changed\nterm abcd"), 0o600)
|
||||
rko := read_relevant_kitty_opts(path)
|
||||
if rko.Term != "abcd" {
|
||||
t.Fatalf("Unexpected TERM: %s", RelevantKittyOpts().Term)
|
||||
}
|
||||
if rko.Shell_integration != "changed" {
|
||||
t.Fatalf("Unexpected shell_integration: %s", RelevantKittyOpts().Shell_integration)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user