From 19372c17f401702fcd299e3f08c58738fdea22a4 Mon Sep 17 00:00:00 2001 From: lash Date: Sun, 22 Sep 2024 03:06:24 +0100 Subject: [PATCH 1/7] Reinstate ssh executable --- cmd/ssh/main.go | 359 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 cmd/ssh/main.go diff --git a/cmd/ssh/main.go b/cmd/ssh/main.go new file mode 100644 index 0000000..2741798 --- /dev/null +++ b/cmd/ssh/main.go @@ -0,0 +1,359 @@ +package main + +import ( + "context" + "encoding/hex" + "errors" + "flag" + "fmt" + "net" + "path" + "os" + "sync" + + "golang.org/x/crypto/ssh" + + "git.defalsify.org/vise.git/db" + "git.defalsify.org/vise.git/engine" + "git.defalsify.org/vise.git/logging" + "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/state" + + "git.grassecon.net/urdt/ussd/internal/handlers" + "git.grassecon.net/urdt/ussd/internal/storage" +) + +var ( + wg sync.WaitGroup + keyStore db.Db + logg = logging.NewVanilla() + scriptDir = path.Join("services", "registration") +) + +type auther struct { + Ctx context.Context + auth map[string]string +} + +func NewAuther(ctx context.Context) *auther { + return &auther{ + Ctx: ctx, + auth: make(map[string]string), + } +} + +func(a *auther) Check(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + keyStore.SetLanguage(nil) + keyStore.SetPrefix(storage.DATATYPE_CUSTOM) + k := append([]byte{0x01}, pubKey.Marshal()...) + v, err := keyStore.Get(a.Ctx, k) + if err != nil { + return nil, err + } + ka := hex.EncodeToString(conn.SessionID()) + va := string(v) + a.auth[ka] = va + fmt.Fprintf(os.Stderr, "connect: %s -> %s\n", ka, v) + return nil, nil +} + +func(a *auther) FromConn(c *ssh.ServerConn) (string, error) { + if c == nil { + return "", errors.New("nil server conn") + } + if c.Conn == nil { + return "", errors.New("nil underlying conn") + } + return a.Get(c.Conn.SessionID()) +} + + +func(a *auther) Get(k []byte) (string, error) { + ka := hex.EncodeToString(k) + v, ok := a.auth[ka] + if !ok { + return "", errors.New("not found") + } + return v, nil +} + +//func serve(ctx context.Context, sessionId string, ch ssh.NewChannel, mss *storage.MenuStorageService, lhs *handlers.LocalHandlerService) error { +func serve(ctx context.Context, sessionId string, ch ssh.NewChannel, en engine.Engine) error { + if ch == nil { + return errors.New("nil channel") + } + if ch.ChannelType() != "session" { + ch.Reject(ssh.UnknownChannelType, "that is not the channel you are looking for") + return errors.New("not a session") + } + channel, requests, err := ch.Accept() + if err != nil { + panic(err) + } + defer channel.Close() + wg.Add(1) + go func(reqIn <-chan *ssh.Request) { + defer wg.Done() + for req := range reqIn { + req.Reply(req.Type == "shell", nil) + } + _ = requests + }(requests) + + cont, err := en.Exec(ctx, []byte{}) + if err != nil { + return fmt.Errorf("initial engine exec err: %v", err) + } + + var input [state.INPUT_LIMIT]byte + for cont { + c, err := en.Flush(ctx, channel) + if err != nil { + return fmt.Errorf("flush err: %v", err) + } + _, err = channel.Write([]byte{0x0a}) + if err != nil { + return fmt.Errorf("newline err: %v", err) + } + c, err = channel.Read(input[:]) + if err != nil { + return fmt.Errorf("read input fail: %v", err) + } + logg.TraceCtxf(ctx, "input read", "c", c, "input", input[:c-1]) + cont, err = en.Exec(ctx, input[:c-1]) + if err != nil { + return fmt.Errorf("engine exec err: %v", err) + } + logg.TraceCtxf(ctx, "exec cont", "cont", cont, "en", en) + _ = c + } + c, err := en.Flush(ctx, channel) + if err != nil { + return fmt.Errorf("last flush err: %v", err) + } + _ = c + return nil +} + +type sshRunner struct { + Ctx context.Context + Cfg engine.Config + FlagFile string + DbDir string + ResourceDir string + Debug bool + KeyFile string + Host string + Port uint +} + +func(s *sshRunner) GetEngine(sessionId string) (engine.Engine, func(), error) { + ctx := s.Ctx + menuStorageService := storage.NewMenuStorageService(s.DbDir, s.ResourceDir) + + err := menuStorageService.EnsureDbDir() + if err != nil { + return nil, nil, err + } + + rs, err := menuStorageService.GetResource(ctx) + if err != nil { + return nil, nil, err + } + + pe, err := menuStorageService.GetPersister(ctx) + if err != nil { + return nil, nil, err + } + + userdatastore, err := menuStorageService.GetUserdataDb(ctx) + if err != nil { + return nil, nil, err + } + + dbResource, ok := rs.(*resource.DbResource) + if !ok { + return nil, nil, err + } + + lhs, err := handlers.NewLocalHandlerService(s.FlagFile, true, dbResource, s.Cfg, rs) + lhs.SetDataStore(&userdatastore) + lhs.SetPersister(pe) + lhs.Cfg.SessionId = sessionId + + if err != nil { + return nil, nil, err + } + + hl, err := lhs.GetHandler() + if err != nil { + return nil, nil, err + } + + en := lhs.GetEngine() + en = en.WithFirst(hl.Init) + if s.Debug { + en = en.WithDebug(nil) + } + // TODO: this is getting very hacky! + closer := func() { + err := menuStorageService.Close() + if err != nil { + logg.ErrorCtxf(ctx, "menu storage service cleanup fail", "err", err) + } + } + return en, closer, nil +} + +// adapted example from crypto/ssh package, NewServerConn doc +func(s *sshRunner) Run(ctx context.Context) { + running := true + + // TODO: waitgroup should probably not be global + defer wg.Wait() + + auth := NewAuther(ctx) + cfg := ssh.ServerConfig{ + PublicKeyCallback: auth.Check, + } + + privateBytes, err := os.ReadFile(s.KeyFile) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to load private key", "err", err) + } + private, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to parse private key", "err", err) + } + cfg.AddHostKey(private) + + lst, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) + if err != nil { + panic(err) + } + + for running { + conn, err := lst.Accept() + if err != nil { + panic(err) + } + + + go func(conn net.Conn) { + defer conn.Close() + for true { + srvConn, nC, rC, err := ssh.NewServerConn(conn, &cfg) + if err != nil { + logg.InfoCtxf(ctx, "rejected client", "err", err) + return + } + logg.DebugCtxf(ctx, "ssh client connected", "conn", srvConn) + + wg.Add(1) + go func() { + ssh.DiscardRequests(rC) + wg.Done() + }() + + sessionId, err := auth.FromConn(srvConn) + if err != nil { + logg.ErrorCtxf(ctx, "Cannot find authentication") + return + } + en, closer, err := s.GetEngine(sessionId) + if err != nil { + logg.ErrorCtxf(ctx, "engine won't start", "err", err) + return + } + defer func() { + err := en.Finish() + if err != nil { + logg.ErrorCtxf(ctx, "engine won't stop", "err", err) + } + closer() + }() + for ch := range nC { + err = serve(ctx, sessionId, ch, en) + logg.ErrorCtxf(ctx, "ssh server finish", "err", err) + } + } + }(conn) + } +} + +// TODO: This is test code, move to external tool for adding and removing keys +func sshLoadKeys(ctx context.Context, dbDir string) error { + keyStoreFile := path.Join(dbDir, "ssh_authorized_keys.gdbm") + keyStore = storage.NewThreadGdbmDb() + err := keyStore.Connect(ctx, keyStoreFile) + if err != nil { + return err + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCu5rYCxMBsVAL1TEkMQgmElAYEZj5zYDdyHjUxZ6qzHBOZD9GAzdxx9GyQDx2vdYm3329tLH/69ky1YA3nUz8SnJGBD6hC5XrqwN6zo9R9oOHAKTwiPGhey2NTVmheP+9XNHukBnOlkkWOQlpDDvMbWOztaZOWDaA8OIeP0t6qzFqLyelyg65lxzM3BKd7bCmmfzl/64BcP1MotAmB9DUxmY0Wb4Q2hYZfNYBx50Z4xthTgKV+Xoo8CbTduKotIz6hluQGvWdtxlCJQEiZ2f4RYY87JSA6/BAH2fhxuLHMXRpzocJNqARqCWpdcTGSg7bzxbKvTFH9OU4wZtr9ie40OR4zsc1lOBZL0rnp8GLkG8ZmeBQrgEDlmR9TTlz4okgtL+c5TCS37rjZYVjmtGwihws0EL9+wyv2dSQibirklC4wK5eWHKXl5vab19qzw/qRLdoRBK40DxbRKggxA7gqSsKrmrf+z7CuLIz/kxF+169FBLbh1MfBOGdx1awm6aU= lash@furioso")) + if err != nil { + return err + } + k := append([]byte{0x01}, pubKey.Marshal()...) + keyStore.SetPrefix(storage.DATATYPE_CUSTOM) + return keyStore.Put(ctx, k, []byte("+25113243546")) +} + +func main() { + var dbDir string + var resourceDir string + var size uint + var engineDebug bool + var stateDebug bool + var host string + var port uint + flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from") + flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir") + flag.BoolVar(&engineDebug, "engine-debug", false, "use engine debug output") + flag.BoolVar(&stateDebug, "state-debug", false, "use engine debug output") + flag.UintVar(&size, "s", 160, "max size of output") + flag.StringVar(&host, "h", "127.0.0.1", "http host") + flag.UintVar(&port, "p", 7122, "http port") + flag.Parse() + + sshKeyFile := flag.Arg(0) + _, err := os.Stat(sshKeyFile) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot open ssh server private key file: %v\n", err) + os.Exit(1) + } + + logg.Infof("start command", "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size, "keyfile", sshKeyFile, "host", host, "port", port) + + ctx := context.Background() + pfp := path.Join(scriptDir, "pp.csv") + + cfg := engine.Config{ + Root: "root", + OutputSize: uint32(size), + FlagCount: uint32(16), + } + if stateDebug { + cfg.StateDebug = true + } + if engineDebug { + cfg.EngineDebug = true + } + + err = sshLoadKeys(ctx, dbDir) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + runner := &sshRunner{ + Cfg: cfg, + Debug: engineDebug, + FlagFile: pfp, + DbDir: dbDir, + ResourceDir: resourceDir, + KeyFile: sshKeyFile, + Host: host, + Port: port, + } + runner.Run(ctx) +} From 935b777e57901adab793454309fb9445a9330436 Mon Sep 17 00:00:00 2001 From: lash Date: Sun, 22 Sep 2024 14:41:01 +0100 Subject: [PATCH 2/7] Factor out ssh service code --- cmd/ssh/main.go | 297 +++----------------------------------------- internal/ssh/ssh.go | 279 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 282 deletions(-) create mode 100644 internal/ssh/ssh.go diff --git a/cmd/ssh/main.go b/cmd/ssh/main.go index 2741798..972bdaf 100644 --- a/cmd/ssh/main.go +++ b/cmd/ssh/main.go @@ -2,25 +2,18 @@ package main import ( "context" - "encoding/hex" - "errors" "flag" "fmt" - "net" "path" "os" "sync" - "golang.org/x/crypto/ssh" - "git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/logging" - "git.defalsify.org/vise.git/resource" - "git.defalsify.org/vise.git/state" - "git.grassecon.net/urdt/ussd/internal/handlers" "git.grassecon.net/urdt/ussd/internal/storage" + "git.grassecon.net/urdt/ussd/internal/ssh" ) var ( @@ -30,274 +23,6 @@ var ( scriptDir = path.Join("services", "registration") ) -type auther struct { - Ctx context.Context - auth map[string]string -} - -func NewAuther(ctx context.Context) *auther { - return &auther{ - Ctx: ctx, - auth: make(map[string]string), - } -} - -func(a *auther) Check(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { - keyStore.SetLanguage(nil) - keyStore.SetPrefix(storage.DATATYPE_CUSTOM) - k := append([]byte{0x01}, pubKey.Marshal()...) - v, err := keyStore.Get(a.Ctx, k) - if err != nil { - return nil, err - } - ka := hex.EncodeToString(conn.SessionID()) - va := string(v) - a.auth[ka] = va - fmt.Fprintf(os.Stderr, "connect: %s -> %s\n", ka, v) - return nil, nil -} - -func(a *auther) FromConn(c *ssh.ServerConn) (string, error) { - if c == nil { - return "", errors.New("nil server conn") - } - if c.Conn == nil { - return "", errors.New("nil underlying conn") - } - return a.Get(c.Conn.SessionID()) -} - - -func(a *auther) Get(k []byte) (string, error) { - ka := hex.EncodeToString(k) - v, ok := a.auth[ka] - if !ok { - return "", errors.New("not found") - } - return v, nil -} - -//func serve(ctx context.Context, sessionId string, ch ssh.NewChannel, mss *storage.MenuStorageService, lhs *handlers.LocalHandlerService) error { -func serve(ctx context.Context, sessionId string, ch ssh.NewChannel, en engine.Engine) error { - if ch == nil { - return errors.New("nil channel") - } - if ch.ChannelType() != "session" { - ch.Reject(ssh.UnknownChannelType, "that is not the channel you are looking for") - return errors.New("not a session") - } - channel, requests, err := ch.Accept() - if err != nil { - panic(err) - } - defer channel.Close() - wg.Add(1) - go func(reqIn <-chan *ssh.Request) { - defer wg.Done() - for req := range reqIn { - req.Reply(req.Type == "shell", nil) - } - _ = requests - }(requests) - - cont, err := en.Exec(ctx, []byte{}) - if err != nil { - return fmt.Errorf("initial engine exec err: %v", err) - } - - var input [state.INPUT_LIMIT]byte - for cont { - c, err := en.Flush(ctx, channel) - if err != nil { - return fmt.Errorf("flush err: %v", err) - } - _, err = channel.Write([]byte{0x0a}) - if err != nil { - return fmt.Errorf("newline err: %v", err) - } - c, err = channel.Read(input[:]) - if err != nil { - return fmt.Errorf("read input fail: %v", err) - } - logg.TraceCtxf(ctx, "input read", "c", c, "input", input[:c-1]) - cont, err = en.Exec(ctx, input[:c-1]) - if err != nil { - return fmt.Errorf("engine exec err: %v", err) - } - logg.TraceCtxf(ctx, "exec cont", "cont", cont, "en", en) - _ = c - } - c, err := en.Flush(ctx, channel) - if err != nil { - return fmt.Errorf("last flush err: %v", err) - } - _ = c - return nil -} - -type sshRunner struct { - Ctx context.Context - Cfg engine.Config - FlagFile string - DbDir string - ResourceDir string - Debug bool - KeyFile string - Host string - Port uint -} - -func(s *sshRunner) GetEngine(sessionId string) (engine.Engine, func(), error) { - ctx := s.Ctx - menuStorageService := storage.NewMenuStorageService(s.DbDir, s.ResourceDir) - - err := menuStorageService.EnsureDbDir() - if err != nil { - return nil, nil, err - } - - rs, err := menuStorageService.GetResource(ctx) - if err != nil { - return nil, nil, err - } - - pe, err := menuStorageService.GetPersister(ctx) - if err != nil { - return nil, nil, err - } - - userdatastore, err := menuStorageService.GetUserdataDb(ctx) - if err != nil { - return nil, nil, err - } - - dbResource, ok := rs.(*resource.DbResource) - if !ok { - return nil, nil, err - } - - lhs, err := handlers.NewLocalHandlerService(s.FlagFile, true, dbResource, s.Cfg, rs) - lhs.SetDataStore(&userdatastore) - lhs.SetPersister(pe) - lhs.Cfg.SessionId = sessionId - - if err != nil { - return nil, nil, err - } - - hl, err := lhs.GetHandler() - if err != nil { - return nil, nil, err - } - - en := lhs.GetEngine() - en = en.WithFirst(hl.Init) - if s.Debug { - en = en.WithDebug(nil) - } - // TODO: this is getting very hacky! - closer := func() { - err := menuStorageService.Close() - if err != nil { - logg.ErrorCtxf(ctx, "menu storage service cleanup fail", "err", err) - } - } - return en, closer, nil -} - -// adapted example from crypto/ssh package, NewServerConn doc -func(s *sshRunner) Run(ctx context.Context) { - running := true - - // TODO: waitgroup should probably not be global - defer wg.Wait() - - auth := NewAuther(ctx) - cfg := ssh.ServerConfig{ - PublicKeyCallback: auth.Check, - } - - privateBytes, err := os.ReadFile(s.KeyFile) - if err != nil { - logg.ErrorCtxf(ctx, "Failed to load private key", "err", err) - } - private, err := ssh.ParsePrivateKey(privateBytes) - if err != nil { - logg.ErrorCtxf(ctx, "Failed to parse private key", "err", err) - } - cfg.AddHostKey(private) - - lst, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) - if err != nil { - panic(err) - } - - for running { - conn, err := lst.Accept() - if err != nil { - panic(err) - } - - - go func(conn net.Conn) { - defer conn.Close() - for true { - srvConn, nC, rC, err := ssh.NewServerConn(conn, &cfg) - if err != nil { - logg.InfoCtxf(ctx, "rejected client", "err", err) - return - } - logg.DebugCtxf(ctx, "ssh client connected", "conn", srvConn) - - wg.Add(1) - go func() { - ssh.DiscardRequests(rC) - wg.Done() - }() - - sessionId, err := auth.FromConn(srvConn) - if err != nil { - logg.ErrorCtxf(ctx, "Cannot find authentication") - return - } - en, closer, err := s.GetEngine(sessionId) - if err != nil { - logg.ErrorCtxf(ctx, "engine won't start", "err", err) - return - } - defer func() { - err := en.Finish() - if err != nil { - logg.ErrorCtxf(ctx, "engine won't stop", "err", err) - } - closer() - }() - for ch := range nC { - err = serve(ctx, sessionId, ch, en) - logg.ErrorCtxf(ctx, "ssh server finish", "err", err) - } - } - }(conn) - } -} - -// TODO: This is test code, move to external tool for adding and removing keys -func sshLoadKeys(ctx context.Context, dbDir string) error { - keyStoreFile := path.Join(dbDir, "ssh_authorized_keys.gdbm") - keyStore = storage.NewThreadGdbmDb() - err := keyStore.Connect(ctx, keyStoreFile) - if err != nil { - return err - } - pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCu5rYCxMBsVAL1TEkMQgmElAYEZj5zYDdyHjUxZ6qzHBOZD9GAzdxx9GyQDx2vdYm3329tLH/69ky1YA3nUz8SnJGBD6hC5XrqwN6zo9R9oOHAKTwiPGhey2NTVmheP+9XNHukBnOlkkWOQlpDDvMbWOztaZOWDaA8OIeP0t6qzFqLyelyg65lxzM3BKd7bCmmfzl/64BcP1MotAmB9DUxmY0Wb4Q2hYZfNYBx50Z4xthTgKV+Xoo8CbTduKotIz6hluQGvWdtxlCJQEiZ2f4RYY87JSA6/BAH2fhxuLHMXRpzocJNqARqCWpdcTGSg7bzxbKvTFH9OU4wZtr9ie40OR4zsc1lOBZL0rnp8GLkG8ZmeBQrgEDlmR9TTlz4okgtL+c5TCS37rjZYVjmtGwihws0EL9+wyv2dSQibirklC4wK5eWHKXl5vab19qzw/qRLdoRBK40DxbRKggxA7gqSsKrmrf+z7CuLIz/kxF+169FBLbh1MfBOGdx1awm6aU= lash@furioso")) - if err != nil { - return err - } - k := append([]byte{0x01}, pubKey.Marshal()...) - keyStore.SetPrefix(storage.DATATYPE_CUSTOM) - return keyStore.Put(ctx, k, []byte("+25113243546")) -} - func main() { var dbDir string var resourceDir string @@ -338,22 +63,30 @@ func main() { if engineDebug { cfg.EngineDebug = true } - - err = sshLoadKeys(ctx, dbDir) + + keyStoreFile := path.Join(dbDir, "ssh_authorized_keys.gdbm") + authKeyStore := storage.NewThreadGdbmDb() + err = authKeyStore.Connect(ctx, keyStoreFile) if err != nil { - fmt.Fprintf(os.Stderr, err.Error()) + fmt.Fprintf(os.Stderr, "keystore file open error: %v", err) os.Exit(1) } + defer func() { + err := authKeyStore.Close() + if err != nil { + logg.ErrorCtxf(ctx, "keystore close error", "err", err) + } + }() - runner := &sshRunner{ + runner := &ssh.SshRunner{ Cfg: cfg, Debug: engineDebug, FlagFile: pfp, DbDir: dbDir, ResourceDir: resourceDir, - KeyFile: sshKeyFile, + SrvKeyFile: sshKeyFile, Host: host, Port: port, } - runner.Run(ctx) + runner.Run(ctx, authKeyStore) } diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..9cb00b3 --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,279 @@ +package ssh + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "net" + "os" + "sync" + + "golang.org/x/crypto/ssh" + + "git.defalsify.org/vise.git/db" + "git.defalsify.org/vise.git/engine" + "git.defalsify.org/vise.git/logging" + "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/state" + + "git.grassecon.net/urdt/ussd/internal/handlers" + "git.grassecon.net/urdt/ussd/internal/storage" +) + +var ( + logg = logging.NewVanilla().WithDomain("ssh") +) + +type auther struct { + Ctx context.Context + keyStore db.Db + auth map[string]string +} + +func NewAuther(ctx context.Context, keyStore db.Db) *auther { + return &auther{ + Ctx: ctx, + keyStore: keyStore, + auth: make(map[string]string), + } +} + +func(a *auther) Check(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + a.keyStore.SetLanguage(nil) + a.keyStore.SetPrefix(storage.DATATYPE_CUSTOM) + k := append([]byte{0x01}, pubKey.Marshal()...) + v, err := a.keyStore.Get(a.Ctx, k) + if err != nil { + return nil, err + } + ka := hex.EncodeToString(conn.SessionID()) + va := string(v) + a.auth[ka] = va + fmt.Fprintf(os.Stderr, "connect: %s -> %s\n", ka, v) + return nil, nil +} + +func(a *auther) FromConn(c *ssh.ServerConn) (string, error) { + if c == nil { + return "", errors.New("nil server conn") + } + if c.Conn == nil { + return "", errors.New("nil underlying conn") + } + return a.Get(c.Conn.SessionID()) +} + + +func(a *auther) Get(k []byte) (string, error) { + ka := hex.EncodeToString(k) + v, ok := a.auth[ka] + if !ok { + return "", errors.New("not found") + } + return v, nil +} + +func(s *SshRunner) serve(ctx context.Context, sessionId string, ch ssh.NewChannel, en engine.Engine) error { + if ch == nil { + return errors.New("nil channel") + } + if ch.ChannelType() != "session" { + ch.Reject(ssh.UnknownChannelType, "that is not the channel you are looking for") + return errors.New("not a session") + } + channel, requests, err := ch.Accept() + if err != nil { + panic(err) + } + defer channel.Close() + s.wg.Add(1) + go func(reqIn <-chan *ssh.Request) { + defer s.wg.Done() + for req := range reqIn { + req.Reply(req.Type == "shell", nil) + } + _ = requests + }(requests) + + cont, err := en.Exec(ctx, []byte{}) + if err != nil { + return fmt.Errorf("initial engine exec err: %v", err) + } + + var input [state.INPUT_LIMIT]byte + for cont { + c, err := en.Flush(ctx, channel) + if err != nil { + return fmt.Errorf("flush err: %v", err) + } + _, err = channel.Write([]byte{0x0a}) + if err != nil { + return fmt.Errorf("newline err: %v", err) + } + c, err = channel.Read(input[:]) + if err != nil { + return fmt.Errorf("read input fail: %v", err) + } + logg.TraceCtxf(ctx, "input read", "c", c, "input", input[:c-1]) + cont, err = en.Exec(ctx, input[:c-1]) + if err != nil { + return fmt.Errorf("engine exec err: %v", err) + } + logg.TraceCtxf(ctx, "exec cont", "cont", cont, "en", en) + _ = c + } + c, err := en.Flush(ctx, channel) + if err != nil { + return fmt.Errorf("last flush err: %v", err) + } + _ = c + return nil +} + +type SshRunner struct { + Ctx context.Context + Cfg engine.Config + FlagFile string + DbDir string + ResourceDir string + Debug bool + SrvKeyFile string + Host string + Port uint + wg sync.WaitGroup +} + +func(s *SshRunner) GetEngine(sessionId string) (engine.Engine, func(), error) { + ctx := s.Ctx + menuStorageService := storage.NewMenuStorageService(s.DbDir, s.ResourceDir) + + err := menuStorageService.EnsureDbDir() + if err != nil { + return nil, nil, err + } + + rs, err := menuStorageService.GetResource(ctx) + if err != nil { + return nil, nil, err + } + + pe, err := menuStorageService.GetPersister(ctx) + if err != nil { + return nil, nil, err + } + + userdatastore, err := menuStorageService.GetUserdataDb(ctx) + if err != nil { + return nil, nil, err + } + + dbResource, ok := rs.(*resource.DbResource) + if !ok { + return nil, nil, err + } + + lhs, err := handlers.NewLocalHandlerService(s.FlagFile, true, dbResource, s.Cfg, rs) + lhs.SetDataStore(&userdatastore) + lhs.SetPersister(pe) + lhs.Cfg.SessionId = sessionId + + if err != nil { + return nil, nil, err + } + + hl, err := lhs.GetHandler() + if err != nil { + return nil, nil, err + } + + en := lhs.GetEngine() + en = en.WithFirst(hl.Init) + if s.Debug { + en = en.WithDebug(nil) + } + // TODO: this is getting very hacky! + closer := func() { + err := menuStorageService.Close() + if err != nil { + logg.ErrorCtxf(ctx, "menu storage service cleanup fail", "err", err) + } + } + return en, closer, nil +} + +// adapted example from crypto/ssh package, NewServerConn doc +func(s *SshRunner) Run(ctx context.Context, keyStore db.Db) { + running := true + + // TODO: waitgroup should probably not be global + defer s.wg.Wait() + + auth := NewAuther(ctx, keyStore) + cfg := ssh.ServerConfig{ + PublicKeyCallback: auth.Check, + } + + privateBytes, err := os.ReadFile(s.SrvKeyFile) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to load private key", "err", err) + } + private, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to parse private key", "err", err) + } + cfg.AddHostKey(private) + + lst, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) + if err != nil { + panic(err) + } + + for running { + conn, err := lst.Accept() + if err != nil { + panic(err) + } + + + go func(conn net.Conn) { + defer conn.Close() + for true { + srvConn, nC, rC, err := ssh.NewServerConn(conn, &cfg) + if err != nil { + logg.InfoCtxf(ctx, "rejected client", "err", err) + return + } + logg.DebugCtxf(ctx, "ssh client connected", "conn", srvConn) + + s.wg.Add(1) + go func() { + ssh.DiscardRequests(rC) + s.wg.Done() + }() + + sessionId, err := auth.FromConn(srvConn) + if err != nil { + logg.ErrorCtxf(ctx, "Cannot find authentication") + return + } + en, closer, err := s.GetEngine(sessionId) + if err != nil { + logg.ErrorCtxf(ctx, "engine won't start", "err", err) + return + } + defer func() { + err := en.Finish() + if err != nil { + logg.ErrorCtxf(ctx, "engine won't stop", "err", err) + } + closer() + }() + for ch := range nC { + err = s.serve(ctx, sessionId, ch, en) + logg.ErrorCtxf(ctx, "ssh server finish", "err", err) + } + } + }(conn) + } +} From ada1f26b680331d9c6fb1e3ff5ea0b37a9df35ee Mon Sep 17 00:00:00 2001 From: lash Date: Sun, 22 Sep 2024 15:41:55 +0100 Subject: [PATCH 3/7] Add keystore class, separate keystore tool executable --- cmd/ssh/main.go | 37 ++++++++++++++++++----- cmd/ssh/sshkey/main.go | 44 +++++++++++++++++++++++++++ internal/ssh/keystore.go | 64 ++++++++++++++++++++++++++++++++++++++++ internal/ssh/ssh.go | 29 +++++++++--------- 4 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 cmd/ssh/sshkey/main.go create mode 100644 internal/ssh/keystore.go diff --git a/cmd/ssh/main.go b/cmd/ssh/main.go index 972bdaf..0227616 100644 --- a/cmd/ssh/main.go +++ b/cmd/ssh/main.go @@ -6,13 +6,14 @@ import ( "fmt" "path" "os" + "os/signal" "sync" + "syscall" "git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/logging" - "git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/ssh" ) @@ -47,9 +48,15 @@ func main() { os.Exit(1) } + ctx := context.Background() + logg.WarnCtxf(ctx, "!!!!! WARNING WARNING WARNING") + logg.WarnCtxf(ctx, "!!!!! =======================") + logg.WarnCtxf(ctx, "!!!!! This is not a production ready server!") + logg.WarnCtxf(ctx, "!!!!! Do not expose to internet and only use with tunnel!") + logg.WarnCtxf(ctx, "!!!!! (See ssh -L <...>)") + logg.Infof("start command", "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size, "keyfile", sshKeyFile, "host", host, "port", port) - ctx := context.Background() pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ @@ -64,20 +71,24 @@ func main() { cfg.EngineDebug = true } - keyStoreFile := path.Join(dbDir, "ssh_authorized_keys.gdbm") - authKeyStore := storage.NewThreadGdbmDb() - err = authKeyStore.Connect(ctx, keyStoreFile) + authKeyStore, err := ssh.NewSshKeyStore(ctx, dbDir) if err != nil { fmt.Fprintf(os.Stderr, "keystore file open error: %v", err) os.Exit(1) } - defer func() { - err := authKeyStore.Close() + defer func () { + logg.TraceCtxf(ctx, "shutdown auth key store reached") + err = authKeyStore.Close() if err != nil { logg.ErrorCtxf(ctx, "keystore close error", "err", err) } }() + cint := make(chan os.Signal) + cterm := make(chan os.Signal) + signal.Notify(cint, os.Interrupt, syscall.SIGINT) + signal.Notify(cterm, os.Interrupt, syscall.SIGTERM) + runner := &ssh.SshRunner{ Cfg: cfg, Debug: engineDebug, @@ -88,5 +99,17 @@ func main() { Host: host, Port: port, } + go func() { + select { + case _ = <-cint: + case _ = <-cterm: + } + logg.TraceCtxf(ctx, "shutdown runner reached") + err := runner.Stop() + if err != nil { + logg.ErrorCtxf(ctx, "runner stop error", "err", err) + } + + }() runner.Run(ctx, authKeyStore) } diff --git a/cmd/ssh/sshkey/main.go b/cmd/ssh/sshkey/main.go new file mode 100644 index 0000000..87b89a3 --- /dev/null +++ b/cmd/ssh/sshkey/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "git.grassecon.net/urdt/ussd/internal/ssh" +) + +func main() { + var dbDir string + var sessionId string + flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from") + flag.StringVar(&sessionId, "i", "", "session id") + flag.Parse() + + if sessionId == "" { + fmt.Fprintf(os.Stderr, "empty session id\n") + os.Exit(1) + } + + ctx := context.Background() + + sshKeyFile := flag.Arg(0) + if sshKeyFile == "" { + fmt.Fprintf(os.Stderr, "missing key file argument\n") + os.Exit(1) + } + + store, err := ssh.NewSshKeyStore(ctx, dbDir) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + defer store.Close() + + err = store.AddFromFile(ctx, sshKeyFile, sessionId) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} diff --git a/internal/ssh/keystore.go b/internal/ssh/keystore.go new file mode 100644 index 0000000..fed7233 --- /dev/null +++ b/internal/ssh/keystore.go @@ -0,0 +1,64 @@ +package ssh + +import ( + "context" + "fmt" + "os" + "path" + + "golang.org/x/crypto/ssh" + + "git.defalsify.org/vise.git/db" + + "git.grassecon.net/urdt/ussd/internal/storage" +) + +type SshKeyStore struct { + store db.Db +} + +func NewSshKeyStore(ctx context.Context, dbDir string) (*SshKeyStore, error) { + keyStore := &SshKeyStore{} + keyStoreFile := path.Join(dbDir, "ssh_authorized_keys.gdbm") + keyStore.store = storage.NewThreadGdbmDb() + err := keyStore.store.Connect(ctx, keyStoreFile) + if err != nil { + return nil, err + } + return keyStore, nil +} + +func(s *SshKeyStore) AddFromFile(ctx context.Context, fp string, sessionId string) error { + _, err := os.Stat(fp) + if err != nil { + return fmt.Errorf("cannot open ssh server public key file: %v\n", err) + } + + publicBytes, err := os.ReadFile(fp) + if err != nil { + return fmt.Errorf("Failed to load public key: %v", err) + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(publicBytes) + if err != nil { + return fmt.Errorf("Failed to parse public key: %v", err) + } + k := append([]byte{0x01}, pubKey.Marshal()...) + s.store.SetPrefix(storage.DATATYPE_CUSTOM) + logg.Infof("Added key", "sessionId", sessionId, "public key", string(publicBytes)) + return s.store.Put(ctx, k, []byte(sessionId)) +} + +func(s *SshKeyStore) Get(ctx context.Context, pubKey ssh.PublicKey) (string, error) { + s.store.SetLanguage(nil) + s.store.SetPrefix(storage.DATATYPE_CUSTOM) + k := append([]byte{0x01}, pubKey.Marshal()...) + v, err := s.store.Get(ctx, k) + if err != nil { + return "", err + } + return string(v), nil +} + +func(s *SshKeyStore) Close() error { + return s.store.Close() +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go index 9cb00b3..31cbc3e 100644 --- a/internal/ssh/ssh.go +++ b/internal/ssh/ssh.go @@ -11,7 +11,6 @@ import ( "golang.org/x/crypto/ssh" - "git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/resource" @@ -27,11 +26,11 @@ var ( type auther struct { Ctx context.Context - keyStore db.Db + keyStore *SshKeyStore auth map[string]string } -func NewAuther(ctx context.Context, keyStore db.Db) *auther { +func NewAuther(ctx context.Context, keyStore *SshKeyStore) *auther { return &auther{ Ctx: ctx, keyStore: keyStore, @@ -40,17 +39,13 @@ func NewAuther(ctx context.Context, keyStore db.Db) *auther { } func(a *auther) Check(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { - a.keyStore.SetLanguage(nil) - a.keyStore.SetPrefix(storage.DATATYPE_CUSTOM) - k := append([]byte{0x01}, pubKey.Marshal()...) - v, err := a.keyStore.Get(a.Ctx, k) + va, err := a.keyStore.Get(a.Ctx, pubKey) if err != nil { return nil, err } ka := hex.EncodeToString(conn.SessionID()) - va := string(v) a.auth[ka] = va - fmt.Fprintf(os.Stderr, "connect: %s -> %s\n", ka, v) + fmt.Fprintf(os.Stderr, "connect: %s -> %s\n", ka, va) return nil, nil } @@ -142,6 +137,11 @@ type SshRunner struct { Host string Port uint wg sync.WaitGroup + lst net.Listener +} + +func(s *SshRunner) Stop() error { + return s.lst.Close() } func(s *SshRunner) GetEngine(sessionId string) (engine.Engine, func(), error) { @@ -203,7 +203,7 @@ func(s *SshRunner) GetEngine(sessionId string) (engine.Engine, func(), error) { } // adapted example from crypto/ssh package, NewServerConn doc -func(s *SshRunner) Run(ctx context.Context, keyStore db.Db) { +func(s *SshRunner) Run(ctx context.Context, keyStore *SshKeyStore) { running := true // TODO: waitgroup should probably not be global @@ -224,18 +224,19 @@ func(s *SshRunner) Run(ctx context.Context, keyStore db.Db) { } cfg.AddHostKey(private) - lst, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) + s.lst, err = net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) if err != nil { panic(err) } for running { - conn, err := lst.Accept() + conn, err := s.lst.Accept() if err != nil { - panic(err) + logg.ErrorCtxf(ctx, "ssh accept error", "err", err) + running = false + continue } - go func(conn net.Conn) { defer conn.Close() for true { From e4c3e9f015bc908ccb2c91eb63a170cc30ec76c3 Mon Sep 17 00:00:00 2001 From: lash Date: Sun, 22 Sep 2024 16:09:57 +0100 Subject: [PATCH 4/7] Add ssh instructions, log host key --- cmd/ssh/README.md | 34 ++++++++++++++++++++++++++++++++++ internal/ssh/ssh.go | 4 ++++ 2 files changed, 38 insertions(+) create mode 100644 cmd/ssh/README.md diff --git a/cmd/ssh/README.md b/cmd/ssh/README.md new file mode 100644 index 0000000..071557b --- /dev/null +++ b/cmd/ssh/README.md @@ -0,0 +1,34 @@ +# URDT-USSD SSH server + +An SSH server entry point for the vise engine. + + +## Adding public keys for access + +Map your (client) public key to a session identifier (e.g. phone number) + +``` +go run -v -tags logtrace ./cmd/ssh/sshkey/main.go -i [--dbdir ] +``` + + +## Create a private key for the server + +``` +ssh-keygen -N "" -f +``` + + +## Run the server + + +``` +go run -v -tags logtrace ./cmd/ssh/main.go -h -p [--dbdir ] +``` + + +## Connect to the server + +``` +ssh -T -p +``` diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go index 31cbc3e..394f55f 100644 --- a/internal/ssh/ssh.go +++ b/internal/ssh/ssh.go @@ -3,6 +3,7 @@ package ssh import ( "context" "encoding/hex" + "encoding/base64" "errors" "fmt" "net" @@ -222,6 +223,9 @@ func(s *SshRunner) Run(ctx context.Context, keyStore *SshKeyStore) { if err != nil { logg.ErrorCtxf(ctx, "Failed to parse private key", "err", err) } + srvPub := private.PublicKey() + srvPubStr := base64.StdEncoding.EncodeToString(srvPub.Marshal()) + logg.InfoCtxf(ctx, "have server key", "type", srvPub.Type(), "public", srvPubStr) cfg.AddHostKey(private) s.lst, err = net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) From 3f3dbf414c5063bba8395999fed13ab1a2d75798 Mon Sep 17 00:00:00 2001 From: lash Date: Sun, 22 Sep 2024 16:13:30 +0100 Subject: [PATCH 5/7] Clarify keyfile use in readme --- cmd/ssh/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/ssh/README.md b/cmd/ssh/README.md index 071557b..3cffae1 100644 --- a/cmd/ssh/README.md +++ b/cmd/ssh/README.md @@ -8,14 +8,14 @@ An SSH server entry point for the vise engine. Map your (client) public key to a session identifier (e.g. phone number) ``` -go run -v -tags logtrace ./cmd/ssh/sshkey/main.go -i [--dbdir ] +go run -v -tags logtrace ./cmd/ssh/sshkey/main.go -i [--dbdir ] ``` ## Create a private key for the server ``` -ssh-keygen -N "" -f +ssh-keygen -N "" -f ``` @@ -23,12 +23,12 @@ ssh-keygen -N "" -f ``` -go run -v -tags logtrace ./cmd/ssh/main.go -h -p [--dbdir ] +go run -v -tags logtrace ./cmd/ssh/main.go -h -p [--dbdir ] ``` ## Connect to the server ``` -ssh -T -p +ssh -T -p -i ``` From c2b68231f5652f45752db96ec7008f8086a4cae6 Mon Sep 17 00:00:00 2001 From: lash Date: Sun, 22 Sep 2024 16:15:40 +0100 Subject: [PATCH 6/7] Update gitignore --- .gitignore | 2 ++ cmd/ssh/README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 54ad3ff..ddccccf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ go.work* **/*/*.bin **/*/.state/ cmd/.state/ +id_* +*.gdbm diff --git a/cmd/ssh/README.md b/cmd/ssh/README.md index 3cffae1..ff325d7 100644 --- a/cmd/ssh/README.md +++ b/cmd/ssh/README.md @@ -30,5 +30,5 @@ go run -v -tags logtrace ./cmd/ssh/main.go -h -p [--dbdir ## Connect to the server ``` -ssh -T -p -i +ssh [-v] -T -p -i ``` From ddc8f6dad12ea9f3e28d22de66fc27d9f966a848 Mon Sep 17 00:00:00 2001 From: lash Date: Sun, 22 Sep 2024 16:24:01 +0100 Subject: [PATCH 7/7] Update module dep --- go.mod | 5 ++++- go.sum | 14 ++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 7cceaa6..486f8ee 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module git.grassecon.net/urdt/ussd go 1.22.6 require ( - git.defalsify.org/vise.git v0.1.0-rc.3.0.20240920144308-b2d2c5f18f38 + git.defalsify.org/vise.git v0.1.0-rc.3.0.20240922152136-7ea16f9137b4 github.com/alecthomas/assert/v2 v2.2.2 github.com/peteole/testdata-loader v0.3.0 + golang.org/x/crypto v0.17.0 gopkg.in/leonelquinteros/gotext.v1 v1.3.1 ) +require golang.org/x/sys v0.15.0 // indirect + require ( github.com/alecthomas/participle/v2 v2.0.0 // indirect github.com/alecthomas/repr v0.2.0 // indirect diff --git a/go.sum b/go.sum index 5d235cc..8dfead8 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ -git.defalsify.org/vise.git v0.1.0-rc.3.0.20240911231817-0d23e0dbb57f h1:CuJvG3NyMoRtHUim4aZdrfjjJBg2AId7z0yp7Q97bRM= -git.defalsify.org/vise.git v0.1.0-rc.3.0.20240911231817-0d23e0dbb57f/go.mod h1:JDguWmcoWBdsnpw7PUjVZAEpdC/ubBmjdUBy3tjP63M= -git.defalsify.org/vise.git v0.1.0-rc.3.0.20240914163514-577f56f43bea h1:6ZYT+dIjd/f5vn9y5AJDZ7SQQckA6w5ZfUoKygyI11o= -git.defalsify.org/vise.git v0.1.0-rc.3.0.20240914163514-577f56f43bea/go.mod h1:JDguWmcoWBdsnpw7PUjVZAEpdC/ubBmjdUBy3tjP63M= -git.defalsify.org/vise.git v0.1.0-rc.3.0.20240920144308-b2d2c5f18f38 h1:4aAZijIcq33ixnZ+U48ckDIkwSfZL3St/CqoXZcC5K8= -git.defalsify.org/vise.git v0.1.0-rc.3.0.20240920144308-b2d2c5f18f38/go.mod h1:JDguWmcoWBdsnpw7PUjVZAEpdC/ubBmjdUBy3tjP63M= +git.defalsify.org/vise.git v0.1.0-rc.3.0.20240922152136-7ea16f9137b4 h1:IMVUK9OkZ/QtYZPHgTZ+XUs5VQ4eIewIaTyVSCF/nAY= +git.defalsify.org/vise.git v0.1.0-rc.3.0.20240922152136-7ea16f9137b4/go.mod h1:JDguWmcoWBdsnpw7PUjVZAEpdC/ubBmjdUBy3tjP63M= github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= github.com/alecthomas/participle/v2 v2.0.0 h1:Fgrq+MbuSsJwIkw3fEj9h75vDP0Er5JzepJ0/HNHv0g= @@ -32,6 +28,12 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/leonelquinteros/gotext.v1 v1.3.1 h1:8d9/fdTG0kn/B7NNGV1BsEyvektXFAbkMsTZS2sFSCc=