From 12ecb72329dd001b5adb77d72d927707e976cf2c Mon Sep 17 00:00:00 2001
From: Erik Brakkee <erik@brakkee.org>
Date: Sun, 21 Jul 2024 21:41:53 +0200
Subject: [PATCH] Lots of work on docuemtation. The docs page now shows the
 correct installation dependent URLs. For now using ServerALiveInterval to
 avoid disconnects.

---
 cmd/agent/agent.go               |  4 +-
 cmd/converge/server.go           | 97 +++++++++++++++++++++++++++++++-
 cmd/tcptows/tcptows.go           |  3 +
 cmd/wsproxy/proxy.go             |  3 +
 pkg/agent/help.txt               |  4 +-
 pkg/agent/session.go             |  9 ++-
 pkg/websocketutil/connections.go |  2 +
 pkg/websocketutil/services.go    |  3 +
 static/index.html                | 61 ++++++++++++++------
 9 files changed, 163 insertions(+), 23 deletions(-)

diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go
index 2d96d69..6cb5c2d 100755
--- a/cmd/agent/agent.go
+++ b/cmd/agent/agent.go
@@ -92,7 +92,7 @@ func sshServer(hostKeyFile string) *ssh.Server {
 		}
 	})
 
-	log.Println("starting ssh server")
+	log.Println("starting ssh server, waiting for debug sessions")
 	server := ssh.Server{
 		PasswordHandler: passwordAuth,
 		SubsystemHandlers: map[string]ssh.SubsystemHandler{
@@ -164,6 +164,8 @@ func main() {
 		log.Println("WebSocket connection error:", err)
 		return
 	}
+	conn.SetReadDeadline(time.Time{})
+	conn.SetWriteDeadline(time.Time{})
 	wsConn := websocketutil.NewWebSocketConn(conn)
 	defer wsConn.Close()
 
diff --git a/cmd/converge/server.go b/cmd/converge/server.go
index 4db9826..fc34ae7 100644
--- a/cmd/converge/server.go
+++ b/cmd/converge/server.go
@@ -1,14 +1,18 @@
 package main
 
 import (
+	"bytes"
 	"cidebug/pkg/converge"
 	"cidebug/pkg/websocketutil"
 	"fmt"
+	"io"
 	"log"
 	"net"
 	"net/http"
 	"os"
 	"regexp"
+	"strings"
+	"sync"
 )
 
 func parsePublicId(path string) (publicId string, _ error) {
@@ -25,6 +29,90 @@ func catchAllHandler(w http.ResponseWriter, r *http.Request) {
 	return
 }
 
+// filters to filter html content.
+var filters map[string]string = make(map[string]string)
+
+type FileHandlerFilter struct {
+	http.Handler
+}
+
+func (handler FileHandlerFilter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	mutex := sync.Mutex{}
+	// pedantic
+	mutex.Lock()
+
+	if r.TLS == nil {
+		filters["secure"] = ""
+	} else {
+		filters["secure"] = "s"
+	}
+	for _, header := range []string{"X-Forwarded-Proto", "X-Scheme", "X-Forwarded-Scheme"} {
+		values := r.Header.Values(header)
+		for _, value := range values {
+			if strings.ToLower(value) == "https" {
+				filters["secure"] = "s"
+			}
+		}
+	}
+	filters["host"] = r.Host
+	mutex.Unlock()
+
+	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+	w.Header().Set("Pragma", "no-cache")
+	w.Header().Set("Expires", "0")
+
+	handler.Handler.ServeHTTP(w, r)
+}
+
+type FilteredFileSystem struct {
+	fs http.FileSystem
+}
+
+func (ffs FilteredFileSystem) Open(name string) (http.File, error) {
+	log.Println("Name : " + name)
+	f, err := ffs.fs.Open(name)
+	if err != nil {
+		return nil, err
+	}
+	if !strings.HasSuffix(name, ".html") {
+		return f, nil
+	}
+	return NewFilteredFile(f, filters)
+}
+
+type FilteredFile struct {
+	http.File
+
+	// Read bytes 0..(index-1) already
+	index        int
+	fullContents *bytes.Buffer
+}
+
+func NewFilteredFile(f http.File, filter map[string]string) (FilteredFile, error) {
+	file := FilteredFile{
+		index:        0,
+		fullContents: bytes.NewBuffer(make([]byte, 0)),
+	}
+	file.File = f
+
+	_, err := io.Copy(file.fullContents, file.File)
+	if err != nil {
+		return FilteredFile{}, err
+	}
+	contents := file.fullContents.String()
+	for key, value := range filter {
+		key = "@" + key + "@"
+		contents = strings.ReplaceAll(contents, key, value)
+	}
+	file.fullContents = bytes.NewBufferString(contents)
+
+	return file, nil
+}
+
+func (ff FilteredFile) Read(p []byte) (n int, err error) {
+	return ff.fullContents.Read(p)
+}
+
 func main() {
 
 	downloadDir := "downloads"
@@ -65,7 +153,14 @@ func main() {
 
 	http.HandleFunc("/agent/", registrationService.Handle)
 	http.HandleFunc("/client/", clientService.Handle)
-	http.Handle("/docs/", http.StripPrefix("/docs/", http.FileServer(http.Dir(downloadDir))))
+
+	filesystem := http.Dir(downloadDir)
+	filteredFilesystem := FilteredFileSystem{
+		fs: filesystem,
+	}
+	fileHandler := FileHandlerFilter{http.FileServer(filteredFilesystem)}
+
+	http.Handle("/docs/", http.StripPrefix("/docs/", fileHandler))
 	http.HandleFunc("/", catchAllHandler)
 
 	// Start HTTP server
diff --git a/cmd/tcptows/tcptows.go b/cmd/tcptows/tcptows.go
index c0a72c8..f99a15f 100644
--- a/cmd/tcptows/tcptows.go
+++ b/cmd/tcptows/tcptows.go
@@ -7,6 +7,7 @@ import (
 	"log"
 	"net"
 	"os"
+	"time"
 )
 
 func closeConnection(conn net.Conn) {
@@ -25,6 +26,8 @@ func handleConnection(conn net.Conn, wsURL string) {
 		log.Println("WebSocket connection error:", err)
 		return
 	}
+	_wsConn.SetReadDeadline(time.Time{})
+	_wsConn.SetWriteDeadline(time.Time{})
 	wsConn := websocketutil.NewWebSocketConn(_wsConn)
 	defer wsConn.Close()
 
diff --git a/cmd/wsproxy/proxy.go b/cmd/wsproxy/proxy.go
index 1b6d249..9218ea6 100644
--- a/cmd/wsproxy/proxy.go
+++ b/cmd/wsproxy/proxy.go
@@ -7,6 +7,7 @@ import (
 	"log"
 	"net"
 	"os"
+	"time"
 )
 
 func closeConnection(conn net.Conn) {
@@ -29,6 +30,8 @@ func main() {
 	wsURL := os.Args[1]
 
 	_wsConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+	_wsConn.SetReadDeadline(time.Time{})
+	_wsConn.SetWriteDeadline(time.Time{})
 	if err != nil {
 		log.Println("WebSocket connection error:", err)
 		panic(err)
diff --git a/pkg/agent/help.txt b/pkg/agent/help.txt
index 44435ef..0fed91e 100644
--- a/pkg/agent/help.txt
+++ b/pkg/agent/help.txt
@@ -1,4 +1,4 @@
-Session is set to expire at %s
+Session is set to expire at %v
 
 The session expires automatically after %d time.
 If there are no more sessions after logging out, the agent
@@ -8,6 +8,6 @@ You can extend this time using
 
   touch $agentdir/.hold
 
-To prevent the agent from exiting after the last sessioni is gone,
+To prevent the agent from exiting after the last session is gone,
 also use the above command in any shell.
 
diff --git a/pkg/agent/session.go b/pkg/agent/session.go
index 3c705c6..2c8016d 100644
--- a/pkg/agent/session.go
+++ b/pkg/agent/session.go
@@ -144,9 +144,9 @@ func fileExists(filename string) bool {
 func (state *AgentState) expiryTime(filename string) time.Time {
 	stats, err := os.Stat(filename)
 	if err != nil {
-		return state.startTime
+		return state.startTime.Add(state.agentExpriryTime)
 	}
-	return stats.ModTime()
+	return stats.ModTime().Add(state.agentExpriryTime)
 }
 
 // Behavior to implement
@@ -177,6 +177,11 @@ func check() {
 			PrintHelpMessage(session.sshSession)
 		}
 	}
+
+	if !fileExists(holdFilename) && len(state.sessions) == 0 {
+		log.Printf("All clients disconnected and no '%s' file found, exiting", holdFilename)
+		os.Exit(0)
+	}
 }
 
 func messageUsers(message string) {
diff --git a/pkg/websocketutil/connections.go b/pkg/websocketutil/connections.go
index ea87213..7ff838a 100644
--- a/pkg/websocketutil/connections.go
+++ b/pkg/websocketutil/connections.go
@@ -72,6 +72,8 @@ func ConnectWebSocket(conn net.Conn, urlStr string) (net.Conn, error) {
 	if err != nil {
 		return nil, err
 	}
+	wsConn.SetReadDeadline(time.Time{})
+	wsConn.SetWriteDeadline(time.Time{})
 
 	return NewWebSocketConn(wsConn), nil
 }
diff --git a/pkg/websocketutil/services.go b/pkg/websocketutil/services.go
index 37efbd5..a1fc248 100644
--- a/pkg/websocketutil/services.go
+++ b/pkg/websocketutil/services.go
@@ -5,6 +5,7 @@ import (
 	"log"
 	"net"
 	"net/http"
+	"time"
 )
 
 type WebSocketAddr string
@@ -28,6 +29,8 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request,
 		log.Println("Error upgrading to WebSocket:", err)
 		return
 	}
+	conn.SetReadDeadline(time.Time{})
+	conn.SetWriteDeadline(time.Time{})
 	wsConn := NewWebSocketConn(conn)
 	defer wsConn.Close()
 
diff --git a/static/index.html b/static/index.html
index b479d3a..1b178ba 100644
--- a/static/index.html
+++ b/static/index.html
@@ -14,6 +14,39 @@
 
 <div class="container">
 
+    <h1>About</h1>
+
+    <p>
+    Converge is a utility for troubleshooting builds on continuous integration serves.
+    It solves a common problem where the cause of job failure is difficult to determine.
+    This is complicated furhter by the fact that build jobs are usually run on a build
+    farm where there is no access to the build agents or in more modern envrionments when
+    jobs are run in ephemeral containers.
+    </p>
+
+    <p>
+    With Converge it is possible to get remote shell access to such jobs. This works
+    by configuring the build job to connect to a Converge server using an agent program.
+    The agent program can be downloaded from within the CI job using curl or wget.
+    Next, an end-use can connect to the Converge server, a rendez-vous server, that connects
+    the client and server together.
+    </p>
+
+    <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>
+    Both ssh and sftp are supported. Multiple shells are also allowed.
+    </p>
+
+    <p>
+    There is a timeout mechanism in the agent such that jobs do not hang indefinetely waiting
+    for a connection.
+    </p>
+
     <h1>Usage</h1>
 
     <h2>Continous integration jobs</h2>
@@ -22,12 +55,12 @@
         In a continous integration job, download the agent, chmod it and run it.
     </p>
     <pre>
-     curl https://HOSTPORT/docs/agent > agent
-     ./agent wss://HOST:PORT/agent/ID
-    chmod 755 agent
+     curl http@secure@://@host@/docs/agent > agent
+     chmod 755 agent
+     ./agent ws@secure@://@host@/agent/ID
     </pre>
     <p>
-        Above, HOST:PORT is the hostname:port of the converge server and ID is a unique id
+        Above, ID is a unique id
         for the job. This should not conflict with other ids.
 
         This connects the agent to the converge server. Clients can now connect to converge
@@ -37,7 +70,7 @@
     <h2>Local clients: using ssh proxy command </h2>
 
     <pre>
-    curl https://HOST:PORT/docs/wsproxy > wsproxy
+    curl http@secure@://@host@/docs/wsproxy > wsproxy
     chmod 755 wsproxy
     </pre>
 
@@ -49,8 +82,8 @@
     </p>
 
     <pre>
-    ssh -oProxyCommand="wsproxy https://HOST:PORT/client/ID" -p 10000 abc@localhost
-    sftp -oProxyCommand="wsproxy https://HOST:PORT/client/ID" -oPort 10000 abc@localhost
+    ssh -oServerAliveInterval=10 -oProxyCommand="wsproxy ws@secure@://@host@/client/ID"  abc@localhost
+    sftp -oServerAliveInterval=10 -oProxyCommand="wsproxy ws@secure@://@host@/client/ID" abc@localhost
     </pre>
 
     <p>
@@ -70,15 +103,9 @@
     </p>
 
     <pre>
-    # for HTTP hosted server
-    curl http://HOST:PORT/docs/tcptows > tcptows
+    curl http@secure@://@host@/docs/tcptows > tcptows
     chmod 755 tcptows
-    ./tcptows 10000 ws://HOST:PORT/client/ID
-
-    # for HTTPS hosted server
-    curl https://HOST:PORT/docs/tcptows > tcptows
-    chmod 755 tcptows
-    ./tcptows 10000 wss://HOST:PORT/client/ID
+    ./tcptows 10000 ws@secure@://@host@/client/ID
     </pre>
 
     <p>
@@ -90,8 +117,8 @@
     </p>
 
     <pre>
-    ssh -p 10000 abc@localhost
-    sftp -oPort 10000 abc@localhost
+    ssh -oServerAliveInterval=10 -p 10000 abc@localhost
+    sftp -oServerAliveInterval=10 -oPort 10000 abc@localhost
     </pre>
 
     <p>