refractoring, added test

This commit is contained in:
Stefan Etringer 2025-07-03 14:50:38 +00:00
parent 7125e3fe4c
commit 1dfc8ba0ef
34 changed files with 648 additions and 474 deletions

2
go.mod
View File

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

5
go.sum
View File

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

View File

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

415
main.go
View File

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

View File

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

View File

@ -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("ContentType = %q; want text/html", ct)
}
if !strings.Contains(rec.Body.String(), "<table") {
t.Errorf("agents list HTML missing <table>")
}
}

View File

@ -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), "<title>") {
t.Errorf("homepage HTML missing <title")
}
}

View File

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

View File

@ -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 inmemory log DB used by logger.FetchLogs.
func initFakeLogs(t *testing.T) {
t.Helper()
if err := logger.InsertLog(logger.Info, "unittest"); 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("ContentType = %q; want application/json", ct)
}
if !strings.Contains(rec.Body.String(), "unittest") {
t.Errorf("JSON body missing expected log entry")
}
}

View File

@ -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",
)
}

View File

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 655 B

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 674 B

View File

Before

Width:  |  Height:  |  Size: 673 B

After

Width:  |  Height:  |  Size: 673 B

View File

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

View File

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

View File

@ -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</title>
<script src="https://unpkg.com/htmx.org@1.8.5"></script>
<style>
.log-info {
color: green;
}
.log-warning {
color: orange;
}
.log-error {
color: red;
}
.log-fatal {
color: blue;
}
.log-debug {
color: violet;
}
</style>
</head>
<body>
<h1>Web Server Logs</h1>
<div id="logs-container">
<!-- Logs will be injected here -->
</div>
<button hx-get="/logs" hx-target="#logs-container" hx-swap="innerHTML">
Load Logs
</button>
<button hx-get="/logs" hx-target="#logs-container" hx-swap="innerHTML" hx-trigger="every 2s">
Auto-Refresh Logs
</button>
<script>
function renderLogs(logs) {
const container = document.getElementById('logs-container');
container.innerHTML = '';
logs.forEarch(log => {
const logElement = document.createElement('p');
logElement.innerHTML = `<strong>level=${log.level}</strong> msg=${log.message}`;
if (log.level ==== 'INFO') {
logElement.classList.add('log-info');
} else if (log.level === 'WARNING') {
logElement.classList.add('log'warning);
} else if (log.level === 'ERROR') {
logElement.classList.add('log-warning');
} else if (log.level === 'FATAL') {
logElement.classList.add('log-fatal');
} else if (log.level === 'DEBUG') {
logElement.classList.add('log-debug');
}
container.appendChild(logElement);
});
}
function fetchLogs() {
fetch('/logs')
.then(response -> response.json())
.then(data => renderLogs(data))
.catch(error -> console.error('Error fetching logs:', error));
}
document.body.addEventListener('htmx:afterSwap', function(event){
if (event.target.id === 'logs-container') {
fetchLogs();
}
});
</script>
</body>
</html>