diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml
index cc7996c0..cbce92d1 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yaml
@@ -1,6 +1,5 @@
name: Bug
description: Report a bug
-# Create a report to help us improve
title: "[Bug] "
body:
@@ -8,38 +7,46 @@ body:
attributes:
value: |
Please search the [open issues](https://github.com/chaitin/SafeLine/issues) and [discussion](https://github.com/chaitin/SafeLine/discussions) for duplicate issue first.
- The more information you share, the faster we can identify and fix the bug.
- # Please check for duplicate issue first.
+
+ - type: input
+ id: version
+ attributes:
+ label: SafeLine version
+ description: |
+ Please provide the exact SafeLine version.
+ For nightly or custom builds, provide the image tag or commit ID.
+ placeholder: "e.g. 9.4.0"
+ validations:
+ required: true
+
- type: textarea
- id: Description
+ id: description
attributes:
- label: What happened?
- # Describe the bug
+ label: Bug description
+ description: Please describe what happened and what you expected to happen.
+ value: |
+ What happened:
+
+ What did you expect:
validations:
- required: false
+ required: true
+
- type: textarea
- id: Reproduce
+ id: reproduce
attributes:
- label: How we reproduce?
- description: |
- Reports cannot be reproduced will Most likely be closed.
- # To Reproduce
+ label: Steps to reproduce (if available)
+ description: If you can reproduce the issue, list the minimum steps.
value: |
1. ...
2. ...
3. ...
+
- type: textarea
- id: Expected
- attributes:
- label: Expected behavior
- # placeholder: |
- # Descript what you expected to happen.
- # Expected behavior. Descript what you expected to happen.
- - type: textarea
- id: Errorlog
+ id: evidence
attributes:
- label: Error log
+ label: Logs or screenshots (optional)
+ description: |
+ Paste relevant logs or drag screenshots here.
+ Please remove sensitive information before submitting.
placeholder: |
- Paste the error logs if any.
- # Expected behavior. Descript what you expected to happen.
-
+ Paste logs or screenshots here.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml
index 371e2e15..85b57a38 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yaml
@@ -1,5 +1,4 @@
name: Suggestion
-# Feature request
description: New feature or improvements.
title: "[Suggestion] "
@@ -8,21 +7,22 @@ body:
attributes:
value: |
Please search the [open issues](https://github.com/chaitin/SafeLine/issues) and [discussion](https://github.com/chaitin/SafeLine/discussions) for duplicate issue first.
- Please rise only one suggestion in an issue.
+ Please submit only one suggestion in an issue.
+
- type: textarea
id: solution
attributes:
label: What would you like to be added or improved?
- # Describe the solution you'd like
- # placeholder: |
- #
+ description: Please describe the feature or improvement you would like to see.
+ placeholder: Describe the desired feature or improvement.
+ validations:
+ required: true
+
- type: textarea
id: problem
attributes:
label: Why is it needed?
- # Background and the specific problem that frustrates you
- placeholder: |
- Background and the problem that frustrates you
-
+ description: Please explain the background and the problem this suggestion would solve.
+ placeholder: Describe the background and the problem that frustrates you.
validations:
required: true
diff --git a/README.md b/README.md
index e29364fa..464cd3a8 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,24 @@
中文版
+🏆 Achievements
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## 👋 INTRODUCTION
SafeLine is a self-hosted **`WAF(Web Application Firewall)`** to protect your web apps from attacks and exploits.
@@ -53,7 +71,7 @@ List of the main features as follows:
- **`Rate Limiting`**
- Defend your web apps against `DoS attacks`, `bruteforce attempts`, `traffic surges`, and other types of abuse by throttling traffic that exceeds defined limits.
- **`Anti-Bot Challenge`**
- - Anti-Bot challenges to protect your website from `bot attacks`, humen users will be allowed, crawlers and bots will be blocked.
+ - Anti-Bot challenges to protect your website from `bot attacks`, human users will be allowed, crawlers and bots will be blocked.
- **`Authentication Challenge`**
- When authentication challenge turned on, visitors need to enter the password, otherwise they will be blocked.
- **`Dynamic Protection`**
@@ -73,7 +91,7 @@ List of the main features as follows:
## 🚀 Quickstart
> [!WARNING]
-> 中国大陆用户安装国际版可能会导致无法连接云服务,请查看 [中文版安装文档](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%AE%89%E8%A3%85%E9%9B%B7%E6%B1%A0)
+> 中国大陆用户安装国际版可能会导致无法连接云服务,请查看 [中文版安装文档](https://help.waf-ce.chaitin.cn/node/01973fc6-df0f-7650-bafa-8ed8d2fc2bc1)
#### 📦 Installing
@@ -83,7 +101,7 @@ Information on how to install SafeLine can be found in the [Install Guide](https
to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication)
-## 📋 More Informations
+## 📋 More Information
#### Effect Evaluation
@@ -99,9 +117,22 @@ to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication
Yes, SafeLine is production-ready.
-- Over 180,000 installations worldwide
-- Protecting over 1,000,000 Websites
-- Handling over 30,000,000,000 HTTP Requests Daily
+- Over 700,000 installations worldwide
+- Protecting over 2,000,000 Websites
+- Handling over 50,000,000,000 HTTP Requests Daily
+
+#### 🌟 Community Recognition
+
+- Rated **10.0/10** by [HelloGitHub](https://hellogithub.com/en/repository/0d07cfe266af4c25ba3eadf2c3d06f50)
+- Listed in [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted-data/blob/master/software/safeline.yml)
+- Featured on [Product Hunt](https://www.producthunt.com/products/safeline-waf/launches/safeline-waf)
+- Recognized by [Cyber Press](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) as the most popular open-source web firewall on GitHub
+
+#### 🔌 Ecosystem Integrations
+
+- [SafeLine MCP Server](./mcp_server/README.md) for AI-assisted management and control
+- [Ingress-NGINX](./sdk/ingress-nginx/README.md) plugin for protecting Kubernetes ingress traffic
+- [Kong Gateway](./sdk/kong/Readme.md) plugin for protecting APIs and services
#### 🙋♂️ Community
@@ -121,7 +152,7 @@ Several contact options exist for our community, the primary one being Discord.
#### 💪 PRO Edition
-Coming soon!
+SafeLine PRO is now available! [Explore plans and pricing](https://cyberserval.tech/landing/safeline#safeline-price) or [start a 7-day trial](https://safeline.app.cyberserval.tech/).
#### 📝 License
diff --git a/README_CN.md b/README_CN.md
index 4da0f5ac..ffd6bf94 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -8,12 +8,30 @@
🏠 官网 |
- 📖 文档 |
+ 📖 文档 |
🔍 演示环境 |
🙋♂️ 社区微信群 |
国际版
+🏆 项目荣誉
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## 👋 项目介绍
SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web 应用防火墙(WAF)`**,可以保护 Web 服务不受黑客攻击。
@@ -72,11 +90,11 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web
#### 📦 安装
-查看 [安装雷池](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%AE%89%E8%A3%85%E9%9B%B7%E6%B1%A0)
+查看 [安装雷池](https://help.waf-ce.chaitin.cn/node/01973fc6-df0f-7650-bafa-8ed8d2fc2bc1)
#### ⚙️ 配置防护站点
-查看 [快速配置](https://docs.waf-ce.chaitin.cn/zh/%E4%B8%8A%E6%89%8B%E6%8C%87%E5%8D%97/%E5%BF%AB%E9%80%9F%E9%85%8D%E7%BD%AE)
+查看 [快速配置](https://help.waf-ce.chaitin.cn/node/01973fc6-e14a-7234-8acd-bdba21c8b3f3)
## 📋 更多信息
@@ -94,10 +112,22 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web
是的,已经有不少用户将雷池投入生产使用,截至目前
-- 全球累计装机量已超过 18 万台
+- [全球累计装机量已超过 40 万台](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/)
- 防护的网站数量超过 100 万个
- 每天清洗 HTTP 请求超过 300 亿次
+#### 🌟 社区认可
+
+- 获得 [HelloGitHub](https://hellogithub.com/en/repository/0d07cfe266af4c25ba3eadf2c3d06f50) **10.0/10** 评分
+- 被 [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted-data/blob/master/software/safeline.yml) 收录
+- 被 [Cyber Press](https://cyberpress.org/safeline-waf-reaches-400000-global-deployments/) 评为 GitHub 上最受欢迎的开源 Web 应用防火墙
+
+#### 🔌 生态集成
+
+- [SafeLine MCP Server](./mcp_server/README.md):支持通过 AI 辅助管理和控制雷池
+- [Ingress-NGINX](./sdk/ingress-nginx/README.md):保护 Kubernetes Ingress 流量
+- [Kong Gateway](./sdk/kong/Readme.md):保护经过 Kong 的 API 和服务
+
#### 🙋♂️ 用户社区
欢迎加入雷池 [社区微信群](/images/wechat.png) 进行技术交流。
@@ -112,4 +142,4 @@ SafeLine,中文名 "雷池",是一款简单好用, 效果突出的 **`Web
#### 💪 专业版
-查看 [社区版 vs 专业版](https://waf-ce.chaitin.cn/version)
+雷池专业版现已发布!查看 [社区版 vs 专业版](https://waf-ce.chaitin.cn/version),选择适合你的版本。
diff --git a/compose.yaml b/compose.yaml
index 5536977c..d4242361 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -43,6 +43,24 @@ services:
test: curl -k -f https://localhost:1443/api/open/health
environment:
- MGT_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
+ # Optional override for the control channel token that tcontrollerd has to
+ # present before it may subscribe. Leave it empty to use
+ # ${SAFELINE_DIR}/resources/sock/mgt_grpc_token, which this container
+ # creates and the tengine container reads.
+ - MGT_GRPC_TOKEN=${MGT_GRPC_TOKEN:-}
+ # Token required on GET /api/OTPUrl as X-Bootstrap-Token. Leave empty
+ # to let the process generate one into
+ # ${SAFELINE_DIR}/resources/sock/tfa_bootstrap_token (0600) and log the
+ # path. The console must send that value; until it does, bind with curl.
+ - MGT_BOOTSTRAP_TOKEN=${MGT_BOOTSTRAP_TOKEN:-}
+ # How long the account may be bound without a credential, measured from
+ # the moment the installation was set up (or from `mgt -reset_user
+ # admin`, which opens it again). Defaults to 30m; set it to 0 to leave
+ # the bootstrap open until the first login, which is what older releases
+ # did. A console that is bound after the window closed answers with an
+ # error that names the command to reopen it, and never rejects a pending
+ # secret that is already bound to the address that asks for it.
+ - MGT_BOOTSTRAP_WINDOW=${MGT_BOOTSTRAP_WINDOW:-}
depends_on:
- postgres
- fvm
@@ -83,6 +101,9 @@ services:
environment:
- TCD_MGT_API=https://${SUBNET_PREFIX}.4:1443/api/open/publish/server
- TCD_SNSERVER=${SUBNET_PREFIX}.5:8000
+ # Optional override for the control channel token; empty means the token
+ # file in ${SAFELINE_DIR}/resources/sock is used.
+ - TCD_MGT_TOKEN=${MGT_GRPC_TOKEN:-}
# deprecated
- SNSERVER_ADDR=${SUBNET_PREFIX}.5:8000
- CHAOS_ADDR=${SUBNET_PREFIX}.10
@@ -140,4 +161,4 @@ services:
- ${SAFELINE_DIR}/resources/chaos:/app/chaos
networks:
safeline-ce:
- ipv4_address: ${SUBNET_PREFIX}.10
\ No newline at end of file
+ ipv4_address: ${SUBNET_PREFIX}.10
diff --git a/images/wechat.png b/images/wechat.png
index 436ed63b..c72cb85c 100644
Binary files a/images/wechat.png and b/images/wechat.png differ
diff --git a/management/tcontrollerd/config.yml b/management/tcontrollerd/config.yml
index 04cf50ce..5f2c49a9 100644
--- a/management/tcontrollerd/config.yml
+++ b/management/tcontrollerd/config.yml
@@ -2,4 +2,9 @@
log:
output: stdout # "stdout", "stderr" or file path
level: debug # "debug", "info", "warn" or "error"
-mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver
\ No newline at end of file
+mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver
+# Shared secret of the control channel. mgt-api creates it in a directory that
+# both containers mount (see the safeLine compose file); set MGT_TOKEN_FILE to
+# point somewhere else, or TCD_MGT_TOKEN to pass the value through the
+# environment instead.
+# mgt_token_file: /app/sock/mgt_grpc_token
diff --git a/management/tcontrollerd/controller/controller.go b/management/tcontrollerd/controller/controller.go
index 5eb56284..eeda201f 100644
--- a/management/tcontrollerd/controller/controller.go
+++ b/management/tcontrollerd/controller/controller.go
@@ -1,9 +1,14 @@
package controller
import (
+ "context"
+ "os"
+ "strings"
+
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/metadata"
+ "chaitin.cn/dev/go/errors"
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/config"
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/log"
pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website"
@@ -13,11 +18,38 @@ var (
logger = log.GetLogger("controller")
)
+const (
+ // TokenEnv holds the control channel token itself.
+ TokenEnv = "TCD_MGT_TOKEN"
+
+ // tokenMetadataKey is the metadata key that carries the token to the
+ // management server. gRPC metadata keys have to be lower case.
+ tokenMetadataKey = "mgt-token"
+)
+
func Handle() error {
logger.Infof("Connect mgt-webserver at %s", config.GlobalConfig.MgtWebserver)
- gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, []grpc.DialOption{
- grpc.WithTransportCredentials(insecure.NewCredentials()),
- }...)
+
+ // The management server only accepts a subscription that proves it belongs
+ // to this installation.
+ token, err := controlToken()
+ if err != nil {
+ logger.Errorf("Fail to read the control channel token: %v", err)
+ return err
+ }
+
+ dialOptions := []grpc.DialOption{}
+
+ // The token below proves that this is tcontrollerd, and the certificate
+ // proves that the peer is the management server of this installation:
+ // reading the traffic of the deployment is not enough to take either side.
+ creds, err := transportCreds()
+ if err != nil {
+ return err
+ }
+ dialOptions = append(dialOptions, grpc.WithTransportCredentials(creds))
+
+ gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, dialOptions...)
if err != nil {
logger.Errorf("Fail to dial: %v", err)
return err
@@ -33,9 +65,34 @@ func Handle() error {
}
}(gRPCConn)
- if err = websiteHandler(wsClient); err != nil {
+ ctx := metadata.AppendToOutgoingContext(context.Background(), tokenMetadataKey, token)
+ if err = websiteHandler(ctx, wsClient); err != nil {
return err
}
return nil
}
+
+// controlToken returns the shared secret of the control channel.
+//
+// The value is taken from the environment when it is set there, and from the
+// token file otherwise. The management server creates that file in the
+// directory both containers mount, so the standard deployment needs no
+// configuration.
+func controlToken() (string, error) {
+ if token := strings.TrimSpace(os.Getenv(TokenEnv)); token != "" {
+ return token, nil
+ }
+
+ content, err := os.ReadFile(config.GlobalConfig.MgtTokenFile)
+ if err != nil {
+ return "", errors.Wrapf(err, "read the control channel token from %s", config.GlobalConfig.MgtTokenFile)
+ }
+
+ token := strings.TrimSpace(string(content))
+ if token == "" {
+ return "", errors.New("the control channel token file " + config.GlobalConfig.MgtTokenFile + " is empty")
+ }
+
+ return token, nil
+}
diff --git a/management/tcontrollerd/controller/creds.go b/management/tcontrollerd/controller/creds.go
new file mode 100644
index 00000000..f506ccb2
--- /dev/null
+++ b/management/tcontrollerd/controller/creds.go
@@ -0,0 +1,76 @@
+package controller
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "google.golang.org/grpc/credentials"
+
+ "chaitin.cn/dev/go/errors"
+ "chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/config"
+)
+
+const (
+ // TLSEnv names the environment variable that used to allow a plaintext
+ // control channel. Plaintext is no longer supported: a value of "0" /
+ // off / false / no is an error.
+ TLSEnv = "TCD_MGT_TLS"
+
+ // ServerCertFile is the certificate the management server is expected to
+ // present, ClientCertFile and ClientKeyFile are the credentials issued for
+ // this side of the control channel, and CertAuthorityFile is the authority
+ // both of them are issued by.
+ ServerName = "waf-management-server"
+ ClientCertFile = "tcd_client.crt"
+ ClientKeyFile = "tcd_client.key"
+ CertAuthorityFile = "grpc_ca.crt"
+)
+
+// transportCreds returns the credentials of the control channel.
+//
+// The channel is always reached over TLS. Missing credentials or an explicit
+// request for plaintext are errors.
+func transportCreds() (credentials.TransportCredentials, error) {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) {
+ case "0", "off", "false", "no":
+ return nil, errors.New("plaintext is not supported: unset " + TLSEnv + " and use credentials published by mgt -gen_certs")
+ }
+
+ return loadTransportCreds()
+}
+
+// loadTransportCreds reads the credentials the management server published for
+// this side of the control channel.
+func loadTransportCreds() (credentials.TransportCredentials, error) {
+ dir := config.CertDir()
+
+ certPath := filepath.Join(dir, ClientCertFile)
+ certificate, err := tls.LoadX509KeyPair(certPath, filepath.Join(dir, ClientKeyFile))
+ if err != nil {
+ return nil, errors.Wrapf(err, "load %s", certPath)
+ }
+
+ caPath := filepath.Join(dir, CertAuthorityFile)
+ content, err := os.ReadFile(caPath)
+ if err != nil {
+ return nil, errors.Wrapf(err, "read %s", caPath)
+ }
+
+ roots := x509.NewCertPool()
+ if !roots.AppendCertsFromPEM(content) {
+ return nil, errors.New("no certificate found in " + caPath)
+ }
+
+ return credentials.NewTLS(&tls.Config{
+ Certificates: []tls.Certificate{certificate},
+ RootCAs: roots,
+ // The server is reached at an address of the deployment, which no
+ // certificate is issued for: the name it is issued for is fixed on
+ // both sides instead, and it is checked against the certificate.
+ ServerName: ServerName,
+ MinVersion: tls.VersionTLS12,
+ }), nil
+}
diff --git a/management/tcontrollerd/controller/validation.go b/management/tcontrollerd/controller/validation.go
new file mode 100644
index 00000000..83bdf1ce
--- /dev/null
+++ b/management/tcontrollerd/controller/validation.go
@@ -0,0 +1,154 @@
+package controller
+
+import (
+ "fmt"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const (
+ // maxPort is the largest TCP port that may be listened on.
+ maxPort = 65535
+
+ // maxServerNameLength and maxLabelLength follow the DNS limits.
+ maxServerNameLength = 253
+ maxLabelLength = 63
+)
+
+var (
+ // portPattern matches the decimal notation of a TCP port.
+ portPattern = regexp.MustCompile(`^[0-9]{1,5}$`)
+
+ // serverNameLabelPattern matches one label of a literal host name. The
+ // accepted character set is deliberately narrow: the value ends up in an
+ // nginx "server_name" directive, so anything that could terminate the
+ // directive (or start another one) must not pass.
+ serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`)
+
+ // certFilenamePattern matches the file name of a certificate or a key. The
+ // name is rendered into "ssl_certificate" and "ssl_certificate_key", and
+ // it is joined to the certificate directory, so only a plain file name
+ // without a path separator, a control character or any nginx syntax may
+ // pass.
+ certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
+
+ // upstreamSchemePattern matches the two schemes an upstream may use. The
+ // scheme is rendered into "proxy_pass", so anything else is rejected.
+ upstreamSchemePattern = regexp.MustCompile(`^https?$`)
+
+ // upstreamHostPattern matches the host of an upstream: a host name, an IPv4
+ // address or a bracketed IPv6 address, with an optional port. The host is
+ // rendered into the "server" directive of the site, so a value carrying
+ // nginx syntax (a space, a semicolon, a "$", a quote) must not pass.
+ upstreamHostPattern = regexp.MustCompile(`^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\.?)(?::([0-9]{1,5}))?$`)
+)
+
+// maxCertFilenameLength bounds a certificate file name.
+const maxCertFilenameLength = 255
+
+// validateCertFilename accepts the name of a file in the certificate directory.
+//
+// The management server validates the same value before it stores it, but this
+// side cannot rely on that: the configuration is pushed over the network, and a
+// name carrying nginx syntax would turn into directives that run commands.
+func validateCertFilename(name string) error {
+ // An empty name means "this site is served over plain HTTP".
+ if name == "" {
+ return nil
+ }
+
+ if len(name) > maxCertFilenameLength {
+ return fmt.Errorf("invalid certificate file name %q: longer than %d characters", name, maxCertFilenameLength)
+ }
+
+ if name != filepath.Base(name) || name == "." || name == ".." {
+ return fmt.Errorf("invalid certificate file name %q: only a file name without a directory is allowed", name)
+ }
+
+ if !certFilenamePattern.MatchString(name) {
+ return fmt.Errorf("invalid certificate file name %q: only letters, digits, dot, dash and underscore are allowed", name)
+ }
+
+ return nil
+}
+
+// validatePort accepts the decimal notation of a TCP port. It rejects any
+// other value because the port is interpolated into a "listen" directive.
+func validatePort(port string) error {
+ if !portPattern.MatchString(port) {
+ return fmt.Errorf("invalid port %q: only digits are allowed", port)
+ }
+
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > maxPort {
+ return fmt.Errorf("invalid port %q: must be between 1 and %d", port, maxPort)
+ }
+
+ return nil
+}
+
+// validateUpstream accepts the address of a site upstream.
+//
+// The management server validates the same value before it stores it, but this
+// side cannot rely on that: the site configuration is pushed over the network,
+// and the scheme and the host of an upstream are rendered into the "proxy_pass"
+// and "server" directives of the site.
+func validateUpstream(upstream string) error {
+ parsed, err := url.Parse(upstream)
+ if err != nil {
+ return fmt.Errorf("invalid upstream %q: %v", upstream, err)
+ }
+
+ if !upstreamSchemePattern.MatchString(parsed.Scheme) {
+ return fmt.Errorf("invalid upstream %q: the scheme has to be http or https", upstream)
+ }
+
+ host := parsed.Host
+ if parsed.Scheme == HttpsScheme && parsed.Port() == "" {
+ // generateNginxConfig appends the default HTTPS port to this value
+ // before it renders it, so validate the value it renders.
+ host = host + ":" + DefaultHttpsPort
+ }
+
+ matches := upstreamHostPattern.FindStringSubmatch(host)
+ if matches == nil {
+ return fmt.Errorf("invalid upstream %q: %q is not a host name, an IP address or a host with a port", upstream, host)
+ }
+
+ if port := matches[1]; port != "" {
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > maxPort {
+ return fmt.Errorf("invalid upstream %q: port has to be between 1 and %d", upstream, maxPort)
+ }
+ }
+
+ return nil
+}
+
+// validateServerName accepts a literal host name, optionally with a wildcard
+// as its first label. It rejects any other value because the name is
+// interpolated into a "server_name" directive.
+func validateServerName(name string) error {
+ if len(name) > maxServerNameLength {
+ return fmt.Errorf("invalid server name %q: longer than %d characters", name, maxServerNameLength)
+ }
+
+ for i, label := range strings.Split(name, ".") {
+ if label == "*" {
+ // nginx only accepts a wildcard as the left most label.
+ if i != 0 {
+ return fmt.Errorf("invalid server name %q: wildcard must be the first label", name)
+ }
+ continue
+ }
+
+ if len(label) > maxLabelLength || !serverNameLabelPattern.MatchString(label) {
+ return fmt.Errorf("invalid server name %q: %q is not a valid host name label", name, label)
+ }
+ }
+
+ return nil
+}
diff --git a/management/tcontrollerd/controller/website.go b/management/tcontrollerd/controller/website.go
index 48bf716e..694aef5d 100644
--- a/management/tcontrollerd/controller/website.go
+++ b/management/tcontrollerd/controller/website.go
@@ -12,6 +12,7 @@ import (
"path/filepath"
"strings"
+ "chaitin.cn/dev/go/errors"
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/model"
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/ngcmd"
pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website"
@@ -30,6 +31,10 @@ const (
nginxBackupFilePrefix = "BAK_IF_backend_"
nginxFileMode = 0644
+ // nginxCertsPath is the directory the ssl_certificate templates below point
+ // at; certificate file names have to stay inside it.
+ nginxCertsPath = "/etc/nginx/certs"
+
HttpsScheme = "https"
DefaultHttpsPort = "443"
)
@@ -48,6 +53,11 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) {
if urlInfo.Scheme == HttpsScheme && urlInfo.Port() == "" {
urlInfo.Host = urlInfo.Host + ":" + DefaultHttpsPort
}
+ // The host and the scheme are rendered into "server" and "proxy_pass",
+ // so a value carrying nginx syntax has to be rejected here.
+ if err := validateUpstream(upstream); err != nil {
+ return "", err
+ }
upstreamAddr = fmt.Sprintf(upstreamAddrTpl, urlInfo.Host)
if len(urlInfo.Scheme) > 0 {
scheme = urlInfo.Scheme
@@ -58,6 +68,19 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) {
sslCertFilename := ""
sslCertKeyFilename := ""
if website.KeyFilename != "" && website.CertFilename != "" {
+ for _, filename := range []string{website.CertFilename, website.KeyFilename} {
+ if err := validateCertFilename(filename); err != nil {
+ return "", err
+ }
+
+ // The template renders the name into an absolute path, so an
+ // explicit check backs up the name validation above: the resolved
+ // path has to stay inside the certificate directory.
+ if filepath.Dir(filepath.Join(nginxCertsPath, filename)) != nginxCertsPath {
+ return "", fmt.Errorf("invalid certificate file name %q: it leaves %s", filename, nginxCertsPath)
+ }
+ }
+
sslFlag = " ssl" // with a blank ahead
sslCertFilename = fmt.Sprintf(certTpl, website.CertFilename)
sslCertKeyFilename = fmt.Sprintf(certKeyTpl, website.KeyFilename)
@@ -70,6 +93,8 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) {
if sn == "*" || sn == "" {
sn = "_"
addrAnyProperties = addrAnyPropertiesTpl
+ } else if err := validateServerName(sn); err != nil {
+ return "", err
}
serverName = fmt.Sprintf(serverNameTpl, sn)
}
@@ -77,6 +102,9 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) {
// only ONE port supported in v1.0
var serverListen string
for _, port := range website.Ports {
+ if err := validatePort(port); err != nil {
+ return "", err
+ }
serverListen = fmt.Sprintf(serverListenTpl, port, sslFlag, addrAnyProperties)
}
@@ -105,11 +133,38 @@ func generateFullConfigAndReload(msg []byte) error {
return err
}
+ // "null" is what a query that returned nothing looks like on the wire, and
+ // a full push that carries it would delete every configuration of the
+ // installation. The management server always sends an array, so a payload
+ // that is not a list is refused and the current configuration is kept: an
+ // installation that really has no site sends an empty array, which is
+ // applied.
+ if websites == nil {
+ return errors.New("the full push does not carry a list of sites, keeping the current configuration")
+ }
+
+ if err := utils.EnsureDir(nginxConfigPath); err != nil {
+ return err
+ }
+
configFilename := make(map[string]struct{})
for _, website := range websites {
configFilename[fmt.Sprintf("%s%d", nginxFilePrefix, website.Id)] = struct{}{}
}
- filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error {
+
+ if err := filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error {
+ // filepath.Walk reports the entries it could not read through err and
+ // leaves info nil in that case: touching info before this check is a
+ // nil pointer dereference that takes the whole daemon down.
+ if err != nil {
+ logger.Warnf("Skip %s: %v", path, err)
+ return nil
+ }
+
+ if info == nil || info.IsDir() {
+ return nil
+ }
+
_, ok := configFilename[info.Name()]
if !ok && strings.HasPrefix(info.Name(), nginxFilePrefix) {
if err := os.Remove(path); err != nil {
@@ -119,7 +174,9 @@ func generateFullConfigAndReload(msg []byte) error {
}
return nil
- })
+ }); err != nil {
+ return err
+ }
for _, website := range websites {
if err := generateConfigAndReload(&website); err != nil {
@@ -226,8 +283,8 @@ func sendResponse(stream pb.Website_SubscribeClient, eventType string, errMsg []
})
}
-func websiteHandler(wc pb.WebsiteClient) error {
- stream, err := wc.Subscribe(context.Background())
+func websiteHandler(ctx context.Context, wc pb.WebsiteClient) error {
+ stream, err := wc.Subscribe(ctx)
if err != nil {
logger.Errorf("Subscribe failed: %v", err)
return err
diff --git a/management/tcontrollerd/go.mod b/management/tcontrollerd/go.mod
index aba1d943..cc1a1723 100644
--- a/management/tcontrollerd/go.mod
+++ b/management/tcontrollerd/go.mod
@@ -1,8 +1,8 @@
module chaitin.cn/patronus/safeline-2/management/tcontrollerd
-go 1.21
+go 1.25.0
-toolchain go1.21.3
+toolchain go1.25.14
require (
chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6
@@ -10,11 +10,12 @@ require (
chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
- google.golang.org/grpc v1.65.0
+ google.golang.org/grpc v1.83.2
)
require (
- golang.org/x/net v0.33.0 // indirect
- golang.org/x/sys v0.20.0 // indirect
- golang.org/x/text v0.15.0 // indirect
+ golang.org/x/net v0.58.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
)
diff --git a/management/tcontrollerd/go.sum b/management/tcontrollerd/go.sum
index fe02fe0e..d23388d5 100644
--- a/management/tcontrollerd/go.sum
+++ b/management/tcontrollerd/go.sum
@@ -1,3 +1,4 @@
+chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8 h1:qljuZkcItfSnd1OvteZKgxlqZzcr48DC9qm+OTnt/Xo=
chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo=
chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 h1:1Qa9ABk907/9ZrOLbbRcS8Fqq9VhjAF/mLjbSP1qAJY=
chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo=
@@ -21,8 +22,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
@@ -49,6 +50,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -66,9 +68,11 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -77,13 +81,17 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
@@ -96,6 +104,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
@@ -103,6 +113,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
google.golang.org/grpc v1.39.0-dev h1:K4VkkiYp4LCvQiW6OiGglzm5nO4Zyryf7pHhzP15cmI=
google.golang.org/grpc v1.39.0-dev/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -117,11 +129,15 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/management/tcontrollerd/pkg/config/config.go b/management/tcontrollerd/pkg/config/config.go
index 3b93f747..3647e69b 100644
--- a/management/tcontrollerd/pkg/config/config.go
+++ b/management/tcontrollerd/pkg/config/config.go
@@ -2,6 +2,8 @@ package config
import (
"os"
+ "path/filepath"
+ "strings"
"chaitin.cn/dev/go/settings"
)
@@ -10,6 +12,28 @@ var (
GlobalConfig = DefaultGlobalConfig()
)
+// DefaultMgtTokenFile is where the control channel token is read from by
+// default. The standard deployment mounts the same host directory at
+// /app/sock inside the management container, which creates the file, and
+// inside the tengine container, which is where tcontrollerd runs.
+const DefaultMgtTokenFile = "/app/sock/mgt_grpc_token"
+
+// CertDirEnv names the environment variable that holds the directory of the
+// credentials the management server published for this side of the control
+// channel. The default is below the directory of the token file, which is the
+// directory the standard deployment shares between the two containers.
+const CertDirEnv = "TCD_MGT_CERT_DIR"
+
+// CertDir returns the directory the credentials of the control channel are
+// read from.
+func CertDir() string {
+ if v, ok := os.LookupEnv(CertDirEnv); ok && strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+
+ return filepath.Join(filepath.Dir(GlobalConfig.MgtTokenFile), "grpc")
+}
+
func InitConfigs(configFilePath string) error {
s, err := settings.New(configFilePath)
if err != nil {
@@ -28,5 +52,15 @@ func InitConfigs(configFilePath string) error {
GlobalConfig.MgtWebserver = v
}
+ // The token file is optional in the configuration file: the default is
+ // where the management server puts it.
+ if err := s.Unmarshal("mgt_token_file", &GlobalConfig.MgtTokenFile); err != nil {
+ return err
+ }
+
+ if v, ok := os.LookupEnv("MGT_TOKEN_FILE"); ok {
+ GlobalConfig.MgtTokenFile = v
+ }
+
return nil
}
diff --git a/management/tcontrollerd/pkg/config/global.go b/management/tcontrollerd/pkg/config/global.go
index e03c1cce..e0258ed2 100644
--- a/management/tcontrollerd/pkg/config/global.go
+++ b/management/tcontrollerd/pkg/config/global.go
@@ -3,11 +3,13 @@ package config
type Config struct {
Log LogConfig
MgtWebserver string
+ MgtTokenFile string
}
func DefaultGlobalConfig() Config {
return Config{
Log: DefaultLogConfig(),
MgtWebserver: "169.254.0.4:9002",
+ MgtTokenFile: DefaultMgtTokenFile,
}
}
diff --git a/management/tcontrollerd/pkg/cron/forbidden_page.go b/management/tcontrollerd/pkg/cron/forbidden_page.go
index d5efe05b..2a8d5fe6 100644
--- a/management/tcontrollerd/pkg/cron/forbidden_page.go
+++ b/management/tcontrollerd/pkg/cron/forbidden_page.go
@@ -45,7 +45,11 @@ func checkAndUpdateForbiddenPage() {
return
}
- err = ioutil.WriteFile(forbiddenPagePath, []byte(constants.DefaultForbiddenPage), 0644)
+ // utils.EnsureWriteFile replaces the file atomically instead of truncating
+ // it in place: the path lives in the nginx configuration tree, so a symlink
+ // planted there would otherwise make this root process overwrite its target
+ // (CWE-59).
+ err = utils.EnsureWriteFile(forbiddenPagePath, []byte(constants.DefaultForbiddenPage), 0644)
if err != nil {
logger.Error(err)
return
diff --git a/management/tcontrollerd/pkg/log/log.go b/management/tcontrollerd/pkg/log/log.go
index 8b5639f7..ec2af6af 100644
--- a/management/tcontrollerd/pkg/log/log.go
+++ b/management/tcontrollerd/pkg/log/log.go
@@ -5,6 +5,7 @@ import (
"os"
"runtime"
"strings"
+ "syscall"
"github.com/sirupsen/logrus"
@@ -43,7 +44,7 @@ func InitLogger() error {
return err
}
- fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC
+ fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC | syscall.O_NOFOLLOW
if !exist {
if err := utils.EnsureFileDir(config.GlobalConfig.Log.Output); err != nil {
return err
@@ -51,11 +52,17 @@ func InitLogger() error {
fileFlag = fileFlag | os.O_CREATE
}
- if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, os.ModePerm); err != nil {
+ // os.ModePerm (0777) let every local user rewrite the audit log. The
+ // file is created with an owner/group readable mode instead.
+ if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, logFileMode); err != nil {
return fmt.Errorf("failed to open log file: %s", err.Error())
} else {
log.SetOutput(log.AllLoggers, log.NewLockOutput(fp))
}
+
+ // The mode above only applies when the file is created, so a log file
+ // that an older release left world writable is tightened here.
+ tightenLogFileMode(config.GlobalConfig.Log.Output)
}
// hook
@@ -67,6 +74,28 @@ func InitLogger() error {
return nil
}
+// logFileMode is the permission the log file is created with. Logs are audit
+// material: they must not be writable by other local users.
+const logFileMode os.FileMode = 0640
+
+// looseLogFileMask matches the group and world write bits.
+const looseLogFileMask os.FileMode = 0o022
+
+// tightenLogFileMode removes the group and world write bits from an existing
+// log file, so that a log file created by an older release cannot stay
+// tamperable by other local users.
+func tightenLogFileMode(path string) {
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm()&looseLogFileMask == 0 {
+ return
+ }
+
+ log.Warnf("Log file %s is writable by other users, setting mode to %#o", path, logFileMode)
+ if err := os.Chmod(path, logFileMode); err != nil {
+ log.Warnf("Cannot tighten the mode of log file %s: %s", path, err)
+ }
+}
+
type RuntimeHook struct{}
func (h *RuntimeHook) Levels() []logrus.Level {
diff --git a/management/tcontrollerd/utils/file.go b/management/tcontrollerd/utils/file.go
index e4b1121c..0aeec06d 100644
--- a/management/tcontrollerd/utils/file.go
+++ b/management/tcontrollerd/utils/file.go
@@ -1,9 +1,9 @@
package utils
import (
+ "bytes"
"fmt"
"io"
- "io/ioutil"
"os"
"path/filepath"
)
@@ -49,12 +49,62 @@ func FilesExist(paths ...string) (bool, error) {
return true, nil
}
+// RenameWriteFile writes data to filename by writing a temporary file next to
+// it and renaming that file into place.
+//
+// os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a file
+// or symlink planted at the temporary path beforehand cannot be followed, and
+// the rename replaces a symlink that was left at filename instead of writing
+// through it. The previous implementation guessed the name with math/rand and
+// wrote through ioutil.WriteFile, which follows symlinks.
func RenameWriteFile(filename string, data []byte, perm os.FileMode) error {
- randFileName := filename + ".tmp." + RandStr(8)
- if err := ioutil.WriteFile(randFileName, data, perm); err != nil {
+ return RenameCopyFromIO(bytes.NewReader(data), filename, perm)
+}
+
+// RenameCopyFromIO streams src into a temporary file created next to dstPath
+// and renames it over dstPath once the whole content has been written.
+//
+// This is the write primitive of the package: opening dstPath in place, as
+// CopyFileFromIO used to do with O_CREATE|O_WRONLY|O_TRUNC and no O_NOFOLLOW,
+// lets anyone who can create an entry in the destination directory redirect the
+// write to another file and truncate it. tcontrollerd runs as root and writes
+// the nginx configuration of every site into a path derived from the site id,
+// so the destination names are predictable and the target of such a link would
+// be overwritten with root privileges.
+//
+// The rename also makes the update atomic: a reader either sees the complete
+// old file or the complete new one, never a half written mixture.
+func RenameCopyFromIO(src io.Reader, dstPath string, perm os.FileMode) error {
+ tmpFile, err := os.CreateTemp(filepath.Dir(dstPath), filepath.Base(dstPath)+".tmp.")
+ if err != nil {
+ return err
+ }
+ tmpName := tmpFile.Name()
+
+ renamed := false
+ defer func() {
+ if !renamed {
+ _ = os.Remove(tmpName)
+ }
+ }()
+
+ if err = tmpFile.Chmod(perm); err != nil {
+ _ = tmpFile.Close()
return err
}
- return os.Rename(randFileName, filename)
+ if _, err = io.Copy(tmpFile, src); err != nil {
+ _ = tmpFile.Close()
+ return err
+ }
+ if err = tmpFile.Close(); err != nil {
+ return err
+ }
+ if err = os.Rename(tmpName, dstPath); err != nil {
+ return err
+ }
+
+ renamed = true
+ return nil
}
func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error {
@@ -66,13 +116,14 @@ func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error {
return RenameWriteFile(path, data, mode)
}
+// EnsureWriteFile writes a file with the given mode, creating its parent
+// directory when needed.
+//
+// It goes through RenameCopyFromIO, so the write is atomic and cannot be
+// redirected through a symlink: ioutil.WriteFile, which this used to call,
+// truncates the target of a pre-existing link at the destination path.
func EnsureWriteFile(path string, data []byte, mode os.FileMode) error {
- err := EnsureFileDir(path)
- if err != nil {
- return err
- }
-
- return ioutil.WriteFile(path, data, mode)
+ return EnsureRenameWriteFile(path, data, mode)
}
func CopyFile(srcPath, dstPath string) error {
@@ -80,11 +131,8 @@ func CopyFile(srcPath, dstPath string) error {
if err != nil {
return err
}
- defer func(srcFile *os.File) {
- err := srcFile.Close()
- if err != nil {
-
- }
+ defer func(f *os.File) {
+ _ = f.Close()
}(srcFile)
fileInfo, err := srcFile.Stat()
@@ -95,24 +143,18 @@ func CopyFile(srcPath, dstPath string) error {
return CopyFileFromIO(srcFile, dstPath, fileInfo.Mode())
}
+// CopyFileFromIO copies src into dstPath.
+//
+// The copy is written to a temporary file and renamed into place, so it is
+// atomic and cannot be redirected through a symlink left at dstPath. Opening
+// dstPath with O_CREATE|O_WRONLY|O_TRUNC and no O_NOFOLLOW, which this used to
+// do, truncated whatever a planted link pointed at.
func CopyFileFromIO(src io.Reader, dstPath string, perm os.FileMode) error {
if err := EnsureFileDir(dstPath); err != nil {
return err
}
- dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
- if err != nil {
- return err
- }
- defer func(dstFile *os.File) {
- err := dstFile.Close()
- if err != nil {
-
- }
- }(dstFile)
-
- _, err = io.Copy(dstFile, src)
- return err
+ return RenameCopyFromIO(src, dstPath, perm)
}
func CopyFileIfNotExist(srcPath, dstPath string) error {
diff --git a/management/tcontrollerd/utils/random.go b/management/tcontrollerd/utils/random.go
index 44ebd98a..3b93401e 100644
--- a/management/tcontrollerd/utils/random.go
+++ b/management/tcontrollerd/utils/random.go
@@ -1,18 +1,36 @@
package utils
import (
- "math/rand"
- "time"
+ "crypto/rand"
+ "encoding/hex"
+ "math/big"
)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+// RandStr returns n letters drawn from a cryptographically secure source.
+//
+// The value is used to name the temporary file created by RenameWriteFile, so
+// it must not be predictable: math/rand is seeded from the clock and its
+// output can be reconstructed from a single sample.
func RandStr(n int) string {
- rand.Seed(time.Now().UnixNano())
-
b := make([]rune, n)
+ limit := big.NewInt(int64(len(letters)))
for i := range b {
- b[i] = letters[rand.Intn(len(letters))]
+ idx, err := rand.Int(rand.Reader, limit)
+ if err != nil {
+ panic("utils: cannot read from the system entropy source: " + err.Error())
+ }
+ b[i] = letters[idx.Int64()]
}
return string(b)
}
+
+// RandHex returns a hex string of 2*n characters read from crypto/rand.
+func RandHex(n int) string {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ panic("utils: cannot read from the system entropy source: " + err.Error())
+ }
+ return hex.EncodeToString(b)
+}
diff --git a/management/webserver/api/auth.go b/management/webserver/api/auth.go
index c0b34c6c..eb333e75 100644
--- a/management/webserver/api/auth.go
+++ b/management/webserver/api/auth.go
@@ -1,8 +1,14 @@
package api
import (
+ "crypto/subtle"
+ "fmt"
"math"
"net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
"time"
"github.com/gin-contrib/sessions"
@@ -15,10 +21,70 @@ import (
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/database"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/log"
+ "chaitin.cn/patronus/safeline-2/management/webserver/pkg/sessionopts"
)
var logger = log.GetLogger("api")
+// loginThrottle slows down passcode guessing. The management console has a
+// single account whose only credential is a six digit passcode.
+var loginThrottle = newLoginGuard()
+
+// BootstrapTokenEnv names the environment variable that gates the anonymous
+// TFA bootstrap. See GetOTPUrl.
+const BootstrapTokenEnv = "MGT_BOOTSTRAP_TOKEN"
+
+// BootstrapTokenHeader is the header that carries the value of
+// BootstrapTokenEnv.
+const BootstrapTokenHeader = "X-Bootstrap-Token"
+
+// BootstrapWindowEnv names the environment variable that holds how long the
+// anonymous TFA bootstrap stays open after it was opened. It accepts a Go
+// duration, and "0" or "off" leaves it open until the first login.
+//
+// The window is what keeps an installation that nobody ever bound from being
+// claimable forever: the account can be bound without a credential while the
+// window is open, and `mgt -reset_user admin` opens it again, which is what an
+// operator does when they want to bind a console that was left alone.
+const BootstrapWindowEnv = "MGT_BOOTSTRAP_WINDOW"
+
+// DefaultBootstrapWindow is how long the anonymous TFA bootstrap stays open
+// when BootstrapWindowEnv is not set. It is long enough for the operator of an
+// installation that is being set up to scan the code, and short enough that an
+// installation which is left unbound does not stay open to the network.
+const DefaultBootstrapWindow = 30 * time.Minute
+
+const (
+ // bootstrapFailureThreshold is how many rejected bootstrap attempts a
+ // client address may make before it has to wait.
+ bootstrapFailureThreshold = 3
+
+ // bootstrapFailureWindow is how long a rejected attempt is remembered.
+ bootstrapFailureWindow = 10 * time.Minute
+
+ // bootstrapMaxLockout caps the wait imposed on a caller that keeps trying.
+ bootstrapMaxLockout = 5 * time.Minute
+)
+
+// bootstrapThrottle slows down guessing of the bootstrap token.
+//
+// GET /api/OTPUrl is reachable without a session, and the token comparison is
+// the only thing between a caller and the TFA secret of the account, so a
+// caller that may try values without limit could sit on the endpoint until it
+// guesses one. The keys are per client address: a key shared by every caller
+// would hand an attacker a way to lock the administrator out of the binding
+// instead.
+var bootstrapThrottle = newThrottle(bootstrapFailureThreshold, bootstrapFailureWindow, bootstrapMaxLockout)
+
+// bootstrapKeys returns the throttle keys of a bootstrap attempt.
+func bootstrapKeys(clientIP string) []string {
+ if clientIP == "" {
+ return nil
+ }
+
+ return []string{fmt.Sprintf("bootstrap:%s", clientIP)}
+}
+
var OtpOpts = totp.GenerateOpts{
Issuer: constants.ProductName,
AccountName: constants.SuperUser,
@@ -46,8 +112,32 @@ func PostLogin(c *gin.Context) {
var user model.User
db.Where(&model.User{Username: constants.SuperUser}).First(&user)
- valid := totp.Validate(params.Passcode, user.TFASecret)
+ keys := loginKeys(c.ClientIP(), user.Username)
+ if key, wait := loginThrottle.lockout(keys); wait > 0 {
+ logger.Warnf("Login throttled by %s, retry in %s", key, wait)
+ c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10))
+ response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many failed login attempts, please try again later"}, http.StatusTooManyRequests)
+ return
+ }
+
+ // Until the first successful login the account is still bound to the address
+ // the TFA secret was handed to. Without this check a caller that reached
+ // GET /api/OTPUrl first would hold a secret the administrator is about to
+ // bind, and knowing the secret is enough to log in.
+ if user.LastLoginTime == 0 && user.TFASecret != "" && user.TFABootstrapIP != "" &&
+ user.TFABootstrapIP != c.ClientIP() {
+ loginThrottle.fail(keys)
+ logger.Warnf("Refused a login from %s before the first successful one: the pending TFA secret was issued to %s", c.ClientIP(), user.TFABootstrapIP)
+ response.Error(c, response.JSONBody{Err: response.ErrWrongPasscode, Msg: "Failed to verify your passcode"}, http.StatusUnauthorized)
+ return
+ }
+
+ now := time.Now()
+ usedStep, valid := verifyPasscode(user.TFASecret, params.Passcode, user.TFALastUsedStep, now)
if !valid {
+ loginThrottle.fail(keys)
+ logger.Warnf("Failed login attempt from %s", c.ClientIP())
+
millisecondTimestamp := params.Timestamp
localTimeStamp := time.Now()
if millisecondTimestamp > 0 {
@@ -65,19 +155,28 @@ func PostLogin(c *gin.Context) {
return
}
- user.LastLoginTime = time.Now().Unix()
- user.IsEnabled = true
- db.Save(&user)
+ // The passcode is spent here, in one conditional update, and the session
+ // below is only created when this request is the one that spent it. A plain
+ // Save after the check would leave a window in which two requests that
+ // carry the same passcode both read the same stored time step and both
+ // accept it.
+ consumed, err := consumePasscode(db, &user, usedStep, now.Unix()/int64(OtpOpts.Period), now)
+ if err != nil {
+ logger.Error(err)
+ response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when creating sessions"}, http.StatusInternalServerError)
+ return
+ }
+ if !consumed {
+ loginThrottle.fail(keys)
+ logger.Warnf("Refused a login from %s: the passcode of time step %d was already used", c.ClientIP(), usedStep)
+ response.Error(c, response.JSONBody{Err: response.ErrWrongPasscode, Msg: "Failed to verify your passcode"}, http.StatusUnauthorized)
+ return
+ }
+
+ loginThrottle.reset(keys)
session := sessions.Default(c)
- session.Options(sessions.Options{
- Path: "/",
- MaxAge: 3600 * 24 * 7,
- //Domain: options.Domain,
- //HttpOnly: true,
- //SameSite: http.SameSiteLaxMode,
- //Secure: false,
- })
+ session.Options(sessionopts.Options(c))
session.Set(constants.DefaultSessionUserKey, user.ID)
if err := session.Save(); err != nil {
logger.Error(err)
@@ -99,14 +198,75 @@ func PostLogout(c *gin.Context) {
response.Success(c, nil)
}
-func GetOTPUrl(c *gin.Context) {
- otpKey, err := totp.Generate(OtpOpts)
- if err != nil {
- logger.Error(err)
- response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError)
- return
+// checkBootstrapWindow returns an error when the window in which the account
+// may be bound without a credential has closed.
+//
+// A passcode is the only credential of the account, so the secret that the
+// caller of GET /api/OTPUrl receives is the account. Leaving the endpoint open
+// until somebody happens to log in means an installation that is deployed and
+// then forgotten can be claimed by whoever reaches the console port first; the
+// window bounds that to the time around the setup, and every console that is
+// bound afterwards reaches this endpoint only to be told to run
+// `mgt -reset_user`.
+func checkBootstrapWindow() error {
+ window := BootstrapWindow()
+ if window <= 0 {
+ return nil
+ }
+
+ openedAt, ok := model.BootstrapOpenedAt()
+ if !ok {
+ // The record is written when the installation is set up, so a missing
+ // one means this database was not written by this release. The account
+ // is not left bound to nobody for that reason: the window starts being
+ // measured from the moment the account was created.
+ var user model.User
+ database.GetDB().Where(&model.User{Username: constants.SuperUser}).First(&user)
+ if openedAt, ok = user.CreatedAt, !user.CreatedAt.IsZero(); !ok {
+ openedAt = time.Now()
+ }
+
+ logger.Warnf("No record of when the TFA bootstrap was opened, measuring the window of %s from %s", window, openedAt.Format(time.RFC3339))
+ }
+
+ if elapsed := time.Since(openedAt); elapsed > window {
+ return fmt.Errorf("the anonymous TFA bootstrap closed %s after it was opened at %s", window, openedAt.Format(time.RFC3339))
+ }
+
+ return nil
+}
+
+// BootstrapWindow returns how long the anonymous TFA bootstrap stays open, and
+// zero when it stays open until the first login.
+func BootstrapWindow() time.Duration {
+ value := strings.TrimSpace(os.Getenv(BootstrapWindowEnv))
+ if value == "" {
+ return DefaultBootstrapWindow
+ }
+
+ if strings.EqualFold(value, "off") {
+ return 0
+ }
+
+ window, err := time.ParseDuration(value)
+ if err != nil || window < 0 {
+ logger.Warnf("Ignoring %s=%q: it is not a duration, keeping the window of %s", BootstrapWindowEnv, value, DefaultBootstrapWindow)
+ return DefaultBootstrapWindow
}
+ return window
+}
+
+// GetOTPUrl hands the TFA secret during first-boot binding.
+//
+// The request must carry X-Bootstrap-Token. Remaining gates:
+// - LastLoginTime > 0: no more secret after the first successful login
+// - secret is bound to the first ClientIP (TFABootstrapIP)
+// - MGT_BOOTSTRAP_WINDOW, bootstrapThrottle, claimBootstrapSecret / adoptBootstrapSecret
+//
+// The stock console must send the header. Until that image is updated, bind
+// with curl using the token written at startup.
+func GetOTPUrl(c *gin.Context) {
db := database.GetDB()
// only SuperUser in v0.9
@@ -118,11 +278,263 @@ func GetOTPUrl(c *gin.Context) {
return
}
- user.TFASecret = otpKey.Secret()
- db.Save(&user)
+ clientIP := c.ClientIP()
+ throttleKeys := bootstrapKeys(clientIP)
+ if key, wait := bootstrapThrottle.lockout(throttleKeys); wait > 0 {
+ logger.Warnf("TFA bootstrap throttled by %s, retry in %s", key, wait)
+ c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10))
+ response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many attempts, please try again later"}, http.StatusTooManyRequests)
+ return
+ }
+
+ if err := checkBootstrapToken(c); err != nil {
+ bootstrapThrottle.fail(throttleKeys)
+ logger.Warnf("Refused to hand out the TFA secret to %s: %s", clientIP, err)
+ response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "TFA bootstrap is not allowed for this request"}, http.StatusUnauthorized)
+ return
+ }
+
+ bootstrapThrottle.reset(throttleKeys)
+
+ // The console displays the QR code on the login page, before any session
+ // exists, so this endpoint is reachable while the account has never logged
+ // in. It must not hand out a fresh secret on every call, and the secret it
+ // does hand out belongs to the one address that asked for it: an anonymous
+ // caller that fetched a secret the administrator is about to bind would
+ // otherwise be able to log in with it, because the passcode is the only
+ // credential.
+ if user.TFASecret != "" {
+ if user.TFABootstrapIP == "" {
+ // An installation that already carries a pending secret from a
+ // release that did not record the address keeps working: the first
+ // caller adopts it, exactly as it did before.
+ //
+ // The window applies here as well, because adopting the pending
+ // secret is the same thing as being handed one.
+ if err := checkBootstrapWindow(); err != nil {
+ logger.Warnf("Refused to hand out the pending TFA secret to %s: %s", clientIP, err)
+ response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "TFA bootstrap is not allowed for this request"}, http.StatusForbidden)
+ return
+ }
+
+ adopted, err := adoptBootstrapSecret(db, user.TFASecret, clientIP)
+ if err != nil {
+ logger.Error(err)
+ response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError)
+ return
+ }
+ if !adopted {
+ refusePendingSecret(c, clientIP)
+ return
+ }
+ } else if user.TFABootstrapIP != clientIP {
+ refusePendingSecret(c, clientIP)
+ return
+ }
+
+ logger.Debugf("Reusing the pending TFA secret for %s", clientIP)
+ response.Success(c, gin.H{"url": otpURL(user.TFASecret)})
+ return
+ }
+
+ if err := checkBootstrapWindow(); err != nil {
+ logger.Warnf("Refused to issue a TFA secret to %s: %s; run 'mgt -reset_user admin' to open the bootstrap again", clientIP, err)
+ response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The TFA bootstrap of this installation is closed, run 'mgt -reset_user admin' to bind a new authenticator"}, http.StatusForbidden)
+ return
+ }
+
+ otpKey, err := totp.Generate(OtpOpts)
+ if err != nil {
+ logger.Error(err)
+ response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError)
+ return
+ }
+
+ // The secret is installed by an update that only matches while the row
+ // still has none, so two calls that arrive together cannot both be told
+ // that the secret they generated is the one the account carries: the
+ // caller that loses reads the stored secret below and hands that one out
+ // when it belongs to the same address, which is what a console that
+ // retries produces, and is refused otherwise.
+ issued, err := claimBootstrapSecret(db, clientIP, otpKey.Secret())
+ if err != nil {
+ logger.Error(err)
+ response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when generating otp qrcode"}, http.StatusInternalServerError)
+ return
+ }
+ if !issued {
+ logger.Warnf("Lost the TFA bootstrap race to another caller, %s is served the stored secret if it is its own", clientIP)
+ var stored model.User
+ db.Where(&model.User{Username: constants.SuperUser}).First(&stored)
+ if stored.TFASecret == "" || stored.TFABootstrapIP != clientIP {
+ refusePendingSecret(c, clientIP)
+ return
+ }
+
+ response.Success(c, gin.H{"url": otpURL(stored.TFASecret)})
+ return
+ }
+
+ logger.Warnf("Issued a new TFA secret to %s; it can only be bound and used from that address until the first login", clientIP)
response.Success(c, gin.H{"url": otpKey.URL()})
}
+// refusePendingSecret answers a caller that may not be handed the pending TFA
+// secret, because it was issued to another address.
+func refusePendingSecret(c *gin.Context, clientIP string) {
+ logger.Warnf("Refused to hand out the pending TFA secret to %s: it belongs to another address", clientIP)
+ response.Error(c, response.JSONBody{Err: response.ErrLoginRequired, Msg: "The pending TFA secret belongs to another address, run 'mgt -reset_user ' to bind a new one"}, http.StatusForbidden)
+}
+
+// claimBootstrapSecret installs secret as the pending TFA secret of the
+// account and reports whether this call is the one that installed it.
+//
+// The update matches only while the row carries no secret yet, which is what
+// makes the claim exclusive. Reading the row and writing it back would let two
+// callers that arrive while the account has no secret both store the one they
+// generated, so both would be told the account is bound to the secret they
+// hold, while the database keeps only the last write.
+func claimBootstrapSecret(db *database.PostgresDB, clientIP, secret string) (bool, error) {
+ result := db.Model(&model.User{}).
+ Where("username = ? AND (tfa_secret = '' OR tfa_secret IS NULL)", constants.SuperUser).
+ Updates(map[string]interface{}{
+ "tfa_secret": secret,
+ "tfa_bootstrap_ip": clientIP,
+ })
+ if result.Error != nil {
+ return false, result.Error
+ }
+
+ return result.RowsAffected == 1, nil
+}
+
+// adoptBootstrapSecret records clientIP as the address of a pending secret
+// that was stored before the address was recorded.
+//
+// The update is restricted to the secret that was read, so a caller that lost a
+// race against the installation of a new secret adopts nothing.
+func adoptBootstrapSecret(db *database.PostgresDB, pending, clientIP string) (bool, error) {
+ result := db.Model(&model.User{}).
+ Where("username = ? AND tfa_secret = ? AND (tfa_bootstrap_ip = '' OR tfa_bootstrap_ip IS NULL)", constants.SuperUser, pending).
+ Updates(map[string]interface{}{
+ "tfa_bootstrap_ip": clientIP,
+ })
+ if result.Error != nil {
+ return false, result.Error
+ }
+
+ return result.RowsAffected == 1, nil
+}
+
+// consumePasscode records an accepted passcode together with the login it
+// belongs to, as a single conditional update.
+//
+// The update is what decides whether the time step of the passcode was already
+// spent: it only matches while the stored step is below the accepted one, so
+// the second of two concurrent requests that carry the same passcode affects no
+// row and is treated as a replay. currentStep is the step the passcode was
+// verified against; a stored step ahead of it means the clock moved backwards,
+// which this process treats as stale (see verifyPasscode) and repairs here.
+func consumePasscode(db *database.PostgresDB, user *model.User, usedStep, currentStep int64, now time.Time) (bool, error) {
+ result := db.Model(&model.User{}).
+ Where("id = ? AND (tfa_last_used_step < ? OR tfa_last_used_step > ?)", user.ID, usedStep, currentStep+1).
+ Updates(map[string]interface{}{
+ "tfa_last_used_step": usedStep,
+ // The bootstrap is over: the secret is bound, so it is no longer
+ // pending and no longer tied to the address that fetched it.
+ "tfa_bootstrap_ip": "",
+ "last_login_time": now.Unix(),
+ "is_enabled": true,
+ })
+ if result.Error != nil {
+ return false, result.Error
+ }
+
+ return result.RowsAffected == 1, nil
+}
+
+// verifyPasscode checks a TOTP passcode and returns the time step it belongs to.
+//
+// lastUsedStep is the step of the passcode that was accepted last; every step
+// up to it is refused, which is what makes a captured passcode unusable: the
+// plain totp.Validate call the handler used before accepted the same code for
+// as long as it stayed inside the window of the current step.
+func verifyPasscode(secret, passcode string, lastUsedStep int64, now time.Time) (int64, bool) {
+ if secret == "" || passcode == "" {
+ return 0, false
+ }
+
+ period := int64(OtpOpts.Period)
+ currentStep := now.Unix() / period
+ if lastUsedStep > currentStep+1 {
+ // The recorded step is ahead of the current window, which means the
+ // clock moved backwards. Keeping it would lock the account out until
+ // the clock catches up, so the record is treated as stale.
+ lastUsedStep = 0
+ }
+
+ opts := totp.ValidateOpts{
+ Period: OtpOpts.Period,
+ Digits: OtpOpts.Digits,
+ Algorithm: OtpOpts.Algorithm,
+ }
+
+ // The current step and its two neighbours: the same window totp.Validate
+ // accepts by default, so a client clock that is off by one period still
+ // works.
+ var matched int64
+ for _, step := range []int64{currentStep - 1, currentStep, currentStep + 1} {
+ if step <= lastUsedStep {
+ continue
+ }
+
+ code, err := totp.GenerateCodeCustom(secret, time.Unix(step*period, 0), opts)
+ if err != nil {
+ continue
+ }
+
+ if subtle.ConstantTimeCompare([]byte(code), []byte(passcode)) == 1 {
+ matched = step
+ }
+ }
+
+ return matched, matched != 0
+}
+
+// otpURL rebuilds the otpauth URL that totp.Generate returns for a secret.
+func otpURL(secret string) string {
+ values := url.Values{}
+ values.Set("secret", secret)
+ values.Set("issuer", OtpOpts.Issuer)
+ values.Set("period", strconv.FormatUint(uint64(OtpOpts.Period), 10))
+ values.Set("algorithm", OtpOpts.Algorithm.String())
+ values.Set("digits", OtpOpts.Digits.String())
+
+ bootstrapURL := url.URL{
+ Scheme: "otpauth",
+ Host: "totp",
+ Path: "/" + OtpOpts.Issuer + ":" + OtpOpts.AccountName,
+ RawQuery: values.Encode(),
+ }
+
+ return bootstrapURL.String()
+}
+
+// checkBootstrapToken requires X-Bootstrap-Token to match the configured
+// bootstrap token. An empty expected value is a server misconfiguration.
+func checkBootstrapToken(c *gin.Context) error {
+ expected := bootstrapToken
+ if expected == "" {
+ return fmt.Errorf("bootstrap token is not configured")
+ }
+
+ if subtle.ConstantTimeCompare([]byte(c.GetHeader(BootstrapTokenHeader)), []byte(expected)) == 1 {
+ return nil
+ }
+
+ return fmt.Errorf("missing or wrong %s header", BootstrapTokenHeader)
+}
+
func GetUser(c *gin.Context) {
db := database.GetDB()
user := model.User{Username: constants.SuperUser}
diff --git a/management/webserver/api/behaviour.go b/management/webserver/api/behaviour.go
index 0d63fddb..4fa10843 100644
--- a/management/webserver/api/behaviour.go
+++ b/management/webserver/api/behaviour.go
@@ -14,6 +14,10 @@ type PostBehaviourRequest struct {
model.Behaviour
}
+// maxRouterLength bounds the console routes that are stored. The values come
+// from the browser, so they are limited to what the console can produce.
+const maxRouterLength = 256
+
func PostBehaviour(c *gin.Context) {
var params PostBehaviourRequest
if err := c.BindJSON(¶ms); err != nil {
@@ -21,7 +25,19 @@ func PostBehaviour(c *gin.Context) {
response.Error(c, response.ErrorParamNotOK, http.StatusInternalServerError)
return
}
+
+ if len(params.SrcRouter) > maxRouterLength || len(params.DstRouter) > maxRouterLength {
+ logger.Warnf("Rejected behaviour entry with %d/%d bytes of route", len(params.SrcRouter), len(params.DstRouter))
+ response.Error(c, response.ErrorParamNotOK, http.StatusBadRequest)
+ return
+ }
+
db := database.GetDB()
- db.Create(&model.Behaviour{SrcRouter: params.SrcRouter, DstRouter: params.DstRouter})
+ if err := db.Create(&model.Behaviour{SrcRouter: params.SrcRouter, DstRouter: params.DstRouter}).Error; err != nil {
+ logger.Error(err)
+ response.Error(c, response.ErrorInternal, http.StatusInternalServerError)
+ return
+ }
+
response.Success(c, nil)
}
diff --git a/management/webserver/api/bootstrap.go b/management/webserver/api/bootstrap.go
new file mode 100644
index 00000000..61214fc2
--- /dev/null
+++ b/management/webserver/api/bootstrap.go
@@ -0,0 +1,133 @@
+package api
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+)
+
+// BootstrapTokenFileEnv names the file the bootstrap token is read from when
+// BootstrapTokenEnv is empty.
+const BootstrapTokenFileEnv = "MGT_BOOTSTRAP_TOKEN_FILE"
+
+// DefaultBootstrapTokenFile is next to the control-channel token on the
+// shared sock volume.
+const DefaultBootstrapTokenFile = "/app/sock/tfa_bootstrap_token"
+
+const bootstrapTokenBytes = 32
+const bootstrapTokenFileMode os.FileMode = 0600
+
+// bootstrapToken is the value GET /api/OTPUrl must present. It is never empty
+// after a successful EnsureBootstrapToken.
+var bootstrapToken string
+
+// BootstrapTokenFile returns the path of the persisted bootstrap token.
+func BootstrapTokenFile() string {
+ if path := strings.TrimSpace(os.Getenv(BootstrapTokenFileEnv)); path != "" {
+ return path
+ }
+ return DefaultBootstrapTokenFile
+}
+
+// EnsureBootstrapToken loads MGT_BOOTSTRAP_TOKEN or a persisted file, creating
+// the file once when neither is set. The console must send the value as
+// X-Bootstrap-Token; an empty token is never accepted.
+func EnsureBootstrapToken() (created bool, path string, err error) {
+ if token := strings.TrimSpace(os.Getenv(BootstrapTokenEnv)); token != "" {
+ bootstrapToken = token
+ return false, "", nil
+ }
+
+ path = BootstrapTokenFile()
+ token, created, err := loadOrCreateBootstrapToken(path)
+ if err != nil {
+ return false, path, err
+ }
+ if token == "" {
+ return false, path, fmt.Errorf("bootstrap token is empty")
+ }
+ bootstrapToken = token
+ return created, path, nil
+}
+
+func loadOrCreateBootstrapToken(path string) (string, bool, error) {
+ if token, err := readBootstrapToken(path); err == nil && token != "" {
+ return token, false, nil
+ }
+
+ raw := make([]byte, bootstrapTokenBytes)
+ if _, err := rand.Read(raw); err != nil {
+ return "", false, err
+ }
+ value := hex.EncodeToString(raw)
+
+ if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
+ return "", false, err
+ }
+
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, bootstrapTokenFileMode)
+ if err != nil {
+ if !os.IsExist(err) {
+ return "", false, err
+ }
+ token, readErr := readBootstrapToken(path)
+ if readErr != nil {
+ return "", false, readErr
+ }
+ if token != "" {
+ return token, false, nil
+ }
+ // Empty leftover file: replace it without following a symlink. Do not
+ // truncate a non-empty secret.
+ if err := writeBootstrapTokenNoFollow(path, value); err != nil {
+ return "", false, err
+ }
+ return value, true, nil
+ }
+ if _, err = file.WriteString(value + "\n"); err != nil {
+ _ = file.Close()
+ return "", false, err
+ }
+ if err = file.Close(); err != nil {
+ return "", false, err
+ }
+ return value, true, nil
+}
+
+// readBootstrapToken reads the token without following a symlink. A link placed
+// on the shared volume must not be treated as the token file.
+func readBootstrapToken(path string) (string, error) {
+ file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
+ if err != nil {
+ return "", err
+ }
+ defer func() {
+ _ = file.Close()
+ }()
+ // Read the whole file. A prefix must not be treated as the token: the console
+ // sends the file contents, and a truncated value would never match.
+ content, err := io.ReadAll(file)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(content)), nil
+}
+
+// writeBootstrapTokenNoFollow overwrites an existing regular file. O_NOFOLLOW
+// refuses a symlink instead of writing through it.
+func writeBootstrapTokenNoFollow(path, value string) error {
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, bootstrapTokenFileMode)
+ if err != nil {
+ return err
+ }
+ if _, err = file.WriteString(value + "\n"); err != nil {
+ _ = file.Close()
+ return err
+ }
+ return file.Close()
+}
diff --git a/management/webserver/api/bootstrap_test.go b/management/webserver/api/bootstrap_test.go
new file mode 100644
index 00000000..35a34241
--- /dev/null
+++ b/management/webserver/api/bootstrap_test.go
@@ -0,0 +1,77 @@
+package api
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestEnsureBootstrapTokenCreatesAndReusesFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "tfa_bootstrap_token")
+ t.Setenv(BootstrapTokenEnv, "")
+ t.Setenv(BootstrapTokenFileEnv, path)
+
+ created, gotPath, err := EnsureBootstrapToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !created || gotPath != path || bootstrapToken == "" {
+ t.Fatalf("created=%v path=%q token empty=%v", created, gotPath, bootstrapToken == "")
+ }
+ first := bootstrapToken
+
+ created, _, err = EnsureBootstrapToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created {
+ t.Fatal("second call rotated the token")
+ }
+ if bootstrapToken != first {
+ t.Fatal("token changed")
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(content) == "" {
+ t.Fatal("file empty")
+ }
+}
+
+func TestEnsureBootstrapTokenPrefersEnv(t *testing.T) {
+ t.Setenv(BootstrapTokenEnv, "from-env")
+ t.Setenv(BootstrapTokenFileEnv, filepath.Join(t.TempDir(), "unused"))
+ created, path, err := EnsureBootstrapToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created || path != "" || bootstrapToken != "from-env" {
+ t.Fatalf("created=%v path=%q token=%q", created, path, bootstrapToken)
+ }
+}
+
+func TestEnsureBootstrapTokenReplacesEmptyFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "tfa_bootstrap_token")
+ if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(BootstrapTokenEnv, "")
+ t.Setenv(BootstrapTokenFileEnv, path)
+ created, _, err := EnsureBootstrapToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !created || bootstrapToken == "" {
+ t.Fatalf("created=%v token empty", created)
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.TrimSpace(string(content)) != bootstrapToken {
+ t.Fatalf("file=%q token=%q", content, bootstrapToken)
+ }
+}
diff --git a/management/webserver/api/cert.go b/management/webserver/api/cert.go
index 890104a8..5948b545 100644
--- a/management/webserver/api/cert.go
+++ b/management/webserver/api/cert.go
@@ -3,12 +3,18 @@ package api
import (
"crypto/x509/pkix"
"fmt"
+ "io"
+ "mime/multipart"
"net/http"
+ "os"
"path/filepath"
+ "strconv"
+ "time"
"github.com/gin-gonic/gin"
"chaitin.cn/patronus/safeline-2/management/webserver/api/response"
+ "chaitin.cn/patronus/safeline-2/management/webserver/model"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/config"
"chaitin.cn/patronus/safeline-2/management/webserver/utils"
)
@@ -17,6 +23,49 @@ type postSSLCertRequest struct {
Hostname string `json:"hostname"`
}
+const (
+ // certGenerationThreshold is how many key pairs a client address may ask
+ // for within certGenerationWindow before it has to wait.
+ certGenerationThreshold = 5
+
+ // certGenerationWindow is how long a generation is counted.
+ certGenerationWindow = 10 * time.Minute
+
+ // certGenerationMaxLockout caps the wait imposed on a caller that keeps
+ // asking.
+ certGenerationMaxLockout = 5 * time.Minute
+
+ // maxUploadedCertBytes is the largest certificate or key this endpoint will
+ // write. A PEM certificate or key is far smaller than this.
+ maxUploadedCertBytes int64 = 1 << 20
+)
+
+// certGenerationThrottle bounds how often this endpoint may spend a key
+// generation.
+//
+// Every accepted request generates a fresh 4096 bit RSA key in the request
+// goroutine; the random file name prefix makes the existence check in
+// WriteCertIfNotExist a no-op, so nothing is ever reused. An authenticated
+// session that loops over this route therefore keeps the process busy with RSA
+// key generation and fills the certificate directory of the site, which is the
+// same process that compiles and pushes the detection rules. The throttle is
+// counted per client address, and one generation is counted whether it
+// succeeds or not.
+var certGenerationThrottle = newThrottle(certGenerationThreshold, certGenerationWindow, certGenerationMaxLockout)
+
+// certUploadThrottle bounds how often an authenticated session may write into
+// the certificate directory. Generation already has a throttle; upload did not.
+var certUploadThrottle = newThrottle(certGenerationThreshold, certGenerationWindow, certGenerationMaxLockout)
+
+// certGenerationKeys returns the throttle keys of a certificate request.
+func certGenerationKeys(clientIP string) []string {
+ if clientIP == "" {
+ return nil
+ }
+
+ return []string{fmt.Sprintf("sslcert:%s", clientIP)}
+}
+
// SSLCertDir is the dir of tengine conf, not mgt-api nginx certs dir defined by constants.CertsPath
const (
CRT = ".crt"
@@ -33,23 +82,46 @@ func PostUploadSSLCert(c *gin.Context) {
response.Error(c, response.ErrorParamNotOK, http.StatusInternalServerError)
return
}
+
+ // The console uploads both the certificate and the private key through
+ // this endpoint. The extension decides which of the two it is, and the key
+ // must not be created with the default (0666 before umask) mode.
+ mode := os.FileMode(0644)
switch filepath.Ext(file.Filename) {
- case CRT, PEM, KEY:
+ case CRT:
+ logger.Debugf("File: %v is valid", file.Filename)
+ case PEM, KEY:
logger.Debugf("File: %v is valid", file.Filename)
+ mode = os.FileMode(0600)
default:
logger.Errorf("Filename: %s, ext: %s", file.Filename, filepath.Ext(file.Filename))
response.Error(c, response.JSONBody{Err: response.ErrWrongFileType, Msg: "Wrong file type, please upload a file of .crt or .key"}, http.StatusUnsupportedMediaType)
return
}
+ if file.Size <= 0 || file.Size > maxUploadedCertBytes {
+ response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Certificate file is empty or too large"}, http.StatusBadRequest)
+ return
+ }
+
+ clientIP := c.ClientIP()
+ throttleKeys := certGenerationKeys(clientIP)
+ if key, wait := certUploadThrottle.lockout(throttleKeys); wait > 0 {
+ logger.Warnf("Certificate upload throttled by %s, retry in %s", key, wait)
+ c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10))
+ response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many certificate uploads, please try again later"}, http.StatusTooManyRequests)
+ return
+ }
+ certUploadThrottle.fail(throttleKeys)
+
var dstPath string
- filename := fmt.Sprintf("%s_%s", utils.RandStr(16), file.Filename)
+ filename := fmt.Sprintf("%s_%s", utils.RandStr(16), filepath.Base(file.Filename))
if config.GlobalConfig.Server.DevMode {
dstPath = filepath.Join("./nginx", SSLCertDir, filename)
} else {
dstPath = filepath.Join(config.GlobalConfig.NgxResDir, SSLCertDir, filename)
}
- if err = c.SaveUploadedFile(file, dstPath); err != nil {
+ if err = saveUploadedFile(file, dstPath, mode); err != nil {
logger.Error(err)
response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when saving file"}, http.StatusInternalServerError)
return
@@ -58,6 +130,38 @@ func PostUploadSSLCert(c *gin.Context) {
response.Success(c, gin.H{"filename": filename})
}
+// saveUploadedFile stores an uploaded file with an explicit permission mode.
+//
+// gin.Context.SaveUploadedFile calls os.Create, which asks for 0666 (0644
+// after the usual umask) for every file it is given, including private keys.
+// O_CREATE|O_EXCL also makes sure an existing file - or a symlink pointing
+// outside the certificate directory - is never followed.
+func saveUploadedFile(file *multipart.FileHeader, dstPath string, mode os.FileMode) error {
+ if err := utils.EnsureFileDir(dstPath); err != nil {
+ return err
+ }
+
+ src, err := file.Open()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ _ = src.Close()
+ }()
+
+ dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
+ if err != nil {
+ return err
+ }
+
+ if _, err = io.Copy(dst, io.LimitReader(src, maxUploadedCertBytes)); err != nil {
+ _ = dst.Close()
+ return err
+ }
+
+ return dst.Close()
+}
+
func PostSSLCert(c *gin.Context) {
var params postSSLCertRequest
if err := c.BindJSON(¶ms); err != nil {
@@ -66,6 +170,35 @@ func PostSSLCert(c *gin.Context) {
return
}
+ // The host name is written into the certificate as a DNS name, and a value
+ // that is not a host name produces a certificate that stands for nothing in
+ // particular. It is checked with the same rule the front end uses for a
+ // server name before it reaches the certificate.
+ if params.Hostname == "" {
+ response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "The host name of the certificate is required"}, http.StatusBadRequest)
+ return
+ }
+
+ if err := model.ValidateServerName(params.Hostname); err != nil {
+ logger.Warn(err)
+ response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest)
+ return
+ }
+
+ clientIP := c.ClientIP()
+ throttleKeys := certGenerationKeys(clientIP)
+ if key, wait := certGenerationThrottle.lockout(throttleKeys); wait > 0 {
+ logger.Warnf("Certificate generation throttled by %s, retry in %s", key, wait)
+ c.Header("Retry-After", strconv.FormatInt(int64(wait.Seconds())+1, 10))
+ response.Error(c, response.JSONBody{Err: response.ErrTooManyAttempts, Msg: "Too many certificate requests, please try again later"}, http.StatusTooManyRequests)
+ return
+ }
+
+ // The generation below is the expensive part of this handler, so the
+ // attempt is recorded up front: a caller that only ever produces failing
+ // requests must not get an unlimited number of key generations either.
+ certGenerationThrottle.fail(throttleKeys)
+
filePrefix := utils.RandStr(16)
certFilename := fmt.Sprintf("%s_backend.crt", filePrefix)
keyFilename := fmt.Sprintf("%s_backend.key", filePrefix)
diff --git a/management/webserver/api/common.go b/management/webserver/api/common.go
index 426b8263..a9847e05 100644
--- a/management/webserver/api/common.go
+++ b/management/webserver/api/common.go
@@ -3,8 +3,10 @@ package api
import (
"encoding/json"
"fmt"
+ "io"
"io/ioutil"
"net/http"
+ "strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -18,13 +20,43 @@ import (
const VersionInfoEntrypoint = "/release/latest/version.json"
+// maxVersionInfoBytes bounds the upgrade information this server reads from
+// the platform. The body is parsed and logged on error, so a large or endless
+// answer from a misbehaving (or hostile) endpoint must not be buffered whole.
+const maxVersionInfoBytes = 1 << 20
+
+// maxLoggedBodyBytes bounds how much of an answer from another host may be put
+// into the log.
+const maxLoggedBodyBytes = 256
+
+// loggableBody renders a bounded, quoted, single line version of a response
+// body that came from another host.
+//
+// The body is remote input, and the log is read by people and by tools that
+// follow it: a verbatim body lets the other end forge log lines (or worse, if
+// the log is read in a terminal) with a carriage return or an escape sequence.
+// strconv.Quote escapes the newlines and the control characters, and the
+// truncation keeps a misbehaving endpoint from filling the disk through the
+// log file.
+func loggableBody(body []byte) string {
+ if len(body) > maxLoggedBodyBytes {
+ return fmt.Sprintf("%s... (%d bytes in total)", strconv.Quote(string(body[:maxLoggedBodyBytes])), len(body))
+ }
+
+ return strconv.Quote(string(body))
+}
+
type idsRequest struct {
IDs []uint `json:"ids" form:"ids"`
}
type pageRequest struct {
+ // PageSize is capped at 100: the value is handed to SQL LIMIT, so an
+ // unbounded one lets a single request ask the server to load an arbitrary
+ // number of rows. 100 is the largest page the console offers and the same
+ // range the MCP tools accept.
Page int `json:"page" form:"page,default=1" binding:"min=1"`
- PageSize int `json:"page_size" form:"page_size,default=10" binding:"min=1"`
+ PageSize int `json:"page_size" form:"page_size,default=10" binding:"min=1,max=100"`
}
type versionInfoResponse struct {
@@ -52,7 +84,12 @@ func GetUpgradeTips(ctx *gin.Context) {
response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade})
return
}
- body, err := ioutil.ReadAll(versionInfoRsp.Body)
+ // The response body has to be released on every path, otherwise the
+ // connection (and its file descriptor) stays around until the transport
+ // times it out.
+ defer versionInfoRsp.Body.Close()
+
+ body, err := ioutil.ReadAll(io.LimitReader(versionInfoRsp.Body, maxVersionInfoBytes))
if err != nil {
logger.Warn(err)
response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade})
@@ -62,7 +99,7 @@ func GetUpgradeTips(ctx *gin.Context) {
versionInfo := &versionInfoResponse{}
err = json.Unmarshal(body, versionInfo)
if err != nil {
- logger.Warnf("err: %v, body: %s", err, body)
+ logger.Warnf("Failed to parse %s: %v, body: %s", config.GlobalConfig.PlatformAddr+VersionInfoEntrypoint, err, loggableBody(body))
response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade})
return
}
@@ -72,7 +109,7 @@ func GetUpgradeTips(ctx *gin.Context) {
recVersionCmp := semver.Compare(currentVersion, versionInfo.RecVersion)
if semver.Compare(versionInfo.LatestVersion, versionInfo.RecVersion) == -1 || latestVersionCmp == 1 {
logger.Warnf("The version number is invalid, current version: %s, latest version: %s, rec version: %s",
- currentVersion, versionInfo.LatestVersion, versionInfo.RecVersion)
+ currentVersion, strconv.Quote(versionInfo.LatestVersion), strconv.Quote(versionInfo.RecVersion))
response.Success(ctx, gin.H{"upgrade_tips": constants.NotUpgrade})
return
}
diff --git a/management/webserver/api/detectlog.go b/management/webserver/api/detectlog.go
index 220e95d9..333eae43 100644
--- a/management/webserver/api/detectlog.go
+++ b/management/webserver/api/detectlog.go
@@ -8,8 +8,6 @@ import (
"strconv"
"strings"
- "chaitin.cn/dev/go/errors"
-
"chaitin.cn/patronus/safeline-2/management/webserver/pkg"
"github.com/gin-gonic/gin"
@@ -51,7 +49,7 @@ func getDetectLog(eventId string) (*model.DetectLog, error) {
var detectLogBasic model.DetectLogBasic
res := db.Where(&model.DetectLogBasic{EventId: eventId}).First(&detectLogBasic)
if res.RowsAffected == 0 {
- return nil, errors.New("Data queried does not exist")
+ return nil, errDataNotExist
}
var detectLogDetail model.DetectLogDetail
@@ -172,6 +170,9 @@ func PostFalsePositives(c *gin.Context) {
response.Success(c, nil)
return
}
+ // The caller owns the response returned by DoPostTelemetry and has to
+ // release it, otherwise every report leaks a connection.
+ defer rsp.Body.Close()
if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusCreated {
log.Warn(fmt.Sprintf("Transfer telemetry failed, status code = %d", rsp.StatusCode), err)
diff --git a/management/webserver/api/errors.go b/management/webserver/api/errors.go
new file mode 100644
index 00000000..059cc812
--- /dev/null
+++ b/management/webserver/api/errors.go
@@ -0,0 +1,31 @@
+package api
+
+import (
+ "chaitin.cn/dev/go/errors"
+
+ "chaitin.cn/patronus/safeline-2/management/webserver/api/response"
+)
+
+// Errors whose message is meant for the administrator. Everything else that
+// comes back from a database or a runtime call is answered with
+// response.ErrorInternal: its text describes the inside of the deployment and
+// belongs in the server log, where the operator can read it.
+var (
+ errDataNotExist = errors.New("Data queried does not exist")
+ errRulesCompile = errors.New("Rules compile error, please check your params.")
+ errPolicyRuleEmpty = errors.New("Policy rule must contain at least one pattern")
+)
+
+// responseMessage returns the text that may be sent to the caller.
+func responseMessage(err error) string {
+ switch err {
+ case errDataNotExist:
+ return errDataNotExist.Error()
+ case errRulesCompile:
+ return errRulesCompile.Error()
+ case errPolicyRuleEmpty:
+ return errPolicyRuleEmpty.Error()
+ default:
+ return response.ErrorInternal.Msg
+ }
+}
diff --git a/management/webserver/api/login_guard.go b/management/webserver/api/login_guard.go
new file mode 100644
index 00000000..29b13681
--- /dev/null
+++ b/management/webserver/api/login_guard.go
@@ -0,0 +1,193 @@
+package api
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ // loginFailureThreshold is the number of failed attempts that are tolerated
+ // before the caller has to wait.
+ loginFailureThreshold = 5
+
+ // loginFailureWindow is how long a failed attempt is remembered.
+ loginFailureWindow = 10 * time.Minute
+
+ // loginMaxLockout caps the wait that is imposed on a caller that keeps
+ // failing. A six digit passcode stays out of reach of a brute force attack
+ // with this delay, even over a long period.
+ loginMaxLockout = 5 * time.Minute
+
+ // loginMaxEntries bounds the memory the throttle may use. The keys carry a
+ // client address, and a client can choose as many of those as it likes.
+ loginMaxEntries = 1024
+)
+
+// loginAttempt is the state of one throttling key.
+type loginAttempt struct {
+ failures int
+ lastFailure time.Time
+}
+
+// loginGuard throttles an endpoint that an unauthenticated or partially
+// authenticated caller can drive per account and per client address.
+//
+// The management console has a single account and the passcode is its only
+// credential, so a caller that is allowed to try passcodes without limit can
+// simply enumerate them. The same guard covers the TFA bootstrap, where the
+// only thing between a caller and the secret of the account is a token
+// comparison.
+type loginGuard struct {
+ mu sync.Mutex
+ attempts map[string]*loginAttempt
+
+ // failureThreshold is how many failures are tolerated before the caller has
+ // to wait, failureWindow how long a failure is remembered and maxLockout
+ // caps the wait that is imposed.
+ failureThreshold int
+ failureWindow time.Duration
+ maxLockout time.Duration
+
+ // now is a seam for the tests.
+ now func() time.Time
+}
+
+// newLoginGuard returns the throttle of the login endpoint.
+func newLoginGuard() *loginGuard {
+ return newThrottle(loginFailureThreshold, loginFailureWindow, loginMaxLockout)
+}
+
+// newThrottle returns a throttle with its own thresholds, which lets an
+// endpoint that is cheaper to reach than the login pick stricter numbers.
+func newThrottle(threshold int, window, maxLockout time.Duration) *loginGuard {
+ return &loginGuard{
+ attempts: make(map[string]*loginAttempt),
+ failureThreshold: threshold,
+ failureWindow: window,
+ maxLockout: maxLockout,
+ now: time.Now,
+ }
+}
+
+// accountKeyPrefix marks the throttle key that every caller of an account
+// shares, as opposed to the keys that belong to a single client address.
+const accountKeyPrefix = "account:"
+
+// isAccountKey reports whether a throttle key protects an account rather than a
+// single client address.
+func isAccountKey(key string) bool {
+ return strings.HasPrefix(key, accountKeyPrefix)
+}
+
+// loginKeys returns the throttle keys of a login attempt.
+func loginKeys(clientIP, username string) []string {
+ keys := []string{accountKeyPrefix + username}
+ if clientIP != "" {
+ keys = append(keys, fmt.Sprintf("ip:%s", clientIP))
+ }
+
+ return keys
+}
+
+// lockout reports how long the caller has to wait, and the key that caused it.
+func (g *loginGuard) lockout(keys []string) (string, time.Duration) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ now := g.now()
+ g.prune(now)
+
+ var (
+ blockedKey string
+ wait time.Duration
+ )
+
+ for _, key := range keys {
+ attempt, ok := g.attempts[key]
+ if !ok || attempt.failures < g.failureThreshold {
+ continue
+ }
+
+ if remaining := g.backoff(attempt.failures) - now.Sub(attempt.lastFailure); remaining > wait {
+ blockedKey, wait = key, remaining
+ }
+ }
+
+ return blockedKey, wait
+}
+
+// fail records an authentication failure.
+func (g *loginGuard) fail(keys []string) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ now := g.now()
+ g.prune(now)
+
+ for _, key := range keys {
+ attempt, ok := g.attempts[key]
+ if !ok || now.Sub(attempt.lastFailure) > g.failureWindow {
+ attempt = &loginAttempt{}
+ g.attempts[key] = attempt
+ }
+
+ attempt.failures++
+ attempt.lastFailure = now
+ }
+}
+
+// reset forgets the failures of a key, which is what a successful login does.
+func (g *loginGuard) reset(keys []string) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+
+ for _, key := range keys {
+ delete(g.attempts, key)
+ }
+}
+
+// prune drops records that are no longer useful and keeps the map bounded.
+func (g *loginGuard) prune(now time.Time) {
+ for key, attempt := range g.attempts {
+ if now.Sub(attempt.lastFailure) > g.failureWindow+g.maxLockout {
+ delete(g.attempts, key)
+ }
+ }
+
+ // The map has to stay bounded, because a client can invent client addresses
+ // faster than the failures expire. Dropping an address key only weakens the
+ // throttle of a caller that stopped trying, but an account key is never
+ // dropped: it is the one that keeps the passcode out of reach, and being
+ // able to reset it by filling the map with invented addresses would make
+ // the whole throttle useless.
+ for key := range g.attempts {
+ if len(g.attempts) <= loginMaxEntries {
+ break
+ }
+ if isAccountKey(key) {
+ continue
+ }
+ delete(g.attempts, key)
+ }
+}
+
+// backoff returns how long a caller that failed n times in a row has to wait
+// after its last failure. The delay doubles per failure and is capped.
+func (g *loginGuard) backoff(failures int) time.Duration {
+ shift := failures - g.failureThreshold
+ if shift < 0 {
+ shift = 0
+ }
+ if shift > 8 {
+ shift = 8
+ }
+
+ backoff := time.Second << uint(shift)
+ if backoff > g.maxLockout {
+ backoff = g.maxLockout
+ }
+
+ return backoff
+}
diff --git a/management/webserver/api/policygroup.go b/management/webserver/api/policygroup.go
index 8a4eb638..753e0d5b 100644
--- a/management/webserver/api/policygroup.go
+++ b/management/webserver/api/policygroup.go
@@ -7,8 +7,6 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
- "chaitin.cn/dev/go/errors"
-
"chaitin.cn/patronus/safeline-2/management/webserver/api/response"
"chaitin.cn/patronus/safeline-2/management/webserver/model"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants"
@@ -24,6 +22,15 @@ func PutPolicyGroupGlobal(ctx *gin.Context) {
return
}
+ // Reject unknown modules and modes before anything is persisted: the map
+ // keys are rendered into the skynet configuration, so a bad key has to be
+ // reported to the caller instead of reaching the engine or the renderer.
+ if err := model.ValidatePolicyGroup(params); err != nil {
+ logger.Error(err)
+ response.Error(ctx, response.JSONBody{Err: response.ErrorParamNotOK.Err, Msg: err.Error()}, http.StatusBadRequest)
+ return
+ }
+
db := database.GetDB()
err := db.Transaction(func(tx *gorm.DB) error {
var pggOption model.Options
@@ -32,13 +39,13 @@ func PutPolicyGroupGlobal(ctx *gin.Context) {
return res.Error
}
if res.RowsAffected == 0 {
- return errors.New("Data queried does not exist")
+ return errDataNotExist
}
pggStr, err := json.Marshal(params)
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return err
}
@@ -46,14 +53,14 @@ func PutPolicyGroupGlobal(ctx *gin.Context) {
tx.Save(&pggOption)
if err := fvm.PushFSL(tx); err != nil {
- return errors.New("Rules compile error, please check your params.")
+ return errRulesCompile
}
return nil
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
@@ -68,7 +75,7 @@ func GetPolicyGroupGlobal(ctx *gin.Context) {
err := json.Unmarshal([]byte(pggOption.Value), &pgg)
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
@@ -91,13 +98,13 @@ func PutSrcIPConfig(ctx *gin.Context) {
return res.Error
}
if res.RowsAffected == 0 {
- return errors.New("Data queried does not exist")
+ return errDataNotExist
}
scStr, err := json.Marshal(params)
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return err
}
@@ -105,14 +112,14 @@ func PutSrcIPConfig(ctx *gin.Context) {
tx.Save(&scOption)
if err := fvm.PushFSL(tx); err != nil {
- return errors.New("Rules compile error, please check your params.")
+ return errRulesCompile
}
return nil
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
diff --git a/management/webserver/api/policyrule.go b/management/webserver/api/policyrule.go
index 784b4274..8b1b0e32 100644
--- a/management/webserver/api/policyrule.go
+++ b/management/webserver/api/policyrule.go
@@ -1,15 +1,15 @@
package api
import (
+ "encoding/json"
"net/http"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/fvm"
"github.com/gin-gonic/gin"
+ "gorm.io/datatypes"
"gorm.io/gorm"
- "chaitin.cn/dev/go/errors"
-
"chaitin.cn/patronus/safeline-2/management/webserver/api/response"
"chaitin.cn/patronus/safeline-2/management/webserver/model"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/database"
@@ -28,6 +28,11 @@ func PostPolicyRule(ctx *gin.Context) {
return
}
+ if err := validatePolicyRulePatterns(params.Pattern); err != nil {
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest)
+ return
+ }
+
db := database.GetDB()
err := db.Transaction(func(tx *gorm.DB) error {
policyRule := &model.PolicyRule{Action: params.Action, Comment: params.Comment, IsEnabled: params.IsEnabled, Pattern: params.Pattern}
@@ -37,14 +42,14 @@ func PostPolicyRule(ctx *gin.Context) {
}
if err := fvm.PushFSL(tx); err != nil {
- return errors.New("Rules compile error, please check your params.")
+ return errRulesCompile
}
return nil
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
@@ -61,23 +66,27 @@ func PutSwitchPolicyRule(ctx *gin.Context) {
db := database.GetDB()
err := db.Transaction(func(tx *gorm.DB) error {
- res := tx.Model(&model.PolicyRule{}).Where(params.IDs).Updates(model.PolicyRule{IsEnabled: params.IsEnabled})
+ // IsEnabled=false is the Go zero value. GORM skips zero-value fields in
+ // Updates(struct), so a disable request would leave the row enabled.
+ res := tx.Model(&model.PolicyRule{}).Where(params.IDs).
+ Select("is_enabled").
+ Updates(model.PolicyRule{IsEnabled: params.IsEnabled})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
- return errors.New("Data queried does not exist")
+ return errDataNotExist
}
if err := fvm.PushFSL(tx); err != nil {
- return errors.New("Rules compile error, please check your params.")
+ return errRulesCompile
}
return nil
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
}
@@ -90,6 +99,11 @@ func PutPolicyRule(ctx *gin.Context) {
return
}
+ if err := validatePolicyRulePatterns(params.Pattern); err != nil {
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest)
+ return
+ }
+
db := database.GetDB()
err := db.Transaction(func(tx *gorm.DB) error {
var policyRule model.PolicyRule
@@ -98,7 +112,7 @@ func PutPolicyRule(ctx *gin.Context) {
return res.Error
}
if res.RowsAffected == 0 {
- return errors.New("Data queried does not exist")
+ return errDataNotExist
}
policyRule.Action = params.Action
@@ -108,14 +122,14 @@ func PutPolicyRule(ctx *gin.Context) {
tx.Save(&policyRule)
if err := fvm.PushFSL(tx); err != nil {
- return errors.New("Rules compile error, please check your params.")
+ return errRulesCompile
}
return nil
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
@@ -137,24 +151,37 @@ func DeletePolicyRule(ctx *gin.Context) {
return res.Error
}
if res.RowsAffected == 0 {
- return errors.New("Data queried does not exist")
+ return errDataNotExist
}
if err := fvm.PushFSL(tx); err != nil {
- return errors.New("Rules compile error, please check your params.")
+ return errRulesCompile
}
return nil
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
response.Success(ctx, nil)
}
+// validatePolicyRulePatterns rejects a rule that would compile to a selector
+// with no WHERE. An empty pattern list matches every request.
+func validatePolicyRulePatterns(raw datatypes.JSON) error {
+ if len(raw) == 0 {
+ return errPolicyRuleEmpty
+ }
+ var patterns []model.PolicyRulePattern
+ if err := json.Unmarshal(raw, &patterns); err != nil || len(patterns) == 0 {
+ return errPolicyRuleEmpty
+ }
+ return nil
+}
+
func GetPolicyRule(ctx *gin.Context) {
var params pageRequest
if err := ctx.BindQuery(¶ms); err != nil {
diff --git a/management/webserver/api/response/error.go b/management/webserver/api/response/error.go
index d62b815c..4d03fbd3 100644
--- a/management/webserver/api/response/error.go
+++ b/management/webserver/api/response/error.go
@@ -1,11 +1,13 @@
package response
const (
- ErrLoginRequired = "login-required"
- ErrWrongPasscode = "wrong-passcode"
- ErrWrongTimeGap = "wrong-time-gap"
- ErrInternalError = "internal-error"
- ErrDataNotExist = "data-not-exist"
- ErrWrongFileType = "wrong-filetype"
- ErrReadOnly = "read-only"
+ ErrLoginRequired = "login-required"
+ ErrWrongPasscode = "wrong-passcode"
+ ErrWrongTimeGap = "wrong-time-gap"
+ ErrInternalError = "internal-error"
+ ErrDataNotExist = "data-not-exist"
+ ErrWrongFileType = "wrong-filetype"
+ ErrReadOnly = "read-only"
+ ErrTooManyAttempts = "too-many-attempts"
+ ErrForbidden = "forbidden"
)
diff --git a/management/webserver/api/response/jsonbody.go b/management/webserver/api/response/jsonbody.go
index 4af6e615..9e498b4b 100644
--- a/management/webserver/api/response/jsonbody.go
+++ b/management/webserver/api/response/jsonbody.go
@@ -17,6 +17,12 @@ var (
ErrorParamNotOK = JSONBody{ErrInternalError, "Error occurred when extracting params", nil}
ErrorDataNotExist = JSONBody{ErrDataNotExist, "Data queried does not exist", nil}
ErrorReadOnly = JSONBody{ErrReadOnly, "This environment is read only", nil}
+
+ // ErrorInternal is the answer to a failed database or runtime call. The
+ // text of the underlying error stays in the server log: it can carry
+ // schema names, addresses and other details of the deployment that the
+ // caller has no use for.
+ ErrorInternal = JSONBody{ErrInternalError, "Error occurred when processing the request, please check the server log", nil}
)
func Success(c *gin.Context, data interface{}) {
diff --git a/management/webserver/api/website.go b/management/webserver/api/website.go
index c21b3d43..2f9e3168 100644
--- a/management/webserver/api/website.go
+++ b/management/webserver/api/website.go
@@ -5,8 +5,6 @@ import (
"net/http"
"strconv"
- "chaitin.cn/dev/go/errors"
-
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -53,6 +51,14 @@ func PostWebsite(ctx *gin.Context) {
return
}
+ if err := params.Validate(); err != nil {
+ logger.Warn(err)
+ // The message of a validation error is written for the administrator
+ // ("invalid port ..."), so it is passed on as it is.
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest)
+ return
+ }
+
db := database.GetDB()
err := db.Transaction(func(tx *gorm.DB) error {
website := &model.Website{Comment: params.Comment, ServerNames: params.ServerNames, Upstreams: params.Upstreams, Ports: params.Ports,
@@ -75,7 +81,7 @@ func PostWebsite(ctx *gin.Context) {
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
@@ -90,6 +96,14 @@ func PutWebsite(ctx *gin.Context) {
return
}
+ if err := params.Validate(); err != nil {
+ logger.Warn(err)
+ // The message of a validation error is written for the administrator
+ // ("invalid port ..."), so it is passed on as it is.
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusBadRequest)
+ return
+ }
+
db := database.GetDB()
err := db.Transaction(func(tx *gorm.DB) error {
var website model.Website
@@ -98,7 +112,7 @@ func PutWebsite(ctx *gin.Context) {
return res.Error
}
if res.RowsAffected == 0 {
- return errors.New("Data queried does not exist")
+ return errDataNotExist
}
website.Comment = params.Comment
@@ -108,13 +122,17 @@ func PutWebsite(ctx *gin.Context) {
website.CertFilename = params.CertFilename
website.KeyFilename = params.KeyFilename
website.IsEnabled = true
- tx.Save(&website)
+ if err := tx.Save(&website).Error; err != nil {
+ return err
+ }
if config.GlobalConfig.Server.DevMode {
return nil
}
- err := publishWebsiteConfig(¶ms)
+ // Publish the row that was just saved, not the request body. The saved
+ // record forces IsEnabled and may differ from params.
+ err := publishWebsiteConfig(&website)
if err != nil {
return err
}
@@ -123,7 +141,7 @@ func PutWebsite(ctx *gin.Context) {
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
@@ -145,7 +163,7 @@ func DeleteWebsite(ctx *gin.Context) {
return res.Error
}
if res.RowsAffected == 0 {
- return errors.New("Data queried does not exist")
+ return errDataNotExist
}
if config.GlobalConfig.Server.DevMode {
@@ -161,7 +179,7 @@ func DeleteWebsite(ctx *gin.Context) {
})
if err != nil {
logger.Error(err)
- response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: err.Error()}, http.StatusInternalServerError)
+ response.Error(ctx, response.JSONBody{Err: response.ErrInternalError, Msg: responseMessage(err)}, http.StatusInternalServerError)
return
}
@@ -188,13 +206,15 @@ func GetWebsite(ctx *gin.Context) {
var statistics []model.SystemStatistics
var websiteIds []string
for _, i := range websiteList {
- websiteIds = append(websiteIds, strconv.Itoa(int(i.ID)))
+ // FormatUint renders the identifier without narrowing it: it is a uint,
+ // and going through int would truncate it on a 32 bit build.
+ websiteIds = append(websiteIds, strconv.FormatUint(uint64(i.ID), 10))
}
db.Where("created_at >= date_trunc('day',now())").Where("website in (?)", websiteIds).Find(&statistics)
for i, website := range websiteList {
for _, j := range statistics {
- if strconv.Itoa(int(website.ID)) == j.Website {
+ if strconv.FormatUint(uint64(website.ID), 10) == j.Website {
if j.Type == "website-req" {
websiteList[i].ReqValue = j.Value
} else if j.Type == "website-denied" {
diff --git a/management/webserver/cmd/gen_certs.go b/management/webserver/cmd/gen_certs.go
index 509e6e44..489d11fc 100644
--- a/management/webserver/cmd/gen_certs.go
+++ b/management/webserver/cmd/gen_certs.go
@@ -1,11 +1,16 @@
package cmd
import (
+ "crypto/x509"
"crypto/x509/pkix"
+ "encoding/pem"
+ "os"
"path/filepath"
+ "time"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/config"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants"
+ "chaitin.cn/patronus/safeline-2/management/webserver/rpc"
"chaitin.cn/patronus/safeline-2/management/webserver/utils"
)
@@ -16,9 +21,196 @@ func GenCerts() error {
if err := genClientCACert(); err != nil {
return err
}
+ if err := genControlChannelCerts(); err != nil {
+ return err
+ }
return nil
}
+// genControlChannelCerts issues the credentials of the control channel between
+// the management server and tcontrollerd.
+//
+// The channel carries the complete site configuration of the installation, and
+// it used to be served in the clear with a token in the metadata as its only
+// gate: the token is readable by anything that shares the network of the
+// containers, and a caller that reads it can subscribe as tcontrollerd. Both
+// ends are therefore issued by a CA that only this installation knows, and each
+// end checks the other against it.
+//
+// The server credentials stay in the resources directory, which only the
+// management container mounts. The credentials of tcontrollerd are published in
+// the directory both containers mount, next to the token file, because
+// tcontrollerd runs in the tengine container.
+func genControlChannelCerts() error {
+ caCert, caKey, err := controlChannelCA()
+ if err != nil {
+ return err
+ }
+
+ // The name is fixed on both sides: tcontrollerd dials an address of the
+ // deployment, which no certificate can be issued for. The addresses of the
+ // standard deployment are listed as well, so that a client which verifies
+ // the address instead of the name still works.
+ hostnames := []string{rpc.ServerName, "mgt", "safeline-mgt", "localhost"}
+
+ if err := ensureSignedCert(
+ filepath.Join(rpc.CertDir(), rpc.ServerCertFile),
+ filepath.Join(rpc.CertDir(), rpc.ServerKeyFile),
+ caCert,
+ caKey,
+ hostnames,
+ &pkix.Name{
+ Country: []string{"CN"},
+ Province: []string{"Beijing"},
+ Locality: []string{"Beijing"},
+ Organization: []string{"Beijing WAF Technology Co., Ltd."},
+ OrganizationalUnit: []string{"Service Infrastructure Department"},
+ CommonName: rpc.ServerName,
+ },
+ false,
+ ); err != nil {
+ return err
+ }
+
+ clientDir := rpc.ClientCertDir()
+
+ if err := ensureSignedCert(
+ filepath.Join(clientDir, rpc.ClientCertFile),
+ filepath.Join(clientDir, rpc.ClientKeyFile),
+ caCert,
+ caKey,
+ []string{},
+ &pkix.Name{
+ Country: []string{"CN"},
+ Province: []string{"Beijing"},
+ Locality: []string{"Beijing"},
+ Organization: []string{"Beijing WAF Technology Co., Ltd."},
+ OrganizationalUnit: []string{"Service Infrastructure Department"},
+ CommonName: "tcontrollerd",
+ },
+ true,
+ ); err != nil {
+ return err
+ }
+
+ // tcontrollerd verifies the server with the CA, so it needs a copy. A
+ // certificate is public, unlike the key that never leaves the management
+ // container.
+ return utils.EnsureRenameWriteFile(filepath.Join(clientDir, rpc.CertAuthorityFile), caCert, 0644)
+}
+
+// controlChannelCA reads the certificate authority of the control channel,
+// creating it when it is missing.
+func controlChannelCA() ([]byte, []byte, error) {
+ certPath := filepath.Join(rpc.CertDir(), rpc.CertAuthorityFile)
+ keyPath := filepath.Join(rpc.CertDir(), "grpc_ca.key")
+
+ if err := utils.WriteCertIfNotExist(
+ certPath,
+ keyPath,
+ func() ([]byte, []byte, error) {
+ return utils.GenerateCert(
+ []string{},
+ 3650,
+ 4096,
+ &pkix.Name{
+ Country: []string{"CN"},
+ Province: []string{"Beijing"},
+ Locality: []string{"Beijing"},
+ Organization: []string{"Beijing WAF Technology Co., Ltd."},
+ OrganizationalUnit: []string{"Service Infrastructure Department"},
+ CommonName: "WAF Control Channel Certificate Authority",
+ },
+ true,
+ )
+ }); err != nil {
+ return nil, nil, err
+ }
+
+ cert, err := os.ReadFile(certPath)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ key, err := os.ReadFile(keyPath)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return cert, key, nil
+}
+
+// ensureSignedCert writes a certificate signed by the CA of the control
+// channel, computing it again when the one on disk does not belong to that CA.
+//
+// Both ends check the other against the CA, so a certificate that was issued by
+// an older CA, or that is about to expire, only shows up as a handshake that
+// fails for no visible reason. It is replaced here instead; the key of the
+// management server credentials is kept to the owner, the one of tcontrollerd
+// is read by the tengine container and is kept to the owner as well.
+func ensureSignedCert(certPath, keyPath string, caCert, caKey []byte, hostnames []string, subject *pkix.Name, client bool) error {
+ reusable, err := signedByAuthority(certPath, caCert)
+ if err != nil {
+ return err
+ }
+
+ if reusable {
+ if _, err := os.Stat(keyPath); err == nil {
+ return nil
+ }
+ }
+
+ cert, key, err := utils.GenerateSignedCert(caCert, caKey, hostnames, 3650, 4096, subject, client)
+ if err != nil {
+ return err
+ }
+
+ if err := utils.EnsureRenameWriteFile(certPath, cert, 0644); err != nil {
+ return err
+ }
+
+ return utils.EnsureRenameWriteFile(keyPath, key, 0600)
+}
+
+// signedByAuthority reports whether the certificate at path is signed by the
+// given authority and is not about to expire.
+func signedByAuthority(path string, caCertPEM []byte) (bool, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+
+ return false, err
+ }
+
+ block, _ := pem.Decode(content)
+ if block == nil {
+ return false, nil
+ }
+
+ certificate, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return false, nil
+ }
+
+ caBlock, _ := pem.Decode(caCertPEM)
+ if caBlock == nil {
+ return false, nil
+ }
+
+ authority, err := x509.ParseCertificate(caBlock.Bytes)
+ if err != nil {
+ return false, nil
+ }
+
+ if err := certificate.CheckSignatureFrom(authority); err != nil {
+ return false, nil
+ }
+
+ return time.Now().Add(24 * time.Hour).Before(certificate.NotAfter), nil
+}
+
func genServerCert() error {
certPath := filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath, "server.crt")
keyPath := filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath, "server.key")
diff --git a/management/webserver/cmd/reset_user.go b/management/webserver/cmd/reset_user.go
index c879f0da..5519136f 100644
--- a/management/webserver/cmd/reset_user.go
+++ b/management/webserver/cmd/reset_user.go
@@ -3,12 +3,33 @@ package cmd
import (
"chaitin.cn/patronus/safeline-2/management/webserver/model"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/database"
+ "chaitin.cn/patronus/safeline-2/management/webserver/pkg/log"
)
+var logger = log.GetLogger("cmd")
+
+// ResetUser clears the credentials of a user so that the next visit of the
+// console binds a new TFA secret.
+//
+// The pending secret and the address it was issued to are cleared together:
+// a secret that was handed to somebody else cannot be bound by the
+// administrator, and starting the bootstrap over from the shell is the only
+// way out of that state.
+//
+// It also opens the anonymous bootstrap window again, because this command is
+// how an operator asks for it: the account is unbound at the end of the call,
+// and the caller is at the console now.
func ResetUser(username string) {
db := database.GetDB()
var user model.User
db.Where(&model.User{Username: username}).First(&user)
user.LastLoginTime = 0
+ user.TFASecret = ""
+ user.TFABootstrapIP = ""
+ user.TFALastUsedStep = 0
db.Save(&user)
+
+ if err := model.OpenBootstrapWindow(); err != nil {
+ logger.Error(err)
+ }
}
diff --git a/management/webserver/go.mod b/management/webserver/go.mod
index db79f843..914f6027 100644
--- a/management/webserver/go.mod
+++ b/management/webserver/go.mod
@@ -1,8 +1,8 @@
module chaitin.cn/patronus/safeline-2/management/webserver
-go 1.21
+go 1.25.0
-toolchain go1.21.3
+toolchain go1.25.14
require (
chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6
@@ -13,8 +13,8 @@ require (
github.com/pquerna/otp v1.4.0
github.com/robfig/cron/v3 v3.0.1
github.com/rogpeppe/go-internal v1.10.0
- github.com/sirupsen/logrus v1.4.2
- google.golang.org/grpc v1.65.0
+ github.com/sirupsen/logrus v1.9.3
+ google.golang.org/grpc v1.83.2
gorm.io/datatypes v1.1.1
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.10
@@ -36,7 +36,7 @@ require (
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
- github.com/jackc/pgx/v5 v5.5.5 // indirect
+ github.com/jackc/pgx/v5 v5.11.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -50,8 +50,8 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
- golang.org/x/sys v0.20.0 // indirect
- golang.org/x/text v0.15.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/management/webserver/go.sum b/management/webserver/go.sum
index c1e3978c..a38752aa 100644
--- a/management/webserver/go.sum
+++ b/management/webserver/go.sum
@@ -1,3 +1,4 @@
+chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8 h1:qljuZkcItfSnd1OvteZKgxlqZzcr48DC9qm+OTnt/Xo=
chaitin.cn/dev/go/errors v0.0.0-20200717101723-df6132d53dc8/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo=
chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6 h1:1Qa9ABk907/9ZrOLbbRcS8Fqq9VhjAF/mLjbSP1qAJY=
chaitin.cn/dev/go/errors v0.0.0-20210324055134-dc5247602af6/go.mod h1:u+ZD0shdyUt0UG9XfCgD1R5mSqXpTVoO5rwQXQeo3Eo=
@@ -7,10 +8,10 @@ chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c h1:tXsraF7o9iUsQY6
chaitin.cn/dev/go/settings v0.0.0-20221220104336-05125760b10c/go.mod h1:fUvtmpG8Z8Zf5aciadL9a/vn5SB3knG7pdNJixDplPg=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
-github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
@@ -21,13 +22,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
-github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
@@ -35,6 +39,7 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@@ -60,10 +65,16 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
+github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@@ -88,9 +99,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
@@ -101,6 +114,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -108,11 +122,13 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -131,53 +147,65 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU=
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
-github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
-golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -190,10 +218,14 @@ google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ
google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag=
google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -212,12 +244,14 @@ gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y=
gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
+gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 h1:9qNbmu21nNThCNnF5i2R3kw2aL27U8ZwbzccNjOmW0g=
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
+gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/management/webserver/main.go b/management/webserver/main.go
index 43e65851..6790a5f4 100644
--- a/management/webserver/main.go
+++ b/management/webserver/main.go
@@ -136,18 +136,49 @@ func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
+ // gin.ClientIP() returns the first value of X-Forwarded-For as soon as the
+ // direct peer belongs to the trusted set, and the default trusted set is
+ // every address. The management port is published on the host and reached
+ // directly, so a client could otherwise pick the address it is throttled by
+ // - the login throttle and the TFA bootstrap both key on it. No proxy is
+ // trusted unless a deployment says so.
+ if err := r.SetTrustedProxies(nil); err != nil {
+ logger.Fatalln("Failed to disable trusted proxies: ", err)
+ }
+ r.Use(middleware.SecurityHeaders)
+ // Nothing in front of this server caps the request body, so it is bounded
+ // here (see middleware.BodyLimit).
+ r.Use(middleware.BodyLimit(middleware.MaxRequestBodyBytes))
var option model.Options
database.GetDB().Where(&model.Options{Key: constants.SecretKey}).First(&option)
- logger.Debugf("Secret: %s", option.Value)
store := cookie.NewStore([]byte(option.Value))
r.Use(sessions.Sessions("session", store))
+ created, tokenPath, err := api.EnsureBootstrapToken()
+ if err != nil {
+ logger.Fatalln("Failed to initialize the TFA bootstrap token: ", err)
+ }
+ if os.Getenv(api.BootstrapTokenEnv) != "" {
+ logger.Infof("GET /api%s requires the %s header from %s.", api.OTPUrl, api.BootstrapTokenHeader, api.BootstrapTokenEnv)
+ } else if created {
+ logger.Infof("Wrote the TFA bootstrap token to %s (mode 0600). GET /api%s requires the %s header. The stock console must send it; until then bind with curl.", tokenPath, api.OTPUrl, api.BootstrapTokenHeader)
+ } else {
+ logger.Infof("GET /api%s requires the %s header; token loaded from %s.", api.OTPUrl, api.BootstrapTokenHeader, tokenPath)
+ }
+ if window := api.BootstrapWindow(); window <= 0 {
+ logger.Warnf("%s is off: the account can be bound from the network until somebody logs in. A console that is left unbound stays claimable.",
+ api.BootstrapWindowEnv)
+ } else if openedAt, ok := model.BootstrapOpenedAt(); ok {
+ logger.Infof("The TFA bootstrap of the account is open until %s (%s after it was opened at %s); run 'mgt -reset_user admin' to open it again.",
+ openedAt.Add(window).Format(time.RFC3339), window, openedAt.Format(time.RFC3339))
+ }
+
publicRouters := r.Group("/api")
+ publicRouters.Use(middleware.SameOrigin)
publicRouters.POST(api.Login, api.PostLogin)
publicRouters.POST(api.Logout, api.PostLogout)
- publicRouters.POST(api.Behaviour, api.PostBehaviour)
- publicRouters.POST(api.FalsePositives, api.PostFalsePositives)
+ // First-boot QR still has no session, but requires X-Bootstrap-Token.
publicRouters.GET(api.OTPUrl, api.GetOTPUrl)
publicRouters.GET(api.Version, api.GetVersion)
publicRouters.GET(api.UpgradeTips, api.GetUpgradeTips)
@@ -159,22 +190,24 @@ func main() {
})
limitedRouters := r.Group("/api")
- noAuth, existed := os.LookupEnv("NO_AUTH")
- if existed && len(noAuth) >= 0 {
- logger.Warn("No auth")
+ limitedRouters.Use(middleware.SameOrigin)
+ if envEnabled(os.Getenv("NO_AUTH")) {
+ logger.Warn("Authentication is disabled because NO_AUTH is enabled")
} else {
limitedRouters.Use(middleware.AuthRequired)
}
- readOnly, existed := os.LookupEnv("READ_ONLY")
- if existed && len(readOnly) >= 0 {
- logger.Warn("Read only")
+ if envEnabled(os.Getenv("READ_ONLY")) {
+ logger.Warn("Write requests are rejected because READ_ONLY is enabled")
limitedRouters.Use(middleware.ReadOnly)
}
limitedRouters.GET(api.User, api.GetUser)
+ limitedRouters.POST(api.Behaviour, api.PostBehaviour)
+
limitedRouters.GET(api.DetectLogList, api.GetDetectLogList)
limitedRouters.GET(api.DetectLogDetail, api.GetDetectLogDetail)
+ limitedRouters.POST(api.FalsePositives, api.PostFalsePositives)
limitedRouters.POST(api.Website, api.PostWebsite)
limitedRouters.PUT(api.Website, api.PutWebsite)
@@ -208,3 +241,16 @@ func main() {
logger.Fatalln("Error occurred when running web server: ", err)
}
}
+
+// envEnabled reports whether an environment variable holds an explicit truthy
+// value. Switches such as NO_AUTH and READ_ONLY disable a protection, so only
+// values that actually ask for it may enable them: an empty string (or any
+// other unparsable value) must never do it.
+func envEnabled(value string) bool {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "1", "true", "yes", "on":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/management/webserver/middleware/auth.go b/management/webserver/middleware/auth.go
index f200175c..e77757d5 100644
--- a/management/webserver/middleware/auth.go
+++ b/management/webserver/middleware/auth.go
@@ -8,6 +8,7 @@ import (
"chaitin.cn/patronus/safeline-2/management/webserver/api/response"
"chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants"
+ "chaitin.cn/patronus/safeline-2/management/webserver/pkg/sessionopts"
)
func AuthRequired(c *gin.Context) {
@@ -21,17 +22,15 @@ func AuthRequired(c *gin.Context) {
}
// extend session expired time
- session.Options(sessions.Options{
- Path: "/",
- MaxAge: 3600 * 24 * 7,
- //Domain: options.Domain,
- //HttpOnly: true,
- //SameSite: http.SameSiteLaxMode,
- //Secure: false,
- })
+ session.Options(sessionopts.Options(c))
if err := session.Save(); err != nil {
response.Error(c, response.JSONBody{Err: response.ErrInternalError, Msg: "Error occurred when creating sessions"}, http.StatusInternalServerError)
+ // The request has to stop here. Gin runs the handlers that follow a
+ // middleware from the same loop that called this one, so returning
+ // without Abort would still let the request reach the protected handler
+ // even though the answer above already reported a failure.
+ c.Abort()
return
}
diff --git a/management/webserver/middleware/bodylimit.go b/management/webserver/middleware/bodylimit.go
new file mode 100644
index 00000000..1e592201
--- /dev/null
+++ b/management/webserver/middleware/bodylimit.go
@@ -0,0 +1,31 @@
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// MaxRequestBodyBytes bounds the size of a request body the management API
+// accepts.
+//
+// This server terminates TLS itself and its port is published on the host, so
+// no proxy caps the body in front of it: without a limit a client can make it
+// buffer an arbitrary amount of data. The largest body the API takes is an SSL
+// certificate upload, which is a few tens of kilobytes, so the limit leaves a
+// wide margin while still bounding what a single request may consume.
+const MaxRequestBodyBytes = 16 << 20 // 16 MiB
+
+// BodyLimit caps the request body at maxBytes.
+//
+// The body is replaced by a reader that reports an error once the limit is
+// exceeded, so a handler that parses the body fails with a parameter error
+// instead of reading it all. Requests without a body are not affected.
+func BodyLimit(maxBytes int64) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if c.Request.Body != nil {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
+ }
+ c.Next()
+ }
+}
diff --git a/management/webserver/middleware/origin.go b/management/webserver/middleware/origin.go
new file mode 100644
index 00000000..35e06158
--- /dev/null
+++ b/management/webserver/middleware/origin.go
@@ -0,0 +1,59 @@
+package middleware
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "chaitin.cn/patronus/safeline-2/management/webserver/api/response"
+)
+
+// SameOrigin rejects cross-site state-changing requests using the Origin
+// header. The management console is a cookie-session SPA; Lax already drops
+// the cookie on cross-site POST, and this check covers non-browser clients
+// that still send Origin, plus browsers that attach the cookie on same-site
+// confusing cases.
+//
+// Safe methods are left alone. Requests without a usable Origin are refused:
+// Referrer-Policy is no-referrer, so Referer is not a fallback.
+func SameOrigin(c *gin.Context) {
+ switch c.Request.Method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions:
+ c.Next()
+ return
+ }
+
+ origin := strings.TrimSpace(c.GetHeader("Origin"))
+ if origin == "" || strings.EqualFold(origin, "null") {
+ response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden)
+ c.Abort()
+ return
+ }
+
+ parsed, err := url.Parse(origin)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden)
+ c.Abort()
+ return
+ }
+
+ if !originMatchesRequest(parsed, c) {
+ response.Error(c, response.JSONBody{Err: response.ErrForbidden, Msg: "cross-origin request is not allowed"}, http.StatusForbidden)
+ c.Abort()
+ return
+ }
+
+ c.Next()
+}
+
+func originMatchesRequest(origin *url.URL, c *gin.Context) bool {
+ // Scheme is not compared: the console is reached over HTTPS while gin
+ // often sees HTTP behind the container TLS terminator. Host still has
+ // to match, so a foreign site cannot satisfy this check.
+ if !strings.EqualFold(origin.Scheme, "http") && !strings.EqualFold(origin.Scheme, "https") {
+ return false
+ }
+ return strings.EqualFold(origin.Host, c.Request.Host)
+}
diff --git a/management/webserver/middleware/origin_test.go b/management/webserver/middleware/origin_test.go
new file mode 100644
index 00000000..bfb68596
--- /dev/null
+++ b/management/webserver/middleware/origin_test.go
@@ -0,0 +1,33 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestOriginMatchesRequestIgnoresScheme(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "http://127.0.0.1:9001/api/Login", nil)
+ c.Request.Host = "example.com:9443"
+
+ origin, err := url.Parse("https://example.com:9443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !originMatchesRequest(origin, c) {
+ t.Fatal("https Origin should match Host behind HTTP gin")
+ }
+
+ evil, err := url.Parse("https://evil.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if originMatchesRequest(evil, c) {
+ t.Fatal("foreign Origin must not match")
+ }
+}
diff --git a/management/webserver/middleware/security.go b/management/webserver/middleware/security.go
new file mode 100644
index 00000000..9b5af017
--- /dev/null
+++ b/management/webserver/middleware/security.go
@@ -0,0 +1,32 @@
+package middleware
+
+import "github.com/gin-gonic/gin"
+
+// SecurityHeaders sets the response headers that keep a browser from
+// interpreting an API answer as something else, and keep the console out of
+// frames.
+//
+// The policy deliberately restricts only embedding, plugins and the base URI:
+// the management console is served from the same host, so a stricter policy
+// would have to be coordinated with the console bundle.
+func SecurityHeaders(c *gin.Context) {
+ // A JSON answer must never be sniffed into HTML or JavaScript.
+ c.Header("X-Content-Type-Options", "nosniff")
+
+ // The console must not be embedded, so that another page cannot click
+ // through it on behalf of the administrator.
+ c.Header("X-Frame-Options", "DENY")
+ c.Header("Content-Security-Policy", "frame-ancestors 'none'; object-src 'none'; base-uri 'none'")
+
+ // Keep the console URL out of the Referer header of third party requests.
+ c.Header("Referrer-Policy", "no-referrer")
+
+ // The console is reached over TLS; a browser that saw this header once will
+ // refuse to fall back to plain HTTP for the following year. includeSubDomains
+ // is deliberately left out: the console shares its host with whatever else
+ // the deployment serves from it, and pinning every subdomain of that name is
+ // not this service's decision to make.
+ c.Header("Strict-Transport-Security", "max-age=31536000")
+
+ c.Next()
+}
diff --git a/management/webserver/model/detectlog.go b/management/webserver/model/detectlog.go
index b12473a6..ef8fafd1 100644
--- a/management/webserver/model/detectlog.go
+++ b/management/webserver/model/detectlog.go
@@ -160,10 +160,13 @@ func InitDetectLogSamples() {
protocolList := []int{constants.ProtocolHTTP, constants.ProtocolHTTPS}
portList := []uint{80, 443}
- provinceList := []string{}
- cityList := []string{}
- ipList := []string{}
- ruleIdList := []string{}
+ // These lists are indexed with randInt%len(...), so they must not be
+ // empty: a zero length makes every one of the lookups below a division by
+ // zero, which panics instead of generating sample data.
+ provinceList := []string{"Beijing", "Shanghai", "Guangdong"}
+ cityList := []string{"Beijing", "Shanghai", "Guangzhou"}
+ ipList := []string{"10.2.35.143", "192.168.1.10", "172.16.0.5"}
+ ruleIdList := []string{"10001", "20002", "30003"}
for i := 0; i < 100; i++ {
randInt := rand.Intn(1000)
diff --git a/management/webserver/model/option.go b/management/webserver/model/option.go
index 538bf5f9..88ee80d7 100644
--- a/management/webserver/model/option.go
+++ b/management/webserver/model/option.go
@@ -4,6 +4,10 @@ import (
"bytes"
"encoding/json"
"net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
"gorm.io/gorm/clause"
@@ -24,14 +28,122 @@ type Options struct {
func initOptions() error {
db := database.GetDB()
- secretKey := Options{Key: constants.SecretKey, Value: utils.RandStr(32)}
+ // The session signing key authorises every management request, so it has
+ // to come from a cryptographically secure source.
+ secretKey := Options{Key: constants.SecretKey, Value: utils.RandHex(32)}
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&secretKey)
+ // The account exists but has never been bound, so the anonymous TFA
+ // bootstrap is open from now on: an installation that is left unbound has
+ // to stop being claimable by whoever reaches the console first, and this
+ // record is what that window is measured from.
+ if err := OpenBootstrapWindowIfMissing(); err != nil {
+ return err
+ }
+
machineId := Options{Key: constants.MachineID, Value: utils.RandStr(32)}
_ = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&machineId)
go NotifyInstallation(machineId.Value)
- return nil
+ return rotatePredictableSecretKey()
+}
+
+// predictableSecretKeyPattern matches the shape of a key produced by the
+// former RandStr: 32 letters out of [a-zA-Z]. RandHex produces 64 hexadecimal
+// characters, so a value matching this pattern can only come from the
+// predictable generator.
+var predictableSecretKeyPattern = regexp.MustCompile(`^[a-zA-Z]{32}$`)
+
+// rotatePredictableSecretKey replaces a session signing key that was generated
+// with the predictable time seeded PRNG.
+//
+// The key is only created when it is missing (OnConflict DoNothing), so an
+// installation that was set up by an older release keeps its weak value
+// forever. Such a key has to be treated as public: it is rotated here, which
+// invalidates the sessions that were signed with it, and the administrator
+// simply logs in again.
+func rotatePredictableSecretKey() error {
+ db := database.GetDB()
+
+ var secretKey Options
+ if err := db.Where(&Options{Key: constants.SecretKey}).First(&secretKey).Error; err != nil {
+ return err
+ }
+
+ if !predictableSecretKeyPattern.MatchString(secretKey.Value) {
+ return nil
+ }
+
+ logger.Warn("The session signing key was generated by a predictable random source, rotating it. Existing sessions are invalidated.")
+ secretKey.Value = utils.RandHex(32)
+
+ return db.Save(&secretKey).Error
+}
+
+// OpenBootstrapWindow records now as the moment the anonymous TFA bootstrap of
+// the account was opened.
+//
+// This is deliberately not tied to the start of the process: a deployment that
+// is restarted, upgraded or power cycled would otherwise open the window again
+// on every boot, and the window is what keeps an installation that nobody ever
+// bound from being claimable forever. Only the setup of an installation and
+// `mgt -reset_user admin` move it.
+func OpenBootstrapWindow() error {
+ return setOption(constants.BootstrapOpenedAt, strconv.FormatInt(time.Now().Unix(), 10))
+}
+
+// BootstrapOpenedAt returns the moment the anonymous TFA bootstrap was opened
+// and whether a record exists.
+func BootstrapOpenedAt() (time.Time, bool) {
+ db := database.GetDB()
+
+ var option Options
+ if err := db.Where(&Options{Key: constants.BootstrapOpenedAt}).First(&option).Error; err != nil {
+ return time.Time{}, false
+ }
+
+ seconds, err := strconv.ParseInt(strings.TrimSpace(option.Value), 10, 64)
+ if err != nil {
+ return time.Time{}, false
+ }
+
+ return time.Unix(seconds, 0), true
+}
+
+// OpenBootstrapWindowIfMissing records the moment the anonymous TFA bootstrap
+// was opened, and leaves the record of an installation that already has one
+// alone. It is called when the daemon starts, which is why a restart does not
+// open the window again.
+//
+// A fresh installation has just created the account, so the window starts now.
+// An installation that is being upgraded keeps the age of its account instead:
+// a console that nobody bound in months must not become claimable again because
+// an upgrade restarted the daemon, and `mgt -reset_user admin` is what opens
+// the window when an operator does want to bind it.
+func OpenBootstrapWindowIfMissing() error {
+ db := database.GetDB()
+
+ openedAt := time.Now()
+
+ var user User
+ if err := db.Where(&User{Username: constants.SuperUser}).First(&user).Error; err == nil && !user.CreatedAt.IsZero() {
+ openedAt = user.CreatedAt
+ }
+
+ opened := Options{Key: constants.BootstrapOpenedAt, Value: strconv.FormatInt(openedAt.Unix(), 10)}
+
+ // A restart must not open the window again, so an existing record wins.
+ return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&opened).Error
+}
+
+// setOption stores value under key, replacing what is there.
+func setOption(key, value string) error {
+ db := database.GetDB()
+
+ return db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "key"}},
+ DoUpdates: clause.AssignmentColumns([]string{"value"}),
+ }).Create(&Options{Key: key, Value: value}).Error
}
func NotifyInstallation(machineId string) {
@@ -58,6 +170,9 @@ func NotifyInstallation(machineId string) {
logger.Error(err)
return
}
+ // The caller owns the response returned by DoPostTelemetry and has to
+ // release it, otherwise every report leaks a connection.
+ defer rsp.Body.Close()
if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusCreated {
logger.Errorf("transfer telemetry %s failed, status code = %d", constants.Installation, rsp.StatusCode)
diff --git a/management/webserver/model/policygroup.go b/management/webserver/model/policygroup.go
index ba6cce8e..636170cf 100644
--- a/management/webserver/model/policygroup.go
+++ b/management/webserver/model/policygroup.go
@@ -2,6 +2,7 @@ package model
import (
"encoding/json"
+ "fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -79,9 +80,54 @@ func initSrcIPConfig() error {
return nil
}
+// skynetConfigStr is the skynet configuration of 5.3.9. It is also the
+// source of truth for the module names the engine knows about: the keys of a
+// policy group are module names that end up in this document.
+const skynetConfigStr = "{\"decode_config\":{\"decode_methods\":[\"url decode\",\"JSON\",\"base64\",\"hex\",\"eval\",\"XML\",\"PHP deserialize\",\"utf7\"]},\"deep_detect\":false,\"modules\":{\"m_asp_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_cmd_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_csrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_include\":{\"detect_config\":{\"detect_suspicious_schema\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_upload\":{\"detect_config\":{\"action_of_handling_custome_file\":[\"deny transmission\"],\"detect_file_content\":true,\"detect_real_file_content\":false,\"enable_module_in_detecting_file_content\":[\"java\",\"asp_code_injection\",\"php_code_injection\"],\"file_type_of_custome_action\":[],\"forbidden_extra_token\":false,\"forbidden_multiple_filename\":true,\"forbidden_suspicious_filename\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_http\":{\"detect_config\":{\"warning_http_parse_failed\":false,\"warning_suspicious_http_version\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_java\":{\"detect_config\":{\"detect_lookup\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_java_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_response\":{\"detect_config\":{\"detection_configure\":[\"detect_jsp_code_leak\",\"detect_php_code_leak\",\"detect_webshell\"],\"error_types\":[\"directory indexing\",\"SQL execution error\",\"server exception\"]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_rule\":{\"detect_config\":{\"check_info_leak_by_rsp\":false,\"compatibility_mode\":false,\"detection_configure\":[\"detect_phpinfo\",\"detect_admin_page\",\"detect_backdoor\",\"detect_php_vuln\",\"detect_nginx\",\"detect_dedecms\",\"detect_apache\",\"detect_xml\",\"detect_struts2\",\"detect_java_vuln\",\"detect_php168\",\"detect_wordpress\",\"detect_directory_traversal\",\"detect_iis\",\"detect_gogs_gitea\",\"detect_thinkphp\",\"detect_jenkins\",\"detect_ecshop\",\"detect_nexus\",\"detect_drupal\",\"detect_ghostscript\",\"detect_atlassian\",\"detect_weblogic\",\"detect_coremail\",\"detect_phpcms\",\"detect_spring\",\"detect_southidc\",\"detect_fastjson\",\"detect_tbk_dvr\",\"detect_joomla\",\"detect_ecology_oa\",\"detect_jackson\",\"detect_xstream\",\"detect_activemq\",\"detect_solr\",\"detect_csii\",\"detect_big_ip\",\"detect_apisix\",\"detect_druid\",\"detect_log4j\"],\"info_leak_types\":[\"test file\",\"backup file\",\"code repository\",\"server sensitive file\"],\"rules_config\":{\"disable_ruleid\":[],\"enable_ruleid\":[],\"rules_status\":[]}},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_scanner\":{\"detect_config\":{\"language_types\":[\"python_scanner\",\"go_scanner\"],\"other_types\":[\"normal_scanner\"],\"spider_types\":[]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_sqli\":{\"detect_config\":{\"detect_non_injection_sql\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssti\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_xss\":{\"detect_config\":{\"detect_complete_html\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"}},\"timeout\":{\"threshold\":1000,\"log\":\"enabled\"}}"
+
+// SkynetModules returns the set of module names the engine knows about.
+func SkynetModules() (map[string]struct{}, error) {
+ var sc SkynetConfig
+ if err := json.Unmarshal([]byte(skynetConfigStr), &sc); err != nil {
+ return nil, err
+ }
+
+ modules := make(map[string]struct{}, len(sc.Modules))
+ for module := range sc.Modules {
+ modules[module] = struct{}{}
+ }
+
+ return modules, nil
+}
+
+// ValidatePolicyGroup checks that a policy group only names modules and modes
+// that the engine understands.
+//
+// The keys of the map are written into the skynet configuration, so an unknown
+// module would either be handed to an engine that has never heard of it or make
+// the rendering step below dereference a nil module entry.
+func ValidatePolicyGroup(pgg PolicyGroup) error {
+ modules, err := SkynetModules()
+ if err != nil {
+ return err
+ }
+
+ for module, mode := range pgg {
+ if _, ok := modules[module]; !ok {
+ return fmt.Errorf("unknown module %q", module)
+ }
+
+ switch mode {
+ case StrictMode, DefaultMode, DisableMode:
+ default:
+ return fmt.Errorf("module %q: unknown mode %q", module, mode)
+ }
+ }
+
+ return nil
+}
+
func GetSkynetConfig(db *gorm.DB) (string, error) {
- // skynetConfigStr skynet config in 5.3.9
- const skynetConfigStr = "{\"decode_config\":{\"decode_methods\":[\"url decode\",\"JSON\",\"base64\",\"hex\",\"eval\",\"XML\",\"PHP deserialize\",\"utf7\"]},\"deep_detect\":false,\"modules\":{\"m_asp_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_cmd_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_csrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_include\":{\"detect_config\":{\"detect_suspicious_schema\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_file_upload\":{\"detect_config\":{\"action_of_handling_custome_file\":[\"deny transmission\"],\"detect_file_content\":true,\"detect_real_file_content\":false,\"enable_module_in_detecting_file_content\":[\"java\",\"asp_code_injection\",\"php_code_injection\"],\"file_type_of_custome_action\":[],\"forbidden_extra_token\":false,\"forbidden_multiple_filename\":true,\"forbidden_suspicious_filename\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_http\":{\"detect_config\":{\"warning_http_parse_failed\":false,\"warning_suspicious_http_version\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_java\":{\"detect_config\":{\"detect_lookup\":false},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_java_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_code_injection\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_php_unserialize\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_response\":{\"detect_config\":{\"detection_configure\":[\"detect_jsp_code_leak\",\"detect_php_code_leak\",\"detect_webshell\"],\"error_types\":[\"directory indexing\",\"SQL execution error\",\"server exception\"]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_rule\":{\"detect_config\":{\"check_info_leak_by_rsp\":false,\"compatibility_mode\":false,\"detection_configure\":[\"detect_phpinfo\",\"detect_admin_page\",\"detect_backdoor\",\"detect_php_vuln\",\"detect_nginx\",\"detect_dedecms\",\"detect_apache\",\"detect_xml\",\"detect_struts2\",\"detect_java_vuln\",\"detect_php168\",\"detect_wordpress\",\"detect_directory_traversal\",\"detect_iis\",\"detect_gogs_gitea\",\"detect_thinkphp\",\"detect_jenkins\",\"detect_ecshop\",\"detect_nexus\",\"detect_drupal\",\"detect_ghostscript\",\"detect_atlassian\",\"detect_weblogic\",\"detect_coremail\",\"detect_phpcms\",\"detect_spring\",\"detect_southidc\",\"detect_fastjson\",\"detect_tbk_dvr\",\"detect_joomla\",\"detect_ecology_oa\",\"detect_jackson\",\"detect_xstream\",\"detect_activemq\",\"detect_solr\",\"detect_csii\",\"detect_big_ip\",\"detect_apisix\",\"detect_druid\",\"detect_log4j\"],\"info_leak_types\":[\"test file\",\"backup file\",\"code repository\",\"server sensitive file\"],\"rules_config\":{\"disable_ruleid\":[],\"enable_ruleid\":[],\"rules_status\":[]}},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_scanner\":{\"detect_config\":{\"language_types\":[\"python_scanner\",\"go_scanner\"],\"other_types\":[\"normal_scanner\"],\"spider_types\":[]},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"disabled\"},\"m_sqli\":{\"detect_config\":{\"detect_non_injection_sql\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssrf\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_ssti\":{\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"},\"m_xss\":{\"detect_config\":{\"detect_complete_html\":true},\"high_risk_action\":\"deny\",\"high_risk_enable_log\":1,\"low_risk_action\":\"continue\",\"low_risk_enable_log\":1,\"medium_risk_action\":\"continue\",\"medium_risk_enable_log\":1,\"state\":\"enabled\"}},\"timeout\":{\"threshold\":1000,\"log\":\"enabled\"}}"
var sc SkynetConfig
err := json.Unmarshal([]byte(skynetConfigStr), &sc)
if err != nil {
@@ -98,28 +144,30 @@ func GetSkynetConfig(db *gorm.DB) (string, error) {
}
for module, mode := range pgg {
+ // The module names come from a request body, so a key that the engine
+ // does not know about must be rejected instead of being dereferenced
+ // (sc.Modules[module] is a nil pointer for an unknown module).
+ scm, ok := sc.Modules[module]
+ if !ok || scm == nil {
+ return "", fmt.Errorf("unknown module %q", module)
+ }
+
switch mode {
case StrictMode:
- scm := sc.Modules[module]
scm.HighRiskAction = "deny"
scm.MediumRiskAction = "deny"
scm.LowRiskAction = "deny"
scm.State = "enabled"
- sc.Modules[module] = scm
case DefaultMode:
- scm := sc.Modules[module]
scm.HighRiskAction = "deny"
scm.MediumRiskAction = "continue"
scm.LowRiskAction = "continue"
scm.State = "enabled"
- sc.Modules[module] = scm
case DisableMode:
- scm := sc.Modules[module]
scm.HighRiskAction = "continue"
scm.MediumRiskAction = "continue"
scm.LowRiskAction = "continue"
scm.State = "disabled"
- sc.Modules[module] = scm
default:
return "", errors.New("no such mode")
}
diff --git a/management/webserver/model/user.go b/management/webserver/model/user.go
index eef58e73..dc1dd57e 100644
--- a/management/webserver/model/user.go
+++ b/management/webserver/model/user.go
@@ -16,7 +16,21 @@ type User struct {
TFAEnabled bool `gorm:"column:tfa_enabled;default:true"`
TFASecret string `gorm:"column:tfa_secret"`
LastLoginTime int64 `gorm:"default:0"`
- IsEnabled bool `gorm:"default:true"`
+
+ // TFALastUsedStep is the time step (unix time divided by the TOTP period)
+ // of the passcode that was accepted last. A passcode is only accepted when
+ // it belongs to a later step, so a captured passcode cannot be replayed
+ // while it is still inside the validity window.
+ TFALastUsedStep int64 `gorm:"column:tfa_last_used_step;default:0"`
+
+ // TFABootstrapIP is the client address the pending TFA secret was handed to
+ // before the account ever logged in. The secret is only served again to
+ // that address, and the first login has to come from it, so a caller that
+ // reaches the API before the administrator cannot bind the account to a
+ // secret it knows.
+ TFABootstrapIP string `gorm:"column:tfa_bootstrap_ip"`
+
+ IsEnabled bool `gorm:"default:true"`
}
func initAdminUser() error {
diff --git a/management/webserver/model/website.go b/management/webserver/model/website.go
index 4eeb7bbf..af530e7d 100644
--- a/management/webserver/model/website.go
+++ b/management/webserver/model/website.go
@@ -1,6 +1,57 @@
package model
-import "gorm.io/datatypes"
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gorm.io/datatypes"
+)
+
+const (
+ // maxPort is the largest TCP port that may be listened on.
+ maxPort = 65535
+
+ // maxServerNameLength and maxLabelLength follow the DNS limits.
+ maxServerNameLength = 253
+ maxLabelLength = 63
+
+ // defaultHttpsPort is the port tcontrollerd appends to an upstream that
+ // names https without a port.
+ defaultHttpsPort = "443"
+)
+
+var (
+ // portPattern matches the decimal notation of a TCP port.
+ portPattern = regexp.MustCompile(`^[0-9]{1,5}$`)
+
+ // serverNameLabelPattern matches one label of a literal host name. The
+ // accepted character set is deliberately narrow: the value ends up in an
+ // nginx "server_name" directive, so anything that could terminate the
+ // directive (or start another one) must not pass.
+ serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`)
+
+ // certFilenamePattern matches the file name of an uploaded certificate or
+ // key. The name is interpolated into "ssl_certificate" and
+ // "ssl_certificate_key", and it is joined to the certificate directory, so
+ // only a plain file name without a path separator, a control character or
+ // any nginx syntax may pass.
+ certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
+
+ // upstreamSchemePattern matches the two schemes an upstream may use. The
+ // scheme is interpolated into "proxy_pass", so anything else is rejected.
+ upstreamSchemePattern = regexp.MustCompile(`^https?$`)
+
+ // upstreamHostPattern matches the host of an upstream: a host name, an IPv4
+ // address or a bracketed IPv6 address, with an optional port. The host is
+ // interpolated into the "server" directive of the site, so a value carrying
+ // nginx syntax (a space, a semicolon, a "$", a quote) must not pass.
+ upstreamHostPattern = regexp.MustCompile(`^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\.?)(?::([0-9]{1,5}))?$`)
+)
type Website struct {
Base
@@ -14,3 +65,179 @@ type Website struct {
IsEnabled bool `gorm:"is_enabled;default=true" json:"is_enabled"`
}
+
+// Validate checks the fields that tcontrollerd renders into the nginx
+// configuration of the site.
+//
+// Ports and server names are interpolated into nginx directives, so they have
+// to be plain ports and host names: a value carrying nginx syntax must be
+// rejected here, before it is stored and pushed to the engine.
+func (w *Website) Validate() error {
+ serverNames, err := unmarshalStringList(w.ServerNames)
+ if err != nil {
+ return fmt.Errorf("invalid server_names: %v", err)
+ }
+
+ ports, err := unmarshalStringList(w.Ports)
+ if err != nil {
+ return fmt.Errorf("invalid ports: %v", err)
+ }
+
+ for _, serverName := range serverNames {
+ if err = ValidateServerName(serverName); err != nil {
+ return err
+ }
+ }
+
+ for _, port := range ports {
+ if err = ValidatePort(port); err != nil {
+ return err
+ }
+ }
+
+ upstreams, err := unmarshalStringList(w.Upstreams)
+ if err != nil {
+ return fmt.Errorf("invalid upstreams: %v", err)
+ }
+
+ for _, upstream := range upstreams {
+ if err = ValidateUpstream(upstream); err != nil {
+ return err
+ }
+ }
+
+ if err = ValidateCertFilename(w.CertFilename); err != nil {
+ return err
+ }
+
+ if err = ValidateCertFilename(w.KeyFilename); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// ValidateUpstream accepts the address of a site upstream.
+//
+// The scheme and the host of this value are interpolated into the "proxy_pass"
+// and "server" directives of the site, so a value carrying nginx syntax must be
+// rejected here, before it is stored and pushed to the engine.
+func ValidateUpstream(upstream string) error {
+ parsed, err := url.Parse(upstream)
+ if err != nil {
+ return fmt.Errorf("invalid upstream %q: %v", upstream, err)
+ }
+
+ if !upstreamSchemePattern.MatchString(parsed.Scheme) {
+ return fmt.Errorf("invalid upstream %q: the scheme has to be http or https", upstream)
+ }
+
+ host := parsed.Host
+ if parsed.Scheme == "https" && parsed.Port() == "" {
+ // tcontrollerd appends the default HTTPS port to this value before it
+ // renders it, so validate the value it renders.
+ host = host + ":" + defaultHttpsPort
+ }
+
+ matches := upstreamHostPattern.FindStringSubmatch(host)
+ if matches == nil {
+ return fmt.Errorf("invalid upstream %q: %q is not a host name, an IP address or a host with a port", upstream, host)
+ }
+
+ if port := matches[1]; port != "" {
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > maxPort {
+ return fmt.Errorf("invalid upstream %q: port has to be between 1 and %d", upstream, maxPort)
+ }
+ }
+
+ return nil
+}
+
+// maxCertFilenameLength bounds a certificate file name.
+const maxCertFilenameLength = 255
+
+// ValidateCertFilename accepts the name of a file in the certificate directory.
+//
+// The console stores uploaded certificates under a generated name, so a value
+// that is not a plain file name is either a mistake or an attempt to inject
+// nginx directives (through the rendered ssl_certificate directives) or to
+// address a file outside the certificate directory.
+func ValidateCertFilename(name string) error {
+ // An empty name means "this site is served over plain HTTP".
+ if name == "" {
+ return nil
+ }
+
+ if len(name) > maxCertFilenameLength {
+ return fmt.Errorf("invalid certificate file name %q: longer than %d characters", name, maxCertFilenameLength)
+ }
+
+ if name != filepath.Base(name) || name == "." || name == ".." {
+ return fmt.Errorf("invalid certificate file name %q: only a file name without a directory is allowed", name)
+ }
+
+ if !certFilenamePattern.MatchString(name) {
+ return fmt.Errorf("invalid certificate file name %q: only letters, digits, dot, dash and underscore are allowed", name)
+ }
+
+ return nil
+}
+
+// ValidatePort accepts the decimal notation of a TCP port.
+func ValidatePort(port string) error {
+ if !portPattern.MatchString(port) {
+ return fmt.Errorf("invalid port %q: only digits are allowed", port)
+ }
+
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > maxPort {
+ return fmt.Errorf("invalid port %q: must be between 1 and %d", port, maxPort)
+ }
+
+ return nil
+}
+
+// ValidateServerName accepts a literal host name, optionally with a wildcard
+// as its first label, or "*" for the default site.
+func ValidateServerName(name string) error {
+ // "*" (and the empty string, which the console sends for the same purpose)
+ // makes the site the default server and is rendered as "_".
+ if name == "*" || name == "" {
+ return nil
+ }
+
+ if len(name) > maxServerNameLength {
+ return fmt.Errorf("invalid server name %q: longer than %d characters", name, maxServerNameLength)
+ }
+
+ for i, label := range strings.Split(name, ".") {
+ if label == "*" {
+ // nginx only accepts a wildcard as the left most label.
+ if i != 0 {
+ return fmt.Errorf("invalid server name %q: wildcard must be the first label", name)
+ }
+ continue
+ }
+
+ if len(label) > maxLabelLength || !serverNameLabelPattern.MatchString(label) {
+ return fmt.Errorf("invalid server name %q: %q is not a valid host name label", name, label)
+ }
+ }
+
+ return nil
+}
+
+// unmarshalStringList decodes one of the JSON list columns of a website.
+func unmarshalStringList(raw datatypes.JSON) ([]string, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+
+ var values []string
+ if err := json.Unmarshal(raw, &values); err != nil {
+ return nil, err
+ }
+
+ return values, nil
+}
diff --git a/management/webserver/pkg/constants/option.go b/management/webserver/pkg/constants/option.go
index e959897c..876d9bdf 100644
--- a/management/webserver/pkg/constants/option.go
+++ b/management/webserver/pkg/constants/option.go
@@ -5,4 +5,11 @@ const (
MachineID = "machine_id"
PolicyGroupGlobal = "policy_group_global"
SrcIPConfig = "src_ip_config"
+
+ // BootstrapOpenedAt records when the anonymous TFA bootstrap of the
+ // account was opened, which is what bounds how long a caller may claim the
+ // TFA secret without a credential (see api.GetOTPUrl). It is written when
+ // the installation is set up and by `mgt -reset_user`, and never by a
+ // restart.
+ BootstrapOpenedAt = "tfa_bootstrap_opened_at"
)
diff --git a/management/webserver/pkg/cron/update_policy.go b/management/webserver/pkg/cron/update_policy.go
index ab5b6f86..7de3c447 100644
--- a/management/webserver/pkg/cron/update_policy.go
+++ b/management/webserver/pkg/cron/update_policy.go
@@ -71,10 +71,19 @@ func CheckAndUpdatePolicy() {
statRspData := statResponseBody{}
statRsp, err := client.Do(statReq)
- if err != nil || statRsp.StatusCode != http.StatusOK {
+ if err != nil {
logger.Warn(err)
return
}
+ // The version check below returns early on every branch that is not "an
+ // update is needed", so the body has to be released on the way out instead
+ // of only on the path that continues.
+ defer statRsp.Body.Close()
+
+ if statRsp.StatusCode != http.StatusOK {
+ logger.Warnf("%s get policy version, return %d", addr, statRsp.StatusCode)
+ return
+ }
body, err := ioutil.ReadAll(statRsp.Body)
if err != nil {
@@ -92,23 +101,15 @@ func CheckAndUpdatePolicy() {
return
}
- err = statRsp.Body.Close()
- if err != nil {
- logger.Warn(err)
- }
-
logger.Info("Update fsl bytecode")
updateRsp, err := client.Do(updateReq)
if err != nil {
logger.Warn(err)
return
}
+ defer updateRsp.Body.Close()
if updateRsp.StatusCode != http.StatusOK {
logger.Warnf("%s update policy, return %d", addr, updateRsp.StatusCode)
}
- err = updateRsp.Body.Close()
- if err != nil {
- logger.Warn(err)
- }
}
diff --git a/management/webserver/pkg/fvm/fsl/quote.go b/management/webserver/pkg/fvm/fsl/quote.go
index fdfea494..d4ed9079 100644
--- a/management/webserver/pkg/fvm/fsl/quote.go
+++ b/management/webserver/pkg/fvm/fsl/quote.go
@@ -28,7 +28,11 @@ func appendEscapedByte(buf []byte, b byte) []byte {
case '\\':
return append(buf, `\\`...)
default:
- return append(buf, fmt.Sprintf("\\x%x", b)...)
+ // The escape has to be two digits wide: "\x5" followed by a literal
+ // "a" is read by the lexer as "\x5a" ("Z"), so a rule whose comment or
+ // header value ends with a control byte would silently change the
+ // source that is handed to the compiler.
+ return append(buf, fmt.Sprintf("\\x%02x", b)...)
}
}
diff --git a/management/webserver/pkg/fvm/fvm.go b/management/webserver/pkg/fvm/fvm.go
index 32a34390..b9dcf7ef 100644
--- a/management/webserver/pkg/fvm/fvm.go
+++ b/management/webserver/pkg/fvm/fvm.go
@@ -19,10 +19,6 @@ import (
"C" //nolint:typecheck
)
import (
- "crypto/tls"
- "fmt"
- "io"
- "net/http"
"net/url"
"unsafe"
@@ -60,11 +56,24 @@ func maybePointer(array []byte) unsafe.Pointer {
}
func (u *FVMUpdate) ToBytes() []byte {
+ if u == nil || u.update == nil {
+ return nil
+ }
return C.GoBytes(unsafe.Pointer(u.update.buf.ptr), C.int(u.update.buf.length))
}
+// FromBytes builds an update from the serialized byte code, as it is stored in
+// the database.
+//
+// An empty buffer is rejected before the address of its first byte is taken:
+// &out[0] panics on a zero length slice, and fvm_update_create would have to
+// read the buffer that the length describes.
func (u *FVMUpdate) FromBytes(out []byte) error {
- ptr := C.build_update(unsafe.Pointer(&out[0]), C.ulong(len(out)))
+ if len(out) == 0 {
+ return errors.New("refuse to create an update from an empty buffer")
+ }
+
+ ptr := C.build_update(maybePointer(out), C.ulong(len(out)))
if ptr == nil {
return errors.New("failed to create update from db")
}
@@ -83,7 +92,11 @@ func (u *FVMUpdate) MergeUpdate(patch *FVMUpdate) error {
}
func (u *FVMUpdate) Release() {
+ if u == nil || u.update == nil {
+ return
+ }
C.fvm_update_destroy(u.update)
+ u.update = nil
}
func (r *FVMRe) ToBytes() []byte {
@@ -183,85 +196,28 @@ func ReleaseOutput(out *FVMOutput) {
}
func (f *FVM) PushFsl(server string, update *FVMUpdate) error {
+ if update == nil || update.update == nil {
+ return errors.New("refuse to push a nil policy update")
+ }
log.Infof("Push FSL to %s", server)
var length = uint32(update.update.buf.length)
- var p = uintptr(unsafe.Pointer(update.update.buf.ptr))
+ ptr := unsafe.Pointer(update.update.buf.ptr)
log.Infof("Get update length %d, %d", length, C.int(update.update.buf.length))
- u := &upload{
- Length: length,
- P: p,
- }
+ // Copy the compiled policy out of the C buffer. The C memory can be
+ // released while the request is in flight, and the Go side needs a value
+ // it can replay for every attempt.
+ policy := C.GoBytes(ptr, C.int(length))
base, _ := url.Parse(server)
base.Path = "/update/policy"
snURL := base.String()
- req, err := http.NewRequest(
- "POST",
- snURL,
- u,
- )
- if err != nil {
- return errors.Annotatef(err, "create request failed")
- }
- req.Header.Add("Content-Type", "application/octet-stream")
- // req.Header.Set("Connection", "close")
- httpClient := &http.Client{
- Transport: &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
- },
- }
-
- var (
- respErr error
- resp *http.Response
- )
- for i := 0; i < 3; i++ {
- resp, respErr = httpClient.Do(req)
- if respErr == nil {
- break
- }
- }
- if respErr != nil {
- return errors.Annotatef(respErr, "Get response failed")
- }
- if resp != nil && resp.StatusCode != 200 {
- bodyBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- log.Fatal(err)
- }
- bodyString := string(bodyBytes)
-
- return errors.New(fmt.Sprintf("Push FSL response(%d %s)", resp.StatusCode, bodyString))
- }
-
- return nil
-}
-
-type upload struct {
- Length uint32
- off uint32
- P uintptr
-}
-
-func (u *upload) Read(p []byte) (n int, err error) {
- if u.off >= u.Length {
- if len(p) == 0 {
- return 0, nil
- }
- return 0, io.EOF
- }
- plen := uint32(len(p))
- var i uint32
- for i = 0; i < plen; i++ {
- if i+u.off >= u.Length {
- break
- }
- p[i] = *(*byte)(unsafe.Pointer(u.P + unsafe.Sizeof(p[0])*uintptr(i+u.off)))
- }
- u.off = i + u.off
- return int(i), nil
+ // The compiled policy is pushed to the detector over the container network
+ // and replaces the bytecode the engine executes, so the certificate of an
+ // HTTPS detector must be verified: a man in the middle could otherwise
+ // replace the policy and silently disable the WAF.
+ return pushPolicy(snURL, policy)
}
diff --git a/management/webserver/pkg/fvm/generator.go b/management/webserver/pkg/fvm/generator.go
index e638333f..8f946fd3 100644
--- a/management/webserver/pkg/fvm/generator.go
+++ b/management/webserver/pkg/fvm/generator.go
@@ -205,6 +205,13 @@ func PolicyRuleTable(db *gorm.DB) (prFSL string, err error) {
}
}
+ // An empty pattern list would compile to a selector with no WHERE, which
+ // matches every request. Skip it instead of emitting an unconditional
+ // DROP or ACCEPT.
+ if len(wheres) == 0 {
+ continue
+ }
+
// save log only when deny and dry_run
logOption := "1"
attackType := "-3"
diff --git a/management/webserver/pkg/fvm/helper.go b/management/webserver/pkg/fvm/helper.go
index 9aba93bc..e3f6e9c0 100644
--- a/management/webserver/pkg/fvm/helper.go
+++ b/management/webserver/pkg/fvm/helper.go
@@ -24,10 +24,15 @@ func CompileAndSave(text string) error {
defer ReleaseOutput(output)
update := Serialize(output, false, 0, 1)
+ if update == nil {
+ return errors.New("failed to serialize rules")
+ }
defer update.Release()
- // save to file
- if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0666); err != nil {
+ // save to file. The bytecode is not secret, but nothing but the management
+ // process has a reason to write it, so it is not created group or world
+ // writable either.
+ if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0644); err != nil {
return errors.Annotate(err, "failed to save bytecode")
}
@@ -42,10 +47,15 @@ func CompileAndPush(text, serverAddr string) error {
defer ReleaseOutput(output)
update := Serialize(output, false, 0, 1)
+ if update == nil {
+ return errors.New("failed to serialize rules")
+ }
defer update.Release()
- // save to file
- if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0666); err != nil {
+ // save to file. The bytecode is not secret, but nothing but the management
+ // process has a reason to write it, so it is not created group or world
+ // writable either.
+ if err = utils.EnsureWriteFile(config.GlobalConfig.Detector.FslBytecode, update.ToBytes(), 0644); err != nil {
return errors.Annotate(err, "failed to save bytecode")
}
diff --git a/management/webserver/pkg/fvm/push.go b/management/webserver/pkg/fvm/push.go
new file mode 100644
index 00000000..0f381021
--- /dev/null
+++ b/management/webserver/pkg/fvm/push.go
@@ -0,0 +1,63 @@
+package fvm
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+
+ "chaitin.cn/dev/go/errors"
+ "chaitin.cn/dev/go/log"
+)
+
+const (
+ // pushPolicyAttempts is how often a failed push is retried.
+ pushPolicyAttempts = 3
+
+ // maxPushPolicyResponseBody bounds the part of an error response that is
+ // kept for the message.
+ maxPushPolicyResponseBody = 4 << 10
+)
+
+// pushPolicy sends a compiled policy to the detector.
+//
+// The body of an http.Request is a stream that can be read once, so a retry
+// that reuses a request sends an empty body. The detector accepts an empty
+// update with 200, which would leave the previous policy in place while the
+// caller believes the new one was applied: every attempt therefore builds its
+// own request around the same bytes.
+func pushPolicy(snURL string, policy []byte) error {
+ client := &http.Client{}
+
+ var lastErr error
+ for attempt := 0; attempt < pushPolicyAttempts; attempt++ {
+ req, err := http.NewRequest(http.MethodPost, snURL, bytes.NewReader(policy))
+ if err != nil {
+ return errors.Annotatef(err, "create request failed")
+ }
+ req.Header.Add("Content-Type", "application/octet-stream")
+ req.ContentLength = int64(len(policy))
+
+ resp, err := client.Do(req)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+
+ body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxPushPolicyResponseBody))
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Warnf("Close push FSL response failed: %s", closeErr)
+ }
+ if readErr != nil {
+ log.Warnf("Read push FSL response failed: %s", readErr)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return errors.New(fmt.Sprintf("Push FSL response(%d %s)", resp.StatusCode, string(body)))
+ }
+
+ return nil
+ }
+
+ return errors.Annotatef(lastErr, "Get response failed")
+}
diff --git a/management/webserver/pkg/log/log.go b/management/webserver/pkg/log/log.go
index 3af9a18b..b6e3ad70 100644
--- a/management/webserver/pkg/log/log.go
+++ b/management/webserver/pkg/log/log.go
@@ -5,6 +5,7 @@ import (
"os"
"runtime"
"strings"
+ "syscall"
"github.com/sirupsen/logrus"
@@ -43,7 +44,9 @@ func InitLogger() error {
return err
}
- fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC
+ // O_NOFOLLOW closes the window between Stat and OpenFile: a symlink swapped
+ // in for the log path is refused instead of followed.
+ fileFlag := os.O_WRONLY | os.O_APPEND | os.O_SYNC | syscall.O_NOFOLLOW
if !exist {
if err := utils.EnsureFileDir(config.GlobalConfig.Log.Output); err != nil {
return err
@@ -51,11 +54,17 @@ func InitLogger() error {
fileFlag = fileFlag | os.O_CREATE
}
- if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, os.ModePerm); err != nil {
+ // os.ModePerm (0777) let every local user rewrite the audit log. The
+ // file is created with an owner/group readable mode instead.
+ if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, logFileMode); err != nil {
return fmt.Errorf("failed to open log file: %s", err.Error())
} else {
log.SetOutput(log.AllLoggers, log.NewLockOutput(fp))
}
+
+ // The mode above only applies when the file is created, so a log file
+ // that an older release left world writable is tightened here.
+ tightenLogFileMode(config.GlobalConfig.Log.Output)
}
// hook
@@ -67,6 +76,28 @@ func InitLogger() error {
return nil
}
+// logFileMode is the permission the log file is created with. Logs are audit
+// material: they must not be writable by other local users.
+const logFileMode os.FileMode = 0640
+
+// looseLogFileMask matches the group and world write bits.
+const looseLogFileMask os.FileMode = 0o022
+
+// tightenLogFileMode removes the group and world write bits from an existing
+// log file, so that a log file created by an older release cannot stay
+// tamperable by other local users.
+func tightenLogFileMode(path string) {
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm()&looseLogFileMask == 0 {
+ return
+ }
+
+ log.Warnf("Log file %s is writable by other users, setting mode to %#o", path, logFileMode)
+ if err := os.Chmod(path, logFileMode); err != nil {
+ log.Warnf("Cannot tighten the mode of log file %s: %s", path, err)
+ }
+}
+
type RuntimeHook struct{}
func (h *RuntimeHook) Levels() []logrus.Level {
diff --git a/management/webserver/pkg/sessionopts/sessionopts.go b/management/webserver/pkg/sessionopts/sessionopts.go
new file mode 100644
index 00000000..24f74f20
--- /dev/null
+++ b/management/webserver/pkg/sessionopts/sessionopts.go
@@ -0,0 +1,64 @@
+// Package sessionopts holds the cookie attributes of the management console
+// session. The login handler and the authentication middleware share it so the
+// flags cannot drift apart from each other.
+package sessionopts
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+
+ "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config"
+)
+
+// maxAge is the lifetime of a management console session: one week.
+const maxAge = 3600 * 24 * 7
+
+// Options returns the cookie attributes used for the management console
+// session.
+//
+// HttpOnly keeps the session cookie out of reach of JavaScript, SameSite=Lax
+// prevents browsers from attaching the cookie to cross-site POST/PUT (which
+// would otherwise turn the state-changing API into CSRF targets), and Secure
+// keeps the cookie on HTTPS outside of a development setup.
+//
+// There is no separate CSRF header by design: the console is a cookie-session
+// SPA, and Lax already drops the cookie on cross-site mutating requests.
+// Adding a token would require a frontend change; do not treat the missing
+// middleware as an accidental gap.
+func Options(c *gin.Context) sessions.Options {
+ return sessions.Options{
+ Path: "/",
+ MaxAge: maxAge,
+ HttpOnly: true,
+ Secure: secureCookie(c),
+ SameSite: http.SameSiteLaxMode,
+ }
+}
+
+// secureCookie reports whether the session cookie may be restricted to HTTPS.
+//
+// The console is only reachable over TLS in production, but the webserver
+// itself sits behind a TLS terminating nginx, so a plain HTTP request in front
+// of it can still be a legitimate request of an HTTPS session.
+func secureCookie(c *gin.Context) bool {
+ if !config.GlobalConfig.Server.DevMode {
+ return true
+ }
+
+ if c.Request.TLS != nil {
+ return true
+ }
+
+ // Honour the scheme reported by a TLS terminating proxy, which is what the
+ // development setup is expected to use when it wants to exercise the
+ // production cookie attributes.
+ proto := c.Request.Header.Get("X-Forwarded-Proto")
+ if i := strings.IndexByte(proto, ','); i >= 0 {
+ proto = proto[:i]
+ }
+
+ return strings.EqualFold(strings.TrimSpace(proto), "https")
+}
diff --git a/management/webserver/rpc/auth.go b/management/webserver/rpc/auth.go
new file mode 100644
index 00000000..fc7163a1
--- /dev/null
+++ b/management/webserver/rpc/auth.go
@@ -0,0 +1,176 @@
+package rpc
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/peer"
+ "google.golang.org/grpc/status"
+)
+
+const (
+ // TokenEnv holds the shared secret of the control channel between
+ // tcontrollerd and this server.
+ TokenEnv = "MGT_GRPC_TOKEN"
+
+ // TokenFileEnv names the file the secret is read from when TokenEnv is
+ // empty.
+ TokenFileEnv = "MGT_GRPC_TOKEN_FILE"
+
+ // DefaultTokenFile is where the secret is read from (and created) by
+ // default. The standard deployment mounts the same host directory at
+ // /app/sock in the management container and in the tengine container,
+ // which is where tcontrollerd runs, so both sides find the same value
+ // without any configuration.
+ DefaultTokenFile = "/app/sock/mgt_grpc_token"
+
+ // tokenMetadataKey is the gRPC metadata key that carries the secret. gRPC
+ // metadata keys have to be lower case.
+ tokenMetadataKey = "mgt-token"
+
+ // tokenBytes is the size of a generated secret.
+ tokenBytes = 32
+)
+
+// tokenFileMode keeps the secret readable for the owner only.
+const tokenFileMode os.FileMode = 0600
+
+// controlToken is the secret that Subscribe requires. It is empty when the
+// server could not obtain one, in which case every subscription is refused.
+var controlToken string
+
+// InitControlToken loads the token of the control channel, creating it when it
+// does not exist yet.
+//
+// A failure here does not stop the server: it disables the control channel,
+// which is reported by Publish as an error, and leaves the console usable so
+// that an operator can fix the deployment.
+func InitControlToken() error {
+ if token := strings.TrimSpace(os.Getenv(TokenEnv)); token != "" {
+ controlToken = token
+ return nil
+ }
+
+ token, err := loadOrCreateToken(TokenFileFromEnv())
+ if err != nil {
+ return err
+ }
+
+ controlToken = token
+ return nil
+}
+
+// TokenFileFromEnv returns the path of the token file.
+func TokenFileFromEnv() string {
+ if path := strings.TrimSpace(os.Getenv(TokenFileEnv)); path != "" {
+ return path
+ }
+
+ return DefaultTokenFile
+}
+
+// loadOrCreateToken reads the control channel token from path, creating it with
+// a fresh random value when the file does not exist yet.
+func loadOrCreateToken(path string) (string, error) {
+ if token := readToken(path); token != "" {
+ return token, nil
+ }
+
+ token := make([]byte, tokenBytes)
+ if _, err := rand.Read(token); err != nil {
+ return "", err
+ }
+ value := hex.EncodeToString(token)
+
+ // 0750: the directory holds the secret of the control channel, so it is not
+ // left traversable by other users. It is a shared mount point whose mode is
+ // normally set by the deployment, so this only applies when this call is the
+ // one that creates it.
+ if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
+ return "", err
+ }
+
+ // O_EXCL: another process may have created the file between the read above
+ // and this call, in which case its value wins.
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, tokenFileMode)
+ if err != nil {
+ if os.IsExist(err) {
+ return readToken(path), nil
+ }
+ return "", err
+ }
+
+ if _, err = file.WriteString(value + "\n"); err != nil {
+ _ = file.Close()
+ return "", err
+ }
+
+ if err = file.Close(); err != nil {
+ return "", err
+ }
+
+ return value, nil
+}
+
+// readToken returns the token stored in path, or an empty string when it is not
+// available.
+func readToken(path string) string {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+
+ return strings.TrimSpace(string(content))
+}
+
+// subscribeAuthInterceptor refuses a Subscribe call that does not prove that it
+// knows the control channel token.
+//
+// Without it, any client that can reach the gRPC port becomes the subscriber:
+// it receives the full site configuration of the installation and silently
+// takes the place of tcontrollerd, which then stops receiving its updates.
+func subscribeAuthInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
+ if controlToken == "" {
+ logger.Error("The control channel token is not configured, refusing subscriptions. Set " + TokenEnv + " or make " + TokenFileFromEnv() + " writable.")
+ return status.Error(codes.Unauthenticated, "the control channel token is not configured on the server")
+ }
+
+ if subtle.ConstantTimeCompare([]byte(incomingToken(ss.Context())), []byte(controlToken)) != 1 {
+ logger.Warnf("Rejected a subscription from %s: invalid control channel token", peerAddress(ss.Context()))
+ return status.Error(codes.Unauthenticated, "a valid control channel token is required")
+ }
+
+ return handler(srv, ss)
+}
+
+// incomingToken returns the token that the client sent in the metadata.
+func incomingToken(ctx context.Context) string {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return ""
+ }
+
+ values := md.Get(tokenMetadataKey)
+ if len(values) == 0 {
+ return ""
+ }
+
+ return values[0]
+}
+
+// peerAddress returns the address of the client for the log.
+func peerAddress(ctx context.Context) string {
+ if p, ok := peer.FromContext(ctx); ok && p.Addr != nil {
+ return p.Addr.String()
+ }
+
+ return "unknown"
+}
diff --git a/management/webserver/rpc/main.go b/management/webserver/rpc/main.go
index 3b5ae8c9..dc65e55a 100644
--- a/management/webserver/rpc/main.go
+++ b/management/webserver/rpc/main.go
@@ -26,12 +26,43 @@ const (
var logger = log.GetLogger("grpc")
+// ServerOptions returns the options the control channel is served with.
+//
+// The channel carries the complete site configuration of the installation and
+// republishes it on every change, so a subscription has to prove that it is
+// tcontrollerd: the client certificate proves that the caller holds a
+// credential of this installation, and the token is the second gate, which is
+// what keeps a caller that reaches the port without one out.
+func ServerOptions() ([]grpc.ServerOption, error) {
+ opts := []grpc.ServerOption{
+ grpc.StreamInterceptor(subscribeAuthInterceptor),
+ }
+
+ creds, err := ServerCreds()
+ if err != nil {
+ return nil, err
+ }
+ opts = append(opts, creds)
+ return opts, nil
+}
+
func StartGRPCSever() error {
lis, err := net.Listen("tcp", config.GlobalConfig.GPRC.ListenAddr)
if err != nil {
return errors.Wrap(err, "Failed to listen")
}
- var opts []grpc.ServerOption
+
+ if err := InitControlToken(); err != nil {
+ // Not fatal: the console keeps working, and every subscription is
+ // refused with an explanation until the token is available.
+ logger.Errorf("Failed to init the control channel token: %s", err)
+ }
+
+ opts, err := ServerOptions()
+ if err != nil {
+ return err
+ }
+
grpcServer := grpc.NewServer(opts...)
pb.RegisterWebsiteServer(grpcServer, GetWebsiteServer())
go func() {
diff --git a/management/webserver/rpc/tls.go b/management/webserver/rpc/tls.go
new file mode 100644
index 00000000..ab4b1d80
--- /dev/null
+++ b/management/webserver/rpc/tls.go
@@ -0,0 +1,139 @@
+package rpc
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ "chaitin.cn/dev/go/errors"
+ "chaitin.cn/patronus/safeline-2/management/webserver/pkg/config"
+ "chaitin.cn/patronus/safeline-2/management/webserver/pkg/constants"
+)
+
+const (
+ // TLSEnv names the environment variable that used to allow a plaintext
+ // control channel. Plaintext is no longer supported: a value of "0" /
+ // off / false / no is an error. Unset it and run `mgt -gen_certs`.
+ TLSEnv = "MGT_GRPC_TLS"
+
+ // CertDirEnv names the environment variable that holds the directory of
+ // the server credentials.
+ CertDirEnv = "MGT_GRPC_CERT_DIR"
+
+ // ClientCertDirEnv names the environment variable that holds the directory
+ // the credentials of tcontrollerd are published in.
+ ClientCertDirEnv = "MGT_GRPC_CLIENT_CERT_DIR"
+
+ // ServerCertFile, ServerKeyFile and CertAuthorityFile are the names of the
+ // server credentials inside CertDir().
+ ServerCertFile = "grpc_server.crt"
+ ServerKeyFile = "grpc_server.key"
+ CertAuthorityFile = "grpc_ca.crt"
+
+ // ClientCertFile and ClientKeyFile are the names of the credentials of
+ // tcontrollerd inside ClientCertDir().
+ ClientCertFile = "tcd_client.crt"
+ ClientKeyFile = "tcd_client.key"
+
+ // ServerName is the name the server certificate is issued for.
+ //
+ // tcontrollerd reaches the management server at an address of the
+ // deployment (169.254.0.4:9002 in the standard compose file), which no
+ // certificate can be issued for. The name is therefore fixed on both sides
+ // and checked against the certificate, which is what makes the peer
+ // verifiable: the address may change, the name may not.
+ ServerName = "waf-management-server"
+)
+
+// CertDir returns the directory the server credentials of the control channel
+// are stored in.
+func CertDir() string {
+ if dir := strings.TrimSpace(os.Getenv(CertDirEnv)); dir != "" {
+ return dir
+ }
+
+ return filepath.Join(config.GlobalConfig.MgtResDir, constants.CertsPath)
+}
+
+// ClientCertDir returns the directory the credentials of tcontrollerd are
+// published in.
+//
+// It is a directory below the one both containers mount, because the tengine
+// container is where tcontrollerd runs and it does not mount the resources
+// directory of the management container.
+func ClientCertDir() string {
+ if dir := strings.TrimSpace(os.Getenv(ClientCertDirEnv)); dir != "" {
+ return dir
+ }
+
+ return filepath.Join(filepath.Dir(TokenFileFromEnv()), "grpc")
+}
+
+// ServerCreds returns the transport credentials of the control channel.
+//
+// The channel is always served over TLS. Missing credentials or an explicit
+// request for plaintext are errors: the subscription token would otherwise
+// travel on the same network as the containers.
+func ServerCreds() (grpc.ServerOption, error) {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv(TLSEnv))) {
+ case "0", "off", "false", "no":
+ return nil, errors.New("plaintext is not supported: unset " + TLSEnv + " and run mgt -gen_certs")
+ }
+
+ creds, err := loadServerCreds()
+ if err != nil {
+ return nil, err
+ }
+
+ logger.Infof("The control channel is served over TLS and only accepts a client certificate issued by %s", filepath.Join(CertDir(), CertAuthorityFile))
+
+ return grpc.Creds(creds), nil
+}
+
+// loadServerCreds reads the server credentials and builds the TLS configuration
+// of the control channel from them.
+func loadServerCreds() (credentials.TransportCredentials, error) {
+ certPath := filepath.Join(CertDir(), ServerCertFile)
+ keyPath := filepath.Join(CertDir(), ServerKeyFile)
+
+ certificate, err := tls.LoadX509KeyPair(certPath, keyPath)
+ if err != nil {
+ return nil, errors.Wrapf(err, "load %s", certPath)
+ }
+
+ clientCAs, err := certPool(filepath.Join(CertDir(), CertAuthorityFile))
+ if err != nil {
+ return nil, err
+ }
+
+ return credentials.NewTLS(&tls.Config{
+ Certificates: []tls.Certificate{certificate},
+ // The subscription token already keeps a caller that only reaches the
+ // port out, but it travels over this connection: without a client
+ // certificate, anyone who can read the traffic of the deployment can
+ // replay it and subscribe as tcontrollerd.
+ ClientAuth: tls.RequireAndVerifyClientCert,
+ ClientCAs: clientCAs,
+ MinVersion: tls.VersionTLS12,
+ }), nil
+}
+
+// certPool reads a PEM encoded certificate authority.
+func certPool(path string) (*x509.CertPool, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil, errors.Wrapf(err, "read %s", path)
+ }
+
+ pool := x509.NewCertPool()
+ if !pool.AppendCertsFromPEM(content) {
+ return nil, errors.New("no certificate found in " + path)
+ }
+
+ return pool, nil
+}
diff --git a/management/webserver/rpc/website.go b/management/webserver/rpc/website.go
index 08012923..932db6be 100644
--- a/management/webserver/rpc/website.go
+++ b/management/webserver/rpc/website.go
@@ -3,6 +3,7 @@ package rpc
import (
"encoding/json"
"io"
+ "sync"
"time"
"chaitin.cn/dev/go/errors"
@@ -12,25 +13,49 @@ import (
)
var (
- ping = pb.Event{Type: Ping, Msg: nil}
- Subscriber *StreamClient // only ONE sub
+ ping = pb.Event{Type: Ping, Msg: nil}
+
+ // subscriberMu guards subscriber, which is written by the goroutine that
+ // serves the Subscribe stream and read by every request handler that
+ // publishes a configuration.
+ subscriberMu sync.RWMutex
+ subscriber *StreamClient // only ONE sub
)
func Publish(msg []byte, eventType string) error {
- if Subscriber == nil {
+ // The subscriber is read once and every use below goes through the local
+ // variable: a new stream may replace it at any moment, and re-reading the
+ // global pointer would let this call talk to a closing stream or to a
+ // different subscription than the one it started with.
+ sub := currentSubscriber()
+ if sub == nil {
return errors.New("Service is abnormal, and the nginx conf cannot be updated for the time being. Please go to the shell to check the relevant logs")
}
- err := Subscriber.stream.Send(&pb.Event{
+
+ // Only one exchange may be outstanding on the stream. The response carries
+ // no request id, so two overlapping callers that share the single slot below
+ // would each read the answer of the other: one would commit a database
+ // change that tcontrollerd refused, the other would roll back one it
+ // applied. The generated client does not allow concurrent sends on one
+ // stream either.
+ sub.exchangeMu.Lock()
+ defer sub.exchangeMu.Unlock()
+
+ // A response whose own request already gave up is still in the slot, and
+ // this call would read it as its own answer.
+ discardPendingResponses(sub)
+
+ err := sub.stream.Send(&pb.Event{
Type: eventType,
Msg: msg,
})
if err != nil {
select {
- case <-Subscriber.stream.Context().Done():
- err = Subscriber.stream.Context().Err() // context canceled
- Subscriber = nil
+ case <-sub.stream.Context().Done():
+ err = sub.stream.Context().Err() // context canceled
+ clearSubscriber(sub)
return err
- case Subscriber.errCh <- errors.Wrapf(err, "Send event err: %s", msg):
+ case sub.errCh <- errors.Wrapf(err, "Send event err: %s", msg):
return errors.Wrapf(err, "Send event err: %s", msg)
}
}
@@ -40,7 +65,7 @@ func Publish(msg []byte, eventType string) error {
for {
select {
- case rsp := <-Subscriber.rspCh:
+ case rsp := <-sub.rspCh:
if rsp.Err {
return errors.New(string(rsp.Msg))
} else {
@@ -48,11 +73,37 @@ func Publish(msg []byte, eventType string) error {
return nil
}
case <-rspTimeoutTicker.C:
+ // The device did not answer, so what it applied is unknown: an
+ // answer that arrives later would be read by the next request as
+ // if it belonged to it. The stream is dropped instead, which
+ // leaves the slot this call abandons unreachable and makes
+ // tcontrollerd reconnect and synchronise with a full push.
+ clearSubscriber(sub)
+ select {
+ case sub.errCh <- errors.New("Wait timeout for updating result"):
+ default:
+ }
+
return errors.New("Wait timeout for updating result")
}
}
}
+// discardPendingResponses drops the responses that are sitting in the slot.
+//
+// It is called while the exchange of the caller is held, so nothing in the slot
+// can belong to a request that is still waiting.
+func discardPendingResponses(sc *StreamClient) {
+ for {
+ select {
+ case rsp := <-sc.rspCh:
+ logger.Warnf("Discarding an unclaimed control channel response of type %s", rsp.GetType())
+ default:
+ return
+ }
+ }
+}
+
// StreamClient is the instance for every client stream connected
type StreamClient struct {
stream pb.Website_SubscribeServer
@@ -60,6 +111,48 @@ type StreamClient struct {
errCh chan error
rspCh chan *pb.Response
quit chan struct{} // quit stream client gracefully
+
+ // exchangeMu serialises the send-and-wait of Publish. The response of a
+ // request is not labelled with the request it answers, so at most one
+ // request may be in flight at a time.
+ exchangeMu sync.Mutex
+}
+
+// currentSubscriber returns the active subscriber, or nil when no stream is
+// connected.
+func currentSubscriber() *StreamClient {
+ subscriberMu.RLock()
+ defer subscriberMu.RUnlock()
+
+ return subscriber
+}
+
+// setSubscriber makes sc the active subscriber.
+func setSubscriber(sc *StreamClient) {
+ subscriberMu.Lock()
+ defer subscriberMu.Unlock()
+
+ if subscriber != nil && subscriber != sc {
+ // A client that restarts reconnects while its previous stream may not
+ // have timed out yet, so a replacement is expected; it is logged
+ // because only a client that knows the control channel token can
+ // trigger it.
+ logger.Warn("A new subscriber replaces the previous one")
+ }
+
+ subscriber = sc
+}
+
+// clearSubscriber forgets the active subscriber, but only when it is still the
+// one that asks: a stream that ends after it was replaced must not disconnect
+// its successor.
+func clearSubscriber(sc *StreamClient) {
+ subscriberMu.Lock()
+ defer subscriberMu.Unlock()
+
+ if subscriber == sc {
+ subscriber = nil
+ }
}
func newStreamClient(stream pb.Website_SubscribeServer) *StreamClient {
@@ -77,7 +170,7 @@ func (sc *StreamClient) pingLoop() {
defer pingTicker.Stop()
for {
- if Subscriber != nil && sc != Subscriber {
+ if current := currentSubscriber(); current != nil && current != sc {
// new subscriber in replace of the old one.
logger.Debug("New subscriber in replace of the old one")
return
@@ -101,7 +194,7 @@ func (sc *StreamClient) pingLoop() {
func (sc *StreamClient) recvLoop() {
for {
- if Subscriber != nil && sc != Subscriber {
+ if current := currentSubscriber(); current != nil && current != sc {
// new subscriber in replace of the old one.
close(sc.quit)
logger.Debug("New subscriber in replace of the old one")
@@ -123,7 +216,18 @@ func (sc *StreamClient) recvLoop() {
if rsp.Type != Pong {
// receive response
logger.Infof("Recv updating website rsp: err(%t), msg(%s)", rsp.GetErr(), rsp.GetMsg())
- sc.rspCh <- rsp
+
+ // This loop is also the goroutine that reads the keepalive pongs
+ // and resets the timer, so it must not block on a full slot: it
+ // would disconnect a stream whose peer is perfectly healthy. Only
+ // one exchange is outstanding at a time (see Publish) and it
+ // empties the slot before it sends, so a slot that is already full
+ // holds a response nobody is waiting for.
+ select {
+ case sc.rspCh <- rsp:
+ default:
+ logger.Warn("Discarding a control channel response that no request is waiting for")
+ }
continue
}
sc.timer.Reset(KeepaliveTimeout)
@@ -136,13 +240,32 @@ type WebsiteServer struct {
}
func GetWebsiteServer() *WebsiteServer {
- return &WebsiteServer{}
+ // UnimplementedWebsiteServer is embedded through a pointer, and the code
+ // that registers a service checks that the pointer is not nil: the check
+ // is there because a nil pointer would only be noticed when a method that
+ // this server does not implement is called. It has to be set here, or the
+ // registration panics as soon as the generated code carries that check.
+ return &WebsiteServer{UnimplementedWebsiteServer: &pb.UnimplementedWebsiteServer{}}
}
func publishFullWebsite() error {
var websites []model.Website
db := database.GetDB().DB
- db.Model(&model.Website{}).Find(&websites)
+
+ // A failed query leaves the slice empty, and an empty list means "this
+ // installation has no sites" to tcontrollerd: it would delete every nginx
+ // configuration it manages. The error has to stop the push instead.
+ if err := db.Model(&model.Website{}).Find(&websites).Error; err != nil {
+ return errors.Wrap(err, "load websites")
+ }
+
+ if websites == nil {
+ // A nil slice marshals to "null", which tcontrollerd reads as a
+ // malformed push. An installation that really has no site sends an
+ // empty list.
+ websites = []model.Website{}
+ }
+
byteWebsites, err := json.Marshal(&websites)
if err != nil {
return err
@@ -153,11 +276,16 @@ func publishFullWebsite() error {
// Subscribe is gRPC API entrypoint
func (ws *WebsiteServer) Subscribe(stream pb.Website_SubscribeServer) error {
- Subscriber = newStreamClient(stream)
- defer Subscriber.timer.Stop()
+ sc := newStreamClient(stream)
+ setSubscriber(sc)
+
+ defer sc.timer.Stop()
+ // A stream that ends without being replaced leaves the channel unusable
+ // until a new client connects, so the global pointer is released here.
+ defer clearSubscriber(sc)
- go Subscriber.pingLoop()
- go Subscriber.recvLoop()
+ go sc.pingLoop()
+ go sc.recvLoop()
if err := publishFullWebsite(); err != nil {
// triggered when tcd starts, ignore push error messages
@@ -165,17 +293,16 @@ func (ws *WebsiteServer) Subscribe(stream pb.Website_SubscribeServer) error {
}
select {
- case <-Subscriber.quit:
+ case <-sc.quit:
logger.Infof("Disconnected gracefully")
return nil
- case <-Subscriber.timer.C:
+ case <-sc.timer.C:
logger.Error("Keepalive timeout")
- case err := <-Subscriber.errCh:
+ case err := <-sc.errCh:
logger.WithError(err).Error()
return err
- case <-Subscriber.stream.Context().Done():
- logger.Infof("Subscribe context done: %s", Subscriber.stream.Context().Err())
- Subscriber = nil
+ case <-sc.stream.Context().Done():
+ logger.Infof("Subscribe context done: %s", sc.stream.Context().Err())
}
return nil
diff --git a/management/webserver/utils/cert.go b/management/webserver/utils/cert.go
index 816cd0c1..8b38be1b 100644
--- a/management/webserver/utils/cert.go
+++ b/management/webserver/utils/cert.go
@@ -9,7 +9,10 @@ import (
"encoding/pem"
"io/ioutil"
"math/big"
+ "os"
"time"
+
+ "chaitin.cn/dev/go/errors"
)
func GenerateCert(hostnames []string, days int64, keyBits int, subject *pkix.Name, isCA bool) ([]byte, []byte, error) {
@@ -79,7 +82,16 @@ func WriteCertIfNotExist(certFilePath, keyFilePath string, generator func() ([]b
return err
}
- if err = EnsureRenameWriteFile(keyFilePath, key, 0644); err != nil {
+ // A certificate is public, its private key is not: 0644 lets every local
+ // user read the key and impersonate the service it belongs to.
+ if err = EnsureRenameWriteFile(keyFilePath, key, 0600); err != nil {
+ return err
+ }
+
+ // The file may already have existed with the permissive mode that older
+ // releases gave it, in which case the mode passed to the write above is
+ // not applied by the kernel.
+ if err = os.Chmod(keyFilePath, 0600); err != nil {
return err
}
@@ -107,3 +119,92 @@ func genCertIfNotExist(certFilePath, keyFilePath string, generator func() ([]byt
return cert, key, nil
}
}
+
+// GenerateSignedCert issues a certificate for hostnames that is signed by the
+// certificate authority that certPEM and keyPEM belong to.
+//
+// GenerateCert produces a self signed certificate, which is what the console
+// uses: the browser is told to accept it. The control channel cannot work that
+// way, because both ends have to recognise each other without trusting the
+// network they talk over, so the management server issues the credentials of
+// both ends from a CA that only this installation knows.
+func GenerateSignedCert(certPEM, keyPEM []byte, hostnames []string, days int64, keyBits int, subject *pkix.Name, client bool) ([]byte, []byte, error) {
+ caCert, caKey, err := parseCertAuthority(certPEM, keyPEM)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ privateKey, err := rsa.GenerateKey(rand.Reader, keyBits)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+ serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
+ keyUsage := x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
+ if client {
+ extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
+ }
+
+ notBefore := time.Now()
+ template := x509.Certificate{
+ SerialNumber: serialNumber,
+ NotBefore: notBefore,
+ NotAfter: notBefore.Add(time.Duration(days) * 24 * time.Hour),
+ DNSNames: hostnames,
+ BasicConstraintsValid: true,
+ IsCA: false,
+ ExtKeyUsage: extKeyUsage,
+ KeyUsage: keyUsage,
+ }
+ if subject != nil {
+ template.Subject = *subject
+ }
+
+ derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &privateKey.PublicKey, caKey)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ var certBuffer bytes.Buffer
+ if err = pem.Encode(&certBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
+ return nil, nil, err
+ }
+
+ var keyBuffer bytes.Buffer
+ if err = pem.Encode(&keyBuffer, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}); err != nil {
+ return nil, nil, err
+ }
+
+ return certBuffer.Bytes(), keyBuffer.Bytes(), nil
+}
+
+// parseCertAuthority reads the certificate and the private key of a CA.
+func parseCertAuthority(certPEM, keyPEM []byte) (*x509.Certificate, *rsa.PrivateKey, error) {
+ certBlock, _ := pem.Decode(certPEM)
+ if certBlock == nil {
+ return nil, nil, errors.New("the certificate authority is not PEM encoded")
+ }
+
+ caCert, err := x509.ParseCertificate(certBlock.Bytes)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ keyBlock, _ := pem.Decode(keyPEM)
+ if keyBlock == nil {
+ return nil, nil, errors.New("the private key of the certificate authority is not PEM encoded")
+ }
+
+ caKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return caCert, caKey, nil
+}
diff --git a/management/webserver/utils/file.go b/management/webserver/utils/file.go
index 4ae2d88c..0fde0d62 100644
--- a/management/webserver/utils/file.go
+++ b/management/webserver/utils/file.go
@@ -2,7 +2,6 @@ package utils
import (
"fmt"
- "io/ioutil"
"os"
"path/filepath"
)
@@ -49,11 +48,41 @@ func FilesExist(paths ...string) (bool, error) {
}
func RenameWriteFile(filename string, data []byte, perm os.FileMode) error {
- randFileName := filename + ".tmp." + RandStr(8)
- if err := ioutil.WriteFile(randFileName, data, perm); err != nil {
+ // os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a
+ // file or symlink that was planted at the temporary path beforehand cannot
+ // be followed and the payload is never readable by other users before the
+ // rename. The previous implementation built a predictable name with
+ // math/rand and wrote through ioutil.WriteFile, which does both.
+ tmpFile, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp.")
+ if err != nil {
+ return err
+ }
+ tmpName := tmpFile.Name()
+
+ renamed := false
+ defer func() {
+ if !renamed {
+ _ = os.Remove(tmpName)
+ }
+ }()
+
+ if err = tmpFile.Chmod(perm); err != nil {
+ _ = tmpFile.Close()
+ return err
+ }
+ if _, err = tmpFile.Write(data); err != nil {
+ _ = tmpFile.Close()
+ return err
+ }
+ if err = tmpFile.Close(); err != nil {
return err
}
- return os.Rename(randFileName, filename)
+ if err = os.Rename(tmpName, filename); err != nil {
+ return err
+ }
+
+ renamed = true
+ return nil
}
func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error {
@@ -65,11 +94,14 @@ func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error {
return RenameWriteFile(path, data, mode)
}
+// EnsureWriteFile writes a file with the given mode, creating its parent
+// directory when needed.
+//
+// It goes through RenameWriteFile, so the write is atomic and cannot be
+// redirected through a symlink: ioutil.WriteFile, which this used to call,
+// truncates whatever a link planted at the destination path points at. The
+// detection engine byte code is written through here, and a reader either sees
+// the complete old file or the complete new one instead of a torn mixture.
func EnsureWriteFile(path string, data []byte, mode os.FileMode) error {
- err := EnsureFileDir(path)
- if err != nil {
- return err
- }
-
- return ioutil.WriteFile(path, data, mode)
+ return EnsureRenameWriteFile(path, data, mode)
}
diff --git a/management/webserver/utils/httpclient.go b/management/webserver/utils/httpclient.go
index d3874d0a..89a4a9ae 100644
--- a/management/webserver/utils/httpclient.go
+++ b/management/webserver/utils/httpclient.go
@@ -1,25 +1,62 @@
package utils
import (
- "crypto/tls"
+ "net"
"net/http"
"net/url"
"os"
+ "sync"
"time"
)
const proxyName = "HTTPS_PROXY"
-var httpClient *http.Client
+const (
+ // httpClientTimeout bounds one request end to end, from the connection
+ // attempt to the last byte of the body.
+ httpClientTimeout = 15 * time.Second
+ // httpClientDialTimeout bounds the connection attempt, and
+ // httpClientTLSHandshakeTimeout the handshake that follows it.
+ httpClientDialTimeout = 5 * time.Second
+ httpClientTLSHandshakeTimeout = 5 * time.Second
+
+ // httpClientResponseHeaderTimeout bounds the wait for the response header
+ // once the request was written.
+ httpClientResponseHeaderTimeout = 10 * time.Second
+)
+
+var (
+ httpClientOnce sync.Once
+ httpClient *http.Client
+)
+
+// GetHTTPClient returns the shared HTTP client.
+//
+// The client is built once: the API handlers and the cron jobs call this at the
+// same time, and the unsynchronized lazy assignment used before let two of them
+// build (and see) a client at the same time.
+//
+// Every phase of a request is bounded. The client is used by endpoints that any
+// caller can reach, such as the upgrade tip lookup, and the endpoints it talks
+// to are remote: an upstream that accepts the connection and then stops
+// answering would otherwise hold the handler goroutine and its socket for as
+// long as the connection lives, which a caller can repeat without limit.
func GetHTTPClient() *http.Client {
- if httpClient == nil {
+ httpClientOnce.Do(func() {
+ // Certificates are verified: this client talks to the telemetry
+ // endpoint and to the upgrade server, both of which are remote and can
+ // be spoofed by a man in the middle when verification is disabled.
tr := &http.Transport{
- MaxIdleConns: 10,
- IdleConnTimeout: 30 * time.Second,
- TLSClientConfig: &tls.Config{
- InsecureSkipVerify: true,
- },
+ DialContext: (&net.Dialer{
+ Timeout: httpClientDialTimeout,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ MaxIdleConns: 10,
+ IdleConnTimeout: 30 * time.Second,
+ TLSHandshakeTimeout: httpClientTLSHandshakeTimeout,
+ ResponseHeaderTimeout: httpClientResponseHeaderTimeout,
+ ExpectContinueTimeout: 1 * time.Second,
}
proxyUrl, existed := os.LookupEnv(proxyName)
@@ -28,8 +65,8 @@ func GetHTTPClient() *http.Client {
tr.Proxy = http.ProxyURL(uri)
}
- httpClient = &http.Client{Transport: tr}
- }
+ httpClient = &http.Client{Transport: tr, Timeout: httpClientTimeout}
+ })
return httpClient
}
diff --git a/management/webserver/utils/random.go b/management/webserver/utils/random.go
index 44ebd98a..bd09f32c 100644
--- a/management/webserver/utils/random.go
+++ b/management/webserver/utils/random.go
@@ -1,18 +1,42 @@
package utils
import (
- "math/rand"
- "time"
+ "crypto/rand"
+ "encoding/hex"
+ "math/big"
)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+// RandStr returns n letters drawn from a cryptographically secure source.
+//
+// The values produced here are used as session signing keys, temporary file
+// names and event ids. math/rand is seeded from a predictable value (and its
+// output can be reconstructed from a single sample), so it must not be used
+// for any of them.
func RandStr(n int) string {
- rand.Seed(time.Now().UnixNano())
-
b := make([]rune, n)
+ limit := big.NewInt(int64(len(letters)))
for i := range b {
- b[i] = letters[rand.Intn(len(letters))]
+ idx, err := rand.Int(rand.Reader, limit)
+ if err != nil {
+ // crypto/rand fails only when the system entropy source is
+ // unusable. Continuing with a predictable value is not an option,
+ // because callers use the result as a secret.
+ panic("utils: cannot read from the system entropy source: " + err.Error())
+ }
+ b[i] = letters[idx.Int64()]
}
return string(b)
}
+
+// RandHex returns a hex string of 2*n characters read from crypto/rand. Every
+// byte of the result carries eight bits of entropy, which makes it the right
+// helper for keys and secrets.
+func RandHex(n int) string {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ panic("utils: cannot read from the system entropy source: " + err.Error())
+ }
+ return hex.EncodeToString(b)
+}
diff --git a/mcp_server/.dockerignore b/mcp_server/.dockerignore
new file mode 100644
index 00000000..6960136b
--- /dev/null
+++ b/mcp_server/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.gitignore
+secrets/
+state/
+*.log
+mcp-server
+coverage.out
diff --git a/mcp_server/.gitignore b/mcp_server/.gitignore
new file mode 100644
index 00000000..770444c1
--- /dev/null
+++ b/mcp_server/.gitignore
@@ -0,0 +1,5 @@
+/secrets/
+/state/
+/mcp-server
+*.log
+coverage.out
diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile
index 3d966342..3eaf0072 100644
--- a/mcp_server/Dockerfile
+++ b/mcp_server/Dockerfile
@@ -1,28 +1,29 @@
-FROM golang:1.24-alpine AS builder
+FROM golang:1.25.14-alpine3.23 AS builder
-WORKDIR /app
+ARG GOPROXY
+
+WORKDIR /src
RUN apk add --no-cache git
COPY go.mod go.sum ./
-
RUN go mod download
COPY . .
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mcp-server .
-RUN CGO_ENABLED=0 go build -o mcp-server .
-
-FROM alpine:latest
-
-WORKDIR /app
+FROM alpine:3.22
-RUN apk add --no-cache ca-certificates tzdata
+RUN apk add --no-cache ca-certificates tzdata \
+ && install -d -m 700 /var/lib/safeline-mcp \
+ && install -d -m 755 /etc/safeline-mcp
-COPY --from=builder /app/mcp-server .
-COPY --from=builder /app/config.yaml .
+COPY --from=builder /out/mcp-server /app/mcp-server
+COPY config.yaml /etc/safeline-mcp/config.yaml
ENV TZ=Asia/Shanghai
EXPOSE 5678
-CMD ["./mcp-server"]
\ No newline at end of file
+ENTRYPOINT ["/app/mcp-server"]
+CMD ["--config", "/etc/safeline-mcp/config.yaml"]
diff --git a/mcp_server/README.md b/mcp_server/README.md
index b88b1c4e..e2653865 100644
--- a/mcp_server/README.md
+++ b/mcp_server/README.md
@@ -1,256 +1,204 @@
# SafeLine MCP Server
-SafeLine MCP Server is an implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) that provides complete management and control capabilities for SafeLine WAF.
-
-[](docker-compose.yml)
-[](go.mod)
-
-## Use Cases
-
-- Automated management and control of SafeLine WAF instances
-- WAF configuration and policy management through API
-- Building AI-based security protection tools and applications
-
-## Prerequisites
+SafeLine MCP Server provides read-only, multi-instance access to SafeLine CE through the Model Context Protocol. This release intentionally exposes one Tool and one SafeLine API:
+
+| MCP Tool | SafeLine request | Effect |
+|---|---|---|
+| `get_attack_events` | `GET /api/open/events` | Read only |
+
+The original site-creation, blacklist, and whitelist Tools have been removed. The downstream client has no generic write method and cannot issue SafeLine `POST`, `PUT`, `PATCH`, or `DELETE` requests.
+
+## Protocol
+
+- MCP SDK: `mcp-go v1.0.0-beta.1`
+- Go: `1.25.14` or newer
+- Transport: Streamable HTTP
+- Endpoint: `POST /mcp`
+- Latest supported MCP revision: `2026-07-28`
+- Removed legacy routes: `/sse` and `/message`
+
+The server supports the 2026-07-28 stateless protocol and retains Streamable HTTP compatibility with older MCP clients on the same `/mcp` endpoint.
+
+## Configuration
+
+Instances are deployment configuration, not MCP Tools. Each Tool call must specify an `instance_id`; the server never guesses an instance or reuses another instance's credential.
+
+```yaml
+server:
+ name: "SafeLine MCP Server"
+ version: "2.0.0"
+ host: "127.0.0.1"
+ port: 5678
+
+logger:
+ level: "info"
+ file_path: ""
+ console: true
+ caller: false
+ development: false
+
+instances:
+ - id: "production-a"
+ display_name: "Production A"
+ base_url: "https://10.0.0.10:9443"
+ token_file: "/run/secrets/production-a.token"
+ timeout: 30
+ debug: false
+ ca_file: ""
+
+ - id: "production-b"
+ display_name: "Production B"
+ base_url: "https://10.0.0.11:9443"
+ token_file: "/run/secrets/production-b.token"
+ timeout: 30
+ debug: false
+ ca_file: ""
+```
-1. Install [Docker](https://www.docker.com/) (if running in container)
-2. Configure SafeLine API Token (obtained from SafeLine console)
+`LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file.
-## Features
+`ca_file` is the certificate authority that signed the certificate of the
+instance. A SafeLine console serves the certificate it generated for itself,
+which no public authority signed: point `ca_file` at that certificate (or at the
+authority that issued it) and the connection stays verified. Relative paths are
+resolved from the directory of the configuration file.
-- Complete MCP (Management Control Protocol) server implementation
-- Support for SafeLine WAF instance management and control
-- Flexible configuration system supporting file configuration and environment variables
-- Docker containerization support
-- Secure API communication
+`insecure_skip_verify` is rejected at startup. Use `ca_file` for a self-signed
+instance certificate. `base_url` must be `https`, or `http` only when the host
+is loopback (`127.0.0.1`, `localhost`, `::1`).
-## Quick Start
+Each `display_name` must be unique, ignoring letter case, and must not match another instance's `id`. During server discovery (or legacy initialization), the server publishes only the `display_name` to `instance_id` mappings as Server Instructions. This lets a user refer to a friendly name while the AI still calls `get_attack_events` with the stable, explicit `instance_id`. Instance addresses and credentials are never included in those instructions.
-### Environment Variables
+SafeLine CE does not yet provide a dedicated read-only service credential for all required management APIs. This release therefore uses an administrator API Token per instance, while the MCP process enforces the fixed read-only API allowlist.
-| Environment Variable | Description | Default Value | Required |
-|---------|------|--------|-----|
-| LISTEN_PORT | Service listening port | 5678 | No |
-| LISTEN_ADDRESS | Service listening address | 0.0.0.0 | No |
-| SAFELINE_SECRET | SSE server secret | - | No |
-| SAFELINE_ADDRESS | SafeLine API address | - | Yes |
-| SAFELINE_API_TOKEN | SafeLine API authentication token | - | Yes |
+## AI client authentication
-### Using Docker
+MCP client authentication is independent from the downstream SafeLine API Tokens.
-#### Method 1: Using docker run
+Authentication is on by default: the server always installs the bearer gate
+unless a deployment explicitly opts out, and it refuses to start without a token
+on any non-loopback listener.
-```bash
-docker run -d \
- --name safeline-mcp \
- -p 5678:5678 \
- -e SAFELINE_API_TOKEN="your_api_token" \
- -e SAFELINE_ADDRESS="https://your.safeline.com" \
- -e LISTEN_PORT=5678 \
- -e LISTEN_ADDRESS="0.0.0.0" \
- chaitin/safeline-mcp:latest
-```
+### Initialization
-#### Method 2: Using docker-compose
+Generate the MCP client Bearer Token once during deployment initialization:
```bash
-# 1. Clone repository
-git clone https://github.com/chaitin/safeline-mcp.git
-cd safeline-mcp
-
-# 2. Edit docker-compose.yml to configure environment variables
-# Example docker-compose.yml:
-# version: '3'
-# services:
-# mcp:
-# image: chaitin/safeline-mcp:latest
-# container_name: safeline-mcp
-# ports:
-# - "5678:5678"
-# environment:
-# - SAFELINE_API_TOKEN=your_api_token
-# - SAFELINE_ADDRESS=https://your.safeline.com
-# - LISTEN_PORT=5678
-# - LISTEN_ADDRESS=0.0.0.0
-
-# 3. Start service
-docker compose -f docker-compose.yml up -d
+docker compose run --rm mcp_server auth init
```
-#### Method 3: Using Go
+The command uses `crypto/rand` to generate a 256-bit token. It displays the plaintext once and stores only its SHA-256 hash and creation time in `state/auth.json` with mode `0600`. A normal service restart only reads that state and never changes or prints the token.
-```bash
-# 1. Clone repository
-git clone https://github.com/chaitin/SafeLine.git
-cd safeline-mcp
+Clients send the generated token on every MCP request:
-# 2. Install dependencies
-go mod download
-
-# 3. Configure config.yaml
-cp config.yaml.example config.yaml
-# Edit config.yaml with necessary configurations
-
-# 4. Run service
-go run main.go
+```http
+Authorization: Bearer slmcp_...
```
-For more API details, please refer to the [API Documentation](https://demo.waf.chaitin.com:9443/swagger/index.html).
-
-## Tools
-
-### Application Management
-
-- **create_application**
-
-### Rule Management
-- **create_blacklist_rule**
-- **create_whitelist_rule**
-
-### Analyze
-- **get_attack_events**
+Loopback (`127.0.0.1` / `localhost` / `::1`) may listen on plain HTTP.
+A non-loopback bind, including Docker `LISTEN_ADDRESS=0.0.0.0`, requires
+`MCP_TLS_CERT` and `MCP_TLS_KEY` (or `tls_cert_file` / `tls_key_file`).
+The process refuses to start without them.
-## Development Guide
+The server refuses to start while this state is missing, so a deployment cannot
+serve any request before the token exists. Explicit rotation is available
+locally:
-The Go API in this project is currently under development, and APIs may change. If you have specific requirements, please submit an Issue for discussion.
-
-### Directory Structure
-
-```
-internal/
-├── api/ # API implementation
-│ ├── app/ # Application-related APIs
-│ │ └── create_application.go
-│ └── rule/ # Rule-related APIs
-│ └── create_rule.go
-└── tools/ # MCP tool implementation
- ├── app/ # Application-related tools
- │ └── create_application.go
- └── rule/ # Rule-related tools
- └── create_rule.go
+```bash
+docker compose run --rm mcp_server auth rotate
```
-### Adding New Tools
-
-1. **Create Tool File**
- - Create corresponding directory and file under `internal/tools`
- - File name should match tool name
- - Use separate file for each tool
- - Example: `internal/tools/app/create_application.go`
-
-2. **Tool Implementation Template**
-```go
-package app
-
-type ToolName struct{}
-
-type ToolParams struct {
- // Parameter definitions
- Param1 string `json:"param1" desc:"parameter description" required:"true"`
- Param2 int `json:"param2" desc:"parameter description" required:"false"`
-}
+After the service restarts, rotation invalidates the previous token. This
+static Bearer gate is intended for self-hosted deployment; it is not presented
+as a complete MCP OAuth 2.1 implementation.
+Run `auth rotate` as a single serialized deployment operation; concurrent
+rotation commands are not supported.
-type ToolResult struct {
- Field1 string `json:"field1"`
-}
+### Disabling authentication
-func (t *ToolName) Name() string {
- return "tool_name"
-}
+`MCP_AUTH_DISABLED=true` (or `MCP_AUTH_ENABLED=false`) removes the gate. The
+server then refuses to start unless the listener is loopback-only, so a disabled
+gate cannot silently publish attack telemetry to the network:
-func (t *ToolName) Description() string {
- return "tool description"
-}
-
-func (t *ToolName) Validate(params ToolParams) error {
- // Parameter validation logic
- return nil
-}
-
-func (t *ToolName) Execute(ctx context.Context, params ToolParams) (result ToolResult, err error) {
- // Tool execution logic
- return result, nil
-}
+```text
+MCP_AUTH_DISABLED=true
```
-3. **[Optional]Create API Implementation**
+## Docker deployment
-If you need to use some APIs that have not been implemented yet, you need to create corresponding files in the api directory for implementation
- - Create same directory structure under `internal/api`
- - File name should match tool func
- - Example: `internal/api/app/create_application.go`
+1. Build the image. The included `.dockerignore` excludes `secrets/` and
+ `state/` from the build context even if local fallback directories exist:
-**API Implementation Template**
-```go
-package app
+ ```bash
+ docker compose build
+ ```
-type RequestType struct {
- // Request parameter definitions
- Param1 string `json:"param1"`
- Param2 int `json:"param2"`
-}
+2. Prefer protected deployment directories outside the source checkout, then
+ create one SafeLine token file per instance:
-func APIName(ctx context.Context, req *RequestType) (ResultType, error) {
- if req == nil {
- return nil, errors.New("request is required")
- }
+ ```bash
+ install -d -m 700 /etc/safeline-mcp/secrets /var/lib/safeline-mcp
+ install -m 600 /path/to/production-a.token /etc/safeline-mcp/secrets/production-a.token
+ install -m 600 /path/to/production-b.token /etc/safeline-mcp/secrets/production-b.token
+ export SAFELINE_MCP_SECRETS_DIR=/etc/safeline-mcp/secrets
+ export SAFELINE_MCP_STATE_DIR=/var/lib/safeline-mcp
+ ```
- var resp api.Response[ResultType]
- err := api.Service().Post(ctx, "/api/path", req, &resp)
- if err != nil {
- return nil, errors.Wrap(err, "failed to execute")
- }
+ For local-only development, `./secrets` and `./state` are accepted and are
+ excluded by both `.gitignore` and `.dockerignore`; create them with the same
+ permissions before running Compose.
- if resp.Err != nil {
- return nil, errors.New(resp.Msg)
- }
+3. Edit `config.yaml` so each `token_file` matches its file under `/run/secrets`.
- return resp.Data, nil
-}
-```
-4. **Tool Registration (init.go)**
+4. Run the one-time `auth init` command and save the displayed token. The
+ service refuses to start until this state exists.
-The tool registration file `internal/tools/init.go` is used to centrally manage all tool registrations
- - Register all tools uniformly in the `init()` function
- - Use the `AppendTool()` method for registration
- - Example:
- ```go
- // Register create application tool
- AppendTool(&app.CreateApp{})
-
- // Register create blacklist rule tool
- AppendTool(&rule.CreateBlacklistRule{})
- ```
+5. Start the service:
-### Development Standards
+ ```bash
+ docker compose up -d
+ ```
-1. **Naming Conventions**
- - Use lowercase letters and underscores for tool names
- - File names should match tool names
+Compose refuses to auto-create missing bind-mount paths. Its host port is
+loopback-only unless `MCP_PUBLISH_ADDRESS` is explicitly changed.
-2. **Directory Organization**
- - Divide directories by functional modules (e.g., app, rule, etc.)
- - Maintain consistent structure between tools and api directories
- - Keep related functionality in the same directory
+## Running with Go
-3. **Code Standards**
- - Follow Go standard code conventions
- - Add necessary parameter validation
- - Use unified error handling approach
- - Add appropriate logging
+```bash
+go test ./...
+go build -o mcp-server .
+./mcp-server auth init --state-file "$PWD/state/auth.json"
+./mcp-server --config ./config.yaml
+```
-4. **Documentation Requirements**
- - Provide clear functional description in tool Description
- - Add detailed description for parameters
- - Update API toolkit documentation in README
+The default configuration listens on `127.0.0.1`, which is the only address the
+server accepts while authentication is disabled:
-### Example
+```bash
+MCP_AUTH_DISABLED=true ./mcp-server --config ./config.yaml
+```
-Refer to the implementation of the `create_application` tool:
-- Tool implementation: `internal/tools/app/create_application.go`
-- API implementation: `internal/api/app/create_application.go`
+## Tool contract
+`get_attack_events` accepts:
+| Field | Type | Required | Constraint |
+|---|---|---|---|
+| `instance_id` | string | Yes | Must match a configured instance |
+| `page` | integer | No | Default `1`, minimum `1` |
+| `page_size` | integer | No | Default `10`, range `1..100` |
+| `ip` | string | No | Source IP, CIDR, or partial-IP filter |
+| `host` | string | No | Protected host filter |
+| `port` | integer | No | Range `1..65535` |
+| `start` | integer | No | Unix milliseconds, non-negative |
+| `end` | integer | No | Unix milliseconds, non-negative and not before `start` |
+Unknown input fields and fractional integers are rejected by server-side JSON Schema validation. Successful results include both text fallback content and `structuredContent` with:
+- `source.instance_id`
+- `source.display_name`
+- `page` and `page_size`
+- `nodes`
+- `total`
+SafeLine HTTP failures, HTTP-200 envelopes with a non-empty `err`, unknown instances, and input validation failures are returned as MCP Tool Execution Errors with `result.isError=true`. The MCP server does not interpret human-readable `msg` content when `err` is empty or absent.
diff --git a/mcp_server/config.yaml b/mcp_server/config.yaml
index 87b5b78c..bed6c1c8 100644
--- a/mcp_server/config.yaml
+++ b/mcp_server/config.yaml
@@ -1,27 +1,38 @@
-# Server Configuration
server:
name: "SafeLine MCP Server"
- version: "1.0.0"
- # Can be overridden by environment variable LISTEN_PORT
+ version: "2.0.0"
+ # LISTEN_ADDRESS and LISTEN_PORT can override these listener values.
+ host: "127.0.0.1"
port: 5678
- # Can be overridden by environment variable LISTEN_ADDRESS
- host: "0.0.0.0"
- # Can be overridden by environment variable SAFELINE_SECRET
- secret: "" # Secret for SSE server
-# Logger Configuration
+ # Required when host is not loopback. MCP_TLS_CERT / MCP_TLS_KEY override these.
+ tls_cert_file: ""
+ tls_key_file: ""
+
logger:
- level: "info" # Log level: debug, info, warn, error
- file_path: "" # Log file path
- console: true # Whether to output to console
- caller: false # Whether to record caller information
- development: true # Whether to use development mode
+ level: "info"
+ file_path: ""
+ console: true
+ caller: false
+ development: false
-# API Configuration
-api:
- # Can be overridden by environment variable SAFELINE_ADDRESS
- base_url: "" # API service address
- # Can be overridden by environment variable SAFELINE_API_TOKEN
- token: "" # Authentication token
- timeout: 30 # Timeout in seconds
- debug: false # Whether to enable debug mode
- insecure_skip_verify: true # Whether to skip certificate verification
+# SafeLine API Tokens remain administrator credentials in this release. Keep
+# one token file per instance; the MCP server never returns them to clients.
+instances:
+ - id: "safeline-a"
+ display_name: "SafeLine A"
+ base_url: "https://safeline-a.example:9443"
+ token_file: "/run/secrets/safeline-a.token"
+ timeout: 30
+ debug: false
+ # ca_file is the certificate authority that signed the certificate of the
+ # instance. A SafeLine console serves the certificate it generated for
+ # itself, which no public authority signed: point this at that certificate
+ # (or at its CA) instead of switching the check off.
+ ca_file: ""
+ - id: "safeline-b"
+ display_name: "SafeLine B"
+ base_url: "https://safeline-b.example:9443"
+ token_file: "/run/secrets/safeline-b.token"
+ timeout: 30
+ debug: false
+ ca_file: ""
diff --git a/mcp_server/docker-compose.yml b/mcp_server/docker-compose.yml
index 2ffdafe1..be26bc6b 100644
--- a/mcp_server/docker-compose.yml
+++ b/mcp_server/docker-compose.yml
@@ -1,15 +1,42 @@
-version: '3.8'
-
services:
mcp_server:
- image: chaitin/safeline-mcp:latest
- container_name: mcp_server
- restart: always
+ build: .
+ image: safeline-mcp:local
+ container_name: safeline-mcp
+ restart: unless-stopped
ports:
- - "5678:5678"
+ - "${MCP_PUBLISH_ADDRESS:-127.0.0.1}:${LISTEN_PORT:-5678}:${LISTEN_PORT:-5678}"
environment:
- - SAFELINE_SECRET=your_secret_key # optional, if you want to use secret key to authenticate
- - SAFELINE_ADDRESS=https://your_safeline_ip:9443 # required, your SafeLine WAF address
- - SAFELINE_API_TOKEN=your_safeline_api_token # required, your SafeLine WAF api token
- - LISTEN_PORT=5678 # optional, default is 5678
- - LISTEN_ADDRESS=0.0.0.0 # optional, default is 0.0.0.0
\ No newline at end of file
+ LISTEN_ADDRESS: "0.0.0.0"
+ LISTEN_PORT: "${LISTEN_PORT:-5678}"
+ # Required because LISTEN_ADDRESS is not loopback.
+ MCP_TLS_CERT: "${MCP_TLS_CERT:-}"
+ MCP_TLS_KEY: "${MCP_TLS_KEY:-}"
+ # Authentication is enabled by default. Only set MCP_AUTH_DISABLED=true
+ # for a loopback-only deployment; a non-loopback listener without a token
+ # is refused at startup.
+ MCP_AUTH_DISABLED: "${MCP_AUTH_DISABLED:-}"
+ MCP_AUTH_STATE_FILE: "/var/lib/safeline-mcp/auth.json"
+ read_only: true
+ security_opt:
+ - no-new-privileges:true
+ tmpfs:
+ - /tmp:size=16m,mode=1777
+ volumes:
+ - type: bind
+ source: ${SAFELINE_MCP_CONFIG_FILE:-./config.yaml}
+ target: /etc/safeline-mcp/config.yaml
+ read_only: true
+ bind:
+ create_host_path: false
+ - type: bind
+ source: ${SAFELINE_MCP_SECRETS_DIR:-./secrets}
+ target: /run/secrets
+ read_only: true
+ bind:
+ create_host_path: false
+ - type: bind
+ source: ${SAFELINE_MCP_STATE_DIR:-./state}
+ target: /var/lib/safeline-mcp
+ bind:
+ create_host_path: false
diff --git a/mcp_server/go.mod b/mcp_server/go.mod
index 7b836231..5ad13ca2 100644
--- a/mcp_server/go.mod
+++ b/mcp_server/go.mod
@@ -1,13 +1,20 @@
module github.com/chaitin/SafeLine/mcp_server
-go 1.24.1
+go 1.25.14
require (
- github.com/mark3labs/mcp-go v0.18.0
+ github.com/mark3labs/mcp-go v1.0.0-beta.1
go.uber.org/zap v1.27.0
gopkg.in/yaml.v3 v3.0.1
)
+require (
+ github.com/google/jsonschema-go v0.4.2 // indirect
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
+ github.com/spf13/cast v1.7.1 // indirect
+ golang.org/x/text v0.41.0 // indirect
+)
+
require (
github.com/google/uuid v1.6.0 // indirect
github.com/mcuadros/go-defaults v1.2.0
diff --git a/mcp_server/go.sum b/mcp_server/go.sum
index 5c31060f..9aace7e6 100644
--- a/mcp_server/go.sum
+++ b/mcp_server/go.sum
@@ -1,18 +1,36 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao=
-github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mark3labs/mcp-go v1.0.0-beta.1 h1:hrPZlyUCfykLdAyO8Yr6Dkk0alPbpeLjkeS0BuNBGK8=
+github.com/mark3labs/mcp-go v1.0.0-beta.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s=
github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc=
github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -21,8 +39,15 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
+golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/mcp_server/internal/api/analyze/get_event_list.go b/mcp_server/internal/api/analyze/get_event_list.go
index 29eac72a..51113404 100644
--- a/mcp_server/internal/api/analyze/get_event_list.go
+++ b/mcp_server/internal/api/analyze/get_event_list.go
@@ -2,20 +2,32 @@ package analyze
import (
"context"
- "fmt"
+ "net/url"
+ "strconv"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
)
type GetEventListRequest struct {
- Page int `json:"page"`
- PageSize int `json:"page_size"`
- IP string `json:"ip"`
- Start int64 `json:"start"`
- End int64 `json:"end"`
+ InstanceID string `json:"instance_id"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ IP string `json:"ip"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Start int64 `json:"start"`
+ End int64 `json:"end"`
}
type GetEventListResponse struct {
+ Source api.InstanceSource `json:"source"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ Nodes []Event `json:"nodes"`
+ Total int64 `json:"total"`
+}
+
+type eventListData struct {
Nodes []Event `json:"nodes"`
Total int64 `json:"total"`
}
@@ -38,10 +50,42 @@ type Event struct {
}
func GetEventList(ctx context.Context, req *GetEventListRequest) (*GetEventListResponse, error) {
- var resp api.Response[GetEventListResponse]
- err := api.Service().Get(ctx, fmt.Sprintf("/api/open/events?page=%d&page_size=%d&ip=%s&start=%d&end=%d", req.Page, req.PageSize, req.IP, req.Start, req.End), &resp)
+ client, err := api.ClientFor(req.InstanceID)
if err != nil {
return nil, err
}
- return &resp.Data, nil
+
+ query := make(url.Values)
+ query.Set("page", strconv.Itoa(req.Page))
+ query.Set("page_size", strconv.Itoa(req.PageSize))
+ if req.IP != "" {
+ query.Set("ip", req.IP)
+ }
+ if req.Host != "" {
+ query.Set("host", req.Host)
+ }
+ if req.Port != 0 {
+ query.Set("port", strconv.Itoa(req.Port))
+ }
+ if req.Start != 0 {
+ query.Set("start", strconv.FormatInt(req.Start, 10))
+ }
+ if req.End != 0 {
+ query.Set("end", strconv.FormatInt(req.End, 10))
+ }
+
+ var resp api.Response[eventListData]
+ if err := client.GetAttackEvents(ctx, query, &resp); err != nil {
+ return nil, err
+ }
+ if err := resp.Validate(); err != nil {
+ return nil, err
+ }
+ return &GetEventListResponse{
+ Source: client.Source(),
+ Page: req.Page,
+ PageSize: req.PageSize,
+ Nodes: resp.Data.Nodes,
+ Total: resp.Data.Total,
+ }, nil
}
diff --git a/mcp_server/internal/api/analyze/get_event_list_test.go b/mcp_server/internal/api/analyze/get_event_list_test.go
new file mode 100644
index 00000000..e5355a55
--- /dev/null
+++ b/mcp_server/internal/api/analyze/get_event_list_test.go
@@ -0,0 +1,96 @@
+package analyze
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/chaitin/SafeLine/mcp_server/internal/api"
+ "github.com/chaitin/SafeLine/mcp_server/internal/config"
+ "github.com/chaitin/SafeLine/mcp_server/pkg/logger"
+)
+
+func TestGetEventListUsesCurrentSafeLineQueryAndReturnsSource(t *testing.T) {
+ if err := logger.Init(&logger.Config{Level: "error"}); err != nil {
+ t.Fatal(err)
+ }
+ tokenPath := filepath.Join(t.TempDir(), "token")
+ if err := os.WriteFile(tokenPath, []byte("instance-token\n"), 0o600); err != nil {
+ t.Fatalf("write token: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/open/events" {
+ t.Errorf("request = %s %s", r.Method, r.URL.Path)
+ }
+ if got := r.Header.Get("X-SLCE-API-TOKEN"); got != "instance-token" {
+ t.Errorf("token header = %q", got)
+ }
+ wantQuery := map[string]string{
+ "page": "2", "page_size": "25", "ip": "10.0.0.0/24",
+ "host": "example.test&a=b", "port": "9443", "start": "1000", "end": "2000",
+ }
+ for key, want := range wantQuery {
+ if got := r.URL.Query().Get(key); got != want {
+ t.Errorf("query[%q] = %q, want %q", key, got, want)
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "data": {
+ "nodes": [{
+ "id": 7,
+ "ip": "10.0.0.8",
+ "protocol": 1,
+ "host": "example.test",
+ "dst_port": 9443,
+ "updated_at": 1800,
+ "start_at": 1100,
+ "end_at": 1700,
+ "deny_count": 3,
+ "pass_count": 0,
+ "finished": true,
+ "country": "CN",
+ "province": "Beijing",
+ "city": "Beijing"
+ }],
+ "total": 1
+ },
+ "err": null,
+ "msg": ""
+ }`))
+ }))
+ defer server.Close()
+
+ if err := api.InitInstances([]*config.InstanceConfig{{
+ ID: "dev152", DisplayName: "Development 152", BaseURL: server.URL, TokenFile: tokenPath,
+ }}); err != nil {
+ t.Fatalf("InitInstances() error = %v", err)
+ }
+
+ response, err := GetEventList(context.Background(), &GetEventListRequest{
+ InstanceID: "dev152",
+ Page: 2,
+ PageSize: 25,
+ IP: "10.0.0.0/24",
+ Host: "example.test&a=b",
+ Port: 9443,
+ Start: 1000,
+ End: 2000,
+ })
+ if err != nil {
+ t.Fatalf("GetEventList() error = %v", err)
+ }
+ if response.Source.InstanceID != "dev152" || response.Source.DisplayName != "Development 152" {
+ t.Errorf("source = %#v", response.Source)
+ }
+ if response.Page != 2 || response.PageSize != 25 || response.Total != 1 {
+ t.Errorf("pagination/total = page %d, size %d, total %d", response.Page, response.PageSize, response.Total)
+ }
+ if len(response.Nodes) != 1 || response.Nodes[0].ID != 7 || response.Nodes[0].DstPort != 9443 {
+ t.Fatalf("nodes = %#v", response.Nodes)
+ }
+}
diff --git a/mcp_server/internal/api/app/create_application.go b/mcp_server/internal/api/app/create_application.go
deleted file mode 100644
index 3d7d0419..00000000
--- a/mcp_server/internal/api/app/create_application.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package app
-
-import (
- "context"
-
- "github.com/chaitin/SafeLine/mcp_server/internal/api"
- "github.com/chaitin/SafeLine/mcp_server/pkg/errors"
-)
-
-type CreateAppRequest struct {
- ServerNames []string `json:"server_names"`
- Ports []string `json:"ports"`
- Upstreams []string `json:"upstreams"`
- Comment string `json:"comment"`
-}
-
-// CreateApp Create new website or app
-func CreateApp(ctx context.Context, req *CreateAppRequest) (int64, error) {
- if req == nil {
- return 0, errors.New("request is required")
- }
-
- var resp api.Response[int64]
- err := api.Service().Post(ctx, "/api/open/site", req, &resp)
- if err != nil {
- return 0, errors.Wrap(err, "failed to create app")
- }
-
- if resp.Err != nil {
- return 0, errors.New(resp.Msg)
- }
-
- return resp.Data, nil
-}
diff --git a/mcp_server/internal/api/client.go b/mcp_server/internal/api/client.go
index 79781725..d094efa2 100644
--- a/mcp_server/internal/api/client.go
+++ b/mcp_server/internal/api/client.go
@@ -1,106 +1,178 @@
package api
import (
- "bytes"
"context"
"crypto/tls"
+ "crypto/x509"
"encoding/json"
"fmt"
"io"
+ "net"
"net/http"
+ "net/url"
+ "os"
+ "strings"
"time"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
-// Client API client
-type Client struct {
- baseURL string
- httpClient *http.Client
- headers map[string]string
-}
+const (
+ // tokenHeader carries the deployment token of the configured instance.
+ tokenHeader = "X-SLCE-API-TOKEN"
-// ClientOption Client configuration options
-type ClientOption func(*Client)
+ maxErrorBodySize = 4 << 10
+ maxResponseBodySize = 4 << 20
-// WithTimeout Set timeout duration
-func WithTimeout(timeout time.Duration) ClientOption {
- return func(c *Client) {
- c.httpClient.Timeout = timeout
+ // maxRedirects bounds how many redirects one request may follow.
+ maxRedirects = 10
+)
+
+// loadRootCAs reads the certificate authority the instance is issued by.
+//
+// It is a file rather than the system pool because the installation that the
+// MCP server talks to usually carries the certificate it generated for itself,
+// which no system pool knows about.
+func loadRootCAs(path string) (*x509.CertPool, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil, errors.Wrap(err, "read ca_file failed")
}
-}
-// WithHeader Set request header
-func WithHeader(key, value string) ClientOption {
- return func(c *Client) {
- c.headers[key] = value
+ roots := x509.NewCertPool()
+ if !roots.AppendCertsFromPEM(content) {
+ return nil, errors.New("ca_file does not contain a PEM encoded certificate")
}
+
+ return roots, nil
}
-// WithBaseURL Set base URL
-func WithBaseURL(baseURL string) ClientOption {
- return func(c *Client) {
- c.baseURL = baseURL
- }
+// Client is the transport used by a single SafeLine instance. Its request
+// method is intentionally unexported; APIClient exposes only approved read-only
+// operations.
+type Client struct {
+ baseURL *url.URL
+ httpClient *http.Client
+ headers http.Header
}
-// WithInsecureSkipVerify Set whether to skip certificate verification
-func WithInsecureSkipVerify(skip bool) ClientOption {
- return func(c *Client) {
- if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
- if transport.TLSClientConfig == nil {
- transport.TLSClientConfig = &tls.Config{}
- }
- transport.TLSClientConfig.InsecureSkipVerify = skip
- }
+func loopbackURLHost(host string) bool {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return false
+ }
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
}
+ host = strings.Trim(host, "[]")
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
}
-// NewClient Create new API client
-func NewClient(opts ...ClientOption) *Client {
- transport := &http.Transport{
- TLSClientConfig: &tls.Config{},
+func newClient(baseURL string, timeout time.Duration, caFile, token string) (*Client, error) {
+ parsedURL, err := url.Parse(strings.TrimSpace(baseURL))
+ if err != nil {
+ return nil, errors.Wrap(err, "parse base_url failed")
+ }
+ if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
+ return nil, errors.New("base_url must be an absolute http or https URL")
+ }
+ if parsedURL.Scheme == "http" && !loopbackURLHost(parsedURL.Host) {
+ return nil, errors.New("deployment token must not be sent over non-loopback HTTP")
}
+ if parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
+ return nil, errors.New("base_url must not contain a query or fragment")
+ }
+ parsedURL.Path = strings.TrimRight(parsedURL.Path, "/")
+
+ tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
+ if caFile != "" {
+ // The console of a SafeLine installation serves a certificate that no
+ // public authority signed. Pinning the authority of the instance keeps
+ // verification on, which is what keeps the deployment token in the
+ // request header out of the hands of anything on the path.
+ roots, err := loadRootCAs(caFile)
+ if err != nil {
+ return nil, err
+ }
+ tlsConfig.RootCAs = roots
+ }
+
+ transport := &http.Transport{TLSClientConfig: tlsConfig}
- c := &Client{
+ client := &Client{
+ baseURL: parsedURL,
httpClient: &http.Client{
- Timeout: 30 * time.Second,
+ Timeout: timeout,
Transport: transport,
+ // Every request carries the deployment token in X-SLCE-API-TOKEN.
+ // net/http drops the Authorization header when a redirect leaves
+ // the origin, but it knows nothing about this header, so a 3xx
+ // from the configured instance would hand the token to whatever
+ // host the Location header names. Redirects are therefore followed
+ // only while they stay on the configured origin.
+ CheckRedirect: sameOriginRedirects(parsedURL),
},
- headers: make(map[string]string),
+ headers: make(http.Header),
}
+ client.headers.Set("Accept", "application/json")
+ client.headers.Set("User-Agent", "SafeLine-MCP/1.0")
+ client.headers.Set(tokenHeader, token)
+ return client, nil
+}
- for _, opt := range opts {
- opt(c)
- }
+// sameOriginRedirects returns the redirect policy of a client: a redirect is
+// followed only while it stays on the scheme, host and port of the configured
+// instance. Anything else fails the request instead of replaying the token.
+func sameOriginRedirects(base *url.URL) func(*http.Request, []*http.Request) error {
+ return func(req *http.Request, via []*http.Request) error {
+ if len(via) >= maxRedirects {
+ return fmt.Errorf("stopped after %d redirects", maxRedirects)
+ }
+ if !sameOrigin(base, req.URL) {
+ return fmt.Errorf("refused to follow a redirect to %s: it would send the instance token to another origin", req.URL.Redacted())
+ }
- return c
+ return nil
+ }
}
-// Request Send request
-func (c *Client) Request(ctx context.Context, method, path string, body interface{}, result interface{}) error {
- reqURL := fmt.Sprintf("%s%s", c.baseURL, path)
+// sameOrigin reports whether target has the same scheme, host and port as base.
+func sameOrigin(base, target *url.URL) bool {
+ return strings.EqualFold(base.Scheme, target.Scheme) &&
+ strings.EqualFold(base.Hostname(), target.Hostname()) &&
+ effectivePort(base) == effectivePort(target)
+}
- var bodyReader io.Reader
- if body != nil {
- bodyBytes, err := json.Marshal(body)
- if err != nil {
- return errors.Wrap(err, "marshal request body failed")
- }
- bodyReader = bytes.NewReader(bodyBytes)
+// effectivePort returns the port a URL connects to, including the default port
+// of its scheme.
+func effectivePort(u *url.URL) string {
+ if port := u.Port(); port != "" {
+ return port
}
- logger.With("url", reqURL).Debug("request url")
- req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
- if err != nil {
- return errors.Wrap(err, "create request failed")
+ if strings.EqualFold(u.Scheme, "https") {
+ return "443"
}
- // Set common headers
- req.Header.Set("Content-Type", "application/json")
- for k, v := range c.headers {
- req.Header.Set(k, v)
+ return "80"
+}
+
+func (c *Client) get(ctx context.Context, path string, query url.Values, result any) error {
+ endpoint := *c.baseURL
+ endpoint.Path = strings.TrimRight(endpoint.Path, "/") + path
+ endpoint.RawPath = ""
+ endpoint.RawQuery = query.Encode()
+
+ logger.With("url", endpoint.String()).Debug("request url")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return errors.Wrap(err, "create request failed")
}
+ req.Header = c.headers.Clone()
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -108,50 +180,38 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
}
defer resp.Body.Close()
- // Read response body
- respBody, err := io.ReadAll(resp.Body)
+ respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodySize+1))
if err != nil {
return errors.Wrap(err, "read response body failed")
}
-
- // Check status code
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return errors.New(fmt.Sprintf("request failed with status %d: %s", resp.StatusCode, string(respBody)))
+ if len(respBody) > maxResponseBodySize {
+ return errors.New("SafeLine response body exceeds the 4 MiB limit")
}
- // Parse response
- if result != nil {
- if err := json.Unmarshal(respBody, result); err == nil {
- return nil
- }
- var respData map[string]interface{}
- if err := json.Unmarshal(respBody, &respData); err != nil {
- return errors.Wrap(err, "unmarshal response failed")
+ var envelope struct {
+ Err any `json:"err"`
+ Msg string `json:"msg"`
+ }
+ envelopeDecoded := json.Unmarshal(respBody, &envelope) == nil
+ if envelopeDecoded {
+ if err := responseError(envelope.Err, envelope.Msg); err != nil {
+ return err
}
- if respData["err"] != nil || respData["msg"] != nil {
- return errors.New(respData["msg"].(string))
+ }
+
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ body := strings.TrimSpace(string(respBody))
+ if len(body) > maxErrorBodySize {
+ body = body[:maxErrorBodySize] + "..."
}
+ return errors.New(fmt.Sprintf("SafeLine request failed with status %d: %s", resp.StatusCode, body))
}
+ if result == nil {
+ return nil
+ }
+ if err := json.Unmarshal(respBody, result); err != nil {
+ return errors.Wrap(err, "unmarshal response failed")
+ }
return nil
}
-
-// Get Send GET request
-func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
- return c.Request(ctx, http.MethodGet, path, nil, result)
-}
-
-// Post Send POST request
-func (c *Client) Post(ctx context.Context, path string, body interface{}, result interface{}) error {
- return c.Request(ctx, http.MethodPost, path, body, result)
-}
-
-// Put Send PUT request
-func (c *Client) Put(ctx context.Context, path string, body interface{}, result interface{}) error {
- return c.Request(ctx, http.MethodPut, path, body, result)
-}
-
-// Delete Send DELETE request
-func (c *Client) Delete(ctx context.Context, path string, result interface{}) error {
- return c.Request(ctx, http.MethodDelete, path, nil, result)
-}
diff --git a/mcp_server/internal/api/client_redirect_test.go b/mcp_server/internal/api/client_redirect_test.go
new file mode 100644
index 00000000..81af567d
--- /dev/null
+++ b/mcp_server/internal/api/client_redirect_test.go
@@ -0,0 +1,138 @@
+package api
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// The client sends the deployment token in a header of its own, while net/http
+// only strips the standard Authorization header when a redirect leaves the
+// origin. A 3xx answered by the configured instance would therefore hand the
+// token to whatever host the Location header names.
+func TestClientRefusesCrossOriginRedirect(t *testing.T) {
+ var tokenAtOtherOrigin atomic.Value
+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ tokenAtOtherOrigin.Store(r.Header.Get(tokenHeader))
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"err":null,"data":{}}`))
+ }))
+ defer other.Close()
+
+ origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, other.URL+"/stolen", http.StatusFound)
+ }))
+ defer origin.Close()
+
+ client, err := newClient(origin.URL, 5*time.Second, "", "deployment-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var result map[string]any
+ err = client.get(context.Background(), "/api/open/events", url.Values{}, &result)
+ if err == nil {
+ t.Fatal("a redirect to another origin has to fail the request")
+ }
+ if !strings.Contains(err.Error(), "refused to follow a redirect") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := tokenAtOtherOrigin.Load(); got != nil {
+ t.Fatalf("the token reached another origin: %v", got)
+ }
+}
+
+func TestClientFollowsSameOriginRedirect(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/open/events", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/moved", http.StatusFound)
+ })
+ var tokenAfterRedirect atomic.Value
+ mux.HandleFunc("/moved", func(w http.ResponseWriter, r *http.Request) {
+ tokenAfterRedirect.Store(r.Header.Get(tokenHeader))
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"err":null,"data":{"ok":true}}`))
+ })
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ client, err := newClient(server.URL, 5*time.Second, "", "deployment-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var result map[string]any
+ if err := client.get(context.Background(), "/api/open/events", url.Values{}, &result); err != nil {
+ t.Fatalf("a same-origin redirect has to be followed: %v", err)
+ }
+ if got := tokenAfterRedirect.Load(); got != "deployment-token" {
+ t.Fatalf("the token was lost on the way: %v", got)
+ }
+}
+
+func TestSameOrigin(t *testing.T) {
+ cases := []struct {
+ base, target string
+ want bool
+ }{
+ {"https://safeline.example:9443", "https://safeline.example:9443/other", true},
+ {"https://safeline.example:9443", "https://SAFELINE.example:9443/other", true},
+ {"https://safeline.example", "https://safeline.example:443/other", true},
+ {"http://safeline.example", "http://safeline.example:80/other", true},
+ {"https://safeline.example", "http://safeline.example/other", false},
+ {"https://safeline.example", "https://safeline.example:8443/other", false},
+ {"https://safeline.example:9443", "https://evil.example:9443/other", false},
+ {"https://safeline.example:9443", "https://safeline.example.evil.example:9443/other", false},
+ }
+
+ for _, c := range cases {
+ base, err := url.Parse(c.base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target, err := url.Parse(c.target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := sameOrigin(base, target); got != c.want {
+ t.Errorf("sameOrigin(%s, %s) = %v, want %v", c.base, c.target, got, c.want)
+ }
+ }
+}
+
+// TestPlainClientLeaksTokenAcrossRedirects documents what the policy above
+// prevents: with the default redirect handling of net/http the custom token
+// header arrives at the other origin.
+func TestPlainClientLeaksTokenAcrossRedirects(t *testing.T) {
+ var tokenAtOtherOrigin atomic.Value
+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ tokenAtOtherOrigin.Store(r.Header.Get(tokenHeader))
+ w.Write([]byte(`{}`))
+ }))
+ defer other.Close()
+
+ origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, other.URL+"/stolen", http.StatusFound)
+ }))
+ defer origin.Close()
+
+ req, err := http.NewRequest(http.MethodGet, origin.URL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set(tokenHeader, "deployment-token")
+ resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+
+ if got := tokenAtOtherOrigin.Load(); got != "deployment-token" {
+ t.Fatalf("the plain client was expected to leak the token, got %v", got)
+ }
+}
diff --git a/mcp_server/internal/api/client_tls_test.go b/mcp_server/internal/api/client_tls_test.go
new file mode 100644
index 00000000..814847ef
--- /dev/null
+++ b/mcp_server/internal/api/client_tls_test.go
@@ -0,0 +1,95 @@
+package api
+
+import (
+ "context"
+ "encoding/pem"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// The console of a SafeLine installation serves the certificate it generated
+// for itself, so an MCP deployment that wants verification on has to be able to
+// name the authority of that instance. ca_file is that, and it exists so that
+// insecure_skip_verify is not the only way to reach an installation with a
+// self-signed certificate.
+func TestClientUsesTheAuthorityOfTheInstance(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"err":null,"data":{"ok":true}}`))
+ }))
+ defer server.Close()
+
+ // The certificate is not known without the authority, and the request is
+ // refused rather than sent in the clear or unverified.
+ untrusted, err := newClient(server.URL, 5*time.Second, "", "deployment-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var result struct {
+ Data struct {
+ OK bool `json:"ok"`
+ } `json:"data"`
+ Err any `json:"err"`
+ }
+ if err := untrusted.get(context.Background(), "/api/open/events", url.Values{}, &result); err == nil {
+ t.Fatal("the instance was reached without trusting its certificate authority")
+ }
+
+ caPath := filepath.Join(t.TempDir(), "instance.crt")
+ encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
+ if err := os.WriteFile(caPath, encoded, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ trusted, err := newClient(server.URL, 5*time.Second, caPath, "deployment-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := trusted.get(context.Background(), "/api/open/events", url.Values{}, &result); err != nil {
+ t.Fatalf("the request with the authority of the instance failed: %v", err)
+ }
+ if !result.Data.OK || result.Err != nil {
+ t.Fatalf("the request did not reach the instance: %+v", result)
+ }
+}
+
+func TestClientRefusesACAFileWithoutACertificate(t *testing.T) {
+ caPath := filepath.Join(t.TempDir(), "not-a-certificate.crt")
+ if err := os.WriteFile(caPath, []byte("this is not a certificate\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := newClient("https://instance.example:9443", 5*time.Second, caPath, "token"); err == nil {
+ t.Fatal("a ca_file without a certificate was accepted")
+ }
+}
+
+func TestClientReportsAMissingCAFile(t *testing.T) {
+ missing := filepath.Join(t.TempDir(), "missing.crt")
+ if _, err := newClient("https://instance.example:9443", 5*time.Second, missing, "token"); err == nil {
+ t.Fatal("a missing ca_file was accepted")
+ }
+}
+
+func TestClientRejectsNonLoopbackHTTP(t *testing.T) {
+ _, err := newClient("http://example.com:9443", 5*time.Second, "", "token")
+ if err == nil || err.Error() != "deployment token must not be sent over non-loopback HTTP" {
+ t.Fatalf("error = %v", err)
+ }
+}
+
+func TestClientAllowsLoopbackHTTP(t *testing.T) {
+ if _, err := newClient("http://127.0.0.1:9443", 5*time.Second, "", "token"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := newClient("http://localhost:9443", 5*time.Second, "", "token"); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/mcp_server/internal/api/response.go b/mcp_server/internal/api/response.go
index bfa2993b..98a2237c 100644
--- a/mcp_server/internal/api/response.go
+++ b/mcp_server/internal/api/response.go
@@ -1,11 +1,45 @@
package api
-// Response Common API response structure
+import (
+ "fmt"
+ "strings"
+)
+
+// Response is the envelope returned by SafeLine's management API. SafeLine can
+// report an application error with HTTP 200, so callers must not infer success
+// from the HTTP status alone.
type Response[T any] struct {
- // Response data
- Data T `json:"data"`
- // Error message
- Err any `json:"err"`
- // Prompt message
- Msg string `json:"msg"`
+ Data T `json:"data"`
+ Err any `json:"err"`
+ Msg string `json:"msg"`
+}
+
+// ResponseError represents an error carried in a SafeLine response envelope.
+type ResponseError struct {
+ Code any
+ Message string
+}
+
+func (e *ResponseError) Error() string {
+ message := strings.TrimSpace(e.Message)
+ if message == "" {
+ message = "request failed"
+ }
+ return fmt.Sprintf("SafeLine API error %v: %s", e.Code, message)
+}
+
+// Validate returns an error whenever the envelope contains a non-empty err
+// value, including responses that otherwise decoded successfully.
+func (r *Response[T]) Validate() error {
+ return responseError(r.Err, r.Msg)
+}
+
+func responseError(code any, message string) error {
+ if code == nil {
+ return nil
+ }
+ if text, ok := code.(string); ok && strings.TrimSpace(text) == "" {
+ return nil
+ }
+ return &ResponseError{Code: code, Message: message}
}
diff --git a/mcp_server/internal/api/rule/create_rule.go b/mcp_server/internal/api/rule/create_rule.go
deleted file mode 100644
index 6eb88439..00000000
--- a/mcp_server/internal/api/rule/create_rule.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package rule
-
-import (
- "context"
-
- "github.com/chaitin/SafeLine/mcp_server/internal/api"
- "github.com/chaitin/SafeLine/mcp_server/pkg/errors"
-)
-
-type CreateRuleRequest struct {
- Name string `json:"name"`
- IP []string `json:"ip"`
- IsEnabled bool `json:"is_enabled"`
- Pattern [][]api.Pattern `json:"pattern"`
- Action int `json:"action"`
-}
-
-// CreateRule Create new rule
-func CreateRule(ctx context.Context, req *CreateRuleRequest) (int64, error) {
- if req == nil {
- return 0, errors.New("request is required")
- }
-
- var resp api.Response[int64]
- err := api.Service().Post(ctx, "/api/open/policy", req, &resp)
- if err != nil {
- return 0, errors.Wrap(err, "failed to create policy rule")
- }
-
- if resp.Err != nil {
- return 0, errors.New(resp.Msg)
- }
-
- return resp.Data, nil
-}
diff --git a/mcp_server/internal/api/service.go b/mcp_server/internal/api/service.go
index 2c0a62a6..e06c6172 100644
--- a/mcp_server/internal/api/service.go
+++ b/mcp_server/internal/api/service.go
@@ -2,6 +2,12 @@ package api
import (
"context"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
"sync"
"time"
@@ -10,91 +16,197 @@ import (
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
-// APIClient API client implementation
+const attackEventsPath = "/api/open/events"
+const maxTokenFileSize = 16 << 10
+
+// InstanceSource is safe to include in tool output and identifies the exact
+// SafeLine instance from which the data was read.
+type InstanceSource struct {
+ InstanceID string `json:"instance_id"`
+ DisplayName string `json:"display_name"`
+}
+
+// APIClient is an independently authenticated client for one SafeLine instance.
type APIClient struct {
client *Client
- config *config.APIConfig
+ source InstanceSource
}
-var (
- instance *APIClient
- once sync.Once
-)
+// ClientRegistry maps stable instance IDs to independently configured clients.
+// Credentials are loaded once per instance and are never shared across entries.
+type ClientRegistry struct {
+ clients map[string]*APIClient
+ order []string
+}
+
+// NewClientRegistry constructs a registry without mutating global state.
+func NewClientRegistry(configs []*config.InstanceConfig) (*ClientRegistry, error) {
+ if len(configs) == 0 {
+ return nil, errors.New("at least one SafeLine instance is required")
+ }
+
+ registry := &ClientRegistry{
+ clients: make(map[string]*APIClient, len(configs)),
+ order: make([]string, 0, len(configs)),
+ }
+ for index, cfg := range configs {
+ if cfg == nil {
+ return nil, errors.New(fmt.Sprintf("instances[%d] is required", index))
+ }
+ if strings.TrimSpace(cfg.ID) == "" {
+ return nil, errors.New(fmt.Sprintf("instances[%d].id is required", index))
+ }
+ if _, exists := registry.clients[cfg.ID]; exists {
+ return nil, errors.New(fmt.Sprintf("duplicate instance id %q", cfg.ID))
+ }
-// Init Initialize API service
-func Init(cfg *config.APIConfig) error {
- var err error
- once.Do(func() {
- instance, err = newAPIClient(cfg)
+ client, err := newAPIClient(cfg)
if err != nil {
- logger.With("error", err).Error("failed to initialize API service")
- return
+ return nil, errors.Wrap(err, fmt.Sprintf("initialize instance %q failed", cfg.ID))
}
- logger.Info("API service initialized successfully")
- })
- return err
+ registry.clients[cfg.ID] = client
+ registry.order = append(registry.order, cfg.ID)
+ }
+ return registry, nil
+}
+
+// Get returns the client for instanceID without falling back to another
+// instance. This prevents a typo from accidentally querying with the wrong
+// instance credential.
+func (r *ClientRegistry) Get(instanceID string) (*APIClient, error) {
+ if r == nil {
+ return nil, errors.New("API service not initialized")
+ }
+ client, ok := r.clients[instanceID]
+ if !ok {
+ return nil, errors.New(fmt.Sprintf("unknown SafeLine instance %q", instanceID))
+ }
+ return client, nil
}
-// Service Get API service instance
-func Service() *APIClient {
- if instance == nil {
- logger.Error("API service not initialized")
- panic("API service not initialized")
+// Sources lists configured instances without exposing addresses or credentials.
+func (r *ClientRegistry) Sources() []InstanceSource {
+ if r == nil {
+ return nil
+ }
+ sources := make([]InstanceSource, 0, len(r.order))
+ for _, id := range r.order {
+ sources = append(sources, r.clients[id].source)
}
- return instance
+ return sources
}
-// newAPIClient Create new API client
-func newAPIClient(config *config.APIConfig) (*APIClient, error) {
- if config == nil {
- return nil, errors.New("config is required")
+var (
+ registryMu sync.RWMutex
+ activeRegistry *ClientRegistry
+)
+
+// InitInstances initializes the runtime registry used by MCP tools.
+func InitInstances(configs []*config.InstanceConfig) error {
+ registry, err := NewClientRegistry(configs)
+ if err != nil {
+ logger.With("error", err).Error("failed to initialize API service")
+ return err
}
- if config.BaseURL == "" {
+ registryMu.Lock()
+ activeRegistry = registry
+ registryMu.Unlock()
+ logger.With("instances", len(configs)).Info("API service initialized successfully")
+ return nil
+}
+
+// ClientFor returns the explicitly selected instance client.
+func ClientFor(instanceID string) (*APIClient, error) {
+ registryMu.RLock()
+ registry := activeRegistry
+ registryMu.RUnlock()
+ return registry.Get(instanceID)
+}
+
+func newAPIClient(cfg *config.InstanceConfig) (*APIClient, error) {
+ if strings.TrimSpace(cfg.BaseURL) == "" {
return nil, errors.New("base_url is required")
}
+ token, err := loadToken(cfg)
+ if err != nil {
+ return nil, err
+ }
timeout := 30
- if config.Timeout > 0 {
- timeout = config.Timeout
+ if cfg.Timeout > 0 {
+ timeout = cfg.Timeout
}
-
- opts := []ClientOption{
- WithBaseURL(config.BaseURL),
- WithTimeout(time.Duration(timeout) * time.Second),
- WithHeader("User-Agent", "SafeLine-MCP/1.0"),
- WithInsecureSkipVerify(config.InsecureSkipVerify),
+ client, err := newClient(cfg.BaseURL, time.Duration(timeout)*time.Second, cfg.CAFile, token)
+ if err != nil {
+ return nil, err
}
- // If token is configured, add authentication header
- if config.Token != "" {
- opts = append(opts, WithHeader("X-SLCE-API-TOKEN", config.Token))
+ displayName := strings.TrimSpace(cfg.DisplayName)
+ if displayName == "" {
+ displayName = cfg.ID
}
-
- client := NewClient(opts...)
-
return &APIClient{
client: client,
- config: config,
+ source: InstanceSource{InstanceID: cfg.ID, DisplayName: displayName},
}, nil
}
-// Post Send POST request
-func (c *APIClient) Post(ctx context.Context, path string, body interface{}, result interface{}) error {
- return c.client.Request(ctx, "POST", path, body, result)
-}
+func loadToken(cfg *config.InstanceConfig) (string, error) {
+ if cfg.TokenFile != "" {
+ dirInfo, err := os.Lstat(filepath.Dir(cfg.TokenFile))
+ if err != nil {
+ return "", errors.Wrap(err, "inspect token_file directory failed")
+ }
+ if dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() || dirInfo.Mode().Perm()&0o022 != 0 {
+ return "", errors.New("token_file directory must be a real directory that is not group or world writable")
+ }
+
+ pathInfo, err := os.Lstat(cfg.TokenFile)
+ if err != nil {
+ return "", errors.Wrap(err, "inspect token_file failed")
+ }
+ if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() || pathInfo.Mode().Perm()&0o077 != 0 {
+ return "", errors.New("token_file must be a regular file with no group or other permissions")
+ }
+
+ file, err := os.Open(cfg.TokenFile)
+ if err != nil {
+ return "", errors.Wrap(err, "open token_file failed")
+ }
+ defer file.Close()
+ openedInfo, err := file.Stat()
+ if err != nil {
+ return "", errors.Wrap(err, "inspect opened token_file failed")
+ }
+ if !os.SameFile(pathInfo, openedInfo) || !openedInfo.Mode().IsRegular() || openedInfo.Mode().Perm()&0o077 != 0 {
+ return "", errors.New("token_file changed while opening")
+ }
+
+ contents, err := io.ReadAll(io.LimitReader(file, maxTokenFileSize+1))
+ if err != nil {
+ return "", errors.Wrap(err, "read token_file failed")
+ }
+ if len(contents) > maxTokenFileSize {
+ return "", errors.New("token_file exceeds the maximum size")
+ }
+ token := strings.TrimSpace(string(contents))
+ if token == "" {
+ return "", errors.New("token_file is empty")
+ }
+ return token, nil
+ }
-// Get Send GET request
-func (c *APIClient) Get(ctx context.Context, path string, result interface{}) error {
- return c.client.Request(ctx, "GET", path, nil, result)
+ return "", errors.New("token_file is required")
}
-// Put Send PUT request
-func (c *APIClient) Put(ctx context.Context, path string, body interface{}, result interface{}) error {
- return c.client.Request(ctx, "PUT", path, body, result)
+// Source returns the non-sensitive identity included in tool results.
+func (c *APIClient) Source() InstanceSource {
+ return c.source
}
-// Delete Send DELETE request
-func (c *APIClient) Delete(ctx context.Context, path string, result interface{}) error {
- return c.client.Request(ctx, "DELETE", path, nil, result)
+// GetAttackEvents is the only SafeLine management request exposed by the
+// client. The path and HTTP method are fixed rather than supplied by a tool.
+func (c *APIClient) GetAttackEvents(ctx context.Context, query url.Values, result any) error {
+ return c.client.get(ctx, attackEventsPath, query, result)
}
diff --git a/mcp_server/internal/api/service_test.go b/mcp_server/internal/api/service_test.go
new file mode 100644
index 00000000..01270270
--- /dev/null
+++ b/mcp_server/internal/api/service_test.go
@@ -0,0 +1,186 @@
+package api
+
+import (
+ "context"
+ stderrors "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/chaitin/SafeLine/mcp_server/internal/config"
+ "github.com/chaitin/SafeLine/mcp_server/pkg/logger"
+)
+
+func TestMain(m *testing.M) {
+ _ = logger.Init(&logger.Config{Level: "error"})
+ os.Exit(m.Run())
+}
+
+func writeTokenFile(t *testing.T, token string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "api_token")
+ if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil {
+ t.Fatalf("write token file: %v", err)
+ }
+ return path
+}
+
+func TestClientRegistryKeepsInstanceCredentialsIsolated(t *testing.T) {
+ type observedRequest struct {
+ method string
+ path string
+ token string
+ query url.Values
+ }
+ requests := make(chan observedRequest, 2)
+ newServer := func() *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests <- observedRequest{
+ method: r.Method,
+ path: r.URL.Path,
+ token: r.Header.Get("X-SLCE-API-TOKEN"),
+ query: r.URL.Query(),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"nodes":[],"total":0},"err":null,"msg":""}`))
+ }))
+ }
+ one := newServer()
+ defer one.Close()
+ two := newServer()
+ defer two.Close()
+
+ registry, err := NewClientRegistry([]*config.InstanceConfig{
+ {ID: "dev152", DisplayName: "Development 152", BaseURL: one.URL, TokenFile: writeTokenFile(t, "token-152")},
+ {ID: "dev180", DisplayName: "Development 180", BaseURL: two.URL, TokenFile: writeTokenFile(t, "token-180")},
+ })
+ if err != nil {
+ t.Fatalf("NewClientRegistry() error = %v", err)
+ }
+
+ for _, id := range []string{"dev152", "dev180"} {
+ client, err := registry.Get(id)
+ if err != nil {
+ t.Fatalf("Get(%q) error = %v", id, err)
+ }
+ var result Response[map[string]any]
+ if err := client.GetAttackEvents(context.Background(), url.Values{"page": {"1"}}, &result); err != nil {
+ t.Fatalf("GetAttackEvents(%q) error = %v", id, err)
+ }
+ }
+
+ seenTokens := map[string]bool{}
+ for range 2 {
+ request := <-requests
+ if request.method != http.MethodGet || request.path != attackEventsPath {
+ t.Errorf("request = %s %s, want GET %s", request.method, request.path, attackEventsPath)
+ }
+ if request.query.Get("page") != "1" {
+ t.Errorf("page query = %q", request.query.Get("page"))
+ }
+ seenTokens[request.token] = true
+ }
+ if !seenTokens["token-152"] || !seenTokens["token-180"] || len(seenTokens) != 2 {
+ t.Fatalf("observed tokens = %#v, want isolated instance tokens", seenTokens)
+ }
+
+ sources := registry.Sources()
+ if len(sources) != 2 || sources[0].InstanceID != "dev152" || sources[1].InstanceID != "dev180" {
+ t.Fatalf("Sources() = %#v", sources)
+ }
+}
+
+func TestClientReturnsHTTP200EnvelopeError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{},"err":"token_invalid","msg":"invalid API token"}`))
+ }))
+ defer server.Close()
+
+ client, err := newAPIClient(&config.InstanceConfig{
+ ID: "broken", BaseURL: server.URL, TokenFile: writeTokenFile(t, "bad-token"),
+ })
+ if err != nil {
+ t.Fatalf("newAPIClient() error = %v", err)
+ }
+ err = client.GetAttackEvents(context.Background(), nil, &Response[map[string]any]{})
+ var responseErr *ResponseError
+ if !stderrors.As(err, &responseErr) {
+ t.Fatalf("GetAttackEvents() error = %T %v, want *ResponseError", err, err)
+ }
+ if responseErr.Code != "token_invalid" || responseErr.Message != "invalid API token" {
+ t.Fatalf("ResponseError = %#v", responseErr)
+ }
+}
+
+func TestClientRegistryRejectsUnknownInstanceAndEmptyToken(t *testing.T) {
+ registry, err := NewClientRegistry([]*config.InstanceConfig{{
+ ID: "known", BaseURL: "https://known.example.test", TokenFile: writeTokenFile(t, "token"),
+ }})
+ if err != nil {
+ t.Fatalf("NewClientRegistry() error = %v", err)
+ }
+ if _, err := registry.Get("missing"); err == nil || !strings.Contains(err.Error(), "unknown SafeLine instance") {
+ t.Fatalf("Get(missing) error = %v", err)
+ }
+
+ _, err = NewClientRegistry([]*config.InstanceConfig{{
+ ID: "empty", BaseURL: "https://empty.example.test", TokenFile: writeTokenFile(t, ""),
+ }})
+ if err == nil || !strings.Contains(err.Error(), "token_file is empty") {
+ t.Fatalf("empty token error = %v", err)
+ }
+}
+
+func TestClientRejectsUnsafeTokenFiles(t *testing.T) {
+ dir := t.TempDir()
+ tokenPath := filepath.Join(dir, "api_token")
+ if err := os.WriteFile(tokenPath, []byte("token"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(tokenPath, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ _, err := NewClientRegistry([]*config.InstanceConfig{{
+ ID: "broad-mode", BaseURL: "https://example.test", TokenFile: tokenPath,
+ }})
+ if err == nil || !strings.Contains(err.Error(), "no group or other permissions") {
+ t.Fatalf("broad token mode error = %v", err)
+ }
+
+ if err := os.Chmod(tokenPath, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ symlinkPath := filepath.Join(dir, "token-link")
+ if err := os.Symlink(tokenPath, symlinkPath); err != nil {
+ t.Fatal(err)
+ }
+ _, err = NewClientRegistry([]*config.InstanceConfig{{
+ ID: "symlink", BaseURL: "https://example.test", TokenFile: symlinkPath,
+ }})
+ if err == nil || !strings.Contains(err.Error(), "regular file") {
+ t.Fatalf("symlink token error = %v", err)
+ }
+}
+
+func TestClientLimitsSafeLineResponseBody(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(strings.Repeat("x", maxResponseBodySize+1)))
+ }))
+ defer server.Close()
+
+ client, err := newAPIClient(&config.InstanceConfig{
+ ID: "oversized", BaseURL: server.URL, TokenFile: writeTokenFile(t, "token"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = client.GetAttackEvents(context.Background(), nil, &Response[map[string]any]{})
+ if err == nil || !strings.Contains(err.Error(), "4 MiB limit") {
+ t.Fatalf("oversized response error = %v", err)
+ }
+}
diff --git a/mcp_server/internal/api/types.go b/mcp_server/internal/api/types.go
deleted file mode 100644
index 5113ce2a..00000000
--- a/mcp_server/internal/api/types.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package api
-
-type PolicyRuleAction int
-
-const (
- PolicyRuleActionAllow PolicyRuleAction = iota
- PolicyRuleActionDeny
- PolicyRuleActionMax
-)
-
-type Key = string
-
-const (
- KeySrcIP Key = "src_ip"
- KeyURI Key = "uri"
- KeyURINoQuery Key = "uri_no_query"
- KeyHost Key = "host"
- KeyMethod Key = "method"
- KeyReqHeader Key = "req_header"
- KeyReqBody Key = "req_body"
- KeyGetParam Key = "get_param"
- KeyPostParam Key = "post_param"
-)
-
-type Op = string
-
-const (
- OpEq Op = "eq" // equal
- OpNotEq Op = "not_eq" // not equal
- OpMatch Op = "match" // match
- OpCIDR Op = "cidr" // cidr
- OpHas Op = "has" // has
- OpNotHas Op = "not_has" // not has
- OpPrefix Op = "prefix" // prefix
- OpRe Op = "re" // regex
- OpIn Op = "in" // in
- OpNotIn Op = "not_in" // not in
- OpNotCIDR Op = "not_cidr" // not cidr
- OpExist Op = "exist" // exist
- OpNotExist Op = "not_exist" // not exist
- OpGeoEq Op = "geo_eq" // geo equal
- OpGeoNotEq Op = "geo_not_eq" // geo not equal
-)
-
-type Pattern struct {
- K Key `json:"k"`
- Op Op `json:"op"`
- V []string `json:"v"`
- SubK string `json:"sub_k"`
-}
diff --git a/mcp_server/internal/auth/auth.go b/mcp_server/internal/auth/auth.go
new file mode 100644
index 00000000..ad9e0f07
--- /dev/null
+++ b/mcp_server/internal/auth/auth.go
@@ -0,0 +1,307 @@
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ EnabledEnv = "MCP_AUTH_ENABLED"
+ DisabledEnv = "MCP_AUTH_DISABLED"
+ StateFileEnv = "MCP_AUTH_STATE_FILE"
+
+ DefaultStateFile = "/var/lib/safeline-mcp/auth.json"
+ stateVersion = 1
+ tokenBytes = 32
+ maxStateFileSize = 16 << 10
+)
+
+var ErrInvalidState = errors.New("invalid MCP authentication state")
+
+type state struct {
+ Version int `json:"version"`
+ TokenHash string `json:"token_hash"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Authenticator verifies the deployment-scoped bearer token without retaining
+// the plaintext token after initialization.
+type Authenticator struct {
+ tokenHash [sha256.Size]byte
+}
+
+// EnabledFromEnv reports whether the MCP bearer gate must be active.
+//
+// Authentication is mandatory unless a deployment explicitly opts out: an
+// unset environment means enabled, so exposing attack telemetry by simply
+// forgetting one variable is not possible. MCP_AUTH_ENABLED=false and
+// MCP_AUTH_DISABLED=true both opt out; the server then refuses to listen on a
+// non-loopback address.
+func EnabledFromEnv() (bool, error) {
+ if raw := strings.TrimSpace(os.Getenv(EnabledEnv)); raw != "" {
+ enabled, err := strconv.ParseBool(raw)
+ if err != nil {
+ return false, fmt.Errorf("parse %s: %w", EnabledEnv, err)
+ }
+ return enabled, nil
+ }
+
+ if raw := strings.TrimSpace(os.Getenv(DisabledEnv)); raw != "" {
+ disabled, err := strconv.ParseBool(raw)
+ if err != nil {
+ return false, fmt.Errorf("parse %s: %w", DisabledEnv, err)
+ }
+ return !disabled, nil
+ }
+
+ return true, nil
+}
+
+func StateFileFromEnv() string {
+ if path := strings.TrimSpace(os.Getenv(StateFileEnv)); path != "" {
+ return path
+ }
+ return DefaultStateFile
+}
+
+// Initialize creates the authentication state once. The returned plaintext
+// token must be shown to the deployer exactly once and is never written to disk.
+func Initialize(path string) (token string, created bool, err error) {
+ if strings.TrimSpace(path) == "" {
+ return "", false, errors.New("authentication state file path is required")
+ }
+
+ if _, err := os.Lstat(path); err == nil {
+ if _, err := Load(path); err != nil {
+ return "", false, fmt.Errorf("existing authentication state is invalid: %w", err)
+ }
+ return "", false, nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return "", false, fmt.Errorf("check authentication state: %w", err)
+ }
+
+ token, s, err := newState()
+ if err != nil {
+ return "", false, err
+ }
+ created, err = writeState(path, s, false)
+ if err != nil {
+ return "", false, err
+ }
+ if !created {
+ // Another initializer won the no-replace install race. Validate its
+ // fully synced state and never expose this process's discarded token.
+ if _, err := Load(path); err != nil {
+ return "", false, fmt.Errorf("concurrently created authentication state is invalid: %w", err)
+ }
+ return "", false, nil
+ }
+ return token, true, nil
+}
+
+// Rotate replaces an existing, valid token state. Initialization is an
+// explicit separate operation so a typo cannot overwrite an unrelated path.
+func Rotate(path string) (string, error) {
+ if _, err := Load(path); err != nil {
+ return "", fmt.Errorf("load existing authentication state before rotation: %w", err)
+ }
+ token, s, err := newState()
+ if err != nil {
+ return "", err
+ }
+ if _, err := writeState(path, s, true); err != nil {
+ return "", err
+ }
+ return token, nil
+}
+
+func newState() (token string, s state, err error) {
+ token, err = generateToken()
+ if err != nil {
+ return "", state{}, err
+ }
+
+ digest := sha256.Sum256([]byte(token))
+ now := time.Now().UTC()
+ s = state{
+ Version: stateVersion,
+ TokenHash: hex.EncodeToString(digest[:]),
+ CreatedAt: now,
+ }
+ return token, s, nil
+}
+
+func generateToken() (string, error) {
+ random := make([]byte, tokenBytes)
+ if _, err := rand.Read(random); err != nil {
+ return "", fmt.Errorf("generate bearer token: %w", err)
+ }
+ return "slmcp_" + base64.RawURLEncoding.EncodeToString(random), nil
+}
+
+// writeState writes a fully synced temporary file before installation. When
+// replace is false, a hard-link install provides atomic create-if-absent
+// semantics: concurrent initializers cannot overwrite the winning state.
+func writeState(path string, s state, replace bool) (bool, error) {
+ if strings.TrimSpace(path) == "" {
+ return false, errors.New("authentication state file path is required")
+ }
+
+ dir := filepath.Dir(path)
+ info, err := os.Lstat(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return false, fmt.Errorf("create authentication state directory: %w", err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ return false, fmt.Errorf("secure authentication state directory: %w", err)
+ }
+ info, err = os.Lstat(dir)
+ } else if err != nil {
+ return false, fmt.Errorf("inspect authentication state directory: %w", err)
+ }
+ if err != nil {
+ return false, fmt.Errorf("inspect authentication state directory: %w", err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
+ return false, errors.New("authentication state directory path must be a real directory")
+ }
+ if info.Mode().Perm()&0o077 != 0 {
+ return false, errors.New("authentication state directory must have no group or other permissions")
+ }
+
+ tmp, err := os.CreateTemp(dir, ".auth-*.tmp")
+ if err != nil {
+ return false, fmt.Errorf("create authentication state: %w", err)
+ }
+ tmpPath := tmp.Name()
+ defer func() {
+ _ = tmp.Close()
+ _ = os.Remove(tmpPath)
+ }()
+
+ if err := tmp.Chmod(0o600); err != nil {
+ return false, fmt.Errorf("secure authentication state: %w", err)
+ }
+ encoder := json.NewEncoder(tmp)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(s); err != nil {
+ return false, fmt.Errorf("encode authentication state: %w", err)
+ }
+ if err := tmp.Sync(); err != nil {
+ return false, fmt.Errorf("sync authentication state: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return false, fmt.Errorf("close authentication state: %w", err)
+ }
+
+ if replace {
+ if err := os.Rename(tmpPath, path); err != nil {
+ return false, fmt.Errorf("install authentication state: %w", err)
+ }
+ return true, nil
+ }
+
+ if err := os.Link(tmpPath, path); err != nil {
+ if errors.Is(err, os.ErrExist) {
+ return false, nil
+ }
+ return false, fmt.Errorf("install authentication state without replacement: %w", err)
+ }
+ return true, nil
+}
+
+func Load(path string) (*Authenticator, error) {
+ dirInfo, err := os.Lstat(filepath.Dir(path))
+ if err != nil {
+ return nil, fmt.Errorf("inspect authentication state directory: %w", err)
+ }
+ if dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() || dirInfo.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("%w: state directory must be a real directory with no group or other permissions", ErrInvalidState)
+ }
+
+ pathInfo, err := os.Lstat(path)
+ if err != nil {
+ return nil, fmt.Errorf("inspect authentication state: %w", err)
+ }
+ if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() || pathInfo.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("%w: state file must be a regular file with no group or other permissions", ErrInvalidState)
+ }
+
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open authentication state: %w", err)
+ }
+ defer file.Close()
+ openedInfo, err := file.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("inspect opened authentication state: %w", err)
+ }
+ if !os.SameFile(pathInfo, openedInfo) || !openedInfo.Mode().IsRegular() || openedInfo.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("%w: authentication state changed while opening", ErrInvalidState)
+ }
+ contents, err := io.ReadAll(io.LimitReader(file, maxStateFileSize+1))
+ if err != nil {
+ return nil, fmt.Errorf("read authentication state: %w", err)
+ }
+ if len(contents) > maxStateFileSize {
+ return nil, fmt.Errorf("%w: state file exceeds the 16 KiB limit", ErrInvalidState)
+ }
+
+ var s state
+ if err := json.Unmarshal(contents, &s); err != nil {
+ return nil, fmt.Errorf("%w: decode state: %v", ErrInvalidState, err)
+ }
+ if s.Version != stateVersion || s.CreatedAt.IsZero() {
+ return nil, ErrInvalidState
+ }
+ digest, err := hex.DecodeString(s.TokenHash)
+ if err != nil || len(digest) != sha256.Size {
+ return nil, ErrInvalidState
+ }
+
+ a := &Authenticator{}
+ copy(a.tokenHash[:], digest)
+ return a, nil
+}
+
+func (a *Authenticator) Verify(token string) bool {
+ if a == nil || token == "" {
+ return false
+ }
+ digest := sha256.Sum256([]byte(token))
+ return subtle.ConstantTimeCompare(a.tokenHash[:], digest[:]) == 1
+}
+
+func (a *Authenticator) Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ token, ok := bearerToken(r.Header.Get("Authorization"))
+ if !ok || !a.Verify(token) {
+ w.Header().Set("WWW-Authenticate", `Bearer realm="safeline-mcp"`)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func bearerToken(header string) (string, bool) {
+ parts := strings.Fields(header)
+ if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
+ return "", false
+ }
+ return parts[1], true
+}
diff --git a/mcp_server/internal/auth/auth_test.go b/mcp_server/internal/auth/auth_test.go
new file mode 100644
index 00000000..446b0966
--- /dev/null
+++ b/mcp_server/internal/auth/auth_test.go
@@ -0,0 +1,286 @@
+package auth
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestInitializePersistsOnlyHashAndKeepsTokenAcrossLoads(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state", "auth.json")
+
+ token, created, err := Initialize(path)
+ if err != nil {
+ t.Fatalf("Initialize() error = %v", err)
+ }
+ if !created || !strings.HasPrefix(token, "slmcp_") {
+ t.Fatalf("Initialize() = (%q, %v), want newly generated prefixed token", token, created)
+ }
+
+ contents, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(contents), token) {
+ t.Fatal("authentication state contains the plaintext token")
+ }
+ var persisted state
+ if err := json.Unmarshal(contents, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted.TokenHash == "" {
+ t.Fatal("authentication state does not contain a token hash")
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := info.Mode().Perm(); got != 0o600 {
+ t.Fatalf("state mode = %o, want 600", got)
+ }
+
+ a, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if !a.Verify(token) || a.Verify(token+"wrong") {
+ t.Fatal("loaded authenticator did not verify exactly the initialized token")
+ }
+
+ second, secondCreated, err := Initialize(path)
+ if err != nil {
+ t.Fatalf("second Initialize() error = %v", err)
+ }
+ if second != "" || secondCreated {
+ t.Fatalf("second Initialize() = (%q, %v), want no rotation", second, secondCreated)
+ }
+}
+
+func TestRotateInvalidatesPreviousToken(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state", "auth.json")
+ oldToken, _, err := Initialize(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ newToken, err := Rotate(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if oldToken == newToken {
+ t.Fatal("Rotate() returned the previous token")
+ }
+
+ a, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.Verify(oldToken) || !a.Verify(newToken) {
+ t.Fatal("rotation did not replace the accepted token")
+ }
+}
+
+func TestInitializeIsAtomicAcrossConcurrentCallers(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state", "auth.json")
+ const callers = 64
+
+ type initializeResult struct {
+ token string
+ created bool
+ err error
+ }
+ start := make(chan struct{})
+ results := make(chan initializeResult, callers)
+ var group sync.WaitGroup
+ for range callers {
+ group.Add(1)
+ go func() {
+ defer group.Done()
+ <-start
+ token, created, err := Initialize(path)
+ results <- initializeResult{token: token, created: created, err: err}
+ }()
+ }
+ close(start)
+ group.Wait()
+ close(results)
+
+ var winningToken string
+ createdCount := 0
+ for result := range results {
+ if result.err != nil {
+ t.Fatalf("Initialize() error = %v", result.err)
+ }
+ if result.created {
+ createdCount++
+ winningToken = result.token
+ } else if result.token != "" {
+ t.Fatalf("losing initializer exposed token %q", result.token)
+ }
+ }
+ if createdCount != 1 {
+ t.Fatalf("created count = %d, want exactly 1", createdCount)
+ }
+ a, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !a.Verify(winningToken) {
+ t.Fatal("persisted state does not accept the one exposed token")
+ }
+}
+
+func TestInitializeRejectsExistingInvalidState(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "auth.json")
+ if err := os.WriteFile(path, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if token, created, err := Initialize(path); err == nil {
+ t.Fatalf("Initialize() = (%q, %v, nil), want invalid-state error", token, created)
+ }
+}
+
+func TestRotateRequiresExistingValidState(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "missing.json")
+ if token, err := Rotate(path); err == nil {
+ t.Fatalf("Rotate() = (%q, nil), want missing-state error", token)
+ }
+
+ if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if token, err := Rotate(path); err == nil {
+ t.Fatalf("Rotate(invalid) = (%q, nil), want invalid-state error", token)
+ }
+}
+
+func TestLoadRejectsOverlyBroadStatePermissions(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state", "auth.json")
+ if _, _, err := Initialize(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(path, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Load(path); err == nil {
+ t.Fatal("Load() accepted authentication state readable by other users")
+ }
+}
+
+func TestLoadRejectsOversizedStateFile(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "state")
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "auth.json")
+ if err := os.WriteFile(path, []byte(strings.Repeat("x", maxStateFileSize+1)), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "16 KiB limit") {
+ t.Fatalf("Load(oversized) error = %v", err)
+ }
+}
+
+func TestInitializeRejectsOverlyBroadStateDirectoryPermissions(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.Chmod(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "auth.json")
+ if _, _, err := Initialize(path); err == nil {
+ t.Fatal("Initialize() accepted a state directory accessible by group or other users")
+ }
+}
+
+func TestMiddlewareRequiresBearerToken(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state", "auth.json")
+ token, _, err := Initialize(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ handler := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+
+ for name, authorization := range map[string]string{
+ "missing": "",
+ "wrong scheme": "Basic " + token,
+ "wrong token": "Bearer wrong",
+ } {
+ t.Run(name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
+ req.Header.Set("Authorization", authorization)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", response.Code)
+ }
+ })
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusNoContent {
+ t.Fatalf("status = %d, want 204", response.Code)
+ }
+}
+
+func TestEnabledFromEnv(t *testing.T) {
+ // Authentication must be mandatory when the deployment says nothing at all,
+ // otherwise forgetting one variable would expose attack telemetry.
+ t.Setenv(EnabledEnv, "")
+ t.Setenv(DisabledEnv, "")
+ enabled, err := EnabledFromEnv()
+ if err != nil || !enabled {
+ t.Fatalf("EnabledFromEnv() with unset environment = (%v, %v), want true", enabled, err)
+ }
+
+ t.Setenv(EnabledEnv, "true")
+ enabled, err = EnabledFromEnv()
+ if err != nil || !enabled {
+ t.Fatalf("EnabledFromEnv() = (%v, %v), want true", enabled, err)
+ }
+
+ t.Setenv(EnabledEnv, "not-a-bool")
+ if _, err := EnabledFromEnv(); err == nil {
+ t.Fatal("EnabledFromEnv() accepted an invalid boolean")
+ }
+
+ t.Setenv(EnabledEnv, "")
+ t.Setenv(DisabledEnv, "true")
+ if enabled, err := EnabledFromEnv(); err != nil || enabled {
+ t.Fatalf("EnabledFromEnv() with %s=true = (%v, %v), want false", DisabledEnv, enabled, err)
+ }
+
+ t.Setenv(EnabledEnv, "false")
+ t.Setenv(DisabledEnv, "")
+ if enabled, err := EnabledFromEnv(); err != nil || enabled {
+ t.Fatalf("EnabledFromEnv() with %s=false = (%v, %v), want false", EnabledEnv, enabled, err)
+ }
+
+ t.Setenv(EnabledEnv, "")
+ t.Setenv(DisabledEnv, "not-a-bool")
+ if _, err := EnabledFromEnv(); err == nil {
+ t.Fatal("EnabledFromEnv() accepted an invalid disable boolean")
+ }
+}
diff --git a/mcp_server/internal/config/config.go b/mcp_server/internal/config/config.go
index 30a3dd7b..51c0aec4 100644
--- a/mcp_server/internal/config/config.go
+++ b/mcp_server/internal/config/config.go
@@ -1,44 +1,49 @@
package config
import (
+ "bytes"
+ "fmt"
"os"
+ "path/filepath"
"strconv"
+ "strings"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"gopkg.in/yaml.v3"
)
-// Config Global configuration structure
+// Config is the complete MCP server configuration.
type Config struct {
- Server *ServerConfig `yaml:"server"`
- Logger *LoggerConfig `yaml:"logger"`
- API *APIConfig `yaml:"api"`
+ Server *ServerConfig `yaml:"server"`
+ Logger *LoggerConfig `yaml:"logger"`
+ Instances []*InstanceConfig `yaml:"instances"`
}
-// APIConfig API configuration
-type APIConfig struct {
- // API base URL
- BaseURL string `yaml:"base_url"`
- // API token
- Token string `yaml:"token"`
- // API timeout
- Timeout int `yaml:"timeout"`
- // API debug mode
- Debug bool `yaml:"debug"`
- // API insecure skip verify
- InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
+// InstanceConfig identifies one independently authenticated SafeLine instance.
+// The API token is deliberately referenced through a file instead of being
+// embedded in the main configuration file.
+type InstanceConfig struct {
+ ID string `yaml:"id"`
+ DisplayName string `yaml:"display_name"`
+ BaseURL string `yaml:"base_url"`
+ TokenFile string `yaml:"token_file"`
+ CAFile string `yaml:"ca_file"`
+ Timeout int `yaml:"timeout"`
+ Debug bool `yaml:"debug"`
+ InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
}
-// ServerConfig Server configuration
+// ServerConfig is the MCP server listener configuration.
type ServerConfig struct {
- Name string `yaml:"name"`
- Version string `yaml:"version"`
- Port int `yaml:"port"`
- Host string `yaml:"host"`
- Secret string `yaml:"secret"`
+ Name string `yaml:"name"`
+ Version string `yaml:"version"`
+ Port int `yaml:"port"`
+ Host string `yaml:"host"`
+ TLSCertFile string `yaml:"tls_cert_file"`
+ TLSKeyFile string `yaml:"tls_key_file"`
}
-// LoggerConfig Logger configuration
+// LoggerConfig is the logging configuration.
type LoggerConfig struct {
Level string `yaml:"level"`
FilePath string `yaml:"file_path"`
@@ -49,7 +54,6 @@ type LoggerConfig struct {
var config *Config
-// getEnvString Get string value from environment variable, return default value if not exists
func getEnvString(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
@@ -57,7 +61,6 @@ func getEnvString(key, defaultValue string) string {
return defaultValue
}
-// getEnvInt Get integer value from environment variable, return default value if not exists or cannot be parsed
func getEnvInt(key string, defaultValue int) int {
if value, exists := os.LookupEnv(key); exists {
if intValue, err := strconv.Atoi(value); err == nil {
@@ -67,33 +70,141 @@ func getEnvInt(key string, defaultValue int) int {
return defaultValue
}
-// Load Load configuration file
+// Load reads and validates the configuration file. Relative token_file paths
+// are resolved from the directory containing the configuration file so that a
+// deployment does not depend on the server process working directory.
func Load(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return errors.Wrap(err, "read config file failed")
}
- config = &Config{}
- if err := yaml.Unmarshal(data, config); err != nil {
+ next := &Config{}
+ decoder := yaml.NewDecoder(bytes.NewReader(data))
+ decoder.KnownFields(true)
+ if err := decoder.Decode(next); err != nil {
return errors.Wrap(err, "unmarshal config failed")
}
- // Override configuration from environment variables
- if config.Server != nil {
- config.Server.Host = getEnvString("LISTEN_ADDRESS", config.Server.Host)
- config.Server.Port = getEnvInt("LISTEN_PORT", config.Server.Port)
+ if next.Server != nil {
+ next.Server.Host = getEnvString("LISTEN_ADDRESS", next.Server.Host)
+ next.Server.Port = getEnvInt("LISTEN_PORT", next.Server.Port)
+ // Empty compose values must not wipe yaml paths.
+ if cert := strings.TrimSpace(os.Getenv("MCP_TLS_CERT")); cert != "" {
+ next.Server.TLSCertFile = cert
+ } else {
+ next.Server.TLSCertFile = strings.TrimSpace(next.Server.TLSCertFile)
+ }
+ if key := strings.TrimSpace(os.Getenv("MCP_TLS_KEY")); key != "" {
+ next.Server.TLSKeyFile = key
+ } else {
+ next.Server.TLSKeyFile = strings.TrimSpace(next.Server.TLSKeyFile)
+ }
+ }
+
+ if next.Server == nil {
+ return errors.New("server configuration is required")
+ }
+ if strings.TrimSpace(next.Server.Name) == "" {
+ return errors.New("server.name is required")
+ }
+ if strings.TrimSpace(next.Server.Version) == "" {
+ return errors.New("server.version is required")
+ }
+ if strings.TrimSpace(next.Server.Host) == "" {
+ return errors.New("server.host is required")
+ }
+ if next.Server.Port < 1 || next.Server.Port > 65535 {
+ return errors.New("server.port must be between 1 and 65535")
+ }
+ if next.Logger == nil {
+ return errors.New("logger configuration is required")
+ }
+
+ if err := normalizeInstances(next, filepath.Dir(path)); err != nil {
+ return err
+ }
+
+ config = next
+ return nil
+}
+
+func normalizeInstances(cfg *Config, configDir string) error {
+ if len(cfg.Instances) == 0 {
+ return errors.New("at least one SafeLine instance is required")
}
- if config.API != nil {
- config.API.BaseURL = getEnvString("SAFELINE_ADDRESS", config.API.BaseURL)
- config.API.Token = getEnvString("SAFELINE_API_TOKEN", config.API.Token)
+ seen := make(map[string]struct{}, len(cfg.Instances))
+ for index, instance := range cfg.Instances {
+ if instance == nil {
+ return errors.New(fmt.Sprintf("instances[%d] is required", index))
+ }
+
+ instance.ID = strings.TrimSpace(instance.ID)
+ instance.DisplayName = strings.TrimSpace(instance.DisplayName)
+ instance.BaseURL = strings.TrimSpace(instance.BaseURL)
+ instance.TokenFile = strings.TrimSpace(instance.TokenFile)
+ instance.CAFile = strings.TrimSpace(instance.CAFile)
+ if instance.ID == "" {
+ return errors.New(fmt.Sprintf("instances[%d].id is required", index))
+ }
+ if _, ok := seen[instance.ID]; ok {
+ return errors.New(fmt.Sprintf("duplicate instance id %q", instance.ID))
+ }
+ seen[instance.ID] = struct{}{}
+
+ if instance.DisplayName == "" {
+ instance.DisplayName = instance.ID
+ }
+ if instance.BaseURL == "" {
+ return errors.New(fmt.Sprintf("instances[%d].base_url is required", index))
+ }
+ if instance.TokenFile == "" {
+ return errors.New(fmt.Sprintf("instances[%d].token_file is required", index))
+ }
+ if instance.TokenFile != "" && !filepath.IsAbs(instance.TokenFile) {
+ instance.TokenFile = filepath.Clean(filepath.Join(configDir, instance.TokenFile))
+ }
+ if instance.CAFile != "" && !filepath.IsAbs(instance.CAFile) {
+ instance.CAFile = filepath.Clean(filepath.Join(configDir, instance.CAFile))
+ }
+
+ if instance.InsecureSkipVerify {
+ return errors.New(fmt.Sprintf(
+ "instances[%d].insecure_skip_verify is not supported; set ca_file to trust a self-signed instance certificate",
+ index,
+ ))
+ }
+ }
+
+ for index, instance := range cfg.Instances {
+ for previousIndex := 0; previousIndex < index; previousIndex++ {
+ previous := cfg.Instances[previousIndex]
+ if strings.EqualFold(instance.DisplayName, previous.DisplayName) {
+ return errors.New(fmt.Sprintf(
+ "duplicate instance display_name %q for instances %q and %q",
+ instance.DisplayName,
+ previous.ID,
+ instance.ID,
+ ))
+ }
+ }
+ for otherIndex, other := range cfg.Instances {
+ if otherIndex != index && strings.EqualFold(instance.DisplayName, other.ID) {
+ return errors.New(fmt.Sprintf(
+ "instance display_name %q for %q conflicts with instance id %q",
+ instance.DisplayName,
+ instance.ID,
+ other.ID,
+ ))
+ }
+ }
}
return nil
}
-// GetServer Get server configuration
+// GetServer returns the listener configuration.
func GetServer() *ServerConfig {
if config == nil {
return nil
@@ -101,7 +212,7 @@ func GetServer() *ServerConfig {
return config.Server
}
-// GetLogger Get logger configuration
+// GetLogger returns the logging configuration.
func GetLogger() *LoggerConfig {
if config == nil {
return nil
@@ -109,10 +220,10 @@ func GetLogger() *LoggerConfig {
return config.Logger
}
-// GetAPI Get API configuration
-func GetAPI() *APIConfig {
+// GetInstances returns all configured SafeLine instances.
+func GetInstances() []*InstanceConfig {
if config == nil {
return nil
}
- return config.API
+ return config.Instances
}
diff --git a/mcp_server/internal/config/config_test.go b/mcp_server/internal/config/config_test.go
new file mode 100644
index 00000000..53253e5b
--- /dev/null
+++ b/mcp_server/internal/config/config_test.go
@@ -0,0 +1,284 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func writeConfigFile(t *testing.T, contents string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ return path
+}
+
+func TestLoadMultiInstanceConfiguration(t *testing.T) {
+ path := writeConfigFile(t, `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 127.0.0.1
+ port: 5678
+logger:
+ level: info
+instances:
+ - id: dev152
+ display_name: Development 152
+ base_url: https://dev152:9443
+ token_file: secrets/dev152_token
+ timeout: 12
+ insecure_skip_verify: false
+ - id: dev180
+ base_url: https://dev180:9443
+ token_file: /run/secrets/dev180_token
+`)
+
+ if err := Load(path); err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ instances := GetInstances()
+ if len(instances) != 2 {
+ t.Fatalf("len(GetInstances()) = %d, want 2", len(instances))
+ }
+ if instances[0].ID != "dev152" || instances[0].DisplayName != "Development 152" {
+ t.Fatalf("first instance = %#v", instances[0])
+ }
+ wantRelativePath := filepath.Join(filepath.Dir(path), "secrets", "dev152_token")
+ if instances[0].TokenFile != wantRelativePath {
+ t.Errorf("relative token_file = %q, want %q", instances[0].TokenFile, wantRelativePath)
+ }
+ if instances[1].DisplayName != "dev180" {
+ t.Errorf("default display_name = %q, want dev180", instances[1].DisplayName)
+ }
+ if instances[1].TokenFile != "/run/secrets/dev180_token" {
+ t.Errorf("absolute token_file = %q", instances[1].TokenFile)
+ }
+}
+
+func TestLoadRejectsDuplicateInstanceIDs(t *testing.T) {
+ path := writeConfigFile(t, `
+server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678}
+logger: {level: info}
+instances:
+ - id: duplicate
+ base_url: https://one.example.test
+ token_file: one_token
+ - id: duplicate
+ base_url: https://two.example.test
+ token_file: two_token
+`)
+
+ err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), "duplicate instance id") {
+ t.Fatalf("Load() error = %v, want duplicate instance id", err)
+ }
+}
+
+func TestLoadRejectsDuplicateDisplayNames(t *testing.T) {
+ path := writeConfigFile(t, `
+server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678}
+logger: {level: info}
+instances:
+ - id: production-a
+ display_name: Production
+ base_url: https://one.example.test
+ token_file: one_token
+ - id: production-b
+ display_name: " production "
+ base_url: https://two.example.test
+ token_file: two_token
+`)
+
+ err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), "duplicate instance display_name") {
+ t.Fatalf("Load() error = %v, want duplicate instance display_name", err)
+ }
+}
+
+func TestLoadRejectsDisplayNameConflictingWithAnotherInstanceID(t *testing.T) {
+ path := writeConfigFile(t, `
+server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678}
+logger: {level: info}
+instances:
+ - id: production-a
+ display_name: PRODUCTION-B
+ base_url: https://one.example.test
+ token_file: one_token
+ - id: production-b
+ display_name: Backup Production
+ base_url: https://two.example.test
+ token_file: two_token
+`)
+
+ err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), "conflicts with instance id") {
+ t.Fatalf("Load() error = %v, want display_name and instance id conflict", err)
+ }
+}
+
+func TestLoadRejectsInlineInstanceToken(t *testing.T) {
+ path := writeConfigFile(t, `
+server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678}
+logger: {level: info}
+instances:
+ - id: unsafe
+ base_url: https://unsafe.example.test
+ token: must-not-be-accepted
+`)
+
+ err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), "field token not found") {
+ t.Fatalf("Load() error = %v, want unknown inline token field", err)
+ }
+}
+
+func TestLoadRequiresAtLeastOneInstance(t *testing.T) {
+ path := writeConfigFile(t, `
+server: {name: SafeLine MCP, version: 1.0.0, host: 127.0.0.1, port: 5678}
+logger: {level: info}
+`)
+ err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), "at least one SafeLine instance") {
+ t.Fatalf("Load() error = %v, want missing instance error", err)
+ }
+}
+
+func TestLoadResolvesCAFileRelativeToTheConfiguration(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+
+ contents := `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 127.0.0.1
+ port: 5678
+logger:
+ level: info
+instances:
+ - id: dev152
+ base_url: https://dev152:9443
+ token_file: secrets/dev152_token
+ ca_file: certs/instance.crt
+`
+ if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ if err := Load(path); err != nil {
+ t.Fatalf("load: %v", err)
+ }
+
+ instances := GetInstances()
+ if len(instances) != 1 {
+ t.Fatalf("got %d instances, want 1", len(instances))
+ }
+ want := filepath.Join(dir, "certs", "instance.crt")
+ if instances[0].CAFile != want {
+ t.Fatalf("ca_file is %q, want %q", instances[0].CAFile, want)
+ }
+}
+
+func TestLoadRejectsCAFileTogetherWithSkippedVerification(t *testing.T) {
+ path := writeConfigFile(t, `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 127.0.0.1
+ port: 5678
+logger:
+ level: info
+instances:
+ - id: dev152
+ base_url: https://dev152:9443
+ token_file: /run/secrets/dev152_token
+ ca_file: /etc/safeline/instance.crt
+ insecure_skip_verify: true
+`)
+
+ err := Load(path)
+ if err == nil {
+ t.Fatal("a configuration that both trusts an authority and skips verification was accepted")
+ }
+ if !strings.Contains(err.Error(), "ca_file") {
+ t.Fatalf("the error does not name the setting: %v", err)
+ }
+}
+
+func TestLoadRejectsInsecureSkipVerify(t *testing.T) {
+ path := writeConfigFile(t, `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 127.0.0.1
+ port: 5678
+logger:
+ level: info
+instances:
+ - id: dev152
+ base_url: https://dev152:9443
+ token_file: /run/secrets/dev152_token
+ insecure_skip_verify: true
+`)
+ err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), "insecure_skip_verify is not supported") {
+ t.Fatalf("Load() error = %v", err)
+ }
+}
+
+func TestLoadTLSCertFromEnv(t *testing.T) {
+ path := writeConfigFile(t, `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 127.0.0.1
+ port: 5678
+ tls_cert_file: /yaml/cert.pem
+ tls_key_file: /yaml/key.pem
+logger:
+ level: info
+instances:
+ - id: dev152
+ base_url: https://dev152:9443
+ token_file: /run/secrets/dev152_token
+`)
+ t.Setenv("MCP_TLS_CERT", "/env/cert.pem")
+ t.Setenv("MCP_TLS_KEY", "/env/key.pem")
+ if err := Load(path); err != nil {
+ t.Fatal(err)
+ }
+ server := GetServer()
+ if server.TLSCertFile != "/env/cert.pem" || server.TLSKeyFile != "/env/key.pem" {
+ t.Fatalf("tls paths = %q %q", server.TLSCertFile, server.TLSKeyFile)
+ }
+}
+
+func TestLoadEmptyTLSEnvDoesNotWipeYAML(t *testing.T) {
+ path := writeConfigFile(t, `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 127.0.0.1
+ port: 5678
+ tls_cert_file: /yaml/cert.pem
+ tls_key_file: /yaml/key.pem
+logger:
+ level: info
+instances:
+ - id: dev152
+ base_url: https://dev152:9443
+ token_file: /run/secrets/dev152_token
+`)
+ t.Setenv("MCP_TLS_CERT", "")
+ t.Setenv("MCP_TLS_KEY", "")
+ if err := Load(path); err != nil {
+ t.Fatal(err)
+ }
+ server := GetServer()
+ if server.TLSCertFile != "/yaml/cert.pem" || server.TLSKeyFile != "/yaml/key.pem" {
+ t.Fatalf("tls paths = %q %q", server.TLSCertFile, server.TLSKeyFile)
+ }
+}
diff --git a/mcp_server/internal/tools/analyze/get_atttack_events.go b/mcp_server/internal/tools/analyze/get_atttack_events.go
index a0ec1b30..20e0750c 100644
--- a/mcp_server/internal/tools/analyze/get_atttack_events.go
+++ b/mcp_server/internal/tools/analyze/get_atttack_events.go
@@ -2,17 +2,24 @@ package analyze
import (
"context"
+ "fmt"
+ "strings"
"github.com/chaitin/SafeLine/mcp_server/internal/api/analyze"
+ "github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
+ markmcp "github.com/mark3labs/mcp-go/mcp"
)
type GetAttackEventsParams struct {
- IP string `json:"ip" desc:"ip" required:"false"`
- Page int `json:"page" desc:"page" required:"false" default:"1"`
- PageSize int `json:"page_size" desc:"page size" required:"false" default:"10"`
- Start int64 `json:"start" desc:"start unix timestamp in milliseconds" required:"false"`
- End int64 `json:"end" desc:"end unix timestamp in milliseconds" required:"false"`
+ InstanceID string `json:"instance_id" jsonschema_description:"Configured SafeLine instance ID"`
+ Page int `json:"page,omitempty" jsonschema_description:"Page number, starting at 1" default:"1"`
+ PageSize int `json:"page_size,omitempty" jsonschema_description:"Events per page, from 1 through 100" default:"10"`
+ IP string `json:"ip,omitempty" jsonschema_description:"Source IP address, CIDR, or partial IP filter"`
+ Host string `json:"host,omitempty" jsonschema_description:"Protected host name filter"`
+ Port int `json:"port,omitempty" jsonschema_description:"Protected destination port filter, from 1 through 65535"`
+ Start int64 `json:"start,omitempty" jsonschema_description:"Start Unix timestamp in milliseconds"`
+ End int64 `json:"end,omitempty" jsonschema_description:"End Unix timestamp in milliseconds"`
}
type GetAttackEvents struct{}
@@ -22,23 +29,82 @@ func (t *GetAttackEvents) Name() string {
}
func (t *GetAttackEvents) Description() string {
- return "get attack events"
+ return "Query aggregated attack events from one configured SafeLine instance"
+}
+
+func (t *GetAttackEvents) InputSchemaOptions() []markmcp.ToolOption {
+ return []markmcp.ToolOption{
+ markmcp.WithString("instance_id",
+ markmcp.Required(),
+ markmcp.MinLength(1),
+ markmcp.Description("Configured SafeLine instance ID"),
+ ),
+ markmcp.WithInteger("page",
+ markmcp.Min(1),
+ markmcp.DefaultNumber(1),
+ markmcp.Description("Page number, starting at 1"),
+ ),
+ markmcp.WithInteger("page_size",
+ markmcp.Min(1),
+ markmcp.Max(100),
+ markmcp.DefaultNumber(10),
+ markmcp.Description("Events per page, from 1 through 100"),
+ ),
+ markmcp.WithString("ip", markmcp.Description("Source IP address, CIDR, or partial IP filter")),
+ markmcp.WithString("host", markmcp.Description("Protected host name filter")),
+ markmcp.WithInteger("port",
+ markmcp.Min(1),
+ markmcp.Max(65535),
+ markmcp.Description("Protected destination port filter, from 1 through 65535"),
+ ),
+ markmcp.WithInteger("start",
+ markmcp.Min(0),
+ markmcp.Description("Start Unix timestamp in milliseconds"),
+ ),
+ markmcp.WithInteger("end",
+ markmcp.Min(0),
+ markmcp.Description("End Unix timestamp in milliseconds"),
+ ),
+ markmcp.WithSchemaAdditionalProperties(false),
+ }
}
func (t *GetAttackEvents) Validate(params GetAttackEventsParams) error {
+ if strings.TrimSpace(params.InstanceID) == "" {
+ return errors.New("instance_id is required")
+ }
+ if params.Page < 1 {
+ return errors.New("page must be at least 1")
+ }
+ if params.PageSize < 1 || params.PageSize > 100 {
+ return errors.New("page_size must be between 1 and 100")
+ }
+ if params.Port < 0 || params.Port > 65535 {
+ return errors.New("port must be between 1 and 65535 when provided")
+ }
+ if params.Start < 0 || params.End < 0 {
+ return errors.New("start and end must be non-negative Unix timestamps")
+ }
+ if params.Start > 0 && params.End > 0 && params.Start > params.End {
+ return errors.New("start must be less than or equal to end")
+ }
return nil
}
func (t *GetAttackEvents) Execute(ctx context.Context, params GetAttackEventsParams) (analyze.GetEventListResponse, error) {
resp, err := analyze.GetEventList(ctx, &analyze.GetEventListRequest{
- IP: params.IP,
- PageSize: params.PageSize,
- Page: params.Page,
- Start: params.Start,
- End: params.End,
+ InstanceID: strings.TrimSpace(params.InstanceID),
+ Page: params.Page,
+ PageSize: params.PageSize,
+ IP: strings.TrimSpace(params.IP),
+ Host: strings.TrimSpace(params.Host),
+ Port: params.Port,
+ Start: params.Start,
+ End: params.End,
})
if err != nil {
- return analyze.GetEventListResponse{}, err
+ logger.With("instance_id", strings.TrimSpace(params.InstanceID)).With("error", err).Error("SafeLine event query failed")
+ return analyze.GetEventListResponse{}, fmt.Errorf("SafeLine query failed for instance %q", strings.TrimSpace(params.InstanceID))
}
logger.With("total", resp.Total).Info("get attack events")
return *resp, nil
diff --git a/mcp_server/internal/tools/analyze/get_atttack_events_test.go b/mcp_server/internal/tools/analyze/get_atttack_events_test.go
new file mode 100644
index 00000000..d825156d
--- /dev/null
+++ b/mcp_server/internal/tools/analyze/get_atttack_events_test.go
@@ -0,0 +1,77 @@
+package analyze
+
+import (
+ "encoding/json"
+ "testing"
+
+ markmcp "github.com/mark3labs/mcp-go/mcp"
+)
+
+func TestGetAttackEventsValidate(t *testing.T) {
+ tool := &GetAttackEvents{}
+ valid := GetAttackEventsParams{InstanceID: "dev152", Page: 1, PageSize: 10}
+ if err := tool.Validate(valid); err != nil {
+ t.Fatalf("Validate(valid) error = %v", err)
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*GetAttackEventsParams)
+ }{
+ {name: "missing instance", mutate: func(p *GetAttackEventsParams) { p.InstanceID = " " }},
+ {name: "page zero", mutate: func(p *GetAttackEventsParams) { p.Page = 0 }},
+ {name: "page size zero", mutate: func(p *GetAttackEventsParams) { p.PageSize = 0 }},
+ {name: "page size too large", mutate: func(p *GetAttackEventsParams) { p.PageSize = 101 }},
+ {name: "negative port", mutate: func(p *GetAttackEventsParams) { p.Port = -1 }},
+ {name: "port too large", mutate: func(p *GetAttackEventsParams) { p.Port = 65536 }},
+ {name: "negative start", mutate: func(p *GetAttackEventsParams) { p.Start = -1 }},
+ {name: "negative end", mutate: func(p *GetAttackEventsParams) { p.End = -1 }},
+ {name: "reversed range", mutate: func(p *GetAttackEventsParams) { p.Start, p.End = 2, 1 }},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ params := valid
+ test.mutate(¶ms)
+ if err := tool.Validate(params); err == nil {
+ t.Fatal("Validate() error = nil, want validation error")
+ }
+ })
+ }
+}
+
+func TestGetAttackEventsV1InputSchema(t *testing.T) {
+ implementation := &GetAttackEvents{}
+ tool := markmcp.NewTool("get_attack_events", implementation.InputSchemaOptions()...)
+ var schema struct {
+ Properties map[string]map[string]any `json:"properties"`
+ Required []string `json:"required"`
+ AdditionalProperties bool `json:"additionalProperties"`
+ }
+ encoded, err := json.Marshal(tool.InputSchema)
+ if err != nil {
+ t.Fatalf("marshal input schema: %v", err)
+ }
+ if err := json.Unmarshal(encoded, &schema); err != nil {
+ t.Fatalf("unmarshal input schema: %v", err)
+ }
+ if len(schema.Required) != 1 || schema.Required[0] != "instance_id" {
+ t.Fatalf("required = %#v, want only instance_id", schema.Required)
+ }
+ for _, field := range []string{"page", "page_size", "port", "start", "end"} {
+ if got := schema.Properties[field]["type"]; got != "integer" {
+ t.Errorf("%s type = %v, want integer", field, got)
+ }
+ }
+ if got := schema.Properties["page"]["minimum"]; got != float64(1) {
+ t.Errorf("page minimum = %v, want 1", got)
+ }
+ if got := schema.Properties["page_size"]["maximum"]; got != float64(100) {
+ t.Errorf("page_size maximum = %v, want 100", got)
+ }
+ if got := schema.Properties["port"]["maximum"]; got != float64(65535) {
+ t.Errorf("port maximum = %v, want 65535", got)
+ }
+ if schema.AdditionalProperties {
+ t.Error("additionalProperties = true, want false")
+ }
+}
diff --git a/mcp_server/internal/tools/app/create_application.go b/mcp_server/internal/tools/app/create_application.go
deleted file mode 100644
index 96809ab2..00000000
--- a/mcp_server/internal/tools/app/create_application.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package app
-
-import (
- "context"
-
- "github.com/chaitin/SafeLine/mcp_server/internal/api/app"
- "github.com/chaitin/SafeLine/mcp_server/pkg/logger"
-)
-
-type CreateApp struct{}
-
-type CreateAppParams struct {
- ServerNames []string `json:"server_names" desc:"domain list" required:"true"`
- Ports []string `json:"ports" desc:"port list" required:"true"`
- Upstreams []string `json:"upstreams" desc:"upstream list" required:"true"`
-}
-
-func (t *CreateApp) Name() string {
- return "create_http_application"
-}
-
-func (t *CreateApp) Description() string {
- return "create a new website or app"
-}
-
-func (t *CreateApp) Validate(params CreateAppParams) error {
- return nil
-}
-
-func (t *CreateApp) Execute(ctx context.Context, params CreateAppParams) (int64, error) {
- id, err := app.CreateApp(ctx, &app.CreateAppRequest{
- ServerNames: params.ServerNames,
- Ports: params.Ports,
- Upstreams: params.Upstreams,
- })
- if err != nil {
- return 0, err
- }
- logger.Info("create app success", logger.Int64("id", id))
- return id, nil
-}
diff --git a/mcp_server/internal/tools/example.go b/mcp_server/internal/tools/example.go
deleted file mode 100644
index d482bc2c..00000000
--- a/mcp_server/internal/tools/example.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package tools
-
-import (
- "context"
-
- "github.com/chaitin/SafeLine/mcp_server/pkg/errors"
- "github.com/chaitin/SafeLine/mcp_server/pkg/logger"
-)
-
-type CalculateSum struct{}
-
-func (t *CalculateSum) Name() string {
- return "calculate_sum"
-}
-
-func (t *CalculateSum) Description() string {
- return "Add two numbers together"
-}
-
-type MyToolInput struct {
- A int `json:"a" desc:"number a" required:"true"`
- B int `json:"b" desc:"number b" required:"true"`
-}
-
-type MyToolOutput struct {
- C int `json:"c"`
-}
-
-func (t *CalculateSum) Validate(params MyToolInput) error {
- return nil
-}
-
-func (t *CalculateSum) Execute(ctx context.Context, params MyToolInput) (MyToolOutput, error) {
- logger.With("a", params.A).
- With("b", params.B).
- Debug("Executing calculation")
-
- result := MyToolOutput{
- C: params.A + params.B,
- }
-
- logger.With("result", result.C).
- Debug("Calculation completed")
-
- return result, errors.New("test error")
-}
diff --git a/mcp_server/internal/tools/init.go b/mcp_server/internal/tools/init.go
index 03f850c9..a8a14825 100644
--- a/mcp_server/internal/tools/init.go
+++ b/mcp_server/internal/tools/init.go
@@ -2,18 +2,8 @@ package tools
import (
"github.com/chaitin/SafeLine/mcp_server/internal/tools/analyze"
- "github.com/chaitin/SafeLine/mcp_server/internal/tools/app"
- "github.com/chaitin/SafeLine/mcp_server/internal/tools/rule"
)
func init() {
- // app
- AppendTool(&app.CreateApp{})
-
- // rule
- AppendTool(&rule.CreateBlacklistRule{})
- AppendTool(&rule.CreateWhitelistRule{})
-
- // analyze
AppendTool(&analyze.GetAttackEvents{})
}
diff --git a/mcp_server/internal/tools/init_test.go b/mcp_server/internal/tools/init_test.go
new file mode 100644
index 00000000..e22e832b
--- /dev/null
+++ b/mcp_server/internal/tools/init_test.go
@@ -0,0 +1,13 @@
+package tools
+
+import "testing"
+
+func TestOnlyReadOnlyAttackEventsToolIsRegistered(t *testing.T) {
+ registered := Tools()
+ if len(registered) != 1 {
+ t.Fatalf("registered tool count = %d, want 1", len(registered))
+ }
+ if got := registered[0].Name(); got != "get_attack_events" {
+ t.Fatalf("registered tool = %q, want get_attack_events", got)
+ }
+}
diff --git a/mcp_server/internal/tools/rule/create_blacklist_rule.go b/mcp_server/internal/tools/rule/create_blacklist_rule.go
deleted file mode 100644
index 3e7b81b6..00000000
--- a/mcp_server/internal/tools/rule/create_blacklist_rule.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package rule
-
-import (
- "context"
-
- "github.com/chaitin/SafeLine/mcp_server/internal/api"
- "github.com/chaitin/SafeLine/mcp_server/internal/api/rule"
- "github.com/chaitin/SafeLine/mcp_server/pkg/logger"
-)
-
-type CreateBlacklistRule struct{}
-
-type CreateBlacklistRuleParams struct {
- Name string `json:"name" desc:"name" required:"true"`
- IP []string `json:"ip" desc:"ip" required:"false"`
- URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"`
-}
-
-func (t *CreateBlacklistRule) Name() string {
- return "create_blacklist_rule"
-}
-
-func (t *CreateBlacklistRule) Description() string {
- return "create a new blacklist rule"
-}
-
-func (t *CreateBlacklistRule) Validate(params CreateBlacklistRuleParams) error {
- return nil
-}
-
-func (t *CreateBlacklistRule) Execute(ctx context.Context, params CreateBlacklistRuleParams) (int64, error) {
- var pattern [][]api.Pattern
- if len(params.IP) > 0 {
- pattern = append(pattern, []api.Pattern{
- {
- K: api.KeySrcIP,
- Op: api.OpEq,
- V: params.IP,
- SubK: "",
- },
- })
- }
- if len(params.URINoQuery) > 0 {
- pattern = append(pattern, []api.Pattern{
- {
- K: api.KeyURINoQuery,
- Op: api.OpEq,
- V: params.URINoQuery,
- SubK: "",
- },
- })
- }
-
- id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{
- Name: params.Name,
- IP: params.IP,
- IsEnabled: true,
- Action: int(api.PolicyRuleActionDeny),
- Pattern: pattern,
- })
- if err != nil {
- return 0, err
- }
- logger.With("id", id).Info("create blacklist rule success")
- return id, nil
-}
diff --git a/mcp_server/internal/tools/rule/create_whitelist_rule.go b/mcp_server/internal/tools/rule/create_whitelist_rule.go
deleted file mode 100644
index 4b83f842..00000000
--- a/mcp_server/internal/tools/rule/create_whitelist_rule.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package rule
-
-import (
- "context"
-
- "github.com/chaitin/SafeLine/mcp_server/internal/api"
- "github.com/chaitin/SafeLine/mcp_server/internal/api/rule"
- "github.com/chaitin/SafeLine/mcp_server/pkg/logger"
-)
-
-type CreateWhitelistRule struct{}
-
-type CreateWhitelistRuleParams struct {
- Name string `json:"name" desc:"name" required:"true"`
- IP []string `json:"ip" desc:"ip" required:"false"`
- URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"`
-}
-
-func (t *CreateWhitelistRule) Name() string {
- return "create_whitelist_rule"
-}
-
-func (t *CreateWhitelistRule) Description() string {
- return "create a new whitelist rule"
-}
-
-func (t *CreateWhitelistRule) Validate(params CreateWhitelistRuleParams) error {
- return nil
-}
-
-func (t *CreateWhitelistRule) Execute(ctx context.Context, params CreateWhitelistRuleParams) (int64, error) {
- var pattern [][]api.Pattern
- if len(params.IP) > 0 {
- pattern = append(pattern, []api.Pattern{
- {
- K: api.KeySrcIP,
- Op: api.OpEq,
- V: params.IP,
- SubK: "",
- },
- })
- }
- if len(params.URINoQuery) > 0 {
- pattern = append(pattern, []api.Pattern{
- {
- K: api.KeyURINoQuery,
- Op: api.OpEq,
- V: params.URINoQuery,
- SubK: "",
- },
- })
- }
-
- id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{
- Name: params.Name,
- IP: params.IP,
- IsEnabled: true,
- Action: int(api.PolicyRuleActionAllow),
- Pattern: pattern,
- })
- if err != nil {
- return 0, err
- }
- logger.With("id", id).Info("create whitelist rule success")
- return id, nil
-}
diff --git a/mcp_server/internal/tools/tool.go b/mcp_server/internal/tools/tool.go
index 6a57c420..ad88eada 100644
--- a/mcp_server/internal/tools/tool.go
+++ b/mcp_server/internal/tools/tool.go
@@ -11,6 +11,7 @@ import (
// Each Tool is wrapped in a toolWrapper that knows its concrete type,
// allowing correct passing of generic parameters during registration.
type ToolWrapper interface {
+ Name() string
Register(s *mcp.MCPServer) error
}
@@ -32,6 +33,10 @@ type toolWrapper[T any, R any] struct {
tool mcp.Tool[T, R]
}
+func (w *toolWrapper[T, R]) Name() string {
+ return w.tool.Name()
+}
+
func (w *toolWrapper[T, R]) Register(s *mcp.MCPServer) error {
logger.Info("Registering tool",
logger.String("name", w.tool.Name()),
diff --git a/mcp_server/main.go b/mcp_server/main.go
index 9a8dcb09..28cac8f4 100644
--- a/mcp_server/main.go
+++ b/mcp_server/main.go
@@ -3,20 +3,121 @@ package main
import (
"flag"
"fmt"
+ "io"
+ "net"
+ "os"
+ "strings"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
+ "github.com/chaitin/SafeLine/mcp_server/internal/auth"
"github.com/chaitin/SafeLine/mcp_server/internal/config"
"github.com/chaitin/SafeLine/mcp_server/internal/tools"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
- "github.com/chaitin/SafeLine/mcp_server/pkg/mcp"
+ mcpserver "github.com/chaitin/SafeLine/mcp_server/pkg/mcp"
)
func main() {
- configPath := flag.String("config", "config.yaml", "path to config file")
- flag.Parse()
+ if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
+ _, _ = fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string, stdout, stderr io.Writer) error {
+ if len(args) > 0 && args[0] == "auth" {
+ return runAuth(args[1:], stdout, stderr)
+ }
+ return runServer(args, stderr)
+}
+
+func runAuth(args []string, stdout, stderr io.Writer) error {
+ if len(args) == 0 {
+ return fmt.Errorf("usage: mcp-server auth [--state-file path]")
+ }
+
+ command := args[0]
+ flags := flag.NewFlagSet("auth "+command, flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ stateFile := flags.String("state-file", auth.StateFileFromEnv(), "authentication state file")
+ if err := flags.Parse(args[1:]); err != nil {
+ return err
+ }
+ if flags.NArg() != 0 {
+ return fmt.Errorf("unexpected auth arguments: %s", strings.Join(flags.Args(), " "))
+ }
+
+ switch command {
+ case "init":
+ token, created, err := auth.Initialize(*stateFile)
+ if err != nil {
+ return err
+ }
+ if !created {
+ _, _ = fmt.Fprintf(stdout, "MCP authentication is already initialized at %s; token was not changed.\n", *stateFile)
+ return nil
+ }
+ printNewToken(stdout, token)
+ return nil
+ case "rotate":
+ token, err := auth.Rotate(*stateFile)
+ if err != nil {
+ return err
+ }
+ printNewToken(stdout, token)
+ return nil
+ default:
+ return fmt.Errorf("unknown auth command %q; expected init or rotate", command)
+ }
+}
+
+func printNewToken(output io.Writer, token string) {
+ _, _ = fmt.Fprintln(output, "MCP bearer token (shown once; save it now):")
+ _, _ = fmt.Fprintln(output, token)
+}
+
+func buildServerInstructions(instances []*config.InstanceConfig) string {
+ var instructions strings.Builder
+ instructions.WriteString("This server exposes read-only SafeLine data. The get_attack_events tool requires an instance_id.\n")
+ instructions.WriteString("Configured SafeLine instance mappings:\n")
+ for _, instance := range instances {
+ instructions.WriteString(fmt.Sprintf(
+ "- display_name %q -> instance_id %q\n",
+ instance.DisplayName,
+ instance.ID,
+ ))
+ }
+ instructions.WriteString("When the user refers to an instance by display_name, use exactly the mapped instance_id. Never guess an instance_id or substitute another instance.")
+ return instructions.String()
+}
+
+// isLoopbackHost reports whether host only accepts connections from the local
+// machine. Anything else (an interface address, 0.0.0.0, a hostname) is
+// reachable from the network.
+func isLoopbackHost(host string) bool {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return false
+ }
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(strings.Trim(host, "[]"))
+ return ip != nil && ip.IsLoopback()
+}
+
+func runServer(args []string, stderr io.Writer) error {
+ flags := flag.NewFlagSet("mcp-server", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ configPath := flags.String("config", "config.yaml", "path to config file")
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+ if flags.NArg() != 0 {
+ return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
+ }
if err := config.Load(*configPath); err != nil {
- panic(fmt.Errorf("failed to load config: %v", err))
+ return fmt.Errorf("load config: %w", err)
}
logConfig := config.GetLogger()
@@ -27,36 +128,60 @@ func main() {
Caller: logConfig.Caller,
Development: logConfig.Development,
}); err != nil {
- panic(fmt.Errorf("failed to init logger: %v", err))
+ return fmt.Errorf("initialize logger: %w", err)
}
- logger.With("base_url", config.GetAPI().BaseURL).Info("Initializing API service...")
- if err := api.Init(config.GetAPI()); err != nil {
- panic(fmt.Errorf("failed to init API service: %v", err))
+ serverConfig := config.GetServer()
+ authEnabled, err := auth.EnabledFromEnv()
+ if err != nil {
+ return err
+ }
+ if !authEnabled && !isLoopbackHost(serverConfig.Host) {
+ return fmt.Errorf(
+ "MCP authentication is disabled but the listener is not loopback-only (host %q); keep authentication enabled or bind a loopback address",
+ serverConfig.Host,
+ )
+ }
+ certFile := strings.TrimSpace(serverConfig.TLSCertFile)
+ keyFile := strings.TrimSpace(serverConfig.TLSKeyFile)
+ if (certFile == "") != (keyFile == "") {
+ return fmt.Errorf("MCP_TLS_CERT and MCP_TLS_KEY (or tls_cert_file and tls_key_file) must be set together")
+ }
+ if !isLoopbackHost(serverConfig.Host) && certFile == "" {
+ return fmt.Errorf(
+ "non-loopback MCP listener %q requires TLS; set MCP_TLS_CERT and MCP_TLS_KEY",
+ serverConfig.Host,
+ )
}
- logger.Info("Starting MCP Server...")
- serverConfig := config.GetServer()
- s := mcp.NewMCPServer(
- serverConfig.Name,
- serverConfig.Version,
- serverConfig.Secret,
- )
+ instances := config.GetInstances()
+ logger.With("instances", len(instances)).Info("initializing SafeLine API clients")
+ if err := api.InitInstances(instances); err != nil {
+ return fmt.Errorf("initialize SafeLine API clients: %w", err)
+ }
+
+ var middleware []mcpserver.Middleware
+ if authEnabled {
+ stateFile := auth.StateFileFromEnv()
+ authenticator, err := auth.Load(stateFile)
+ if err != nil {
+ return fmt.Errorf("MCP authentication is enabled but not initialized; run `mcp-server auth init --state-file %s`: %w", stateFile, err)
+ }
+ middleware = append(middleware, mcpserver.Middleware(authenticator.Middleware))
+ }
+ logger.With("enabled", authEnabled).Info("MCP bearer authentication configured")
- logger.Info("Registering tools...")
+ s := mcpserver.NewMCPServer(serverConfig.Name, serverConfig.Version, buildServerInstructions(instances))
for _, tool := range tools.Tools() {
if err := tool.Register(s); err != nil {
- logger.With("error", err).
- Error("Failed to register tool")
- panic(err)
+ return fmt.Errorf("register tool: %w", err)
}
}
addr := fmt.Sprintf("%s:%d", serverConfig.Host, serverConfig.Port)
- logger.With("addr", addr).Info("Starting server")
- if err := s.Start(addr); err != nil {
- logger.With("error", err).
- Error("Server failed to start")
- panic(err)
+ logger.With("addr", addr).With("endpoint", mcpserver.EndpointPath).Info("starting MCP server")
+ if certFile != "" {
+ return s.StartTLS(addr, certFile, keyFile, middleware...)
}
+ return s.Start(addr, middleware...)
}
diff --git a/mcp_server/main_test.go b/mcp_server/main_test.go
new file mode 100644
index 00000000..bbdc889c
--- /dev/null
+++ b/mcp_server/main_test.go
@@ -0,0 +1,171 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/chaitin/SafeLine/mcp_server/internal/auth"
+ "github.com/chaitin/SafeLine/mcp_server/internal/config"
+)
+
+func TestAuthInitIsOneTimeAndRotateIsExplicit(t *testing.T) {
+ stateFile := filepath.Join(t.TempDir(), "state", "auth.json")
+ var first bytes.Buffer
+ if err := run([]string{"auth", "init", "--state-file", stateFile}, &first, &bytes.Buffer{}); err != nil {
+ t.Fatalf("auth init error = %v", err)
+ }
+ firstToken := lastLine(first.String())
+ if !strings.HasPrefix(firstToken, "slmcp_") {
+ t.Fatalf("init output did not contain generated token: %q", first.String())
+ }
+
+ contents, err := os.ReadFile(stateFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(contents), firstToken) {
+ t.Fatal("state file contains plaintext token")
+ }
+
+ var second bytes.Buffer
+ if err := run([]string{"auth", "init", "--state-file", stateFile}, &second, &bytes.Buffer{}); err != nil {
+ t.Fatalf("second auth init error = %v", err)
+ }
+ if strings.Contains(second.String(), "slmcp_") {
+ t.Fatalf("second init exposed or generated a token: %q", second.String())
+ }
+
+ var rotated bytes.Buffer
+ if err := run([]string{"auth", "rotate", "--state-file", stateFile}, &rotated, &bytes.Buffer{}); err != nil {
+ t.Fatalf("auth rotate error = %v", err)
+ }
+ rotatedToken := lastLine(rotated.String())
+ if rotatedToken == firstToken || !strings.HasPrefix(rotatedToken, "slmcp_") {
+ t.Fatalf("rotate output token = %q", rotatedToken)
+ }
+ a, err := auth.Load(stateFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.Verify(firstToken) || !a.Verify(rotatedToken) {
+ t.Fatal("explicit rotation did not replace the accepted token")
+ }
+}
+
+func lastLine(output string) string {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ return lines[len(lines)-1]
+}
+
+func TestBuildServerInstructionsIncludesFriendlyInstanceMappings(t *testing.T) {
+ instructions := buildServerInstructions([]*config.InstanceConfig{
+ {
+ ID: "dev152",
+ DisplayName: "Development 152",
+ BaseURL: "https://secret.internal",
+ TokenFile: "/run/secrets/dev152.token",
+ },
+ {
+ ID: "dev180",
+ DisplayName: "Development 180",
+ BaseURL: "https://other.internal",
+ TokenFile: "/run/secrets/dev180.token",
+ },
+ })
+
+ for _, expected := range []string{
+ `display_name "Development 152" -> instance_id "dev152"`,
+ `display_name "Development 180" -> instance_id "dev180"`,
+ "Never guess an instance_id",
+ } {
+ if !strings.Contains(instructions, expected) {
+ t.Errorf("instructions = %q, want substring %q", instructions, expected)
+ }
+ }
+ for _, sensitive := range []string{
+ "secret.internal",
+ "other.internal",
+ "/run/secrets/dev152.token",
+ "/run/secrets/dev180.token",
+ } {
+ if strings.Contains(instructions, sensitive) {
+ t.Fatalf("instructions exposed instance configuration %q: %q", sensitive, instructions)
+ }
+ }
+}
+
+func TestAuthDisabledRefusesNonLoopbackListener(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ contents := `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 0.0.0.0
+ port: 5678
+logger:
+ level: info
+instances:
+ - id: dev152
+ base_url: https://dev152:9443
+ token_file: /run/secrets/dev152_token
+`
+ if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv(auth.DisabledEnv, "true")
+ err := run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{})
+ if err == nil || !strings.Contains(err.Error(), "loopback") {
+ t.Fatalf("run() error = %v, want refusal of a non-loopback listener", err)
+ }
+}
+
+func TestIsLoopbackHost(t *testing.T) {
+ for _, host := range []string{"127.0.0.1", "::1", "[::1]", "localhost", "LocalHost"} {
+ if !isLoopbackHost(host) {
+ t.Errorf("isLoopbackHost(%q) = false, want true", host)
+ }
+ }
+ for _, host := range []string{"", "0.0.0.0", "::", "10.0.0.10", "example.com"} {
+ if isLoopbackHost(host) {
+ t.Errorf("isLoopbackHost(%q) = true, want false", host)
+ }
+ }
+}
+
+func TestNonLoopbackListenerRequiresTLS(t *testing.T) {
+ dir := t.TempDir()
+ stateDir := filepath.Join(dir, "state")
+ if err := os.Mkdir(stateDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ configPath := filepath.Join(dir, "config.yaml")
+ contents := `
+server:
+ name: SafeLine MCP
+ version: 1.0.0
+ host: 0.0.0.0
+ port: 5678
+logger:
+ level: info
+instances:
+ - id: dev152
+ base_url: https://dev152:9443
+ token_file: /run/secrets/dev152_token
+`
+ if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ stateFile := filepath.Join(stateDir, "auth.json")
+ if _, _, err := auth.Initialize(stateFile); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(auth.StateFileEnv, stateFile)
+ err := run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{})
+ if err == nil || !strings.Contains(err.Error(), "requires TLS") {
+ t.Fatalf("run() error = %v, want TLS requirement", err)
+ }
+}
diff --git a/mcp_server/pkg/config/config.go b/mcp_server/pkg/config/config.go
deleted file mode 100644
index a4d93f13..00000000
--- a/mcp_server/pkg/config/config.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package config
-
-import (
- "os"
-
- "gopkg.in/yaml.v3"
-)
-
-// Config Global configuration structure
-type Config struct {
- Server ServerConfig `yaml:"server"`
- Logger LoggerConfig `yaml:"logger"`
-}
-
-// ServerConfig Server configuration
-type ServerConfig struct {
- Name string `yaml:"name"`
- Version string `yaml:"version"`
- Port int `yaml:"port"`
- Host string `yaml:"host"`
- Secret string `yaml:"secret"`
-}
-
-// LoggerConfig Logger configuration
-type LoggerConfig struct {
- Level string `yaml:"level"`
- FilePath string `yaml:"file_path"`
- Console bool `yaml:"console"`
- Caller bool `yaml:"caller"`
- Development bool `yaml:"development"`
-}
-
-var (
- globalConfig *Config
-)
-
-// Load Load configuration from file
-func Load(filename string) error {
- data, err := os.ReadFile(filename)
- if err != nil {
- return err
- }
-
- config := &Config{}
- if err := yaml.Unmarshal(data, config); err != nil {
- return err
- }
-
- globalConfig = config
- return nil
-}
-
-// Get Get global configuration
-func Get() *Config {
- return globalConfig
-}
-
-// GetServer Get server configuration
-func GetServer() ServerConfig {
- if globalConfig == nil {
- return ServerConfig{}
- }
- return globalConfig.Server
-}
-
-// GetLogger Get logger configuration
-func GetLogger() LoggerConfig {
- if globalConfig == nil {
- return LoggerConfig{}
- }
- return globalConfig.Logger
-}
diff --git a/mcp_server/pkg/errors/errors.go b/mcp_server/pkg/errors/errors.go
index ee6ebae5..64f42a1d 100644
--- a/mcp_server/pkg/errors/errors.go
+++ b/mcp_server/pkg/errors/errors.go
@@ -28,11 +28,16 @@ type Error struct {
}
// Error Implement error interface
+//
+// The message never carries the source location: it is what the MCP client
+// receives as the text of a failed tool call, and the location is an absolute
+// path of the machine the helper runs on. WrapL puts it into the server side
+// log, where it belongs, and Location exposes it to callers that need it.
func (e *Error) Error() string {
if e.msg != "" {
- return fmt.Sprintf("%s: %v (at %s)", e.msg, e.err, e.location)
+ return fmt.Sprintf("%s: %v", e.msg, e.err)
}
- return fmt.Sprintf("%v (at %s)", e.err, e.location)
+ return fmt.Sprintf("%v", e.err)
}
// Unwrap Return original error
diff --git a/mcp_server/pkg/logger/logger.go b/mcp_server/pkg/logger/logger.go
index e52ad250..5a6e4b30 100644
--- a/mcp_server/pkg/logger/logger.go
+++ b/mcp_server/pkg/logger/logger.go
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"sync"
+ "syscall"
"time"
"go.uber.org/zap"
@@ -34,6 +35,11 @@ type Config struct {
Development bool `json:"development" yaml:"development"`
}
+// logFileMode keeps the log file readable for its owner and its group only.
+// The tools that run behind this logger pass request arguments through it, and
+// those are none of the business of every other account on the host.
+const logFileMode os.FileMode = 0640
+
// 默认配置
var defaultConfig = Config{
Level: "info",
@@ -87,8 +93,13 @@ func Init(cfg *Config) error {
// 文件输出
if cfg.FilePath != "" {
- fileWriter, err := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
- if err != nil {
+ // Do not shadow err: the old code declared a new variable here, so
+ // a failure to open the log file returned a nil error and left
+ // defaultLogger unset, which turned every later log call into a nil
+ // pointer dereference.
+ fileWriter, openErr := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW, logFileMode)
+ if openErr != nil {
+ err = openErr
return
}
cores = append(cores, zapcore.NewCore(
@@ -130,8 +141,18 @@ func Init(cfg *Config) error {
// GetLogger 获取日志实例
func GetLogger() *Logger {
if defaultLogger == nil {
- Init(nil)
+ _ = Init(nil)
}
+
+ if defaultLogger == nil {
+ // Init only runs once: when it failed (or when it was called with a
+ // configuration that could not be opened) it never installs a logger,
+ // and the call above cannot repair that. Handing out nil here crashed
+ // the caller on the next log call, so a no-op logger is returned
+ // instead.
+ return &Logger{zl: zap.NewNop()}
+ }
+
return defaultLogger
}
diff --git a/mcp_server/pkg/mcp/mcp.go b/mcp_server/pkg/mcp/mcp.go
index 55be4e4b..a64d04e4 100644
--- a/mcp_server/pkg/mcp/mcp.go
+++ b/mcp_server/pkg/mcp/mcp.go
@@ -2,18 +2,22 @@ package mcp
import (
"context"
- "encoding/json"
"fmt"
"net/http"
- "strconv"
+ "strings"
+ "time"
- "github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
- "github.com/mark3labs/mcp-go/mcp"
+ markmcp "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mcuadros/go-defaults"
)
+const (
+ EndpointPath = "/mcp"
+ maxRequestBodySize = 1 << 20
+)
+
type Tool[T any, R any] interface {
Name() string
@@ -24,125 +28,129 @@ type Tool[T any, R any] interface {
Validate(params T) error
}
-type SSEServer struct {
- sse *server.SSEServer
- secret string
+// InputSchemaProvider lets a tool declare constraints that cannot be inferred
+// from Go types alone, such as numeric ranges and documented defaults.
+type InputSchemaProvider interface {
+ InputSchemaOptions() []markmcp.ToolOption
}
-func (s *SSEServer) Start(addr string) error {
- srv := &http.Server{
- Addr: addr,
- Handler: s,
- }
- return srv.ListenAndServe()
-}
-
-func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if s.secret == "" {
- s.sse.ServeHTTP(w, r)
- return
- }
- messagePath := s.sse.CompleteMessagePath()
- if messagePath != "" && r.URL.Path == messagePath {
- secret := r.Header.Get("Secret")
- if secret != s.secret {
- http.Error(w, "Unauthorized", http.StatusUnauthorized)
- return
- }
- }
- s.sse.ServeHTTP(w, r)
-}
+type Middleware func(http.Handler) http.Handler
type MCPServer struct {
- server *server.MCPServer
- sse *SSEServer
+ server *server.MCPServer
+ transport *server.StreamableHTTPServer
}
-func NewMCPServer(name, version string, secret string) *MCPServer {
+func NewMCPServer(name, version, instructions string) *MCPServer {
s := server.NewMCPServer(
name,
version,
- server.WithLogging(),
+ server.WithInstructions(instructions),
+ server.WithToolCapabilities(false),
+ server.WithInputSchemaValidation(),
+ server.WithStrictInputSchemaDefault(),
+ server.WithOutputSchemaValidation(),
)
- return &MCPServer{
- server: s,
- sse: &SSEServer{sse: server.NewSSEServer(s), secret: secret},
+ transport := server.NewStreamableHTTPServer(
+ s,
+ server.WithEndpointPath(EndpointPath),
+ server.WithStateLess(true),
+ )
+ return &MCPServer{server: s, transport: transport}
+}
+
+// Handler exposes exactly one Streamable HTTP endpoint. Legacy /sse and
+// /message routes are intentionally not mounted.
+func (s *MCPServer) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.Handle(EndpointPath, http.MaxBytesHandler(s.transport, maxRequestBodySize))
+ return mux
+}
+
+func (s *MCPServer) Start(addr string, middleware ...Middleware) error {
+ return s.listen(addr, "", "", middleware...)
+}
+
+// StartTLS serves the same handler over HTTPS.
+func (s *MCPServer) StartTLS(addr, certFile, keyFile string, middleware ...Middleware) error {
+ if strings.TrimSpace(certFile) == "" || strings.TrimSpace(keyFile) == "" {
+ return fmt.Errorf("tls cert and key are required")
}
+ return s.listen(addr, certFile, keyFile, middleware...)
}
-func (s *MCPServer) Start(addr string) error {
- return s.sse.Start(addr)
+func (s *MCPServer) listen(addr, certFile, keyFile string, middleware ...Middleware) error {
+ var handler http.Handler = s.Handler()
+ for i := len(middleware) - 1; i >= 0; i-- {
+ handler = middleware[i](handler)
+ }
+
+ srv := &http.Server{
+ Addr: addr,
+ Handler: handler,
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+ if certFile != "" {
+ return srv.ListenAndServeTLS(certFile, keyFile)
+ }
+ return srv.ListenAndServe()
}
-func handleToolCall[T any, R any](ctx context.Context, request mcp.CallToolRequest, tool Tool[T, R]) (result *mcp.CallToolResult, err error) {
+func handleToolCall[T any, R any](ctx context.Context, request markmcp.CallToolRequest, tool Tool[T, R]) (result *markmcp.CallToolResult, err error) {
defer func() {
- if r := recover(); r != nil {
- err = fmt.Errorf("panic recovered: %v", r)
+ if recovered := recover(); recovered != nil {
+ logger.With("tool", tool.Name()).Error("tool execution panicked")
+ result = markmcp.NewToolResultError("tool execution failed")
+ err = nil
}
}()
- var raw []byte
- raw, err = json.Marshal(request.Params.Arguments)
- if err != nil {
- return nil, errors.Wrap(err, "marshal arguments failed")
- }
var params T
defaults.SetDefaults(¶ms)
- if err = json.Unmarshal(raw, ¶ms); err != nil {
- return nil, errors.Wrap(err, "unmarshal parameters failed")
+ if err := request.BindArguments(¶ms); err != nil {
+ return markmcp.NewToolResultError(fmt.Sprintf("invalid arguments: %v", err)), nil
}
-
- if err = tool.Validate(params); err != nil {
- return nil, err
+ if err := tool.Validate(params); err != nil {
+ return markmcp.NewToolResultError(fmt.Sprintf("invalid arguments: %v", err)), nil
}
- var execResult R
- execResult, err = tool.Execute(ctx, params)
+ execResult, err := tool.Execute(ctx, params)
if err != nil {
- return nil, err
+ logger.With("tool", tool.Name()).With("error", err).Error("tool execution failed")
+ return markmcp.NewToolResultError(err.Error()), nil
}
- v := any(execResult)
- switch v := v.(type) {
- case string:
- return mcp.NewToolResultText(v), nil
- case []byte:
- return mcp.NewToolResultText(string(v)), nil
- case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
- return mcp.NewToolResultText(json.Number(fmt.Sprint(v)).String()), nil
- case bool:
- return mcp.NewToolResultText(strconv.FormatBool(v)), nil
- default:
- bytes, err := json.Marshal(v)
- if err != nil {
- return nil, errors.New("invalid result type")
- }
- return mcp.NewToolResultText(string(bytes)), nil
+ result, err = markmcp.NewToolResultJSON(execResult)
+ if err != nil {
+ logger.With("tool", tool.Name()).With("error", err).Error("tool result serialization failed")
+ return markmcp.NewToolResultError("tool result serialization failed"), nil
}
+ return result, nil
}
func RegisterTool[T any, R any](s *MCPServer, tool Tool[T, R]) error {
- var v T
- opts, err := SchemaToOptions(v)
- if err != nil {
- return err
+ options := []markmcp.ToolOption{
+ markmcp.WithDescription(tool.Description()),
+ }
+ if provider, ok := any(tool).(InputSchemaProvider); ok {
+ options = append(options, provider.InputSchemaOptions()...)
+ } else {
+ options = append(options, markmcp.WithInputSchema[T]())
}
- opts = append(opts, mcp.WithDescription(tool.Description()))
- t := mcp.NewTool(tool.Name(),
- opts...,
+ options = append(options,
+ markmcp.WithOutputSchema[R](),
+ markmcp.WithReadOnlyHintAnnotation(true),
+ markmcp.WithDestructiveHintAnnotation(false),
+ markmcp.WithIdempotentHintAnnotation(true),
+ markmcp.WithOpenWorldHintAnnotation(true),
)
+ t := markmcp.NewTool(tool.Name(), options...)
- s.server.AddTool(t, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- result, err := handleToolCall(ctx, request, tool)
- if err != nil {
- logger.With("error", err).Error("handle tool call failed")
- if wrapped, ok := err.(*errors.Error); ok {
- return nil, wrapped.Unwrap()
- }
- return nil, err
- }
- return result, nil
+ s.server.AddTool(t, func(ctx context.Context, request markmcp.CallToolRequest) (*markmcp.CallToolResult, error) {
+ return handleToolCall(ctx, request, tool)
})
-
return nil
}
diff --git a/mcp_server/pkg/mcp/mcp_test.go b/mcp_server/pkg/mcp/mcp_test.go
new file mode 100644
index 00000000..7ff219a7
--- /dev/null
+++ b/mcp_server/pkg/mcp/mcp_test.go
@@ -0,0 +1,239 @@
+package mcp
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/chaitin/SafeLine/mcp_server/pkg/logger"
+ markmcp "github.com/mark3labs/mcp-go/mcp"
+)
+
+type probeParams struct {
+ Count int `json:"count" jsonschema:"Number of results to return"`
+}
+
+type probeResult struct {
+ Count int `json:"count" jsonschema:"Returned result count"`
+}
+
+type probeTool struct{}
+
+func (probeTool) Name() string { return "probe" }
+func (probeTool) Description() string { return "Protocol test tool" }
+func (probeTool) Validate(params probeParams) error {
+ if params.Count < 1 {
+ return errors.New("count must be at least 1")
+ }
+ return nil
+}
+func (probeTool) Execute(_ context.Context, params probeParams) (probeResult, error) {
+ if params.Count == 13 {
+ return probeResult{}, errors.New("downstream failed")
+ }
+ return probeResult{Count: params.Count}, nil
+}
+
+func TestModernStreamableHTTPAndToolErrorSemantics(t *testing.T) {
+ if err := logger.Init(&logger.Config{Level: "error"}); err != nil {
+ t.Fatal(err)
+ }
+ const instructions = "Use the configured display-name to instance-ID mappings."
+ s := NewMCPServer("test", "1.0.0", instructions)
+ if err := RegisterTool[probeParams, probeResult](s, probeTool{}); err != nil {
+ t.Fatal(err)
+ }
+ httpServer := httptest.NewServer(s.Handler())
+ defer httpServer.Close()
+
+ discover := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodServerDiscover, map[string]any{})
+ result := requireRPCResult(t, discover)
+ if result["instructions"] != instructions {
+ t.Fatalf("instructions = %#v, want %q", result["instructions"], instructions)
+ }
+ versions, ok := result["supportedVersions"].([]any)
+ if !ok || len(versions) == 0 || versions[0] != markmcp.ProtocolVersion20260728 {
+ t.Fatalf("supportedVersions = %#v, want %s first", result["supportedVersions"], markmcp.ProtocolVersion20260728)
+ }
+
+ valid := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodToolsCall, map[string]any{
+ "name": "probe",
+ "arguments": map[string]any{"count": 2},
+ })
+ validResult := requireRPCResult(t, valid)
+ if validResult["isError"] == true {
+ t.Fatalf("valid tool result = %#v", validResult)
+ }
+ structured := validResult["structuredContent"].(map[string]any)
+ if structured["count"] != float64(2) {
+ t.Fatalf("structuredContent = %#v", structured)
+ }
+
+ for name, arguments := range map[string]map[string]any{
+ "decimal integer": {"count": 1.5},
+ "unknown field": {"count": 1, "unexpected": true},
+ "validation": {"count": 0},
+ "downstream": {"count": 13},
+ } {
+ t.Run(name, func(t *testing.T) {
+ response := postModern(t, httpServer.URL+EndpointPath, markmcp.MethodToolsCall, map[string]any{
+ "name": "probe",
+ "arguments": arguments,
+ })
+ toolResult := requireRPCResult(t, response)
+ if toolResult["isError"] != true {
+ t.Fatalf("tool result = %#v, want isError=true", toolResult)
+ }
+ })
+ }
+
+ for _, path := range []string{"/sse", "/message"} {
+ response, err := http.Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = response.Body.Close()
+ if response.StatusCode != http.StatusNotFound {
+ t.Fatalf("GET %s status = %d, want 404", path, response.StatusCode)
+ }
+ }
+}
+
+func TestHandlerRejectsOversizedRequestBody(t *testing.T) {
+ s := NewMCPServer("test", "1.0.0", "")
+ body := strings.NewReader(strings.Repeat("x", maxRequestBodySize+1))
+ req := httptest.NewRequest(http.MethodPost, EndpointPath, body)
+ req.Header.Set("Content-Type", "application/json")
+ response := httptest.NewRecorder()
+
+ s.Handler().ServeHTTP(response, req)
+ if !strings.Contains(response.Body.String(), "request body too large") {
+ t.Fatalf("oversized response = status %d body %q", response.Code, response.Body.String())
+ }
+}
+
+func TestLegacyInitializeReturnsInstructions(t *testing.T) {
+ const instructions = "Use the configured display-name to instance-ID mappings."
+ s := NewMCPServer("test", "1.0.0", instructions)
+ httpServer := httptest.NewServer(s.Handler())
+ defer httpServer.Close()
+
+ body, err := json.Marshal(map[string]any{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": markmcp.MethodInitialize,
+ "params": map[string]any{
+ "protocolVersion": markmcp.ProtocolVersion20251125,
+ "clientInfo": map[string]any{
+ "name": "safeline-mcp-legacy-test",
+ "version": "1.0.0",
+ },
+ "capabilities": map[string]any{},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req, err := http.NewRequestWithContext(
+ t.Context(),
+ http.MethodPost,
+ httpServer.URL+EndpointPath,
+ bytes.NewReader(body),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ response, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("initialize status = %d, want 200", response.StatusCode)
+ }
+
+ var rpc map[string]any
+ if err := json.NewDecoder(response.Body).Decode(&rpc); err != nil {
+ t.Fatal(err)
+ }
+ result, ok := rpc["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("initialize response = %#v, want result", rpc)
+ }
+ if result["instructions"] != instructions {
+ t.Fatalf("instructions = %#v, want %q", result["instructions"], instructions)
+ }
+}
+
+func postModern(t *testing.T, url string, method markmcp.MCPMethod, params map[string]any) *http.Response {
+ t.Helper()
+ params["_meta"] = map[string]any{
+ markmcp.MetaKeyProtocolVersion: markmcp.ProtocolVersion20260728,
+ markmcp.MetaKeyClientInfo: map[string]any{
+ "name": "safeline-mcp-test",
+ "version": "1.0.0",
+ },
+ markmcp.MetaKeyClientCapabilities: map[string]any{},
+ }
+ body, err := json.Marshal(map[string]any{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": method,
+ "params": params,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json, text/event-stream")
+ req.Header.Set(markmcp.HeaderProtocolVersion, markmcp.ProtocolVersion20260728)
+ req.Header.Set(markmcp.HeaderMethod, string(method))
+ if name, ok := params["name"].(string); ok {
+ encoded, valid := markmcp.EncodeHeaderValue(name)
+ if !valid {
+ t.Fatalf("invalid MCP name %q", name)
+ }
+ req.Header.Set(markmcp.HeaderName, encoded)
+ }
+
+ response, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = response.Body.Close() })
+ if response.StatusCode != http.StatusOK {
+ var payload any
+ _ = json.NewDecoder(response.Body).Decode(&payload)
+ t.Fatalf("POST %s status = %d, payload = %#v", method, response.StatusCode, payload)
+ }
+ return response
+}
+
+func requireRPCResult(t *testing.T, response *http.Response) map[string]any {
+ t.Helper()
+ var rpc map[string]any
+ if err := json.NewDecoder(response.Body).Decode(&rpc); err != nil {
+ t.Fatal(err)
+ }
+ result, ok := rpc["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("JSON-RPC response = %#v", rpc)
+ }
+ if result["resultType"] != string(markmcp.ResultTypeComplete) {
+ t.Fatalf("resultType = %#v, want %q", result["resultType"], markmcp.ResultTypeComplete)
+ }
+ return result
+}
diff --git a/mcp_server/pkg/mcp/schema.go b/mcp_server/pkg/mcp/schema.go
deleted file mode 100644
index 7461563b..00000000
--- a/mcp_server/pkg/mcp/schema.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package mcp
-
-import (
- "encoding/json"
- "reflect"
- "strconv"
- "strings"
-
- "github.com/mark3labs/mcp-go/mcp"
-)
-
-// SchemaToOptions Convert struct to MCP ToolOption list
-func SchemaToOptions(schema any) ([]mcp.ToolOption, error) {
- t := reflect.TypeOf(schema)
- if t.Kind() == reflect.Ptr {
- t = t.Elem()
- }
-
- var options []mcp.ToolOption
- for i := 0; i < t.NumField(); i++ {
- field := t.Field(i)
- jsonTag := field.Tag.Get("json")
- if jsonTag == "" {
- continue
- }
-
- desc := field.Tag.Get("desc")
- required := field.Tag.Get("required") == "true"
- enumTag := field.Tag.Get("enum")
- defaultTag := field.Tag.Get("default")
- minTag := field.Tag.Get("min")
- maxTag := field.Tag.Get("max")
- opts := []mcp.PropertyOption{}
-
- if desc != "" {
- opts = append(opts, mcp.Description(desc))
- }
- if required {
- opts = append(opts, mcp.Required())
- }
- if enumTag != "" && field.Type.Kind() == reflect.String {
- enumValues := strings.Split(enumTag, ",")
- opts = append(opts, mcp.Enum(enumValues...))
- }
-
- switch field.Type.Kind() {
- case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64:
- if defaultTag != "" {
- if defaultValue, err := strconv.Atoi(defaultTag); err == nil {
- opts = append(opts, mcp.DefaultNumber(float64(defaultValue)))
- }
- }
- if minTag != "" {
- if minValue, err := strconv.Atoi(minTag); err == nil {
- opts = append(opts, mcp.Min(float64(minValue)))
- }
- }
- if maxTag != "" {
- if maxValue, err := strconv.Atoi(maxTag); err == nil {
- opts = append(opts, mcp.Max(float64(maxValue)))
- }
- }
- options = append(options, mcp.WithNumber(jsonTag, opts...))
- case reflect.Bool:
- if defaultTag != "" {
- if defaultValue, err := strconv.ParseBool(defaultTag); err == nil {
- opts = append(opts, mcp.DefaultBool(defaultValue))
- }
- }
- options = append(options, mcp.WithBoolean(jsonTag, opts...))
- case reflect.String:
- if defaultTag != "" {
- opts = append(opts, mcp.DefaultString(defaultTag))
- }
- options = append(options, mcp.WithString(jsonTag, opts...))
- case reflect.Struct:
- subSchema := reflect.New(field.Type).Interface()
- subOptions, err := SchemaToOptions(subSchema)
- if err != nil {
- return nil, err
- }
-
- // Create a temporary Tool to get JSON Schema of sub-struct
- tempTool := mcp.NewTool("temp", subOptions...)
- tempJSON, _ := tempTool.MarshalJSON()
- var tempMap map[string]any
- if err := json.Unmarshal(tempJSON, &tempMap); err != nil {
- continue
- }
-
- // Extract properties from temporary Tool
- if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok {
- if properties, ok := inputSchema["properties"].(map[string]any); ok {
- // Check if there are required fields
- if required, ok := inputSchema["required"].([]any); ok {
- // Add required field information to corresponding properties
- for _, req := range required {
- if reqStr, ok := req.(string); ok {
- if prop, ok := properties[reqStr].(map[string]any); ok {
- prop["required"] = true
- }
- }
- }
- }
- opts = append(opts, mcp.Properties(properties))
- }
- }
- options = append(options, mcp.WithObject(jsonTag, opts...))
-
- case reflect.Slice:
- elemType := field.Type.Elem()
- var items map[string]any
-
- switch elemType.Kind() {
- case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64:
- items = map[string]any{
- "type": "number",
- }
- case reflect.Bool:
- items = map[string]any{
- "type": "boolean",
- }
- case reflect.String:
- items = map[string]any{
- "type": "string",
- }
- case reflect.Struct:
- subSchema := reflect.New(elemType).Interface()
- subOptions, err := SchemaToOptions(subSchema)
- if err != nil {
- return nil, err
- }
-
- // Create a temporary Tool to get JSON Schema of sub-struct
- tempTool := mcp.NewTool("temp", subOptions...)
- tempJSON, _ := tempTool.MarshalJSON()
- var tempMap map[string]any
- if err := json.Unmarshal(tempJSON, &tempMap); err != nil {
- continue
- }
-
- // Extract properties from temporary Tool
- if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok {
- if properties, ok := inputSchema["properties"].(map[string]any); ok {
- // Check if there are required fields
- if required, ok := inputSchema["required"].([]any); ok {
- // Add required field information to corresponding properties
- for _, req := range required {
- if reqStr, ok := req.(string); ok {
- if prop, ok := properties[reqStr].(map[string]any); ok {
- prop["required"] = true
- }
- }
- }
- }
- items = map[string]any{
- "type": "object",
- "properties": properties,
- }
- }
- }
- }
- opts = append(opts, mcp.Items(items))
- options = append(options, mcp.WithArray(jsonTag, opts...))
- }
- }
-
- return options, nil
-}
diff --git a/mcp_server/pkg/mcp/schema_test.go b/mcp_server/pkg/mcp/schema_test.go
deleted file mode 100644
index 6025f74f..00000000
--- a/mcp_server/pkg/mcp/schema_test.go
+++ /dev/null
@@ -1,227 +0,0 @@
-package mcp
-
-import (
- "reflect"
- "testing"
-
- "github.com/mark3labs/mcp-go/mcp"
-)
-
-func TestSchemaToOptions(t *testing.T) {
- tests := []struct {
- name string
- args any
- want mcp.Tool
- }{
- {
- name: "test number",
- args: struct {
- A int `json:"a" desc:"number a" required:"true"`
- }{},
- want: mcp.NewTool("test number",
- mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")),
- ),
- },
- {
- name: "test number int64",
- args: struct {
- A int64 `json:"a" desc:"number a" required:"true"`
- }{},
- want: mcp.NewTool("test number int64",
- mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")),
- ),
- },
- {
- name: "test number float64",
- args: struct {
- A float64 `json:"a" desc:"number a" required:"true"`
- }{},
- want: mcp.NewTool("test number float64",
- mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")),
- ),
- },
- {
- name: "test number default",
- args: struct {
- A int `json:"a" desc:"number a" required:"true" default:"10"`
- }{},
- want: mcp.NewTool("test number default",
- mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.DefaultNumber(10)),
- ),
- },
- {
- name: "test number min max",
- args: struct {
- A int `json:"a" desc:"number a" required:"true" min:"10" max:"20"`
- }{},
- want: mcp.NewTool("test number min max",
- mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.Min(10), mcp.Max(20)),
- ),
- },
- {
- name: "test number optional",
- args: struct {
- A int `json:"a" desc:"number a"`
- }{},
- want: mcp.NewTool("test number optional",
- mcp.WithNumber("a", mcp.Description("number a")),
- ),
- },
- {
- name: "test boolean",
- args: struct {
- A bool `json:"a" desc:"boolean a" required:"true"`
- }{},
- want: mcp.NewTool("test boolean",
- mcp.WithBoolean("a", mcp.Required(), mcp.Description("boolean a")),
- ),
- },
- {
- name: "test string",
- args: struct {
- A string `json:"a" desc:"string a" required:"true"`
- }{},
- want: mcp.NewTool("test string",
- mcp.WithString("a", mcp.Required(), mcp.Description("string a")),
- ),
- },
- {
- name: "test string default",
- args: struct {
- A string `json:"a" desc:"string a" required:"true" default:"hello"`
- }{},
- want: mcp.NewTool("test string default",
- mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.DefaultString("hello")),
- ),
- },
- {
- name: "test string enum",
- args: struct {
- A string `json:"a" desc:"string a" required:"true" enum:"1,2,3"`
- }{},
- want: mcp.NewTool("test string enum",
- mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.Enum("1", "2", "3")),
- ),
- },
- {
- name: "test object",
- args: struct {
- A struct {
- B int `json:"b" desc:"number b" required:"true"`
- } `json:"a" desc:"object a" required:"true"`
- }{},
- want: mcp.NewTool("test object",
- mcp.WithObject("a", mcp.Required(), mcp.Description("object a"),
- mcp.Properties(map[string]any{
- "b": map[string]any{
- "type": "number",
- "description": "number b",
- "required": true,
- },
- }),
- ),
- ),
- },
- {
- name: "test object optional",
- args: struct {
- A struct {
- B int `json:"b" desc:"number b" required:"true"`
- C int `json:"c" desc:"number c"`
- } `json:"a" desc:"object a" required:"true"`
- }{},
- want: mcp.NewTool("test object optional",
- mcp.WithObject("a", mcp.Required(), mcp.Description("object a"),
- mcp.Properties(map[string]any{
- "b": map[string]any{
- "type": "number",
- "description": "number b",
- "required": true,
- },
- "c": map[string]any{
- "type": "number",
- "description": "number c",
- },
- }),
- ),
- ),
- },
- {
- name: "test nested object",
- args: struct {
- A struct {
- B struct {
- C int `json:"c" desc:"number c" required:"true"`
- } `json:"b" desc:"object b" required:"true"`
- } `json:"a" desc:"object a" required:"true"`
- }{},
- want: mcp.NewTool("test nested object",
- mcp.WithObject("a", mcp.Required(), mcp.Description("object a"),
- mcp.Properties(map[string]any{
- "b": map[string]any{
- "type": "object",
- "description": "object b",
- "required": true,
- "properties": map[string]any{
- "c": map[string]any{
- "type": "number",
- "description": "number c",
- "required": true,
- },
- },
- },
- }),
- ),
- ),
- },
- {
- name: "test array",
- args: struct {
- A []int `json:"a" desc:"array a" required:"true"`
- }{},
- want: mcp.NewTool("test array",
- mcp.WithArray("a", mcp.Required(), mcp.Description("array a"),
- mcp.Items(map[string]any{
- "type": "number",
- }),
- ),
- ),
- },
- {
- name: "test array of object",
- args: struct {
- A []struct {
- B int `json:"b" desc:"number b" required:"true"`
- } `json:"a" desc:"array of object a" required:"true"`
- }{},
- want: mcp.NewTool("test array of object",
- mcp.WithArray("a", mcp.Required(), mcp.Description("array of object a"),
- mcp.Items(map[string]any{
- "type": "object",
- "properties": map[string]any{
- "b": map[string]any{
- "type": "number",
- "description": "number b",
- "required": true,
- },
- },
- }),
- ),
- ),
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := SchemaToOptions(tt.args)
- if err != nil {
- t.Errorf("SchemaToOptions() error = %v", err)
- return
- }
- s1, _ := mcp.NewTool(tt.name, got...).MarshalJSON()
- s2, _ := tt.want.MarshalJSON()
- if !reflect.DeepEqual(s1, s2) {
- t.Errorf("\n got %v\n want %v", string(s1), string(s2))
- }
- })
- }
-}
diff --git a/scripts/manage.py b/scripts/manage.py
index 7bcaa2f7..142b255a 100644
--- a/scripts/manage.py
+++ b/scripts/manage.py
@@ -497,8 +497,6 @@ def init_global_config():
global REQUEST_CTX, LANG, DOMAIN, PRODUCT, DEBUG
REQUEST_CTX = ssl.create_default_context()
- REQUEST_CTX.check_hostname = False
- REQUEST_CTX.verify_mode = ssl.CERT_NONE
parser = argparse.ArgumentParser(
prog='installer-management',
diff --git a/sdk/ingress-nginx/lib/safeline/main.lua b/sdk/ingress-nginx/lib/safeline/main.lua
index b4741813..db895178 100644
--- a/sdk/ingress-nginx/lib/safeline/main.lua
+++ b/sdk/ingress-nginx/lib/safeline/main.lua
@@ -3,6 +3,7 @@ local t1k_constants = require "resty.t1k.constants"
local ngx = ngx
local fmt = string.format
+local lower = string.lower
local blocked_message = [[{"code": %s, "success":false, ]] ..
[["message": "blocked by Chaitin SafeLine Web Application Firewall", "event_id": "%s"}]]
@@ -36,26 +37,56 @@ local function get_conf()
return t
end
+local function is_block_mode(t)
+ -- resty.t1k lower cases the mode before comparing it, so do the same here:
+ -- a mode of "BLOCK" has to fail closed exactly like "block".
+ return type(t.mode) == "string" and lower(t.mode) == t1k_constants.MODE_BLOCK
+end
+
+-- fail_closed answers a request that could not be checked.
+--
+-- The detector was not asked (or the plugin is not configured at all), so
+-- nothing is known about this request. Answering it while the plugin runs in
+-- block mode would turn a detector outage into a complete bypass of the WAF,
+-- which is the one outcome an inline protection must not have. monitor mode
+-- keeps passing traffic, and off mode never reaches this point.
+local function fail_closed(t)
+ if not is_block_mode(t) then
+ return
+ end
+
+ local status = ngx.HTTP_INTERNAL_SERVER_ERROR
+ local msg = fmt(blocked_message, status, "")
+ ngx.status = status
+ ngx.header.content_type = t1k_constants.BLOCK_CONTENT_TYPE
+ ngx.say(msg)
+ return ngx.exit(status)
+end
+
function _M.rewrite()
local t = get_conf()
if not t.host then
ngx.log(ngx.ERR, "safeline host is required")
- return
+ -- resty.t1k refuses to run without a host, so this is the same
+ -- "cannot be checked" situation as a detector that does not answer.
+ return fail_closed(t)
end
local ok, err, result = t1k.do_access(t, false)
if not ok then
ngx.log(ngx.ERR, "failed to detector req: ", err)
- return
+ return fail_closed(t)
end
if result then
if result.action == t1k_constants.ACTION_BLOCKED then
- local msg = fmt(blocked_message, result.status, result.event_id)
- ngx.log(ngx.ERR, "blocked by safeline waf: ",msg)
- ngx.status = tonumber(result.status,10)
+ local status = tonumber(result.status, 10) or ngx.HTTP_FORBIDDEN
+ local msg = fmt(blocked_message, status, result.event_id)
+ ngx.log(ngx.ERR, "blocked by safeline waf: ", msg)
+ ngx.status = status
+ ngx.header.content_type = t1k_constants.BLOCK_CONTENT_TYPE
ngx.say(msg)
- return ngx.exit(ngx.HTTP_OK)
+ return ngx.exit(status)
end
end
end
-return _M
\ No newline at end of file
+return _M
diff --git a/sdk/kong/kong/plugins/safeline/handler.lua b/sdk/kong/kong/plugins/safeline/handler.lua
index 06da7bf0..af6fcda6 100644
--- a/sdk/kong/kong/plugins/safeline/handler.lua
+++ b/sdk/kong/kong/plugins/safeline/handler.lua
@@ -28,11 +28,18 @@ local function get_conf(conf)
end
function SafelineHandler:access(conf)
- -- your custom code here
local t = get_conf(conf)
local ok, err, result = t1k.do_access(t, false)
if not ok then
+ -- The detector could not be asked, so nothing is known about this
+ -- request. Letting it through in block mode would turn a detector
+ -- outage into a complete bypass of the WAF, which is the one outcome an
+ -- inline protection must not have. monitor mode keeps passing traffic.
kong.log.err("failed to detector req: ", err)
+ if conf.mode == t1k_constants.MODE_BLOCK then
+ return kong.response.exit(500, fmt(blocked_message, 500, ""))
+ end
+ return
end
if result and result.status then
if result.action == t1k_constants.ACTION_BLOCKED then
diff --git a/version.json b/version.json
new file mode 100644
index 00000000..387ca1f9
--- /dev/null
+++ b/version.json
@@ -0,0 +1,5 @@
+{
+ "latest_version": "v9.2.7",
+ "rec_version": "v9.2.7",
+ "lts_version": "v9.1.0-lts"
+}
diff --git a/yanshi/src/common.cc b/yanshi/src/common.cc
index 2ea555bb..b301c6c6 100644
--- a/yanshi/src/common.cc
+++ b/yanshi/src/common.cc
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -75,6 +76,57 @@ void err_msg(const char *format, ...)
}
#define err_msg_g(...) ({err_msg(__VA_ARGS__); goto quit;})
+// MAX_BACKTRACE_FRAMES bounds the addresses that are collected and resolved.
+// backtrace() writes as many entries as the stack holds, so the count handed
+// to it has to be the size of the buffer itself: asking for more than the
+// array holds writes past the end of it.
+#define MAX_BACKTRACE_FRAMES 99
+
+// run_addr2line resolves the collected addresses and writes the result to
+// stderr.
+//
+// The addresses are passed as an argument vector rather than as a shell
+// command line: the program name is argv[0], which whoever spawns this binary
+// chooses, so a value carrying shell syntax would otherwise be able to run a
+// command of its own.
+static void run_addr2line(void** bt, int nptrs)
+{
+ if (nptrs > MAX_BACKTRACE_FRAMES)
+ nptrs = MAX_BACKTRACE_FRAMES;
+
+ char addrs[MAX_BACKTRACE_FRAMES][32];
+ // three fixed entries (the program, its options and the object file), one
+ // address per frame, and the terminating NULL
+ char* argv[MAX_BACKTRACE_FRAMES + 4];
+ int n = 0;
+ argv[n++] = (char*)"addr2line";
+ argv[n++] = (char*)"-Cfipe";
+ // glibc leaves program_invocation_name empty when it cannot name the
+ // program; addr2line then falls back to its own default.
+ argv[n++] = (char*)(program_invocation_name ? program_invocation_name : "");
+ REP(i, nptrs) {
+ snprintf(addrs[i], sizeof addrs[i], "%p", bt[i]);
+ argv[n++] = addrs[i];
+ }
+ argv[n] = NULL;
+
+ pid_t pid = fork();
+ if (pid < 0)
+ return;
+
+ if (pid == 0) {
+ // the backtrace belongs next to the message that is being written
+ dup2(STDERR_FILENO, STDOUT_FILENO);
+ execvp(argv[0], argv);
+ dprintf(STDERR_FILENO, "addr2line: %s\n", strerror(errno));
+ _exit(EXIT_FAILURE);
+ }
+
+ int status;
+ while (waitpid(pid, &status, 0) < 0 && errno == EINTR)
+ ;
+}
+
void err_exit(int exitno, const char *format, ...)
{
va_list ap;
@@ -84,16 +136,10 @@ void err_exit(int exitno, const char *format, ...)
errno = saved;
va_end(ap);
- void *bt[99];
- char buf[1024];
- int nptrs = backtrace(bt, LEN(buf));
- int i = sprintf(buf, "addr2line -Cfipe %s", program_invocation_name), j = 0;
- while (j < nptrs && i+30 < sizeof buf)
- i += sprintf(buf+i, " %p", bt[j++]);
- strcat(buf, ">&2");
+ void *bt[MAX_BACKTRACE_FRAMES];
+ int nptrs = backtrace(bt, LEN(bt));
fputs("\n", stderr);
- system(buf);
- //backtrace_symbols_fd(buf, nptrs, STDERR_FILENO);
+ run_addr2line(bt, nptrs);
exit(exitno);
}
diff --git a/yanshi/src/common.hh b/yanshi/src/common.hh
index 1d1051bb..b31314f5 100644
--- a/yanshi/src/common.hh
+++ b/yanshi/src/common.hh
@@ -44,6 +44,18 @@ extern char*** _NSGetArgv(void);
#define CYAN "\x1b[1;36m"
#define NORMAL_YELLOW "\x1b[33m"
const long MAX_CODEPOINT = 0x10ffff;
+// Upper bound for a single repetition count. gen_repeat() rejects larger
+// bounds while parsing and FsaAnno::repeat() enforces the same limit as
+// defense in depth, because the automaton expansion performed per iteration
+// makes the cost of a large count grow without bound (CWE-770).
+const long MAX_REPEAT = 10000;
+// Upper bound for the nesting depth of an expression tree. A line of adjacent
+// concatenations is reduced one factor at a time into a left leaning chain, so
+// the depth of the tree follows the length of the line the compiler is given.
+// Every later pass over that tree recurses along its edges, and so does the
+// code that frees it, which is why the depth is bounded while the tree is built
+// (CWE-674). The bound is far above what a hand written rule reaches.
+const long MAX_EXPR_DEPTH = 4096;
extern long action_label_base, action_label, call_label_base, call_label, collapse_label_base, collapse_label;
void bold(long fd = 1);
diff --git a/yanshi/src/compiler.cc b/yanshi/src/compiler.cc
index 809961c6..22004cca 100644
--- a/yanshi/src/compiler.cc
+++ b/yanshi/src/compiler.cc
@@ -278,7 +278,11 @@ void generate_transitions(DefineStmt* stmt)
fprintf(output,
")\n"
"{\n"
-" long v = -1;\n"
+" long v = -1;\n");
+ if (opt_gen_c)
+ fprintf(output,
+" if (!ret_stack || !ret_stack_len) return -1;\n");
+ fprintf(output,
"again:\n"
" switch (u) {\n");
REP(u, anno.fsa.n()) {
@@ -288,8 +292,11 @@ fprintf(output,
" u = %ld;\n"
, u, call_addr[u].first);
if (opt_gen_c)
+ // C ABI takes a signed length from the caller. Rejecting only the
+ // upper bound lets a negative *ret_stack_len pass, then
+ // ret_stack[(*ret_stack_len)++] writes before the array.
fprintf(output,
-" if (*ret_stack_len >= %ld) return -1;\n"
+" if (*ret_stack_len < 0 || *ret_stack_len >= %ld) return -1;\n"
" ret_stack[(*ret_stack_len)++] = %ld;\n"
, opt_max_return_stack, call_addr[u].second);
else
@@ -389,7 +396,7 @@ fprintf(output,
fprintf(output, "default:\n");
indent(output, 3);
fprintf(output, opt_gen_c ?
-"if (*ret_stack_len) { u = ret_stack[--*ret_stack_len]; goto again; }\n"
+"if (*ret_stack_len > 0) { u = ret_stack[--*ret_stack_len]; goto again; }\n"
:
"if (ret_stack.size()) { u = ret_stack.back(); ret_stack.pop_back(); goto again; }\n");
indent(output, 3);
@@ -423,9 +430,13 @@ bool compile_export(DefineStmt* stmt)
unordered_map start2stmt;
vector starts;
vector sub_final;
- function allocate = [&](DefineStmt* stmt) {
+ function allocate = [&](DefineStmt* stmt, long depth) {
if (stmt2offset.count(stmt))
return;
+ // stmt2offset stops cycles. It does not stop a deep acyclic embed graph,
+ // which still recurses once per statement.
+ if (depth > MAX_EXPR_DEPTH)
+ err_exit(EX_DATAERR, "define graph deeper than %ld", MAX_EXPR_DEPTH);
DP(4, "Allocate %ld to %s", allo, stmt->lhs.c_str());
FsaAnno& anno = compiled[stmt];
long base = stmt2offset[stmt] = allo;
@@ -448,10 +459,10 @@ bool compile_export(DefineStmt* stmt)
if (has_start(aa.second)) {
if (auto* e = dynamic_cast(aa.first)) {
DefineStmt* v = e->define_stmt;
- allocate(v);
+ allocate(v, depth+1);
} else if (auto* e = dynamic_cast(aa.first)) {
DefineStmt* v = e->define_stmt;
- allocate(v);
+ allocate(v, depth+1);
// (i@{CollapseExpr,...}, special, _) -> ({CollapseExpr,...}, epsilon, CollapseExpr.define_stmt.start)
sorted_emplace(adj[i], epsilon, stmt2offset[v]+compiled[v].fsa.start);
}
@@ -467,7 +478,7 @@ bool compile_export(DefineStmt* stmt)
for (auto aa: assoc[v])
if (has_final(aa.second) && (e = dynamic_cast(aa.first))) {
DefineStmt* w = e->define_stmt;
- allocate(w);
+ allocate(w, depth+1);
// (_, special, v@{CollapseExpr,...}) -> (CollapseExpr.define_stmt.final, epsilon, v)
for (long f: compiled[w].fsa.finals) {
long g = stmt2offset[w]+f;
@@ -481,7 +492,7 @@ bool compile_export(DefineStmt* stmt)
adj[i].resize(j);
}
};
- allocate(stmt);
+ allocate(stmt, 1);
anno.fsa.adj = move(adj);
anno.assoc = move(assoc);
anno.deterministic = false;
@@ -732,6 +743,8 @@ static void generate_cxx_export(DefineStmt* stmt)
generate_final("", final);
generate_final("sub_", stmt2final[stmt]);
fprintf(output, opt_gen_c ?
+" if (ret_stack_len < 0) return false;\n"
+" if (ret_stack_len > 0 && !ret_stack) return false;\n"
" for (long i = ret_stack_len; i; u = ret_stack[--i])\n"
:
" for (auto i = ret_stack.size(); i; u = ret_stack[--i])\n"
@@ -811,6 +824,7 @@ void generate_cxx(Module* mo)
" utf8 = argv[1];\n"
" else {\n"
" FILE* f = argc == 1 ? stdin : fopen(argv[1], \"r\");\n"
+" if (!f) return 1;\n"
" while ((c = fgetc(f)) != EOF)\n"
" utf8 += c;\n"
" fclose(f);\n"
diff --git a/yanshi/src/fsa.cc b/yanshi/src/fsa.cc
index 42c3e0d1..2a415ab5 100644
--- a/yanshi/src/fsa.cc
+++ b/yanshi/src/fsa.cc
@@ -375,8 +375,13 @@ Fsa Fsa::determinize(const vector* starts, function= 0)
live.insert(ev.second);
- else
- live.erase(live.find(~ ev.second));
+ else {
+ // A reversed interval (signed overflow of end = start+1) can close an
+ // event that was never opened. erase(end()) is undefined.
+ auto it = live.find(~ ev.second);
+ if (it != live.end())
+ live.erase(it);
+ }
}
}
sort(ALL(r.finals));
diff --git a/yanshi/src/fsa_anno.cc b/yanshi/src/fsa_anno.cc
index 3e6c6738..c0fcd4af 100644
--- a/yanshi/src/fsa_anno.cc
+++ b/yanshi/src/fsa_anno.cc
@@ -7,6 +7,7 @@
#include
#include
#include