feat: SafeLine CE 9.3.9 with pro features
MCP Docker / build-and-push (push) Has been cancelled

- Remove telemetry: disabled all phone-home (installation notify, false positives, behavior tracking, upgrade tips)
- Version branding: removed 'ce-' prefix, product name changed to '长亭雷池 WAF'
- Detection engine enhancement: strict preset enables all 17 modules with aggressive configs
- Multi-user RBAC: admin/operator/viewer roles with password + TOTP 2FA
- Rate limiting: nginx limit_req per website (RPS, burst, action)
- JS Challenge: browser fingerprint verification page with cookie-based bypass
- Security fixes: removed InsecureSkipVerify, hardcoded credentials, NO_AUTH backdoor
This commit is contained in:
2026-07-04 07:59:04 +08:00
commit b8788c239e
259 changed files with 23055 additions and 0 deletions
@@ -0,0 +1,41 @@
package controller
import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"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"
)
var (
logger = log.GetLogger("controller")
)
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()),
}...)
if err != nil {
logger.Errorf("Fail to dial: %v", err)
return err
}
wsClient := pb.NewWebsiteClient(gRPCConn)
defer func(conn *grpc.ClientConn) {
err := conn.Close()
if err != nil {
logger.Errorf("Fail to close: %v", err)
return
}
}(gRPCConn)
if err = websiteHandler(wsClient); err != nil {
return err
}
return nil
}
@@ -0,0 +1,78 @@
package controller
// nginxConfigTpl generates nginx config for each website.
// Placeholders:
// %s = upstream name
// %s = upstream addr(s)
// %s = server listen directive
// %s = server_name directive
// %s = ssl_certificate directive (or empty)
// %s = ssl_certificate_key directive (or empty)
// %s = upstream scheme (http/https)
// %s = upstream name (again for proxy_pass)
// %d = site UUID (SL-CE-SUID header value)
var nginxConfigTpl = `
upstream %s {
%s
keepalive 128;
keepalive_timeout 75;
}
server {
%s
%s
%s
%s
location = /forbidden_page {
internal;
root /etc/nginx/forbidden_pages;
try_files /default_forbidden_page.html =403;
}
location ^~ / {
proxy_pass %s://%s;
include proxy_params;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Accept-Encoding "";
t1k_append_header SL-CE-SUID %d;
t1k_body_size 1024k;
tx_body_size 4k;
t1k_error_page 403 /forbidden_page;
tx_error_page 403 /forbidden_page;
%s
%s
}
}`
// rateLimitZoneTpl defines a shared memory zone for rate limiting.
// %s = zone name (site_N)
// %s = rate (e.g. "100r/s")
var rateLimitZoneTpl = `limit_req_zone $binary_remote_addr zone=%s:%dm rate=%s;`
// rateLimitReqTpl applies rate limiting to a location.
// %s = zone name
// %d = burst value
var rateLimitReqTpl = `limit_req zone=%s burst=%d nodelay;`
// rateLimitRejectTpl returns 429 when rate limit is exceeded.
var rateLimitRejectTpl = `limit_req_status 429;`
// challengeLocationTpl adds a JS challenge verification endpoint.
var challengeLocationTpl = `
location = /__waf_challenge {
internal;
root /etc/nginx/challenge_pages;
try_files /challenge.html =200;
}
location = /__waf_verify {
internal;
proxy_pass http://127.0.0.1:3335/verify;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}`
var upstreamAddrTpl = `server %s;`
var serverListenTpl = `listen 0.0.0.0:%s%s%s;`
var addrAnyPropertiesTpl = " default_server backlog=65536 reuseport"
var serverNameTpl = `server_name %s;`
var certTpl = `ssl_certificate /etc/nginx/certs/%s;`
var certKeyTpl = `ssl_certificate_key /etc/nginx/certs/%s;`
var backendTpl = `backend_%d`
@@ -0,0 +1,328 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"strings"
"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"
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/utils"
)
const (
Ping = "ping"
Pong = "pong"
EventTypeWebsite = "website"
EventTypeDeleteWebsite = "deleteWebsite"
EventTypeFullWebsite = "fullWebsite"
nginxConfigPath = "/etc/nginx/sites-enabled/" // 修改的为 host /resources/nginx/ 中的文件
nginxFilePrefix = "IF_backend_"
nginxBackupFilePrefix = "BAK_IF_backend_"
nginxFileMode = 0644
HttpsScheme = "https"
DefaultHttpsPort = "443"
)
var pong = pb.Response{Type: Pong, Msg: nil, Err: false}
func generateNginxConfig(website *model.WebsiteConfig) (string, error) {
// only ONE upstream supported in v1.0
var upstreamAddr string
scheme := "http"
for _, upstream := range website.Upstreams {
urlInfo, err := url.Parse(upstream)
if err != nil {
return "", err
}
if urlInfo.Scheme == HttpsScheme && urlInfo.Port() == "" {
urlInfo.Host = urlInfo.Host + ":" + DefaultHttpsPort
}
upstreamAddr = fmt.Sprintf(upstreamAddrTpl, urlInfo.Host)
if len(urlInfo.Scheme) > 0 {
scheme = urlInfo.Scheme
}
}
sslFlag := ""
sslCertFilename := ""
sslCertKeyFilename := ""
if website.KeyFilename != "" && website.CertFilename != "" {
sslFlag = " ssl" // with a blank ahead
sslCertFilename = fmt.Sprintf(certTpl, website.CertFilename)
sslCertKeyFilename = fmt.Sprintf(certKeyTpl, website.KeyFilename)
}
// only ONE server name supported in v1.0
var serverName string
var addrAnyProperties string
for _, sn := range website.ServerNames {
if sn == "*" || sn == "" {
sn = "_"
addrAnyProperties = addrAnyPropertiesTpl
}
serverName = fmt.Sprintf(serverNameTpl, sn)
}
// only ONE port supported in v1.0
var serverListen string
for _, port := range website.Ports {
serverListen = fmt.Sprintf(serverListenTpl, port, sslFlag, addrAnyProperties)
}
// Rate limiting directives
rateLimitDirective := ""
if website.RateLimitEnabled && website.RateLimitRPS > 0 {
zoneName := fmt.Sprintf("site_%d", website.Id)
rps := website.RateLimitRPS
burst := website.RateLimitBurst
if burst <= 0 {
burst = rps * 2
}
rateLimitDirective = fmt.Sprintf(rateLimitReqTpl, zoneName, burst)
}
// Challenge directive
challengeDirective := ""
if website.ChallengeEnabled {
challengeDirective = `error_page 403 = /__waf_challenge;`
}
upstreamName := fmt.Sprintf(backendTpl, website.Id)
nginxConfig := strings.TrimSpace(fmt.Sprintf(nginxConfigTpl,
upstreamName, upstreamAddr,
serverListen, serverName, sslCertFilename, sslCertKeyFilename,
scheme, upstreamName, website.Id,
rateLimitDirective, challengeDirective,
))
// Prepend rate limit zone definition if enabled
if website.RateLimitEnabled && website.RateLimitRPS > 0 {
zoneName := fmt.Sprintf("site_%d", website.Id)
rps := website.RateLimitRPS
zoneDef := fmt.Sprintf(rateLimitZoneTpl, zoneName, 10, fmt.Sprintf("%dr/s", rps))
nginxConfig = zoneDef + "\n" + nginxConfig
}
return nginxConfig, nil
}
func nginxTestAndReload() error {
err := ngcmd.NginxConfTest()
if err != nil {
return err
}
err = ngcmd.NginxConfReload()
if err != nil {
return err
}
return nil
}
func generateFullConfigAndReload(msg []byte) error {
var websites []model.WebsiteConfig
if err := json.Unmarshal(msg, &websites); 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 {
_, ok := configFilename[info.Name()]
if !ok && strings.HasPrefix(info.Name(), nginxFilePrefix) {
if err := os.Remove(path); err != nil {
// not return error only logged error in order to delete not exist website nginx conf
logger.Warn(err)
}
}
return nil
})
for _, website := range websites {
if err := generateConfigAndReload(&website); err != nil {
// trigger a full site push when tcd starts, ignore some site errors, and push as much as possible
logger.Warn(err)
}
}
return nil
}
func generateConfigAndReload(website *model.WebsiteConfig) error {
nginxConfig, err := generateNginxConfig(website)
if err != nil {
return err
}
configPath := filepath.Join(nginxConfigPath, fmt.Sprintf("%s%d", nginxFilePrefix, website.Id))
backupPath := filepath.Join(nginxConfigPath, fmt.Sprintf("%s%d", nginxBackupFilePrefix, website.Id))
oldConfigExists, err := utils.FileExist(configPath)
if err != nil {
return err
}
if oldConfigExists {
oldConfig, err := ioutil.ReadFile(configPath)
if err != nil {
return err
}
if string(oldConfig) == nginxConfig {
logger.Info("No changes in the new website config, skip nginx -s reload")
return nil
}
// tmp save old config to the backup path
if err = utils.CopyFile(configPath, backupPath); err != nil {
return err
}
}
if err = utils.EnsureWriteFile(configPath, []byte(nginxConfig), nginxFileMode); err != nil {
return err
}
if err = nginxTestAndReload(); err != nil {
nginxError := err
if err = os.Remove(configPath); err != nil {
return err
}
if oldConfigExists {
// new config err, restore old config
if err = utils.CopyFile(backupPath, configPath); err != nil {
return err
}
if err = os.Remove(backupPath); err != nil {
return err
}
}
return nginxError
}
if oldConfigExists {
if err = os.Remove(backupPath); err != nil {
return err
}
}
return nil
}
func deleteConfigAndReload(config []byte) error {
var websiteIds []uint
if err := json.Unmarshal(config, &websiteIds); err != nil {
return err
}
for _, id := range websiteIds {
configPath := filepath.Join(nginxConfigPath, fmt.Sprintf("%s%d", nginxFilePrefix, id))
exists, err := utils.FileExist(configPath)
if err != nil {
return err
}
if exists {
if err := os.Remove(configPath); err != nil {
return err
}
}
}
if err := nginxTestAndReload(); err != nil {
return err
}
return nil
}
func sendResponse(stream pb.Website_SubscribeClient, eventType string, errMsg []byte) error {
return stream.Send(&pb.Response{
Type: eventType,
Msg: errMsg,
Err: len(errMsg) != 0,
})
}
func websiteHandler(wc pb.WebsiteClient) error {
stream, err := wc.Subscribe(context.Background())
if err != nil {
logger.Errorf("Subscribe failed: %v", err)
return err
}
for {
event, err := stream.Recv()
if err != nil {
if err == io.EOF {
// read done.
logger.Infof("Recv EOF from webserver")
return nil
} else {
logger.Errorf("Handle failed: %v", err)
return err
}
}
logger.Debugf("Got message Type %s, Msg: %s", event.GetType(), event.GetMsg())
if event.Type == Ping {
if err = stream.Send(&pong); err != nil {
return err
}
} else if event.Type == EventTypeWebsite {
logger.Infof("Update website with config: %s", event.GetMsg())
var website model.WebsiteConfig
if err := json.Unmarshal(event.GetMsg(), &website); err != nil {
return err
}
if err = generateConfigAndReload(&website); err != nil {
logger.Error(err)
if err := sendResponse(stream, EventTypeDeleteWebsite, []byte(err.Error())); err != nil {
return err
}
} else {
if err = sendResponse(stream, EventTypeDeleteWebsite, nil); err != nil {
return err
}
}
} else if event.Type == EventTypeFullWebsite {
if err = generateFullConfigAndReload(event.Msg); err != nil {
logger.Error(err)
sendErr := sendResponse(stream, EventTypeFullWebsite, []byte(err.Error()))
if sendErr != nil {
return sendErr
}
} else {
if err = sendResponse(stream, EventTypeFullWebsite, nil); err != nil {
return err
}
}
} else if event.Type == EventTypeDeleteWebsite {
if err = deleteConfigAndReload(event.Msg); err != nil {
logger.Error(err)
sendErr := sendResponse(stream, EventTypeDeleteWebsite, []byte(err.Error()))
if sendErr != nil {
return sendErr
}
} else {
if err = sendResponse(stream, EventTypeDeleteWebsite, nil); err != nil {
return err
}
}
}
}
}