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.
This commit is contained in:
Erik Brakkee 2024-08-03 21:03:29 +02:00
parent 49db7578a7
commit b41317c598
7 changed files with 199 additions and 94 deletions

View File

@ -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" ]

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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

View File

@ -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) {

View File

@ -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 + " "
}