├── LICENSE ├── README.md ├── examples └── simple │ ├── .gitignore │ ├── handlers.go │ ├── main.go │ ├── template.go │ └── templates │ ├── layout │ ├── footer.html │ └── header.html │ └── pages │ ├── home.html │ ├── login.html │ ├── profile.html │ ├── signup.html │ ├── snippets.html │ ├── unauthorized.html │ └── users.html ├── go.mod ├── go.sum ├── server.go └── store.go /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Manos Ragiadakos 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Badgermole 2 | 3 | Badgermole is a library for creating an SSH App server for OTP (One-Time Passwords) for authenticating requests to HTTP server.
4 | This library exists because usernames and passwords are unsafe in this day and age, while other OTP mechanics have become complicated. 5 | 6 | ## Install 7 | 8 | ```bash 9 | go get github.com/rm4n0s/badgermole 10 | ``` 11 | 12 | 13 | ## How to use 14 | There is an example in 'examples/simple' which is a web server that allows the user to sign up with the SSH public key and login with the OTP from SSH. 15 | ```bash 16 | # 1) build the example 17 | cd examples/simple 18 | go build 19 | 20 | # 2) visit the home page 21 | firefox localhost:3000 22 | 23 | # 3) copy the public key 24 | cat ~/.ssh/your_public_key 25 | 26 | # 4) visit localhost:3000/signup and add a name and the key 27 | 28 | # 5) copy the OTP from SSH 29 | ssh -i ~/.ssh/your_private_key -p 10000 localhost 30 | # If you have signed up correctly then you will see something like One Time Password = 31 | # You can also get the OTP in a json format if you put "json" at the end of the command line 32 | 33 | # 6) visit localhost:3000/login and submit the One Time Password before a minute pass. 34 | # now a cookie is created that shows you are authorized 35 | 36 | ``` 37 | 38 | This is just a preview 39 | 40 | ```go 41 | import "github.com/rm4n0s/badgermole" 42 | 43 | func main(){ 44 | cfg := &badgermole.Config{ 45 | SshHost: "localhost:3000", 46 | SshKeyPath: "sshkeys/somekey", // if it does not exist, then it will create it automatically 47 | SshAuthFunc: func(ctx ssh.Context, key ssh.PublicKey) bool { 48 | // authenticate key and return true if it is authorized to receive OTP" 49 | return true 50 | }, 51 | Store: badgermole.NewMemoryStore(), // you can create your own DB for OTP as long as it implements IStore interface 52 | } 53 | bmSrv, err := badgermole.NewServer(cfg) 54 | if err != nil { 55 | log.Fatal("Error:", err) 56 | } 57 | 58 | err := bmSrv.Start() 59 | if err != nil { 60 | log.Fatal("Error:", err) 61 | } 62 | } 63 | 64 | 65 | 66 | ``` 67 | 68 | 69 | -------------------------------------------------------------------------------- /examples/simple/.gitignore: -------------------------------------------------------------------------------- 1 | .ssh 2 | simple -------------------------------------------------------------------------------- /examples/simple/handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/charmbracelet/ssh" 12 | "github.com/rm4n0s/badgermole" 13 | gossh "golang.org/x/crypto/ssh" 14 | ) 15 | 16 | type Name string 17 | type SshKey string 18 | 19 | type Handlers struct { 20 | users map[SshKey]Name 21 | userMutex *sync.RWMutex 22 | tmpl *Template 23 | otpStore *badgermole.MemoryStore 24 | } 25 | 26 | func NewHandlers(otpStore *badgermole.MemoryStore) *Handlers { 27 | return &Handlers{ 28 | users: map[SshKey]Name{}, 29 | tmpl: NewTemplate(), 30 | otpStore: otpStore, 31 | userMutex: &sync.RWMutex{}, 32 | } 33 | } 34 | func (h *Handlers) sshAuthHandler(ctx ssh.Context, key ssh.PublicKey) bool { 35 | authorizedKey := gossh.MarshalAuthorizedKey(key) 36 | cleanedKey := strings.Replace(string(authorizedKey), "\n", "", -1) 37 | h.userMutex.RLock() 38 | defer h.userMutex.RUnlock() 39 | name, ok := h.users[SshKey(cleanedKey)] 40 | if ok { 41 | log.Println(name, "passed SSH authentication") 42 | return true 43 | } 44 | 45 | return false 46 | } 47 | 48 | func (h *Handlers) getHomeHandler(w http.ResponseWriter, r *http.Request) { 49 | _, err := r.Cookie("authorized") 50 | authorized := err == nil 51 | 52 | h.tmpl.Render(w, "home.html", authorized) 53 | } 54 | 55 | func (h *Handlers) getSignUpHandler(w http.ResponseWriter, r *http.Request) { 56 | h.tmpl.Render(w, "signup.html", nil) 57 | } 58 | 59 | func (h *Handlers) postSignUpHandler(w http.ResponseWriter, r *http.Request) { 60 | name := r.FormValue("name") 61 | pk := r.FormValue("publickey") 62 | log.Println("New user signed up and the name is", name, "with public key", pk) 63 | if len(name) == 0 { 64 | h.tmpl.Render(w, "error", "name is empty") 65 | return 66 | } 67 | if len(pk) == 0 { 68 | h.tmpl.Render(w, "error", "public key is empty") 69 | return 70 | } 71 | parsed, _, _, _, err := ssh.ParseAuthorizedKey( 72 | []byte(pk), 73 | ) 74 | if err != nil { 75 | h.tmpl.Render(w, "error", fmt.Sprint("public key error:", err.Error())) 76 | return 77 | } 78 | authorizedKey := gossh.MarshalAuthorizedKey(parsed) 79 | cleanedKey := strings.Replace(string(authorizedKey), "\n", "", -1) 80 | h.userMutex.Lock() 81 | h.users[SshKey(cleanedKey)] = Name(name) 82 | h.userMutex.Unlock() 83 | 84 | h.tmpl.Render(w, "signup-success", nil) 85 | } 86 | 87 | func (h *Handlers) getLoginHandler(w http.ResponseWriter, r *http.Request) { 88 | h.tmpl.Render(w, "login.html", nil) 89 | } 90 | 91 | func (h *Handlers) postLoginHandler(w http.ResponseWriter, r *http.Request) { 92 | otp := r.FormValue("otp") 93 | if len(otp) == 0 { 94 | h.tmpl.Render(w, "not-correct-otp", nil) 95 | return 96 | } 97 | au, err := h.otpStore.GetUserFromOtp(r.Context(), otp) 98 | if err != nil { 99 | h.tmpl.Render(w, "not-correct-otp", nil) 100 | return 101 | } 102 | h.userMutex.RLock() 103 | defer h.userMutex.RUnlock() 104 | name, ok := h.users[SshKey(au.SshPublicKey)] 105 | if !ok { 106 | h.tmpl.Render(w, "error", "user does not exist anymore") 107 | return 108 | } 109 | cookie := http.Cookie{ 110 | Name: "authorized", 111 | Value: string(name), 112 | Path: "/", 113 | MaxAge: 3600, 114 | HttpOnly: true, 115 | Secure: true, 116 | SameSite: http.SameSiteLaxMode, 117 | } 118 | 119 | http.SetCookie(w, &cookie) 120 | 121 | h.otpStore.RemoveOtp(r.Context(), au.SshPublicKey) 122 | http.Redirect(w, r, "/", http.StatusSeeOther) 123 | } 124 | 125 | func (h *Handlers) getUsersHandler(w http.ResponseWriter, r *http.Request) { 126 | _, err := r.Cookie("authorized") 127 | if err != nil { 128 | switch { 129 | case errors.Is(err, http.ErrNoCookie): 130 | h.tmpl.Render(w, "unauthorized.html", nil) 131 | default: 132 | log.Println("Error:", err) 133 | http.Error(w, "server error", http.StatusInternalServerError) 134 | } 135 | return 136 | } 137 | h.userMutex.RLock() 138 | defer h.userMutex.RUnlock() 139 | h.tmpl.Render(w, "users.html", h.users) 140 | } 141 | 142 | func (h *Handlers) getProfileHandler(w http.ResponseWriter, r *http.Request) { 143 | cookie, err := r.Cookie("authorized") 144 | if err != nil { 145 | switch { 146 | case errors.Is(err, http.ErrNoCookie): 147 | h.tmpl.Render(w, "unauthorized.html", nil) 148 | default: 149 | log.Println("Error:", err) 150 | http.Error(w, "server error", http.StatusInternalServerError) 151 | } 152 | return 153 | } 154 | name := Name(cookie.Value) 155 | var key SshKey 156 | h.userMutex.RLock() 157 | defer h.userMutex.RUnlock() 158 | for k, v := range h.users { 159 | if v == name { 160 | key = k 161 | break 162 | } 163 | } 164 | 165 | user := struct { 166 | Name string 167 | Key string 168 | }{ 169 | Name: string(name), 170 | Key: string(key), 171 | } 172 | 173 | h.tmpl.Render(w, "profile.html", user) 174 | } 175 | 176 | func (h *Handlers) getLogoutHandler(w http.ResponseWriter, r *http.Request) { 177 | cookie, err := r.Cookie("authorized") 178 | if err != nil { 179 | switch { 180 | case errors.Is(err, http.ErrNoCookie): 181 | h.tmpl.Render(w, "unauthorized.html", nil) 182 | default: 183 | log.Println("Error:", err) 184 | http.Error(w, "server error", http.StatusInternalServerError) 185 | } 186 | return 187 | } 188 | cookie.MaxAge = -1 189 | http.SetCookie(w, cookie) 190 | http.Redirect(w, r, "/", http.StatusSeeOther) 191 | } 192 | -------------------------------------------------------------------------------- /examples/simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/rm4n0s/badgermole" 14 | ) 15 | 16 | func main() { 17 | log.SetFlags(log.LstdFlags | log.Lshortfile) 18 | var ( 19 | webhost string 20 | sshhost string 21 | keypath string 22 | ) 23 | flag.StringVar(&webhost, "webhost", "localhost:3000", "ip:port of the website") 24 | flag.StringVar(&sshhost, "sshhost", "localhost:10000", "ip:port of the ssh OTP") 25 | flag.StringVar(&keypath, "keypath", ".ssh/id_ed25519", "the path of the private key file path for the SSH OTP app") 26 | flag.Parse() 27 | otpStore := badgermole.NewMemoryStore() 28 | mux := http.NewServeMux() 29 | handlers := NewHandlers(otpStore) 30 | cfg := &badgermole.Config{ 31 | SshHost: sshhost, 32 | SshKeyPath: keypath, 33 | SshAuthFunc: handlers.sshAuthHandler, 34 | Store: otpStore, 35 | } 36 | bmSrv, err := badgermole.NewServer(cfg) 37 | if err != nil { 38 | log.Fatal("Error:", err) 39 | } 40 | mux.HandleFunc("GET /", handlers.getHomeHandler) 41 | mux.HandleFunc("GET /signup", handlers.getSignUpHandler) 42 | mux.HandleFunc("POST /signup", handlers.postSignUpHandler) 43 | mux.HandleFunc("GET /login", handlers.getLoginHandler) 44 | mux.HandleFunc("POST /login", handlers.postLoginHandler) 45 | mux.HandleFunc("GET /users", handlers.getUsersHandler) 46 | mux.HandleFunc("GET /profile", handlers.getProfileHandler) 47 | mux.HandleFunc("GET /logout", handlers.getLogoutHandler) 48 | 49 | httpSrv := &http.Server{ 50 | Addr: webhost, 51 | Handler: mux, 52 | } 53 | log.Println("Web server:", webhost) 54 | log.Println("OTP server:", sshhost) 55 | 56 | go func(bmSrv *badgermole.Server) { 57 | err := bmSrv.Run() 58 | if err != nil { 59 | log.Println("Error:", err) 60 | } 61 | }(bmSrv) 62 | 63 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 64 | defer stop() 65 | // clean OTP that lasted a minute 66 | go otpStore.RunCleanScheduler(ctx, 1*time.Minute) 67 | 68 | go func(ctx context.Context) { 69 | <-ctx.Done() 70 | bmSrv.Shutdown(ctx) 71 | httpSrv.Shutdown(ctx) 72 | log.Println("bye!") 73 | }(ctx) 74 | 75 | httpSrv.ListenAndServe() 76 | } 77 | -------------------------------------------------------------------------------- /examples/simple/template.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | "log" 7 | ) 8 | 9 | type Template struct { 10 | tmpl *template.Template 11 | } 12 | 13 | func NewTemplate() *Template { 14 | g, err := template.ParseGlob("templates/**/*.html") 15 | if err != nil { 16 | log.Fatal(err) 17 | } 18 | return &Template{ 19 | tmpl: template.Must(g, err), 20 | } 21 | } 22 | 23 | func (t *Template) Render(w io.Writer, name string, data interface{}) error { 24 | return t.tmpl.ExecuteTemplate(w, name, data) 25 | } 26 | -------------------------------------------------------------------------------- /examples/simple/templates/layout/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/simple/templates/layout/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Simple 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/simple/templates/pages/home.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" . }} 2 | 3 | 4 |

Home

5 | 6 | 17 | 18 | 19 | 20 | {{template "footer.html" . }} 21 | -------------------------------------------------------------------------------- /examples/simple/templates/pages/login.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" . }} 2 | 3 | 4 |

Login

5 |
6 |

To receive One Time Password, execute this command line ssh -i ~/.ssh/your_private_key -p 10000 localhost

7 |
8 |
9 |
10 |
11 | 12 |
13 | 14 | 15 | {{template "footer.html" . }} 16 | -------------------------------------------------------------------------------- /examples/simple/templates/pages/profile.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" . }} 2 | 3 |

Profile

4 | 5 |

Your name is {{.Name}}

6 |
7 |

Your public key is {{.Key}}

8 | 9 | 10 | {{template "footer.html" . }} 11 | -------------------------------------------------------------------------------- /examples/simple/templates/pages/signup.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" . }} 2 | 3 | 4 |

Signup

5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 | 13 |
14 | 15 | 16 | {{template "footer.html" . }} 17 | -------------------------------------------------------------------------------- /examples/simple/templates/pages/snippets.html: -------------------------------------------------------------------------------- 1 | {{ block "error" . }} 2 |

Error: {{.}}

3 | {{end}} 4 | 5 | 6 | 7 | {{ block "signup-success" . }} 8 |

Success

9 |
10 |

Now Login

11 | {{end}} 12 | 13 | 14 | 15 | {{ block "not-correct-otp" . }} 16 |

Error: OTP is not correct

17 | {{end}} -------------------------------------------------------------------------------- /examples/simple/templates/pages/unauthorized.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" . }} 2 | 3 | 4 | 5 |

Unauthorized to view the page

6 | 7 | 8 | 9 | {{template "footer.html" . }} 10 | -------------------------------------------------------------------------------- /examples/simple/templates/pages/users.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" . }} 2 | 3 |

Users

4 | 9 | {{template "footer.html" . }} 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rm4n0s/badgermole 2 | 3 | go 1.22.6 4 | 5 | require github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa 6 | 7 | require ( 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 9 | github.com/charmbracelet/bubbletea v0.26.6 // indirect 10 | github.com/charmbracelet/keygen v0.5.0 // indirect 11 | github.com/charmbracelet/lipgloss v0.12.1 // indirect 12 | github.com/charmbracelet/log v0.4.0 // indirect 13 | github.com/charmbracelet/x/ansi v0.1.4 // indirect 14 | github.com/charmbracelet/x/input v0.1.0 // indirect 15 | github.com/charmbracelet/x/term v0.1.1 // indirect 16 | github.com/charmbracelet/x/windows v0.1.0 // indirect 17 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 18 | github.com/go-logfmt/logfmt v0.6.0 // indirect 19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/mattn/go-localereader v0.0.1 // indirect 22 | github.com/mattn/go-runewidth v0.0.15 // indirect 23 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 24 | github.com/muesli/cancelreader v0.2.2 // indirect 25 | github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 // indirect 26 | github.com/rivo/uniseg v0.4.7 // indirect 27 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 28 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 29 | golang.org/x/sync v0.7.0 // indirect 30 | golang.org/x/text v0.16.0 // indirect 31 | ) 32 | 33 | require ( 34 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 35 | github.com/charmbracelet/wish v1.4.1 36 | github.com/charmbracelet/x/conpty v0.1.0 // indirect 37 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect 38 | github.com/charmbracelet/x/termios v0.1.0 // indirect 39 | github.com/creack/pty v1.1.21 // indirect 40 | github.com/google/uuid v1.6.0 41 | golang.org/x/crypto v0.25.0 42 | golang.org/x/sys v0.22.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 2 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s= 6 | github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= 7 | github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc= 8 | github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8= 9 | github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= 10 | github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= 11 | github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= 12 | github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= 13 | github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa h1:6rePgmsJguB6Z7Y55stsEVDlWFJoUpQvOX4mdnBjgx4= 14 | github.com/charmbracelet/ssh v0.0.0-20240725163421-eb71b85b27aa/go.mod h1:LmMZag2g7ILMmWtDmU7dIlctUopwmb73KpPzj0ip1uk= 15 | github.com/charmbracelet/wish v1.4.1 h1:SbSAnD3EInzFn5a1NYzLWaJpRRrIfG9ck5peBhPriio= 16 | github.com/charmbracelet/wish v1.4.1/go.mod h1:ekqHw/OIPSdCDZHC46KCo19ppjbBBw/kKQENoQl94bk= 17 | github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= 18 | github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 19 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 20 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 21 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 22 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 23 | github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= 24 | github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= 25 | github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= 26 | github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= 27 | github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= 28 | github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= 29 | github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= 30 | github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= 31 | github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= 32 | github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 33 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 35 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 36 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 37 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 38 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 39 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 40 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 42 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 43 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 44 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 45 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 46 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 47 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 48 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 49 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 50 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 51 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 52 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 53 | github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5 h1:NiONcKK0EV5gUZcnCiPMORaZA0eBDc+Fgepl9xl4lZ8= 54 | github.com/muesli/termenv v0.15.3-0.20240509142007-81b8f94111d5/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= 55 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 56 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 57 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 58 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 59 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 60 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 61 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 62 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 63 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 64 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 65 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 66 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 67 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 68 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 69 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 70 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 73 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 74 | golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= 75 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 76 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 77 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package badgermole 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "slices" 8 | 9 | "github.com/charmbracelet/ssh" 10 | "github.com/charmbracelet/wish" 11 | "github.com/charmbracelet/wish/logging" 12 | ) 13 | 14 | type Config struct { 15 | SshHost string 16 | SshKeyPath string 17 | SshAuthFunc func(ctx ssh.Context, key ssh.PublicKey) bool 18 | Store IStore 19 | } 20 | 21 | type Server struct { 22 | srv *ssh.Server 23 | store IStore 24 | } 25 | 26 | func NewServer(config *Config) (*Server, error) { 27 | if config.SshHost == "" { 28 | return nil, errors.New("field SshHost is empty") 29 | } 30 | if config.SshKeyPath == "" { 31 | return nil, errors.New("field SshKeyPath is empty") 32 | } 33 | if config.Store == nil { 34 | return nil, errors.New("field Store is nil") 35 | } 36 | if config.SshAuthFunc == nil { 37 | return nil, errors.New("field SshAuthFunc is nil") 38 | } 39 | srv, err := wish.NewServer( 40 | wish.WithAddress(config.SshHost), 41 | wish.WithHostKeyPath(config.SshKeyPath), 42 | wish.WithPublicKeyAuth(config.SshAuthFunc), 43 | ) 44 | 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to create an SSH server: %w", err) 47 | } 48 | mole := &Server{ 49 | srv: srv, 50 | store: config.Store, 51 | } 52 | 53 | err = mole.srv.SetOption(wish.WithMiddleware( 54 | mole.sshHandler, 55 | logging.Middleware(), 56 | )) 57 | 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to set SSH middleware: %w", err) 60 | } 61 | return mole, nil 62 | } 63 | 64 | func (s *Server) sshHandler(next ssh.Handler) ssh.Handler { 65 | return func(sess ssh.Session) { 66 | u, err := s.store.AddPassForPublicKey(sess.Context(), sess.PublicKey(), sess.RemoteAddr().String()) 67 | if err != nil { 68 | wish.Println(sess, "Error: "+err.Error()) 69 | next(sess) 70 | return 71 | } 72 | if slices.Contains(sess.Command(), "json") { 73 | wish.Println(sess, u.Json()) 74 | next(sess) 75 | return 76 | } 77 | wish.Println(sess, u.String()) 78 | next(sess) 79 | } 80 | } 81 | 82 | func (s *Server) Run() error { 83 | err := s.srv.ListenAndServe() 84 | if err != nil { 85 | return fmt.Errorf("failed to serve SSH server: %w", err) 86 | } 87 | return nil 88 | } 89 | 90 | func (s *Server) Shutdown(ctx context.Context) error { 91 | err := s.srv.Shutdown(ctx) 92 | if err != nil { 93 | return fmt.Errorf("failed to stop SSH server: %w", err) 94 | } 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package badgermole 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/charmbracelet/ssh" 13 | "github.com/google/uuid" 14 | gossh "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | type AuthorizedUser struct { 18 | SshPublicKey string `json:"-"` 19 | OneTimePassword string `json:"oneTimePassword"` 20 | RemoteAddr string `json:"-"` 21 | Date time.Time `json:"-"` 22 | } 23 | 24 | func (u *AuthorizedUser) String() string { 25 | return fmt.Sprintf("One Time Password: %s\n", u.OneTimePassword) 26 | } 27 | 28 | func (u *AuthorizedUser) Json() string { 29 | b, _ := json.Marshal(u) 30 | return string(b) 31 | } 32 | 33 | type IStore interface { 34 | AddPassForPublicKey(ctx context.Context, key ssh.PublicKey, remoteAddr string) (*AuthorizedUser, error) 35 | } 36 | 37 | type MemoryStore struct { 38 | store *sync.Map // map[PublicKey]AuthorizedUser 39 | } 40 | 41 | func NewMemoryStore() *MemoryStore { 42 | return &MemoryStore{ 43 | store: &sync.Map{}, 44 | } 45 | } 46 | 47 | func (s *MemoryStore) AddPassForPublicKey(ctx context.Context, key ssh.PublicKey, remoteAddr string) (*AuthorizedUser, error) { 48 | authorizedKey := gossh.MarshalAuthorizedKey(key) 49 | cleanedKey := strings.Replace(string(authorizedKey), "\n", "", -1) 50 | pass := uuid.NewString() 51 | user := &AuthorizedUser{ 52 | SshPublicKey: cleanedKey, 53 | OneTimePassword: pass, 54 | Date: time.Now(), 55 | RemoteAddr: remoteAddr, 56 | } 57 | s.store.Store(cleanedKey, user) 58 | 59 | return user, nil 60 | } 61 | 62 | func (s *MemoryStore) OneTimePasswordExists(ctx context.Context, pass string) bool { 63 | isFound := false 64 | s.store.Range(func(key, value any) bool { 65 | user := value.(*AuthorizedUser) 66 | if user.OneTimePassword == pass { 67 | isFound = true 68 | return false 69 | } 70 | return true 71 | }) 72 | 73 | return isFound 74 | } 75 | 76 | func (s *MemoryStore) GetUserFromOtp(ctx context.Context, otp string) (*AuthorizedUser, error) { 77 | var foundUser *AuthorizedUser 78 | s.store.Range(func(key, value any) bool { 79 | user := value.(*AuthorizedUser) 80 | if user.OneTimePassword == otp { 81 | foundUser = user 82 | return false 83 | } 84 | return true 85 | }) 86 | if foundUser == nil { 87 | return nil, errors.New("not found") 88 | } 89 | 90 | return foundUser, nil 91 | } 92 | 93 | func (s *MemoryStore) RemoveOtp(ctx context.Context, pk string) { 94 | s.store.Delete(pk) 95 | } 96 | 97 | func (s *MemoryStore) RunCleanScheduler(ctx context.Context, dur time.Duration) { 98 | ticker := time.NewTicker(dur) 99 | for { 100 | select { 101 | case <-ticker.C: 102 | s.store.Range(func(key, value any) bool { 103 | user := value.(*AuthorizedUser) 104 | if time.Since(user.Date) >= dur { 105 | s.store.Delete(key) 106 | } 107 | return true 108 | }) 109 | 110 | case <-ctx.Done(): 111 | return 112 | } 113 | } 114 | } 115 | --------------------------------------------------------------------------------