fixed interactive terminal through proxyAgent

This commit is contained in:
Stefan Etringer 2025-06-27 12:08:12 +00:00
parent 7472e2384e
commit a03b419b43
6 changed files with 324 additions and 152 deletions

230
main.go
View File

@ -12,6 +12,7 @@ import (
"fmt"
"html/template"
"log"
"net"
"net/http"
"strconv"
"sync"
@ -40,8 +41,8 @@ var (
//go:embed static/*
var staticFiles embed.FS
//go:embed templates
var templateFiles embed.FS
// //go:embed templates
// var templateFiles embed.FS
type Config struct {
Database struct {
@ -68,8 +69,20 @@ func processError(err error) {
func init() {
tmpl, _ = template.ParseGlob("templates/*.html")
// var err error
// tmpl, err = template.ParseFS(
// templateFiles,
// "templates/*.html",
// "templates/partials/*.html",
// )
// if err != nil {
// log.Fatalf("Failed to parse embedded templates: %v", err)
// }
// Sqlite3
err := logger.InitDB("/tmp/gontrol_logs.db")
if err != nil {
log.Fatal(err)
}
@ -78,6 +91,19 @@ func init() {
db = database.InitSQLiteDB("/tmp/gontrol_agents.db")
}
// func renderTemplate(w http.ResponseWriter, tmplPath string, data interface{}) {
// t := tmpl.Lookup(strings.TrimPrefix(tmplPath, "templates/"))
// if t == nil {
// log.Printf("Template %s not found", tmplPath)
// http.Error(w, "Internal Server Error", http.StatusInternalServerError)
// return
// }
// if err := t.Execute(w, data); err != nil {
// log.Printf("Failed to render template %s: %v", tmplPath, err)
// http.Error(w, "Internal Server Error", http.StatusInternalServerError)
// }
func renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
t, err := template.ParseFiles(tmpl)
if err != nil {
@ -109,6 +135,7 @@ func agentsHandler(w http.ResponseWriter, r *http.Request) {
} else {
agent, _ := api.GetAgent(db, w, r, agentId)
renderTemplate(w, "templates/partials/agent_detail.html", agent)
// renderTemplate(w, "agent_detail.html", agent)
}
case http.MethodPost:
api.CreateAgent(db, w, r)
@ -155,6 +182,7 @@ func listAgents(w http.ResponseWriter, r *http.Request) {
}
renderTemplate(w, "templates/partials/agent_list.html", agents)
// renderTemplate(w, "agent_list.html", agents)
}
func getAgentsStatus() []string {
@ -218,167 +246,67 @@ func logsHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "templates/partials/logs_partial.html", logs)
// renderTemplate(w, "logs_partial.html", logs)
}
// func proxyAgentHandler(w http.ResponseWriter, r *http.Request) {
// agentIP := r.URL.Query().Get("ip") // e.g., 10.0.0.42
// if agentIP == "" {
// http.Error(w, "Missing 'ip' parameter", http.StatusBadRequest)
// return
// }
// agentPort := r.URL.Query().Get("port")
// if agentIP == "" {
// http.Error(w, "Missing 'port' parameter", http.StatusBadRequest)
// return
// }
// // Construct the URL to proxy to
// agentURL := "http://" + agentIP + ":" + agentPort
// // Send request to agent server
// resp, err := http.Get(agentURL)
// if err != nil {
// http.Error(w, "Failed to reach agent: "+err.Error(), http.StatusBadGateway)
// return
// }
// defer resp.Body.Close()
// // Parse the HTML from the agent's response
// doc, err := html.Parse(resp.Body)
// if err != nil {
// http.Error(w, "Failed to parse agent response: "+err.Error(), http.StatusInternalServerError)
// return
// }
// // Extract all <link> elements for stylesheets
// var stylesheets []string
// var extractStylesheets func(*html.Node)
// extractStylesheets = func(n *html.Node) {
// if n.Type == html.ElementNode && n.Data == "link" {
// for _, attr := range n.Attr {
// if attr.Key == "rel" && attr.Val == "stylesheet" {
// for _, attr := range n.Attr {
// if attr.Key == "href" {
// stylesheets = append(stylesheets, attr.Val)
// }
// }
// }
// }
// }
// for c := n.FirstChild; c != nil; c = c.NextSibling {
// extractStylesheets(c)
// }
// }
// extractStylesheets(doc)
// // Return the HTML and inject the stylesheets in the <head> section
// w.Header().Set("Content-Type", "text/html")
// w.WriteHeader(http.StatusOK)
// // Inject the stylesheets into the <head> section of your page
// fmt.Fprintf(w, "<html><head>")
// for _, stylesheet := range stylesheets {
// // Make sure the stylesheet is loaded properly (absolute URLs or proxy it)
// fmt.Fprintf(w, `<link rel="stylesheet" href="%s">`, stylesheet)
// }
// fmt.Fprintf(w, "</head><body>")
// // Now, serve the HTML content of the agent web app (or an iframe)
// // Output the rest of the HTML (including the agent's content inside iframe)
// fmt.Fprintf(w, `
// <iframe src="%s" width="100%" height="800px" style="border:none;">
// Your browser does not support iframes.
// </iframe>
// `, agentURL)
// fmt.Fprintf(w, "</body></html>")
// }
// proxyAgentHandler proxies requests to the agent server specified by IP and port query parameters.
// It strips the "/proxyAgent" prefix from the request path before forwarding.
// proxyAgentHandler tunnels HTTP and WebSocket traffic to an agent
// selected by ?ip=…&port=… . It keeps the path *after* /proxyAgent,
// removes the two query parameters, disables HTTP/2 (mandatory for WS),
// and streams with no buffering.
func proxyAgentHandler(w http.ResponseWriter, r *http.Request) {
agentIP := r.URL.Query().Get("ip")
if agentIP == "" {
http.Error(w, "Missing 'ip' parameter", http.StatusBadRequest)
return
}
agentPort := r.URL.Query().Get("port")
if agentPort == "" {
http.Error(w, "Missing 'port' parameter", http.StatusBadRequest)
return
}
ip := r.URL.Query().Get("ip")
port := r.URL.Query().Get("port")
if ip == "" || port == "" {
http.Error(w, "ip and port query parameters are required", http.StatusBadRequest)
return
}
agentBaseURL := "http://" + agentIP + ":" + agentPort
// We leave the scheme "http" even for WebSockets the Upgrade
// header does the rest. (Only cosmetic to change it to "ws”.)
target, err := url.Parse("http://" + ip + ":" + port)
if err != nil {
http.Error(w, "invalid ip/port: "+err.Error(), http.StatusBadRequest)
return
}
targetURL, err := url.Parse(agentBaseURL)
if err != nil {
http.Error(w, "Invalid agent URL: "+err.Error(), http.StatusBadRequest)
return
}
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
// Point to the agent
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
proxy := httputil.NewSingleHostReverseProxy(targetURL)
// Trim the first "/proxyAgent" prefix
if strings.HasPrefix(req.URL.Path, "/proxyAgent") {
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/proxyAgent")
if req.URL.Path == "" {
req.URL.Path = "/"
}
}
// Modify the Director function to rewrite the request URL before sending to agent
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// Scrub ip/port from downstream query
q := req.URL.Query()
q.Del("ip")
q.Del("port")
req.URL.RawQuery = q.Encode()
// Strip "/proxyAgent" prefix from the path
const prefix = "/proxyAgent"
if strings.HasPrefix(req.URL.Path, prefix) {
req.URL.Path = strings.TrimPrefix(req.URL.Path, prefix)
if req.URL.Path == "" {
req.URL.Path = "/"
}
}
// Preserve Host (many CLI-style servers care)
req.Host = target.Host
},
// Preserve original query parameters except ip and port (remove those for backend)
query := req.URL.Query()
query.Del("ip")
query.Del("port")
req.URL.RawQuery = query.Encode()
// Critical tweaks for WebSockets
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: false, // MUST be HTTP/1.1 for WS
ResponseHeaderTimeout: 0,
},
// Optional: set the Host header to the target host
req.Host = targetURL.Host
}
FlushInterval: -1, // stream bytes immediately
}
proxy.ServeHTTP(w, r)
proxy.ServeHTTP(w, r)
}
// func proxyAgentHandler(w http.ResponseWriter, r *http.Request) {
// agentIP := r.URL.Query().Get("ip")
// if agentIP == "" {
// http.Error(w, "Missing 'ip' parameter", http.StatusBadRequest)
// return
// }
// agentPort := r.URL.Query().Get("port")
// if agentPort == "" {
// http.Error(w, "Missing 'port' parameter", http.StatusBadRequest)
// return
// }
// agentURL := "http://" + agentIP + ":" + agentPort + "/"
// resp, err := http.Get(agentURL)
// if err != nil {
// http.Error(w, "Failed to reach agent: "+err.Error(), http.StatusBadGateway)
// return
// }
// defer resp.Body.Close()
// // Copy headers from agent response (you might want to filter some)
// for k, v := range resp.Header {
// for _, vv := range v {
// w.Header().Add(k, vv)
// }
// }
// w.WriteHeader(resp.StatusCode)
// // Stream the entire response body directly to the client
// io.Copy(w, resp.Body)
// }
func main() {
// sqlite3 has been initialized in init()
@ -405,8 +333,8 @@ func main() {
webMux.HandleFunc("/logs", logsHandler)
webMux.HandleFunc("/logs/{level}", logsHandler)
webMux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
webMux.Handle("/templates/", http.FileServer(http.FS(templateFiles)))
webMux.HandleFunc("/proxyAgent", proxyAgentHandler)
webMux.HandleFunc("/proxyAgent/", proxyAgentHandler)
// db := database.InitDB (cfg.Database.Username, cfg.Database.Password, cfg.Database.Host, cfg.Database.Port, cfg.Database.Name)
// defer db.Close()

View File

@ -10,6 +10,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (!interactiveMode && event.key === "Enter") {
const command = input.value.trim();
if (command === "start-interactive") {
startInteractiveSession();
input.value = "";
event.preventDefault();
@ -19,6 +20,30 @@ document.addEventListener("DOMContentLoaded", function () {
}
});
// Helper: get ?ip=…&port=… from the current location
function getQueryParam(name) {
return new URLSearchParams(window.location.search).get(name);
}
function makeWsUrl() {
const proxyIp = getQueryParam("ip"); // "10.0.0.42" if you came via /proxyAgent
const proxyPort = getQueryParam("port"); // "8080"
const usingProxy = proxyIp && proxyPort; // truthy only in that case
if (usingProxy) {
// Build ws(s)://<main-server>/proxyAgent/terminal?ip=…&port=…
const u = new URL("/proxyAgent/terminal", window.location);
u.searchParams.set("ip", proxyIp);
u.searchParams.set("port", proxyPort);
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
return u.toString();
}
// Fallback: open directly on the agent were already on
const u = new URL("/terminal", window.location);
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
return u.toString();
}
function startInteractiveSession() {
interactiveMode = true;
// Hide the normal terminal and input.
@ -55,7 +80,8 @@ document.addEventListener("DOMContentLoaded", function () {
console.log("Initial fit: container width =", xtermContainer.offsetWidth, "cols =", term.cols);
}, 100);
interactiveWS = new WebSocket("ws://" + location.host + "/terminal");
interactiveWS = new WebSocket(makeWsUrl());
// interactiveWS = new WebSocket("ws://" + location.host + "/terminal");
interactiveWS.binaryType = "arraybuffer";
interactiveWS.onopen = function () {

View File

@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map

209
static/xterm.css Normal file
View File

@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}

2
static/xterm.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -13,6 +13,11 @@
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.23/dist/cytoscape.min.js"></script>
<script type="text/javascript" src="static/agents-graph.js"></script>
<script type="text/javascript" src="static/gontrol-helper.js"></script>
<link rel="stylesheet" href="static/xterm.css" />
<script rel="text/javascript" src="static/xterm.js"></script>
<script rel="text/javascript" src="static/xterm-addon-fit.js"></script>
<title>g2: gommand & gontrol</title>
</head>
<!-- <body class="bg-dark text-light" data-bs-theme="dark"> -->