From b41317c598ef7e7a7fff08ea2af00013f79e581a Mon Sep 17 00:00:00 2001 From: Erik Brakkee <erik@brakkee.org> Date: Sat, 3 Aug 2024 21:03:29 +0200 Subject: [PATCH] Lots of work on making easier interactive documentation, especially to make working with SSH public keys really easy. Next step is to do more validation in the UI. Specifically: * validate authorized keys * detection of accidental use of a private key Then, password based access can be disabled. --- Dockerfile | 2 +- cmd/converge/converge.go | 20 +++- cmd/converge/usage.go | 19 ++-- pkg/server/templates/about.templ | 132 ++++++++++++++++++++------- pkg/server/templates/downloads.templ | 12 +-- pkg/server/templates/usage.templ | 84 +++++++++-------- pkg/server/templates/usageinputs.go | 24 ++++- 7 files changed, 199 insertions(+), 94 deletions(-) diff --git a/Dockerfile b/Dockerfile index dfe85ee..a5d6b8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,4 +28,4 @@ COPY --from=builder /opt/converge/bin/agent \ /opt/converge/static/ COPY --from=builder /opt/converge/static/ /opt/converge/static/ -ENTRYPOINT ["/opt/converge/bin/converge", "-d", "/opt/converge/static" ] +ENTRYPOINT ["/opt/converge/bin/converge", "-s", "/opt/converge/static", "-d", "/opt/converge/static" ] diff --git a/cmd/converge/converge.go b/cmd/converge/converge.go index 5c4edf4..7076b5a 100644 --- a/cmd/converge/converge.go +++ b/cmd/converge/converge.go @@ -48,14 +48,16 @@ func printHelp(msg string) { "an embedded SSH server to provide interactive access to the end-user. This works\n" + "both on linux and on windows.\n" + "\n" + - "-d <contentdir>: directory where static content of converge is placed" + "-s <contentdir>: directory where static content of converge is placed\n" + + "-d <downloaddir>: directory where downloads of converge are placed\n" fmt.Fprintln(os.Stderr, helpText) os.Exit(1) } func main() { - downloadDir := "../static" + downloaddir := "." + staticdir := "../static" args := os.Args[1:] for len(args) > 0 && strings.HasPrefix(args[0], "-") { @@ -64,14 +66,20 @@ func main() { if len(args) <= 1 { printHelp("The -d option expects an argument") } - downloadDir = args[1] + downloaddir = args[1] + args = args[1:] + case "-s": + if len(args) <= 1 { + printHelp("The -s option expects an argument") + } + staticdir = args[1] args = args[1:] default: printHelp("Unknown option " + args[0]) } args = args[1:] } - log.Println("Content directory", downloadDir) + log.Println("Content directory", staticdir) if len(args) != 0 { printHelp("") @@ -158,7 +166,9 @@ func main() { // create filehandler with templating for html files. http.Handle("/docs/", http.StripPrefix("/docs/", http.HandlerFunc(pageHandler))) http.Handle("/static/", http.StripPrefix("/static/", - http.FileServer(http.Dir(downloadDir)))) + http.FileServer(http.Dir(staticdir)))) + http.Handle("/downloads/", http.StripPrefix("/downloads/", + http.FileServer(http.Dir(downloaddir)))) http.HandleFunc("/", catchAllHandler) // create usage generator diff --git a/cmd/converge/usage.go b/cmd/converge/usage.go index dc53a2a..8940851 100644 --- a/cmd/converge/usage.go +++ b/cmd/converge/usage.go @@ -2,16 +2,14 @@ package main import ( "converge/pkg/server/templates" - "log" "math/rand" "net/http" "os" "strconv" + "strings" ) func generateCLIExammple(w http.ResponseWriter, r *http.Request) { - log.Println("usage: got ", r.URL.Path) - err := r.ParseForm() if err != nil { http.Error(w, "Error parsing form", http.StatusBadRequest) @@ -27,14 +25,17 @@ func generateCLIExammple(w http.ResponseWriter, r *http.Request) { } remoteShells := r.Form["remote-shell"] localShells := r.Form["local-shell"] - keys := r.FormValue("ssh-keys") - log.Printf("remote_shells %v", remoteShells) - log.Printf("local_shells %v", localShells) - log.Printf("ssh-keys %v", keys) - + keysString := r.FormValue("ssh-keys") + sshPublicKeys := make([]string, 0) + for _, line := range strings.Split(keysString, "\n") { + line := strings.TrimSpace(line) + if line != "" { + sshPublicKeys = append(sshPublicKeys, line) + } + } access := getConvergeAccess(r, getAgentSshUser()) - usageInputs := templates.NewUsageInputs(id, remoteShells, localShells) + usageInputs := templates.NewUsageInputs(id, sshPublicKeys, remoteShells, localShells) err = templates.ShellUsage(access, usageInputs).Render(r.Context(), w) if err != nil { http.Error(w, err.Error(), 500) diff --git a/pkg/server/templates/about.templ b/pkg/server/templates/about.templ index 90ee0bf..a6a72f1 100644 --- a/pkg/server/templates/about.templ +++ b/pkg/server/templates/about.templ @@ -19,55 +19,123 @@ templ About() { the client and server together. </p> + <h2>other tools</h2> + + <p>Using available existing tools such as + <a href="https://github.com/namespacelabs/breakpoint">breakpoint</a> in combination + with a websocket tunneling tool such as + <a href="https://github.com/erebe/wstunnel">wstunnel</a> a similar solution can be + obtained. There are however some problems with these solutions that converge is + trying to address: + </p> + + <p> + <ul> + <li>deployment: Breakpoint uses an embedded SSH server which is a really good idea but + uses the QUIC protocol for connecting to a rendez-vous server. The rendez-vous server than + exposes a random port for every client. This make deployment on kubernetes really hard + where fixed ports must be used and QUIC is also not a widely supported protocol.</li> + <li>The problem with the random ports can be solved by using wstunnel running together + with breakpoint server in a kubernetes pod, where wstunnel can forward traffic over an + extern websocket connection to the local random port that breakpoint server is listening on.</li> + <li>breakpoint leaves it open on how users install the breakpoint executable (agent). </li> + <li>Because of the hacky nature of this setup, it is very difficult for users to use + and troubleshoot when things go wrong. </li> + </ul> + + </p> + Converve server addresses these issues in the following ways: + <ul> + <li>Use the websocket protocol both for agents and for clients, providing a fixed port and + a supported protocol for kubernetes deploymment.</li> + <li>Providing online documentation where the instructions take into account the + hostname and protocol where converge is running allowing users to cut and paste + instructions that can be used without modification. In the usage page the users + can even generate the correct agent startup commands and client connection commands + based on the type of shell they are connecting to. </li> + <li>Converge server provides out of the box downloads of required software. This makes sure + client and server are always up to date. In addition a protocol version check is done. </li> + <li>User-friendly error messages can be given to users in most case when things do not work + out because of <code>wsproxy</code>, an SSH proxy command that also talk to the server + to tell the user if a connection is accepted and if not why not. </li> + <li>A live screen showing the current sessions that are running. </li> + <li>Interactivity in the user's session with notifications about timeouts and a very + simple inactivity timmeout mechanism. </li> + <li>Possibility for the user to define his own shell. </li> + <li>Support for unix like bash shells and command prompt and powershell. </li> + </ul> + <p> + </p> + + + <h2>how it works</h2> + + <p> + The steps involved are as follows: + <ul> + <li>The agent connects to converge server. If no id is specified than a new id will + be generated. The ids specified by different agents must be unique. If the agent + specifies an id that is already in use, then a new id will be generated. + When started the agent will echo the commands to connect to it in its output. + </li> + <li> + Since the emmbedded SSH server in the agent will allow multiple clients to connect + to it, it wants to listen for copnnections. By default it cannot do this, it just + setup a connection to the converge server, but the converge server can in general + not connect back to it because of networking. Therefore, a multiplexing library is + used to establish multiple virtual connections over a single TCP connection. + The agent can now listen for connections from clients. + </li> + <li>The agent connects to the converge server using the commmand specified by the agent. + The converge server can then match the agent with the client based on the id and + the connectio at network level is established. + </li> + <li>The embedded SSH server now performs authentication, after successful login, + a shell is spwaned and the network connection of the user is connected to it. + The connection is practically identical to a regular terminal connection. To + achieve this some magic is used to make the shell beiieve it is connected to a + terminal. + </li> + </ul> + </p> + + <h2>Security</h2> + <p> The setup is such that the connection from client (end-user) to server (agent on CI job) is end-to-end encrypted. The Converge server itself is no more than a bitpipe which pumps data between client and agent. </p> + <p>Currently converge server still supports password based login but this will be disabled. + Image two people configuring an agent with the same id where one of the agents actually + gets it and other gets a new id. Now, with a password each user can access each other's + agents. This is of course highly confusing and undesirable. Converge server already support + authorized keys but this is not yet mandatory. I is made extremely easy through the + <a href="usage.html">usage</a> page to configure this, so the additional complexity should + not be an issue. + </p> + + <h2>SSH and SFTP</h2> + <p> Both ssh and sftp are supported. Multiple shells are also allowed. </p> + <h2>Timeouts</h2> + <p> There is a timeout mechanism in the agent such that jobs do not hang indefinitely waiting for a connection. This mechanism is useful to make sure build agents do not keep build agents occupied for a long time. By default, the agent exits with status 0 when - the first client exits after logging in. This behavior as well as general expiry can be - controlled from within a shell session by touching a .hold file. After logging in, the - user can control expiry of the session as instructed by messages in the ssh session. - When the timeout of a session is near the user is informed about this with messages - in the shell. + the first client exits after logging in. </p> - - <p>end-to-end encryoption</p> - <p> ssh keys</p> - <p>agent options </p> - <p>client access </p> - - <h2>Local clients: using ssh with a proxy command </h2> - - <p> - <code>wsproxy</code> is a command that can be used as a proxy command for SSH which performs the connection to the - remote server. This command needs to be downloaded only once (see <a href="downloads.html">downloads</a>). It does not depend on - the converge implementation but only on the websocket standards. Other tools that - provide a mapping of stdio to a websocket can also be used instead of wsproxy. + <p>When the user touches a .hold file, the agent keeps waiting for connections even + after the last client logs out, taking into account the timeout. </p> + <p>The sessions have an inactivity timeout. Any keypress on the keyboard by a user + is interpreted as activity. </p> - <h2>Local clients: using SSH with a local TCP forwarding proxy</h2> - - <p> - This option is less convenient than the proxy command because it requires two separate - commands to execute. - </p> - - <p> - Local clients can connect using regular ssh and sftp commands through a tunnel that - translates a local TCP port to a websocket connection in converge. See - the <a href="downloads.html">downloads</a> section. - This runs a local client that allows SSH to port 10000 and connects to converge using - a websocket connection. - </p> <h2>Remote shell usage</h2> diff --git a/pkg/server/templates/downloads.templ b/pkg/server/templates/downloads.templ index 2899ab1..c9125b9 100644 --- a/pkg/server/templates/downloads.templ +++ b/pkg/server/templates/downloads.templ @@ -17,20 +17,20 @@ templ Downloads() { </thead> <tr> <td>agent</td> - <td><a href="../static/agent">agent</a></td> - <td><a href="../static/agent.exe">agent.exe</a></td> + <td><a href="../downloads/agent">agent</a></td> + <td><a href="../downloads/agent.exe">agent.exe</a></td> <td>The agent to run inside aa CI job</td> </tr> <tr> <td>wsproxy</td> - <td><a href="../static/wsproxy">wsproxy</a></td> - <td><a href="../static/wsproxy.exe">wsproxy.exe</a></td> + <td><a href="../downloads/wsproxy">wsproxy</a></td> + <td><a href="../downloads/wsproxy.exe">wsproxy.exe</a></td> <td>SSH proxy command that can be directly used by ssh</td> </tr> <tr> <td>tcptows</td> - <td><a href="../static/tcptows">tcptows</a></td> - <td><a href="../static/tcptows.exe">tcptows.exe</a></td> + <td><a href="../downloads/tcptows">tcptows</a></td> + <td><a href="../downloads/tcptows.exe">tcptows.exe</a></td> <td>You typically do not need to download this. It was used for testing at the beginning and can still be used as a generic TCP to WS tunnel for allowing regular diff --git a/pkg/server/templates/usage.templ b/pkg/server/templates/usage.templ index 2c830e3..6b2eeb0 100644 --- a/pkg/server/templates/usage.templ +++ b/pkg/server/templates/usage.templ @@ -12,16 +12,23 @@ templ AgentUsage(access models.ConvergeAccess, shells map[string]bool, usageInpu </p> if shells[BASH] { - <pre>{` - `}curl http{access.Secure}://{access.HostPort}/static/agent > agent{` + <pre>{addSshKeys(BASH, usageInputs.SshKeys)} + curl --fail-with-body http{access.Secure}://{access.HostPort}/downloads/agent > agent{` chmod 755 agent `}./agent --id {usageInputs.Id} ws{access.Secure}://{access.HostPort}{` rm -f agent `}</pre> } - if shells[CMD] || shells[POWERSHELL] { - <pre>{` - `}curl http{access.Secure}://{access.HostPort}/static/agent.exe > agent.exe{` + if shells[CMD] { + <pre>{addSshKeys(CMD, usageInputs.SshKeys)} + curl --fail-with-body http{access.Secure}://{access.HostPort}/downloads/agent.exe > agent.exe{` + `}agent --id {usageInputs.Id} ws{access.Secure}://{access.HostPort}{` + del agent.exe + `}</pre> + } + if shells[POWERSHELL] { + <pre>{addSshKeys(POWERSHELL, usageInputs.SshKeys)} + curl --fail-with-body http{access.Secure}://{access.HostPort}/downloads/agent.exe > agent.exe{` `}agent --id {usageInputs.Id} ws{access.Secure}://{access.HostPort}{` del agent.exe `}</pre> @@ -41,11 +48,41 @@ templ AgentUsage(access models.ConvergeAccess, shells map[string]bool, usageInpu <h2>Connecting to the agent</h2> + <p>The embedded ssh server in the agent supports both ssh and sftp. The user name is fixed + at <code>{ access.Username }</code>. This is the user used to connect to the embedded + SSH server, after logging in however you will be running in a shell that is started + by the same user that started the agent. + </p> + <pre>{` `}ssh -oServerAliveInterval=10 -oProxyCommand="wsproxy ws{access.Secure}://{access.HostPort}/client/{usageInputs.Id}" { access.Username }{"@localhost"} {` `}sftp -oServerAliveInterval=10 -oProxyCommand="wsproxy ws{access.Secure}://{access.HostPort}/client/{usageInputs.Id}" { access.Username }{"@localhost"} {` `}</pre> + <p>This requires the <code>wsproxy</code> utility which is available in the + <a href="downloads.html">downloads</a> section. This utility must be downloaded + only once since it is quite generic. It will warn you when it a newer version must + be downloaded. + </p> + + <p>For other ssh clients that do not support the openssh ProxyCommand option, there is another + way to connect. In this method, a local port forwarder is started that forwards a local port + to the webserver. Then you can start an ssh client that connects to the local tcp port. + </p> + <pre>{` + `}ssh -oServerAliveInterval=10 -p 10000 { access.Username }{"@localhost"} {` + `}sftp -oServerAliveInterval=10 -p 10000 { access.Username }{"@localhost"} {` + `}</pre> + + <p>This requires the <code>tcptows</code> utility which is available in the + <a href="downloads.html">downloads</a> section. The utility must be started beforehand + using: + </p> + <pre>{` + `}tcptows ws{access.Secure}://{access.HostPort}/client/{usageInputs.Id} {` + `}tcptows ws{access.Secure}://{access.HostPort}/client/{usageInputs.Id} {` + `}</pre> + <h2>Working with the agent</h2> if shells[BASH] { @@ -90,25 +127,11 @@ templ AgentUsage(access models.ConvergeAccess, shells map[string]bool, usageInpu </div> } -templ LocalShellUsage(access models.ConvergeAccess, shells map[string]bool, usageInput UsageInputs) { - <div> - if shells[BASH] { - <p>bash</p> - } - if shells[CMD] { - <p>cmd</p> - } - if shells[POWERSHELL] { - <p>powershell</p> - } - </div> -} templ ShellUsage(access models.ConvergeAccess, usageInputs UsageInputs) { <div> @AgentUsage(access, usageInputs.RemoteShells, usageInputs) </div> - @LocalShellUsage(access, usageInputs.LocalShells, usageInputs) } @@ -163,25 +186,6 @@ templ Usage(access models.ConvergeAccess) { <div id="example-cli"> </div> - - - - - - <pre> {` - `}# linux {` - `}echo "ssh-rsa dkddkdkkk a@b.c" > .authorized_keys {` - `}echo "ssh-rsa adfadjfdf d@e.f" >> .authorized_keys {` - `} {` - `}# windows {` - `}echo ssh-rsa dkddkdkkk a@b.c > .authorized_keys {` - `}echo ssh-rsa adfadjfdf d@e.f >> .authorized_keys - </pre> - <p> - Note that on windows you should not used quotes. - </p> - - <script> function setCookie(name, value, days) { let expires = ""; @@ -274,8 +278,8 @@ templ Usage(access models.ConvergeAccess) { document.getElementById('inputs').addEventListener('change', saveFormToCookie); document.body.addEventListener('htmx:load', function(event) { - //console.log("htmx:load") - //loadFormFromCookie(); + console.log("htmx:load") + loadFormFromCookie(); }); document.body.addEventListener('htmx:afterSettle', function(event) { diff --git a/pkg/server/templates/usageinputs.go b/pkg/server/templates/usageinputs.go index 4f85902..8b1ffaa 100644 --- a/pkg/server/templates/usageinputs.go +++ b/pkg/server/templates/usageinputs.go @@ -1,5 +1,9 @@ package templates +import ( + "fmt" +) + type UsageInputs struct { Id string SshKeys []string @@ -7,9 +11,10 @@ type UsageInputs struct { LocalShells map[string]bool } -func NewUsageInputs(id string, remoteShells []string, localShells []string) UsageInputs { +func NewUsageInputs(id string, sshPublicKeys []string, remoteShells []string, localShells []string) UsageInputs { inputs := UsageInputs{ Id: id, + SshKeys: sshPublicKeys, RemoteShells: make(map[string]bool), LocalShells: make(map[string]bool), } @@ -21,3 +26,20 @@ func NewUsageInputs(id string, remoteShells []string, localShells []string) Usag } return inputs } + +func addSshKeys(shell string, keys []string) string { + quote := `"` + if shell == CMD { + quote = "" + } + res := "" + for index, key := range keys { + operator := ">>" + if index == 0 { + operator = ">" + } + res += fmt.Sprintf(" echo %s%s%s %s .authorized_keys\n", quote, key, quote, + operator) + } + return res + " " +}