489 lines
16 KiB
Go
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
|
|
}
|