From 1dfc8ba0efc2889784a1a8b2991716dbf9e4beaa Mon Sep 17 00:00:00 2001 From: Stefan Etringer Date: Thu, 3 Jul 2025 14:50:38 +0000 Subject: [PATCH] refractoring, added test --- go.mod | 2 +- go.sum | 5 +- gomatic.sql | 17 - main.go | 415 ++---------------- src/server/database/database.go | 2 +- src/server/webapp/agents_handler_html_test.go | 86 ++++ src/server/webapp/e2e_test.go | 47 ++ src/server/webapp/handlers.go | 232 ++++++++++ src/server/webapp/handlers_test.go | 116 +++++ src/server/webapp/router.go | 45 ++ .../server/webapp/static}/agents-graph.js | 0 .../webapp/static}/computer-offline.svg | 0 .../server/webapp/static}/computer-online.svg | 0 .../server/webapp/static}/download-command.js | 0 .../server/webapp/static}/gontrol-helper.js | 0 .../webapp/static}/gontrol-stylesheet.css | 0 .../server/webapp/static}/help-command.js | 0 .../webapp/static}/keyboard-shortcuts.js | 0 .../server/webapp/static}/server-offline.svg | 0 .../server/webapp/static}/server-online.svg | 0 .../webapp/static}/start-interactive.js | 0 .../server/webapp/static}/stylesheet.css | 0 .../server/webapp/static}/switch-themes.js | 0 .../webapp/static}/xterm/xterm-addon-fit.js | 0 .../server/webapp/static}/xterm/xterm.css | 0 .../server/webapp/static}/xterm/xterm.js | 0 src/server/webapp/static_files_test.go | 54 +++ src/server/webapp/template_test.go | 21 + .../server/webapp/templates}/index.html | 0 .../templates}/partials/agent_detail.html | 0 .../templates}/partials/agent_list.html | 0 .../templates}/partials/logs_partial.html | 0 src/server/webapp/webapp.go | 0 templates/logs.html | 80 ---- 34 files changed, 648 insertions(+), 474 deletions(-) delete mode 100644 gomatic.sql create mode 100644 src/server/webapp/agents_handler_html_test.go create mode 100644 src/server/webapp/e2e_test.go create mode 100644 src/server/webapp/handlers.go create mode 100644 src/server/webapp/handlers_test.go create mode 100644 src/server/webapp/router.go rename {static => src/server/webapp/static}/agents-graph.js (100%) rename {static => src/server/webapp/static}/computer-offline.svg (100%) rename {static => src/server/webapp/static}/computer-online.svg (100%) rename {static => src/server/webapp/static}/download-command.js (100%) rename {static => src/server/webapp/static}/gontrol-helper.js (100%) rename {static => src/server/webapp/static}/gontrol-stylesheet.css (100%) rename {static => src/server/webapp/static}/help-command.js (100%) rename {static => src/server/webapp/static}/keyboard-shortcuts.js (100%) rename {static => src/server/webapp/static}/server-offline.svg (100%) rename {static => src/server/webapp/static}/server-online.svg (100%) rename {static => src/server/webapp/static}/start-interactive.js (100%) rename {static => src/server/webapp/static}/stylesheet.css (100%) rename {static => src/server/webapp/static}/switch-themes.js (100%) rename {static => src/server/webapp/static}/xterm/xterm-addon-fit.js (100%) rename {static => src/server/webapp/static}/xterm/xterm.css (100%) rename {static => src/server/webapp/static}/xterm/xterm.js (100%) create mode 100644 src/server/webapp/static_files_test.go create mode 100644 src/server/webapp/template_test.go rename {templates => src/server/webapp/templates}/index.html (100%) rename {templates => src/server/webapp/templates}/partials/agent_detail.html (100%) rename {templates => src/server/webapp/templates}/partials/agent_list.html (100%) rename {templates => src/server/webapp/templates}/partials/logs_partial.html (100%) delete mode 100644 src/server/webapp/webapp.go delete mode 100644 templates/logs.html diff --git a/go.mod b/go.mod index 7eecfcd..08fbd79 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module gontrol go 1.23.4 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/PuerkitoBio/goquery v1.10.1 github.com/go-sql-driver/mysql v1.8.1 github.com/gorilla/websocket v1.5.3 - github.com/kelseyhightower/envconfig v1.4.0 github.com/mattn/go-sqlite3 v1.14.28 ) diff --git a/go.sum b/go.sum index a87bc7f..69cef94 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -9,8 +11,7 @@ github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqw github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/gomatic.sql b/gomatic.sql deleted file mode 100644 index df94bc7..0000000 --- a/gomatic.sql +++ /dev/null @@ -1,17 +0,0 @@ -/* create database 'gomatic'; */ - -drop table if exists agents; -create table agents ( - id UUID default uuid() Primary Key, - agentId int unique, - agentType varchar(255), - agentName varchar(255), - IPv4Address varchar(15), - initialContact timestamp, - lastContact timestamp, - status Boolean, - addPort varchar(255), - hostName varchar(255), -); - -insert into agents (IPv4Address, agentId, agentName, initialContact, lastContact) values ( '127.0.0.1', 'testAgent', NOW(), NOW()); diff --git a/main.go b/main.go index e75e5be..486bb26 100644 --- a/main.go +++ b/main.go @@ -2,356 +2,53 @@ package main import ( "context" - "encoding/json" - "embed" - "os" - "strings" - "time" - "slices" - "database/sql" - "fmt" - "html/template" "log" - "net" "net/http" - "strconv" - "sync" - "syscall" - - "net/http/httputil" - "net/url" - // "golang.org/x/net/html" - "gontrol/src/logger" - "gontrol/src/randomname" - api "gontrol/src/server/api" - "gontrol/src/server/database" - websocketserver "gontrol/src/server/websocket" - + "os" "os/signal" + "syscall" + "time" - _ "github.com/go-sql-driver/mysql" - "github.com/kelseyhightower/envconfig" + "gontrol/src/logger" + "gontrol/src/server/database" + "gontrol/src/server/webapp" + "gontrol/src/server/websocket" ) -var ( - tmpl *template.Template - db *sql.DB -) - -//go:embed static/* -var staticFiles embed.FS - -// //go:embed templates -// var templateFiles embed.FS - -type Config struct { - Database struct { - Username string `envconfig:"DB_USERNAME"` - Password string `envconfig:"DB_PASSWORD"` - Port int16 `envconfig:"DB_PORT"` - Name string `envconfig:"DB_NAME"` - Host string `envconfig:"DB_HOST"` - } -} - -func readEnv(cfg *Config) { - err := envconfig.Process("", cfg) - if err != nil { - processError(err) - } -} - -func processError(err error) { - fmt.Println(err) - os.Exit(2) -} - -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) - } - - // Agents database - 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 { - log.Printf("Failed to load template %s: %v", tmpl, err) - 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", tmpl, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } -} - -func agentsHandler(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/agents/"), "/") - agentId := "" - if len(parts) > 0 && parts[0] != "" { - agentId = parts[0] - } - - switch r.Method { - case http.MethodDelete: - api.DeleteAgent(db, w, r, agentId) - listAgents(w,r) - case http.MethodGet: - if agentId == "" { - listAgents(w, r) - } 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) - listAgents(w, r) - case http.MethodPut: - api.UpdateAgent(db, w, r, agentId) - listAgents(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -func getHomepage(w http.ResponseWriter, r *http.Request) { - tmpl.ExecuteTemplate(w, "index.html", nil) -} - -func listAgents(w http.ResponseWriter, r *http.Request) { - var agents []api.Agent - agents, err := api.GetAgents(db) - currentAgents := getAgentsStatus() - - for i := range agents { - if slices.Contains(currentAgents, agents[i].AgentName) { - agents[i].Status = "Connected" - } else { - agents[i].Status = "Disconnected" - } - } - - if strings.Contains(r.Header.Get("Accept"), "json") { - w.Header().Set("Content-Type", "application/json") - jsonData, err := json.Marshal(agents) - if err != nil { - http.Error(w, "Failed to encode agents to JSON", http.StatusInternalServerError) - return - } - w.Write(jsonData) - return - } - - if err != nil { - http.Error(w, "Failed to fetch agents", http.StatusInternalServerError) - return - } - - renderTemplate(w, "templates/partials/agent_list.html", agents) - // renderTemplate(w, "agent_list.html", agents) -} - -func getAgentsStatus() []string { - resp, err := http.Get("http://localhost:5555/agentNames") - if err != nil { - log.Println("Error fetching agent names:", err) - logger.InsertLog(logger.Error, "Error fetching agent names from websocketServer") - } - defer resp.Body.Close() - - var agentNames []string - if err := json.NewDecoder(resp.Body).Decode(&agentNames); err != nil { - log.Println("Error decoding response:", err) - return []string{} - } - - return agentNames -} - -func getAgentNames(w http.ResponseWriter, r *http.Request) { - api.GetAgentNames(db, w, r) - return -} - -func getAgentIds(w http.ResponseWriter, r *http.Request) { - api.GetAgentIds(db, w, r) - return -} - -func logsHandler(w http.ResponseWriter, r *http.Request) { - // Warning this bit me in the nose: var countStr is []string, but - // variable = countStr[0] is casted to int automatically - // when the string is a number. Jesus Christ, this is odd behavior! - levels := r.URL.Query()["level"] - countStr := r.URL.Query()["limit"] - var limit int = 128 - if len(countStr) > 0 { - parsedCount, err := strconv.Atoi(countStr[0]) - if err == nil { - limit = parsedCount - } else { - http.Error(w, "Invalid count value", http.StatusBadRequest) - return - } - } - - // This enables not only `level` GET parameters but also selecting by paths - // For example /logs/error is now identical to /logs?level=error - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/logs/"), "/") - if (len(levels) == 0) && len(parts) > 0 && parts[0] != "" { - levels = []string{parts[0]} - } - - // Call the police... I mean logger - logs, err := logger.FetchLogs(limit, levels) - - if err != nil { - http.Error(w, "Error fetching logs", http.StatusInternalServerError) - return - } - - - renderTemplate(w, "templates/partials/logs_partial.html", logs) - // renderTemplate(w, "logs_partial.html", logs) -} - -// 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) { - 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 - } - - // 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 - } - - proxy := &httputil.ReverseProxy{ - Director: func(req *http.Request) { - // Point to the agent - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - - // 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 = "/" - } - } - - // Scrub ip/port from downstream query - q := req.URL.Query() - q.Del("ip") - q.Del("port") - req.URL.RawQuery = q.Encode() - - // Preserve Host (many CLI-style servers care) - req.Host = target.Host - }, - - // 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, - }, - - FlushInterval: -1, // stream bytes immediately - } - - proxy.ServeHTTP(w, r) -} - func main() { - - // sqlite3 has been initialized in init() - defer logger.CloseDB() - - defer db.Close() - - var cfg Config - readEnv(&cfg) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var wg sync.WaitGroup - - websocketServer := websocketserver.Server() - - webMux := http.NewServeMux() - webMux.HandleFunc("/", getHomepage) - webMux.HandleFunc("/agents", agentsHandler) - webMux.HandleFunc("/agentNames", getAgentNames) - webMux.HandleFunc("/agentIds", getAgentIds) - webMux.HandleFunc("/agents/{agentId}", agentsHandler) - webMux.HandleFunc("/logs", logsHandler) - webMux.HandleFunc("/logs/{level}", logsHandler) - webMux.Handle("/static/", http.FileServer(http.FS(staticFiles))) - 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() - - name := randomname.GenerateRandomName() - log.Println("Server instance: ", name) - - webServer := &http.Server { - Addr: ":3333", - Handler: webMux, + tmpl, err := webapp.ParseTemplates() + if err != nil { + log.Fatalf("Parse templates: %v", err) + } + + db := database.InitSQLiteDB("/tmp/gontrol_agents.db") + defer db.Close() + + if err := logger.InitDB("/tmp/gontrol_logs.db"); err != nil { + log.Fatalf("Init log db: %v", err) + } + defer logger.CloseDB() + + app := &webapp.App{Tmpl: tmpl, DB: db} + + srv := &http.Server { + Addr: ":3333", + Handler: webapp.BuildRouter(app), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, } - wg.Add(1) go func() { - defer wg.Done() - logLine := "Websocket server is running on port 5555" + log.Println("Web server is running on port :3333") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Listen: %v", err) + } + }() + + go func() { + logLine := "Websocket server is running on port :5555" log.Println(logLine) + websocketServer := websocketserver.Server() if err := websocketServer.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("Websocket server failed: %s", err) } @@ -361,39 +58,11 @@ func main() { } }() - wg.Add(1) - go func() { - defer wg.Done() - logLine := "Web server is running on port 3333" - log.Println(logLine) + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop - if err := webServer.ListenAndServe(); err != http.ErrServerClosed { - log.Fatalf("Web server failed: %s", err) - } - - err := logger.InsertLog(logger.Info, logLine) - if err != nil { - log.Println("Error inserting log:", err) - } - }() - - shutdownCh := make(chan os.Signal, 1) - signal.Notify(shutdownCh, os.Interrupt, syscall.SIGTERM) - - <-shutdownCh - log.Println("Shutdown signal received") - - shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 10*time.Second) - defer shutdownCancel() - - if err := websocketServer.Shutdown(shutdownCtx); err != nil { - log.Printf("error shutting down websocket server: %s", err) - } - - if err := webServer.Shutdown(shutdownCtx); err != nil { - log.Printf("Error shutting down web server: %s", err) - } - - wg.Wait() - log.Println("All servers stopped") + ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second) + defer cancel() + _ = srv.Shutdown(ctx) } diff --git a/src/server/database/database.go b/src/server/database/database.go index bc06e85..fb07b50 100644 --- a/src/server/database/database.go +++ b/src/server/database/database.go @@ -71,7 +71,7 @@ func CloseDB() { if Agent_db != nil { err := Agent_db.Close() if err != nil { - log.Println("Error closing database: %v", err) + log.Fatalf("Error closing database: %v", err) } } } diff --git a/src/server/webapp/agents_handler_html_test.go b/src/server/webapp/agents_handler_html_test.go new file mode 100644 index 0000000..a30c43b --- /dev/null +++ b/src/server/webapp/agents_handler_html_test.go @@ -0,0 +1,86 @@ +// src/server/webapp/agents_handler_html_test.go +package webapp + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +// startStubAgentNameServer spins up an HTTP server **on :5555** +// so getAgentsStatus() succeeds during tests. +func startStubAgentNameServer(t *testing.T) func() { + t.Helper() + + // Prepare a listener on the exact address getAgentsStatus() calls. + l, err := net.Listen("tcp", "127.0.0.1:5555") + if err != nil { + t.Fatalf("cannot bind :5555 (is another process using it?): %v", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/agentNames", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`["alpha","beta"]`)) + }) + + srv := &http.Server{Handler: mux} + go srv.Serve(l) + + // Return cleanup function. + return func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + } +} + +// Build App with sqlmock that returns an empty agents result. +func newAppForAgents(t *testing.T) *App { + t.Helper() + + tmpl, err := ParseTemplates() + if err != nil { + t.Fatalf("ParseTemplates: %v", err) + } + + // sqlmock with regexp matcher + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + + // Any SELECT from agents returns zero rows + mock.ExpectQuery("(?i)select .* from agents"). + WillReturnRows(sqlmock.NewRows([]string{"agent_id", "agent_name", "status"})) + + return &App{Tmpl: tmpl, DB: db} +} + +func TestAgentsHandler_ListHTML(t *testing.T) { + cleanup := startStubAgentNameServer(t) + defer cleanup() + + app := newAppForAgents(t) + + req := httptest.NewRequest(http.MethodGet, "/agents", nil) + rec := httptest.NewRecorder() + + app.agentsHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content‑Type = %q; want text/html", ct) + } + if !strings.Contains(rec.Body.String(), "") + } +} diff --git a/src/server/webapp/e2e_test.go b/src/server/webapp/e2e_test.go new file mode 100644 index 0000000..fa0a374 --- /dev/null +++ b/src/server/webapp/e2e_test.go @@ -0,0 +1,47 @@ +package webapp_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gontrol/src/server/webapp" +) + +func TestRouter_Homepage(t *testing.T) { + tmpl, err := webapp.ParseTemplates() + if err != nil { + t.Fatalf("parse templates: %v",err) + } + + appDB, _, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer appDB.Close() + + app := &webapp.App{ + Tmpl: tmpl, + DB: appDB, + } + + ts := httptest.NewServer(webapp.BuildRouter(app)) + defer ts.Close() + + res, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", res.StatusCode) + } + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "") { + t.Errorf("homepage HTML missing <title") + } +} diff --git a/src/server/webapp/handlers.go b/src/server/webapp/handlers.go new file mode 100644 index 0000000..2fee4ca --- /dev/null +++ b/src/server/webapp/handlers.go @@ -0,0 +1,232 @@ +package webapp + +import ( + "database/sql" + "encoding/json" + "html/template" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "slices" + "time" + + "gontrol/src/logger" + "gontrol/src/server/api" +) + +type App struct { + Tmpl *template.Template + DB *sql.DB +} + +func (a *App) renderTemplate(w http.ResponseWriter, name string, data any) { + t := a.Tmpl.Lookup(name) + if t == nil { + log.Printf("Template %s not found", name) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if err := t.Execute(w, data); err != nil { + log.Printf("Failed to render %s: %v", name, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// +// Handlers +// +//////////////////////////////////////////////////////////////////////////////// + +func (a *App) getHomePage(w http.ResponseWriter, r *http.Request) { + a.renderTemplate(w, "index.html", nil) +} + +func (a *App) agentsHandler(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/agents/"), "/") + agentId := "" + if len(parts) > 0 && parts[0] != "" { + agentId = parts[0] + } + + switch r.Method { + case http.MethodDelete: + api.DeleteAgent(a.DB, w, r, agentId) + a.listAgents(w,r) + case http.MethodGet: + if agentId == "" { + a.listAgents(w, r) + } else { + agent, _ := api.GetAgent(a.DB, w, r, agentId) + a.renderTemplate(w, "agent_detail.html", agent) + } + case http.MethodPost: + api.CreateAgent(a.DB, w, r) + a.listAgents(w, r) + case http.MethodPut: + api.UpdateAgent(a.DB, w, r, agentId) + a.listAgents(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func (a *App) listAgents(w http.ResponseWriter, r *http.Request) { + var agents []api.Agent + agents, err := api.GetAgents(a.DB) + if err != nil { + http.Error(w, "Failed to fetch agents", http.StatusInternalServerError) + return + } + + currentAgents := a.getAgentsStatus() + + for i := range agents { + + if slices.Contains(currentAgents, agents[i].AgentName) { + agents[i].Status = "Connected" + } else { + agents[i].Status = "Disconnected" + } + } + + if strings.Contains(r.Header.Get("Accept"), "json") { + w.Header().Set("Content-Type", "application/json") + jsonData, err := json.Marshal(agents) + if err != nil { + http.Error(w, "Failed to encode agents to JSON", http.StatusInternalServerError) + return + } + w.Write(jsonData) + return + } + + a.renderTemplate(w, "agent_list.html", agents) + // renderTemplate(w, "agent_list.html", agents) +} + +func (a *App) logsHandler(w http.ResponseWriter, r *http.Request) { + // Warning this bit me in the nose: var countStr is []string, but + // variable = countStr[0] is casted to int automatically + // when the string is a number. Jesus Christ, this is odd behavior! + levels := r.URL.Query()["level"] + countStr := r.URL.Query()["limit"] + var limit int = 128 + if len(countStr) > 0 { + parsedCount, err := strconv.Atoi(countStr[0]) + if err == nil { + limit = parsedCount + } else { + http.Error(w, "Invalid count value", http.StatusBadRequest) + return + } + } + + // This enables not only `level` GET parameters but also selecting by paths + // For example /logs/error is now identical to /logs?level=error + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/logs/"), "/") + if (len(levels) == 0) && len(parts) > 0 && parts[0] != "" { + levels = []string{parts[0]} + } + + // Call the police... I mean logger + logs, err := logger.FetchLogs(limit, levels) + + if err != nil { + http.Error(w, "Error fetching logs", http.StatusInternalServerError) + return + } + + + a.renderTemplate(w, "logs_partial.html", logs) +} + +func (a *App) getAgentsStatus() []string { + resp, err := http.Get("http://localhost:5555/agentNames") + if err != nil { + log.Println("Error fetching agent names:", err) + logger.InsertLog(logger.Error, "Error fetching agent names from websocketServer") + } + defer resp.Body.Close() + + var agentNames []string + if err := json.NewDecoder(resp.Body).Decode(&agentNames); err != nil { + log.Println("Error decoding response:", err) + return []string{} + } + + return agentNames +} + +func (a *App) getAgentNames(w http.ResponseWriter, r *http.Request) { + api.GetAgentNames(a.DB, w, r) + return +} + +func (a *App) getAgentIds(w http.ResponseWriter, r *http.Request) { + api.GetAgentIds(a.DB, w, r) + return +} + +// 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 (a *App) proxyAgentHandler(w http.ResponseWriter, r *http.Request) { + 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 + } + + // 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 + } + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + // Point to the agent + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + + // 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 = "/" + } + } + + // Scrub ip/port from downstream query + q := req.URL.Query() + q.Del("ip") + q.Del("port") + req.URL.RawQuery = q.Encode() + + // Preserve Host (many CLI-style servers care) + req.Host = target.Host + }, + + // 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, + }, + + FlushInterval: -1, // stream bytes immediately + } + + proxy.ServeHTTP(w, r) +} diff --git a/src/server/webapp/handlers_test.go b/src/server/webapp/handlers_test.go new file mode 100644 index 0000000..f8036bc --- /dev/null +++ b/src/server/webapp/handlers_test.go @@ -0,0 +1,116 @@ +package webapp + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "strings" + + "github.com/DATA-DOG/go-sqlmock" + "gontrol/src/logger" +) + +func newTestApp(t *testing.T) (*App, sqlmock.Sqlmock) { + t.Helper() + + tmpl, err := ParseTemplates() + if err != nil { + t.Fatalf("ParseTemplates: %v", err) + } + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + return &App{Tmpl: tmpl, DB: db}, mock +} + +// initFakeLogs puts one record in the in‑memory log DB used by logger.FetchLogs. +func initFakeLogs(t *testing.T) { + t.Helper() + if err := logger.InsertLog(logger.Info, "unit‑test"); err != nil { + t.Fatalf("InsertLog: %v", err) + } +} + +func TestLogsHandler_HTML(t *testing.T) { + tmp, err := os.CreateTemp("", "logs_*.db") + if err != nil { + t.Fatalf("Temp DB: %v", err) + } + tmp.Close() + defer os.Remove(tmp.Name()) + + if err := logger.InitDB(tmp.Name()); err != nil { + t.Fatalf("logger.InitDB: %v", err) + } + defer logger.CloseDB() + + logger.InsertLog(logger.Error, "fake-log-1") + + app, mock := newTestApp(t) + defer app.DB.Close() + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sqlmock expectations: %v", err) + } + + + req := httptest.NewRequest(http.MethodGet, "/logs/error?limit=1", nil) + rec := httptest.NewRecorder() + + app.logsHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %s; want text/html", ct) + } + if !strings.Contains(rec.Body.String(), "fake-log-1") { + t.Errorf("rendered HTML missing our fake log line") + } +} + +// Test that `limit=abc` is rejected with 400 and FetchLogs is **not** hit. +func TestLogsHandler_InvalidLimit(t *testing.T) { + initFakeLogs(t) + + app, _ := newTestApp(t) // helper from previous file + + req := httptest.NewRequest(http.MethodGet, "/logs?limit=abc", nil) + rec := httptest.NewRecorder() + + app.logsHandler(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Invalid count value") { + t.Errorf("missing error message") + } +} + +// Test JSON response when client sends Accept: application/json. +func TestLogsHandler_JSON(t *testing.T) { + initFakeLogs(t) + + app, _ := newTestApp(t) + + req := httptest.NewRequest(http.MethodGet, "/logs?limit=1", nil) + req.Header.Set("Accept", "application/json") + rec := httptest.NewRecorder() + + app.logsHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content‑Type = %q; want application/json", ct) + } + if !strings.Contains(rec.Body.String(), "unit‑test") { + t.Errorf("JSON body missing expected log entry") + } +} diff --git a/src/server/webapp/router.go b/src/server/webapp/router.go new file mode 100644 index 0000000..d4e2a41 --- /dev/null +++ b/src/server/webapp/router.go @@ -0,0 +1,45 @@ +package webapp + +import ( + "embed" + "io/fs" + "html/template" + "net/http" +) + +//go:embed templates/*.html templates/partials/*.html static/* +var assets embed.FS + +// App is defined in handlers.go and called in main.go +func BuildRouter(app *App) *http.ServeMux { + mux := http.NewServeMux() + + mux.HandleFunc("/", app.getHomePage) + mux.HandleFunc("/agents", app.agentsHandler) + mux.HandleFunc("/agents/{agentId}", app.agentsHandler) + mux.HandleFunc("/agentNames", app.getAgentNames) + mux.HandleFunc("/agentIds", app.getAgentIds) + mux.HandleFunc("/logs", app.logsHandler) + mux.HandleFunc("/logs/{level}", app.logsHandler) + mux.HandleFunc("/proxyAgent", app.proxyAgentHandler) + mux.HandleFunc("/proxyAgent/", app.proxyAgentHandler) + + staticSub, _ := fs.Sub(assets, "static") + mux.Handle("/static/", + http.StripPrefix("/static/", + http.FileServer(http.FS(staticSub)), + ), + ) + + return mux +} + +// Parse all templates from the embedded FS +// Called in main() and passed into &App{Tmp: tmpl) +func ParseTemplates() (*template.Template, error) { + return template.ParseFS( + assets, + "templates/*.html", + "templates/partials/*.html", + ) +} diff --git a/static/agents-graph.js b/src/server/webapp/static/agents-graph.js similarity index 100% rename from static/agents-graph.js rename to src/server/webapp/static/agents-graph.js diff --git a/static/computer-offline.svg b/src/server/webapp/static/computer-offline.svg similarity index 100% rename from static/computer-offline.svg rename to src/server/webapp/static/computer-offline.svg diff --git a/static/computer-online.svg b/src/server/webapp/static/computer-online.svg similarity index 100% rename from static/computer-online.svg rename to src/server/webapp/static/computer-online.svg diff --git a/static/download-command.js b/src/server/webapp/static/download-command.js similarity index 100% rename from static/download-command.js rename to src/server/webapp/static/download-command.js diff --git a/static/gontrol-helper.js b/src/server/webapp/static/gontrol-helper.js similarity index 100% rename from static/gontrol-helper.js rename to src/server/webapp/static/gontrol-helper.js diff --git a/static/gontrol-stylesheet.css b/src/server/webapp/static/gontrol-stylesheet.css similarity index 100% rename from static/gontrol-stylesheet.css rename to src/server/webapp/static/gontrol-stylesheet.css diff --git a/static/help-command.js b/src/server/webapp/static/help-command.js similarity index 100% rename from static/help-command.js rename to src/server/webapp/static/help-command.js diff --git a/static/keyboard-shortcuts.js b/src/server/webapp/static/keyboard-shortcuts.js similarity index 100% rename from static/keyboard-shortcuts.js rename to src/server/webapp/static/keyboard-shortcuts.js diff --git a/static/server-offline.svg b/src/server/webapp/static/server-offline.svg similarity index 100% rename from static/server-offline.svg rename to src/server/webapp/static/server-offline.svg diff --git a/static/server-online.svg b/src/server/webapp/static/server-online.svg similarity index 100% rename from static/server-online.svg rename to src/server/webapp/static/server-online.svg diff --git a/static/start-interactive.js b/src/server/webapp/static/start-interactive.js similarity index 100% rename from static/start-interactive.js rename to src/server/webapp/static/start-interactive.js diff --git a/static/stylesheet.css b/src/server/webapp/static/stylesheet.css similarity index 100% rename from static/stylesheet.css rename to src/server/webapp/static/stylesheet.css diff --git a/static/switch-themes.js b/src/server/webapp/static/switch-themes.js similarity index 100% rename from static/switch-themes.js rename to src/server/webapp/static/switch-themes.js diff --git a/static/xterm/xterm-addon-fit.js b/src/server/webapp/static/xterm/xterm-addon-fit.js similarity index 100% rename from static/xterm/xterm-addon-fit.js rename to src/server/webapp/static/xterm/xterm-addon-fit.js diff --git a/static/xterm/xterm.css b/src/server/webapp/static/xterm/xterm.css similarity index 100% rename from static/xterm/xterm.css rename to src/server/webapp/static/xterm/xterm.css diff --git a/static/xterm/xterm.js b/src/server/webapp/static/xterm/xterm.js similarity index 100% rename from static/xterm/xterm.js rename to src/server/webapp/static/xterm/xterm.js diff --git a/src/server/webapp/static_files_test.go b/src/server/webapp/static_files_test.go new file mode 100644 index 0000000..0389f6c --- /dev/null +++ b/src/server/webapp/static_files_test.go @@ -0,0 +1,54 @@ +// src/server/webapp/static_files_test.go +package webapp + +import ( + "io" + "io/fs" + "net/http" + "net/http/httptest" + "testing" +) + +// findFirstStaticFile walks the embedded FS and returns the first file path. +func findFirstStaticFile() (webPath string, content []byte, ok bool) { + _ = fs.WalkDir(assets, "static", func(path string, d fs.DirEntry, err error) error { + if !d.IsDir() && ok == false { + data, _ := assets.ReadFile(path) // ignore err; test will skip if nil + webPath = "/" + path // e.g. /static/css/main.css + content = data + ok = true + } + return nil + }) + return +} + +// Requires at least one file under static/. Skips if none embedded. +func TestStaticFileServer(t *testing.T) { + webPath, wantBytes, ok := findFirstStaticFile() + if !ok { + t.Skip("no embedded static files to test") + } + + //----------------------------------------------------------------- + // build router with sqlmock DB (not used in this test) + //----------------------------------------------------------------- + app, _ := newTestApp(t) + ts := httptest.NewServer(BuildRouter(app)) + defer ts.Close() + + res, err := http.Get(ts.URL + webPath) + if err != nil { + t.Fatalf("GET %s: %v", webPath, err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", res.StatusCode) + } + + gotBytes, _ := io.ReadAll(res.Body) + if len(gotBytes) == 0 || string(gotBytes) != string(wantBytes) { + t.Errorf("served file differs from embedded asset") + } +} diff --git a/src/server/webapp/template_test.go b/src/server/webapp/template_test.go new file mode 100644 index 0000000..7535c48 --- /dev/null +++ b/src/server/webapp/template_test.go @@ -0,0 +1,21 @@ +package webapp + +import ( + "bytes" + "testing" +) + +func TestTemplatesExecute(t *testing.T ) { + tmpl, err := ParseTemplates() + if err != nil { + t.Fatalf("ParseTemplates: %v", err) + } + + names := tmpl.Templates() + for _, tt := range names { + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, tt.Name(), nil); err != nil { + t.Errorf("template %s failed: %v", tt.Name(), err) + } + } +} diff --git a/templates/index.html b/src/server/webapp/templates/index.html similarity index 100% rename from templates/index.html rename to src/server/webapp/templates/index.html diff --git a/templates/partials/agent_detail.html b/src/server/webapp/templates/partials/agent_detail.html similarity index 100% rename from templates/partials/agent_detail.html rename to src/server/webapp/templates/partials/agent_detail.html diff --git a/templates/partials/agent_list.html b/src/server/webapp/templates/partials/agent_list.html similarity index 100% rename from templates/partials/agent_list.html rename to src/server/webapp/templates/partials/agent_list.html diff --git a/templates/partials/logs_partial.html b/src/server/webapp/templates/partials/logs_partial.html similarity index 100% rename from templates/partials/logs_partial.html rename to src/server/webapp/templates/partials/logs_partial.html diff --git a/src/server/webapp/webapp.go b/src/server/webapp/webapp.go deleted file mode 100644 index e69de29..0000000 diff --git a/templates/logs.html b/templates/logs.html deleted file mode 100644 index 03900f7..0000000 --- a/templates/logs.html +++ /dev/null @@ -1,80 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Web Server Logs - - - - -

Web Server Logs

- -
- -
- - - - - - - -