Gitfed
bastien-mrq/sssh / main.go
// sssh — simple-ssh : un gestionnaire de connexions SSH minimaliste.
//
//	sssh              TUI de sélection
//	sssh <nom>        connexion directe (préfixe unique accepté)
//	sssh add ...      ajout en une ligne
package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"strings"
	"text/tabwriter"

	"github.com/pelletier/go-toml/v2"

	"git.neuromancer.ovh/bastien-mrq/sssh/internal/connect"
	"git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
	"git.neuromancer.ovh/bastien-mrq/sssh/internal/sshconf"
	"git.neuromancer.ovh/bastien-mrq/sssh/internal/store"
	"git.neuromancer.ovh/bastien-mrq/sssh/internal/tui"
)

const version = "0.1.0"

const usage = `sssh — simple-ssh

Usage :
  sssh                        ouvre le TUI de sélection
  sssh <nom> [cmd...]         se connecte au host (préfixe unique accepté)
  sssh add <nom> <[user@]host[:port]> [-p port] [-i clé] [-J jump] [-t tags]
  sssh edit <nom>             édite un host (formulaire)
  sssh rm <nom>               supprime un host
  sssh list                   liste les hosts (--names : noms seuls)
  sssh export [fichier]       exporte hosts.toml (stdout par défaut)
  sssh import <fichier>       importe en fusionnant (--force pour écraser)
  sssh import-ssh [noms...]   importe des hosts depuis ~/.ssh/config
                              (sans argument : sélection interactive)
  sssh help | version

Fichier : ` + "`$SSSH_CONFIG`" + ` ou ~/.config/sssh/hosts.toml`

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "sssh :", err)
		os.Exit(1)
	}
}

func run(args []string) error {
	if len(args) == 0 {
		return runTUI()
	}
	switch args[0] {
	case "add":
		return cmdAdd(args[1:])
	case "rm", "remove":
		return cmdRm(args[1:])
	case "edit":
		return cmdEdit(args[1:])
	case "list", "ls":
		return cmdList(args[1:])
	case "export":
		return cmdExport(args[1:])
	case "import":
		return cmdImport(args[1:])
	case "import-ssh":
		return cmdImportSSH(args[1:])
	case "help", "-h", "--help":
		fmt.Println(usage)
		return nil
	case "version", "-V", "--version":
		fmt.Println("sssh", version)
		return nil
	default:
		return cmdConnect(args[0], args[1:])
	}
}

// sshConfigCandidates rend les hosts de ~/.ssh/config dont le nom
// n'est pas encore enregistré dans sssh.
func sshConfigCandidates(st *store.Store) []host.Host {
	var out []host.Host
	for _, h := range sshconf.Hosts() {
		if _, ok := st.Get(h.Name); !ok {
			out = append(out, h)
		}
	}
	return out
}

// resolveJump construit la chaîne ProxyJump complète. Un jump qui référence
// un host sssh est remplacé par sa cible réelle, et si ce host a lui-même
// un jump, la chaîne remonte (-J hop1,hop2,…) jusqu'au premier hop.
// Un jump inconnu de sssh est passé tel quel à ssh ([user@]host[:port]).
func resolveJump(st *store.Store, h *host.Host) {
	if h.Jump == "" {
		return
	}
	var chain []string
	seen := map[string]bool{h.Name: true}
	jump := h.Jump
	for {
		if seen[jump] {
			break // cycle dans les jumps : on coupe la chaîne là
		}
		j, ok := st.Get(jump)
		if !ok {
			chain = append([]string{jump}, chain...)
			break
		}
		seen[jump] = true
		chain = append([]string{j.JumpSpec()}, chain...)
		if j.Jump == "" {
			break
		}
		jump = j.Jump
	}
	h.Jump = strings.Join(chain, ",")
}

func cmdConnect(name string, extra []string) error {
	st, err := store.Load()
	if err != nil {
		return err
	}
	h, err := store.Resolve(st.List(), name)
	if err != nil {
		return err
	}
	resolveJump(st, &h)
	return connect.Exec(h, extra)
}

func runTUI() error {
	for {
		st, err := store.Load()
		if err != nil {
			return err
		}
		res, err := tui.Run(st.List(), len(sshConfigCandidates(st)) > 0)
		if err != nil {
			return err
		}

		switch res.Action {
		case tui.ActionQuit:
			return nil

		case tui.ActionConnect:
			h := res.Host
			resolveJump(st, &h)
			return connect.Exec(h, nil)

		case tui.ActionAdd:
			h := host.Host{}
			if err := editForm(st, &h, ""); err != nil {
				return err
			}

		case tui.ActionEdit:
			h := res.Host
			if err := editForm(st, &h, res.Host.Name); err != nil {
				return err
			}

		case tui.ActionDelete:
			if tui.Confirm(fmt.Sprintf("Supprimer %s (%s) ?", res.Host.Name, res.Host.Target())) {
				st.Remove(res.Host.Name)
				if err := st.Save(); err != nil {
					return err
				}
			}

		case tui.ActionImportSSH:
			if _, err := importSSHConfig(st, sshConfigCandidates(st)); err != nil {
				return err
			}
		}
	}
}

// editForm ouvre le formulaire, puis persiste (gère aussi le renommage).
// Une annulation du formulaire n'est pas une erreur.
func editForm(st *store.Store, h *host.Host, oldName string) error {
	exists := func(name string) bool { _, ok := st.Get(name); return ok }
	if err := tui.HostForm(h, oldName, exists); err != nil {
		if errors.Is(err, tui.ErrAborted) {
			return nil
		}
		return err
	}
	if oldName != "" && oldName != h.Name {
		st.Remove(oldName)
	}
	st.Set(*h)
	return st.Save()
}

func cmdAdd(args []string) error {
	if len(args) < 2 {
		return errors.New("usage : sssh add <nom> <[user@]host[:port]> [-p port] [-i clé] [-J jump] [-t tags]")
	}
	name, target := args[0], args[1]

	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	port := fs.Int("p", 0, "port")
	identity := fs.String("i", "", "clé privée")
	jump := fs.String("J", "", "jump host (ProxyJump)")
	tags := fs.String("t", "", "tags séparés par des virgules")
	if err := fs.Parse(args[2:]); err != nil {
		return err
	}

	st, err := store.Load()
	if err != nil {
		return err
	}
	if _, ok := st.Get(name); ok {
		return fmt.Errorf("%q existe déjà (sssh edit %s pour le modifier)", name, name)
	}

	h, err := host.Parse(name, target)
	if err != nil {
		return err
	}
	if *port != 0 {
		h.Port = *port
	}
	h.Identity = *identity
	h.Jump = *jump
	for _, t := range strings.Split(*tags, ",") {
		if t = strings.TrimSpace(t); t != "" {
			h.Tags = append(h.Tags, t)
		}
	}

	st.Set(h)
	if err := st.Save(); err != nil {
		return err
	}
	fmt.Printf("✓ %s ajouté (%s)\n", h.Name, h.Describe())
	return nil
}

func cmdRm(args []string) error {
	if len(args) != 1 {
		return errors.New("usage : sssh rm <nom>")
	}
	st, err := store.Load()
	if err != nil {
		return err
	}
	if !st.Remove(args[0]) {
		return fmt.Errorf("host inconnu : %q", args[0])
	}
	if err := st.Save(); err != nil {
		return err
	}
	fmt.Printf("✓ %s supprimé\n", args[0])
	return nil
}

func cmdEdit(args []string) error {
	if len(args) != 1 {
		return errors.New("usage : sssh edit <nom>")
	}
	st, err := store.Load()
	if err != nil {
		return err
	}
	h, ok := st.Get(args[0])
	if !ok {
		return fmt.Errorf("host inconnu : %q", args[0])
	}
	return editForm(st, &h, args[0])
}

func cmdList(args []string) error {
	st, err := store.Load()
	if err != nil {
		return err
	}
	hosts := st.List()

	if len(args) > 0 && args[0] == "--names" {
		for _, h := range hosts {
			fmt.Println(h.Name)
		}
		return nil
	}

	if len(hosts) == 0 {
		fmt.Println("Aucun host. Ajoute-en un : sssh add <nom> <user@host>")
		if n := len(sshConfigCandidates(st)); n > 0 {
			fmt.Printf("(%d hosts trouvés dans ~/.ssh/config : sssh import-ssh pour les importer)\n", n)
		}
		return nil
	}
	w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
	fmt.Fprintln(w, "NOM\tCIBLE\tPORT\tTAGS")
	for _, h := range hosts {
		port := ""
		if h.Port != 0 && h.Port != 22 {
			port = fmt.Sprint(h.Port)
		}
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
			h.Name, h.Target(), port, strings.Join(h.Tags, ","))
	}
	return w.Flush()
}

// importSSHConfig ouvre la multi-sélection sur candidates et enregistre
// les hosts choisis ; rend le nombre importé (annulation = 0, sans erreur).
func importSSHConfig(st *store.Store, candidates []host.Host) (int, error) {
	selected, err := tui.SelectSSHConfigHosts(candidates)
	if err != nil {
		if errors.Is(err, tui.ErrAborted) {
			return 0, nil
		}
		return 0, err
	}
	for _, h := range selected {
		st.Set(h)
	}
	if len(selected) == 0 {
		return 0, nil
	}
	return len(selected), st.Save()
}

func cmdImportSSH(args []string) error {
	st, err := store.Load()
	if err != nil {
		return err
	}

	// Sans argument : sélection interactive parmi les hosts non enregistrés.
	if len(args) == 0 {
		candidates := sshConfigCandidates(st)
		if len(candidates) == 0 {
			fmt.Println("Rien à importer : aucun host de ~/.ssh/config n'est absent de sssh.")
			return nil
		}
		n, err := importSSHConfig(st, candidates)
		if err != nil {
			return err
		}
		fmt.Printf("✓ %d importés depuis ~/.ssh/config\n", n)
		return nil
	}

	byName := map[string]host.Host{}
	for _, h := range sshconf.Hosts() {
		byName[h.Name] = h
	}
	added, skipped := 0, 0
	for _, name := range args {
		h, ok := byName[name]
		if !ok {
			return fmt.Errorf("host absent de ~/.ssh/config : %q", name)
		}
		if _, exists := st.Get(name); exists {
			skipped++
			continue
		}
		st.Set(h)
		added++
	}
	if err := st.Save(); err != nil {
		return err
	}
	fmt.Printf("✓ %d importés, %d ignorés (déjà enregistrés)\n", added, skipped)
	return nil
}

func cmdExport(args []string) error {
	st, err := store.Load()
	if err != nil {
		return err
	}
	data, err := st.Marshal()
	if err != nil {
		return err
	}
	if len(args) > 0 {
		if err := os.WriteFile(args[0], data, 0o600); err != nil {
			return err
		}
		fmt.Printf("✓ %d hosts exportés vers %s\n", len(st.Hosts), args[0])
		return nil
	}
	_, err = os.Stdout.Write(data)
	return err
}

func cmdImport(args []string) error {
	force := false
	var path string
	for _, a := range args {
		if a == "--force" || a == "-f" {
			force = true
		} else {
			path = a
		}
	}
	if path == "" {
		return errors.New("usage : sssh import <fichier> [--force]")
	}

	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	var in struct {
		Hosts map[string]host.Host `toml:"hosts"`
	}
	if err := toml.Unmarshal(data, &in); err != nil {
		return fmt.Errorf("%s : %w", path, err)
	}

	st, err := store.Load()
	if err != nil {
		return err
	}
	added, skipped := 0, 0
	for name, h := range in.Hosts {
		if _, ok := st.Get(name); ok && !force {
			skipped++
			continue
		}
		h.Name = name
		st.Set(h)
		added++
	}
	if err := st.Save(); err != nil {
		return err
	}
	fmt.Printf("✓ %d importés, %d ignorés (déjà présents, --force pour écraser)\n", added, skipped)
	return nil
}