package service import ( "context" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "errors" "fmt" "net" "net/url" "os" "path/filepath" "slices" "strconv" "strings" "sync" "syscall" "time" "github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/nginx" "github.com/1Panel-dev/1Panel/agent/utils/nginx/parser" "github.com/docker/docker/api/types/container" "github.com/google/uuid" "golang.org/x/net/idna" "gorm.io/gorm" ) const ( mcpWebsitePrivateDir = ".1panel-mcp" mcpWebsiteProxyName = "proxy.conf" mcpWebsiteOwnerName = "binding.json" ) var mcpWebsiteMutation sync.Mutex func init() { configureWebsiteManagedConfig(websiteManagedConfigPolicy{ protectedDirectories: func() []string { return []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir)} }, backupIncludes: mcpWebsiteBackupIncludes, }) } type MCPWebsiteBindingService struct { createWebsite func(request.WebsiteCreate) error deleteWebsite func(request.WebsiteDelete) error updateHTTPS func(context.Context, request.WebsiteHTTPSOp) error applyInclude func(model.Website) error removeInclude func(model.Website) error checkReload func() error openresty func() (model.AppInstall, error) now func() time.Time } func NewMCPWebsiteBindingService() *MCPWebsiteBindingService { s := &MCPWebsiteBindingService{ createWebsite: func(req request.WebsiteCreate) error { return NewIWebsiteService().CreateWebsite(req) }, deleteWebsite: func(req request.WebsiteDelete) error { return NewIWebsiteService().DeleteWebsite(req) }, updateHTTPS: func(ctx context.Context, req request.WebsiteHTTPSOp) error { if req.Enable { current, err := NewIWebsiteService().GetWebsiteHTTPS(req.WebsiteID) if err != nil { return err } defaults := newWebsiteCreateHTTPSOp(req.WebsiteSSLID) if current.Enable { req.SSLProtocol, req.Algorithm = current.SSLProtocol, current.Algorithm req.Hsts, req.HstsIncludeSubDomains, req.Http3 = current.Hsts, current.HstsIncludeSubDomains, current.Http3 req.HttpConfig = current.HttpConfig } else { defaults.WebsiteID = req.WebsiteID req = defaults } } _, err := NewIWebsiteService().OpWebsiteHTTPS(ctx, req) return err }, checkReload: func() error { installed, err := getAppInstallByKey(constant.AppOpenresty) if err != nil { return err } if installed.Status != constant.StatusRunning { return nil } if err = opNginx(installed.ContainerName, constant.NginxCheck); err != nil { return err } return opNginx(installed.ContainerName, constant.NginxReload) }, openresty: mcpWebsiteOpenresty, now: time.Now, } s.applyInclude = func(site model.Website) error { return s.setInclude(site, true) } s.removeInclude = func(site model.Website) error { return s.setInclude(site, false) } return s } func mcpWebsiteOpenresty() (model.AppInstall, error) { installed, err := getAppInstallByKey(constant.AppOpenresty) if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning { return installed, err } client, err := docker.NewDockerClient() if err != nil { return model.AppInstall{}, errors.New("OpenResty mount is unavailable") } defer client.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() inspected, err := client.ContainerInspect(ctx, installed.ContainerName) if err != nil || !mcpWebsiteHasWWWMount(inspected.Mounts, GetWebSiteRootDir()) { return model.AppInstall{}, errors.New("OpenResty mount is unavailable") } return installed, nil } func mcpWebsiteHasWWWMount(mounts []container.MountPoint, root string) bool { resolvedRoot, err := filepath.EvalSymlinks(root) if err != nil { return false } for _, mount := range mounts { if filepath.Clean(mount.Destination) != "/www" { continue } resolvedSource, sourceErr := filepath.EvalSymlinks(mount.Source) if sourceErr == nil && resolvedSource == resolvedRoot { return true } } return false } type mcpWebsiteOwner struct { BindingID string `json:"bindingId"` WebsiteID uint `json:"websiteId"` KeyHash string `json:"keyHash"` BindHash string `json:"bindHash"` ProxyHash string `json:"proxyHash"` RequestID string `json:"requestId"` RequestHash string `json:"requestHash"` Operation string `json:"operation"` DeleteRoot string `json:"deleteRoot,omitempty"` DeletePaths []mcpWebsiteDeletePath `json:"deletePaths,omitempty"` } type mcpWebsiteDeletePath struct { Kind string `json:"kind"` Device uint64 `json:"device"` Inode uint64 `json:"inode"` } func mcpWebsiteHash(value string) string { digest := sha256.Sum256([]byte(value)) return hex.EncodeToString(digest[:]) } func mcpWebsiteAlias(bindingID string) string { return "panel-mcp-" + bindingID } func mcpWebsitePrivatePath(site model.Website, name string) string { return filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, strings.TrimPrefix(site.Alias, "panel-mcp-"), name) } func mcpWebsiteInclude(site model.Website) string { return "/www/" + mcpWebsitePrivateDir + "/" + strings.TrimPrefix(site.Alias, "panel-mcp-") + "/" + mcpWebsiteProxyName } func mcpWebsiteBackupIncludes(site model.Website) []string { bindingID := strings.TrimPrefix(site.Alias, "panel-mcp-") if site.Alias != mcpWebsiteAlias(bindingID) || !validMCPWebsiteID(bindingID) { return nil } return []string{mcpWebsiteInclude(site)} } func mcpWebsitePrivateDirectory(site model.Website, create bool) error { for _, directory := range []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir), filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteProxyName))} { info, err := os.Lstat(directory) if os.IsNotExist(err) && create { if err = os.Mkdir(directory, 0700); err != nil { return err } info, err = os.Lstat(directory) } if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 { return errors.New("invalid MCP website private directory") } } return nil } func (s *MCPWebsiteBindingService) setInclude(site model.Website, enabled bool) error { configPath := GetSitePath(site, SiteConf) old, err := os.ReadFile(configPath) if err != nil { return err } config, err := parser.NewStringParser(string(old)).Parse() if err != nil || len(config.FindServers()) == 0 { return errors.New("invalid website configuration") } server := config.FindServers()[0] include := mcpWebsiteInclude(site) for _, directive := range server.FindDirectives("include") { parameters := directive.GetParameters() if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include { server.RemoveDirective("include", parameters) } } if enabled { server.UpdateDirective("include", []string{include}) } updated := []byte(nginx.DumpConfig(config, nginx.IndentedStyle)) if string(updated) == string(old) { return nil } if err = mcpWebsiteAtomicWrite(configPath, updated); err != nil { return err } if err = s.checkReload(); err != nil { _ = mcpWebsiteAtomicWrite(configPath, old) _ = s.checkReload() return err } return nil } func mcpWebsiteRemark(req request.MCPWebsiteBinding) string { return "1Panel MCP " + req.BindingID + " " + mcpWebsiteHash(req.ProxyKey) } func mcpWebsiteFailure(reason string, definitive, conflict bool) response.MCPWebsiteBinding { code, message := "MCP_WEBSITE_UNAVAILABLE", "MCP website operation is unavailable" if definitive { code, message = "MCP_WEBSITE_INVALID_ARGUMENTS", "Invalid MCP website configuration" } if conflict { code, message = "MCP_WEBSITE_CONFLICT", "MCP website configuration changed" } if definitive && reason == "http_port_requires_default" { code, message = "MCP_WEBSITE_HTTP_PORT_REQUIRED", "Use the OpenResty HTTP port before enabling HTTPS" } return response.MCPWebsiteBinding{State: "unavailable", Reason: reason, Code: code, Error: message, Definitive: definitive} } func validMCPWebsiteID(value string) bool { parsed, err := uuid.Parse(value) return err == nil && parsed != uuid.Nil && parsed.String() == value } func mcpWebsiteDomain(value string) (string, int, error) { if value == "" || len(value) > 320 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "/\\?#@\r\n\t ;{}\"'$\x00") { return "", 0, errors.New("invalid domain") } host, port := value, 0 if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil { host = parsedHost port, err = strconv.Atoi(parsedPort) if err != nil || port < 1 || port > 65535 { return "", 0, errors.New("invalid port") } } else if strings.ContainsAny(value, ":[]") { return "", 0, errors.New("invalid domain") } host, err := idna.Lookup.ToASCII(strings.ToLower(host)) if err != nil || len(host) > 253 || net.ParseIP(host) != nil { return "", 0, errors.New("invalid domain") } for _, label := range strings.Split(host, ".") { if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { return "", 0, errors.New("invalid domain") } for _, char := range label { if char != '-' && (char < 'a' || char > 'z') && (char < '0' || char > '9') { return "", 0, errors.New("invalid domain") } } } return host, port, nil } // Core can select a locally bound panel IP, but an external upstream is never // accepted even on this private API. Host/SNI is a literal hostname, not Nginx code. func validateMCPWebsiteUpstream(req request.MCPWebsiteBinding) bool { if strings.ContainsAny(req.Upstream, "\r\n\t ;{}\"'$#?") { return false } u, err := url.Parse(req.Upstream) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "/mcp" || u.RawPath != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Port() == "" { return false } ip := net.ParseIP(u.Hostname()) if ip == nil || ip.IsUnspecified() { return false } port, err := strconv.Atoi(u.Port()) if err != nil || port < 1 || port > 65535 { return false } local := ip.IsLoopback() if !local { addresses, err := net.InterfaceAddrs() if err != nil { return false } for _, address := range addresses { if network, ok := address.(*net.IPNet); ok && network.IP.Equal(ip) { local = true break } } } if !local || len(req.ProxyKey) != 64 { return false } if _, err = hex.DecodeString(req.ProxyKey); err != nil { return false } if req.CoreHost == "" || len(req.CoreHost) > 320 || strings.ContainsAny(req.CoreHost, "/\\?#@\r\n\t ;{}\"'$\x00") { return false } host := req.CoreHost if parsed, _, splitErr := net.SplitHostPort(host); splitErr == nil { host = parsed } if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { host = strings.Trim(host, "[]") } if net.ParseIP(host) == nil { if _, _, domainErr := mcpWebsiteDomain(host); domainErr != nil { return false } } return true } func mcpWebsiteProxy(req request.MCPWebsiteBinding) []byte { host := req.CoreHost if parsed, _, err := net.SplitHostPort(host); err == nil { host = parsed } host = strings.Trim(host, "[]") var tlsDirectives string if strings.HasPrefix(req.Upstream, "https://") { tlsDirectives = " proxy_ssl_server_name on;\n proxy_ssl_name " + host + ";\n" } return []byte("# Managed by 1Panel MCP: " + req.BindingID + "\n" + "location = /mcp {\n" + " proxy_pass " + req.Upstream + ";\n" + " proxy_http_version 1.1;\n" + " proxy_set_header Host " + req.CoreHost + ";\n" + " proxy_set_header Connection \"\";\n" + " proxy_set_header X-Panel-MCP-Website-Key " + req.ProxyKey + ";\n" + " proxy_set_header X-Panel-MCP-Website-Peer $realip_remote_addr;\n" + " proxy_set_header X-Forwarded-For $http_x_forwarded_for;\n" + " proxy_set_header X-Real-IP $http_x_real_ip;\n" + " proxy_set_header Forwarded $http_forwarded;\n" + " proxy_set_header Authorization $http_authorization;\n" + " proxy_set_header Origin $http_origin;\n" + " proxy_set_header Accept $http_accept;\n" + " proxy_set_header Content-Type $http_content_type;\n" + " proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version;\n" + " proxy_buffering off;\n proxy_request_buffering off;\n proxy_cache off;\n" + " proxy_read_timeout 360s;\n proxy_send_timeout 360s;\n proxy_connect_timeout 5s;\n" + " proxy_next_upstream off;\n client_max_body_size 1m;\n" + tlsDirectives + "}\n") } func (s *MCPWebsiteBindingService) validCertificate(id uint, domain string) bool { certificate, err := websiteSSLRepo.GetFirst(repo.WithByID(id)) if err != nil { return false } pair, err := tls.X509KeyPair([]byte(certificate.Pem), []byte(certificate.PrivateKey)) if err != nil || len(pair.Certificate) == 0 { return false } leaf, err := x509.ParseCertificate(pair.Certificate[0]) if err != nil || s.now().Before(leaf.NotBefore) || !s.now().Before(leaf.NotAfter) || leaf.VerifyHostname(domain) != nil { return false } if len(leaf.ExtKeyUsage) == 0 { return true } for _, usage := range leaf.ExtKeyUsage { if usage == x509.ExtKeyUsageServerAuth || usage == x509.ExtKeyUsageAny { return true } } return false } func mcpWebsiteReadOwner(site model.Website) (mcpWebsiteOwner, error) { var owner mcpWebsiteOwner if err := mcpWebsitePrivateDirectory(site, false); err != nil { return owner, err } path := mcpWebsitePrivatePath(site, mcpWebsiteOwnerName) info, err := os.Lstat(path) if err != nil || !info.Mode().IsRegular() || info.Size() > 8192 { return owner, errors.New("invalid MCP website ownership") } data, err := os.ReadFile(path) if err != nil || json.Unmarshal(data, &owner) != nil { return owner, errors.New("invalid MCP website ownership") } return owner, nil } func mcpWebsiteWriteOwner(site model.Website, owner mcpWebsiteOwner) error { data, err := json.Marshal(owner) if err != nil { return err } return mcpWebsiteAtomicWrite(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName), data) } func mcpWebsiteAtomicWrite(path string, data []byte) error { if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() { return errors.New("managed file is not regular") } else if err != nil && !os.IsNotExist(err) { return err } temp, err := os.CreateTemp(filepath.Dir(path), ".panel-mcp-*") if err != nil { return err } defer os.Remove(temp.Name()) if err = temp.Chmod(0600); err == nil { _, err = temp.Write(data) } if closeErr := temp.Close(); err == nil { err = closeErr } if err != nil { return err } return os.Rename(temp.Name(), path) } func mcpWebsiteCurrentProxy(site model.Website, owner mcpWebsiteOwner) ([]byte, error) { if err := mcpWebsitePrivateDirectory(site, false); err != nil { return nil, err } path := mcpWebsitePrivatePath(site, mcpWebsiteProxyName) info, err := os.Lstat(path) if err != nil || !info.Mode().IsRegular() || info.Size() > 16<<10 { return nil, errors.New("managed proxy is missing or invalid") } data, err := os.ReadFile(path) if err != nil || mcpWebsiteHash(string(data)) != owner.ProxyHash { return nil, errors.New("managed proxy was modified") } return data, nil } func mcpWebsiteReadProxy(path string) ([]byte, error) { info, err := os.Lstat(path) if err != nil { return nil, err } if !info.Mode().IsRegular() || info.Size() > 16<<10 { return nil, errors.New("managed proxy is not a regular file") } return os.ReadFile(path) } func mcpWebsitePayloadHash(req request.MCPWebsiteBinding) string { payload := fmt.Sprintf("%s\n%s\n%t\n%d", req.Operation, req.Domain, req.EnableSSL, req.SSLID) if req.DeleteWebsite { payload += "\ndeleteWebsite=true" } return mcpWebsiteHash(payload) } func mcpWebsiteBindHash(req request.MCPWebsiteBinding) string { return mcpWebsiteHash(fmt.Sprintf("%s\n%t\n%d", req.Domain, req.EnableSSL, req.SSLID)) } func mcpWebsiteDeleteLocations(site model.Website) map[string]string { return map[string]string{"site": GetSitePath(site, SiteDir), "config": GetSitePath(site, SiteConf)} } func mcpWebsiteDeleteParents() error { root := filepath.Clean(GetWebSiteRootDir()) if !filepath.IsAbs(root) { return errors.New("invalid managed website root") } for _, directory := range []string{root, filepath.Join(root, "sites"), filepath.Join(root, "conf.d")} { info, err := os.Lstat(directory) if os.IsNotExist(err) { continue } if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return errors.New("managed website directory changed") } } return nil } func mcpWebsiteDeleteIdentity(kind, location string) (mcpWebsiteDeletePath, bool, error) { info, err := os.Lstat(location) if os.IsNotExist(err) { return mcpWebsiteDeletePath{}, false, nil } if err != nil || info.Mode()&os.ModeSymlink != 0 || kind == "site" && !info.IsDir() || kind == "config" && !info.Mode().IsRegular() { return mcpWebsiteDeletePath{}, false, errors.New("managed website path changed") } stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return mcpWebsiteDeletePath{}, false, errors.New("managed website path identity is unavailable") } return mcpWebsiteDeletePath{Kind: kind, Device: uint64(stat.Dev), Inode: uint64(stat.Ino)}, true, nil } func mcpWebsiteRecordDeletePaths(site model.Website, owner *mcpWebsiteOwner) error { if err := mcpWebsiteDeleteParents(); err != nil { return err } owner.DeleteRoot, owner.DeletePaths = filepath.Clean(GetWebSiteRootDir()), nil for kind, location := range mcpWebsiteDeleteLocations(site) { identity, exists, err := mcpWebsiteDeleteIdentity(kind, location) if err != nil { return err } if exists { owner.DeletePaths = append(owner.DeletePaths, identity) } } return nil } func mcpWebsiteVerifyDeletePaths(site model.Website, owner mcpWebsiteOwner) (map[string]string, error) { if owner.DeleteRoot == "" || owner.DeleteRoot != filepath.Clean(GetWebSiteRootDir()) { return nil, errors.New("managed website root changed") } if err := mcpWebsiteDeleteParents(); err != nil { return nil, err } expected := make(map[string]mcpWebsiteDeletePath) for _, identity := range owner.DeletePaths { if identity.Kind != "site" && identity.Kind != "config" || expected[identity.Kind].Kind != "" { return nil, errors.New("invalid managed website deletion journal") } expected[identity.Kind] = identity } remaining := make(map[string]string) for kind, location := range mcpWebsiteDeleteLocations(site) { actual, exists, err := mcpWebsiteDeleteIdentity(kind, location) if err != nil { return nil, err } if exists { if expected[kind] != actual { return nil, errors.New("managed website path was replaced") } remaining[kind] = location } } return remaining, nil } func mcpWebsiteCleanupDeletedPrivate(site model.Website) error { if err := mcpWebsitePrivateDirectory(site, false); err != nil { if _, statErr := os.Lstat(filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName))); os.IsNotExist(statErr) { return nil } return err } if _, err := os.Lstat(mcpWebsitePrivatePath(site, mcpWebsiteProxyName)); !os.IsNotExist(err) { return errors.New("managed proxy remains after website deletion") } if err := os.Remove(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)); err != nil && !os.IsNotExist(err) { return err } directory := filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)) entries, err := os.ReadDir(directory) if err != nil { return err } if len(entries) == 0 { return os.Remove(directory) } return nil } func (s *MCPWebsiteBindingService) deletedWebsite(req request.MCPWebsiteBinding, site model.Website, owner mcpWebsiteOwner, recoverFiles bool) response.MCPWebsiteBinding { if replacement, err := websiteRepo.GetFirst(websiteRepo.WithAlias(site.Alias)); err == nil || !errors.Is(err, gorm.ErrRecordNotFound) { if err == nil && replacement.ID == site.ID { return mcpWebsiteFailure("website_delete_unconfirmed", false, false) } return mcpWebsiteFailure("website_ownership_changed", false, true) } remaining, err := mcpWebsiteVerifyDeletePaths(site, owner) if err != nil { return mcpWebsiteFailure("website_directory_changed", false, true) } if len(remaining) > 0 && !recoverFiles { return mcpWebsiteFailure("website_delete_unconfirmed", false, false) } for kind, location := range remaining { fileOp := files.NewFileOp() if kind == "site" { err = fileOp.DeleteDir(location) } else { err = fileOp.DeleteFile(location) } if err != nil { return mcpWebsiteFailure("website_delete_failed", false, false) } } remaining, err = mcpWebsiteVerifyDeletePaths(site, owner) if err != nil || len(remaining) != 0 { return mcpWebsiteFailure("website_delete_unconfirmed", false, false) } if err = mcpWebsiteCleanupDeletedPrivate(site); err != nil { return mcpWebsiteFailure("ownership_cleanup_failed", false, false) } return response.MCPWebsiteBinding{State: "unbound", WebsiteID: site.ID, BindingID: req.BindingID, RequestID: req.RequestID, WebsiteDeleted: true} } func (s *MCPWebsiteBindingService) missingDeletedWebsite(req request.MCPWebsiteBinding) response.MCPWebsiteBinding { site := model.Website{BaseModel: model.BaseModel{ID: req.WebsiteID}, Alias: mcpWebsiteAlias(req.BindingID)} if _, err := websiteRepo.GetFirst(websiteRepo.WithAlias(site.Alias)); err == nil || !errors.Is(err, gorm.ErrRecordNotFound) { return mcpWebsiteFailure("website_ownership_changed", false, true) } owner, ownerErr := mcpWebsiteReadOwner(site) if ownerErr == nil && owner.BindingID == req.BindingID && (req.WebsiteID == 0 || owner.WebsiteID == req.WebsiteID) && owner.KeyHash == mcpWebsiteHash(req.ProxyKey) && owner.Operation == "unbind" && owner.RequestID == req.RequestID && owner.RequestHash == mcpWebsitePayloadHash(req) { site.ID = owner.WebsiteID if _, err := websiteRepo.GetFirst(repo.WithByID(site.ID)); !errors.Is(err, gorm.ErrRecordNotFound) { return mcpWebsiteFailure("website_ownership_changed", false, true) } return s.deletedWebsite(req, site, owner, true) } if _, err := os.Lstat(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)); !os.IsNotExist(err) { return mcpWebsiteFailure("website_ownership_changed", false, true) } if _, err := os.Lstat(mcpWebsitePrivatePath(site, mcpWebsiteProxyName)); !os.IsNotExist(err) { return mcpWebsiteFailure("proxy_modified", false, true) } if err := mcpWebsiteDeleteParents(); err != nil { return mcpWebsiteFailure("website_directory_changed", false, true) } for _, location := range mcpWebsiteDeleteLocations(site) { if _, err := os.Lstat(location); !os.IsNotExist(err) { return mcpWebsiteFailure("website_delete_unconfirmed", false, false) } } return response.MCPWebsiteBinding{State: "unbound", WebsiteID: req.WebsiteID, BindingID: req.BindingID, RequestID: req.RequestID, WebsiteDeleted: true} } func (s *MCPWebsiteBindingService) matchesInitialSite(req request.MCPWebsiteBinding, site model.Website) bool { host, port, err := mcpWebsiteDomain(req.Domain) if err != nil || (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL || site.WebsiteSSLID != req.SSLID { return false } explicitPort := port != 0 if !explicitPort || req.EnableSSL { installed, installErr := s.openresty() if installErr != nil { return false } if !explicitPort { port = installed.HttpPort } if req.EnableSSL && port == installed.HttpPort { for _, domain := range site.Domains { if domain.Domain == host && domain.Port == installed.HttpPort && !domain.SSL { return true // Complete an interrupted bind against its existing HTTP base. } } } } for _, domain := range site.Domains { if domain.Domain == host && domain.Port == port && domain.SSL == (req.EnableSSL && explicitPort) { return true } } return false } func mcpWebsiteListenConflict(candidate model.Website, installed model.AppInstall) error { if err := checkWebsiteListenConflict(candidate, installed.HttpPort, installed.HttpsPort); err != nil { return err } sites, err := websiteRepo.List() if err != nil { return err } ports := websiteListenPorts(candidate, installed.HttpPort, installed.HttpsPort) isTLS := func(site model.Website, port int) bool { return site.Protocol == constant.ProtocolHTTPS && slices.Contains(buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort).httpsPorts, port) } for _, site := range sites { if site.ID == candidate.ID || site.Type == constant.Stream { continue } for port := range websiteListenPorts(site, installed.HttpPort, installed.HttpsPort) { if _, exists := ports[port]; exists && isTLS(candidate, port) != isTLS(site, port) { return buserr.WithName("ErrDomainIsUsed", site.PrimaryDomain) } } } return nil } func mcpWebsiteTLSUsesHTTPPort(site model.Website, installed model.AppInstall) bool { for _, domain := range site.Domains { if domain.SSL && domain.Port == installed.HttpPort { return true } } return false } func (s *MCPWebsiteBindingService) load(req request.MCPWebsiteBinding) (model.Website, error) { if req.WebsiteID > 0 { return websiteRepo.GetFirst(repo.WithByID(req.WebsiteID)) } return websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID))) } func mcpWebsiteProxyIncluded(site model.Website) bool { server, err := getServer(site) if err != nil { return false } include := mcpWebsiteInclude(site) for _, directive := range server.FindDirectives("include") { parameters := directive.GetParameters() if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include { return true } } return false } func (s *MCPWebsiteBindingService) status(req request.MCPWebsiteBinding, site model.Website, owner mcpWebsiteOwner) response.MCPWebsiteBinding { result := response.MCPWebsiteBinding{State: "bound", WebsiteID: site.ID, BindingID: req.BindingID, RequestID: owner.RequestID, Domain: site.PrimaryDomain, EnableSSL: site.Protocol == constant.ProtocolHTTPS, SSLID: site.WebsiteSSLID, WebsiteStatus: site.Status} if !result.EnableSSL { result.SSLID = 0 } if owner.Operation == "unbind" { result.State = "unbound" return result } if _, err := mcpWebsiteCurrentProxy(site, owner); err != nil { result.State, result.Reason = "unavailable", "proxy_modified" return result } if mcpWebsiteHash(string(mcpWebsiteProxy(req))) != owner.ProxyHash { result.State, result.Reason = "unavailable", "upstream_changed" return result } if site.Status != constant.WebRunning { result.State, result.Reason = "unavailable", "website_stopped" return result } if !mcpWebsiteProxyIncluded(site) { result.State, result.Reason = "unavailable", "proxy_not_loaded" return result } host, _, err := mcpWebsiteDomain(site.PrimaryDomain) if err != nil { result.State, result.Reason = "unavailable", "website_modified" return result } if result.EnableSSL && !s.validCertificate(site.WebsiteSSLID, host) { result.State, result.Reason = "unavailable", "certificate_invalid" return result } installed, err := s.openresty() if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning { result.State, result.Reason = "unavailable", "openresty_unavailable" return result } matching, matchingSSL := 0, 0 for _, domain := range site.Domains { if domain.Domain != host || domain.Port < 1 { continue } if matching == 0 || !domain.SSL { matching = domain.Port } if domain.SSL { matchingSSL = domain.Port } } if matching == 0 { result.State, result.Reason = "unavailable", "website_modified" return result } port := matching if result.EnableSSL { port = matchingSSL if port == 0 { plan := buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort) if slices.Contains(plan.httpsPorts, installed.HttpsPort) { port = installed.HttpsPort } else if len(plan.httpsPorts) > 0 { port = plan.httpsPorts[0] } } } if port == 0 { result.State, result.Reason = "unavailable", "website_modified" return result } scheme := "http" if result.EnableSSL { scheme = "https" } address := host if result.EnableSSL && port != 443 || !result.EnableSSL && port != 80 { address = net.JoinHostPort(host, strconv.Itoa(port)) } result.Endpoint = (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String() return result } func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWebsiteBinding) (result response.MCPWebsiteBinding) { mcpWebsiteMutation.Lock() defer mcpWebsiteMutation.Unlock() mutationStarted := false defer func() { if mutationStarted && result.Error != "" { result.Definitive = false } }() if req.Operation == "bind" && validMCPWebsiteID(req.BindingID) { _, existingErr := websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID))) _, privateErr := os.Lstat(filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, req.BindingID)) mutationStarted = !errors.Is(existingErr, gorm.ErrRecordNotFound) || !os.IsNotExist(privateErr) } if ctx.Err() != nil { return mcpWebsiteFailure("request_cancelled", true, false) } if req.Operation != "status" && req.Operation != "bind" && req.Operation != "update" && req.Operation != "unbind" && req.Operation != "sync" { return mcpWebsiteFailure("invalid_operation", true, false) } if req.DeleteWebsite && req.Operation != "unbind" { return mcpWebsiteFailure("invalid_operation", true, false) } if req.BindingID == "" && req.WebsiteID == 0 && req.Operation == "status" { return response.MCPWebsiteBinding{State: "unbound"} } if !validMCPWebsiteID(req.BindingID) || !validateMCPWebsiteUpstream(req) { return mcpWebsiteFailure("invalid_configuration", true, false) } if req.Operation != "status" && req.Operation != "sync" && !validMCPWebsiteID(req.RequestID) { return mcpWebsiteFailure("invalid_request", true, false) } if req.Operation == "bind" { host, port, err := mcpWebsiteDomain(req.Domain) if err != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) { return mcpWebsiteFailure("invalid_domain_or_certificate", true, false) } req.Domain = host if port != 0 { req.Domain = net.JoinHostPort(host, strconv.Itoa(port)) } } site, err := s.load(req) if errors.Is(err, gorm.ErrRecordNotFound) { if req.Operation == "status" || req.Operation == "unbind" { if req.DeleteWebsite { return s.missingDeletedWebsite(req) } if req.Operation == "status" && req.WebsiteID == 0 { partial := model.Website{Alias: mcpWebsiteAlias(req.BindingID)} for _, location := range []string{GetSitePath(partial, SiteDir), GetSitePath(partial, SiteConf), mcpWebsitePrivatePath(partial, mcpWebsiteOwnerName), mcpWebsitePrivatePath(partial, mcpWebsiteProxyName)} { if _, err := os.Lstat(location); !os.IsNotExist(err) { return response.MCPWebsiteBinding{State: "unavailable", BindingID: req.BindingID, RequestID: req.RequestID, Reason: "website_missing"} } } } if req.WebsiteID == 0 || req.Operation == "unbind" { return response.MCPWebsiteBinding{State: "unbound", BindingID: req.BindingID, RequestID: req.RequestID} } return response.MCPWebsiteBinding{State: "unavailable", WebsiteID: req.WebsiteID, BindingID: req.BindingID, Reason: "website_missing"} } if req.Operation != "bind" { return mcpWebsiteFailure("website_missing", true, true) } if req.WebsiteID != 0 { return mcpWebsiteFailure("website_missing", true, true) } if _, ownerErr := os.Lstat(mcpWebsitePrivatePath(model.Website{Alias: mcpWebsiteAlias(req.BindingID)}, mcpWebsiteOwnerName)); !os.IsNotExist(ownerErr) { return mcpWebsiteFailure("website_ownership_changed", false, true) } installed, installErr := s.openresty() if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning { return mcpWebsiteFailure("openresty_unavailable", true, false) } group, groupErr := groupRepo.Get(groupRepo.WithByWebsiteDefault()) if groupErr != nil || group.ID == 0 { return mcpWebsiteFailure("website_group_unavailable", true, false) } domain, port, _ := mcpWebsiteDomain(req.Domain) explicitPort := port != 0 if port == 0 { port = installed.HttpPort } domainSSL := req.EnableSSL && explicitPort candidate := model.Website{Protocol: constant.ProtocolHTTP, Domains: []model.WebsiteDomain{{Domain: domain, Port: port, SSL: domainSSL}}} if req.EnableSSL { candidate.Protocol = constant.ProtocolHTTPS if mcpWebsiteTLSUsesHTTPPort(candidate, installed) { return mcpWebsiteFailure("invalid_https_port", true, false) } } if conflictErr := mcpWebsiteListenConflict(candidate, installed); conflictErr != nil { var business buserr.BusinessError if errors.As(conflictErr, &business) && business.Msg == "ErrDomainIsUsed" { return mcpWebsiteFailure("domain_conflict", true, true) } return mcpWebsiteFailure("website_unavailable", false, false) } create := request.WebsiteCreate{Alias: mcpWebsiteAlias(req.BindingID), Type: constant.Static, Remark: mcpWebsiteRemark(req), WebsiteGroupID: group.ID, Domains: []request.WebsiteDomain{{Domain: domain, Port: port, SSL: domainSSL}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}} previousMutation := mutationStarted mutationStarted = true if createErr := s.createWebsite(create); createErr != nil { var business buserr.BusinessError if errors.As(createErr, &business) && business.Msg == "ErrDomainIsUsed" { mutationStarted = previousMutation return mcpWebsiteFailure("domain_conflict", true, true) } return mcpWebsiteFailure("website_create_failed", false, false) } site, err = s.load(req) } if err != nil { return mcpWebsiteFailure("website_unavailable", false, false) } if site.Alias != mcpWebsiteAlias(req.BindingID) { return mcpWebsiteFailure("website_ownership_changed", true, true) } owner, ownerErr := mcpWebsiteReadOwner(site) if ownerErr == nil && owner.RequestID == req.RequestID && req.RequestID != "" { mutationStarted = true } if req.Operation == "update" { if req.Domain != "" && req.Domain != site.PrimaryDomain { return mcpWebsiteFailure("domain_changed", true, true) } req.Domain = site.PrimaryDomain host, _, domainErr := mcpWebsiteDomain(req.Domain) if domainErr != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) { return mcpWebsiteFailure("invalid_domain_or_certificate", true, false) } } if info, dirErr := os.Lstat(GetSitePath(site, SiteDir)); dirErr == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) { return mcpWebsiteFailure("website_directory_changed", true, true) } else if dirErr != nil && !os.IsNotExist(dirErr) { return mcpWebsiteFailure("website_unavailable", false, false) } if ownerErr != nil { if req.DeleteWebsite { return mcpWebsiteFailure("website_ownership_changed", false, true) } if (req.Operation != "bind" && req.Operation != "unbind") || site.Remark != mcpWebsiteRemark(req) { return mcpWebsiteFailure("website_ownership_changed", true, true) } if req.Operation == "bind" && !s.matchesInitialSite(req, site) { return mcpWebsiteFailure("binding_request_changed", true, true) } owner = mcpWebsiteOwner{BindingID: req.BindingID, WebsiteID: site.ID, KeyHash: mcpWebsiteHash(req.ProxyKey), BindHash: mcpWebsiteBindHash(req)} } else if owner.BindingID != req.BindingID || owner.WebsiteID != site.ID || owner.KeyHash != mcpWebsiteHash(req.ProxyKey) { return mcpWebsiteFailure("website_ownership_changed", true, true) } if req.DeleteWebsite { if site.Type != constant.Static || site.AppInstallID != 0 || site.RuntimeID != 0 || site.DbID != 0 || site.DbType != "" || site.ParentWebsiteID != 0 { return mcpWebsiteFailure("website_modified", true, true) } if err = mcpWebsiteDeleteParents(); err != nil { return mcpWebsiteFailure("website_directory_changed", true, true) } for kind, location := range mcpWebsiteDeleteLocations(site) { if _, _, err = mcpWebsiteDeleteIdentity(kind, location); err != nil { return mcpWebsiteFailure("website_directory_changed", true, true) } } children, childErr := websiteRepo.GetBy(websiteRepo.WithParentID(site.ID)) if childErr != nil { return mcpWebsiteFailure("website_unavailable", false, false) } if len(children) != 0 { return mcpWebsiteFailure("website_modified", true, true) } } if req.Operation == "status" { return s.status(req, site, owner) } operationHash := mcpWebsitePayloadHash(req) deleteRetry := req.DeleteWebsite && owner.Operation == "unbind" && owner.RequestID == req.RequestID && owner.RequestHash == operationHash && owner.DeleteRoot != "" if owner.DeleteRoot != "" && req.Operation != "status" && (owner.RequestID != req.RequestID || owner.RequestHash != operationHash) { return mcpWebsiteFailure("operation_request_changed", false, true) } if deleteRetry { if _, err = mcpWebsiteVerifyDeletePaths(site, owner); err != nil { return mcpWebsiteFailure("website_directory_changed", false, true) } } if owner.RequestID == req.RequestID && req.RequestID != "" { if owner.RequestHash != operationHash { return mcpWebsiteFailure("operation_request_changed", true, true) } if owner.Operation == "unbind" && !req.DeleteWebsite || owner.Operation != "unbind" && owner.ProxyHash == mcpWebsiteHash(string(mcpWebsiteProxy(req))) { return s.status(req, site, owner) } } if req.Operation == "bind" && owner.BindHash != mcpWebsiteBindHash(req) { return mcpWebsiteFailure("binding_request_changed", true, true) } if req.Operation == "update" && (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL { installed, installErr := s.openresty() if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning { return mcpWebsiteFailure("openresty_unavailable", true, false) } candidate := site candidate.Protocol = constant.ProtocolHTTP if req.EnableSSL { candidate.Protocol = constant.ProtocolHTTPS if mcpWebsiteTLSUsesHTTPPort(candidate, installed) { return mcpWebsiteFailure("invalid_https_port", true, false) } } if conflictErr := mcpWebsiteListenConflict(candidate, installed); conflictErr != nil { var business buserr.BusinessError if errors.As(conflictErr, &business) && business.Msg == "ErrDomainIsUsed" { return mcpWebsiteFailure("domain_conflict", true, true) } return mcpWebsiteFailure("website_unavailable", false, false) } } if owner.ProxyHash != "" && owner.Operation != "unbind" && req.Operation != "unbind" && !mcpWebsiteProxyIncluded(site) { return mcpWebsiteFailure("proxy_not_loaded", true, true) } proxyPath := mcpWebsitePrivatePath(site, mcpWebsiteProxyName) if err = mcpWebsitePrivateDirectory(site, true); err != nil { return mcpWebsiteFailure("proxy_directory_changed", true, true) } if ownerErr != nil { mutationStarted = true if err = mcpWebsiteWriteOwner(site, owner); err != nil { return mcpWebsiteFailure("ownership_write_failed", false, false) } } var oldProxy []byte if owner.ProxyHash != "" && owner.Operation != "unbind" { oldProxy, err = mcpWebsiteCurrentProxy(site, owner) if err != nil { actual, readErr := mcpWebsiteReadProxy(proxyPath) if req.Operation == "unbind" && os.IsNotExist(readErr) { oldProxy = nil } else if readErr == nil && string(actual) == string(mcpWebsiteProxy(req)) { oldProxy = actual } else { return mcpWebsiteFailure("proxy_modified", true, true) } } } else if _, statErr := os.Lstat(proxyPath); statErr == nil { actual, readErr := mcpWebsiteReadProxy(proxyPath) if readErr != nil || string(actual) != string(mcpWebsiteProxy(req)) { return mcpWebsiteFailure("proxy_already_exists", true, true) } oldProxy = actual } else if !os.IsNotExist(statErr) { return mcpWebsiteFailure("proxy_unavailable", false, false) } if req.Operation == "unbind" { if mcpWebsiteProxyIncluded(site) { mutationStarted = true if err = s.removeInclude(site); err != nil { return mcpWebsiteFailure("proxy_reload_failed", false, false) } } if len(oldProxy) > 0 { mutationStarted = true if err = os.Remove(proxyPath); err != nil { return mcpWebsiteFailure("proxy_remove_failed", false, false) } } if req.DeleteWebsite && !deleteRetry { if err = mcpWebsiteRecordDeletePaths(site, &owner); err != nil { return mcpWebsiteFailure("website_directory_changed", false, true) } } owner.Operation, owner.RequestID, owner.RequestHash = "unbind", req.RequestID, operationHash mutationStarted = true if err = mcpWebsiteWriteOwner(site, owner); err != nil { return mcpWebsiteFailure("ownership_write_failed", false, false) } if req.DeleteWebsite { if err = s.deleteWebsite(request.WebsiteDelete{ID: site.ID}); err != nil { return mcpWebsiteFailure("website_delete_failed", false, false) } if _, err = websiteRepo.GetFirst(repo.WithByID(site.ID)); !errors.Is(err, gorm.ErrRecordNotFound) { return mcpWebsiteFailure("website_delete_unconfirmed", false, false) } return s.deletedWebsite(req, site, owner, false) } return s.status(req, site, owner) } if req.Operation == "update" { if req.Domain != site.PrimaryDomain { return mcpWebsiteFailure("domain_changed", true, true) } if site.Protocol == constant.ProtocolHTTPS && !req.EnableSSL || site.Protocol != constant.ProtocolHTTPS && req.EnableSSL || req.EnableSSL && site.WebsiteSSLID != req.SSLID { mutationStarted = true if err = s.updateHTTPS(context.WithoutCancel(ctx), request.WebsiteHTTPSOp{WebsiteID: site.ID, Enable: req.EnableSSL, Type: constant.SSLExisted, WebsiteSSLID: req.SSLID, HttpConfig: constant.HTTPSOnly}); err != nil { return mcpWebsiteFailure("certificate_update_failed", false, false) } site, err = s.load(req) if err != nil { return mcpWebsiteFailure("website_unavailable", false, false) } } } newProxy := mcpWebsiteProxy(req) mutationStarted = true if err = mcpWebsiteAtomicWrite(proxyPath, newProxy); err != nil { return mcpWebsiteFailure("proxy_write_failed", false, false) } if err = s.applyInclude(site); err != nil { if len(oldProxy) == 0 { _ = os.Remove(proxyPath) } else { _ = mcpWebsiteAtomicWrite(proxyPath, oldProxy) } _ = s.checkReload() return mcpWebsiteFailure("proxy_reload_failed", false, false) } owner.ProxyHash = mcpWebsiteHash(string(newProxy)) if req.Operation != "sync" || req.RequestID != "" { owner.RequestID, owner.RequestHash, owner.Operation = req.RequestID, operationHash, req.Operation } if err = mcpWebsiteWriteOwner(site, owner); err != nil { return mcpWebsiteFailure("ownership_write_failed", false, false) } return s.status(req, site, owner) }