heroagent/pkg/servers/webdavserver/server.go
2025-04-23 04:18:28 +02:00

489 lines
16 KiB
Go

package webdavserver
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/net/webdav"
)
// Config holds the configuration for the WebDAV server
type Config struct {
Host string
Port int
BasePath string
FileSystem string
ReadTimeout time.Duration
WriteTimeout time.Duration
DebugMode bool
UseAuth bool
Username string
Password string
UseHTTPS bool
CertFile string
KeyFile string
AutoGenerateCerts bool
CertValidityDays int
CertOrganization string
}
// Server represents the WebDAV server
type Server struct {
config Config
httpServer *http.Server
handler *webdav.Handler
debugLog func(format string, v ...interface{})
}
// responseWrapper wraps http.ResponseWriter to capture the status code
type responseWrapper struct {
http.ResponseWriter
statusCode int
}
// WriteHeader captures the status code and passes it to the wrapped ResponseWriter
func (rw *responseWrapper) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Write captures a 200 status code if WriteHeader hasn't been called yet
func (rw *responseWrapper) Write(b []byte) (int, error) {
if rw.statusCode == 0 {
rw.statusCode = http.StatusOK
}
return rw.ResponseWriter.Write(b)
}
// NewServer creates a new WebDAV server
func NewServer(config Config) (*Server, error) {
log.Printf("Creating new WebDAV server with config: host=%s, port=%d, basePath=%s, fileSystem=%s, debug=%v, auth=%v, https=%v",
config.Host, config.Port, config.BasePath, config.FileSystem, config.DebugMode, config.UseAuth, config.UseHTTPS)
// Ensure the file system directory exists
if err := os.MkdirAll(config.FileSystem, 0755); err != nil {
log.Printf("ERROR: Failed to create file system directory %s: %v", config.FileSystem, err)
return nil, fmt.Errorf("failed to create file system directory: %w", err)
}
// Log the file system path
log.Printf("Using file system path: %s", config.FileSystem)
// Create debug logger function
debugLog := func(format string, v ...interface{}) {
if config.DebugMode {
log.Printf("[WebDAV DEBUG] "+format, v...)
}
}
// Create WebDAV handler
handler := &webdav.Handler{
FileSystem: webdav.Dir(config.FileSystem),
LockSystem: webdav.NewMemLS(),
Logger: func(r *http.Request, err error) {
if err != nil {
log.Printf("WebDAV error: %s %s - %v", r.Method, r.URL.Path, err)
} else {
log.Printf("WebDAV: %s %s", r.Method, r.URL.Path)
}
// Additional debug logging
if config.DebugMode {
log.Printf("[WebDAV DEBUG] Request Headers: %v", r.Header)
log.Printf("[WebDAV DEBUG] Request RemoteAddr: %s", r.RemoteAddr)
log.Printf("[WebDAV DEBUG] Request UserAgent: %s", r.UserAgent())
}
},
}
// Create HTTP server
httpServer := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
ReadTimeout: config.ReadTimeout,
WriteTimeout: config.WriteTimeout,
}
return &Server{
config: config,
httpServer: httpServer,
handler: handler,
debugLog: debugLog,
}, nil
}
// Start starts the WebDAV server
func (s *Server) Start() error {
log.Printf("Starting WebDAV server at %s with file system %s", s.httpServer.Addr, s.config.FileSystem)
// Create a mux to handle the WebDAV requests
mux := http.NewServeMux()
// Register the WebDAV handler at the base path
mux.HandleFunc(s.config.BasePath, func(w http.ResponseWriter, r *http.Request) {
// Enhanced debug logging
s.debugLog("Received request: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
s.debugLog("Request Protocol: %s", r.Proto)
s.debugLog("User-Agent: %s", r.UserAgent())
// Log all request headers
for name, values := range r.Header {
s.debugLog("Header: %s = %s", name, values)
}
// Log request depth (important for WebDAV)
s.debugLog("Depth header: %s", r.Header.Get("Depth"))
// Add CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE")
w.Header().Set("Access-Control-Allow-Headers", "Depth, Authorization, Content-Type, X-Requested-With")
w.Header().Set("Access-Control-Max-Age", "86400")
// Handle OPTIONS requests for CORS and WebDAV discovery
if r.Method == "OPTIONS" {
// Add WebDAV specific headers for OPTIONS responses
w.Header().Set("DAV", "1, 2")
w.Header().Set("MS-Author-Via", "DAV")
w.Header().Set("Allow", "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE")
// Check if this is a macOS WebDAV client
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
strings.Contains(r.UserAgent(), "Darwin")
if isMacOSClient {
s.debugLog("Detected macOS WebDAV client OPTIONS request, adding macOS-specific headers")
// These headers help macOS Finder with WebDAV compatibility
w.Header().Set("X-Dav-Server", "HeroLauncher WebDAV Server")
}
w.WriteHeader(http.StatusOK)
return
}
// Handle authentication if enabled
if s.config.UseAuth {
s.debugLog("Authentication required for request")
auth := r.Header.Get("Authorization")
// Check if this is a macOS WebDAV client
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
strings.Contains(r.UserAgent(), "Darwin")
// Special handling for OPTIONS requests from macOS clients
if r.Method == "OPTIONS" && isMacOSClient {
s.debugLog("Detected macOS WebDAV client OPTIONS request, allowing without auth")
// macOS sends OPTIONS without auth first, we need to let this through
// but still send the auth challenge
w.Header().Set("WWW-Authenticate", "Basic realm=\"WebDAV Server\"")
return
}
if auth == "" {
s.debugLog("No Authorization header provided for non-OPTIONS request")
w.Header().Set("WWW-Authenticate", "Basic realm=\"WebDAV Server\"")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse the authentication header
if !strings.HasPrefix(auth, "Basic ") {
s.debugLog("Invalid Authorization header format: %s", auth)
http.Error(w, "Invalid authorization header", http.StatusBadRequest)
return
}
payload, err := base64.StdEncoding.DecodeString(auth[6:])
if err != nil {
s.debugLog("Failed to decode Authorization header: %v, raw header: %s", err, auth)
http.Error(w, "Invalid authorization header", http.StatusBadRequest)
return
}
pair := strings.SplitN(string(payload), ":", 2)
if len(pair) != 2 {
s.debugLog("Invalid credential format: could not split into username:password")
w.Header().Set("WWW-Authenticate", "Basic realm=\"WebDAV Server\"")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Log username for debugging (don't log password)
s.debugLog("Received credentials for user: %s", pair[0])
if pair[0] != s.config.Username || pair[1] != s.config.Password {
s.debugLog("Invalid credentials provided, expected user: %s", s.config.Username)
w.Header().Set("WWW-Authenticate", "Basic realm=\"WebDAV Server\"")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
s.debugLog("Authentication successful for user: %s", pair[0])
}
// Log request body for WebDAV methods
if r.Method == "PROPFIND" || r.Method == "PROPPATCH" || r.Method == "REPORT" || r.Method == "PUT" {
if r.Body != nil {
bodyBytes, err := io.ReadAll(r.Body)
if err == nil {
s.debugLog("Request body: %s", string(bodyBytes))
// Create a new reader with the same content
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
}
// Add macOS-specific headers for better compatibility
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
strings.Contains(r.UserAgent(), "Darwin")
if isMacOSClient {
s.debugLog("Adding macOS-specific headers for better compatibility")
// These headers help macOS Finder with WebDAV compatibility
w.Header().Set("MS-Author-Via", "DAV")
w.Header().Set("X-Dav-Server", "HeroLauncher WebDAV Server")
w.Header().Set("DAV", "1, 2")
// Special handling for PROPFIND requests from macOS
if r.Method == "PROPFIND" {
s.debugLog("Handling macOS PROPFIND request with special compatibility")
// Make sure Content-Type is set correctly for PROPFIND responses
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
}
}
// Create a response wrapper to capture the response
responseWrapper := &responseWrapper{ResponseWriter: w}
// Handle WebDAV requests
s.debugLog("Handling WebDAV request: %s %s", r.Method, r.URL.Path)
s.handler.ServeHTTP(responseWrapper, r)
// Log response details
s.debugLog("Response status: %d", responseWrapper.statusCode)
s.debugLog("Response content type: %s", w.Header().Get("Content-Type"))
// Log detailed information for debugging connection issues
if responseWrapper.statusCode >= 400 {
s.debugLog("ERROR: WebDAV request failed with status %d", responseWrapper.statusCode)
s.debugLog("Request method: %s, path: %s", r.Method, r.URL.Path)
s.debugLog("Response headers: %v", w.Header())
} else {
s.debugLog("WebDAV request succeeded with status %d", responseWrapper.statusCode)
}
})
// Set the mux as the HTTP server handler
s.httpServer.Handler = mux
// Start the server with HTTPS if configured
var err error
if s.config.UseHTTPS {
// Check if certificate files exist or need to be generated
if (s.config.CertFile == "" || s.config.KeyFile == "") && !s.config.AutoGenerateCerts {
log.Printf("ERROR: HTTPS enabled but certificate or key file not provided and auto-generation is disabled")
return fmt.Errorf("HTTPS enabled but certificate or key file not provided and auto-generation is disabled")
}
// Auto-generate certificates if needed
if (s.config.CertFile == "" || s.config.KeyFile == "" ||
!fileExists(s.config.CertFile) || !fileExists(s.config.KeyFile)) &&
s.config.AutoGenerateCerts {
s.debugLog("Certificate files not found, auto-generating...")
// Get base directory from the file system path
baseDir := filepath.Dir(s.config.FileSystem)
// Create certificates directory if it doesn't exist
certsDir := filepath.Join(baseDir, "certificates")
if err := os.MkdirAll(certsDir, 0755); err != nil {
log.Printf("ERROR: Failed to create certificates directory: %v", err)
return fmt.Errorf("failed to create certificates directory: %w", err)
}
// Set default certificate paths if not provided
if s.config.CertFile == "" {
s.config.CertFile = filepath.Join(certsDir, "webdav.crt")
}
if s.config.KeyFile == "" {
s.config.KeyFile = filepath.Join(certsDir, "webdav.key")
}
// Generate certificates
if err := generateCertificate(
s.config.CertFile,
s.config.KeyFile,
s.config.CertOrganization,
s.config.CertValidityDays,
s.debugLog,
); err != nil {
log.Printf("ERROR: Failed to generate certificates: %v", err)
return fmt.Errorf("failed to generate certificates: %w", err)
}
log.Printf("Successfully generated self-signed certificates at %s and %s",
s.config.CertFile, s.config.KeyFile)
}
// Verify certificate files exist
if !fileExists(s.config.CertFile) || !fileExists(s.config.KeyFile) {
log.Printf("ERROR: Certificate files not found at %s and/or %s",
s.config.CertFile, s.config.KeyFile)
return fmt.Errorf("certificate files not found")
}
// Configure TLS
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
s.httpServer.TLSConfig = tlsConfig
log.Printf("Starting WebDAV server with HTTPS on %s using certificates: %s, %s",
s.httpServer.Addr, s.config.CertFile, s.config.KeyFile)
err = s.httpServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)
} else {
log.Printf("Starting WebDAV server with HTTP on %s", s.httpServer.Addr)
err = s.httpServer.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
log.Printf("ERROR: WebDAV server failed to start: %v", err)
return err
}
return nil
}
// Stop stops the WebDAV server
func (s *Server) Stop() error {
log.Printf("Stopping WebDAV server at %s", s.httpServer.Addr)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := s.httpServer.Shutdown(ctx)
if err != nil {
log.Printf("ERROR: Failed to stop WebDAV server: %v", err)
}
return err
}
// DefaultConfig returns the default configuration for the WebDAV server
func DefaultConfig() Config {
// Use system temp directory as default base path
defaultBasePath := filepath.Join(os.TempDir(), "heroagent")
return Config{
Host: "0.0.0.0",
Port: 9999,
BasePath: "/",
FileSystem: defaultBasePath,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
DebugMode: false,
UseAuth: false,
Username: "admin",
Password: "1234",
UseHTTPS: false,
CertFile: "",
KeyFile: "",
AutoGenerateCerts: true,
CertValidityDays: 365,
CertOrganization: "HeroLauncher WebDAV Server",
}
}
// fileExists checks if a file exists and is not a directory
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return err == nil && !info.IsDir()
}
// generateCertificate creates a self-signed TLS certificate and key
func generateCertificate(certFile, keyFile, organization string, validityDays int, debugLog func(format string, args ...interface{})) error {
debugLog("Generating self-signed certificate: certFile=%s, keyFile=%s, organization=%s, validityDays=%d",
certFile, keyFile, organization, validityDays)
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
}
// Prepare certificate template
notBefore := time.Now()
notAfter := notBefore.Add(time.Duration(validityDays) * 24 * time.Hour)
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{organization},
CommonName: "localhost",
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
DNSNames: []string{"localhost"},
}
// Create certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Write certificate to file
certOut, err := os.Create(certFile)
if err != nil {
return fmt.Errorf("failed to open %s for writing: %w", certFile, err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return fmt.Errorf("failed to write certificate to file: %w", err)
}
// Write private key to file
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open %s for writing: %w", keyFile, err)
}
defer keyOut.Close()
privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
if err := pem.Encode(keyOut, privateKeyPEM); err != nil {
return fmt.Errorf("failed to write private key to file: %w", err)
}
debugLog("Successfully generated self-signed certificate valid for %d days", validityDays)
return nil
}