Implement configuration management and control for I2P tunnels with HTTP handlers and associated tests

This commit is contained in:
eyedeekay
2025-10-18 22:59:04 -04:00
parent e07d61432e
commit bf44e03d24
4 changed files with 691 additions and 2 deletions

View File

@@ -0,0 +1,191 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// createTestConfig creates a temporary config file with the given parameters
func createTestConfig(t *testing.T, name, tunnelType, target string, port int) string {
configDir := t.TempDir()
configFile := filepath.Join(configDir, fmt.Sprintf("%s.yaml", name))
// YAML format requires tunnels: map with tunnel name as key
configContent := fmt.Sprintf(`tunnels:
%s:
name: %s
type: %s
interface: 127.0.0.1
port: %d`, name, name, tunnelType, port)
if target != "" {
configContent += fmt.Sprintf(`
target: %s`, target)
}
configContent += "\n"
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
return configFile
}
// TestConfigServeHTTPGet tests the GET request for configuration display
func TestConfigServeHTTPGet(t *testing.T) {
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
cfg, err := NewConfig(configFile)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/test-tcp-client/config", nil)
w := httptest.NewRecorder()
cfg.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "test-tcp-client") {
t.Errorf("Response should contain tunnel name")
}
}
// TestConfigServeHTTPPost tests saving configuration changes
func TestConfigServeHTTPPost(t *testing.T) {
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
cfg, err := NewConfig(configFile)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Test POST request with new config
formData := url.Values{}
formData.Set("host", "localhost")
formData.Set("port", "9090")
req := httptest.NewRequest(http.MethodPost, "/test-tcp-client/config", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
cfg.ServeHTTP(w, req)
// Should redirect on success
if w.Code != http.StatusSeeOther {
t.Logf("Response body: %s", w.Body.String())
t.Errorf("Expected status 303 (redirect), got %d", w.Code)
}
}
// TestConfigServeHTTPPostInvalidPort tests validation of invalid port numbers
func TestConfigServeHTTPPostInvalidPort(t *testing.T) {
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
cfg, err := NewConfig(configFile)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
testCases := []struct {
name string
port string
wantCode int
}{
{"invalid port text", "abc", http.StatusBadRequest},
{"port too low", "0", http.StatusBadRequest},
{"port too high", "99999", http.StatusBadRequest},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
formData := url.Values{}
formData.Set("port", tc.port)
req := httptest.NewRequest(http.MethodPost, "/test-tcp-client/config", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
cfg.ServeHTTP(w, req)
if w.Code != tc.wantCode {
t.Errorf("Expected status %d, got %d", tc.wantCode, w.Code)
}
})
}
}
// TestConfigServeHTTPPostWhileRunning tests that config cannot be changed while tunnel is running
func TestConfigServeHTTPPostWhileRunning(t *testing.T) {
t.Skip("Skipping test that requires I2P router connection - known i2cp.leaseSetEncType duplicate parameter issue")
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
cfg, err := NewConfig(configFile)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Start the tunnel
if err := cfg.Start(); err != nil {
t.Fatalf("Failed to start tunnel: %v", err)
}
defer cfg.Stop()
// Try to modify config while running
formData := url.Values{}
formData.Set("port", "9090")
req := httptest.NewRequest(http.MethodPost, "/test-tcp-client/config", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
cfg.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "running") {
t.Errorf("Error message should mention tunnel is running")
}
}// TestNewConfig tests config creation from file
func TestNewConfig(t *testing.T) {
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
cfg, err := NewConfig(configFile)
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
if cfg == nil {
t.Fatal("Expected non-nil config")
}
if cfg.configPath != configFile {
t.Errorf("Expected configPath %s, got %s", configFile, cfg.configPath)
}
}
// TestNewConfigInvalidFile tests handling of invalid config files
func TestNewConfigInvalidFile(t *testing.T) {
cfg, err := NewConfig("/nonexistent/path/config.yaml")
if err == nil {
t.Error("Expected error for non-existent file")
}
if cfg != nil {
t.Error("Expected nil config for non-existent file")
}
}

View File

@@ -0,0 +1,194 @@
package controller
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestControllerServeHTTPGet tests the GET request for control page
func TestControllerServeHTTPGet(t *testing.T) {
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
controller, err := NewController(configFile)
if err != nil {
t.Fatalf("Failed to create controller: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/test-tcp-client/control", nil)
w := httptest.NewRecorder()
controller.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "test-tcp-client") {
t.Errorf("Response should contain tunnel name")
}
if !strings.Contains(body, "stopped") {
t.Errorf("Response should show tunnel status")
}
}
// TestControllerStart tests starting a tunnel via POST
func TestControllerStart(t *testing.T) {
t.Skip("Skipping test that requires I2P router connection - known i2cp.leaseSetEncType duplicate parameter issue")
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
controller, err := NewController(configFile)
if err != nil {
t.Fatalf("Failed to create controller: %v", err)
}
defer controller.Stop()
formData := url.Values{}
formData.Set("action", "Start")
req := httptest.NewRequest(http.MethodPost, "/test-tcp-client/control", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
controller.ServeHTTP(w, req)
// Should redirect on success
if w.Code != http.StatusSeeOther {
t.Errorf("Expected status 303 (redirect), got %d", w.Code)
}
// Verify tunnel is running
status := controller.Status()
if status != "running" {
t.Errorf("Expected tunnel to be running, got status: %s", status)
}
}
// TestControllerStop tests stopping a running tunnel
func TestControllerStop(t *testing.T) {
t.Skip("Skipping test that requires I2P router connection - known i2cp.leaseSetEncType duplicate parameter issue")
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
controller, err := NewController(configFile)
if err != nil {
t.Fatalf("Failed to create controller: %v", err)
}
// Start tunnel first
if err := controller.Start(); err != nil {
t.Fatalf("Failed to start tunnel: %v", err)
}
formData := url.Values{}
formData.Set("action", "Stop")
req := httptest.NewRequest(http.MethodPost, "/test-tcp-client/control", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
controller.ServeHTTP(w, req)
// Should redirect on success
if w.Code != http.StatusSeeOther {
t.Errorf("Expected status 303 (redirect), got %d", w.Code)
}
// Verify tunnel is stopped
status := controller.Status()
if status != "stopped" {
t.Errorf("Expected tunnel to be stopped, got status: %s", status)
}
}
// TestControllerRestart tests restarting a tunnel
func TestControllerRestart(t *testing.T) {
t.Skip("Skipping test that requires I2P router connection - known i2cp.leaseSetEncType duplicate parameter issue")
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)controller, err := NewController(configFile)
if err != nil {
t.Fatalf("Failed to create controller: %v", err)
}
defer controller.Stop()
// Start tunnel first
if err := controller.Start(); err != nil {
t.Fatalf("Failed to start tunnel: %v", err)
}
formData := url.Values{}
formData.Set("action", "Restart")
req := httptest.NewRequest(http.MethodPost, "/test-tcp-client/control", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
controller.ServeHTTP(w, req)
// Should redirect on success
if w.Code != http.StatusSeeOther {
t.Errorf("Expected status 303 (redirect), got %d", w.Code)
}
// Verify tunnel is running after restart
status := controller.Status()
if status != "running" {
t.Errorf("Expected tunnel to be running after restart, got status: %s", status)
}
}
// TestControllerInvalidAction tests handling of invalid actions
func TestControllerInvalidAction(t *testing.T) {
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
controller, err := NewController(configFile)
if err != nil {
t.Fatalf("Failed to create controller: %v", err)
}
formData := url.Values{}
formData.Set("action", "InvalidAction")
req := httptest.NewRequest(http.MethodPost, "/test-tcp-client/control", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
controller.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Unknown action") {
t.Errorf("Error message should mention unknown action")
}
}
// TestMiniServeHTTP tests the mini control widget rendering
func TestMiniServeHTTP(t *testing.T) {
configFile := createTestConfig(t, "test-tcp-client", "tcpclient", "example.i2p", 8080)
controller, err := NewController(configFile)
if err != nil {
t.Fatalf("Failed to create controller: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/home", nil)
w := httptest.NewRecorder()
controller.MiniServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "test-tcp-client") {
t.Errorf("Response should contain tunnel name")
}
}

View File

@@ -1,10 +1,16 @@
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strconv"
i2ptunnel "github.com/go-i2p/go-i2ptunnel/lib/core" i2ptunnel "github.com/go-i2p/go-i2ptunnel/lib/core"
"github.com/go-i2p/go-i2ptunnel/lib/loader" "github.com/go-i2p/go-i2ptunnel/lib/loader"
"github.com/go-i2p/go-i2ptunnel/webui/templates"
"gopkg.in/yaml.v2"
) )
/** /**
@@ -17,9 +23,158 @@ It uses ../templates/i2ptunnelconfig.html as an HTML template
type Config struct { type Config struct {
i2ptunnel.I2PTunnel i2ptunnel.I2PTunnel
configPath string // Store config file path for persistence
} }
// ConfigData holds data for rendering the configuration template
type ConfigData struct {
Name string
ID string
Type string
Target string
Options map[string]string
Error string
}
// ServeHTTP handles both GET (display config form) and POST (save config)
func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
c.handleGetConfig(w, r)
case http.MethodPost:
c.handlePostConfig(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// handleGetConfig displays the configuration form with current settings
func (c *Config) handleGetConfig(w http.ResponseWriter, r *http.Request) {
data := ConfigData{
Name: c.Name(),
ID: c.ID(),
Type: c.Type(),
Target: c.Target(),
Options: c.Options(),
}
if err := templates.I2PTunnelConfigTemplate.Execute(w, data); err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
}
}
// handlePostConfig processes configuration changes and persists them to disk
func (c *Config) handlePostConfig(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
c.renderConfigWithError(w, fmt.Sprintf("Failed to parse form: %v", err))
return
}
// Validate that tunnel is not running before applying config changes
if c.Status() == i2ptunnel.I2PTunnelStatusRunning {
c.renderConfigWithError(w, "Cannot modify configuration while tunnel is running. Stop the tunnel first.")
return
}
// Build new options map from form data
newOptions := make(map[string]string)
for key := range r.Form {
// Skip non-option fields
if key == "name" || key == "id" || key == "type" || key == "destination" {
continue
}
newOptions[key] = r.FormValue(key)
}
// Validate port if provided
if portStr := r.FormValue("port"); portStr != "" {
port, err := strconv.Atoi(portStr)
if err != nil {
c.renderConfigWithError(w, "Invalid port number")
return
}
if port < 1 || port > 65535 {
c.renderConfigWithError(w, "Port must be between 1 and 65535")
return
}
newOptions["port"] = portStr
}
// Validate host if provided
if host := r.FormValue("host"); host != "" {
newOptions["host"] = host
}
// Apply new options to tunnel
if err := c.SetOptions(newOptions); err != nil {
c.renderConfigWithError(w, fmt.Sprintf("Failed to apply options: %v", err))
return
}
// Persist configuration to disk if config path is available
if c.configPath != "" {
if err := c.saveConfig(); err != nil {
c.renderConfigWithError(w, fmt.Sprintf("Configuration applied but failed to save to disk: %v", err))
return
}
}
// Redirect to control page after successful save
http.Redirect(w, r, fmt.Sprintf("/%s/control", c.ID()), http.StatusSeeOther)
}
// renderConfigWithError displays the config form with an error message
func (c *Config) renderConfigWithError(w http.ResponseWriter, errMsg string) {
data := ConfigData{
Name: c.Name(),
ID: c.ID(),
Type: c.Type(),
Target: c.Target(),
Options: c.Options(),
Error: errMsg,
}
w.WriteHeader(http.StatusBadRequest)
templates.I2PTunnelConfigTemplate.Execute(w, data)
}
// saveConfig persists the current tunnel configuration to disk in YAML format
func (c *Config) saveConfig() error {
// Create config structure matching loader expectations
config := map[string]interface{}{
"type": c.Type(),
"name": c.Name(),
"id": c.ID(),
"options": c.Options(),
}
if target := c.Target(); target != "" {
config["target"] = target
}
// Marshal to YAML
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Ensure directory exists
dir := filepath.Dir(c.configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Write to file atomically using temp file + rename
tempPath := c.configPath + ".tmp"
if err := os.WriteFile(tempPath, data, 0644); err != nil {
return fmt.Errorf("failed to write temp config: %w", err)
}
if err := os.Rename(tempPath, c.configPath); err != nil {
os.Remove(tempPath) // Clean up temp file
return fmt.Errorf("failed to save config: %w", err)
}
return nil
} }
func NewConfig(yamlFile string) (*Config, error) { func NewConfig(yamlFile string) (*Config, error) {
@@ -29,5 +184,6 @@ func NewConfig(yamlFile string) (*Config, error) {
} }
return &Config{ return &Config{
I2PTunnel: tunnel, I2PTunnel: tunnel,
configPath: yamlFile,
}, nil }, nil
} }

View File

@@ -1,7 +1,10 @@
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"github.com/go-i2p/go-i2ptunnel/webui/templates"
) )
/* /*
@@ -16,10 +19,155 @@ type Controller struct {
*Config *Config
} }
func (c *Controller) ServeHTTP(w http.ResponseWriter, r *http.Request) { // ControlData holds data for rendering control templates
type ControlData struct {
Name string
ID string
Type string
Status string
LocalAddress string
Address string
Target string
Options map[string]string
Error string
} }
// ServeHTTP handles the full control page (GET displays info, POST handles actions)
func (c *Controller) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
c.handleGetControl(w, r)
case http.MethodPost:
c.handlePostControl(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// MiniServeHTTP renders the mini control widget for the dashboard
func (c *Controller) MiniServeHTTP(w http.ResponseWriter, r *http.Request) { func (c *Controller) MiniServeHTTP(w http.ResponseWriter, r *http.Request) {
data := c.buildControlData()
if err := templates.I2PTunnelMiniControlTemplate.Execute(w, data); err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
}
}
// handleGetControl displays the full control page with tunnel status
func (c *Controller) handleGetControl(w http.ResponseWriter, r *http.Request) {
data := c.buildControlData()
if err := templates.I2PTunnelControlTemplate.Execute(w, data); err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
}
}
// handlePostControl processes control actions (Start, Stop, Restart)
func (c *Controller) handlePostControl(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
c.renderControlWithError(w, fmt.Sprintf("Failed to parse form: %v", err))
return
}
action := r.FormValue("action")
var err error
switch action {
case "Start":
err = c.handleStart()
case "Stop":
err = c.handleStop()
case "Restart":
err = c.handleRestart()
default:
c.renderControlWithError(w, fmt.Sprintf("Unknown action: %s", action))
return
}
if err != nil {
c.renderControlWithError(w, fmt.Sprintf("Action '%s' failed: %v", action, err))
return
}
// Redirect back to control page to show updated status
http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
}
// handleStart starts the tunnel if it's not already running
func (c *Controller) handleStart() error {
status := c.Status()
if status == "running" || status == "starting" {
return fmt.Errorf("tunnel is already %s", status)
}
if err := c.Start(); err != nil {
return fmt.Errorf("failed to start tunnel: %w", err)
}
return nil
}
// handleStop stops the tunnel if it's running
func (c *Controller) handleStop() error {
status := c.Status()
if status == "stopped" || status == "stopping" {
return fmt.Errorf("tunnel is already %s", status)
}
if err := c.Stop(); err != nil {
return fmt.Errorf("failed to stop tunnel: %w", err)
}
return nil
}
// handleRestart stops and then starts the tunnel
func (c *Controller) handleRestart() error {
// Stop tunnel if running
if c.Status() == "running" {
if err := c.Stop(); err != nil {
return fmt.Errorf("failed to stop tunnel during restart: %w", err)
}
}
// Start tunnel
if err := c.Start(); err != nil {
return fmt.Errorf("failed to start tunnel during restart: %w", err)
}
return nil
}
// buildControlData creates the data structure for control templates
func (c *Controller) buildControlData() ControlData {
localAddr, _ := c.LocalAddress()
data := ControlData{
Name: c.Name(),
ID: c.ID(),
Type: c.Type(),
Status: string(c.Status()),
LocalAddress: localAddr,
Address: c.Address(),
Target: c.Target(),
Options: c.Options(),
}
// Include error message if tunnel has an error
if err := c.Error(); err != nil {
data.Error = err.Error()
}
return data
}
// renderControlWithError displays the control page with an error message
func (c *Controller) renderControlWithError(w http.ResponseWriter, errMsg string) {
data := c.buildControlData()
data.Error = errMsg
w.WriteHeader(http.StatusBadRequest)
templates.I2PTunnelControlTemplate.Execute(w, data)
} }
func NewController(yamlFile string) (*Controller, error) { func NewController(yamlFile string) (*Controller, error) {