فهرست منبع

feat: initial FTP Server for Windows with web admin panel

Your Name 3 هفته پیش
کامیت
799d814503
7فایلهای تغییر یافته به همراه1865 افزوده شده و 0 حذف شده
  1. 20 0
      .gitignore
  2. 62 0
      cmd/main.go
  3. 171 0
      config/config.go
  4. 701 0
      ftp/server.go
  5. 3 0
      go.mod
  6. 545 0
      static/embed.go
  7. 363 0
      web/server.go

+ 20 - 0
.gitignore

@@ -0,0 +1,20 @@
+# Binaries
+*.exe
+ftp-server
+
+# Config (contains passwords)
+config.json
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS
+Thumbs.db
+Desktop.ini
+.DS_Store
+
+# Build output
+ftp_root/

+ 62 - 0
cmd/main.go

@@ -0,0 +1,62 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"ftp-server/config"
+	ftpserver "ftp-server/ftp"
+	webserver "ftp-server/web"
+)
+
+func main() {
+	configPath := flag.String("config", "config.json", "配置文件路径")
+	flag.Parse()
+
+	// Load configuration
+	cfg, err := config.Load(*configPath)
+	if err != nil {
+		log.Fatalf("Failed to load config: %v", err)
+	}
+
+	fmt.Println("======================================")
+	fmt.Println("       FTP Server for Windows")
+	fmt.Println("======================================")
+	fmt.Printf("FTP Port:    %d\n", cfg.FTP.Port)
+	fmt.Printf("Web Admin:   http://127.0.0.1:%d\n", cfg.Web.Port)
+	fmt.Printf("Admin User:  %s\n", cfg.Admin.Username)
+	fmt.Printf("Root Dir:    %s\n", cfg.FTP.RootDir)
+	fmt.Println("======================================")
+
+	// Ensure root directory exists
+	if err := os.MkdirAll(cfg.FTP.RootDir, 0755); err != nil {
+		log.Fatalf("Failed to create root directory: %v", err)
+	}
+
+	// Start FTP server
+	ftpSrv := ftpserver.NewServer(cfg, *configPath)
+	if err := ftpSrv.Start(); err != nil {
+		log.Fatalf("Failed to start FTP server: %v", err)
+	}
+
+	// Start Web admin server
+	webSrv := webserver.NewServer(cfg, *configPath)
+	go func() {
+		if err := webSrv.Start(); err != nil {
+			log.Fatalf("Failed to start web server: %v", err)
+		}
+	}()
+
+	// Wait for termination signal
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+	sig := <-sigChan
+
+	fmt.Printf("\nReceived signal %v, shutting down...\n", sig)
+	ftpSrv.Stop()
+	fmt.Println("FTP Server stopped.")
+}

+ 171 - 0
config/config.go

@@ -0,0 +1,171 @@
+package config
+
+import (
+	"encoding/json"
+	"os"
+	"sync"
+)
+
+// Config represents the application configuration
+type Config struct {
+	mu          sync.RWMutex `json:"-"`
+	FTP         FTPConfig    `json:"ftp"`
+	Web         WebConfig    `json:"web"`
+	Admin       AdminConfig  `json:"admin"`
+	FTPUsers    []FTPUser    `json:"ftpUsers"`
+}
+
+// FTPConfig holds FTP server settings
+type FTPConfig struct {
+	Host       string `json:"host"`
+	Port       int    `json:"port"`
+	PassiveMin int    `json:"passivePortMin"`
+	PassiveMax int    `json:"passivePortMax"`
+	RootDir    string `json:"rootDir"`
+}
+
+// WebConfig holds web admin panel settings
+type WebConfig struct {
+	Host string `json:"host"`
+	Port int    `json:"port"`
+}
+
+// AdminConfig holds admin credentials
+type AdminConfig struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+// FTPUser represents an FTP user account
+type FTPUser struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+	HomeDir  string `json:"homeDir"`
+	Write    bool   `json:"write"`
+}
+
+// DefaultConfig returns a default configuration
+func DefaultConfig() *Config {
+	return &Config{
+		FTP: FTPConfig{
+			Host:       "0.0.0.0",
+			Port:       2121,
+			PassiveMin: 50000,
+			PassiveMax: 50100,
+			RootDir:    "./ftp_root",
+		},
+		Web: WebConfig{
+			Host: "0.0.0.0",
+			Port: 8080,
+		},
+		Admin: AdminConfig{
+			Username: "admin",
+			Password: "admin123",
+		},
+		FTPUsers: []FTPUser{
+			{
+				Username: "ftpuser",
+				Password: "ftp123",
+				HomeDir:  "./ftp_root",
+				Write:    true,
+			},
+		},
+	}
+}
+
+// Load loads configuration from a file
+func Load(path string) (*Config, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			cfg := DefaultConfig()
+			if saveErr := cfg.Save(path); saveErr != nil {
+				return nil, saveErr
+			}
+			return cfg, nil
+		}
+		return nil, err
+	}
+
+	var cfg Config
+	if err := json.Unmarshal(data, &cfg); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+// Save saves configuration to a file
+func (c *Config) Save(path string) error {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	data, err := json.MarshalIndent(c, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0644)
+}
+
+// GetFTPUser finds an FTP user by username
+func (c *Config) GetFTPUser(username string) *FTPUser {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	for i := range c.FTPUsers {
+		if c.FTPUsers[i].Username == username {
+			return &c.FTPUsers[i]
+		}
+	}
+	return nil
+}
+
+// AddFTPUser adds a new FTP user
+func (c *Config) AddFTPUser(user FTPUser) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.FTPUsers = append(c.FTPUsers, user)
+}
+
+// DeleteFTPUser removes an FTP user by username
+func (c *Config) DeleteFTPUser(username string) bool {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	for i, u := range c.FTPUsers {
+		if u.Username == username {
+			c.FTPUsers = append(c.FTPUsers[:i], c.FTPUsers[i+1:]...)
+			return true
+		}
+	}
+	return false
+}
+
+// UpdateFTPUser updates an existing FTP user
+func (c *Config) UpdateFTPUser(username string, user FTPUser) bool {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	for i, u := range c.FTPUsers {
+		if u.Username == username {
+			c.FTPUsers[i] = user
+			return true
+		}
+	}
+	return false
+}
+
+// AuthenticateAdmin checks admin credentials
+func (c *Config) AuthenticateAdmin(username, password string) bool {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.Admin.Username == username && c.Admin.Password == password
+}
+
+// GetFTPUsers returns a copy of all FTP users
+func (c *Config) GetFTPUsers() []FTPUser {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	users := make([]FTPUser, len(c.FTPUsers))
+	copy(users, c.FTPUsers)
+	return users
+}

+ 701 - 0
ftp/server.go

@@ -0,0 +1,701 @@
+package ftp
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"ftp-server/config"
+)
+
+// Server represents an FTP server
+type Server struct {
+	config     *config.Config
+	configPath string
+	listener   net.Listener
+	clients    map[net.Conn]*clientConn
+	mu         sync.Mutex
+}
+
+type clientConn struct {
+	conn       net.Conn
+	server     *Server
+	reader     *bufio.Reader
+	username   string
+	authenticated bool
+	cwd        string
+	dataHost   string
+	dataPort   int
+	pasvMode   bool
+	pasvListener net.Listener
+	transferType string
+	renameFrom string
+}
+
+// NewServer creates a new FTP server
+func NewServer(cfg *config.Config, configPath string) *Server {
+	return &Server{
+		config:     cfg,
+		configPath: configPath,
+		clients:    make(map[net.Conn]*clientConn),
+	}
+}
+
+// Start starts the FTP server
+func (s *Server) Start() error {
+	addr := fmt.Sprintf("%s:%d", s.config.FTP.Host, s.config.FTP.Port)
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		return fmt.Errorf("FTP server listen error: %v", err)
+	}
+	s.listener = listener
+
+	// Ensure root directory exists
+	rootDir := s.config.FTP.RootDir
+	if err := os.MkdirAll(rootDir, 0755); err != nil {
+		return fmt.Errorf("failed to create root directory: %v", err)
+	}
+
+	log.Printf("FTP server listening on %s, root dir: %s", addr, rootDir)
+
+	go s.acceptLoop()
+	return nil
+}
+
+// Stop stops the FTP server
+func (s *Server) Stop() {
+	if s.listener != nil {
+		s.listener.Close()
+	}
+	s.mu.Lock()
+	for conn, client := range s.clients {
+		client.pasvListener.Close()
+		conn.Close()
+	}
+	s.mu.Unlock()
+}
+
+func (s *Server) acceptLoop() {
+	for {
+		conn, err := s.listener.Accept()
+		if err != nil {
+			log.Printf("FTP accept error: %v", err)
+			return
+		}
+
+		client := &clientConn{
+			conn:    conn,
+			server:  s,
+			reader:  bufio.NewReader(conn),
+			cwd:     "/",
+			transferType: "ASCII",
+		}
+
+		s.mu.Lock()
+		s.clients[conn] = client
+		s.mu.Unlock()
+
+		log.Printf("FTP client connected: %s", conn.RemoteAddr())
+		client.sendResponse(220, "FTP Server Ready")
+
+		go client.handle()
+	}
+}
+
+func (c *clientConn) sendResponse(code int, message string) {
+	fmt.Fprintf(c.conn, "%d %s\r\n", code, message)
+}
+
+func (c *clientConn) sendMultiResponse(code int, lines []string) {
+	for i, line := range lines {
+		if i == len(lines)-1 {
+			fmt.Fprintf(c.conn, "%d %s\r\n", code, line)
+		} else {
+			fmt.Fprintf(c.conn, "%d-%s\r\n", code, line)
+		}
+	}
+}
+
+func (c *clientConn) handle() {
+	defer func() {
+		c.conn.Close()
+		if c.pasvListener != nil {
+			c.pasvListener.Close()
+		}
+		c.server.mu.Lock()
+		delete(c.server.clients, c.conn)
+		c.server.mu.Unlock()
+		log.Printf("FTP client disconnected: %s", c.conn.RemoteAddr())
+	}()
+
+	for {
+		c.conn.SetDeadline(time.Now().Add(5 * time.Minute))
+		line, err := c.reader.ReadString('\n')
+		if err != nil {
+			if err != io.EOF {
+				log.Printf("FTP read error: %v", err)
+			}
+			return
+		}
+
+		line = strings.TrimRight(line, "\r\n")
+		if line == "" {
+			continue
+		}
+
+		log.Printf("FTP [%s] %s", c.conn.RemoteAddr(), line)
+		c.processCommand(line)
+	}
+}
+
+func (c *clientConn) processCommand(line string) {
+	parts := strings.SplitN(line, " ", 2)
+	cmd := strings.ToUpper(parts[0])
+	var args string
+	if len(parts) > 1 {
+		args = parts[1]
+	}
+
+	switch cmd {
+	case "USER":
+		c.handleUSER(args)
+	case "PASS":
+		c.handlePASS(args)
+	case "QUIT":
+		c.handleQUIT()
+	case "PWD":
+		c.handlePWD()
+	case "CWD":
+		c.handleCWD(args)
+	case "CDUP":
+		c.handleCDUP()
+	case "TYPE":
+		c.handleTYPE(args)
+	case "PASV":
+		c.handlePASV()
+	case "PORT":
+		c.handlePORT(args)
+	case "LIST":
+		c.handleLIST(args)
+	case "NLST":
+		c.handleNLST(args)
+	case "RETR":
+		c.handleRETR(args)
+	case "STOR":
+		c.handleSTOR(args)
+	case "DELE":
+		c.handleDELE(args)
+	case "MKD":
+		c.handleMKD(args)
+	case "RMD":
+		c.handleRMD(args)
+	case "RNFR":
+		c.handleRNFR(args)
+	case "RNTO":
+		c.handleRNTO(args)
+	case "SIZE":
+		c.handleSIZE(args)
+	case "SYST":
+		c.sendResponse(215, "UNIX Type: L8")
+	case "FEAT":
+		c.handleFEAT()
+	case "NOOP":
+		c.sendResponse(200, "OK")
+	case "OPTS":
+		c.handleOPTS(args)
+	default:
+		c.sendResponse(502, fmt.Sprintf("Command %s not implemented", cmd))
+	}
+}
+
+func (c *clientConn) handleUSER(args string) {
+	if args == "" {
+		c.sendResponse(501, "Syntax error: USER <username>")
+		return
+	}
+	c.username = args
+	c.authenticated = false
+	c.sendResponse(331, "Password required")
+}
+
+func (c *clientConn) handlePASS(args string) {
+	if c.username == "" {
+		c.sendResponse(503, "Login with USER first")
+		return
+	}
+
+	user := c.server.config.GetFTPUser(c.username)
+	if user != nil && user.Password == args {
+		c.authenticated = true
+		// Set home directory as CWD
+		homeDir := user.HomeDir
+		if homeDir == "" {
+			homeDir = c.server.config.FTP.RootDir
+		}
+		c.cwd = "/"
+		c.sendResponse(230, "Login successful")
+		log.Printf("FTP user '%s' logged in from %s", c.username, c.conn.RemoteAddr())
+	} else {
+		c.username = ""
+		c.sendResponse(530, "Login incorrect")
+	}
+}
+
+func (c *clientConn) requireAuth() bool {
+	if !c.authenticated {
+		c.sendResponse(530, "Please login first")
+		return false
+	}
+	return true
+}
+
+func (c *clientConn) handleQUIT() {
+	c.sendResponse(221, "Goodbye")
+	c.conn.Close()
+}
+
+func (c *clientConn) handlePWD() {
+	if !c.requireAuth() {
+		return
+	}
+	c.sendResponse(257, fmt.Sprintf("\"%s\" is current directory", c.cwd))
+}
+
+func (c *clientConn) handleCWD(args string) {
+	if !c.requireAuth() {
+		return
+	}
+	newPath := c.resolvePath(args)
+	realPath := c.toRealPath(newPath)
+
+	info, err := os.Stat(realPath)
+	if err != nil || !info.IsDir() {
+		c.sendResponse(550, "Directory not found")
+		return
+	}
+
+	c.cwd = newPath
+	c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd))
+}
+
+func (c *clientConn) handleCDUP() {
+	if !c.requireAuth() {
+		return
+	}
+	if c.cwd != "/" {
+		c.cwd = filepath.Dir(c.cwd)
+		if c.cwd == "" {
+			c.cwd = "/"
+		}
+	}
+	c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd))
+}
+
+func (c *clientConn) handleTYPE(args string) {
+	args = strings.ToUpper(args)
+	switch args {
+	case "A", "A N":
+		c.transferType = "ASCII"
+		c.sendResponse(200, "Type set to ASCII")
+	case "I", "L 8":
+		c.transferType = "BINARY"
+		c.sendResponse(200, "Type set to Binary")
+	default:
+		c.sendResponse(504, "Type not supported")
+	}
+}
+
+func (c *clientConn) handlePASV() {
+	if !c.requireAuth() {
+		return
+	}
+
+	if c.pasvListener != nil {
+		c.pasvListener.Close()
+	}
+
+	listener, err := net.Listen("tcp", "0.0.0.0:0")
+	if err != nil {
+		c.sendResponse(425, "Cannot open passive connection")
+		return
+	}
+	c.pasvListener = listener
+	c.pasvMode = true
+
+	addr := listener.Addr().(*net.TCPAddr)
+	hostParts := strings.Split(c.conn.LocalAddr().(*net.TCPAddr).IP.String(), ".")
+	p1 := addr.Port / 256
+	p2 := addr.Port % 256
+
+	c.sendResponse(227, fmt.Sprintf("Entering Passive Mode (%s,%s,%s,%s,%d,%d)",
+		hostParts[0], hostParts[1], hostParts[2], hostParts[3], p1, p2))
+}
+
+func (c *clientConn) handlePORT(args string) {
+	if !c.requireAuth() {
+		return
+	}
+
+	parts := strings.Split(args, ",")
+	if len(parts) != 6 {
+		c.sendResponse(501, "Syntax error in PORT command")
+		return
+	}
+
+	host := strings.Join(parts[0:4], ".")
+	p1, _ := strconv.Atoi(parts[4])
+	p2, _ := strconv.Atoi(parts[5])
+	c.dataHost = host
+	c.dataPort = p1*256 + p2
+	c.pasvMode = false
+
+	c.sendResponse(200, "PORT command successful")
+}
+
+func (c *clientConn) getDataConn() (net.Conn, error) {
+	if c.pasvMode && c.pasvListener != nil {
+		conn, err := c.pasvListener.Accept()
+		c.pasvListener.Close()
+		c.pasvListener = nil
+		return conn, err
+	}
+
+	addr := fmt.Sprintf("%s:%d", c.dataHost, c.dataPort)
+	return net.DialTimeout("tcp", addr, 10*time.Second)
+}
+
+func (c *clientConn) handleLIST(args string) {
+	if !c.requireAuth() {
+		return
+	}
+
+	targetPath := args
+	if targetPath == "" || targetPath == "-a" || targetPath == "-la" || targetPath == "-al" {
+		targetPath = c.cwd
+	} else if !strings.HasPrefix(targetPath, "/") {
+		targetPath = c.resolvePath(targetPath)
+	}
+
+	realPath := c.toRealPath(targetPath)
+
+	entries, err := os.ReadDir(realPath)
+	if err != nil {
+		c.sendResponse(550, "Directory listing failed")
+		return
+	}
+
+	dataConn, err := c.getDataConn()
+	if err != nil {
+		c.sendResponse(425, "Cannot open data connection")
+		return
+	}
+	defer dataConn.Close()
+
+	c.sendResponse(150, "Opening data connection for directory listing")
+
+	var lines []string
+	for _, entry := range entries {
+		info, _ := entry.Info()
+		modTime := info.ModTime()
+		perm := "-rwxr-xr-x"
+		if info.IsDir() {
+			perm = "drwxr-xr-x"
+		}
+		line := fmt.Sprintf("%s 1 ftp ftp %12d %s %s",
+			perm,
+			info.Size(),
+			modTime.Format("Jan 02 15:04"),
+			entry.Name(),
+		)
+		lines = append(lines, line)
+	}
+
+	if len(lines) == 0 {
+		dataConn.Write([]byte(""))
+	} else {
+		dataConn.Write([]byte(strings.Join(lines, "\r\n") + "\r\n"))
+	}
+
+	c.sendResponse(226, "Transfer complete")
+}
+
+func (c *clientConn) handleNLST(args string) {
+	if !c.requireAuth() {
+		return
+	}
+
+	targetPath := args
+	if targetPath == "" {
+		targetPath = c.cwd
+	}
+
+	realPath := c.toRealPath(c.resolvePath(targetPath))
+
+	entries, err := os.ReadDir(realPath)
+	if err != nil {
+		c.sendResponse(550, "Directory listing failed")
+		return
+	}
+
+	dataConn, err := c.getDataConn()
+	if err != nil {
+		c.sendResponse(425, "Cannot open data connection")
+		return
+	}
+	defer dataConn.Close()
+
+	c.sendResponse(150, "Opening data connection")
+
+	var names []string
+	for _, entry := range entries {
+		names = append(names, entry.Name())
+	}
+	dataConn.Write([]byte(strings.Join(names, "\r\n") + "\r\n"))
+
+	c.sendResponse(226, "Transfer complete")
+}
+
+func (c *clientConn) handleRETR(args string) {
+	if !c.requireAuth() {
+		return
+	}
+
+	filePath := c.resolvePath(args)
+	realPath := c.toRealPath(filePath)
+
+	file, err := os.Open(realPath)
+	if err != nil {
+		c.sendResponse(550, "File not found")
+		return
+	}
+	defer file.Close()
+
+	dataConn, err := c.getDataConn()
+	if err != nil {
+		c.sendResponse(425, "Cannot open data connection")
+		return
+	}
+	defer dataConn.Close()
+
+	c.sendResponse(150, "Opening data connection")
+
+	written, err := io.Copy(dataConn, file)
+	if err != nil {
+		c.sendResponse(426, "Transfer aborted")
+		return
+	}
+
+	log.Printf("FTP RETR %s (%d bytes)", filePath, written)
+	c.sendResponse(226, "Transfer complete")
+}
+
+func (c *clientConn) handleSTOR(args string) {
+	if !c.requireAuth() {
+		return
+	}
+
+	// Check write permission
+	user := c.server.config.GetFTPUser(c.username)
+	if user == nil || !user.Write {
+		c.sendResponse(550, "Permission denied")
+		return
+	}
+
+	filePath := c.resolvePath(args)
+	realPath := c.toRealPath(filePath)
+
+	// Ensure parent directory exists
+	os.MkdirAll(filepath.Dir(realPath), 0755)
+
+	file, err := os.Create(realPath)
+	if err != nil {
+		c.sendResponse(550, "Cannot create file")
+		return
+	}
+	defer file.Close()
+
+	dataConn, err := c.getDataConn()
+	if err != nil {
+		c.sendResponse(425, "Cannot open data connection")
+		return
+	}
+	defer dataConn.Close()
+
+	c.sendResponse(150, "Opening data connection")
+
+	written, err := io.Copy(file, dataConn)
+	if err != nil {
+		c.sendResponse(426, "Transfer aborted")
+		return
+	}
+
+	log.Printf("FTP STOR %s (%d bytes)", filePath, written)
+	c.sendResponse(226, "Transfer complete")
+}
+
+func (c *clientConn) handleDELE(args string) {
+	if !c.requireAuth() {
+		return
+	}
+	user := c.server.config.GetFTPUser(c.username)
+	if user == nil || !user.Write {
+		c.sendResponse(550, "Permission denied")
+		return
+	}
+
+	filePath := c.resolvePath(args)
+	realPath := c.toRealPath(filePath)
+
+	if err := os.Remove(realPath); err != nil {
+		c.sendResponse(550, "Delete failed")
+		return
+	}
+	c.sendResponse(250, "File deleted")
+}
+
+func (c *clientConn) handleMKD(args string) {
+	if !c.requireAuth() {
+		return
+	}
+	user := c.server.config.GetFTPUser(c.username)
+	if user == nil || !user.Write {
+		c.sendResponse(550, "Permission denied")
+		return
+	}
+
+	dirPath := c.resolvePath(args)
+	realPath := c.toRealPath(dirPath)
+
+	if err := os.MkdirAll(realPath, 0755); err != nil {
+		c.sendResponse(550, "Cannot create directory")
+		return
+	}
+	c.sendResponse(257, fmt.Sprintf("\"%s\" created", dirPath))
+}
+
+func (c *clientConn) handleRMD(args string) {
+	if !c.requireAuth() {
+		return
+	}
+	user := c.server.config.GetFTPUser(c.username)
+	if user == nil || !user.Write {
+		c.sendResponse(550, "Permission denied")
+		return
+	}
+
+	dirPath := c.resolvePath(args)
+	realPath := c.toRealPath(dirPath)
+
+	if err := os.RemoveAll(realPath); err != nil {
+		c.sendResponse(550, "Cannot remove directory")
+		return
+	}
+	c.sendResponse(250, "Directory removed")
+}
+
+func (c *clientConn) handleRNFR(args string) {
+	if !c.requireAuth() {
+		return
+	}
+	user := c.server.config.GetFTPUser(c.username)
+	if user == nil || !user.Write {
+		c.sendResponse(550, "Permission denied")
+		return
+	}
+
+	filePath := c.resolvePath(args)
+	realPath := c.toRealPath(filePath)
+
+	if _, err := os.Stat(realPath); err != nil {
+		c.sendResponse(550, "File not found")
+		return
+	}
+
+	c.renameFrom = filePath
+	c.sendResponse(350, "Ready for RNTO")
+}
+
+func (c *clientConn) handleRNTO(args string) {
+	if !c.requireAuth() {
+		return
+	}
+	if c.renameFrom == "" {
+		c.sendResponse(503, "RNFR required first")
+		return
+	}
+
+	fromPath := c.toRealPath(c.renameFrom)
+	toPath := c.toRealPath(c.resolvePath(args))
+
+	if err := os.Rename(fromPath, toPath); err != nil {
+		c.sendResponse(550, "Rename failed")
+		return
+	}
+
+	c.sendResponse(250, "Renamed")
+	c.renameFrom = ""
+}
+
+func (c *clientConn) handleSIZE(args string) {
+	if !c.requireAuth() {
+		return
+	}
+
+	filePath := c.resolvePath(args)
+	realPath := c.toRealPath(filePath)
+
+	info, err := os.Stat(realPath)
+	if err != nil {
+		c.sendResponse(550, "File not found")
+		return
+	}
+
+	c.sendResponse(213, fmt.Sprintf("%d", info.Size()))
+}
+
+func (c *clientConn) handleFEAT() {
+	features := []string{
+		"Features:",
+		" PASV",
+		" UTF8",
+		" SIZE",
+	}
+	c.sendMultiResponse(211, features)
+}
+
+func (c *clientConn) handleOPTS(args string) {
+	if strings.ToUpper(args) == "UTF8 ON" {
+		c.sendResponse(200, "UTF8 set to on")
+	} else {
+		c.sendResponse(501, "Option not understood")
+	}
+}
+
+// resolvePath resolves a relative path to an absolute path within the FTP root
+func (c *clientConn) resolvePath(path string) string {
+	if strings.HasPrefix(path, "/") {
+		return filepath.Clean(path)
+	}
+	return filepath.Clean(filepath.Join(c.cwd, path))
+}
+
+// toRealPath converts an FTP virtual path to a real filesystem path
+func (c *clientConn) toRealPath(ftpPath string) string {
+	user := c.server.config.GetFTPUser(c.username)
+	rootDir := c.server.config.FTP.RootDir
+	if user != nil && user.HomeDir != "" {
+		rootDir = user.HomeDir
+	}
+
+	// Remove leading slash
+	cleanPath := strings.TrimPrefix(ftpPath, "/")
+	return filepath.Join(rootDir, cleanPath)
+}

+ 3 - 0
go.mod

@@ -0,0 +1,3 @@
+module ftp-server
+
+go 1.26.2

+ 545 - 0
static/embed.go

@@ -0,0 +1,545 @@
+package static
+
+// IndexHTML is the embedded admin panel HTML
+var IndexHTML = `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>FTP Server 管理面板</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #333; }
+
+        /* Login Page */
+        .login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
+        .login-card { background: white; border-radius: 12px; padding: 40px; width: 400px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
+        .login-card h1 { text-align: center; margin-bottom: 30px; color: #333; font-size: 24px; }
+        .login-card h1 .icon { font-size: 48px; display: block; margin-bottom: 10px; }
+        .form-group { margin-bottom: 20px; }
+        .form-group label { display: block; margin-bottom: 6px; font-weight: 600; color: #555; font-size: 14px; }
+        .form-group input { width: 100%; padding: 12px 16px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; transition: border-color 0.3s; }
+        .form-group input:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
+        .btn { padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.3s; }
+        .btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; width: 100%; }
+        .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 15px rgba(102,126,234,0.4); }
+        .btn-danger { background: #dc3545; color: white; }
+        .btn-danger:hover { background: #c82333; }
+        .btn-success { background: #28a745; color: white; }
+        .btn-success:hover { background: #218838; }
+        .btn-sm { padding: 6px 14px; font-size: 12px; }
+        .error-msg { color: #dc3545; text-align: center; margin-top: 12px; font-size: 14px; min-height: 20px; }
+
+        /* Dashboard */
+        .app-container { display: none; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px 30px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 10px rgba(0,0,0,0.2); }
+        .header h1 { font-size: 20px; font-weight: 600; }
+        .header .user-info { display: flex; align-items: center; gap: 16px; }
+        .header .user-info span { font-size: 14px; opacity: 0.9; }
+
+        .sidebar { position: fixed; left: 0; top: 60px; bottom: 0; width: 220px; background: #fff; box-shadow: 2px 0 10px rgba(0,0,0,0.05); padding-top: 20px; }
+        .sidebar a { display: block; padding: 14px 24px; color: #666; text-decoration: none; font-size: 14px; transition: all 0.3s; border-left: 3px solid transparent; }
+        .sidebar a:hover, .sidebar a.active { background: #f8f9ff; color: #667eea; border-left-color: #667eea; }
+
+        .main-content { margin-left: 220px; padding: 30px; margin-top: 60px; }
+
+        .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px; margin-bottom: 30px; }
+        .stat-card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
+        .stat-card .label { color: #888; font-size: 13px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
+        .stat-card .value { font-size: 28px; font-weight: 700; color: #333; }
+        .stat-card .value.purple { color: #667eea; }
+        .stat-card .value.green { color: #28a745; }
+        .stat-card .value.blue { color: #007bff; }
+
+        .card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 20px; }
+        .card h2 { font-size: 18px; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #eee; color: #333; }
+        .card h3 { font-size: 15px; margin-bottom: 16px; color: #555; }
+
+        table { width: 100%; border-collapse: collapse; }
+        th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
+        th { background: #fafafa; font-weight: 600; color: #555; }
+        tr:hover { background: #f8f9ff; }
+
+        .badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
+        .badge-success { background: #e8f5e9; color: #28a745; }
+        .badge-secondary { background: #f5f5f5; color: #888; }
+
+        .section { display: none; }
+        .section.active { display: block; }
+
+        .config-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+        .config-grid .form-group input, .config-grid .form-group select { width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
+        .config-grid .form-group input:focus { outline: none; border-color: #667eea; }
+
+        .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
+        .modal-overlay.show { display: flex; }
+        .modal { background: white; border-radius: 12px; padding: 30px; width: 480px; max-width: 90%; }
+        .modal h2 { margin-bottom: 24px; }
+        .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
+
+        .toast { position: fixed; top: 80px; right: 30px; padding: 14px 24px; border-radius: 8px; color: white; font-size: 14px; font-weight: 500; z-index: 2000; animation: slideIn 0.3s ease; }
+        .toast-success { background: #28a745; }
+        .toast-error { background: #dc3545; }
+        @keyframes slideIn { from { transform: translateX(100px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+
+        .actions-cell { display: flex; gap: 8px; }
+    </style>
+</head>
+<body>
+
+<!-- Login Page -->
+<div class="login-container" id="loginPage">
+    <div class="login-card">
+        <h1><span class="icon">&#128193;</span>FTP Server 管理面板</h1>
+        <div class="form-group">
+            <label>用户名</label>
+            <input type="text" id="loginUser" placeholder="请输入管理员用户名" value="admin">
+        </div>
+        <div class="form-group">
+            <label>密码</label>
+            <input type="password" id="loginPass" placeholder="请输入密码" value="">
+        </div>
+        <button class="btn btn-primary" onclick="doLogin()">登 录</button>
+        <div class="error-msg" id="loginError"></div>
+    </div>
+</div>
+
+<!-- Dashboard -->
+<div class="app-container" id="appContainer">
+    <div class="header">
+        <h1>&#128193; FTP Server 管理面板</h1>
+        <div class="user-info">
+            <span id="welcomeText">欢迎, Admin</span>
+            <button class="btn btn-sm btn-danger" onclick="doLogout()">退出登录</button>
+        </div>
+    </div>
+
+    <div class="sidebar">
+        <a href="#" class="active" onclick="showSection('dashboard', this)">&#128202; 仪表盘</a>
+        <a href="#" onclick="showSection('users', this)">&#128100; 用户管理</a>
+        <a href="#" onclick="showSection('settings', this)">&#9881; 系统设置</a>
+    </div>
+
+    <div class="main-content">
+        <!-- Dashboard Section -->
+        <div class="section active" id="section-dashboard">
+            <div class="stats-grid">
+                <div class="stat-card">
+                    <div class="label">服务状态</div>
+                    <div class="value green">运行中</div>
+                </div>
+                <div class="stat-card">
+                    <div class="label">FTP 端口</div>
+                    <div class="value purple" id="statFtpPort">-</div>
+                </div>
+                <div class="stat-card">
+                    <div class="label">Web 端口</div>
+                    <div class="value blue" id="statWebPort">-</div>
+                </div>
+                <div class="stat-card">
+                    <div class="label">FTP 用户数</div>
+                    <div class="value" id="statUserCount">-</div>
+                </div>
+            </div>
+            <div class="card">
+                <h2>服务器信息</h2>
+                <table>
+                    <tr><td style="width:200px;font-weight:600;">FTP 根目录</td><td id="infoRootDir">-</td></tr>
+                    <tr><td style="font-weight:600;">被动模式端口范围</td><td id="infoPassiveRange">-</td></tr>
+                    <tr><td style="font-weight:600;">管理面板地址</td><td id="infoWebAddr">-</td></tr>
+                </table>
+            </div>
+        </div>
+
+        <!-- Users Section -->
+        <div class="section" id="section-users">
+            <div class="card">
+                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
+                    <h2 style="margin:0;border:none;padding:0;">FTP 用户管理</h2>
+                    <button class="btn btn-primary" style="width:auto;background:linear-gradient(135deg,#667eea,#764ba2);" onclick="showAddUserModal()">+ 添加用户</button>
+                </div>
+                <table>
+                    <thead>
+                        <tr><th>用户名</th><th>主目录</th><th>写入权限</th><th>操作</th></tr>
+                    </thead>
+                    <tbody id="userTableBody">
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <!-- Settings Section -->
+        <div class="section" id="section-settings">
+            <div class="card">
+                <h2>FTP 服务设置</h2>
+                <div class="config-grid">
+                    <div class="form-group">
+                        <label>FTP 监听地址</label>
+                        <input type="text" id="cfgFtpHost">
+                    </div>
+                    <div class="form-group">
+                        <label>FTP 端口</label>
+                        <input type="number" id="cfgFtpPort">
+                    </div>
+                    <div class="form-group">
+                        <label>被动模式起始端口</label>
+                        <input type="number" id="cfgPassiveMin">
+                    </div>
+                    <div class="form-group">
+                        <label>被动模式结束端口</label>
+                        <input type="number" id="cfgPassiveMax">
+                    </div>
+                    <div class="form-group">
+                        <label>FTP 根目录</label>
+                        <input type="text" id="cfgRootDir">
+                    </div>
+                </div>
+            </div>
+            <div class="card">
+                <h2>Web 管理面板设置</h2>
+                <div class="config-grid">
+                    <div class="form-group">
+                        <label>Web 监听地址</label>
+                        <input type="text" id="cfgWebHost">
+                    </div>
+                    <div class="form-group">
+                        <label>Web 端口</label>
+                        <input type="number" id="cfgWebPort">
+                    </div>
+                </div>
+            </div>
+            <div class="card">
+                <h2>管理员密码</h2>
+                <div class="config-grid">
+                    <div class="form-group">
+                        <label>新密码</label>
+                        <input type="password" id="cfgAdminPass" placeholder="输入新密码">
+                    </div>
+                    <div class="form-group">
+                        <label>确认密码</label>
+                        <input type="password" id="cfgAdminPassConfirm" placeholder="再次输入新密码">
+                    </div>
+                </div>
+            </div>
+            <button class="btn btn-primary" style="width:auto;padding:12px 40px;" onclick="saveSettings()">保存设置</button>
+        </div>
+    </div>
+</div>
+
+<!-- Add/Edit User Modal -->
+<div class="modal-overlay" id="userModal">
+    <div class="modal">
+        <h2 id="userModalTitle">添加 FTP 用户</h2>
+        <input type="hidden" id="editUserName">
+        <div class="form-group">
+            <label>用户名</label>
+            <input type="text" id="modalUsername" placeholder="输入用户名">
+        </div>
+        <div class="form-group">
+            <label>密码</label>
+            <input type="password" id="modalPassword" placeholder="输入密码">
+        </div>
+        <div class="form-group">
+            <label>主目录</label>
+            <input type="text" id="modalHomeDir" placeholder="例如: ./ftp_root/user1">
+        </div>
+        <div class="form-group">
+            <label><input type="checkbox" id="modalWrite" checked> 允许写入</label>
+        </div>
+        <div class="modal-actions">
+            <button class="btn" style="background:#eee;" onclick="closeUserModal()">取消</button>
+            <button class="btn btn-primary" style="width:auto;" onclick="saveUser()">保存</button>
+        </div>
+    </div>
+</div>
+
+<script>
+let authToken = localStorage.getItem('ftp_admin_token') || '';
+
+// Check auth on load
+window.onload = function() {
+    if (authToken) {
+        checkAuth();
+    }
+};
+
+function checkAuth() {
+    fetch('/api/status', {
+        headers: { 'Authorization': 'Bearer ' + authToken }
+    }).then(r => {
+        if (r.ok) {
+            showDashboard();
+        } else {
+            showLogin();
+        }
+    }).catch(() => showLogin());
+}
+
+function showLogin() {
+    document.getElementById('loginPage').style.display = 'flex';
+    document.getElementById('appContainer').style.display = 'none';
+}
+
+function showDashboard() {
+    document.getElementById('loginPage').style.display = 'none';
+    document.getElementById('appContainer').style.display = 'block';
+    loadStatus();
+    loadUsers();
+    loadConfig();
+}
+
+function doLogin() {
+    const username = document.getElementById('loginUser').value;
+    const password = document.getElementById('loginPass').value;
+    const errorEl = document.getElementById('loginError');
+
+    if (!username || !password) {
+        errorEl.textContent = '请输入用户名和密码';
+        return;
+    }
+
+    fetch('/api/login', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ username, password })
+    }).then(r => r.json()).then(data => {
+        if (data.token) {
+            authToken = data.token;
+            localStorage.setItem('ftp_admin_token', authToken);
+            showDashboard();
+        } else {
+            errorEl.textContent = data.error || '登录失败';
+        }
+    }).catch(() => {
+        errorEl.textContent = '网络错误';
+    });
+}
+
+function doLogout() {
+    fetch('/api/logout', {
+        headers: { 'Authorization': 'Bearer ' + authToken }
+    }).finally(() => {
+        authToken = '';
+        localStorage.removeItem('ftp_admin_token');
+        showLogin();
+    });
+}
+
+function loadStatus() {
+    fetch('/api/status', {
+        headers: { 'Authorization': 'Bearer ' + authToken }
+    }).then(r => r.json()).then(data => {
+        document.getElementById('statFtpPort').textContent = data.ftpPort;
+        document.getElementById('statWebPort').textContent = data.webPort;
+        document.getElementById('statUserCount').textContent = data.userCount;
+        document.getElementById('infoRootDir').textContent = data.rootDir;
+    });
+}
+
+function loadConfig() {
+    fetch('/api/config', {
+        headers: { 'Authorization': 'Bearer ' + authToken }
+    }).then(r => r.json()).then(data => {
+        document.getElementById('cfgFtpHost').value = data.ftp.host;
+        document.getElementById('cfgFtpPort').value = data.ftp.port;
+        document.getElementById('cfgPassiveMin').value = data.ftp.passivePortMin;
+        document.getElementById('cfgPassiveMax').value = data.ftp.passivePortMax;
+        document.getElementById('cfgRootDir').value = data.ftp.rootDir;
+        document.getElementById('cfgWebHost').value = data.web.host;
+        document.getElementById('cfgWebPort').value = data.web.port;
+        document.getElementById('infoPassiveRange').textContent = data.ftp.passivePortMin + ' - ' + data.ftp.passivePortMax;
+        document.getElementById('infoWebAddr').textContent = 'http://' + data.web.host + ':' + data.web.port;
+    });
+}
+
+function loadUsers() {
+    fetch('/api/users', {
+        headers: { 'Authorization': 'Bearer ' + authToken }
+    }).then(r => r.json()).then(users => {
+        const tbody = document.getElementById('userTableBody');
+        tbody.innerHTML = '';
+        if (users && users.length > 0) {
+            users.forEach(u => {
+                const tr = document.createElement('tr');
+                const writeBadge = u.write ? '<span class="badge badge-success">可写</span>' : '<span class="badge badge-secondary">只读</span>';
+                tr.innerHTML =
+                    '<td>' + u.username + '</td>' +
+                    '<td>' + u.homeDir + '</td>' +
+                    '<td>' + writeBadge + '</td>' +
+                    '<td class="actions-cell">' +
+                        '<button class="btn btn-sm" style="background:#667eea;color:white;" onclick="editUser(\'' + u.username + '\', \'' + u.homeDir + '\', ' + u.write + ')">编辑</button>' +
+                        '<button class="btn btn-sm btn-danger" onclick="deleteUser(\'' + u.username + '\')">删除</button>' +
+                    '</td>';
+                tbody.appendChild(tr);
+            });
+        } else {
+            tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#888;">暂无用户</td></tr>';
+        }
+    });
+}
+
+function showSection(name, el) {
+    document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
+    document.getElementById('section-' + name).classList.add('active');
+    document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
+    el.classList.add('active');
+
+    if (name === 'dashboard') { loadStatus(); loadConfig(); }
+    if (name === 'users') { loadUsers(); }
+    if (name === 'settings') { loadConfig(); }
+}
+
+function showAddUserModal() {
+    document.getElementById('userModalTitle').textContent = '添加 FTP 用户';
+    document.getElementById('editUserName').value = '';
+    document.getElementById('modalUsername').value = '';
+    document.getElementById('modalUsername').disabled = false;
+    document.getElementById('modalPassword').value = '';
+    document.getElementById('modalHomeDir').value = '';
+    document.getElementById('modalWrite').checked = true;
+    document.getElementById('userModal').classList.add('show');
+}
+
+function editUser(username, homeDir, write) {
+    document.getElementById('userModalTitle').textContent = '编辑 FTP 用户';
+    document.getElementById('editUserName').value = username;
+    document.getElementById('modalUsername').value = username;
+    document.getElementById('modalUsername').disabled = true;
+    document.getElementById('modalPassword').value = '';
+    document.getElementById('modalPassword').placeholder = '留空则不修改密码';
+    document.getElementById('modalHomeDir').value = homeDir;
+    document.getElementById('modalWrite').checked = write;
+    document.getElementById('userModal').classList.add('show');
+}
+
+function closeUserModal() {
+    document.getElementById('userModal').classList.remove('show');
+    document.getElementById('modalPassword').placeholder = '输入密码';
+}
+
+function saveUser() {
+    const editUser = document.getElementById('editUserName').value;
+    const username = document.getElementById('modalUsername').value;
+    const password = document.getElementById('modalPassword').value;
+    const homeDir = document.getElementById('modalHomeDir').value;
+    const write = document.getElementById('modalWrite').checked;
+
+    if (!username) { showToast('请输入用户名', 'error'); return; }
+
+    if (editUser) {
+        // Update existing user
+        const user = { username, homeDir, write };
+        if (password) user.password = password;
+        else {
+            // Keep existing password - send a flag
+            user.keepPassword = true;
+        }
+        fetch('/api/users/update', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
+            body: JSON.stringify({ username: editUser, user })
+        }).then(r => r.json()).then(data => {
+            if (data.status === 'ok') {
+                showToast('用户已更新');
+                loadUsers();
+                closeUserModal();
+            } else {
+                showToast(data.error || '更新失败', 'error');
+            }
+        });
+    } else {
+        // Add new user
+        if (!password) { showToast('请输入密码', 'error'); return; }
+        fetch('/api/users/add', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
+            body: JSON.stringify({ username, password, homeDir, write })
+        }).then(r => r.json()).then(data => {
+            if (data.status === 'ok') {
+                showToast('用户已添加');
+                loadUsers();
+                closeUserModal();
+            } else {
+                showToast(data.error || '添加失败', 'error');
+            }
+        });
+    }
+}
+
+function deleteUser(username) {
+    if (!confirm('确定要删除用户 "' + username + '" 吗?')) return;
+
+    fetch('/api/users/delete', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
+        body: JSON.stringify({ username })
+    }).then(r => r.json()).then(data => {
+        if (data.status === 'ok') {
+            showToast('用户已删除');
+            loadUsers();
+        } else {
+            showToast(data.error || '删除失败', 'error');
+        }
+    });
+}
+
+function saveSettings() {
+    const adminPass = document.getElementById('cfgAdminPass').value;
+    const adminPassConfirm = document.getElementById('cfgAdminPassConfirm').value;
+
+    if (adminPass && adminPass !== adminPassConfirm) {
+        showToast('两次密码输入不一致', 'error');
+        return;
+    }
+
+    const update = {
+        ftp: {
+            host: document.getElementById('cfgFtpHost').value,
+            port: parseInt(document.getElementById('cfgFtpPort').value),
+            passivePortMin: parseInt(document.getElementById('cfgPassiveMin').value),
+            passivePortMax: parseInt(document.getElementById('cfgPassiveMax').value),
+            rootDir: document.getElementById('cfgRootDir').value
+        },
+        web: {
+            host: document.getElementById('cfgWebHost').value,
+            port: parseInt(document.getElementById('cfgWebPort').value)
+        }
+    };
+
+    if (adminPass) {
+        update.adminPassword = adminPass;
+    }
+
+    fetch('/api/config/update', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
+        body: JSON.stringify(update)
+    }).then(r => r.json()).then(data => {
+        if (data.status === 'ok') {
+            showToast('设置已保存,部分设置需重启服务生效');
+            document.getElementById('cfgAdminPass').value = '';
+            document.getElementById('cfgAdminPassConfirm').value = '';
+            loadConfig();
+            loadStatus();
+        } else {
+            showToast(data.error || '保存失败', 'error');
+        }
+    });
+}
+
+function showToast(msg, type) {
+    const toast = document.createElement('div');
+    toast.className = 'toast ' + (type === 'error' ? 'toast-error' : 'toast-success');
+    toast.textContent = msg;
+    document.body.appendChild(toast);
+    setTimeout(() => toast.remove(), 3000);
+}
+
+// Handle Enter key on login
+document.getElementById('loginPass').addEventListener('keypress', function(e) {
+    if (e.key === 'Enter') doLogin();
+});
+</script>
+</body>
+</html>
+`

+ 363 - 0
web/server.go

@@ -0,0 +1,363 @@
+package web
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"ftp-server/config"
+	"ftp-server/static"
+)
+
+// Server represents the web admin server
+type Server struct {
+	config     *config.Config
+	configPath string
+	sessions   map[string]time.Time
+	mu         sync.RWMutex
+}
+
+// NewServer creates a new web admin server
+func NewServer(cfg *config.Config, configPath string) *Server {
+	return &Server{
+		config:     cfg,
+		configPath: configPath,
+		sessions:   make(map[string]time.Time),
+	}
+}
+
+// Start starts the web admin server
+func (s *Server) Start() error {
+	mux := http.NewServeMux()
+
+	// Static files served directly from code
+	// No file server needed - HTML is embedded in static package
+
+	// Serve the main page
+	mux.HandleFunc("/", s.handleIndex)
+
+	// API routes
+	mux.HandleFunc("/api/login", s.handleLogin)
+	mux.HandleFunc("/api/logout", s.authRequired(s.handleLogout))
+	mux.HandleFunc("/api/status", s.authRequired(s.handleStatus))
+	mux.HandleFunc("/api/users", s.authRequired(s.handleUsers))
+	mux.HandleFunc("/api/users/add", s.authRequired(s.handleAddUser))
+	mux.HandleFunc("/api/users/delete", s.authRequired(s.handleDeleteUser))
+	mux.HandleFunc("/api/users/update", s.authRequired(s.handleUpdateUser))
+	mux.HandleFunc("/api/config", s.authRequired(s.handleConfig))
+	mux.HandleFunc("/api/config/update", s.authRequired(s.handleUpdateConfig))
+
+	// Start session cleanup
+	go s.cleanupSessions()
+
+	addr := fmt.Sprintf("%s:%d", s.config.Web.Host, s.config.Web.Port)
+	log.Printf("Web admin server listening on http://%s", addr)
+
+	return http.ListenAndServe(addr, mux)
+}
+
+// authRequired is middleware that requires authentication
+func (s *Server) authRequired(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		token := r.Header.Get("Authorization")
+		if token == "" {
+			cookie, err := r.Cookie("session")
+			if err == nil {
+				token = cookie.Value
+			}
+		}
+
+		token = strings.TrimPrefix(token, "Bearer ")
+
+		if !s.isValidSession(token) {
+			http.Error(w, `{"error":"Unauthorized"}`, http.StatusUnauthorized)
+			return
+		}
+
+		next(w, r)
+	}
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Path != "/" {
+		http.NotFound(w, r)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.Write([]byte(static.IndexHTML))
+}
+
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
+		return
+	}
+
+	var creds struct {
+		Username string `json:"username"`
+		Password string `json:"password"`
+	}
+
+	if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
+		http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
+		return
+	}
+
+	if !s.config.AuthenticateAdmin(creds.Username, creds.Password) {
+		http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized)
+		return
+	}
+
+	token := s.createSession()
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{
+		"token": token,
+	})
+
+	log.Printf("Web admin '%s' logged in from %s", creds.Username, r.RemoteAddr)
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+	token := r.Header.Get("Authorization")
+	token = strings.TrimPrefix(token, "Bearer ")
+	if cookie, err := r.Cookie("session"); err == nil {
+		token = cookie.Value
+	}
+	s.deleteSession(token)
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"status":    "running",
+		"ftpPort":   s.config.FTP.Port,
+		"webPort":   s.config.Web.Port,
+		"rootDir":   s.config.FTP.RootDir,
+		"userCount": len(s.config.GetFTPUsers()),
+	})
+}
+
+func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
+	users := s.config.GetFTPUsers()
+
+	// Hide passwords in response
+	type safeUser struct {
+		Username string `json:"username"`
+		HomeDir  string `json:"homeDir"`
+		Write    bool   `json:"write"`
+	}
+
+	var safeUsers []safeUser
+	for _, u := range users {
+		safeUsers = append(safeUsers, safeUser{
+			Username: u.Username,
+			HomeDir:  u.HomeDir,
+			Write:    u.Write,
+		})
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(safeUsers)
+}
+
+func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
+		return
+	}
+
+	var user config.FTPUser
+	if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
+		http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
+		return
+	}
+
+	if user.Username == "" || user.Password == "" {
+		http.Error(w, `{"error":"Username and password required"}`, http.StatusBadRequest)
+		return
+	}
+
+	if s.config.GetFTPUser(user.Username) != nil {
+		http.Error(w, `{"error":"User already exists"}`, http.StatusConflict)
+		return
+	}
+
+	if user.HomeDir == "" {
+		user.HomeDir = s.config.FTP.RootDir
+	}
+
+	s.config.AddFTPUser(user)
+	if err := s.config.Save(s.configPath); err != nil {
+		http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
+		return
+	}
+
+	log.Printf("FTP user '%s' added via web admin", user.Username)
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req struct {
+		Username string `json:"username"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
+		return
+	}
+
+	if !s.config.DeleteFTPUser(req.Username) {
+		http.Error(w, `{"error":"User not found"}`, http.StatusNotFound)
+		return
+	}
+
+	if err := s.config.Save(s.configPath); err != nil {
+		http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
+		return
+	}
+
+	log.Printf("FTP user '%s' deleted via web admin", req.Username)
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req struct {
+		Username     string         `json:"username"`
+		User         config.FTPUser `json:"user"`
+		KeepPassword bool           `json:"keepPassword"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
+		return
+	}
+
+	if req.KeepPassword && req.User.Password == "" {
+		existing := s.config.GetFTPUser(req.Username)
+		if existing != nil {
+			req.User.Password = existing.Password
+		}
+	}
+
+	if !s.config.UpdateFTPUser(req.Username, req.User) {
+		http.Error(w, `{"error":"User not found"}`, http.StatusNotFound)
+		return
+	}
+
+	if err := s.config.Save(s.configPath); err != nil {
+		http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
+		return
+	}
+
+	log.Printf("FTP user '%s' updated via web admin", req.Username)
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"ftp":            s.config.FTP,
+		"web":            s.config.Web,
+		"adminUsername":  s.config.Admin.Username,
+	})
+}
+
+func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
+		return
+	}
+
+	var update struct {
+		FTP         *config.FTPConfig   `json:"ftp,omitempty"`
+		Web         *config.WebConfig   `json:"web,omitempty"`
+		AdminPass   string              `json:"adminPassword,omitempty"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
+		http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
+		return
+	}
+
+	if update.FTP != nil {
+		s.config.FTP = *update.FTP
+	}
+	if update.Web != nil {
+		s.config.Web = *update.Web
+	}
+	if update.AdminPass != "" {
+		s.config.Admin.Password = update.AdminPass
+	}
+
+	if err := s.config.Save(s.configPath); err != nil {
+		http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
+		return
+	}
+
+	log.Println("Configuration updated via web admin")
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+// Session management
+func (s *Server) createSession() string {
+	b := make([]byte, 32)
+	rand.Read(b)
+	token := hex.EncodeToString(b)
+
+	s.mu.Lock()
+	s.sessions[token] = time.Now().Add(24 * time.Hour)
+	s.mu.Unlock()
+
+	return token
+}
+
+func (s *Server) isValidSession(token string) bool {
+	if token == "" {
+		return false
+	}
+	s.mu.RLock()
+	expiry, ok := s.sessions[token]
+	s.mu.RUnlock()
+	return ok && time.Now().Before(expiry)
+}
+
+func (s *Server) deleteSession(token string) {
+	s.mu.Lock()
+	delete(s.sessions, token)
+	s.mu.Unlock()
+}
+
+func (s *Server) cleanupSessions() {
+	ticker := time.NewTicker(1 * time.Hour)
+	defer ticker.Stop()
+
+	for range ticker.C {
+		s.mu.Lock()
+		now := time.Now()
+		for token, expiry := range s.sessions {
+			if now.After(expiry) {
+				delete(s.sessions, token)
+			}
+		}
+		s.mu.Unlock()
+	}
+}