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
}