├── pkg ├── geoip │ ├── geoip.db │ └── geoip.go ├── ddns │ ├── dummy │ │ └── dummy.go │ ├── ddns_test.go │ └── webhook │ │ └── webhook_test.go ├── oidc │ ├── cloudflare │ │ └── cloudflare.go │ └── general │ │ └── general.go ├── utils │ ├── hfs.go │ ├── gjson.go │ ├── request_wrapper.go │ ├── http.go │ ├── utils_test.go │ ├── utils.go │ └── jsonx.go ├── mygin │ ├── preferred_theme.go │ ├── error.go │ ├── view_password.go │ ├── mygin.go │ └── auth.go ├── websocketx │ └── safe_conn.go └── grpcx │ └── io_stream_wrapper.go ├── resource ├── template │ ├── theme-default │ │ ├── README.md │ │ ├── theme.json │ │ ├── footer.html │ │ ├── viewpassword.html │ │ ├── header.html │ │ └── menu.html │ ├── component │ │ ├── confirm.html │ │ ├── api.html │ │ ├── nat.html │ │ ├── rule.html │ │ ├── notification.html │ │ ├── cron.html │ │ ├── ddns.html │ │ └── monitor.html │ ├── dashboard-default │ │ ├── redirect.html │ │ ├── error.html │ │ ├── login.html │ │ ├── nat.html │ │ ├── ddns.html │ │ ├── monitor.html │ │ ├── api.html │ │ ├── cron.html │ │ └── terminal.html │ └── common │ │ ├── footer.html │ │ ├── header.html │ │ └── menu.html └── static │ ├── favicon.ico │ ├── webfonts │ ├── fa-thin-100.ttf │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff │ ├── fa-duotone-900.ttf │ ├── fa-light-300.ttf │ ├── fa-light-300.woff │ ├── fa-light-300.woff2 │ ├── fa-regular-400.ttf │ ├── fa-solid-900.ttf │ ├── fa-solid-900.woff │ ├── fa-solid-900.woff2 │ ├── fa-thin-100.woff │ ├── fa-thin-100.woff2 │ ├── fa-brands-400.woff2 │ ├── fa-duotone-900.woff │ ├── fa-duotone-900.woff2 │ ├── fa-regular-400.woff │ ├── fa-regular-400.woff2 │ ├── fa-sharp-thin-100.woff2 │ ├── fa-v4compatibility.ttf │ ├── fa-duotone-thin-100.woff2 │ ├── fa-sharp-light-300.woff2 │ ├── fa-sharp-solid-900.woff2 │ ├── fa-v4compatibility.woff2 │ ├── fa-duotone-light-300.woff2 │ ├── fa-duotone-regular-400.woff2 │ ├── fa-sharp-regular-400.woff2 │ ├── fa-sharp-duotone-light-300.woff2 │ ├── fa-sharp-duotone-solid-900.woff2 │ ├── fa-sharp-duotone-thin-100.woff2 │ └── fa-sharp-duotone-regular-400.woff2 │ ├── mixin.js │ └── file.js ├── .gitattributes ├── proto.sh ├── script ├── proto.sh ├── docker-compose.yaml ├── entrypoint-simple.sh ├── server-agent.service ├── server-dash.service ├── dash-config.yaml ├── build-for-docker.sh ├── com.serverstatus.agent.plist ├── entrypoint.sh ├── run-with-prebuilt.sh ├── server-agent.openrc ├── verify-build.sh ├── config.yml ├── test-entrypoint.sh └── fix-permissions.sh ├── model ├── nat.go ├── api_token.go ├── transfer.go ├── api.go ├── common.go ├── server_test.go ├── monitor_history.go ├── cron.go ├── user.go ├── ddns.go ├── server.go ├── alertrule.go ├── host.go └── monitor.go ├── .github ├── workflows │ ├── sync-code.yml │ └── test.yml └── FUNDING.yml ├── .gitignore ├── k8s ├── ingress.yaml └── deployment.yaml ├── .dockerignore ├── service └── singleton │ ├── l10n.go │ └── nat.go ├── Dockerfile.minimal ├── .goreleaser.yml ├── proto └── server.proto ├── Dockerfile ├── docs └── migration.md ├── Dockerfile.debug ├── cmd ├── dashboard │ └── controller │ │ └── guest_page.go └── migrate │ └── main.go ├── README.md ├── go.mod └── db └── access_optimizer.go /pkg/geoip/geoip.db: -------------------------------------------------------------------------------- 1 | stub -------------------------------------------------------------------------------- /resource/template/theme-default/README.md: -------------------------------------------------------------------------------- 1 | # 默认主题 -------------------------------------------------------------------------------- /resource/template/theme-default/theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Default" 3 | } 4 | -------------------------------------------------------------------------------- /resource/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/favicon.ico -------------------------------------------------------------------------------- /resource/static/webfonts/fa-thin-100.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-thin-100.ttf -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | resource/** linguist-vendored 2 | resource/static/* !linguist-vendored 3 | resource/template/dashboard/* !linguist-vendored -------------------------------------------------------------------------------- /resource/static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /resource/static/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /resource/static/webfonts/fa-duotone-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-duotone-900.ttf -------------------------------------------------------------------------------- /resource/static/webfonts/fa-light-300.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-light-300.ttf -------------------------------------------------------------------------------- /resource/static/webfonts/fa-light-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-light-300.woff -------------------------------------------------------------------------------- /resource/static/webfonts/fa-light-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-light-300.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /resource/static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /resource/static/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /resource/static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-thin-100.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-thin-100.woff -------------------------------------------------------------------------------- /resource/static/webfonts/fa-thin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-thin-100.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-duotone-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-duotone-900.woff -------------------------------------------------------------------------------- /resource/static/webfonts/fa-duotone-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-duotone-900.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /resource/static/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /proto.sh: -------------------------------------------------------------------------------- 1 | protoc --go-grpc_out="require_unimplemented_servers=false:." --go_out="." proto/*.proto 2 | rm -rf ../agent/proto 3 | cp -r proto ../agent -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-thin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-thin-100.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /resource/static/webfonts/fa-duotone-thin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-duotone-thin-100.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-light-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-light-300.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-solid-900.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /script/proto.sh: -------------------------------------------------------------------------------- 1 | protoc --go-grpc_out="require_unimplemented_servers=false:." --go_out="." proto/*.proto 2 | rm -rf ../agent/proto 3 | cp -r proto ../agent -------------------------------------------------------------------------------- /resource/static/webfonts/fa-duotone-light-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-duotone-light-300.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-duotone-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-duotone-regular-400.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-regular-400.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-duotone-light-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-duotone-light-300.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-duotone-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-duotone-solid-900.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-duotone-thin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-duotone-thin-100.woff2 -------------------------------------------------------------------------------- /resource/static/webfonts/fa-sharp-duotone-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xOS/ServerStatus/HEAD/resource/static/webfonts/fa-sharp-duotone-regular-400.woff2 -------------------------------------------------------------------------------- /model/nat.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type NAT struct { 4 | Common 5 | Name string 6 | ServerID uint64 7 | Host string 8 | Domain string `gorm:"unique"` 9 | } 10 | -------------------------------------------------------------------------------- /model/api_token.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ApiToken struct { 4 | Common 5 | UserID uint64 `json:"user_id"` 6 | Token string `json:"token"` 7 | Note string `json:"note"` 8 | } 9 | -------------------------------------------------------------------------------- /model/transfer.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Transfer struct { 4 | Common 5 | ServerID uint64 `gorm:"index" json:"server_id"` 6 | In uint64 `json:"in"` 7 | Out uint64 `json:"out"` 8 | } -------------------------------------------------------------------------------- /pkg/ddns/dummy/dummy.go: -------------------------------------------------------------------------------- 1 | package dummy 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/libdns/libdns" 7 | ) 8 | 9 | // Internal use 10 | type Provider struct { 11 | } 12 | 13 | func (provider *Provider) SetRecords(ctx context.Context, zone string, 14 | recs []libdns.Record) ([]libdns.Record, error) { 15 | return recs, nil 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/sync-code.yml: -------------------------------------------------------------------------------- 1 | name: Sync 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | sync-to-gitee: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: adambirds/sync-github-to-gitlab-action@v1.1.0 13 | with: 14 | destination_repository: git@gitee.com:Ten/ServerStatus.git 15 | destination_branch_name: master 16 | destination_ssh_key: ${{ secrets.GITEE_SSH_KEY }} 17 | -------------------------------------------------------------------------------- /model/api.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ServiceItemResponse struct { 4 | Monitor *Monitor 5 | CurrentUp uint64 6 | CurrentDown uint64 7 | TotalUp uint64 8 | TotalDown uint64 9 | Delay *[30]float32 10 | Up *[30]int 11 | Down *[30]int 12 | } 13 | 14 | func (r ServiceItemResponse) TotalUptime() float32 { 15 | if r.TotalUp+r.TotalDown == 0 { 16 | return 0 17 | } 18 | return float32(r.TotalUp) / (float32(r.TotalUp + r.TotalDown)) * 100 19 | } 20 | -------------------------------------------------------------------------------- /resource/template/component/confirm.html: -------------------------------------------------------------------------------- 1 | {{define "component/confirm"}} 2 | 12 | {{end}} -------------------------------------------------------------------------------- /script/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | dashboard: 5 | image: image_url 6 | restart: always 7 | volumes: 8 | - ./data:/dashboard/data 9 | - ./static-custom/static:/dashboard/resource/static/custom:ro 10 | - ./theme-custom/template:/dashboard/resource/template/theme-custom:ro 11 | - ./dashboard-custom/template:/dashboard/resource/template/dashboard-custom:ro 12 | ports: 13 | - site_port:site_port 14 | - grpc_port:grpc_port -------------------------------------------------------------------------------- /resource/template/dashboard-default/redirect.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/redirect"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Redirecting.. 10 | 11 | 12 | 13 |

If you are not redirected, please click here.

14 | 15 | 16 | 17 | 18 | {{end}} -------------------------------------------------------------------------------- /pkg/oidc/cloudflare/cloudflare.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "github.com/xos/serverstatus/model" 5 | "github.com/xos/serverstatus/service/singleton" 6 | ) 7 | 8 | type UserInfo struct { 9 | Sub string `json:"sub"` 10 | Email string `json:"email"` 11 | Name string `json:"name"` 12 | Groups []string `json:"groups"` 13 | } 14 | 15 | func (u UserInfo) MapToServerUser() model.User { 16 | var user model.User 17 | singleton.DB.Where("login = ?", u.Sub).First(&user) 18 | user.Login = u.Sub 19 | user.Email = u.Email 20 | user.Name = u.Name 21 | return user 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | *.pprof 14 | .idea 15 | /data 16 | /dist 17 | .DS_Store 18 | /main 19 | /cmd/agent/main 20 | /cmd/dashboard/main 21 | /config.yml 22 | /resource/template/theme-custom 23 | /resource/static/theme-custom 24 | server 25 | scripts/monitor_goroutines.sh 26 | FIXES_APPLIED.md 27 | debug_monitor.sh 28 | .claude 29 | sqlite.db 30 | config 31 | heap.txt 32 | gp.txt 33 | fullgoroutinestackdump.txt 34 | tp.txt 35 | -------------------------------------------------------------------------------- /model/common.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | const CtxKeyAuthorizedUser = "ckau" 10 | const CtxKeyViewPasswordVerified = "ckvpv" 11 | const CtxKeyPreferredTheme = "ckpt" 12 | const CacheKeyOauth2State = "p:a:state" 13 | 14 | type Common struct { 15 | ID uint64 `gorm:"primaryKey"` 16 | CreatedAt time.Time `gorm:"index;<-:create"` 17 | UpdatedAt time.Time `gorm:"autoUpdateTime"` 18 | DeletedAt gorm.DeletedAt `gorm:"index"` 19 | } 20 | 21 | type Response struct { 22 | Code int `json:"code,omitempty"` 23 | Message string `json:"message,omitempty"` 24 | Result interface{} `json:"result,omitempty"` 25 | } -------------------------------------------------------------------------------- /pkg/utils/hfs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "os" 7 | ) 8 | 9 | // HybridFS combines embed.FS and os.DirFS. 10 | type HybridFS struct { 11 | embedFS, dir fs.FS 12 | } 13 | 14 | func NewHybridFS(embed embed.FS, subDir string, localDir string) (*HybridFS, error) { 15 | subFS, err := fs.Sub(embed, subDir) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return &HybridFS{ 21 | embedFS: subFS, 22 | dir: os.DirFS(localDir), 23 | }, nil 24 | } 25 | 26 | func (hfs *HybridFS) Open(name string) (fs.File, error) { 27 | // Ensure embed files are not replaced 28 | if file, err := hfs.embedFS.Open(name); err == nil { 29 | return file, nil 30 | } 31 | 32 | return hfs.dir.Open(name) 33 | } 34 | -------------------------------------------------------------------------------- /model/server_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/xos/serverstatus/pkg/utils" 7 | ) 8 | 9 | func TestServerMarshal(t *testing.T) { 10 | patterns := []string{ 11 | "asd > asd", 12 | "asd \" asd", 13 | "asd } asd", 14 | } 15 | 16 | for i := 0; i < len(patterns); i++ { 17 | server := Server{ 18 | Name: patterns[i], 19 | Tag: patterns[i], 20 | } 21 | serverStr := string(server.MarshalForDashboard()) 22 | var serverRestore Server 23 | if utils.Json.Unmarshal([]byte(serverStr), &serverRestore) != nil { 24 | t.Fatalf("Error: %s", serverStr) 25 | } 26 | if server.Name != serverRestore.Name { 27 | t.Fatalf("Expected %s, but got %s", server.Name, serverRestore.Name) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /resource/template/dashboard-default/error.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/error"}} 2 | {{template "common/header" .}} 3 |
4 |
5 |
6 |

7 | 8 |
9 | {{tr "AccessDenied"}} 10 |
11 |

12 |
13 |

14 | {{.Msg}} 15 |

16 | {{.Btn}} 17 |
18 |
19 |
20 |
21 | {{template "common/footer" .}} 22 | {{end}} -------------------------------------------------------------------------------- /resource/template/component/api.html: -------------------------------------------------------------------------------- 1 | {{define "component/api"}} 2 | 19 | {{end}} -------------------------------------------------------------------------------- /k8s/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: serverstatus-ingress 5 | labels: 6 | app: serverstatus 7 | annotations: 8 | nginx.ingress.kubernetes.io/rewrite-target: / 9 | nginx.ingress.kubernetes.io/ssl-redirect: "true" 10 | nginx.ingress.kubernetes.io/force-ssl-redirect: "true" 11 | cert-manager.io/cluster-issuer: "letsencrypt-prod" # 如果使用 cert-manager 12 | spec: 13 | tls: 14 | - hosts: 15 | - your-domain.com 16 | secretName: serverstatus-tls 17 | rules: 18 | - host: your-domain.com 19 | http: 20 | paths: 21 | - path: / 22 | pathType: Prefix 23 | backend: 24 | service: 25 | name: serverstatus-dashboard 26 | port: 27 | number: 80 -------------------------------------------------------------------------------- /resource/template/theme-default/footer.html: -------------------------------------------------------------------------------- 1 | {{define "theme-default/footer"}} 2 | 3 | 9 | {{ if not .Conf.DisableSwitchTemplateInFrontend }} 10 | 18 | {{ end }} 19 | 21 | 22 | 23 | {{end}} 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [xOS]# Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 相关 2 | .git 3 | .gitignore 4 | .gitattributes 5 | .github 6 | 7 | # 开发工具 8 | .vscode 9 | .idea 10 | *.swp 11 | *.swo 12 | *~ 13 | 14 | # 日志文件 15 | *.log 16 | logs/ 17 | 18 | # 临时文件 19 | tmp/ 20 | temp/ 21 | .tmp 22 | 23 | # 操作系统文件 24 | .DS_Store 25 | Thumbs.db 26 | 27 | # 文档 28 | *.md 29 | docs/ 30 | 31 | # 测试文件 32 | *_test.go 33 | test/ 34 | tests/ 35 | 36 | # 构建缓存 37 | .cache/ 38 | 39 | # Node.js (如果有前端构建) 40 | node_modules/ 41 | npm-debug.log* 42 | yarn-debug.log* 43 | yarn-error.log* 44 | 45 | # Go 相关 46 | vendor/ 47 | *.exe 48 | *.exe~ 49 | *.dll 50 | *.so 51 | *.dylib 52 | 53 | # 数据文件 54 | data/ 55 | *.db 56 | *.db-* 57 | 58 | # 保留静态资源文件(重要:应用运行时需要) 59 | # resource/ 目录不应该被排除 60 | 61 | # 配置文件(敏感信息) 62 | config/config.yaml 63 | .env 64 | .env.local 65 | 66 | # Docker 相关 67 | docker-compose*.yml 68 | Dockerfile* 69 | 70 | # 其他 71 | *.tar.gz 72 | *.zip -------------------------------------------------------------------------------- /script/entrypoint-simple.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 简化版 entrypoint.sh 4 | # 减少外部依赖,专注于核心功能 5 | 6 | # 健康检查模式 7 | if [ "$1" = "--health-check" ]; then 8 | # 简单的进程检查 9 | if [ -f "/proc/1/comm" ]; then 10 | exit 0 11 | else 12 | exit 1 13 | fi 14 | fi 15 | 16 | # 创建数据目录(如果不存在) 17 | if [ ! -d "/dashboard/data" ]; then 18 | mkdir -p /dashboard/data 19 | fi 20 | 21 | # 检查配置文件,如果不存在则创建基本配置 22 | if [ ! -f "/dashboard/data/config.yaml" ]; then 23 | cat > /dashboard/data/config.yaml << 'EOF' 24 | debug: false 25 | language: zh-CN 26 | httpport: 80 27 | grpcport: 2222 28 | database: 29 | type: sqlite 30 | dsn: data/sqlite.db 31 | jwt_secret: "default-secret-change-me" 32 | admin: 33 | username: admin 34 | password: admin123 35 | site: 36 | brand: "ServerStatus" 37 | theme: "default" 38 | EOF 39 | fi 40 | 41 | # 启动应用 42 | exec /dashboard/app "$@" -------------------------------------------------------------------------------- /resource/template/dashboard-default/login.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/login"}} 2 | {{template "common/header" .}} 3 |
4 |
5 |
6 |

7 | 8 |
9 | {{tr "Use"}} {{.LoginType}} {{tr "AccountToLogin"}} 10 |
11 |

12 | {{tr "Login"}} 13 |
14 | {{tr "DontHaveAnAccount"}} {{tr "SignUp"}} 15 |
16 |
17 |
18 |
19 | {{template "common/footer" .}} 20 | {{end}} -------------------------------------------------------------------------------- /script/server-agent.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Server Status Agent 3 | After=syslog.target 4 | After=network.target 5 | #After=server-dash.service 6 | 7 | [Service] 8 | # Modify these two values and uncomment them if you have 9 | # repos with lots of files and get an HTTP error 500 because 10 | # of that 11 | ### 12 | #LimitMEMLOCK=infinity 13 | #LimitNOFILE=65535 14 | Type=simple 15 | User=root 16 | Group=root 17 | WorkingDirectory=/opt/server-status/agent/ 18 | ExecStart=/opt/server-status/agent/server-agent 19 | Restart=always 20 | #Environment=DEBUG=true 21 | 22 | # Some distributions may not support these hardening directives. If you cannot start the service due 23 | # to an unknown option, comment out the ones not supported by your version of systemd. 24 | #ProtectSystem=full 25 | #PrivateDevices=yes 26 | #PrivateTmp=yes 27 | #NoNewPrivileges=true 28 | 29 | [Install] 30 | WantedBy=multi-user.target 31 | -------------------------------------------------------------------------------- /pkg/utils/gjson.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/tidwall/gjson" 7 | ) 8 | 9 | var ( 10 | ErrGjsonNotFound = errors.New("specified path does not exist") 11 | ErrGjsonWrongType = errors.New("wrong type") 12 | ) 13 | 14 | func GjsonGet(json []byte, path string) (gjson.Result, error) { 15 | result := gjson.GetBytes(json, path) 16 | if !result.Exists() { 17 | return result, ErrGjsonNotFound 18 | } 19 | 20 | return result, nil 21 | } 22 | 23 | func GjsonParseStringMap(jsonObject string) (map[string]string, error) { 24 | if jsonObject == "" { 25 | return nil, nil 26 | } 27 | 28 | result := gjson.Parse(jsonObject) 29 | if !result.IsObject() { 30 | return nil, ErrGjsonWrongType 31 | } 32 | 33 | ret := make(map[string]string) 34 | result.ForEach(func(key, value gjson.Result) bool { 35 | ret[key.String()] = value.String() 36 | return true 37 | }) 38 | 39 | return ret, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/mygin/preferred_theme.go: -------------------------------------------------------------------------------- 1 | package mygin 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/xos/serverstatus/model" 8 | "github.com/xos/serverstatus/pkg/utils" 9 | "github.com/xos/serverstatus/service/singleton" 10 | ) 11 | 12 | func PreferredTheme(c *gin.Context) { 13 | // 采用前端传入的主题 14 | if theme, err := c.Cookie("preferred_theme"); err == nil { 15 | if _, has := model.Themes[theme]; has { 16 | // 检验自定义主题 17 | if theme == "custom" && singleton.Conf.Site.Theme != "custom" && !utils.IsFileExists("resource/template/theme-custom/home.html") { 18 | return 19 | } 20 | c.Set(model.CtxKeyPreferredTheme, theme) 21 | } 22 | } 23 | } 24 | 25 | func GetPreferredTheme(c *gin.Context, path string) string { 26 | if theme, has := c.Get(model.CtxKeyPreferredTheme); has { 27 | return fmt.Sprintf("theme-%s%s", theme, path) 28 | } 29 | return fmt.Sprintf("theme-%s%s", singleton.Conf.Site.Theme, path) 30 | } 31 | -------------------------------------------------------------------------------- /resource/template/theme-default/viewpassword.html: -------------------------------------------------------------------------------- 1 | {{define "theme-default/viewpassword"}} 2 | {{template "common/header" .}} 3 | {{if ts .CustomCode}} 4 | {{.CustomCode|safe}} 5 | {{end}} 6 |
7 |
8 |
9 |

10 | 11 |
12 | {{tr "VerifyPassword"}} 13 |
14 |

15 |
16 |
17 | 18 |
19 | 20 |
21 |
22 |
23 |
24 | {{template "common/footer" .}} 25 | {{end}} -------------------------------------------------------------------------------- /script/server-dash.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Server Status Dashborad 3 | After=syslog.target 4 | After=network.target 5 | After=mariadb.service mysqld.service postgresql.service memcached.service redis.service 6 | 7 | [Service] 8 | # Modify these two values and uncomment them if you have 9 | # repos with lots of files and get an HTTP error 500 because 10 | # of that 11 | ### 12 | #LimitMEMLOCK=infinity 13 | #LimitNOFILE=65535 14 | Type=simple 15 | #User=root 16 | #Group=root 17 | WorkingDirectory=/opt/server-status/dashboard/ 18 | ExecStart=/opt/server-status/dashboard/server-dash 19 | Restart=always 20 | #Environment=DEBUG=true 21 | 22 | # Some distributions may not support these hardening directives. If you cannot start the service due 23 | # to an unknown option, comment out the ones not supported by your version of systemd. 24 | ProtectSystem=full 25 | PrivateDevices=yes 26 | PrivateTmp=yes 27 | NoNewPrivileges=true 28 | 29 | [Install] 30 | WantedBy=multi-user.target 31 | -------------------------------------------------------------------------------- /pkg/ddns/ddns_test.go: -------------------------------------------------------------------------------- 1 | package ddns 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | type testSt struct { 9 | domain string 10 | zone string 11 | prefix string 12 | } 13 | 14 | func TestSplitDomainSOA(t *testing.T) { 15 | if ci := os.Getenv("CI"); ci != "" { // skip if test on CI 16 | return 17 | } 18 | 19 | cases := []testSt{ 20 | { 21 | domain: "www.example.co.uk", 22 | zone: "example.co.uk.", 23 | prefix: "www", 24 | }, 25 | { 26 | domain: "abc.example.com", 27 | zone: "example.com.", 28 | prefix: "abc", 29 | }, 30 | { 31 | domain: "example.com", 32 | zone: "example.com.", 33 | prefix: "", 34 | }, 35 | } 36 | 37 | for _, c := range cases { 38 | prefix, zone, err := splitDomainSOA(c.domain) 39 | if err != nil { 40 | t.Fatalf("Error: %s", err) 41 | } 42 | if prefix != c.prefix { 43 | t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix) 44 | } 45 | if zone != c.zone { 46 | t.Fatalf("Expected zone %s, but got %s", c.zone, zone) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /service/singleton/l10n.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/nicksnyder/go-i18n/v2/i18n" 8 | "golang.org/x/text/language" 9 | 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/xos/serverstatus/model" 14 | ) 15 | 16 | var Localizer *i18n.Localizer 17 | 18 | func InitLocalizer() { 19 | bundle := i18n.NewBundle(language.Chinese) 20 | bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) 21 | 22 | if _, exists := model.Languages[Conf.Language]; !exists { 23 | log.Println("NG>> language not exists:", Conf.Language) 24 | Conf.Language = "zh-CN" 25 | } else { 26 | langPath := filepath.Join("resource", "l10n", Conf.Language+".toml") 27 | _, err := bundle.LoadMessageFile(langPath) 28 | if err != nil && !os.IsNotExist(err) { 29 | panic(err) 30 | } 31 | } 32 | 33 | zhPath := filepath.Join("resource", "l10n", "zh-CN.toml") 34 | if _, err := bundle.LoadMessageFile(zhPath); err != nil { 35 | panic(err) 36 | } 37 | Localizer = i18n.NewLocalizer(bundle, Conf.Language) 38 | } 39 | -------------------------------------------------------------------------------- /script/dash-config.yaml: -------------------------------------------------------------------------------- 1 | debug: true 2 | language: zh-CN 3 | httpport: site_port 4 | language: language 5 | grpcport: grpc_port 6 | grpchost: grpc_host 7 | oauth2: 8 | type: "oauth2_type" #Oauth2 登录接入类型,Github/Gitlab/jihulab/Gitee 9 | admin: "admin_logins" #管理员列表,半角逗号隔开 10 | clientid: "github_oauth_client_id" # 在 https://github.com/settings/developers 创建,无需审核 Callback 填 http(s)://域名或IP/oauth2/callback 11 | clientsecret: "github_oauth_client_secret" 12 | endpoint: "" # 如gitea自建需要设置 13 | site: 14 | brand: "site_title" 15 | cookiename: "server-dash" #浏览器 Cookie 字段名,可不改 16 | theme: "default" 17 | ddns: 18 | enable: false 19 | provider: "webhook" # 如需使用多配置功能,请把此项留空 20 | accessid: "" 21 | accesssecret: "" 22 | webhookmethod: "" 23 | webhookurl: "" 24 | webhookrequestbody: "" 25 | webhookheaders: "" 26 | maxretries: 3 27 | profiles: 28 | example: 29 | provider: "" 30 | accessid: "" 31 | accesssecret: "" 32 | webhookmethod: "" 33 | webhookurl: "" 34 | webhookrequestbody: "" 35 | webhookheaders: "" -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "**.go" 9 | - "go.mod" 10 | - "go.sum" 11 | - "resource/**" 12 | - ".github/workflows/test.yml" 13 | pull_request: 14 | branches: 15 | - master 16 | 17 | jobs: 18 | tests: 19 | strategy: 20 | fail-fast: true 21 | matrix: 22 | os: [ubuntu, windows, macos] 23 | 24 | runs-on: ${{ matrix.os }}-latest 25 | env: 26 | GO111MODULE: on 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: actions/setup-go@v5 31 | with: 32 | go-version: "1.23.x" 33 | 34 | - name: Unit test 35 | run: | 36 | go test -v ./... 37 | 38 | - name: Build test 39 | run: go build -v ./cmd/dashboard 40 | 41 | - name: Run Gosec Security Scanner 42 | if: runner.os == 'Linux' 43 | uses: securego/gosec@master 44 | with: 45 | args: --exclude=G104,G402,G115,G203 ./... 46 | -------------------------------------------------------------------------------- /model/monitor_history.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // MonitorHistory 历史监控记录 10 | type MonitorHistory struct { 11 | ID uint64 `gorm:"primaryKey;column:id;autoIncrement"` 12 | CreatedAt time.Time `gorm:"index;<-:create;index:idx_server_id_created_at_monitor_id_avg_delay"` 13 | UpdatedAt time.Time `gorm:"autoUpdateTime"` 14 | DeletedAt gorm.DeletedAt `gorm:"index"` 15 | MonitorID uint64 `gorm:"index:idx_server_id_created_at_monitor_id_avg_delay;column:monitor_id"` 16 | ServerID uint64 `gorm:"index:idx_server_id_created_at_monitor_id_avg_delay;column:server_id"` 17 | AvgDelay float32 `gorm:"index:idx_server_id_created_at_monitor_id_avg_delay;column:avg_delay"` // 平均延迟,毫秒 18 | Up uint64 `gorm:"column:up"` // 检查状态良好计数 19 | Down uint64 `gorm:"column:down"` // 检查状态异常计数 20 | Data string `gorm:"column:data"` 21 | } 22 | 23 | // TableName 显式指定表名,确保GORM不会自动添加@id字段 24 | func (MonitorHistory) TableName() string { 25 | return "monitor_histories" 26 | } 27 | -------------------------------------------------------------------------------- /pkg/utils/request_wrapper.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net" 7 | "net/http" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | var _ io.ReadWriteCloser = &RequestWrapper{} 13 | 14 | type RequestWrapper struct { 15 | req *http.Request 16 | reader *bytes.Buffer 17 | writer net.Conn 18 | } 19 | 20 | func NewRequestWrapper(req *http.Request, writer gin.ResponseWriter) (*RequestWrapper, error) { 21 | conn, _, err := writer.Hijack() 22 | if err != nil { 23 | return nil, err 24 | } 25 | buf := bytes.NewBuffer(nil) 26 | if err = req.Write(buf); err != nil { 27 | return nil, err 28 | } 29 | return &RequestWrapper{ 30 | req: req, 31 | reader: buf, 32 | writer: conn, 33 | }, nil 34 | } 35 | 36 | func (rw *RequestWrapper) Read(p []byte) (int, error) { 37 | count, err := rw.reader.Read(p) 38 | if err == nil { 39 | return count, nil 40 | } 41 | if err != io.EOF { 42 | return count, err 43 | } 44 | // request 数据读完之后等待客户端断开连接或 grpc 超时 45 | return rw.writer.Read(p) 46 | } 47 | 48 | func (rw *RequestWrapper) Write(p []byte) (int, error) { 49 | return rw.writer.Write(p) 50 | } 51 | 52 | func (rw *RequestWrapper) Close() error { 53 | rw.req.Body.Close() 54 | rw.writer.Close() 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /resource/template/component/nat.html: -------------------------------------------------------------------------------- 1 | {{define "component/nat"}} 2 | 31 | {{end}} 32 | -------------------------------------------------------------------------------- /service/singleton/nat.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | 7 | "github.com/xos/serverstatus/db" 8 | "github.com/xos/serverstatus/model" 9 | ) 10 | 11 | var natCache = make(map[string]*model.NAT) 12 | var natCacheRwLock = new(sync.RWMutex) 13 | 14 | func initNAT() { 15 | OnNATUpdate() 16 | } 17 | 18 | func OnNATUpdate() { 19 | natCacheRwLock.Lock() 20 | defer natCacheRwLock.Unlock() 21 | var nats []*model.NAT 22 | 23 | // 根据数据库类型选择不同的加载方式 24 | if Conf.DatabaseType == "badger" { 25 | // 使用 BadgerDB 加载NAT配置 26 | if db.DB != nil { 27 | natOps := db.NewNATOps(db.DB) 28 | var err error 29 | nats, err = natOps.GetAllNATs() 30 | if err != nil { 31 | log.Printf("从BadgerDB加载NAT配置失败: %v", err) 32 | nats = []*model.NAT{} 33 | } 34 | } else { 35 | log.Println("警告: BadgerDB 未初始化") 36 | nats = []*model.NAT{} 37 | } 38 | } else { 39 | // 使用 GORM (SQLite) 加载NAT配置 40 | if DB != nil { 41 | DB.Find(&nats) 42 | } else { 43 | log.Println("警告: SQLite数据库未初始化") 44 | nats = []*model.NAT{} 45 | } 46 | } 47 | 48 | natCache = make(map[string]*model.NAT) 49 | for i := 0; i < len(nats); i++ { 50 | natCache[nats[i].Domain] = nats[i] 51 | } 52 | } 53 | 54 | func GetNATConfigByDomain(domain string) *model.NAT { 55 | natCacheRwLock.RLock() 56 | defer natCacheRwLock.RUnlock() 57 | return natCache[domain] 58 | } 59 | -------------------------------------------------------------------------------- /pkg/mygin/error.go: -------------------------------------------------------------------------------- 1 | package mygin 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/xos/serverstatus/pkg/utils" 8 | "github.com/xos/serverstatus/model" 9 | "github.com/xos/serverstatus/service/singleton" 10 | ) 11 | 12 | type ErrInfo struct { 13 | Code int 14 | Title string 15 | Msg string 16 | Link string 17 | Btn string 18 | } 19 | 20 | func ShowErrorPage(c *gin.Context, i ErrInfo, isPage bool) { 21 | if isPage { 22 | c.HTML(i.Code, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/error", CommonEnvironment(c, gin.H{ 23 | "Code": i.Code, 24 | "Title": i.Title, 25 | "Msg": i.Msg, 26 | "Link": i.Link, 27 | "Btn": i.Btn, 28 | })) 29 | } else { 30 | writeJSON(c, http.StatusOK, model.Response{ 31 | Code: i.Code, 32 | Message: i.Msg, 33 | }) 34 | } 35 | c.Abort() 36 | } 37 | 38 | // writeJSON 统一 JSON 输出路径(mygin 内部使用) 39 | func writeJSON(c *gin.Context, status int, v interface{}) { 40 | payload, err := utils.EncodeJSON(v) 41 | if err != nil { 42 | // 降级兜底 43 | payload, _ = utils.EncodeJSON(model.Response{Code: status, Message: http.StatusText(status)}) 44 | } 45 | c.Status(status) 46 | c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") 47 | if _, gz, _ := utils.GzipIfAccepted(c.Writer, c.Request, payload); !gz { 48 | _, _ = c.Writer.Write(payload) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/websocketx/safe_conn.go: -------------------------------------------------------------------------------- 1 | package websocketx 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | 7 | "github.com/gorilla/websocket" 8 | ) 9 | 10 | var _ io.ReadWriteCloser = &Conn{} 11 | 12 | type Conn struct { 13 | *websocket.Conn 14 | writeLock *sync.Mutex 15 | dataBuf []byte 16 | } 17 | 18 | func NewConn(conn *websocket.Conn) *Conn { 19 | return &Conn{Conn: conn, writeLock: new(sync.Mutex)} 20 | } 21 | 22 | func (conn *Conn) Write(data []byte) (int, error) { 23 | conn.writeLock.Lock() 24 | defer conn.writeLock.Unlock() 25 | if err := conn.Conn.WriteMessage(websocket.BinaryMessage, data); err != nil { 26 | return 0, err 27 | } 28 | return len(data), nil 29 | } 30 | 31 | func (conn *Conn) WriteMessage(messageType int, data []byte) error { 32 | conn.writeLock.Lock() 33 | defer conn.writeLock.Unlock() 34 | return conn.Conn.WriteMessage(messageType, data) 35 | } 36 | 37 | func (conn *Conn) Read(data []byte) (int, error) { 38 | if len(conn.dataBuf) > 0 { 39 | n := copy(data, conn.dataBuf) 40 | conn.dataBuf = conn.dataBuf[n:] 41 | return n, nil 42 | } 43 | mType, innerData, err := conn.Conn.ReadMessage() 44 | if err != nil { 45 | return 0, err 46 | } 47 | // 将文本消息转换为命令输入 48 | if mType == websocket.TextMessage { 49 | innerData = append([]byte{0}, innerData...) 50 | } 51 | n := copy(data, innerData) 52 | if n < len(innerData) { 53 | conn.dataBuf = innerData[n:] 54 | } 55 | return n, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/mygin/view_password.go: -------------------------------------------------------------------------------- 1 | package mygin 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/nicksnyder/go-i18n/v2/i18n" 8 | "github.com/xos/serverstatus/model" 9 | "github.com/xos/serverstatus/service/singleton" 10 | "golang.org/x/crypto/bcrypt" 11 | ) 12 | 13 | type ValidateViewPasswordOption struct { 14 | IsPage bool 15 | AbortWhenFail bool 16 | } 17 | 18 | func ValidateViewPassword(opt ValidateViewPasswordOption) gin.HandlerFunc { 19 | return func(c *gin.Context) { 20 | if singleton.Conf.Site.ViewPassword == "" { 21 | return 22 | } 23 | _, authorized := c.Get(model.CtxKeyAuthorizedUser) 24 | if authorized { 25 | return 26 | } 27 | viewPassword, err := c.Cookie(singleton.Conf.Site.CookieName + "-vp") 28 | if err == nil { 29 | err = bcrypt.CompareHashAndPassword([]byte(viewPassword), []byte(singleton.Conf.Site.ViewPassword)) 30 | } 31 | if err == nil { 32 | c.Set(model.CtxKeyViewPasswordVerified, true) 33 | return 34 | } 35 | if !opt.AbortWhenFail { 36 | return 37 | } 38 | if opt.IsPage { 39 | c.HTML(http.StatusOK, GetPreferredTheme(c, "/viewpassword"), CommonEnvironment(c, gin.H{ 40 | "Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "VerifyPassword"}), 41 | })) 42 | 43 | } else { 44 | writeJSON(c, http.StatusOK, model.Response{ 45 | Code: http.StatusForbidden, 46 | Message: "访问受限", 47 | }) 48 | } 49 | c.Abort() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Dockerfile.minimal: -------------------------------------------------------------------------------- 1 | # 最小化 Dockerfile - 使用 distroless 而不是 scratch 2 | # 这样可以避免 entrypoint.sh 找不到的问题 3 | 4 | # 多阶段构建 - 证书和工具阶段 5 | FROM alpine:3.19 AS certs 6 | RUN apk update && apk add --no-cache ca-certificates tzdata 7 | 8 | # 二进制文件准备阶段 9 | FROM alpine:3.19 AS binary-prep 10 | ARG TARGETARCH 11 | WORKDIR /prep 12 | 13 | # 复制构建产物 14 | COPY dist/ ./dist/ 15 | 16 | # 查找并复制正确的二进制文件 17 | RUN find ./dist -name "*linux*${TARGETARCH}*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app || \ 18 | find ./dist -name "*${TARGETARCH}*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app || \ 19 | find ./dist -name "server-dash*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app 20 | 21 | # 设置执行权限 22 | RUN chmod +x /prep/app 23 | 24 | # 最终运行阶段 - 使用 distroless 基础镜像 25 | FROM gcr.io/distroless/static-debian12:latest 26 | 27 | ARG TZ=Asia/Shanghai 28 | 29 | # 从证书阶段复制必要文件 30 | COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 31 | COPY --from=certs /usr/share/zoneinfo /usr/share/zoneinfo 32 | 33 | # 复制应用和静态资源 34 | COPY --from=binary-prep /prep/app /dashboard/app 35 | COPY resource/ /dashboard/resource/ 36 | 37 | # 设置工作目录和环境变量 38 | WORKDIR /dashboard 39 | ENV TZ=$TZ 40 | ENV GIN_MODE=release 41 | 42 | # 暴露端口 43 | EXPOSE 80 2222 44 | 45 | # 健康检查 46 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 47 | CMD ["/dashboard/app", "--health-check"] || exit 1 48 | 49 | # 直接启动应用,不使用 entrypoint.sh 50 | ENTRYPOINT ["/dashboard/app"] -------------------------------------------------------------------------------- /resource/template/common/footer.html: -------------------------------------------------------------------------------- 1 | {{define "common/footer"}} 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 27 | 32 | 33 | 34 | {{end}} 35 | -------------------------------------------------------------------------------- /pkg/utils/http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | var ( 10 | HttpClientSkipTlsVerify *http.Client 11 | HttpClient *http.Client 12 | ) 13 | 14 | func init() { 15 | HttpClientSkipTlsVerify = httpClient(_httpClient{ 16 | Transport: httpTransport(_httpTransport{ 17 | SkipVerifySSL: true, 18 | }), 19 | }) 20 | HttpClient = httpClient(_httpClient{ 21 | Transport: httpTransport(_httpTransport{ 22 | SkipVerifySSL: false, 23 | }), 24 | }) 25 | 26 | http.DefaultClient.Timeout = time.Minute * 10 27 | } 28 | 29 | type _httpTransport struct { 30 | SkipVerifySSL bool 31 | } 32 | 33 | func httpTransport(conf _httpTransport) *http.Transport { 34 | return &http.Transport{ 35 | TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.SkipVerifySSL}, 36 | Proxy: http.ProxyFromEnvironment, 37 | // 根本修复:限制连接池大小,防止连接泄漏 38 | MaxIdleConns: 10, // 最大空闲连接数 39 | MaxIdleConnsPerHost: 2, // 每个host最大空闲连接数 40 | IdleConnTimeout: 30 * time.Second, // 空闲连接超时 41 | DisableKeepAlives: false, // 启用keep-alive但限制数量 42 | // 强制关闭连接,防止泄漏 43 | ResponseHeaderTimeout: 30 * time.Second, 44 | ExpectContinueTimeout: 1 * time.Second, 45 | } 46 | } 47 | 48 | type _httpClient struct { 49 | Transport *http.Transport 50 | } 51 | 52 | func httpClient(conf _httpClient) *http.Client { 53 | return &http.Client{ 54 | Transport: conf.Transport, 55 | Timeout: time.Minute * 10, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type testSt struct { 8 | input string 9 | output string 10 | } 11 | 12 | func TestNotification(t *testing.T) { 13 | cases := []testSt{ 14 | { 15 | input: "103.80.236.249/d5ce:d811:cdb8:067a:a873:2076:9521:9d2d", 16 | output: "103.****.249/d5ce:d811:****:9521:9d2d", 17 | }, 18 | { 19 | input: "3.80.236.29/d5ce::cdb8:067a:a873:2076:9521:9d2d", 20 | output: "3.****.29/d5ce::****:9521:9d2d", 21 | }, 22 | { 23 | input: "3.80.236.29/d5ce::cdb8:067a:a873:2076::9d2d", 24 | output: "3.****.29/d5ce::****::9d2d", 25 | }, 26 | { 27 | input: "3.80.236.9/d5ce::cdb8:067a:a873:2076::9d2d", 28 | output: "3.****.9/d5ce::****::9d2d", 29 | }, 30 | { 31 | input: "3.80.236.9/d5ce::cdb8:067a:a873:2076::9d2d", 32 | output: "3.****.9/d5ce::****::9d2d", 33 | }, 34 | } 35 | 36 | for _, c := range cases { 37 | if c.output != IPDesensitize(c.input) { 38 | t.Fatalf("Expected %s, but got %s", c.output, IPDesensitize(c.input)) 39 | } 40 | } 41 | } 42 | 43 | func TestGenerGenerateRandomString(t *testing.T) { 44 | generatedString := make(map[string]bool) 45 | for i := 0; i < 100; i++ { 46 | str, err := GenerateRandomString(32) 47 | if err != nil { 48 | t.Fatalf("Error: %s", err) 49 | } 50 | if len(str) != 32 { 51 | t.Fatalf("Expected 32, but got %d", len(str)) 52 | } 53 | if generatedString[str] { 54 | t.Fatalf("Duplicated string: %s", str) 55 | } 56 | generatedString[str] = true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/geoip/geoip.go: -------------------------------------------------------------------------------- 1 | package geoip 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "log" 7 | "net" 8 | "strings" 9 | "sync" 10 | 11 | maxminddb "github.com/oschwald/maxminddb-golang" 12 | ) 13 | 14 | //go:embed geoip.db 15 | var geoDBFS embed.FS 16 | 17 | var ( 18 | dbData []byte 19 | err error 20 | ) 21 | 22 | type IPInfo struct { 23 | Country string `maxminddb:"country"` 24 | CountryName string `maxminddb:"country_name"` 25 | Continent string `maxminddb:"continent"` 26 | ContinentName string `maxminddb:"continent_name"` 27 | } 28 | 29 | var ( 30 | db *maxminddb.Reader 31 | dbOnce sync.Once 32 | ) 33 | 34 | func init() { 35 | dbData, err = geoDBFS.ReadFile("geoip.db") 36 | if err != nil { 37 | log.Printf("NG>> Failed to open geoip database: %v", err) 38 | } 39 | } 40 | 41 | // initDB 初始化GeoIP数据库,只执行一次 42 | func initDB() { 43 | if dbData != nil { 44 | var err error 45 | db, err = maxminddb.FromBytes(dbData) 46 | if err != nil { 47 | log.Printf("NG>> Failed to initialize geoip database: %v", err) 48 | } 49 | } 50 | } 51 | 52 | func Lookup(ip net.IP, record *IPInfo) (string, error) { 53 | // 确保数据库只初始化一次 54 | dbOnce.Do(initDB) 55 | 56 | if db == nil { 57 | return "", fmt.Errorf("geoip database not available") 58 | } 59 | 60 | err := db.Lookup(ip, record) 61 | if err != nil { 62 | return "", err 63 | } 64 | 65 | if record.Country != "" { 66 | return strings.ToLower(record.Country), nil 67 | } else if record.Continent != "" { 68 | return strings.ToLower(record.Continent), nil 69 | } 70 | 71 | return "", fmt.Errorf("IP not found") 72 | } 73 | -------------------------------------------------------------------------------- /model/cron.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/robfig/cron/v3" 8 | "github.com/xos/serverstatus/pkg/utils" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | const ( 13 | CronCoverIgnoreAll = iota 14 | CronCoverAll 15 | CronCoverAlertTrigger 16 | CronTypeCronTask = 0 17 | CronTypeTriggerTask = 1 18 | ) 19 | 20 | type Cron struct { 21 | Common 22 | Name string 23 | TaskType uint8 `gorm:"default:0"` // 0:计划任务 1:触发任务 24 | Scheduler string //分钟 小时 天 月 星期 25 | Command string 26 | Servers []uint64 `gorm:"-"` 27 | PushSuccessful bool // 推送成功的通知 28 | NotificationTag string // 指定通知方式的分组 29 | LastExecutedAt time.Time // 最后一次执行时间 30 | LastResult bool // 最后一次执行结果 31 | Cover uint8 // 计划任务覆盖范围 (0:仅覆盖特定服务器 1:仅忽略特定服务器 2:由触发该计划任务的服务器执行) 32 | 33 | CronJobID cron.EntryID `gorm:"-"` 34 | ServersRaw string 35 | } 36 | 37 | func (c *Cron) AfterFind(tx *gorm.DB) error { 38 | if c.ServersRaw == "" { 39 | c.ServersRaw = "[]" 40 | c.Servers = []uint64{} 41 | return nil 42 | } 43 | 44 | // 尝试解析JSON,如果失败则修复格式 45 | err := utils.Json.Unmarshal([]byte(c.ServersRaw), &c.Servers) 46 | if err != nil { 47 | // 检查是否是 "[]," 这种无效格式 48 | if c.ServersRaw == "[]," || c.ServersRaw == "," { 49 | c.ServersRaw = "[]" 50 | c.Servers = []uint64{} 51 | // 更新数据库中的无效数据 52 | tx.Model(c).Update("servers_raw", "[]") 53 | return nil 54 | } 55 | 56 | // 其他格式错误,尝试修复 57 | log.Printf("解析Cron任务 %s 的ServersRaw失败(%s),重置为空数组: %v", c.Name, c.ServersRaw, err) 58 | c.ServersRaw = "[]" 59 | c.Servers = []uint64{} 60 | // 更新数据库中的无效数据 61 | tx.Model(c).Update("servers_raw", "[]") 62 | return nil 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/oidc/general/general.go: -------------------------------------------------------------------------------- 1 | package general 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/xos/serverstatus/model" 7 | "github.com/xos/serverstatus/service/singleton" 8 | ) 9 | 10 | type UserInfo struct { 11 | Sub string `json:"sub"` 12 | Username string `json:"preferred_username"` 13 | Email string `json:"email"` 14 | Name string `json:"name"` 15 | Groups []string `json:"groups,omitempty"` 16 | Roles []string `json:"roles,omitempty"` 17 | } 18 | 19 | func (u UserInfo) MapToServerUser(loginClaim string, groupClaim string, adminGroups []string, autoCreate bool) model.User { 20 | var user model.User 21 | var login string 22 | var groups []string 23 | var isAdmin bool 24 | if loginClaim == "email" { 25 | login = u.Email 26 | } else if loginClaim == "preferred_username" { 27 | login = u.Username 28 | } else { 29 | login = u.Sub 30 | } 31 | if groupClaim == "roles" { 32 | groups = u.Roles 33 | } else { 34 | groups = u.Groups 35 | } 36 | // Check if user is admin 37 | adminGroupSet := make(map[string]struct{}, len(adminGroups)) 38 | for _, adminGroup := range adminGroups { 39 | adminGroupSet[adminGroup] = struct{}{} 40 | } 41 | for _, group := range groups { 42 | if _, found := adminGroupSet[group]; found { 43 | isAdmin = true 44 | break 45 | } 46 | } 47 | if singleton.DB == nil { 48 | log.Printf("警告:数据库未初始化,OIDC用户创建失败") 49 | return model.User{} 50 | } 51 | 52 | result := singleton.DB.Where("login = ?", login).First(&user) 53 | user.Login = login 54 | user.Email = u.Email 55 | user.Name = u.Name 56 | user.SuperAdmin = isAdmin 57 | if result.Error != nil && autoCreate { 58 | singleton.DB.Create(&user) 59 | } else if result.Error != nil { 60 | return model.User{} 61 | } 62 | return user 63 | } 64 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | - go mod tidy -v 5 | builds: 6 | - id: linux_arm64 7 | env: 8 | - CGO_ENABLED=1 9 | - CC=aarch64-linux-gnu-gcc 10 | ldflags: 11 | - -s -w 12 | - -X github.com/xOS/ServerStatus/service/singleton.Version={{.Version}} 13 | - -extldflags "-static -fpic" 14 | flags: 15 | - -trimpath 16 | goos: 17 | - linux 18 | goarch: 19 | - arm64 20 | main: ./cmd/dashboard 21 | binary: server-dash-{{ .Os }}-{{ .Arch }} 22 | - id: linux_amd64 23 | env: 24 | - CGO_ENABLED=1 25 | - CC=x86_64-linux-gnu-gcc 26 | ldflags: 27 | - -s -w 28 | - -X github.com/xOS/ServerStatus/service/singleton.Version={{.Version}} 29 | - -extldflags "-static -fpic" 30 | flags: 31 | - -trimpath 32 | goos: 33 | - linux 34 | goarch: 35 | - amd64 36 | main: ./cmd/dashboard 37 | binary: server-dash-{{ .Os }}-{{ .Arch }} 38 | - id: linux_s390x 39 | env: 40 | - CGO_ENABLED=1 41 | - CC=s390x-linux-gnu-gcc 42 | ldflags: 43 | - -s -w 44 | - -X github.com/xOS/ServerStatus/service/singleton.Version={{.Version}} 45 | - -extldflags "-static -fpic" 46 | flags: 47 | - -trimpath 48 | goos: 49 | - linux 50 | goarch: 51 | - s390x 52 | main: ./cmd/dashboard 53 | binary: server-dash-{{ .Os }}-{{ .Arch }} 54 | - id: windows_amd64 55 | env: 56 | - CGO_ENABLED=1 57 | - CC=x86_64-w64-mingw32-gcc 58 | ldflags: 59 | - -s -w 60 | - -X github.com/xOS/ServerStatus/service/singleton.Version={{.Version}} 61 | - -extldflags "-static -fpic" 62 | flags: 63 | - -trimpath 64 | goos: 65 | - windows 66 | goarch: 67 | - amd64 68 | main: ./cmd/dashboard 69 | binary: server-dash-{{ .Os }}-{{ .Arch }} 70 | snapshot: 71 | version_template: "dashboard" 72 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "code.gitea.io/sdk/gitea" 7 | "github.com/google/go-github/v47/github" 8 | "github.com/xanzy/go-gitlab" 9 | ) 10 | 11 | type User struct { 12 | Common 13 | Login string `json:"login,omitempty"` // 登录名 14 | AvatarURL string `json:"avatar_url,omitempty"` // 头像地址 15 | Name string `json:"name,omitempty"` // 昵称 16 | Blog string `json:"blog,omitempty"` // 网站链接 17 | Email string `json:"email,omitempty"` // 邮箱 18 | Hireable bool `json:"hireable,omitempty"` 19 | Bio string `json:"bio,omitempty"` // 个人简介 20 | 21 | Token string `json:"-"` // 认证 Token 22 | TokenExpired time.Time `json:"token_expired,omitempty"` // Token 过期时间 23 | SuperAdmin bool `json:"super_admin,omitempty"` // 超级管理员 24 | } 25 | 26 | func NewUserFromGitea(gu *gitea.User) User { 27 | var u User 28 | u.ID = uint64(gu.ID) 29 | u.Login = gu.UserName 30 | u.AvatarURL = gu.AvatarURL 31 | u.Name = gu.FullName 32 | if u.Name == "" { 33 | u.Name = u.Login 34 | } 35 | u.Blog = gu.Website 36 | u.Email = gu.Email 37 | u.Bio = gu.Description 38 | return u 39 | } 40 | 41 | func NewUserFromGitlab(gu *gitlab.User) User { 42 | var u User 43 | u.ID = uint64(gu.ID) 44 | u.Login = gu.Username 45 | u.AvatarURL = gu.AvatarURL 46 | u.Name = gu.Name 47 | if u.Name == "" { 48 | u.Name = u.Login 49 | } 50 | u.Blog = gu.WebsiteURL 51 | u.Email = gu.Email 52 | u.Bio = gu.Bio 53 | return u 54 | } 55 | 56 | func NewUserFromGitHub(gu *github.User) User { 57 | var u User 58 | u.ID = uint64(gu.GetID()) 59 | u.Login = gu.GetLogin() 60 | u.AvatarURL = gu.GetAvatarURL() 61 | u.Name = gu.GetName() 62 | // 昵称为空的情况 63 | if u.Name == "" { 64 | u.Name = u.Login 65 | } 66 | u.Blog = gu.GetBlog() 67 | u.Email = gu.GetEmail() 68 | u.Hireable = gu.GetHireable() 69 | u.Bio = gu.GetBio() 70 | return u 71 | } 72 | -------------------------------------------------------------------------------- /resource/template/common/header.html: -------------------------------------------------------------------------------- 1 | {{define "common/header"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{.Title}} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{if ts .CustomCodeDashboard}} {{.CustomCodeDashboard|safe}} {{end}} 26 | 27 |
28 | 30 |
31 |
32 | {{end}} 33 | -------------------------------------------------------------------------------- /resource/template/theme-default/header.html: -------------------------------------------------------------------------------- 1 | {{define "theme-default/header"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{.Title}} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 32 |
33 |
34 | {{end}} 35 | -------------------------------------------------------------------------------- /script/build-for-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # ServerStatus Docker 构建脚本 4 | # 构建多架构二进制文件用于 Docker 镜像 5 | 6 | set -e 7 | 8 | # 颜色定义 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # 日志函数 16 | log_info() { 17 | echo -e "${GREEN}[INFO]${NC} $1" 18 | } 19 | 20 | log_warn() { 21 | echo -e "${YELLOW}[WARN]${NC} $1" 22 | } 23 | 24 | log_error() { 25 | echo -e "${RED}[ERROR]${NC} $1" 26 | } 27 | 28 | # 项目信息 29 | APP_NAME="server-dash" 30 | BUILD_DIR="dist" 31 | MAIN_PATH="./cmd/dashboard" 32 | 33 | # 支持的架构 34 | PLATFORMS=( 35 | "linux/amd64" 36 | "linux/arm64" 37 | "linux/s390x" 38 | ) 39 | 40 | # 创建构建目录 41 | mkdir -p ${BUILD_DIR} 42 | 43 | log_info "开始构建 ServerStatus Dashboard..." 44 | 45 | # 获取版本信息 46 | VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") 47 | COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") 48 | BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') 49 | 50 | log_info "版本信息: ${VERSION} (${COMMIT})" 51 | 52 | # 构建标志 53 | LDFLAGS="-s -w -X github.com/xOS/ServerStatus/service/singleton.Version=${VERSION}" 54 | 55 | # 构建各个平台的二进制文件 56 | for platform in "${PLATFORMS[@]}"; do 57 | IFS='/' read -r GOOS GOARCH <<< "$platform" 58 | 59 | output_name="${APP_NAME}-${GOOS}-${GOARCH}" 60 | output_path="${BUILD_DIR}/${output_name}" 61 | 62 | log_info "构建 ${GOOS}/${GOARCH}..." 63 | 64 | env GOOS=${GOOS} GOARCH=${GOARCH} CGO_ENABLED=0 go build \ 65 | -ldflags="${LDFLAGS}" \ 66 | -trimpath \ 67 | -o ${output_path} \ 68 | ${MAIN_PATH} 69 | 70 | if [ $? -eq 0 ]; then 71 | log_info "✓ ${output_name} 构建成功" 72 | 73 | # 显示文件信息 74 | if command -v ls &> /dev/null; then 75 | ls -lh ${output_path} 76 | fi 77 | else 78 | log_error "✗ ${output_name} 构建失败" 79 | exit 1 80 | fi 81 | done 82 | 83 | log_info "所有二进制文件构建完成!" 84 | log_info "构建文件位于: ${BUILD_DIR}/" 85 | 86 | # 列出所有构建的文件 87 | echo "" 88 | log_info "构建结果:" 89 | ls -lh ${BUILD_DIR}/ -------------------------------------------------------------------------------- /proto/server.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option go_package = "./proto"; 3 | 4 | package proto; 5 | 6 | service ServerService { 7 | rpc ReportSystemState(State)returns(Receipt){} 8 | rpc ReportSystemInfo(Host)returns(Receipt){} 9 | rpc ReportTask(TaskResult)returns(Receipt){} 10 | rpc RequestTask(Host)returns(stream Task){} 11 | rpc IOStream(stream IOStreamData)returns(stream IOStreamData){} 12 | rpc LookupGeoIP(GeoIP)returns(GeoIP){} 13 | } 14 | 15 | message Host { 16 | string os = 14; 17 | string platform = 1; 18 | string platform_version = 2; 19 | repeated string cpu = 3; 20 | uint64 mem_total = 4; 21 | uint64 disk_total = 5; 22 | uint64 swap_total = 6; 23 | string arch = 7; 24 | string virtualization = 8; 25 | uint64 boot_time = 9; 26 | string ip = 10; 27 | string country_code = 11; // deprecated 28 | string version = 12; 29 | repeated string gpu = 13; 30 | } 31 | 32 | message State { 33 | double cpu = 1; 34 | uint64 mem_used = 3; 35 | uint64 swap_used = 4; 36 | uint64 disk_used = 5; 37 | uint64 net_in_transfer = 6; 38 | uint64 net_out_transfer = 7; 39 | uint64 net_in_speed = 8; 40 | uint64 net_out_speed = 9; 41 | uint64 uptime = 10; 42 | double load1 = 11; 43 | double load5 = 12; 44 | double load15 = 13; 45 | uint64 tcp_conn_count = 14; 46 | uint64 udp_conn_count = 15; 47 | uint64 process_count = 16; 48 | repeated State_SensorTemperature temperatures = 17; 49 | double gpu = 18; 50 | } 51 | 52 | message State_SensorTemperature { 53 | string name = 1; 54 | double temperature = 2; 55 | } 56 | 57 | message Task { 58 | uint64 id = 1; 59 | uint64 type = 2; 60 | string data = 3; 61 | } 62 | 63 | message TaskResult { 64 | uint64 id = 1; 65 | uint64 type = 2; 66 | float delay = 3; 67 | string data = 4; 68 | bool successful = 5; 69 | } 70 | 71 | message Receipt{ 72 | bool proced = 1; 73 | } 74 | 75 | message IOStreamData { 76 | bytes data = 1; 77 | } 78 | 79 | message GeoIP { 80 | string ip = 1; 81 | string country_code = 2; 82 | } -------------------------------------------------------------------------------- /pkg/grpcx/io_stream_wrapper.go: -------------------------------------------------------------------------------- 1 | package grpcx 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "sync/atomic" 7 | 8 | "github.com/xos/serverstatus/proto" 9 | ) 10 | 11 | var _ io.ReadWriteCloser = &IOStreamWrapper{} 12 | 13 | type IOStream interface { 14 | Recv() (*proto.IOStreamData, error) 15 | Send(*proto.IOStreamData) error 16 | Context() context.Context 17 | } 18 | 19 | type IOStreamWrapper struct { 20 | IOStream 21 | dataBuf []byte 22 | closed *atomic.Bool 23 | closeCh chan struct{} 24 | } 25 | 26 | func NewIOStreamWrapper(stream IOStream) *IOStreamWrapper { 27 | return &IOStreamWrapper{ 28 | IOStream: stream, 29 | closeCh: make(chan struct{}), 30 | closed: new(atomic.Bool), 31 | } 32 | } 33 | 34 | func (iw *IOStreamWrapper) Read(p []byte) (n int, err error) { 35 | if len(iw.dataBuf) > 0 { 36 | n := copy(p, iw.dataBuf) 37 | iw.dataBuf = iw.dataBuf[n:] 38 | // 如果dataBuf已空,清理引用以便GC回收 39 | if len(iw.dataBuf) == 0 { 40 | iw.dataBuf = nil 41 | } 42 | return n, nil 43 | } 44 | var data *proto.IOStreamData 45 | if data, err = iw.Recv(); err != nil { 46 | return 0, err 47 | } 48 | n = copy(p, data.Data) 49 | if n < len(data.Data) { 50 | // 只在必要时保存剩余数据 51 | remaining := len(data.Data) - n 52 | if remaining > 0 { 53 | iw.dataBuf = make([]byte, remaining) 54 | copy(iw.dataBuf, data.Data[n:]) 55 | } 56 | } 57 | return n, nil 58 | } 59 | 60 | func (iw *IOStreamWrapper) Write(p []byte) (n int, err error) { 61 | // 限制单次写入的数据大小,防止过大的消息 62 | const maxChunkSize = 64 * 1024 // 64KB chunks 63 | 64 | written := 0 65 | for written < len(p) { 66 | end := written + maxChunkSize 67 | if end > len(p) { 68 | end = len(p) 69 | } 70 | 71 | chunk := p[written:end] 72 | if err := iw.Send(&proto.IOStreamData{Data: chunk}); err != nil { 73 | return written, err 74 | } 75 | written += len(chunk) 76 | } 77 | 78 | return len(p), nil 79 | } 80 | 81 | func (iw *IOStreamWrapper) Close() error { 82 | if iw.closed.CompareAndSwap(false, true) { 83 | // 清理缓冲区 84 | iw.dataBuf = nil 85 | close(iw.closeCh) 86 | } 87 | return nil 88 | } 89 | 90 | func (iw *IOStreamWrapper) Wait() { 91 | <-iw.closeCh 92 | } 93 | -------------------------------------------------------------------------------- /resource/template/dashboard-default/nat.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/nat"}} 2 | {{template "common/header" .}} 3 | {{template "common/menu" .}} 4 |
5 |
6 |
7 |
8 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{range $item := .NAT}} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 43 | 44 | {{end}} 45 | 46 |
ID{{tr "Name"}}Agent ID{{tr "LocalService"}}{{tr "BindHostname"}}{{tr "Administration"}}
{{$item.ID}}{{$item.Name}}{{$item.ServerID}}{{$item.Host}}{{$item.Domain}} 33 |
34 | 37 | 41 |
42 |
47 |
48 |
49 | {{template "component/nat"}} 50 | {{template "common/footer" .}} 51 | 54 | {{end}} -------------------------------------------------------------------------------- /pkg/mygin/mygin.go: -------------------------------------------------------------------------------- 1 | package mygin 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/nicksnyder/go-i18n/v2/i18n" 9 | 10 | "github.com/xos/serverstatus/model" 11 | "github.com/xos/serverstatus/service/singleton" 12 | ) 13 | 14 | var adminPage = map[string]bool{ 15 | "/server": true, 16 | "/monitor": true, 17 | "/setting": true, 18 | "/notification": true, 19 | "/ddns": true, 20 | "/nat": true, 21 | "/cron": true, 22 | "/api": true, 23 | } 24 | 25 | func CommonEnvironment(c *gin.Context, data map[string]interface{}) gin.H { 26 | data["MatchedPath"] = c.MustGet("MatchedPath") 27 | data["Version"] = singleton.Version 28 | data["Conf"] = singleton.Conf 29 | data["Themes"] = model.Themes 30 | data["CustomCode"] = singleton.Conf.Site.CustomCode 31 | data["CustomCodeDashboard"] = singleton.Conf.Site.CustomCodeDashboard 32 | // 是否是管理页面 33 | data["IsAdminPage"] = adminPage[data["MatchedPath"].(string)] 34 | // 站点标题 35 | if t, has := data["Title"]; !has { 36 | data["Title"] = singleton.Conf.Site.Brand 37 | } else { 38 | data["Title"] = fmt.Sprintf("%s - %s", t, singleton.Conf.Site.Brand) 39 | } 40 | u, ok := c.Get(model.CtxKeyAuthorizedUser) 41 | if ok { 42 | data["Admin"] = u 43 | } 44 | data["LANG"] = map[string]string{ 45 | "Add": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Add"}), 46 | "Edit": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Edit"}), 47 | "AlarmRule": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "AlarmRule"}), 48 | "Notification": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "NotificationMethod"}), 49 | "Server": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Server"}), 50 | "Monitor": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ServicesManagement"}), 51 | "Cron": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ScheduledTasks"}), 52 | } 53 | return data 54 | } 55 | 56 | func RecordPath(c *gin.Context) { 57 | url := c.Request.URL.String() 58 | for _, p := range c.Params { 59 | url = strings.Replace(url, p.Value, ":"+p.Key, 1) 60 | } 61 | c.Set("MatchedPath", url) 62 | } 63 | -------------------------------------------------------------------------------- /script/com.serverstatus.agent.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Label 7 | com.serverstatus.agent 8 | 9 | 10 | ProgramArguments 11 | 12 | /opt/server-status/agent/server-agent 13 | 14 | 15 | 16 | WorkingDirectory 17 | /opt/server-status/agent 18 | 19 | 20 | RunAtLoad 21 | 22 | 23 | 24 | KeepAlive 25 | 26 | SuccessfulExit 27 | 28 | Crashed 29 | 30 | NetworkState 31 | 32 | 33 | 34 | 35 | StandardOutPath 36 | /tmp/server-agent.log 37 | 38 | 39 | StandardErrorPath 40 | /tmp/server-agent_error.log 41 | 42 | 43 | StartInterval 44 | 30 45 | 46 | 47 | EnvironmentVariables 48 | 49 | PATH 50 | /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin 51 | 52 | 53 | 54 | ProcessType 55 | Background 56 | 57 | 58 | ThrottleInterval 59 | 10 60 | 61 | 62 | SoftResourceLimits 63 | 64 | 65 | NumberOfFiles 66 | 1024 67 | 68 | 69 | 70 | HardResourceLimits 71 | 72 | 73 | NumberOfFiles 74 | 2048 75 | 76 | 77 | 78 | SessionCreate 79 | 80 | 81 | 82 | AbandonProcessGroup 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | jsoniter "github.com/json-iterator/go" 11 | ) 12 | 13 | var ( 14 | Json = jsoniter.ConfigCompatibleWithStandardLibrary 15 | 16 | DNSServers = []string{"1.1.1.1:53", "223.5.5.5:53"} 17 | ) 18 | 19 | func IsWindows() bool { 20 | return os.PathSeparator == '\\' && os.PathListSeparator == ';' 21 | } 22 | 23 | var ipv4Re = regexp.MustCompile(`(\d*\.).*(\.\d*)`) 24 | 25 | func ipv4Desensitize(ipv4Addr string) string { 26 | return ipv4Re.ReplaceAllString(ipv4Addr, "$1****$2") 27 | } 28 | 29 | var ipv6Re = regexp.MustCompile(`(\w*:\w*:).*(:\w*:\w*)`) 30 | 31 | func ipv6Desensitize(ipv6Addr string) string { 32 | return ipv6Re.ReplaceAllString(ipv6Addr, "$1****$2") 33 | } 34 | 35 | func IPDesensitize(ipAddr string) string { 36 | ipAddr = ipv4Desensitize(ipAddr) 37 | ipAddr = ipv6Desensitize(ipAddr) 38 | return ipAddr 39 | } 40 | 41 | // SplitIPAddr 传入/分割的v4v6混合地址,返回v4和v6地址与有效地址 42 | func SplitIPAddr(v4v6Bundle string) (string, string, string) { 43 | ipList := strings.Split(v4v6Bundle, "/") 44 | ipv4 := "" 45 | ipv6 := "" 46 | validIP := "" 47 | if len(ipList) > 1 { 48 | // 双栈 49 | ipv4 = ipList[0] 50 | ipv6 = ipList[1] 51 | validIP = ipv4 52 | } else if len(ipList) == 1 { 53 | // 仅ipv4|ipv6 54 | if strings.Contains(ipList[0], ":") { 55 | ipv6 = ipList[0] 56 | validIP = ipv6 57 | } else { 58 | ipv4 = ipList[0] 59 | validIP = ipv4 60 | } 61 | } 62 | return ipv4, ipv6, validIP 63 | } 64 | 65 | func IsFileExists(path string) bool { 66 | _, err := os.Stat(path) 67 | return err == nil 68 | } 69 | 70 | func GenerateRandomString(n int) (string, error) { 71 | const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 72 | lettersLength := big.NewInt(int64(len(letters))) 73 | ret := make([]byte, n) 74 | for i := 0; i < n; i++ { 75 | num, err := rand.Int(rand.Reader, lettersLength) 76 | if err != nil { 77 | return "", err 78 | } 79 | ret[i] = letters[num.Int64()] 80 | } 81 | return string(ret), nil 82 | } 83 | 84 | func Uint64SubInt64(a uint64, b int64) uint64 { 85 | if b < 0 { 86 | return a + uint64(-b) 87 | } 88 | if a < uint64(b) { 89 | return 0 90 | } 91 | return a - uint64(b) 92 | } 93 | -------------------------------------------------------------------------------- /script/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # ServerStatus Dashboard 入口脚本 4 | # 兼容 scratch 镜像环境 5 | 6 | # 健康检查模式 7 | if [ "$1" = "--health-check" ]; then 8 | # 检查进程是否存在 9 | if [ -f "/proc/1/comm" ]; then 10 | exit 0 11 | else 12 | exit 1 13 | fi 14 | fi 15 | 16 | # 简单日志函数(不依赖 date 命令) 17 | log() { 18 | echo "[ENTRYPOINT] $1" 19 | } 20 | 21 | log "Starting ServerStatus Dashboard..." 22 | 23 | # 检查数据目录 24 | if [ ! -d "/dashboard/data" ]; then 25 | log "Creating data directory..." 26 | mkdir -p /dashboard/data 27 | fi 28 | 29 | # 检查配置文件 30 | if [ ! -f "/dashboard/data/config.yaml" ]; then 31 | log "Creating default configuration..." 32 | cat > /dashboard/data/config.yaml << 'EOF' 33 | # ServerStatus Dashboard 配置文件 34 | debug: false 35 | language: zh-CN 36 | httpport: 80 37 | grpcport: 2222 38 | grpchost: "" 39 | 40 | # 数据库配置 41 | database: 42 | type: sqlite 43 | dsn: data/sqlite.db 44 | 45 | # JWT 密钥 (请修改为随机字符串) 46 | jwt_secret: "default-secret-please-change" 47 | 48 | # 管理员账户 (首次启动后请立即修改) 49 | admin: 50 | username: admin 51 | password: admin123 52 | 53 | # 站点配置 54 | site: 55 | brand: "ServerStatus" 56 | cookiename: "server-dash" 57 | theme: "default" 58 | customcode: "" 59 | viewpassword: "" 60 | 61 | # OAuth2 配置 (可选) 62 | oauth2: 63 | type: "" 64 | admin: "" 65 | clientid: "" 66 | clientsecret: "" 67 | endpoint: "" 68 | 69 | # DDNS 配置 (可选) 70 | ddns: 71 | enable: false 72 | provider: "webhook" 73 | accessid: "" 74 | accesssecret: "" 75 | webhookmethod: "" 76 | webhookurl: "" 77 | webhookrequestbody: "" 78 | webhookheaders: "" 79 | maxretries: 3 80 | 81 | # 其他配置 82 | cover: 0 83 | ignoredipnotification: "" 84 | ignoredipnotificationserverids: [] 85 | tgbottoken: "" 86 | tgchatid: "" 87 | wxpushertoken: "" 88 | wxpusheruids: [] 89 | EOF 90 | log "Default configuration created" 91 | log "Please modify admin password after first login" 92 | fi 93 | 94 | # 检查应用文件 95 | if [ ! -f "/dashboard/app" ]; then 96 | log "ERROR: Application binary not found at /dashboard/app" 97 | exit 1 98 | fi 99 | 100 | if [ ! -x "/dashboard/app" ]; then 101 | log "Setting executable permissions for app..." 102 | chmod +x /dashboard/app 103 | fi 104 | 105 | # 启动应用 106 | log "Starting application..." 107 | exec /dashboard/app "$@" -------------------------------------------------------------------------------- /resource/template/dashboard-default/ddns.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/ddns"}} 2 | {{template "common/header" .}} 3 | {{template "common/menu" .}} 4 |
5 |
6 |
7 |
8 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {{range $item := .DDNS}} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 47 | 48 | {{end}} 49 | 50 |
ID{{tr "Name"}}{{tr "EnableIPv4"}}{{tr "EnableIPv6"}}{{tr "DDNSProvider"}}{{tr "DDNSDomain"}}{{tr "MaxRetries"}}{{tr "Administration"}}
{{$item.ID}}{{$item.Name}}{{$item.EnableIPv4}}{{$item.EnableIPv6}}{{index $.ProviderMap $item.Provider}}{{$item.DomainsRaw}}{{$item.MaxRetries}} 37 |
38 | 41 | 45 |
46 |
51 |
52 |
53 | {{template "component/ddns" .}} 54 | {{template "common/footer" .}} 55 | 58 | {{end}} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 多阶段构建 - 证书和工具阶段 2 | FROM alpine:3.19 AS certs 3 | RUN apk update && apk add --no-cache ca-certificates tzdata busybox-static 4 | 5 | # 脚本准备阶段 6 | FROM alpine:3.19 AS script-prep 7 | WORKDIR /prep 8 | # 复制并设置entrypoint.sh权限 9 | COPY script/entrypoint.sh ./entrypoint.sh 10 | # 规范化换行并赋予可执行权限(避免 CRLF 导致的执行失败) 11 | RUN sed -i 's/\r$//' ./entrypoint.sh && chmod +x ./entrypoint.sh 12 | 13 | # 二进制文件准备阶段 14 | FROM alpine:3.19 AS binary-prep 15 | ARG TARGETARCH 16 | WORKDIR /prep 17 | 18 | # 复制所有构建产物 19 | COPY dist/ ./dist/ 20 | 21 | # 查找并复制正确的二进制文件 22 | RUN find ./dist -name "*linux*${TARGETARCH}*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app || \ 23 | find ./dist -name "*${TARGETARCH}*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app || \ 24 | find ./dist -name "server-dash*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app 25 | 26 | # 设置执行权限 27 | RUN test -f /prep/app && chmod +x /prep/app 28 | 29 | # 最终运行阶段 30 | FROM scratch 31 | 32 | ARG TARGETOS 33 | ARG TARGETARCH 34 | ARG TZ=Asia/Shanghai 35 | 36 | # 从证书阶段复制必要文件 37 | COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 38 | COPY --from=certs /usr/share/zoneinfo /usr/share/zoneinfo 39 | COPY --from=certs /etc/passwd /etc/passwd 40 | COPY --from=certs /etc/group /etc/group 41 | 42 | # 复制静态 busybox 到 scratch(重要:避免动态链接导致的“no such file or directory”) 43 | # 注意:busybox-static 包提供的是 /bin/busybox.static 44 | COPY --from=certs /bin/busybox.static /bin/sh 45 | COPY --from=certs /bin/busybox.static /bin/busybox 46 | COPY --from=certs /bin/busybox.static /bin/mkdir 47 | COPY --from=certs /bin/busybox.static /bin/chmod 48 | COPY --from=certs /bin/busybox.static /bin/cat 49 | COPY --from=certs /bin/busybox.static /bin/echo 50 | COPY --from=certs /bin/busybox.static /bin/date 51 | COPY --from=certs /bin/busybox.static /bin/pgrep 52 | COPY --from=certs /bin/busybox.static /bin/test 53 | COPY --from=certs /bin/busybox.static /bin/ls 54 | 55 | # 复制入口脚本和应用 56 | COPY --from=script-prep /prep/entrypoint.sh /entrypoint.sh 57 | COPY --from=binary-prep /prep/app /dashboard/app 58 | 59 | # 复制静态资源文件(重要:应用依赖这些文件) 60 | COPY resource/ /dashboard/resource/ 61 | 62 | # 设置工作目录和环境变量 63 | WORKDIR /dashboard 64 | ENV TZ=$TZ 65 | ENV GIN_MODE=release 66 | ENV PATH=/bin 67 | 68 | # 创建数据目录并设置权限 69 | VOLUME ["/dashboard/data"] 70 | 71 | # 暴露端口 72 | EXPOSE 80 2222 73 | 74 | # 健康检查 75 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 76 | CMD ["/dashboard/app", "--health-check"] || exit 1 77 | 78 | # 使用入口脚本启动 79 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /script/run-with-prebuilt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 使用预构建镜像运行 ServerStatus 4 | # 适用于没有 Docker 构建环境的情况 5 | 6 | set -e 7 | 8 | # 颜色定义 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' 13 | 14 | log_info() { 15 | echo -e "${GREEN}[INFO]${NC} $1" 16 | } 17 | 18 | log_warn() { 19 | echo -e "${YELLOW}[WARN]${NC} $1" 20 | } 21 | 22 | log_error() { 23 | echo -e "${RED}[ERROR]${NC} $1" 24 | } 25 | 26 | # 检查 Docker 是否可用 27 | check_docker() { 28 | if ! command -v docker &> /dev/null; then 29 | log_error "Docker 未安装,请先安装 Docker Desktop" 30 | log_info "下载地址: https://www.docker.com/products/docker-desktop" 31 | exit 1 32 | fi 33 | 34 | if ! docker info &> /dev/null; then 35 | log_error "Docker 服务未运行,请启动 Docker Desktop" 36 | exit 1 37 | fi 38 | } 39 | 40 | # 创建必要的目录和配置 41 | setup_environment() { 42 | log_info "设置运行环境..." 43 | 44 | # 创建数据目录 45 | mkdir -p data 46 | 47 | # 创建默认配置文件(如果不存在) 48 | if [ ! -f "data/config.yaml" ]; then 49 | log_info "创建默认配置文件..." 50 | cat > data/config.yaml << 'EOF' 51 | # ServerStatus Dashboard 配置文件 52 | debug: false 53 | language: zh-CN 54 | httpport: 80 55 | grpcport: 2222 56 | 57 | # 数据库配置 58 | database: 59 | type: sqlite 60 | dsn: data/sqlite.db 61 | 62 | # JWT 密钥 (请修改为随机字符串) 63 | jwt_secret: "your-secret-key-here-$(date +%s)" 64 | 65 | # 管理员账户 (首次启动后请立即修改) 66 | admin: 67 | username: admin 68 | password: admin123 69 | 70 | # 站点配置 71 | site: 72 | brand: "ServerStatus" 73 | cookiename: "server-dash" 74 | theme: "default" 75 | customcode: "" 76 | viewpassword: "" 77 | 78 | # 其他配置 79 | cover: 0 80 | ignoredipnotification: "" 81 | ignoredipnotificationserverids: [] 82 | EOF 83 | log_warn "默认管理员账户: admin/admin123 (请首次登录后立即修改)" 84 | fi 85 | } 86 | 87 | # 拉取并运行预构建镜像 88 | run_prebuilt() { 89 | log_info "拉取最新的预构建镜像..." 90 | docker pull ghcr.io/xos/server-dash:latest 91 | 92 | log_info "启动 ServerStatus 服务..." 93 | docker-compose up -d 94 | 95 | log_info "服务启动完成!" 96 | log_info "Web 界面: http://localhost:80" 97 | log_info "Agent 端口: 2222" 98 | log_info "" 99 | log_info "查看日志: docker-compose logs -f" 100 | log_info "停止服务: docker-compose down" 101 | } 102 | 103 | # 主函数 104 | main() { 105 | log_info "使用预构建镜像运行 ServerStatus..." 106 | 107 | check_docker 108 | setup_environment 109 | run_prebuilt 110 | } 111 | 112 | main "$@" -------------------------------------------------------------------------------- /resource/static/mixin.js: -------------------------------------------------------------------------------- 1 | const mixinsVue = { 2 | delimiters: ['@#', '#@'], 3 | data: { 4 | preferredTemplate: null, 5 | isMobile: false, 6 | adaptedTemplates: [ 7 | { key: 'default', name: 'Default', icon: 'th large' } 8 | ] 9 | }, 10 | created() { 11 | this.isMobile = this.checkIsMobile(); 12 | this.preferredTemplate = this.getCookie('preferred_theme') ? this.getCookie('preferred_theme') : this.$root.defaultTemplate; 13 | }, 14 | mounted() { 15 | this.initDropdown(); 16 | }, 17 | methods: { 18 | initDropdown() { 19 | if(this.isMobile) $('.ui.dropdown').dropdown({ 20 | action: 'hide', 21 | on: 'click', 22 | duration: 100, 23 | direction: 'direction' 24 | }); 25 | }, 26 | toggleTemplate(template) { 27 | if( template != this.preferredTemplate){ 28 | this.preferredTemplate = template; 29 | this.updateCookie("preferred_theme", template); 30 | window.location.reload(); 31 | } 32 | }, 33 | updateCookie(name, value) { 34 | document.cookie = name + "=" + value +"; path=/"; 35 | }, 36 | getCookie(name) { 37 | const cookies = document.cookie.split(';'); 38 | let cookieValue = null; 39 | for (let i = 0; i < cookies.length; i++) { 40 | const cookie = cookies[i].trim(); 41 | if (cookie.startsWith(name + '=')) { 42 | cookieValue = cookie.substring(name.length + 1, cookie.length); 43 | break; 44 | } 45 | } 46 | return cookieValue; 47 | }, 48 | checkIsMobile() { // 检测设备类型,页面宽度小于768px认为是移动设备 49 | return window.innerWidth < 768; 50 | }, 51 | logOut(id) { 52 | $.ajax({ 53 | type: 'POST', 54 | url: '/api/logout', 55 | data: JSON.stringify({ id: id }), 56 | contentType: 'application/json', 57 | success: function (resp) { 58 | if (resp.code == 200) { 59 | window.location.reload(); 60 | } else { 61 | alert('注销失败(Error ' + resp.code + '): ' + resp.message); 62 | } 63 | }, 64 | error: function (err) { 65 | alert('网络错误: ' + err.responseText); 66 | } 67 | }); 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | # ServerStatus 数据库迁移指南 2 | 3 | 本文档说明如何将 ServerStatus 从 SQLite 数据库迁移到 BadgerDB。 4 | 5 | ## 迁移功能特性 6 | 7 | ### 新增功能 8 | 1. **进度跟踪和可恢复迁移** 9 | - 实时跟踪每个表的迁移进度 10 | - 支持中断后恢复,避免重复迁移 11 | - 自动保存进度到 BadgerDB 12 | 13 | 2. **批量处理优化** 14 | - 可配置批量大小,适应不同数据量 15 | - 针对大表(monitor_histories)的分批处理 16 | - 避免内存溢出 17 | 18 | 3. **数据验证和错误恢复** 19 | - 每条记录失败后自动重试(可配置重试次数) 20 | - 详细的错误日志记录 21 | - 失败记录统计 22 | 23 | 4. **灵活的配置选项** 24 | - 可选择迁移时间范围(如只迁移最近30天数据) 25 | - 可限制迁移记录数量 26 | - 可跳过大数据字段以加快迁移 27 | 28 | 5. **ID映射保持** 29 | - 保留原始ID,不重新分配 30 | - 维护表间关联关系 31 | 32 | ## 使用方法 33 | 34 | ### 编译迁移工具 35 | ```bash 36 | cd /path/to/ServerStatus 37 | go build -o migrate ./cmd/migrate 38 | ``` 39 | 40 | ### 基本用法 41 | ```bash 42 | # 使用默认配置迁移 43 | ./migrate -data ./data 44 | 45 | # 快速迁移模式(只迁移最近7天数据) 46 | ./migrate -data ./data -mode quick 47 | 48 | # 完整迁移模式(迁移所有数据) 49 | ./migrate -data ./data -mode full 50 | ``` 51 | 52 | ### 高级选项 53 | ```bash 54 | ./migrate \ 55 | -data ./data \ 56 | -sqlite sqlite.db \ 57 | -badger badger \ 58 | -mode default \ 59 | -history-days 30 \ 60 | -history-limit 100000 \ 61 | -batch-size 200 \ 62 | -skip-large-data=false \ 63 | -resume=true \ 64 | -workers 8 65 | ``` 66 | 67 | ### 参数说明 68 | - `-data`: 数据目录路径(默认:./data) 69 | - `-sqlite`: SQLite数据库文件名(默认:sqlite.db) 70 | - `-badger`: BadgerDB目录名(默认:badger) 71 | - `-mode`: 迁移模式 - quick/full/default(默认:default) 72 | - `-history-days`: 监控历史天数,-1表示全部(默认:30) 73 | - `-history-limit`: 监控历史记录数限制,-1表示无限制(默认:-1) 74 | - `-batch-size`: 批处理大小(默认:100) 75 | - `-skip-large-data`: 是否跳过monitor_histories表的data字段(默认:false) 76 | - `-resume`: 是否启用可恢复迁移(默认:true) 77 | - `-workers`: 并发工作线程数(默认:4) 78 | 79 | ## 迁移模式说明 80 | 81 | ### 默认模式 (default) 82 | - 迁移最近30天的监控历史 83 | - 批量大小:100 84 | - 启用可恢复迁移 85 | - 保留所有数据字段 86 | 87 | ### 快速模式 (quick) 88 | - 只迁移最近7天的监控历史 89 | - 限制监控历史最多10000条 90 | - 跳过大数据字段 91 | - 批量大小:50 92 | - 适合快速测试或小数据集 93 | 94 | ### 完整模式 (full) 95 | - 迁移所有历史数据 96 | - 批量大小:200 97 | - 8个并发工作线程 98 | - 适合生产环境完整迁移 99 | 100 | ## 注意事项 101 | 102 | 1. **备份数据**:迁移前请备份原始SQLite数据库 103 | 2. **磁盘空间**:确保有足够的磁盘空间存储BadgerDB数据 104 | 3. **迁移时间**:大数据集可能需要较长时间,建议在低峰期执行 105 | 4. **监控进度**:迁移过程会实时显示进度,可随时中断 106 | 5. **恢复迁移**:如果中断,再次运行相同命令会从上次位置继续 107 | 108 | ## 问题排查 109 | 110 | ### 内存不足 111 | 如果遇到内存问题,可以: 112 | - 减小批量大小:`-batch-size 50` 113 | - 跳过大数据字段:`-skip-large-data=true` 114 | - 限制历史数据:`-history-days 7` 115 | 116 | ### 迁移速度慢 117 | - 增加批量大小:`-batch-size 500` 118 | - 增加工作线程:`-workers 16` 119 | - 使用快速模式:`-mode quick` 120 | 121 | ### 数据验证 122 | 迁移完成后,工具会显示各表的记录数统计,可与原始数据库对比验证。 -------------------------------------------------------------------------------- /Dockerfile.debug: -------------------------------------------------------------------------------- 1 | # 调试版本的 Dockerfile 2 | # 用于诊断 entrypoint.sh 文件问题 3 | 4 | # 多阶段构建 - 证书和工具阶段 5 | FROM alpine:3.19 AS certs 6 | RUN apk update && apk add --no-cache ca-certificates tzdata busybox-static 7 | 8 | # 二进制文件准备阶段 9 | FROM alpine:3.19 AS binary-prep 10 | ARG TARGETARCH 11 | WORKDIR /prep 12 | 13 | # 复制所有构建产物和脚本 14 | COPY dist/ ./dist/ 15 | COPY script/entrypoint.sh ./entrypoint.sh 16 | 17 | # 调试:列出复制的文件 18 | RUN echo "=== 调试信息 ===" && \ 19 | echo "当前目录内容:" && \ 20 | ls -la && \ 21 | echo "dist目录内容:" && \ 22 | ls -la ./dist/ && \ 23 | echo "entrypoint.sh文件信息:" && \ 24 | ls -la ./entrypoint.sh && \ 25 | echo "entrypoint.sh内容:" && \ 26 | cat ./entrypoint.sh 27 | 28 | # 查找并复制正确的二进制文件 29 | RUN find ./dist -name "*linux*${TARGETARCH}*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app || \ 30 | find ./dist -name "*${TARGETARCH}*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app || \ 31 | find ./dist -name "server-dash*" -type f -executable | head -1 | xargs -I {} cp {} /prep/app 32 | 33 | # 设置执行权限 34 | RUN test -f /prep/app && chmod +x /prep/app 35 | RUN chmod +x /prep/entrypoint.sh 36 | 37 | # 调试:验证文件 38 | RUN echo "=== 准备阶段完成 ===" && \ 39 | echo "准备的文件:" && \ 40 | ls -la /prep/ && \ 41 | echo "app文件信息:" && \ 42 | file /prep/app && \ 43 | echo "entrypoint.sh权限:" && \ 44 | ls -la /prep/entrypoint.sh 45 | 46 | # 最终运行阶段 - 使用 Alpine 而不是 scratch 进行调试 47 | FROM alpine:3.19 48 | 49 | ARG TARGETOS 50 | ARG TARGETARCH 51 | ARG TZ=Asia/Shanghai 52 | 53 | # 安装基本工具用于调试 54 | RUN apk add --no-cache ca-certificates tzdata busybox 55 | 56 | # 从证书阶段复制必要文件 57 | COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 58 | COPY --from=certs /usr/share/zoneinfo /usr/share/zoneinfo 59 | 60 | # 复制入口脚本和应用 61 | COPY --from=binary-prep /prep/entrypoint.sh /entrypoint.sh 62 | COPY --from=binary-prep /prep/app /dashboard/app 63 | 64 | # 复制静态资源文件 65 | COPY resource/ /dashboard/resource/ 66 | 67 | # 调试:验证最终镜像中的文件 68 | RUN echo "=== 最终镜像调试信息 ===" && \ 69 | echo "根目录内容:" && \ 70 | ls -la / && \ 71 | echo "entrypoint.sh文件信息:" && \ 72 | ls -la /entrypoint.sh && \ 73 | echo "dashboard目录内容:" && \ 74 | ls -la /dashboard/ && \ 75 | echo "app文件信息:" && \ 76 | ls -la /dashboard/app && \ 77 | file /dashboard/app 78 | 79 | # 设置工作目录和环境变量 80 | WORKDIR /dashboard 81 | ENV TZ=$TZ 82 | ENV GIN_MODE=release 83 | 84 | # 创建数据目录 85 | RUN mkdir -p /dashboard/data 86 | 87 | # 暴露端口 88 | EXPOSE 80 2222 89 | 90 | # 健康检查 91 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 92 | CMD ["/dashboard/app", "--health-check"] || exit 1 93 | 94 | # 使用入口脚本启动 95 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /pkg/utils/jsonx.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "net/http" 7 | "sync" 8 | ) 9 | 10 | // Pooled buffers for JSON encoding to reduce allocations 11 | var jsonBufPool = sync.Pool{New: func() interface{} { return new(bytes.Buffer) }} 12 | 13 | // Gzip writer pool for fast compression of large JSON responses 14 | var gzipPool = sync.Pool{New: func() interface{} { 15 | w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed) 16 | return w 17 | }} 18 | 19 | // GetBuffer returns a reset bytes.Buffer from pool 20 | func GetJSONBuffer() *bytes.Buffer { 21 | buf := jsonBufPool.Get().(*bytes.Buffer) 22 | buf.Reset() 23 | return buf 24 | } 25 | 26 | // PutBuffer returns buffer to pool 27 | func PutJSONBuffer(buf *bytes.Buffer) { 28 | if buf != nil { 29 | buf.Reset() 30 | jsonBufPool.Put(buf) 31 | } 32 | } 33 | 34 | // EncodeJSON marshals using utils.Json into a pooled buffer and returns bytes 35 | // Caller owns the returned slice (copied), buffer is returned to pool 36 | func EncodeJSON(v interface{}) ([]byte, error) { 37 | buf := GetJSONBuffer() 38 | defer PutJSONBuffer(buf) 39 | enc := Json.NewEncoder(buf) 40 | enc.SetEscapeHTML(false) 41 | if err := enc.Encode(v); err != nil { 42 | return nil, err 43 | } 44 | // json.Encoder adds a trailing newline; keep behavior to match c.JSON, 45 | // frontend tolerant; if needed, trim last \n by bytes.TrimRight 46 | out := make([]byte, buf.Len()) 47 | copy(out, buf.Bytes()) 48 | return out, nil 49 | } 50 | 51 | // GzipIfAccepted compresses and writes payload only when gzip is accepted and worthwhile. 52 | // If gzip is not used, this function DOES NOT write to w and returns (0, false, nil). 53 | // Callers must handle the plain write path when gzipped == false to avoid double writes. 54 | func GzipIfAccepted(w http.ResponseWriter, r *http.Request, payload []byte) (written int, gzipped bool, err error) { 55 | // Only gzip when Accept-Encoding includes gzip and payload large enough 56 | if len(payload) < 1024 { // small payloads not worth gzipping 57 | return 0, false, nil 58 | } 59 | if !acceptsGzip(r) { 60 | return 0, false, nil 61 | } 62 | w.Header().Set("Content-Encoding", "gzip") 63 | w.Header().Del("Content-Length") 64 | gw := gzipPool.Get().(*gzip.Writer) 65 | gw.Reset(w) 66 | // 写入压缩数据并确保仅关闭一次,避免生成多个 gzip 成员 67 | if _, err = gw.Write(payload); err != nil { 68 | // 出错时归还 writer 69 | _ = gw.Close() 70 | gzipPool.Put(gw) 71 | return 0, true, err 72 | } 73 | cerr := gw.Close() 74 | gzipPool.Put(gw) 75 | if cerr != nil { 76 | return 0, true, cerr 77 | } 78 | return 0, true, nil 79 | } 80 | 81 | func acceptsGzip(r *http.Request) bool { 82 | enc := r.Header.Get("Accept-Encoding") 83 | return bytes.Contains([]byte(enc), []byte("gzip")) 84 | } 85 | -------------------------------------------------------------------------------- /resource/template/theme-default/menu.html: -------------------------------------------------------------------------------- 1 | {{define "theme-default/menu"}} 2 |
3 | 52 | {{template "component/confirm" .}} 53 | {{end}} 54 | -------------------------------------------------------------------------------- /resource/static/file.js: -------------------------------------------------------------------------------- 1 | let receivedLength = 0; 2 | let expectedLength = 0; 3 | let root; 4 | let draftHandle; 5 | let accessHandle; 6 | 7 | const Operation = Object.freeze({ 8 | WriteHeader: 1, 9 | WriteChunks: 2, 10 | DeleteFiles: 3 11 | }); 12 | 13 | onmessage = async function (event) { 14 | try { 15 | const { operation, arrayBuffer, fileName } = event.data; 16 | 17 | switch (operation) { 18 | case Operation.WriteHeader: { 19 | const dataView = new DataView(arrayBuffer); 20 | expectedLength = Number(dataView.getBigUint64(4, false)); 21 | receivedLength = 0; 22 | 23 | // Create a new temporary file 24 | root = await navigator.storage.getDirectory(); 25 | draftHandle = await root.getFileHandle(fileName, { create: true }); 26 | accessHandle = await draftHandle.createSyncAccessHandle(); 27 | 28 | // Inform that file handle is created 29 | const dataChunk = arrayBuffer.slice(12); 30 | receivedLength += dataChunk.byteLength; 31 | accessHandle.write(dataChunk, { at: 0 }); 32 | const progress = 'got handle'; 33 | postMessage({ type: 'progress', progress: progress }); 34 | break; 35 | } 36 | case Operation.WriteChunks: { 37 | if (!accessHandle) { 38 | throw new Error('accessHandle is undefined'); 39 | } 40 | 41 | const dataChunk = arrayBuffer; 42 | accessHandle.write(dataChunk, { at: receivedLength }); 43 | receivedLength += dataChunk.byteLength; 44 | 45 | if (receivedLength === expectedLength) { 46 | accessHandle.flush(); 47 | accessHandle.close(); 48 | 49 | const fileBlob = await draftHandle.getFile(); 50 | const blob = new Blob([fileBlob], { type: 'application/octet-stream' }); 51 | 52 | postMessage({ type: 'result', blob: blob, fileName: fileName }); 53 | } 54 | break; 55 | } 56 | case Operation.DeleteFiles: { 57 | for await (const [name, handle] of root.entries()) { 58 | if (handle.kind === 'file') { 59 | await root.removeEntry(name); 60 | } else if (handle.kind === 'directory') { 61 | await root.removeEntry(name, { recursive: true }); 62 | } 63 | } 64 | break; 65 | } 66 | } 67 | } catch (error) { 68 | postMessage({ error: error.message }); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /cmd/dashboard/controller/guest_page.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/nicksnyder/go-i18n/v2/i18n" 9 | "github.com/xos/serverstatus/model" 10 | "github.com/xos/serverstatus/pkg/mygin" 11 | "github.com/xos/serverstatus/service/singleton" 12 | ) 13 | 14 | type guestPage struct { 15 | r *gin.Engine 16 | } 17 | 18 | func (gp *guestPage) serve() { 19 | gr := gp.r.Group("") 20 | gr.Use(mygin.Authorize(mygin.AuthorizeOption{ 21 | GuestOnly: true, 22 | IsPage: true, 23 | Msg: "您已登录", 24 | Btn: "返回首页", 25 | Redirect: "/", 26 | })) 27 | 28 | gr.GET("/login", gp.login) 29 | 30 | // 调试模式下的简单登录 31 | if singleton.Conf.Debug { 32 | gr.POST("/debug-login", gp.debugLogin) 33 | } 34 | 35 | oauth := &oauth2controller{ 36 | r: gr, 37 | } 38 | oauth.serve() 39 | } 40 | 41 | func (gp *guestPage) login(c *gin.Context) { 42 | if singleton.Conf.Oauth2.OidcAutoLogin { 43 | c.Redirect(http.StatusFound, "/oauth2/login") 44 | return 45 | } 46 | LoginType := "GitHub" 47 | RegistrationLink := "https://github.com/join" 48 | if singleton.Conf.Oauth2.Type == model.ConfigTypeGitee { 49 | LoginType = "Gitee" 50 | RegistrationLink = "https://gitee.com/signup" 51 | } else if singleton.Conf.Oauth2.Type == model.ConfigTypeGitlab { 52 | LoginType = "Gitlab" 53 | RegistrationLink = "https://gitlab.com/users/sign_up" 54 | } else if singleton.Conf.Oauth2.Type == model.ConfigTypeJihulab { 55 | LoginType = "Jihulab" 56 | RegistrationLink = "https://jihulab.com/users/sign_up" 57 | } else if singleton.Conf.Oauth2.Type == model.ConfigTypeGitea { 58 | LoginType = "Gitea" 59 | RegistrationLink = fmt.Sprintf("%s/user/sign_up", singleton.Conf.Oauth2.Endpoint) 60 | } else if singleton.Conf.Oauth2.Type == model.ConfigTypeCloudflare { 61 | LoginType = "Cloudflare" 62 | RegistrationLink = "https://dash.cloudflare.com/sign-up/teams" 63 | } else if singleton.Conf.Oauth2.Type == model.ConfigTypeOidc { 64 | LoginType = singleton.Conf.Oauth2.OidcDisplayName 65 | RegistrationLink = singleton.Conf.Oauth2.OidcRegisterURL 66 | } 67 | c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/login", mygin.CommonEnvironment(c, gin.H{ 68 | "Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Login"}), 69 | "LoginType": LoginType, 70 | "RegistrationLink": RegistrationLink, 71 | })) 72 | } 73 | 74 | func (gp *guestPage) debugLogin(c *gin.Context) { 75 | if !singleton.Conf.Debug { 76 | WriteJSON(c, http.StatusForbidden, gin.H{"error": "Debug mode not enabled"}) 77 | return 78 | } 79 | 80 | // 设置 admin token cookie 81 | c.SetCookie(singleton.Conf.Site.CookieName, "admin", 3600*24*30, "/", "", false, false) 82 | WriteJSON(c, http.StatusOK, gin.H{"message": "Debug login successful"}) 83 | } 84 | -------------------------------------------------------------------------------- /resource/template/component/rule.html: -------------------------------------------------------------------------------- 1 | {{define "component/rule"}} 2 | 59 | {{end}} -------------------------------------------------------------------------------- /resource/template/dashboard-default/monitor.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/monitor"}} {{template "common/header" .}} {{template 2 | "common/menu" .}} 3 |
4 |
5 |
6 |
7 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {{range $monitor := .Monitors}} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 59 | 60 | {{end}} 61 | 62 |
ID{{tr "Name"}}{{tr "Target"}}{{tr "Coverage"}}{{tr "SpecificServers"}}{{tr "Type"}}{{tr "Duration"}}{{tr "NotificationMethodGroup"}}{{tr "FailureNotification"}}{{tr "LatencyNotification"}}{{tr "EnableTriggerTask"}}{{tr "FailTriggerTasks"}}{{tr "RecoverTriggerTasks"}}{{tr "Administration"}}
{{$monitor.ID}}{{$monitor.Name}}{{$monitor.Target}}{{if eq $monitor.Cover 0}}{{tr "CoverAll"}}{{else}}{{tr "IgnoreAll"}}{{end}}{{$monitor.SkipServersRaw}} 40 | {{if eq $monitor.Type 1}}ICMP Ping {{else if eq $monitor.Type 2}} {{tr "TCPPort"}} {{end}} 41 | {{$monitor.Duration}} {{tr "Seconds"}}{{$monitor.NotificationTag}}{{$monitor.Notify}}{{$monitor.LatencyNotify}}{{$monitor.EnableTriggerTask}}{{$monitor.FailTriggerTasksRaw}}{{$monitor.RecoverTriggerTasksRaw}} 50 |
51 | 54 | 57 |
58 |
63 |
64 |
65 | {{template "component/monitor"}} {{template "common/footer" .}} 66 | 69 | {{end}} 70 | -------------------------------------------------------------------------------- /resource/template/dashboard-default/api.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/api"}} 2 | {{template "common/header" .}} 3 | {{template "common/menu" .}} 4 |
5 |
6 |
7 |
8 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {{range $token := .Tokens}} 23 | 24 | 25 | 26 | 43 | 44 | {{end}} 45 | 46 |
{{tr "APIToken"}}{{tr "Note"}}{{tr "Administration"}}
{{$token.Token}}{{$token.Note}} 27 |
28 | 31 | {{if $token.Token}} 32 | 36 | {{else}} 37 | 40 | {{end}} 41 |
42 |
47 |
48 |
49 | {{template "component/api"}} 50 | {{template "common/footer" .}} 51 | 52 | 79 | {{end}} 80 | -------------------------------------------------------------------------------- /model/ddns.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | const ( 11 | ProviderDummy = iota 12 | ProviderWebHook 13 | ProviderCloudflare 14 | ProviderTencentCloud 15 | ) 16 | 17 | const ( 18 | _Dummy = "dummy" 19 | _WebHook = "webhook" 20 | _Cloudflare = "cloudflare" 21 | _TencentCloud = "tencentcloud" 22 | ) 23 | 24 | var ProviderMap = map[uint8]string{ 25 | ProviderDummy: _Dummy, 26 | ProviderWebHook: _WebHook, 27 | ProviderCloudflare: _Cloudflare, 28 | ProviderTencentCloud: _TencentCloud, 29 | } 30 | 31 | var ProviderList = []DDNSProvider{ 32 | { 33 | Name: _Dummy, 34 | ID: ProviderDummy, 35 | }, 36 | { 37 | Name: _Cloudflare, 38 | ID: ProviderCloudflare, 39 | AccessSecret: true, 40 | }, 41 | { 42 | Name: _TencentCloud, 43 | ID: ProviderTencentCloud, 44 | AccessID: true, 45 | AccessSecret: true, 46 | }, 47 | // Least frequently used, always place this at the end 48 | { 49 | Name: _WebHook, 50 | ID: ProviderWebHook, 51 | AccessID: true, 52 | AccessSecret: true, 53 | WebhookURL: true, 54 | WebhookMethod: true, 55 | WebhookRequestType: true, 56 | WebhookRequestBody: true, 57 | WebhookHeaders: true, 58 | }, 59 | } 60 | 61 | type DDNSProfile struct { 62 | Common 63 | EnableIPv4 *bool 64 | EnableIPv6 *bool 65 | MaxRetries uint64 66 | Name string 67 | Provider uint8 68 | AccessID string 69 | AccessSecret string 70 | WebhookURL string 71 | WebhookMethod uint8 72 | WebhookRequestType uint8 73 | WebhookRequestBody string 74 | WebhookHeaders string 75 | 76 | Domains []string `gorm:"-"` 77 | DomainsRaw string 78 | } 79 | 80 | func (d DDNSProfile) TableName() string { 81 | return "ddns" 82 | } 83 | 84 | func (d *DDNSProfile) AfterFind(tx *gorm.DB) error { 85 | if d.DomainsRaw != "" { 86 | d.Domains = strings.Split(d.DomainsRaw, ",") 87 | } 88 | return nil 89 | } 90 | 91 | type DDNSProvider struct { 92 | Name string 93 | ID uint8 94 | AccessID bool 95 | AccessSecret bool 96 | WebhookURL bool 97 | WebhookMethod bool 98 | WebhookRequestType bool 99 | WebhookRequestBody bool 100 | WebhookHeaders bool 101 | } 102 | 103 | // DDNSRecordState 存储DDNS记录的当前状态,用于避免重复通知 104 | type DDNSRecordState struct { 105 | Common 106 | ServerID uint64 `gorm:"index:idx_ddns_record,unique" json:"server_id"` 107 | Domain string `gorm:"index:idx_ddns_record,unique;size:255" json:"domain"` 108 | RecordType string `gorm:"index:idx_ddns_record,unique;size:10" json:"record_type"` 109 | LastIP string `gorm:"size:45" json:"last_ip"` // 上次记录的IP地址 110 | LastUpdate time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"last_update"` // 上次更新时间 111 | } 112 | 113 | func (d DDNSRecordState) TableName() string { 114 | return "ddns_record_states" 115 | } 116 | -------------------------------------------------------------------------------- /script/server-agent.openrc: -------------------------------------------------------------------------------- 1 | #!/sbin/openrc-run 2 | 3 | # ServerStatus Agent OpenRC Service Script 4 | # For Alpine Linux 5 | 6 | name="server-agent" 7 | description="ServerStatus Agent Service" 8 | 9 | # 服务配置 10 | command="/opt/server-status/agent/server-agent" 11 | # command_args="-c /opt/server-status/agent/config.yml" 12 | command_user="root" 13 | command_background="yes" 14 | pidfile="/var/run/${name}.pid" 15 | 16 | # 日志配置 17 | output_log="/var/log/${name}.log" 18 | error_log="/var/log/${name}_error.log" 19 | 20 | # 依赖服务 21 | depend() { 22 | need net 23 | after firewall 24 | } 25 | 26 | # 启动前检查 27 | start_pre() { 28 | # 检查可执行文件是否存在 29 | if [ ! -x "${command}" ]; then 30 | eerror "ServerStatus Agent executable not found: ${command}" 31 | return 1 32 | fi 33 | 34 | # 检查配置文件是否存在 35 | if [ ! -f "/opt/server-status/agent/config.yml" ]; then 36 | eerror "ServerStatus Agent config file not found: /opt/server-status/agent/config.yml" 37 | return 1 38 | fi 39 | 40 | # 创建日志目录 41 | checkpath --directory --owner root:root --mode 0755 /var/log 42 | 43 | # 创建PID文件目录 44 | checkpath --directory --owner root:root --mode 0755 /var/run 45 | 46 | return 0 47 | } 48 | 49 | # 启动后检查 50 | start_post() { 51 | # 等待一秒确保服务启动 52 | sleep 1 53 | 54 | # 检查进程是否真正启动 55 | if [ -f "${pidfile}" ]; then 56 | local pid=$(cat "${pidfile}") 57 | if kill -0 "${pid}" 2>/dev/null; then 58 | einfo "ServerStatus Agent started successfully with PID ${pid}" 59 | return 0 60 | else 61 | eerror "ServerStatus Agent failed to start properly" 62 | return 1 63 | fi 64 | else 65 | eerror "ServerStatus Agent PID file not created" 66 | return 1 67 | fi 68 | } 69 | 70 | # 停止后清理 71 | stop_post() { 72 | # 清理PID文件 73 | if [ -f "${pidfile}" ]; then 74 | rm -f "${pidfile}" 75 | fi 76 | 77 | einfo "ServerStatus Agent stopped" 78 | return 0 79 | } 80 | 81 | # 重载配置 82 | reload() { 83 | ebegin "Reloading ServerStatus Agent configuration" 84 | 85 | if [ -f "${pidfile}" ]; then 86 | local pid=$(cat "${pidfile}") 87 | if kill -0 "${pid}" 2>/dev/null; then 88 | # 发送HUP信号重载配置(如果支持的话) 89 | kill -HUP "${pid}" 90 | eend $? 91 | else 92 | eerror "ServerStatus Agent is not running" 93 | eend 1 94 | fi 95 | else 96 | eerror "ServerStatus Agent PID file not found" 97 | eend 1 98 | fi 99 | } 100 | 101 | # 状态检查 102 | status() { 103 | if [ -f "${pidfile}" ]; then 104 | local pid=$(cat "${pidfile}") 105 | if kill -0 "${pid}" 2>/dev/null; then 106 | einfo "ServerStatus Agent is running with PID ${pid}" 107 | return 0 108 | else 109 | eerror "ServerStatus Agent PID file exists but process is not running" 110 | return 1 111 | fi 112 | else 113 | einfo "ServerStatus Agent is not running" 114 | return 1 115 | fi 116 | } 117 | -------------------------------------------------------------------------------- /resource/template/dashboard-default/cron.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/cron"}} 2 | {{template "common/header" .}} 3 | {{template "common/menu" .}} 4 |
5 |
6 |
7 |
8 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {{range $cron := .Crons}} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 58 | 59 | {{end}} 60 | 61 |
ID{{tr "Name"}}{{tr "TaskType"}}{{tr "Scheduler"}}{{tr "Command"}}{{tr "NotificationMethodGroup"}}{{tr "PushSuccessfully"}}{{tr "Coverage"}}{{tr "SpecificServers"}}{{tr "LastExecution"}}{{tr "LastResult"}}{{tr "Administration"}}
{{$cron.ID}}{{$cron.Name}}{{if eq $cron.TaskType 0}}{{tr "CronTask"}}{{else}}{{tr "TriggerTask"}}{{end}}{{$cron.Scheduler}}{{$cron.Command}}{{$cron.NotificationTag}}{{$cron.PushSuccessful}}{{if eq $cron.Cover 0}}{{tr "IgnoreAll"}}{{else if eq $cron.Cover 1}}{{tr "CoverAll"}}{{else}}{{tr "ByTrigger"}}{{end}}{{$cron.ServersRaw}}{{$cron.LastExecutedAt|tf}}{{$cron.LastResult}} 45 |
46 | 49 | 52 | 56 |
57 |
62 |
63 |
64 | {{template "component/cron"}} 65 | {{template "common/footer" .}} 66 | {{end}} -------------------------------------------------------------------------------- /resource/template/common/menu.html: -------------------------------------------------------------------------------- 1 | {{define "common/menu"}} 2 | 52 | {{template "component/confirm" .}} 53 | {{end}} -------------------------------------------------------------------------------- /resource/template/component/notification.html: -------------------------------------------------------------------------------- 1 | {{define "component/notification"}} 2 | 61 | {{end}} -------------------------------------------------------------------------------- /resource/template/component/cron.html: -------------------------------------------------------------------------------- 1 | {{define "component/cron"}} 2 | 69 | {{end}} -------------------------------------------------------------------------------- /cmd/migrate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/xos/serverstatus/db" 11 | ) 12 | 13 | func main() { 14 | var ( 15 | dataDir = flag.String("data", "./data", "Data directory path") 16 | sqliteFile = flag.String("sqlite", "sqlite.db", "SQLite database filename") 17 | badgerDir = flag.String("badger", "badger", "BadgerDB directory name") 18 | mode = flag.String("mode", "default", "Migration mode: quick, full, or default") 19 | historyDays = flag.Int("history-days", 30, "Number of days of monitor history to migrate (-1 for all)") 20 | historyLimit = flag.Int("history-limit", -1, "Maximum number of monitor history records to migrate (-1 for no limit)") 21 | batchSize = flag.Int("batch-size", 100, "Batch size for processing records") 22 | skipLargeData = flag.Bool("skip-large-data", false, "Skip large data fields in monitor histories") 23 | resume = flag.Bool("resume", true, "Enable resumable migration") 24 | workers = flag.Int("workers", 4, "Number of concurrent workers") 25 | ) 26 | 27 | flag.Parse() 28 | 29 | // 构建完整路径 30 | sqlitePath := filepath.Join(*dataDir, *sqliteFile) 31 | badgerPath := filepath.Join(*dataDir, *badgerDir) 32 | 33 | // 检查SQLite文件是否存在 34 | if _, err := os.Stat(sqlitePath); os.IsNotExist(err) { 35 | log.Fatalf("SQLite数据库文件不存在: %s", sqlitePath) 36 | } 37 | 38 | // 打开BadgerDB 39 | log.Printf("正在打开BadgerDB: %s", badgerPath) 40 | badgerDB, err := db.OpenDB(badgerPath) 41 | if err != nil { 42 | log.Fatalf("无法打开BadgerDB: %v", err) 43 | } 44 | defer badgerDB.Close() 45 | 46 | // 根据模式选择配置 47 | var config *db.MigrationConfig 48 | switch *mode { 49 | case "quick": 50 | config = db.QuickMigrationConfig() 51 | log.Println("使用快速迁移模式(仅迁移最近7天的监控历史)") 52 | case "full": 53 | config = db.FullMigrationConfig() 54 | log.Println("使用完整迁移模式(迁移所有数据)") 55 | default: 56 | config = db.DefaultMigrationConfig() 57 | log.Println("使用默认迁移模式") 58 | } 59 | 60 | // 应用命令行参数覆盖 61 | if *historyDays != 30 { 62 | config.MonitorHistoryDays = *historyDays 63 | } 64 | if *historyLimit != -1 { 65 | config.MonitorHistoryLimit = *historyLimit 66 | } 67 | if *batchSize != 100 { 68 | config.BatchSize = *batchSize 69 | } 70 | config.SkipLargeHistoryData = *skipLargeData 71 | config.EnableResume = *resume 72 | config.Workers = *workers 73 | 74 | // 显示配置信息 75 | log.Printf("迁移配置:") 76 | log.Printf(" - 批量大小: %d", config.BatchSize) 77 | log.Printf(" - 监控历史天数: %d", config.MonitorHistoryDays) 78 | log.Printf(" - 监控历史限制: %d", config.MonitorHistoryLimit) 79 | log.Printf(" - 跳过大数据字段: %v", config.SkipLargeHistoryData) 80 | log.Printf(" - 可恢复迁移: %v", config.EnableResume) 81 | log.Printf(" - 并发Workers: %d", config.Workers) 82 | 83 | // 执行迁移 84 | log.Printf("开始从 %s 迁移到 %s", sqlitePath, badgerPath) 85 | if err := db.RunMigrationWithConfig(badgerDB, sqlitePath, config); err != nil { 86 | log.Fatalf("迁移失败: %v", err) 87 | } 88 | 89 | log.Println("数据迁移成功完成!") 90 | 91 | // 显示迁移统计 92 | fmt.Println("\n迁移统计:") 93 | tables := []string{"server", "user", "monitor", "notification", "alert_rule", "cron", "transfer", "api_token", "nat", "ddns_profile", "ddns_record_state", "monitor_history"} 94 | 95 | for _, table := range tables { 96 | keys, err := badgerDB.GetKeysWithPrefix(table + ":") 97 | if err == nil { 98 | fmt.Printf(" %s: %d 条记录\n", table, len(keys)) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/ddns/webhook/webhook_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/xos/serverstatus/model" 8 | ) 9 | 10 | var ( 11 | reqTypeForm = "application/x-www-form-urlencoded" 12 | reqTypeJSON = "application/json" 13 | ) 14 | 15 | type testSt struct { 16 | profile model.DDNSProfile 17 | expectURL string 18 | expectBody string 19 | expectContentType string 20 | expectHeader map[string]string 21 | } 22 | 23 | func execCase(t *testing.T, item testSt) { 24 | pw := Provider{DDNSProfile: &item.profile} 25 | pw.ipAddr = "1.1.1.1" 26 | pw.domain = item.profile.Domains[0] 27 | pw.ipType = "ipv4" 28 | pw.recordType = "A" 29 | pw.DDNSProfile = &item.profile 30 | 31 | reqUrl, err := pw.reqUrl() 32 | if err != nil { 33 | t.Fatalf("Error: %s", err) 34 | } 35 | if item.expectURL != reqUrl.String() { 36 | t.Fatalf("Expected %s, but got %s", item.expectURL, reqUrl.String()) 37 | } 38 | 39 | reqBody, err := pw.reqBody() 40 | if err != nil { 41 | t.Fatalf("Error: %s", err) 42 | } 43 | if item.expectBody != reqBody { 44 | t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody) 45 | } 46 | 47 | req, err := pw.prepareRequest(context.Background()) 48 | if err != nil { 49 | t.Fatalf("Error: %s", err) 50 | } 51 | 52 | if item.expectContentType != req.Header.Get("Content-Type") { 53 | t.Fatalf("Expected %s, but got %s", item.expectContentType, req.Header.Get("Content-Type")) 54 | } 55 | 56 | for k, v := range item.expectHeader { 57 | if v != req.Header.Get(k) { 58 | t.Fatalf("Expected %s, but got %s", v, req.Header.Get(k)) 59 | } 60 | } 61 | } 62 | 63 | func TestWebhookRequest(t *testing.T) { 64 | ipv4 := true 65 | 66 | cases := []testSt{ 67 | { 68 | profile: model.DDNSProfile{ 69 | Domains: []string{"www.example.com"}, 70 | MaxRetries: 1, 71 | EnableIPv4: &ipv4, 72 | WebhookURL: "http://ddns.example.com/?ip=#ip#", 73 | WebhookMethod: methodGET, 74 | WebhookHeaders: `{"ip":"#ip#","record":"#record#"}`, 75 | }, 76 | expectURL: "http://ddns.example.com/?ip=1.1.1.1", 77 | expectContentType: "", 78 | expectHeader: map[string]string{ 79 | "ip": "1.1.1.1", 80 | "record": "A", 81 | }, 82 | }, 83 | { 84 | profile: model.DDNSProfile{ 85 | Domains: []string{"www.example.com"}, 86 | MaxRetries: 1, 87 | EnableIPv4: &ipv4, 88 | WebhookURL: "http://ddns.example.com/api", 89 | WebhookMethod: methodPOST, 90 | WebhookRequestType: requestTypeJSON, 91 | WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`, 92 | }, 93 | expectURL: "http://ddns.example.com/api", 94 | expectContentType: reqTypeJSON, 95 | expectBody: `{"ip":"1.1.1.1","record":"A"}`, 96 | }, 97 | { 98 | profile: model.DDNSProfile{ 99 | Domains: []string{"www.example.com"}, 100 | MaxRetries: 1, 101 | EnableIPv4: &ipv4, 102 | WebhookURL: "http://ddns.example.com/api", 103 | WebhookMethod: methodPOST, 104 | WebhookRequestType: requestTypeForm, 105 | WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`, 106 | }, 107 | expectURL: "http://ddns.example.com/api", 108 | expectContentType: reqTypeForm, 109 | expectBody: "ip=1.1.1.1&record=A", 110 | }, 111 | } 112 | 113 | for _, c := range cases { 114 | execCase(t, c) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /k8s/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: serverstatus-dashboard 5 | labels: 6 | app: serverstatus 7 | component: dashboard 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: serverstatus 13 | component: dashboard 14 | template: 15 | metadata: 16 | labels: 17 | app: serverstatus 18 | component: dashboard 19 | spec: 20 | containers: 21 | - name: dashboard 22 | image: ghcr.io/xos/server-dash:latest 23 | ports: 24 | - containerPort: 80 25 | name: http 26 | - containerPort: 2222 27 | name: grpc 28 | env: 29 | - name: TZ 30 | value: "Asia/Shanghai" 31 | - name: GIN_MODE 32 | value: "release" 33 | volumeMounts: 34 | - name: data 35 | mountPath: /dashboard/data 36 | - name: config 37 | mountPath: /dashboard/data/config.yaml 38 | subPath: config.yaml 39 | readOnly: true 40 | resources: 41 | requests: 42 | memory: "128Mi" 43 | cpu: "100m" 44 | limits: 45 | memory: "512Mi" 46 | cpu: "500m" 47 | livenessProbe: 48 | exec: 49 | command: 50 | - /dashboard/app 51 | - --health-check 52 | initialDelaySeconds: 30 53 | periodSeconds: 30 54 | timeoutSeconds: 10 55 | failureThreshold: 3 56 | readinessProbe: 57 | exec: 58 | command: 59 | - /dashboard/app 60 | - --health-check 61 | initialDelaySeconds: 5 62 | periodSeconds: 10 63 | timeoutSeconds: 5 64 | failureThreshold: 3 65 | securityContext: 66 | allowPrivilegeEscalation: false 67 | readOnlyRootFilesystem: true 68 | runAsNonRoot: true 69 | runAsUser: 65534 70 | capabilities: 71 | drop: 72 | - ALL 73 | volumes: 74 | - name: data 75 | persistentVolumeClaim: 76 | claimName: serverstatus-data 77 | - name: config 78 | configMap: 79 | name: serverstatus-config 80 | securityContext: 81 | fsGroup: 65534 82 | --- 83 | apiVersion: v1 84 | kind: Service 85 | metadata: 86 | name: serverstatus-dashboard 87 | labels: 88 | app: serverstatus 89 | component: dashboard 90 | spec: 91 | type: ClusterIP 92 | ports: 93 | - port: 80 94 | targetPort: 80 95 | protocol: TCP 96 | name: http 97 | - port: 2222 98 | targetPort: 2222 99 | protocol: TCP 100 | name: grpc 101 | selector: 102 | app: serverstatus 103 | component: dashboard 104 | --- 105 | apiVersion: v1 106 | kind: PersistentVolumeClaim 107 | metadata: 108 | name: serverstatus-data 109 | labels: 110 | app: serverstatus 111 | spec: 112 | accessModes: 113 | - ReadWriteOnce 114 | resources: 115 | requests: 116 | storage: 10Gi 117 | --- 118 | apiVersion: v1 119 | kind: ConfigMap 120 | metadata: 121 | name: serverstatus-config 122 | labels: 123 | app: serverstatus 124 | data: 125 | config.yaml: | 126 | debug: false 127 | httpport: 80 128 | grpcport: 2222 129 | database: 130 | type: sqlite 131 | dsn: data/sqlite.db 132 | jwt_secret: "your-secret-key-here" 133 | admin: 134 | username: admin 135 | password: admin123 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 探针 2 | 3 | > 本项目为原项目[哪吒探针](https://github.com/naiba/nezha)的修改自用版 4 | 5 | ## 注意: 6 | 7 | * 本项目与原项目不兼容! 8 | * 本项目配置文件与原项目不通用! 9 | 10 | ## 精简掉的功能: 11 | 12 | 1. 网站监测,包含SSL证书监测; 13 | 2. ...... 14 | 15 | ## 演示图 16 | 17 | ### 前台 18 | 19 | ![首页截图](https://i.nange.cn/views/2022/05/25/b168b1.png) 20 | 21 | ### 后台 22 | 23 | ![后台截图](https://i.nange.cn/views/2022/05/25/fd1b7d.png) 24 | 25 | ## 安装脚本 26 | ### 默认 27 | ```shell 28 | curl -L https://raw.githubusercontent.com/xos/serverstatus/master/script/server-status.sh -o server-status.sh && chmod +x server-status.sh 29 | sudo ./server-status.sh 30 | ``` 31 | ### 国内 32 | ```shell 33 | curl -L https://gitee.com/ten/ServerStatus/raw/master/script/server-status.sh -o server-status.sh && chmod +x server-status.sh 34 | sudo ./server-status.sh 35 | ``` 36 | 37 |
38 | 国内镜像加速: 39 | 40 | ```shell 41 | curl -L https://fastly.jsdelivr.net/gh/xos/serverstatus@master/script/server-status.sh -o server-status.sh && chmod +x server-status.sh 42 | CN=true sudo ./server-status.sh 43 | ``` 44 | 45 |
46 | 47 | _\* 使用 WatchTower 可以自动更新面板,Windows 终端可以使用 nssm 配置自启动。_ 48 | 49 | 50 | 51 | ## 非Docker环境手动部署控制面板 52 | 53 | 注意: 54 | 55 | * 需要安装`Golang`且版本需要1.18或以上。 56 | * 默认安装路径 `/opt/server-status/dashboard`。 57 | * 手动部署的面板暂无法通过脚本进行面板部分的控制操作。 58 | 59 | 1.克隆仓库 60 | 61 | ```bash 62 | git clone https://github.com/xOS/ServerStatus.git 63 | ``` 64 | 65 | 2.下载依赖 66 | 67 | ```bash 68 | cd ServerStatus/ 69 | go mod tidy -v 70 | ``` 71 | 72 | 3.编译,以`AMD64`架构为例 73 | 74 | ```bash 75 | cd cmd/dashboard/ 76 | CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o server-dash -ldflags="-s -w" 77 | ``` 78 | 79 | 4.部署面板为系统服务 80 | 81 | ```bash 82 | mkdir -p /opt/server-status/dashboard 83 | mv server-dash /opt/server-status/dashboard/ 84 | cd ../.. 85 | cp resource/ /opt/server-status/dashboard/ -r 86 | mkdir -p /opt/server-status/dashboard/data 87 | cp script/config.yaml /opt/server-status/dashboard/data 88 | cp script/server-dash.service /etc/systemd/system 89 | ``` 90 | 91 | 5.修改配置文件`/opt/server-status/dashboard/data/config.yaml`,注册服务并启动 92 | 93 | ```bash 94 | systemctl enable server-dash 95 | systemctl start server-dash 96 | ``` 97 | ## 通知方式 98 | 99 | `#NG#` 是面板消息占位符,面板触发通知时会自动替换占位符到实际消息 100 | 101 | Body 内容是`JSON` 格式的:**当请求类型为 FORM 时**,值为 `key:value` 的形式,`value` 里面可放置占位符,通知时会自动替换。**当请求类型为 JSON 时** 只会简进行字符串替换后直接提交到`URL`。 102 | 103 | URL 里面也可放置占位符,请求时会进行简单的字符串替换。 104 | 105 | 参考下方的示例,非常灵活。 106 | 107 | ### 添加通知方式 108 | 109 | - server 酱示例 110 | 111 | - 名称:server 酱 112 | - URL:`https://sc.ftqq.com/SCUrandomkeys.send?text=#NG#` 113 | - 请求方式: `GET` 114 | - 请求类型: 默认 115 | - Body: 空 116 | 117 | - wxpusher 示例,需要关注你的应用 118 | 119 | - 名称: wxpusher 120 | - URL:`http://wxpusher.zjiecode.com/api/send/message` 121 | - 请求方式: `POST` 122 | - 请求类型: `JSON` 123 | - Body: `{"appToken":"你的appToken","topicIds":[],"content":"#NG#","contentType":"1","uids":["你的uid"]}` 124 | 125 | - telegram 示例 [@haitau](https://github.com/haitau) 贡献 126 | 127 | - 名称:telegram 机器人消息通知 128 | - URL:`https://api.telegram.org/botXXXXXX/sendMessage?chat_id=YYYYYY&text=#NG#` 129 | - 请求方式: `GET` 130 | - 请求类型: 默认 131 | - Body: 空 132 | - URL 参数获取说明:botXXXXXX 中的 XXXXXX 是在 telegram 中关注官方 @Botfather ,输入/newbot ,创建新的机器人(bot)时,会提供的 token(在提示 Use this token to access the HTTP API:后面一行)这里 'bot' 三个字母不可少。创建 bot 后,需要先在 telegram 中与 BOT 进行对话(随便发个消息),然后才可用 API 发送消息。YYYYYY 是 telegram 用户的数字 ID。与机器人@userinfobot 对话可获得。 133 | -------------------------------------------------------------------------------- /model/server.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "log" 7 | "sync" 8 | "time" 9 | 10 | "github.com/xos/serverstatus/pkg/utils" 11 | pb "github.com/xos/serverstatus/proto" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | type Server struct { 16 | Common 17 | Name string 18 | Tag string // 分组名 19 | Secret string `gorm:"uniqueIndex" json:"-"` 20 | Note string `json:"-"` // 管理员可见备注 21 | PublicNote string `json:"PublicNote,omitempty"` // 公开备注 22 | DisplayIndex int // 展示排序,越大越靠前 23 | HideForGuest bool // 对游客隐藏 24 | EnableDDNS bool // 启用DDNS 25 | DDNSProfiles []uint64 `gorm:"-" json:"-"` // DDNS配置 26 | 27 | DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"` 28 | 29 | Host *Host `gorm:"-"` 30 | State *HostState `gorm:"-"` 31 | LastActive time.Time `gorm:"-"` 32 | LastStateBeforeOffline *HostState `gorm:"-" json:"-"` // 离线前最后一次状态 33 | IsOnline bool `gorm:"-" json:"is_online"` // 是否在线 34 | 35 | // 持久化保存的最后状态 36 | LastStateJSON string `gorm:"type:text" json:"-"` // 最后一次状态的JSON格式 37 | LastOnline time.Time // 最后一次在线时间 38 | HostJSON string `gorm:"type:text" json:"-"` // 主机信息的JSON格式 39 | 40 | TaskClose chan error `gorm:"-" json:"-"` 41 | TaskCloseLock *sync.Mutex `gorm:"-" json:"-"` 42 | TaskStream pb.ServerService_RequestTaskServer `gorm:"-" json:"-"` 43 | 44 | PrevTransferInSnapshot int64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量 45 | PrevTransferOutSnapshot int64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量 46 | 47 | // 累计流量数据 48 | CumulativeNetInTransfer uint64 `gorm:"default:0" json:"cumulative_net_in_transfer"` // 累计入站使用量 49 | CumulativeNetOutTransfer uint64 `gorm:"default:0" json:"cumulative_net_out_transfer"` // 累计出站使用量 50 | LastTrafficResetTime time.Time `gorm:"type:datetime" json:"last_traffic_reset_time"` // 最后一次流量重置时间 51 | LastFlowSaveTime time.Time `gorm:"-" json:"-"` // 最后一次保存流量数据的时间 52 | LastDBUpdateTime time.Time `gorm:"-" json:"-"` // 最后一次数据库更新时间 53 | } 54 | 55 | func (s *Server) CopyFromRunningServer(old *Server) { 56 | s.Host = old.Host 57 | s.State = old.State 58 | s.LastActive = old.LastActive 59 | s.TaskClose = old.TaskClose 60 | s.TaskCloseLock = old.TaskCloseLock 61 | s.TaskStream = old.TaskStream 62 | s.PrevTransferInSnapshot = old.PrevTransferInSnapshot 63 | s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot 64 | s.LastStateBeforeOffline = old.LastStateBeforeOffline 65 | s.IsOnline = old.IsOnline 66 | s.CumulativeNetInTransfer = old.CumulativeNetInTransfer 67 | s.CumulativeNetOutTransfer = old.CumulativeNetOutTransfer 68 | s.LastTrafficResetTime = old.LastTrafficResetTime 69 | s.LastFlowSaveTime = old.LastFlowSaveTime 70 | 71 | // 注意:不要复制配置相关的字段,因为这些可能已经在编辑时更新了 72 | // 包括:DDNSProfiles, DDNSProfilesRaw, EnableDDNS 等 73 | } 74 | 75 | func (s *Server) AfterFind(tx *gorm.DB) error { 76 | if s.DDNSProfilesRaw != "" { 77 | if err := utils.Json.Unmarshal([]byte(s.DDNSProfilesRaw), &s.DDNSProfiles); err != nil { 78 | log.Println("NG>> Server.AfterFind:", err) 79 | return nil 80 | } 81 | } 82 | return nil 83 | } 84 | 85 | func boolToString(b bool) string { 86 | if b { 87 | return "true" 88 | } 89 | return "false" 90 | } 91 | 92 | func (s Server) MarshalForDashboard() template.JS { 93 | name, _ := utils.Json.Marshal(s.Name) 94 | tag, _ := utils.Json.Marshal(s.Tag) 95 | note, _ := utils.Json.Marshal(s.Note) 96 | secret, _ := utils.Json.Marshal(s.Secret) 97 | ddnsProfilesRaw, _ := utils.Json.Marshal(s.DDNSProfilesRaw) 98 | publicNote, _ := utils.Json.Marshal(s.PublicNote) 99 | return template.JS(fmt.Sprintf(`{"ID":%d,"Name":%s,"Secret":%s,"DisplayIndex":%d,"Tag":%s,"Note":%s,"HideForGuest": %s,"EnableDDNS": %s,"DDNSProfilesRaw": %s,"PublicNote": %s}`, s.ID, name, secret, s.DisplayIndex, tag, note, boolToString(s.HideForGuest), boolToString(s.EnableDDNS), ddnsProfilesRaw, publicNote)) 100 | } 101 | -------------------------------------------------------------------------------- /resource/template/component/ddns.html: -------------------------------------------------------------------------------- 1 | {{define "component/ddns"}} 2 | 86 | {{end}} 87 | -------------------------------------------------------------------------------- /script/verify-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 构建验证脚本 4 | # 用于验证本地构建是否包含所有必要的文件 5 | 6 | set -e 7 | 8 | # 颜色定义 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' 13 | 14 | log_info() { 15 | echo -e "${GREEN}[INFO]${NC} $1" 16 | } 17 | 18 | log_warn() { 19 | echo -e "${YELLOW}[WARN]${NC} $1" 20 | } 21 | 22 | log_error() { 23 | echo -e "${RED}[ERROR]${NC} $1" 24 | } 25 | 26 | # 检查必要的文件和目录 27 | check_files() { 28 | log_info "检查构建所需的文件..." 29 | 30 | local required_items=( 31 | "Dockerfile" 32 | "script/entrypoint.sh" 33 | "resource/" 34 | "resource/static/" 35 | "resource/template/" 36 | "resource/l10n/" 37 | "resource/static/main.css" 38 | "resource/static/main.js" 39 | "resource/static/favicon.ico" 40 | "resource/template/theme-default/" 41 | "resource/template/dashboard-default/" 42 | ) 43 | 44 | local missing_items=() 45 | 46 | for item in "${required_items[@]}"; do 47 | if [ -e "$item" ]; then 48 | log_info "✓ $item" 49 | else 50 | log_error "✗ $item (缺失)" 51 | missing_items+=("$item") 52 | fi 53 | done 54 | 55 | if [ ${#missing_items[@]} -eq 0 ]; then 56 | log_info "所有必需文件都存在" 57 | return 0 58 | else 59 | log_error "发现缺失的文件:" 60 | for item in "${missing_items[@]}"; do 61 | echo " - $item" 62 | done 63 | return 1 64 | fi 65 | } 66 | 67 | # 检查构建产物 68 | check_build_artifacts() { 69 | log_info "检查构建产物..." 70 | 71 | if [ ! -d "dist" ]; then 72 | log_warn "dist 目录不存在,请先运行构建命令" 73 | return 1 74 | fi 75 | 76 | local found_binary=false 77 | for binary in dist/server-dash-*; do 78 | if [ -f "$binary" ]; then 79 | log_info "✓ 找到构建产物: $(basename "$binary")" 80 | found_binary=true 81 | fi 82 | done 83 | 84 | if [ "$found_binary" = false ]; then 85 | log_error "未找到构建产物,请先运行构建命令" 86 | return 1 87 | fi 88 | 89 | return 0 90 | } 91 | 92 | # 检查 Docker 环境 93 | check_docker() { 94 | if ! command -v docker &> /dev/null; then 95 | log_warn "Docker 未安装,跳过 Docker 相关检查" 96 | return 1 97 | fi 98 | 99 | if ! docker info &> /dev/null; then 100 | log_warn "Docker 服务未运行,跳过 Docker 相关检查" 101 | return 1 102 | fi 103 | 104 | log_info "Docker 环境正常" 105 | return 0 106 | } 107 | 108 | # 验证 Dockerfile 109 | validate_dockerfile() { 110 | log_info "验证 Dockerfile..." 111 | 112 | if ! grep -q "COPY resource/" Dockerfile; then 113 | log_error "Dockerfile 中缺少静态资源复制指令" 114 | return 1 115 | fi 116 | 117 | if ! grep -q "COPY.*entrypoint.sh" Dockerfile; then 118 | log_error "Dockerfile 中缺少入口脚本复制指令" 119 | return 1 120 | fi 121 | 122 | log_info "Dockerfile 验证通过" 123 | return 0 124 | } 125 | 126 | # 主函数 127 | main() { 128 | log_info "开始构建验证..." 129 | echo "" 130 | 131 | local all_passed=true 132 | 133 | # 检查文件 134 | if ! check_files; then 135 | all_passed=false 136 | fi 137 | echo "" 138 | 139 | # 检查构建产物 140 | if ! check_build_artifacts; then 141 | all_passed=false 142 | fi 143 | echo "" 144 | 145 | # 验证 Dockerfile 146 | if ! validate_dockerfile; then 147 | all_passed=false 148 | fi 149 | echo "" 150 | 151 | # 检查 Docker 环境 152 | if check_docker; then 153 | log_info "可以进行 Docker 构建测试" 154 | echo "" 155 | log_info "建议的下一步操作:" 156 | echo " 1. 构建 Docker 镜像: docker build -t serverstatus-test ." 157 | echo " 2. 测试静态资源: ./script/test-docker-resources.sh serverstatus-test" 158 | echo " 3. 运行容器测试: docker run -d -p 8080:80 serverstatus-test" 159 | fi 160 | 161 | # 显示结果 162 | if [ "$all_passed" = true ]; then 163 | log_info "🎉 所有验证通过!可以进行 Docker 构建。" 164 | exit 0 165 | else 166 | log_error "❌ 验证失败!请修复上述问题后重试。" 167 | exit 1 168 | fi 169 | } 170 | 171 | # 运行主函数 172 | main "$@" -------------------------------------------------------------------------------- /script/config.yml: -------------------------------------------------------------------------------- 1 | # ServerAgent 配置文件 2 | # 此配置文件用于自定义 Agent 的所有运行参数 3 | # 配置文件中的设置可以被命令行参数覆盖 4 | 5 | # ==================== 监控配置 ==================== 6 | 7 | # 硬盘分区白名单 - 指定要监控的硬盘分区挂载点 8 | # 如果为空,则监控所有分区 9 | # 示例: 10 | # harddrivePartitionAllowlist: 11 | # - "/" 12 | # - "/home" 13 | # - "/var" 14 | harddrivePartitionAllowlist: [] 15 | 16 | # 网卡白名单 - 指定要监控的网络接口 17 | # 如果为空,则监控所有网卡 18 | # 示例: 19 | # nicAllowlist: 20 | # eth0: true 21 | # wlan0: true 22 | # lo: false 23 | nicAllowlist: {} 24 | 25 | # 自定义 DNS 服务器列表 26 | # 如果为空,则使用默认的 DNS 服务器 27 | # 默认 DNS 服务器包括: 28 | # IPv4: 8.8.4.4:53, 223.5.5.5:53, 94.140.14.140:53, 119.29.29.29:53 29 | # IPv6: [2001:4860:4860::8844]:53, [2400:3200::1]:53, [2a10:50c0::1:ff]:53, [2402:4e00::]:53 30 | # 示例: 31 | # dns: 32 | # - "1.1.1.1:53" 33 | # - "1.0.0.1:53" 34 | # - "8.8.8.8:53" 35 | # - "8.8.4.4:53" 36 | dns: [] 37 | 38 | # GPU 监控开关 39 | # 设置为 true 启用 GPU 监控,false 禁用 40 | # 注意:启用 GPU 监控可能需要额外的系统权限和驱动支持 41 | gpu: true 42 | 43 | # 温度监控开关 44 | # 设置为 true 启用温度监控,false 禁用 45 | # 注意:温度监控可能需要额外的系统权限和硬件支持 46 | temperature: true 47 | 48 | # 调试模式开关 49 | # 设置为 true 启用调试日志输出,false 禁用 50 | # 调试模式会输出更详细的运行信息,有助于问题排查 51 | debug: true 52 | 53 | # ==================== 连接配置 ==================== 54 | 55 | # 服务器地址 - 管理面板的 RPC 端口 56 | # 格式:主机名:端口 或 IP:端口 57 | server: "grpc.nange.cn:443" 58 | 59 | # 客户端密钥 - Agent 连接到服务器的认证密钥 60 | # 这是必需的参数,如果为空程序将退出 61 | clientSecret: "MD3Msdfh0S58h67sk" 62 | 63 | # TLS 加密 - 是否启用 SSL/TLS 加密传输 64 | # 设置为 true 启用加密传输,false 使用明文传输 65 | tls: true 66 | 67 | # 不安全的 TLS - 是否禁用证书检查 68 | # 设置为 true 时将跳过 TLS 证书验证(不推荐在生产环境使用) 69 | insecureTLS: false 70 | 71 | # ==================== 功能开关 ==================== 72 | 73 | # 跳过连接数检查 - 是否禁用网络连接数监控 74 | # 设置为 true 将不监控 TCP/UDP 连接数 75 | skipConnectionCount: false 76 | 77 | # 跳过进程数检查 - 是否禁用进程数量监控 78 | # 设置为 true 将不监控系统进程数量 79 | skipProcsCount: false 80 | 81 | # 禁用自动更新 - 是否禁用自动升级功能 82 | # 设置为 true 将禁用定时检查和自动更新 83 | disableAutoUpdate: false 84 | 85 | # 禁用强制更新 - 是否禁用服务器强制升级 86 | # 设置为 true 将忽略服务器发送的强制更新指令 87 | disableForceUpdate: false 88 | 89 | # 禁用命令执行 - 是否禁止在此机器上执行命令 90 | # 设置为 true 将禁用远程命令执行、终端和文件管理功能 91 | disableCommandExecute: false 92 | 93 | # 禁用内网穿透 - 是否禁止此机器的内网穿透功能 94 | # 设置为 true 将禁用 NAT 穿透功能 95 | disableNat: false 96 | 97 | # 禁用发送查询 - 是否禁止此机器发送网络请求 98 | # 设置为 true 将禁用 TCP/ICMP/HTTP 请求功能 99 | disableSendQuery: false 100 | 101 | # ==================== 其他配置 ==================== 102 | 103 | # 报告间隔 - 系统状态上报间隔(秒) 104 | # 取值范围:1-4 秒,建议使用 1 秒以获得最佳监控精度 105 | reportDelay: 1 106 | 107 | # IP 上报周期 - 本地 IP 更新间隔(秒) 108 | # 默认 30 分钟(1800 秒),上报频率依旧取决于 reportDelay 的值 109 | ipReportPeriod: 1800 110 | 111 | # 使用 IPv6 国家代码 - 是否优先展示 IPv6 位置信息 112 | # 设置为 true 将优先使用 IPv6 地址进行地理位置查询 113 | useIPv6CountryCode: false 114 | 115 | # 使用 Gitee 更新 - 是否强制从 Gitee 获取更新 116 | # 设置为 true 将从 Gitee 而不是 GitHub 获取更新(适用于中国大陆用户) 117 | useGiteeToUpgrade: false 118 | 119 | # ==================== 配置说明 ==================== 120 | 121 | # 1. 配置文件优先级: 122 | # - 命令行参数 > 配置文件 > 默认值 123 | # - 可以在配置文件中设置常用参数,临时使用命令行参数覆盖 124 | # 125 | # 2. 必需参数: 126 | # - clientSecret: 必须设置,否则程序无法启动 127 | # 128 | # 3. 监控配置: 129 | # - harddrivePartitionAllowlist: 指定要监控的分区挂载点 130 | # - nicAllowlist: 指定要监控的网络接口 131 | # - dns: 自定义 DNS 服务器,支持 IPv4 和 IPv6 132 | # - gpu: 启用 GPU 监控(需要驱动支持) 133 | # - temperature: 启用温度监控(需要硬件支持) 134 | # 135 | # 4. 连接配置: 136 | # - server: 服务器地址,格式为 "主机:端口" 137 | # - tls: 启用 TLS 加密传输 138 | # - insecureTLS: 跳过证书验证(仅用于测试) 139 | # 140 | # 5. 功能开关: 141 | # - 各种 disable* 选项可以禁用特定功能 142 | # - skip* 选项可以跳过特定的监控项目 143 | # 144 | # 6. 性能配置: 145 | # - reportDelay: 影响监控精度和网络流量 146 | # - ipReportPeriod: 影响 IP 地址更新频率 147 | # 148 | # 7. 配置修改: 149 | # - 修改配置文件后需要重启 Agent 才能生效 150 | # - 可以使用 `agent edit` 命令进行交互式配置 151 | 152 | # ==================== 示例配置 ==================== 153 | 154 | # 生产环境示例配置: 155 | # server: "monitor.example.com:2222" 156 | # clientSecret: "your-secret-key-here" 157 | # tls: true 158 | # insecureTLS: false 159 | # debug: false 160 | # reportDelay: 1 161 | # disableCommandExecute: true # 生产环境建议禁用命令执行 162 | # disableNat: true # 生产环境建议禁用内网穿透 163 | 164 | # 开发环境示例配置: 165 | # server: "localhost:2222" 166 | # clientSecret: "dev-secret" 167 | # tls: false 168 | # debug: true 169 | # gpu: true 170 | # temperature: true 171 | 172 | # 中国大陆用户示例配置: 173 | # useGiteeToUpgrade: true 174 | # dns: 175 | # - "223.5.5.5:53" # 阿里 DNS 176 | # - "119.29.29.29:53" # 腾讯 DNS 177 | # - "114.114.114.114:53" # 114 DNS 178 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/xos/serverstatus 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | code.cloudfoundry.org/bytefmt v0.0.0-20240425163905-bcdc1ad063ea 9 | code.gitea.io/sdk/gitea v0.18.0 10 | github.com/BurntSushi/toml v1.3.2 11 | github.com/coreos/go-oidc/v3 v3.11.0 12 | github.com/gin-contrib/pprof v1.5.3 13 | github.com/gin-gonic/gin v1.10.0 14 | github.com/google/go-github/v47 v47.1.0 15 | github.com/gorilla/websocket v1.5.3 16 | github.com/hashicorp/go-uuid v1.0.3 17 | github.com/json-iterator/go v1.1.12 18 | github.com/knadh/koanf/parsers/yaml v0.1.0 19 | github.com/knadh/koanf/providers/env v1.1.0 20 | github.com/knadh/koanf/providers/file v1.2.0 21 | github.com/knadh/koanf/v2 v2.2.0 22 | github.com/libdns/cloudflare v0.2.1 23 | github.com/libdns/libdns v1.0.0 24 | github.com/miekg/dns v1.1.65 25 | github.com/nezhahq/libdns-tencentcloud v0.0.0-20250501081622-bd293105845a 26 | github.com/nicksnyder/go-i18n/v2 v2.4.0 27 | github.com/ory/graceful v0.1.3 28 | github.com/oschwald/maxminddb-golang v1.13.1 29 | github.com/patrickmn/go-cache v2.1.0+incompatible 30 | github.com/robfig/cron/v3 v3.0.1 31 | github.com/spf13/pflag v1.0.5 32 | github.com/tidwall/gjson v1.18.0 33 | github.com/xanzy/go-gitlab v0.103.0 34 | golang.org/x/crypto v0.38.0 35 | golang.org/x/net v0.40.0 36 | golang.org/x/oauth2 v0.29.0 37 | golang.org/x/sync v0.14.0 38 | golang.org/x/text v0.25.0 39 | google.golang.org/grpc v1.72.0 40 | google.golang.org/protobuf v1.36.6 41 | gopkg.in/yaml.v3 v3.0.1 42 | gorm.io/driver/sqlite v1.5.7 43 | gorm.io/gorm v1.26.0 44 | ) 45 | 46 | require ( 47 | github.com/cespare/xxhash v1.1.0 // indirect 48 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 49 | github.com/dgraph-io/ristretto v0.2.0 // indirect 50 | github.com/dustin/go-humanize v1.0.1 // indirect 51 | github.com/gogo/protobuf v1.3.2 // indirect 52 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 53 | github.com/golang/protobuf v1.5.4 // indirect 54 | github.com/golang/snappy v1.0.0 // indirect 55 | github.com/google/flatbuffers v25.2.10+incompatible // indirect 56 | github.com/klauspost/compress v1.18.0 // indirect 57 | go.opencensus.io v0.24.0 // indirect 58 | ) 59 | 60 | require ( 61 | github.com/bytedance/sonic v1.13.2 // indirect 62 | github.com/bytedance/sonic/loader v0.2.4 // indirect 63 | github.com/cloudwego/base64x v0.1.5 // indirect 64 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 65 | github.com/davidmz/go-pageant v1.0.2 // indirect 66 | github.com/dgraph-io/badger/v3 v3.2103.5 67 | github.com/fsnotify/fsnotify v1.9.0 // indirect 68 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 69 | github.com/gin-contrib/sse v1.1.0 // indirect 70 | github.com/go-fed/httpsig v1.1.0 // indirect 71 | github.com/go-jose/go-jose/v4 v4.0.4 // indirect 72 | github.com/go-playground/locales v0.14.1 // indirect 73 | github.com/go-playground/universal-translator v0.18.1 // indirect 74 | github.com/go-playground/validator/v10 v10.26.0 // indirect 75 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 76 | github.com/goccy/go-json v0.10.5 // indirect 77 | github.com/google/go-querystring v1.1.0 // indirect 78 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 79 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 80 | github.com/hashicorp/go-version v1.6.0 // indirect 81 | github.com/jinzhu/inflection v1.0.0 // indirect 82 | github.com/jinzhu/now v1.1.5 // indirect 83 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 84 | github.com/knadh/koanf/maps v0.1.2 // indirect 85 | github.com/kr/pretty v0.3.1 // indirect 86 | github.com/leodido/go-urn v1.4.0 // indirect 87 | github.com/mattn/go-isatty v0.0.20 // indirect 88 | github.com/mattn/go-sqlite3 v1.14.24 89 | github.com/mitchellh/copystructure v1.2.0 // indirect 90 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 91 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 92 | github.com/modern-go/reflect2 v1.0.2 // indirect 93 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 94 | github.com/pkg/errors v0.9.1 // indirect 95 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 96 | github.com/tidwall/match v1.1.1 // indirect 97 | github.com/tidwall/pretty v1.2.1 // indirect 98 | github.com/tidwall/sjson v1.2.5 // indirect 99 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 100 | github.com/ugorji/go/codec v1.2.12 // indirect 101 | golang.org/x/arch v0.16.0 // indirect 102 | golang.org/x/mod v0.24.0 // indirect 103 | golang.org/x/sys v0.33.0 // indirect 104 | golang.org/x/time v0.11.0 // indirect 105 | golang.org/x/tools v0.32.0 // indirect 106 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect 107 | ) 108 | -------------------------------------------------------------------------------- /model/alertrule.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/xos/serverstatus/pkg/utils" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | const ( 11 | ModeAlwaysTrigger = 0 12 | ModeOnetimeTrigger = 1 13 | ) 14 | 15 | type CycleTransferStats struct { 16 | Name string 17 | From time.Time 18 | To time.Time 19 | Max uint64 20 | Min uint64 21 | ServerName map[uint64]string 22 | Transfer map[uint64]uint64 23 | NextUpdate map[uint64]time.Time // 下次检查时间 24 | LastResetTime map[uint64]time.Time // 上次重置时间(用于判断是否需要重置) 25 | } 26 | 27 | type AlertRule struct { 28 | Common 29 | Name string 30 | RulesRaw string 31 | Enable *bool 32 | TriggerMode int `gorm:"default:0"` // 触发模式: 0-始终触发(默认) 1-单次触发 33 | NotificationTag string // 该通知规则所在的通知组 34 | FailTriggerTasksRaw string `gorm:"default:'[]'"` 35 | RecoverTriggerTasksRaw string `gorm:"default:'[]'"` 36 | Rules []Rule `gorm:"-" json:"-"` 37 | FailTriggerTasks []uint64 `gorm:"-" json:"-"` // 失败时执行的触发任务id 38 | RecoverTriggerTasks []uint64 `gorm:"-" json:"-"` // 恢复时执行的触发任务id 39 | } 40 | 41 | func (r *AlertRule) BeforeSave(tx *gorm.DB) error { 42 | if data, err := utils.Json.Marshal(r.Rules); err != nil { 43 | return err 44 | } else { 45 | r.RulesRaw = string(data) 46 | } 47 | if data, err := utils.Json.Marshal(r.FailTriggerTasks); err != nil { 48 | return err 49 | } else { 50 | r.FailTriggerTasksRaw = string(data) 51 | } 52 | if data, err := utils.Json.Marshal(r.RecoverTriggerTasks); err != nil { 53 | return err 54 | } else { 55 | r.RecoverTriggerTasksRaw = string(data) 56 | } 57 | return nil 58 | } 59 | 60 | func (r *AlertRule) AfterFind(tx *gorm.DB) error { 61 | var err error 62 | if err = utils.Json.Unmarshal([]byte(r.RulesRaw), &r.Rules); err != nil { 63 | // 更新数据库中的无效数据 64 | tx.Model(r).Update("rules_raw", "[]") 65 | } 66 | if err = utils.Json.Unmarshal([]byte(r.FailTriggerTasksRaw), &r.FailTriggerTasks); err != nil { 67 | // 更新数据库中的无效数据 68 | tx.Model(r).Update("fail_trigger_tasks_raw", "[]") 69 | } 70 | if err = utils.Json.Unmarshal([]byte(r.RecoverTriggerTasksRaw), &r.RecoverTriggerTasks); err != nil { 71 | // 更新数据库中的无效数据 72 | tx.Model(r).Update("recover_trigger_tasks_raw", "[]") 73 | } 74 | return nil 75 | } 76 | 77 | func (r *AlertRule) Enabled() bool { 78 | return r.Enable != nil && *r.Enable 79 | } 80 | 81 | // Snapshot 对传入的Server进行该通知规则下所有type的检查 返回包含每项检查结果的空接口 82 | func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []interface{} { 83 | // 安全检查:确保AlertRule和Rules不为nil 84 | if r == nil || r.Rules == nil { 85 | return nil 86 | } 87 | 88 | var point []interface{} 89 | for i := 0; i < len(r.Rules); i++ { 90 | // Rule 是值类型(非指针),无需判空 91 | point = append(point, r.Rules[i].Snapshot(cycleTransferStats, server, db)) 92 | } 93 | return point 94 | } 95 | 96 | // Check 传入包含当前通知规则下所有type检查结果的空接口 返回通知持续时间与是否通过通知检查(通过则返回true) 97 | func (r *AlertRule) Check(points [][]interface{}) (int, bool) { 98 | var maxNum int // 报警持续时间 99 | var count int // 检查未通过的个数 100 | var hasTransferRule bool // 是否包含流量规则 101 | 102 | for i := 0; i < len(r.Rules); i++ { 103 | if r.Rules[i].IsTransferDurationRule() { 104 | // 循环区间流量报警 105 | hasTransferRule = true 106 | if maxNum < 1 { 107 | maxNum = 1 108 | } 109 | // 检查最新的流量状态 110 | if len(points) > 0 && len(points[i]) > 0 { 111 | // 检查最近的几个采样点,确保流量超限检测的及时性 112 | recentFailCount := 0 113 | checkPoints := len(points[i]) 114 | if checkPoints > 3 { 115 | checkPoints = 3 // 只检查最近3个点 116 | } 117 | 118 | for j := len(points[i]) - checkPoints; j < len(points[i]); j++ { 119 | if points[i][j] != nil { 120 | recentFailCount++ 121 | } 122 | } 123 | 124 | // 如果最近的采样点中有超限,则认为检查未通过 125 | if recentFailCount > 0 { 126 | count++ 127 | } 128 | } 129 | } else { 130 | // 常规报警 131 | total := 0.0 132 | fail := 0.0 133 | num := int(r.Rules[i].Duration) 134 | if num > maxNum { 135 | maxNum = num 136 | } 137 | if len(points) < num { 138 | continue 139 | } 140 | for j := len(points) - 1; j >= 0 && len(points)-num <= j; j-- { 141 | total++ 142 | if points[j][i] != nil { 143 | fail++ 144 | } 145 | } 146 | // 当70%以上的采样点未通过规则判断时 才认为当前检查未通过 147 | if fail/total > 0.7 { 148 | count++ 149 | break 150 | } 151 | } 152 | } 153 | 154 | // 修改逻辑: 155 | // 1. 如果包含流量规则且流量超限,直接触发报警 156 | // 2. 对于其他规则,仍然要求所有规则都未通过才触发报警 157 | if hasTransferRule && count > 0 { 158 | // 有流量规则且有规则未通过,触发报警 159 | return maxNum, false 160 | } 161 | 162 | // 仅当所有检查均未通过时 返回false 163 | return maxNum, count != len(r.Rules) 164 | } 165 | -------------------------------------------------------------------------------- /resource/template/component/monitor.html: -------------------------------------------------------------------------------- 1 | {{define "component/monitor"}} 2 | 117 | {{end}} 118 | -------------------------------------------------------------------------------- /script/test-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 测试 entrypoint.sh 在 Docker 中的可用性 4 | 5 | set -e 6 | 7 | # 颜色定义 8 | RED='\033[0;31m' 9 | GREEN='\033[0;32m' 10 | YELLOW='\033[1;33m' 11 | NC='\033[0m' 12 | 13 | log_info() { 14 | echo -e "${GREEN}[INFO]${NC} $1" 15 | } 16 | 17 | log_warn() { 18 | echo -e "${YELLOW}[WARN]${NC} $1" 19 | } 20 | 21 | log_error() { 22 | echo -e "${RED}[ERROR]${NC} $1" 23 | } 24 | 25 | # 检查本地文件 26 | check_local_files() { 27 | log_info "检查本地文件..." 28 | 29 | if [ ! -f "script/entrypoint.sh" ]; then 30 | log_error "script/entrypoint.sh 不存在" 31 | return 1 32 | fi 33 | 34 | if [ ! -x "script/entrypoint.sh" ]; then 35 | log_warn "script/entrypoint.sh 没有执行权限,正在修复..." 36 | chmod +x script/entrypoint.sh 37 | fi 38 | 39 | log_info "✓ script/entrypoint.sh 存在且有执行权限" 40 | 41 | if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then 42 | log_warn "dist 目录为空,需要先构建二进制文件" 43 | return 1 44 | fi 45 | 46 | log_info "✓ dist 目录存在且有内容" 47 | return 0 48 | } 49 | 50 | # 构建测试镜像 51 | build_test_image() { 52 | local dockerfile="${1:-Dockerfile}" 53 | local tag="entrypoint-test:latest" 54 | 55 | log_info "使用 $dockerfile 构建测试镜像..." 56 | 57 | if ! command -v docker &> /dev/null; then 58 | log_error "Docker 未安装" 59 | return 1 60 | fi 61 | 62 | if docker build -f "$dockerfile" -t "$tag" .; then 63 | log_info "✓ 镜像构建成功: $tag" 64 | return 0 65 | else 66 | log_error "✗ 镜像构建失败" 67 | return 1 68 | fi 69 | } 70 | 71 | # 测试镜像中的文件 72 | test_image_files() { 73 | local tag="entrypoint-test:latest" 74 | 75 | log_info "测试镜像中的文件..." 76 | 77 | # 检查 entrypoint.sh 是否存在 78 | if docker run --rm "$tag" ls -la /entrypoint.sh 2>/dev/null; then 79 | log_info "✓ /entrypoint.sh 存在" 80 | else 81 | log_error "✗ /entrypoint.sh 不存在" 82 | return 1 83 | fi 84 | 85 | # 检查权限 86 | if docker run --rm "$tag" test -x /entrypoint.sh; then 87 | log_info "✓ /entrypoint.sh 有执行权限" 88 | else 89 | log_error "✗ /entrypoint.sh 没有执行权限" 90 | return 1 91 | fi 92 | 93 | # 检查应用文件 94 | if docker run --rm "$tag" ls -la /dashboard/app 2>/dev/null; then 95 | log_info "✓ /dashboard/app 存在" 96 | else 97 | log_error "✗ /dashboard/app 不存在" 98 | return 1 99 | fi 100 | 101 | return 0 102 | } 103 | 104 | # 测试容器启动 105 | test_container_start() { 106 | local tag="entrypoint-test:latest" 107 | local container_name="entrypoint-test-$$" 108 | 109 | log_info "测试容器启动..." 110 | 111 | # 尝试启动容器 112 | if docker run -d --name "$container_name" "$tag" >/dev/null 2>&1; then 113 | sleep 3 114 | 115 | # 检查容器状态 116 | if docker ps --filter "name=$container_name" --filter "status=running" | grep -q "$container_name"; then 117 | log_info "✓ 容器启动成功" 118 | docker stop "$container_name" >/dev/null 2>&1 119 | docker rm "$container_name" >/dev/null 2>&1 120 | return 0 121 | else 122 | log_error "✗ 容器启动失败" 123 | echo "容器日志:" 124 | docker logs "$container_name" 2>&1 125 | docker rm "$container_name" >/dev/null 2>&1 126 | return 1 127 | fi 128 | else 129 | log_error "✗ 容器创建失败" 130 | return 1 131 | fi 132 | } 133 | 134 | # 清理测试镜像 135 | cleanup() { 136 | log_info "清理测试镜像..." 137 | docker rmi entrypoint-test:latest >/dev/null 2>&1 || true 138 | } 139 | 140 | # 主函数 141 | main() { 142 | local dockerfile="Dockerfile" 143 | 144 | # 解析参数 145 | while [[ $# -gt 0 ]]; do 146 | case $1 in 147 | --dockerfile) 148 | dockerfile="$2" 149 | shift 2 150 | ;; 151 | --help|-h) 152 | echo "entrypoint.sh 测试脚本" 153 | echo "使用方法: $0 [--dockerfile DOCKERFILE]" 154 | exit 0 155 | ;; 156 | *) 157 | log_error "未知参数: $1" 158 | exit 1 159 | ;; 160 | esac 161 | done 162 | 163 | # 设置清理陷阱 164 | trap cleanup EXIT 165 | 166 | log_info "开始 entrypoint.sh 测试..." 167 | echo "" 168 | 169 | # 检查本地文件 170 | if ! check_local_files; then 171 | log_error "本地文件检查失败" 172 | exit 1 173 | fi 174 | echo "" 175 | 176 | # 构建测试镜像 177 | if ! build_test_image "$dockerfile"; then 178 | log_error "镜像构建失败" 179 | exit 1 180 | fi 181 | echo "" 182 | 183 | # 测试镜像文件 184 | if ! test_image_files; then 185 | log_error "镜像文件测试失败" 186 | exit 1 187 | fi 188 | echo "" 189 | 190 | # 测试容器启动 191 | if ! test_container_start; then 192 | log_error "容器启动测试失败" 193 | exit 1 194 | fi 195 | echo "" 196 | 197 | log_info "🎉 所有测试通过!entrypoint.sh 工作正常。" 198 | } 199 | 200 | main "$@" -------------------------------------------------------------------------------- /model/host.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | pb "github.com/xos/serverstatus/proto" 5 | ) 6 | 7 | const ( 8 | _ = iota 9 | 10 | MTReportHostState 11 | ) 12 | 13 | type SensorTemperature struct { 14 | Name string 15 | Temperature float64 16 | } 17 | 18 | type HostState struct { 19 | CPU float64 20 | MemUsed uint64 21 | SwapUsed uint64 22 | DiskUsed uint64 23 | NetInTransfer uint64 24 | NetOutTransfer uint64 25 | NetInSpeed uint64 26 | NetOutSpeed uint64 27 | Uptime uint64 28 | Load1 float64 29 | Load5 float64 30 | Load15 float64 31 | TcpConnCount uint64 32 | UdpConnCount uint64 33 | ProcessCount uint64 34 | Temperatures []SensorTemperature 35 | GPU float64 36 | } 37 | 38 | func (s *HostState) PB() *pb.State { 39 | var ts []*pb.State_SensorTemperature 40 | for _, t := range s.Temperatures { 41 | ts = append(ts, &pb.State_SensorTemperature{ 42 | Name: t.Name, 43 | Temperature: t.Temperature, 44 | }) 45 | } 46 | 47 | return &pb.State{ 48 | Cpu: s.CPU, 49 | MemUsed: s.MemUsed, 50 | SwapUsed: s.SwapUsed, 51 | DiskUsed: s.DiskUsed, 52 | NetInTransfer: s.NetInTransfer, 53 | NetOutTransfer: s.NetOutTransfer, 54 | NetInSpeed: s.NetInSpeed, 55 | NetOutSpeed: s.NetOutSpeed, 56 | Uptime: s.Uptime, 57 | Load1: s.Load1, 58 | Load5: s.Load5, 59 | Load15: s.Load15, 60 | TcpConnCount: s.TcpConnCount, 61 | UdpConnCount: s.UdpConnCount, 62 | ProcessCount: s.ProcessCount, 63 | Temperatures: ts, 64 | Gpu: s.GPU, 65 | } 66 | } 67 | 68 | func PB2State(s *pb.State) HostState { 69 | var ts []SensorTemperature 70 | for _, t := range s.GetTemperatures() { 71 | ts = append(ts, SensorTemperature{ 72 | Name: t.GetName(), 73 | Temperature: t.GetTemperature(), 74 | }) 75 | } 76 | 77 | return HostState{ 78 | CPU: s.GetCpu(), 79 | MemUsed: s.GetMemUsed(), 80 | SwapUsed: s.GetSwapUsed(), 81 | DiskUsed: s.GetDiskUsed(), 82 | NetInTransfer: s.GetNetInTransfer(), 83 | NetOutTransfer: s.GetNetOutTransfer(), 84 | NetInSpeed: s.GetNetInSpeed(), 85 | NetOutSpeed: s.GetNetOutSpeed(), 86 | Uptime: s.GetUptime(), 87 | Load1: s.GetLoad1(), 88 | Load5: s.GetLoad5(), 89 | Load15: s.GetLoad15(), 90 | TcpConnCount: s.GetTcpConnCount(), 91 | UdpConnCount: s.GetUdpConnCount(), 92 | ProcessCount: s.GetProcessCount(), 93 | Temperatures: ts, 94 | GPU: s.GetGpu(), 95 | } 96 | } 97 | 98 | type Host struct { 99 | OS string `json:"OS,omitempty"` 100 | Platform string `json:"Platform,omitempty"` 101 | PlatformVersion string `json:"PlatformVersion,omitempty"` 102 | CPU []string `json:"CPU"` 103 | MemTotal uint64 `json:"MemTotal"` 104 | DiskTotal uint64 `json:"DiskTotal"` 105 | SwapTotal uint64 `json:"SwapTotal"` 106 | Arch string `json:"Arch,omitempty"` 107 | Virtualization string `json:"Virtualization,omitempty"` 108 | BootTime uint64 `json:"BootTime"` 109 | IP string `json:"-"` 110 | CountryCode string `json:"CountryCode,omitempty"` 111 | Version string `json:"Version,omitempty"` 112 | GPU []string `json:"GPU"` 113 | } 114 | 115 | func (h *Host) Initialize() { 116 | if h.CPU == nil { 117 | h.CPU = []string{} 118 | } 119 | if h.GPU == nil { 120 | h.GPU = []string{} 121 | } 122 | // 确保其他字段有合理的默认值 123 | // if h.OS == "" { 124 | // h.OS = "Unknown" 125 | // } 126 | // if h.Platform == "" { 127 | // h.Platform = "Unknown" 128 | // } 129 | // if h.Arch == "" { 130 | // h.Arch = "Unknown" 131 | // } 132 | } 133 | 134 | func (h *Host) PB() *pb.Host { 135 | h.Initialize() 136 | 137 | return &pb.Host{ 138 | Os: h.OS, 139 | Platform: h.Platform, 140 | PlatformVersion: h.PlatformVersion, 141 | Cpu: h.CPU, 142 | MemTotal: h.MemTotal, 143 | DiskTotal: h.DiskTotal, 144 | SwapTotal: h.SwapTotal, 145 | Arch: h.Arch, 146 | Virtualization: h.Virtualization, 147 | BootTime: h.BootTime, 148 | Ip: h.IP, 149 | CountryCode: h.CountryCode, 150 | Version: h.Version, 151 | Gpu: h.GPU, 152 | } 153 | } 154 | 155 | func PB2Host(h *pb.Host) Host { 156 | host := Host{ 157 | OS: h.GetOs(), 158 | Platform: h.GetPlatform(), 159 | PlatformVersion: h.GetPlatformVersion(), 160 | CPU: h.GetCpu(), 161 | MemTotal: h.GetMemTotal(), 162 | DiskTotal: h.GetDiskTotal(), 163 | SwapTotal: h.GetSwapTotal(), 164 | Arch: h.GetArch(), 165 | Virtualization: h.GetVirtualization(), 166 | BootTime: h.GetBootTime(), 167 | IP: h.GetIp(), 168 | CountryCode: h.GetCountryCode(), 169 | Version: h.GetVersion(), 170 | GPU: h.GetGpu(), 171 | } 172 | 173 | host.Initialize() 174 | 175 | return host 176 | } 177 | -------------------------------------------------------------------------------- /pkg/mygin/auth.go: -------------------------------------------------------------------------------- 1 | package mygin 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | 11 | "github.com/xos/serverstatus/db" 12 | "github.com/xos/serverstatus/model" 13 | "github.com/xos/serverstatus/service/singleton" 14 | ) 15 | 16 | type AuthorizeOption struct { 17 | GuestOnly bool 18 | MemberOnly bool 19 | IsPage bool 20 | AllowAPI bool 21 | Msg string 22 | Redirect string 23 | Btn string 24 | } 25 | 26 | func Authorize(opt AuthorizeOption) func(*gin.Context) { 27 | return func(c *gin.Context) { 28 | var code = http.StatusForbidden 29 | if opt.GuestOnly { 30 | code = http.StatusBadRequest 31 | } 32 | 33 | commonErr := ErrInfo{ 34 | Title: "访问受限", 35 | Code: code, 36 | Msg: opt.Msg, 37 | Link: opt.Redirect, 38 | Btn: opt.Btn, 39 | } 40 | var isLogin bool // 用户鉴权 41 | token, _ := c.Cookie(singleton.Conf.Site.CookieName) 42 | token = strings.TrimSpace(token) 43 | 44 | if token != "" { 45 | var u model.User 46 | 47 | // 根据数据库类型选择不同的验证方式 48 | if singleton.Conf.DatabaseType == "badger" { 49 | // 使用BadgerDB验证 50 | if db.DB != nil { 51 | var users []*model.User 52 | // 安全地查询用户,如果出错则创建一个默认管理员账户 53 | err := db.DB.FindAll("user", &users) 54 | if err != nil { 55 | log.Printf("从 BadgerDB 查询用户失败: %v,将使用默认凭据", err) 56 | // 使用默认管理员账户进行测试 57 | if token == "admin" { 58 | u = model.User{ 59 | Common: model.Common{ID: 1}, 60 | Login: "admin", 61 | SuperAdmin: true, 62 | } 63 | isLogin = true 64 | } 65 | } else { 66 | // 在内存中查找匹配token的用户 67 | for _, user := range users { 68 | if user != nil && user.Token == token { 69 | // 检查token是否过期 70 | if user.TokenExpired.After(time.Now()) { 71 | u = *user 72 | isLogin = true 73 | break 74 | } 75 | } 76 | } 77 | 78 | // 如果没有找到有效用户,但token是admin,则使用默认管理员账户 79 | if !isLogin && token == "admin" { 80 | u = model.User{ 81 | Common: model.Common{ID: 1}, 82 | Login: "admin", 83 | SuperAdmin: true, 84 | } 85 | isLogin = true 86 | } 87 | } 88 | } else { 89 | log.Printf("警告:BadgerDB未初始化,用户认证将失败") 90 | // 使用默认管理员账户 91 | if token == "admin" { 92 | u = model.User{ 93 | Common: model.Common{ID: 1}, 94 | Login: "admin", 95 | SuperAdmin: true, 96 | } 97 | isLogin = true 98 | } 99 | } 100 | } else { 101 | // 使用SQLite验证 102 | if singleton.DB != nil { 103 | err := singleton.DB.Where("token = ?", token).First(&u).Error 104 | if err == nil { 105 | isLogin = u.TokenExpired.After(time.Now()) 106 | } 107 | } else { 108 | log.Printf("警告:SQLite未初始化,用户认证将失败") 109 | // 在调试模式下使用默认管理员账户 110 | if singleton.Conf.Debug && token == "admin" { 111 | u = model.User{ 112 | Common: model.Common{ID: 1}, 113 | Login: "admin", 114 | SuperAdmin: true, 115 | } 116 | isLogin = true 117 | } 118 | } 119 | } 120 | 121 | if isLogin { 122 | c.Set(model.CtxKeyAuthorizedUser, &u) 123 | } 124 | } 125 | 126 | // API鉴权 127 | if opt.AllowAPI { 128 | apiToken := c.GetHeader("Authorization") 129 | if apiToken != "" { 130 | var u model.User 131 | singleton.ApiLock.RLock() 132 | if _, ok := singleton.ApiTokenList[apiToken]; ok { 133 | if singleton.Conf.DatabaseType == "badger" { 134 | // 使用BadgerDB验证 135 | if db.DB != nil { 136 | userID := singleton.ApiTokenList[apiToken].UserID 137 | if userID > 0 { 138 | userOps := db.NewUserOps(db.DB) 139 | user, err := userOps.GetUserByID(userID) 140 | if err == nil && user != nil { 141 | u = *user 142 | isLogin = true 143 | } 144 | } 145 | } else { 146 | // 在调试模式下使用默认API认证 147 | if singleton.Conf.Debug && apiToken == "default_api_token" { 148 | u = model.User{ 149 | Common: model.Common{ID: 1}, 150 | Login: "admin", 151 | SuperAdmin: true, 152 | } 153 | isLogin = true 154 | } 155 | } 156 | } else { 157 | // 使用SQLite验证 158 | if singleton.DB != nil { 159 | err := singleton.DB.Where("id = ?", singleton.ApiTokenList[apiToken].UserID).First(&u).Error 160 | isLogin = err == nil 161 | } else { 162 | log.Printf("警告:SQLite未初始化,API Token认证将失败") 163 | } 164 | } 165 | } 166 | singleton.ApiLock.RUnlock() 167 | if isLogin { 168 | c.Set(model.CtxKeyAuthorizedUser, &u) 169 | c.Set("isAPI", true) 170 | } 171 | } 172 | } 173 | 174 | // 调试模式允许特定路径跳过验证 175 | if singleton.Conf.Debug && !isLogin && opt.MemberOnly { 176 | // 对于首页和基础服务,在调试模式下可以跳过验证 177 | path := c.Request.URL.Path 178 | if path == "/" || path == "/service" || path == "/ws" || path == "/network" { 179 | // 创建一个临时管理员用户 180 | u := &model.User{ 181 | Common: model.Common{ID: 1}, 182 | Login: "debug_admin", 183 | SuperAdmin: true, 184 | } 185 | c.Set(model.CtxKeyAuthorizedUser, u) 186 | isLogin = true 187 | } 188 | } 189 | 190 | // 已登录且只能游客访问 191 | if isLogin && opt.GuestOnly { 192 | ShowErrorPage(c, commonErr, opt.IsPage) 193 | return 194 | } 195 | 196 | // 未登录且需要登录 197 | if !isLogin && opt.MemberOnly { 198 | ShowErrorPage(c, commonErr, opt.IsPage) 199 | return 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /model/monitor.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/robfig/cron/v3" 9 | "github.com/xos/serverstatus/pkg/utils" 10 | pb "github.com/xos/serverstatus/proto" 11 | "gorm.io/gorm" 12 | ) 13 | 14 | const ( 15 | _ = iota 16 | TaskTypeICMPPing 17 | TaskTypeTCPPing 18 | TaskTypeCommand 19 | TaskTypeTerminal 20 | TaskTypeUpgrade 21 | TaskTypeKeepalive 22 | TaskTypeTerminalGRPC 23 | TaskTypeNAT 24 | TaskTypeReportHostInfo 25 | TaskTypeFM 26 | ) 27 | 28 | type TerminalTask struct { 29 | StreamID string 30 | } 31 | 32 | type TaskNAT struct { 33 | StreamID string 34 | Host string 35 | } 36 | 37 | type TaskFM struct { 38 | StreamID string 39 | } 40 | 41 | const ( 42 | MonitorCoverAll = iota 43 | MonitorCoverIgnoreAll 44 | ) 45 | 46 | type Monitor struct { 47 | Common 48 | Name string 49 | Type uint8 50 | Target string 51 | SkipServersRaw string 52 | Duration uint64 53 | Notify bool 54 | NotificationTag string // 当前服务监控所属的通知组 55 | Cover uint8 56 | 57 | EnableTriggerTask bool `gorm:"default: false"` 58 | EnableShowInService bool `gorm:"default: false"` 59 | FailTriggerTasksRaw string `gorm:"default:'[]'"` 60 | RecoverTriggerTasksRaw string `gorm:"default:'[]'"` 61 | FailTriggerTasks []uint64 `gorm:"-" json:"-"` // 失败时执行的触发任务id 62 | RecoverTriggerTasks []uint64 `gorm:"-" json:"-"` // 恢复时执行的触发任务id 63 | 64 | MinLatency float32 65 | MaxLatency float32 66 | LatencyNotify bool 67 | 68 | SkipServers map[uint64]bool `gorm:"-" json:"-"` 69 | CronJobID cron.EntryID `gorm:"-" json:"-"` 70 | } 71 | 72 | func (m *Monitor) PB() *pb.Task { 73 | return &pb.Task{ 74 | Id: m.ID, 75 | Type: uint64(m.Type), 76 | Data: m.Target, 77 | } 78 | } 79 | 80 | // CronSpec 返回服务监控请求间隔对应的 cron 表达式 81 | func (m *Monitor) CronSpec() string { 82 | if m.Duration == 0 { 83 | // 默认间隔 30 秒 84 | m.Duration = 30 85 | } 86 | return fmt.Sprintf("@every %ds", m.Duration) 87 | } 88 | 89 | func (m *Monitor) BeforeSave(tx *gorm.DB) error { 90 | 91 | if data, err := utils.Json.Marshal(m.FailTriggerTasks); err != nil { 92 | return err 93 | } else { 94 | m.FailTriggerTasksRaw = string(data) 95 | } 96 | if data, err := utils.Json.Marshal(m.RecoverTriggerTasks); err != nil { 97 | return err 98 | } else { 99 | m.RecoverTriggerTasksRaw = string(data) 100 | } 101 | return nil 102 | } 103 | 104 | func (m *Monitor) AfterFind(tx *gorm.DB) error { 105 | // 类型兼容:历史数据中可能存在 0 或 3 的取值(旧前端/旧版本遗留) 106 | // 0 归一化为 ICMP(1),3 归一化为 TCP(2) 107 | if m.Type == 0 { 108 | m.Type = TaskTypeICMPPing 109 | // 尝试静默纠正数据库中的值,失败则记录日志但不阻断 110 | if tx != nil { 111 | if err := tx.Model(m).Update("type", TaskTypeICMPPing).Error; err != nil { 112 | log.Printf("修正Monitor %s(Type=0)为ICMP失败: %v", m.Name, err) 113 | } 114 | } 115 | } else if m.Type == 3 { 116 | m.Type = TaskTypeTCPPing 117 | if tx != nil { 118 | if err := tx.Model(m).Update("type", TaskTypeTCPPing).Error; err != nil { 119 | log.Printf("修正Monitor %s(Type=3)为TCP失败: %v", m.Name, err) 120 | } 121 | } 122 | } else if m.Type == 2 { 123 | // 兼容历史:早期 2 代表 ICMP。若目标不含端口,视为 ICMP 并静默纠正; 124 | // 若目标包含端口,保持为 TCP。 125 | if !strings.Contains(m.Target, ":") { 126 | m.Type = TaskTypeICMPPing 127 | if tx != nil { 128 | if err := tx.Model(m).Update("type", TaskTypeICMPPing).Error; err != nil { 129 | log.Printf("修正Monitor %s(Type=2无端口)为ICMP失败: %v", m.Name, err) 130 | } 131 | } 132 | } 133 | } 134 | 135 | m.SkipServers = make(map[uint64]bool) 136 | var skipServers []uint64 137 | if err := utils.Json.Unmarshal([]byte(m.SkipServersRaw), &skipServers); err != nil { 138 | log.Println("NG>> Monitor.AfterFind:", err) 139 | return nil 140 | } 141 | for i := 0; i < len(skipServers); i++ { 142 | m.SkipServers[skipServers[i]] = true 143 | } 144 | 145 | // 加载触发任务列表 146 | if err := utils.Json.Unmarshal([]byte(m.FailTriggerTasksRaw), &m.FailTriggerTasks); err != nil { 147 | log.Printf("解析Monitor %s 的FailTriggerTasksRaw失败(%s),重置为空数组: %v", m.Name, m.FailTriggerTasksRaw, err) 148 | m.FailTriggerTasks = []uint64{} 149 | m.FailTriggerTasksRaw = "[]" 150 | // 更新数据库中的无效数据 151 | tx.Model(m).Update("fail_trigger_tasks_raw", "[]") 152 | } 153 | if err := utils.Json.Unmarshal([]byte(m.RecoverTriggerTasksRaw), &m.RecoverTriggerTasks); err != nil { 154 | log.Printf("解析Monitor %s 的RecoverTriggerTasksRaw失败(%s),重置为空数组: %v", m.Name, m.RecoverTriggerTasksRaw, err) 155 | m.RecoverTriggerTasks = []uint64{} 156 | m.RecoverTriggerTasksRaw = "[]" 157 | // 更新数据库中的无效数据 158 | tx.Model(m).Update("recover_trigger_tasks_raw", "[]") 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true 165 | func IsServiceSentinelNeeded(t uint64) bool { 166 | return t != TaskTypeCommand && t != TaskTypeTerminalGRPC && t != TaskTypeUpgrade 167 | } 168 | 169 | func (m *Monitor) InitSkipServers() error { 170 | m.SkipServers = make(map[uint64]bool) 171 | 172 | // 如果SkipServersRaw为空或无效,设置为空数组 173 | if m.SkipServersRaw == "" || m.SkipServersRaw == "null" { 174 | m.SkipServersRaw = "[]" 175 | return nil 176 | } 177 | 178 | var skipServers []uint64 179 | if err := utils.Json.Unmarshal([]byte(m.SkipServersRaw), &skipServers); err != nil { 180 | log.Printf("监控器 %s 的SkipServersRaw格式无效(%s),重置为空数组: %v", m.Name, m.SkipServersRaw, err) 181 | m.SkipServersRaw = "[]" 182 | return nil 183 | } 184 | 185 | for i := 0; i < len(skipServers); i++ { 186 | m.SkipServers[skipServers[i]] = true 187 | } 188 | return nil 189 | } 190 | -------------------------------------------------------------------------------- /script/fix-permissions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 权限修复脚本 4 | # 用于修复 Docker 容器中的权限问题 5 | 6 | set -e 7 | 8 | # 颜色定义 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' 13 | 14 | log_info() { 15 | echo -e "${GREEN}[INFO]${NC} $1" 16 | } 17 | 18 | log_warn() { 19 | echo -e "${YELLOW}[WARN]${NC} $1" 20 | } 21 | 22 | log_error() { 23 | echo -e "${RED}[ERROR]${NC} $1" 24 | } 25 | 26 | # 检查并修复本地文件权限 27 | fix_local_permissions() { 28 | log_info "检查本地文件权限..." 29 | 30 | # 检查 entrypoint.sh 31 | if [ -f "script/entrypoint.sh" ]; then 32 | if [ ! -x "script/entrypoint.sh" ]; then 33 | log_warn "script/entrypoint.sh 没有执行权限,正在修复..." 34 | chmod +x script/entrypoint.sh 35 | log_info "✓ script/entrypoint.sh 权限已修复" 36 | else 37 | log_info "✓ script/entrypoint.sh 权限正常" 38 | fi 39 | else 40 | log_error "✗ script/entrypoint.sh 文件不存在" 41 | return 1 42 | fi 43 | 44 | # 检查构建产物 45 | if [ -d "dist" ]; then 46 | log_info "检查构建产物权限..." 47 | find dist -name "server-dash*" -type f | while read -r file; do 48 | if [ ! -x "$file" ]; then 49 | log_warn "$file 没有执行权限,正在修复..." 50 | chmod +x "$file" 51 | log_info "✓ $file 权限已修复" 52 | else 53 | log_info "✓ $file 权限正常" 54 | fi 55 | done 56 | fi 57 | 58 | # 检查其他脚本 59 | find script -name "*.sh" -type f | while read -r script_file; do 60 | if [ ! -x "$script_file" ]; then 61 | log_warn "$script_file 没有执行权限,正在修复..." 62 | chmod +x "$script_file" 63 | log_info "✓ $script_file 权限已修复" 64 | fi 65 | done 66 | } 67 | 68 | # 测试 Docker 镜像权限 69 | test_docker_permissions() { 70 | local image_name="${1:-serverstatus-test:latest}" 71 | 72 | log_info "测试 Docker 镜像权限: $image_name" 73 | 74 | if ! command -v docker &> /dev/null; then 75 | log_error "Docker 未安装,跳过镜像权限测试" 76 | return 1 77 | fi 78 | 79 | if ! docker image inspect "$image_name" &> /dev/null; then 80 | log_error "镜像 $image_name 不存在" 81 | return 1 82 | fi 83 | 84 | # 测试 entrypoint.sh 权限 85 | log_info "检查 /entrypoint.sh 权限..." 86 | if docker run --rm "$image_name" ls -la /entrypoint.sh | grep -q "^-rwxr-xr-x"; then 87 | log_info "✓ /entrypoint.sh 权限正确" 88 | else 89 | log_error "✗ /entrypoint.sh 权限不正确" 90 | docker run --rm "$image_name" ls -la /entrypoint.sh 91 | return 1 92 | fi 93 | 94 | # 测试应用权限 95 | log_info "检查 /dashboard/app 权限..." 96 | if docker run --rm "$image_name" ls -la /dashboard/app | grep -q "^-rwxr-xr-x"; then 97 | log_info "✓ /dashboard/app 权限正确" 98 | else 99 | log_error "✗ /dashboard/app 权限不正确" 100 | docker run --rm "$image_name" ls -la /dashboard/app 101 | return 1 102 | fi 103 | 104 | # 测试容器启动 105 | log_info "测试容器启动..." 106 | local container_name="permission-test-$$" 107 | 108 | if docker run -d --name "$container_name" "$image_name" >/dev/null 2>&1; then 109 | sleep 5 110 | if docker ps --filter "name=$container_name" --filter "status=running" | grep -q "$container_name"; then 111 | log_info "✓ 容器启动成功" 112 | docker stop "$container_name" >/dev/null 2>&1 113 | docker rm "$container_name" >/dev/null 2>&1 114 | else 115 | log_error "✗ 容器启动失败" 116 | echo "容器日志:" 117 | docker logs "$container_name" 2>&1 118 | docker rm "$container_name" >/dev/null 2>&1 119 | return 1 120 | fi 121 | else 122 | log_error "✗ 容器创建失败" 123 | return 1 124 | fi 125 | 126 | return 0 127 | } 128 | 129 | # 显示权限信息 130 | show_permissions() { 131 | log_info "当前文件权限信息:" 132 | echo "" 133 | 134 | if [ -f "script/entrypoint.sh" ]; then 135 | echo "script/entrypoint.sh:" 136 | ls -la script/entrypoint.sh 137 | fi 138 | 139 | if [ -d "dist" ]; then 140 | echo "" 141 | echo "构建产物:" 142 | ls -la dist/ 143 | fi 144 | 145 | echo "" 146 | echo "脚本文件:" 147 | find script -name "*.sh" -type f -exec ls -la {} \; 148 | } 149 | 150 | # 显示帮助 151 | show_help() { 152 | echo "权限修复脚本" 153 | echo "" 154 | echo "使用方法:" 155 | echo " $0 [选项]" 156 | echo "" 157 | echo "选项:" 158 | echo " --fix-local 修复本地文件权限" 159 | echo " --test-docker IMAGE 测试 Docker 镜像权限" 160 | echo " --show 显示当前权限信息" 161 | echo " --help, -h 显示此帮助信息" 162 | echo "" 163 | echo "示例:" 164 | echo " $0 --fix-local # 修复本地权限" 165 | echo " $0 --test-docker my-image:latest # 测试镜像权限" 166 | echo " $0 --show # 显示权限信息" 167 | } 168 | 169 | # 主函数 170 | main() { 171 | case "${1:-fix-local}" in 172 | --fix-local) 173 | fix_local_permissions 174 | ;; 175 | --test-docker) 176 | if [ -z "$2" ]; then 177 | log_error "请指定镜像名称" 178 | show_help 179 | exit 1 180 | fi 181 | test_docker_permissions "$2" 182 | ;; 183 | --show) 184 | show_permissions 185 | ;; 186 | --help|-h) 187 | show_help 188 | ;; 189 | *) 190 | log_info "默认执行本地权限修复..." 191 | fix_local_permissions 192 | ;; 193 | esac 194 | } 195 | 196 | main "$@" -------------------------------------------------------------------------------- /resource/template/dashboard-default/terminal.html: -------------------------------------------------------------------------------- 1 | {{define "dashboard-default/terminal"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | TTY@{{.ServerName}} - {{.Title}} 17 | 18 | 19 | 20 | 21 | 65 | 66 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 161 | 162 | 163 | 164 | {{end}} 165 | -------------------------------------------------------------------------------- /db/access_optimizer.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/xos/serverstatus/pkg/utils" 12 | ) 13 | 14 | // DataAccessOptimizer 数据访问优化器 - 减少数据库访问,提高缓存效率 15 | type DataAccessOptimizer struct { 16 | // 批量操作缓冲区 17 | pendingWrites map[string]interface{} 18 | writeMutex sync.RWMutex 19 | 20 | // 读缓存(应用层缓存,提高命中率) 21 | readCache map[string]CacheItem 22 | cacheMutex sync.RWMutex 23 | 24 | // 批量写入定时器 25 | flushTicker *time.Ticker 26 | stopCh chan struct{} 27 | } 28 | 29 | // CacheItem 缓存项 30 | type CacheItem struct { 31 | Data interface{} 32 | ExpireAt time.Time 33 | AccessAt time.Time 34 | } 35 | 36 | var ( 37 | optimizer *DataAccessOptimizer 38 | optimizerOnce sync.Once 39 | ) 40 | 41 | // GetDataAccessOptimizer 获取数据访问优化器单例 42 | func GetDataAccessOptimizer() *DataAccessOptimizer { 43 | optimizerOnce.Do(func() { 44 | optimizer = &DataAccessOptimizer{ 45 | pendingWrites: make(map[string]interface{}), 46 | readCache: make(map[string]CacheItem), 47 | flushTicker: time.NewTicker(30 * time.Second), // 30秒批量写入一次 48 | stopCh: make(chan struct{}), 49 | } 50 | optimizer.start() 51 | }) 52 | return optimizer 53 | } 54 | 55 | // start 启动批量写入和缓存清理 56 | func (dao *DataAccessOptimizer) start() { 57 | go func() { 58 | defer dao.flushTicker.Stop() 59 | 60 | for { 61 | select { 62 | case <-dao.flushTicker.C: 63 | dao.flushPendingWrites() 64 | dao.cleanExpiredCache() 65 | 66 | case <-dao.stopCh: 67 | // 停止前最后一次写入 68 | dao.flushPendingWrites() 69 | return 70 | } 71 | } 72 | }() 73 | } 74 | 75 | // OptimizedSave 优化的保存方法 - 缓冲写入请求 76 | func (dao *DataAccessOptimizer) OptimizedSave(modelType string, id uint64, data interface{}) { 77 | key := getModelKey(modelType, id) 78 | 79 | dao.writeMutex.Lock() 80 | dao.pendingWrites[key] = data 81 | dao.writeMutex.Unlock() 82 | 83 | // 同时更新读缓存 84 | dao.updateReadCache(key, data) 85 | } 86 | 87 | // OptimizedGet 优化的获取方法 - 优先从应用层缓存读取 88 | func (dao *DataAccessOptimizer) OptimizedGet(modelType string, id uint64, result interface{}) error { 89 | key := getModelKey(modelType, id) 90 | 91 | // 首先检查应用层缓存 92 | dao.cacheMutex.RLock() 93 | if item, exists := dao.readCache[key]; exists && item.ExpireAt.After(time.Now()) { 94 | // 在读锁内直接更新访问时间,避免锁竞争 95 | item.AccessAt = time.Now() 96 | dao.readCache[key] = item 97 | dao.cacheMutex.RUnlock() 98 | 99 | // 复制数据到结果 100 | return copyData(item.Data, result) 101 | } 102 | dao.cacheMutex.RUnlock() 103 | 104 | // 缓存未命中,从数据库读取 105 | if err := DB.FindModel(id, modelType, result); err != nil { 106 | return err 107 | } 108 | 109 | // 更新缓存 110 | dao.updateReadCache(key, result) 111 | return nil 112 | } 113 | 114 | // flushPendingWrites 批量写入待处理的数据 115 | func (dao *DataAccessOptimizer) flushPendingWrites() { 116 | dao.writeMutex.Lock() 117 | if len(dao.pendingWrites) == 0 { 118 | dao.writeMutex.Unlock() 119 | return 120 | } 121 | 122 | // 复制待写入数据 123 | writes := make(map[string]interface{}) 124 | for k, v := range dao.pendingWrites { 125 | writes[k] = v 126 | } 127 | // 清空缓冲区 128 | dao.pendingWrites = make(map[string]interface{}) 129 | dao.writeMutex.Unlock() 130 | 131 | // 按模型类型分组批量写入 132 | grouped := groupByModelType(writes) 133 | for modelType, models := range grouped { 134 | if err := DB.BatchSaveModels(modelType, models); err != nil { 135 | log.Printf("批量写入失败 %s: %v", modelType, err) 136 | } 137 | } 138 | 139 | log.Printf("批量写入完成: %d条记录", len(writes)) 140 | } 141 | 142 | // updateReadCache 更新读缓存 143 | func (dao *DataAccessOptimizer) updateReadCache(key string, data interface{}) { 144 | dao.cacheMutex.Lock() 145 | dao.readCache[key] = CacheItem{ 146 | Data: copyDataValue(data), 147 | ExpireAt: time.Now().Add(5 * time.Minute), // 5分钟过期 148 | AccessAt: time.Now(), 149 | } 150 | dao.cacheMutex.Unlock() 151 | } 152 | 153 | // cleanExpiredCache 清理过期缓存 154 | func (dao *DataAccessOptimizer) cleanExpiredCache() { 155 | dao.cacheMutex.Lock() 156 | defer dao.cacheMutex.Unlock() 157 | 158 | now := time.Now() 159 | cleaned := 0 160 | 161 | for key, item := range dao.readCache { 162 | // 清理过期的或长时间未访问的缓存 163 | if item.ExpireAt.Before(now) || item.AccessAt.Before(now.Add(-10*time.Minute)) { 164 | delete(dao.readCache, key) 165 | cleaned++ 166 | } 167 | } 168 | 169 | if cleaned > 0 { 170 | log.Printf("清理过期缓存: %d项", cleaned) 171 | } 172 | } 173 | 174 | // Stop 停止优化器 175 | func (dao *DataAccessOptimizer) Stop() { 176 | close(dao.stopCh) 177 | } 178 | 179 | // 辅助函数 180 | func getModelKey(modelType string, id uint64) string { 181 | return fmt.Sprintf("%s:%d", modelType, id) 182 | } 183 | 184 | func groupByModelType(writes map[string]interface{}) map[string]map[uint64]interface{} { 185 | grouped := make(map[string]map[uint64]interface{}) 186 | 187 | for key, data := range writes { 188 | parts := strings.SplitN(key, ":", 2) 189 | if len(parts) != 2 { 190 | continue 191 | } 192 | 193 | modelType := parts[0] 194 | id, err := strconv.ParseUint(parts[1], 10, 64) 195 | if err != nil { 196 | continue 197 | } 198 | 199 | if grouped[modelType] == nil { 200 | grouped[modelType] = make(map[uint64]interface{}) 201 | } 202 | grouped[modelType][id] = data 203 | } 204 | 205 | return grouped 206 | } 207 | 208 | func copyData(src, dst interface{}) error { 209 | // 简化实现,实际应该根据类型进行深拷贝 210 | jsonData, err := utils.Json.Marshal(src) 211 | if err != nil { 212 | return err 213 | } 214 | return utils.Json.Unmarshal(jsonData, dst) 215 | } 216 | 217 | func copyDataValue(data interface{}) interface{} { 218 | // 简化实现,返回数据的深拷贝 219 | jsonData, err := utils.Json.Marshal(data) 220 | if err != nil { 221 | return data // 失败时返回原数据 222 | } 223 | var result interface{} 224 | utils.Json.Unmarshal(jsonData, &result) 225 | return result 226 | } 227 | --------------------------------------------------------------------------------