commit 7e803e2cdbfcb01bf495816c936f78f854dca657 Author: BBIT-Kai <2911862937@qq.com> Date: Tue May 26 13:53:23 2026 +0800 初始化项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90b7cf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +build/ +logs/ +*.exe diff --git a/bin/config.yaml b/bin/config.yaml new file mode 100644 index 0000000..3790ef8 --- /dev/null +++ b/bin/config.yaml @@ -0,0 +1,5 @@ +version_code: 27 +pid: 29412 +need_update: false +control_state: CONTROL_LOST +last_alive_at: 1771987569 diff --git a/build_all.ps1 b/build_all.ps1 new file mode 100644 index 0000000..df279d4 --- /dev/null +++ b/build_all.ps1 @@ -0,0 +1,44 @@ +# build_all.ps1 +# 一键打包 main 和 updater,三平台: Windows 64, Linux amd64, Linux arm64 + +# ------------------------- +# 项目根目录 +$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +Set-Location $RootDir + +# ------------------------- +# 定义要打包的平台 +$targets = @( + @{ OS="windows"; ARCH="amd64"; Dir="build/win"; MainOut="main.exe"; UpdaterOut="updater.exe" }, + @{ OS="linux"; ARCH="amd64"; Dir="build/linux_amd64"; MainOut="main"; UpdaterOut="updater" }, + @{ OS="linux"; ARCH="arm64"; Dir="build/linux_arm64"; MainOut="main"; UpdaterOut="updater" } +) + +# ------------------------- +# 循环打包 +foreach ($t in $targets) { + Write-Host "=== Build $($t.OS)-$($t.ARCH) Version ===" + + # 设置环境变量 + $env:GOOS = $t.OS + $env:GOARCH = $t.ARCH + $env:CGO_ENABLED = "0" + + # 创建输出目录 + if (!(Test-Path $t.Dir)) { + New-Item -ItemType Directory -Path $t.Dir | Out-Null + } + + # 编译 main + Write-Host "Building main..." + go build -ldflags="-s -w" -o "$($t.Dir)/$($t.MainOut)" ./main + + # 编译 updater + Write-Host "Building updater..." + go build -ldflags="-s -w" -o "$($t.Dir)/$($t.UpdaterOut)" ./updater + + Write-Host "$($t.OS)-$($t.ARCH) Build complete, Output dir: $($t.Dir)" + Write-Host "" +} + +Write-Host "All builds finished." diff --git a/edge_agent.service b/edge_agent.service new file mode 100644 index 0000000..f6dcadd --- /dev/null +++ b/edge_agent.service @@ -0,0 +1,31 @@ +[Unit] +Description=Sentinel Updater Service +After=network.target +Wants=network.target + +[Service] +Type=simple + +# 启动前确保可执行权限 +ExecStartPre=/bin/chmod +x /opt/edge/updater + +ExecStart=/opt/edge/updater + +WorkingDirectory=/opt/edge/ + +User=root +Group=root + +# 崩溃自动拉起 +Restart=always +RestartSec=10 + +# 日志直接进 journalctl +StandardOutput=journal +StandardError=journal + +# 可选:限制资源 +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..196e6ba --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module sentinel + +go 1.25.5 + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/minio/crc64nvme v1.1.0 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.97 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tinylib/msgp v1.3.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..13596d4 --- /dev/null +++ b/go.sum @@ -0,0 +1,107 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= +github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= +github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= +github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k= +github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main/business_service.go b/main/business_service.go new file mode 100644 index 0000000..201bcb4 --- /dev/null +++ b/main/business_service.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "sentinel/pkg/config" + "sentinel/pkg/device" + "sentinel/pkg/docker" + "sentinel/pkg/log" + model2 "sentinel/pkg/model" + "sentinel/pkg/utils" + "time" +) + +type BusinessService struct { + mqtt *MQTTService + dockerManager docker.DockerManager + deviceID string + deptId string + cmdTopic string + deviceType string +} + +func NewBusinessService(m *MQTTService, deviceID string, dm docker.DockerManager) *BusinessService { + return &BusinessService{ + mqtt: m, + dockerManager: dm, + deviceID: deviceID, + } +} + +func (b *BusinessService) Start() error { + if err := b.mqtt.SubscribeTopic(getInitTopic(b.deviceID), 1); err != nil { + return err + } + + b.mqtt.SetMessageHandler(b.onMQTTMessage) + + return nil +} + +func getInitTopic(deviceID string) string { + return "+/+/+/" + deviceID + "/#" +} + +func (b *BusinessService) getOwnTopic(deviceID string) string { + return b.deptId + "/cmd/" + b.deviceType + "/" + deviceID + "/#" +} + +// 消息处理 +func (b *BusinessService) onMQTTMessage(topic string, payload []byte) { + model := model2.FromStringToMqttTopic(topic) + payload_json, err := utils.PayloadToMap(payload) + if err != nil { + fmt.Println("解析失败:", err) + return + } + + // 指令 + if model.Domain == "cmd" && model.DeviceType == b.deviceType { + log.Println("收到指令:", model.Resource) + switch model.Resource { + case "shutdown": + payload_json["massage"] = b.handleShutdown() + case "restart": + payload_json["massage"] = b.handleRestart() + case "check_update": + payload_json["massage"] = b.handleCheckUpdate() + default: + log.Println("未知的命令:", model.Resource) + payload_json["massage"] = "未知的命令" + } + topic := "x/receipt/x/" + device.GetDeviceID() + "/info" + payload := utils.JSONToString(payload_json) + b.mqtt.PublicMsg(topic, payload, false) // 回执 + } else if model.Domain == "status" && model.Resource == "receipt" { + b.deviceType = model.DeviceType + b.deptId = model.DeptId + // 取消订阅之前的初始化主题 + if b.mqtt.UnsubscribeTopic(getInitTopic(b.deviceID)) != nil { + log.Error("无法取消初始化主题") + return + } + // 新订阅属于自己的主题 + if b.mqtt.SubscribeTopic(b.getOwnTopic(b.deviceID), 1) != nil { + log.Error("无法定于属于自己的主题") + return + } + log.Println("设备初始化成功:所属项目:", model.DeptId, "\t设备类型:", model.DeviceType) + } +} + +func getStatusInfoTopicPayload(isOnline bool) (string, string) { + info := map[string]interface{}{ + "version": config.APP_VERSION, + "online": isOnline, + "ip": utils.GetLocalIP(), + "hostname": utils.GetHostname(), + "mac": utils.GetMacAddress(), + "os": utils.GetOSInfo(), + "cpu": utils.GetCPUInfo(), + "memory_total": utils.GetMemory(), + "disk_total": utils.GetDisk(), + "last_seen": time.Now().UTC().Format(time.RFC3339), + } + payloadBytes, _ := json.Marshal(info) + payload := string(payloadBytes) // 转成 string + return "x/status/x/" + device.GetDeviceID() + "/info", payload +} + +// 关闭程序(立即退出) +func (b *BusinessService) handleShutdown() string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + b.dockerManager.StopAndRemoveContainer(ctx) + return "程序已退出" +} + +// 重启程序 +// 重启程序 +func (b *BusinessService) handleRestart() string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // 先停止并移除旧容器 + if err := b.dockerManager.StopAndRemoveContainer(ctx); err != nil { + log.Println("警告:移除容器失败,可能容器不存在:", err) + } + + // 拉取最新镜像 + log.Println("正在拉取镜像:", config.DOCKER_IMAGE) + if err := b.dockerManager.PullImage(ctx, config.DOCKER_IMAGE); err != nil { + log.Println("镜像拉取失败:", err) + return "程序重启失败,拉取镜像失败" + } + + // 启动新容器 + if err := b.dockerManager.RunContainer(ctx); err != nil { + log.Println("容器启动失败:", err) + return "程序重启失败,容器启动失败" + } + + return "程序已重启" +} + +func (b *BusinessService) handleCheckUpdate() string { + cfg := config.LoadConfig() + cfg.NeedUpdate = true + if err := config.WriteLocalConfig(&cfg); err != nil { + log.Println("load config failed:", err) + } + return "程序更新机制已触发" +} + +//func (b *BusinessService) handleCheckUpdate() { +// args := []string{ +// "--version", strconv.Itoa(config.APP_VERSION), +// } +// +// launcher := newUpdaterLauncher() +// +// if err := launcher.Start(args); err != nil { +// log.Println("[BUS] failed to start updater:", err) +// return +// } +// +// log.Println( +// "[BUS] updater started, exiting main program", +// ) +// +// time.Sleep(500 * time.Millisecond) +// os.Exit(0) +//} diff --git a/main/main.go b/main/main.go new file mode 100644 index 0000000..e9056c2 --- /dev/null +++ b/main/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "sentinel/pkg/config" + "sentinel/pkg/device" + "sentinel/pkg/docker" + "sentinel/pkg/log" +) + +func main() { + deviceID := device.GetDeviceID() + + // APP Logo + InitPrint(deviceID) + // Config + InitConfigFile() + // MQTT + InitMQTT(deviceID) + +} +func InitConfigFile() { + // 第一次写入默认配置 + cfg := config.AppConfig{ + VersionCode: config.APP_VERSION, + PID: os.Getpid(), + NeedUpdate: false, + ControlState: "DEGRADED", + LastAliveAt: time.Now().Unix(), + } + + // 写入文件 + if err := config.WriteLocalConfig(&cfg); err != nil { + log.Println("[config] 写入初始配置失败:", err) + } +} + +func InitPrint(deviceID string) { + banner := ` +============================================================================== + + ██████ █████ ██████ ██████ ██ ██████ ██████ ██ ██ ██████ + ██ ██ ██ ██ ██ ████ ██ ██ ███ ██ ██ + █████ ██ ██ ██ ██ █████ ██ ██ ██ ██ █████ ████ ██ ██ + ██ ██ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ████ ██ + ██████ █████ █████ ██████ ██ ██ █████ ██████ ██ ██ ██ + +============================================================================== +` + log.Println(banner) + log.Println("Device id: "+deviceID, "\t版本号: ", config.APP_VERSION) // 第一次启动记录 +} + +func InitMQTT(deviceID string) { + var mqttSvc *MQTTService + firstFail := true // 标记是否第一次失败 + mqttSvc = NewMQTTService(config.MQTT_BROKER, deviceID, deviceID, config.PASSWORD, 60) + for { + err := mqttSvc.Connect() + if err != nil { + if firstFail { + log.Error("物联网服务器连接失败,如未注册设备,请先注册: " + deviceID) + firstFail = false + } + time.Sleep(3 * time.Second) // 5秒后重试 + continue + } + break + } + defer mqttSvc.Close() + + dm, err := docker.NewDockerManager() + if err != nil { + log.Fatal(err) + } + + biz := NewBusinessService(mqttSvc, deviceID, *dm) + + for { + // MQTT业务 + err := biz.Start() + if err != nil { + log.Error("business service start failed: " + err.Error()) + fmt.Println("业务启动失败,5秒后重试...") + time.Sleep(5 * time.Second) + continue + } + break + } + // 第一次运行直接启动 + biz.handleRestart() + + // 4️⃣ T2 超时自毁检查 + tickerLost := time.NewTicker(10 * time.Second) + defer tickerLost.Stop() + go func() { + for range tickerLost.C { + cfg := config.LoadConfig() + now := time.Now().Unix() + if cfg.ControlState == "DEGRADED" && now-cfg.LastAliveAt > config.DAEMON_ALIVE_GAP_SECONDS { + log.Println("[main] MQTT 长期失联,进入 CONTROL_LOST") + cfg.ControlState = "CONTROL_LOST" + cfg.LastAliveAt = time.Now().Unix() + if err := config.WriteLocalConfig(cfg); err != nil { + log.Println("[main] 写状态失败:", err) + } + // 停止业务容器 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + dm.StopAndRemoveContainer(ctx) + // 自杀退出,让 daemon 重启 + os.Exit(1) + } + } + }() + + <-make(chan struct{}) +} diff --git a/main/main_factory.go b/main/main_factory.go new file mode 100644 index 0000000..8112abb --- /dev/null +++ b/main/main_factory.go @@ -0,0 +1,7 @@ +package main + +import "sentinel/pkg/platform" + +func newUpdaterLauncher() platform.UpdaterLauncher { + return &updaterLauncher{} +} diff --git a/main/main_linux.go b/main/main_linux.go new file mode 100644 index 0000000..a79f490 --- /dev/null +++ b/main/main_linux.go @@ -0,0 +1,20 @@ +//go:build linux +// +build linux + +package main + +import ( + "os" + "os/exec" +) + +type updaterLauncher struct{} + +func (u *updaterLauncher) Start(args []string) error { + cmd := exec.Command("./updater", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Linux 下先不做进程组处理,保证能跑 + return cmd.Start() +} diff --git a/main/main_windows.go b/main/main_windows.go new file mode 100644 index 0000000..94206df --- /dev/null +++ b/main/main_windows.go @@ -0,0 +1,24 @@ +//go:build windows +// +build windows + +package main + +import ( + "os" + "os/exec" + "syscall" +) + +type updaterLauncher struct{} + +func (u *updaterLauncher) Start(args []string) error { + cmd := exec.Command("./updater.exe", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } + + return cmd.Start() +} diff --git a/main/mqtt_service.go b/main/mqtt_service.go new file mode 100644 index 0000000..2d9fdb6 --- /dev/null +++ b/main/mqtt_service.go @@ -0,0 +1,145 @@ +package main + +import ( + "crypto/tls" + "sentinel/pkg/config" + "sentinel/pkg/log" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +type MQTTService struct { + client mqtt.Client + handler func(topic string, payload []byte) + subscriptions map[string]struct{} // 记录已订阅 topic +} + +func NewMQTTService(broker, clientID, username, password string, keepalive int) *MQTTService { + opts := mqtt.NewClientOptions() + opts.AddBroker(broker) + opts.SetClientID(clientID) + opts.SetUsername(username) + opts.SetPassword(password) + opts.AutoReconnect = true + opts.OnReconnecting = func(c mqtt.Client, opts *mqtt.ClientOptions) { + log.Println("正在重连到物联网服务器...") + } + opts.SetKeepAlive(time.Duration(keepalive) * time.Second) + opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) + // 设置遗嘱消息 + lwtTopic, lwtPayload := getStatusInfoTopicPayload(false) + opts.SetWill( + lwtTopic, // topic + lwtPayload, // payload + 1, // qos + false, // retained + ) + ms := &MQTTService{subscriptions: make(map[string]struct{})} + opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) { + if ms.handler != nil { + ms.handler(m.Topic(), m.Payload()) + } + }) + opts.OnConnect = func(c mqtt.Client) { + log.Println("物联网服务器已连接") + cfg := config.LoadConfig() + cfg.ControlState = "CONNECTED" + cfg.LastAliveAt = time.Now().Unix() + config.WriteLocalConfig(cfg) + // 连接成功 发送状态信息 + topic, payload := getStatusInfoTopicPayload(true) + ms.PublicMsg(topic, payload, false) + } + opts.OnConnectionLost = func(c mqtt.Client, err error) { + log.Println("物联网服务器已断开:", err) + cfg := config.LoadConfig() + cfg.ControlState = "DEGRADED" // 表示短暂断开 + cfg.LastAliveAt = time.Now().Unix() // 更新这个时间戳,让 daemon 与 主程序看到尚可忍耐 + + if err := config.WriteLocalConfig(cfg); err != nil { + log.Println("[main] 写状态失败:", err) + } + } + + ms.client = mqtt.NewClient(opts) + return ms +} + +func (m *MQTTService) Connect() error { + token := m.client.Connect() + if token.Wait() && token.Error() != nil { + return token.Error() + } + return nil +} + +func (m *MQTTService) Subscribe(topic string, qos byte) error { + token := m.client.Subscribe(topic, qos, nil) + if token.Wait() && token.Error() != nil { + return token.Error() + } + log.Debug("物联网服务消息订阅:", topic) + return nil +} + +func (m *MQTTService) Publish(topic string, qos byte, retained bool, payload interface{}) error { + token := m.client.Publish(topic, qos, retained, payload) + token.Wait() + return token.Error() +} + +func (m *MQTTService) SetMessageHandler(h func(topic string, payload []byte)) { + m.handler = h +} + +func (m *MQTTService) Close() { + if m.client != nil { + m.client.Disconnect(250) + } +} + +func (m *MQTTService) UnsubscribeTopic(topic string) error { + token := m.client.Unsubscribe(topic) + go func() { + if token.Wait() && token.Error() != nil { + log.Println("取消订阅失败:", token.Error()) + } else { + delete(m.subscriptions, topic) + log.Debug("取消订阅成功:", topic) + } + }() + return nil +} + +// UnsubscribeAll 取消所有已订阅 topic +func (m *MQTTService) UnsubscribeAll() { + for topic := range m.subscriptions { + _ = m.client.Unsubscribe(topic) + delete(m.subscriptions, topic) + } +} + +// SubscribeTopic 订阅指定 topic,并记录可取消 +func (m *MQTTService) SubscribeTopic(topic string, qos byte) error { + token := m.client.Subscribe(topic, qos, nil) + go func() { + if token.Wait() && token.Error() != nil { + log.Println("订阅失败:", token.Error()) + } else { + m.subscriptions[topic] = struct{}{} + log.Debug("订阅成功:", topic) + } + }() + return nil +} + +func (m *MQTTService) PublicMsg(topic string, payload string, retained bool) { + qos := byte(1) + log.Debug("发送消息:", topic) + if err := m.Publish(topic, qos, retained, payload); err != nil { + log.Println("发送信息出错:", err) + } else { + log.Debug("发送信息:", string(payload)) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..3249420 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,105 @@ +package config + +import ( + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// 常量 +const ( + // 版本号 + APP_VERSION = 29 + + BASE_URL = "https://ai.ronsunny.cn:8090" + //BASE_URL = "http://127.0.0.1:13011" + LOG_FILE_DIC = "./logs" + LOG_CHECK_INTERVAL_HOURS = 10 // 文件轮询检查间隔(秒) + MQTT_BROKER = "tls://ai.ronsunny.cn:8093" + PASSWORD = "123456" + + DOCKER_REGISTRY = "ai.ronsunny.cn:13011" + DOCKER_USERNAME = "iot_device" + DOCKER_PASSWORD = "Bbit000000" + DOCKER_IMAGE = "ai.ronsunny.cn:13011/bbit_iot/ce_sentinel" + DOCKER_CONTAINER_NAME = "BBIT_Project" + DOCKER_TIME_OUT = 30 * time.Second + + DAEMON_UPDATE_CHECK_INTERVAL_SECONDS = 5 // 文件轮询检查间隔(秒) + DAEMON_ALIVE_GAP_SECONDS = 30 +) + +var ( + DOCKER_CONTAINER_BINDS = []string{ + "/opt/sentinel/logs:/app/logs:rw", + } +) + +type AppConfig struct { + VersionCode int `yaml:"version_code"` + PID int `yaml:"pid"` + NeedUpdate bool `yaml:"need_update"` + /** + 状态 语义 + CONNECTED MQTT 已连,设备可控 + DEGRADED MQTT 短暂失联,容忍期 + CONTROL_LOST MQTT 长期失联,不应继续运行 + */ + ControlState string `yaml:"control_state"` // CONNECTED / DEGRADED / CONTROL_LOST + LastAliveAt int64 `yaml:"last_alive_at"` // Unix 秒 +} + +func LoadConfig() *AppConfig { + cfg := &AppConfig{} + if err := loadLocalConfig(cfg); err != nil { + print("[daemon] 加载配置失败,当作新状态处理:", err) + } + return cfg +} + +func loadLocalConfig[T any](cfg *T) error { + exePath, err := os.Executable() + if err != nil { + return err + } + dir := filepath.Dir(exePath) + cfgPath := filepath.Join(dir, "config.yaml") + + // 不存在就创建一个默认的 + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + cfg := &AppConfig{ + VersionCode: -1, + PID: -1, + NeedUpdate: false, + } + return WriteLocalConfig(cfg) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + return err + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return err + } + + return nil +} +func WriteLocalConfig(cfg any) error { + exePath, err := os.Executable() + if err != nil { + return err + } + dir := filepath.Dir(exePath) + cfgPath := filepath.Join(dir, "config.yaml") + + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + + return os.WriteFile(cfgPath, data, 0644) +} diff --git a/pkg/device/deviceid.go b/pkg/device/deviceid.go new file mode 100644 index 0000000..b75849d --- /dev/null +++ b/pkg/device/deviceid.go @@ -0,0 +1,40 @@ +package device + +import ( + "net" + "os" + "strings" +) + +// GetDeviceID 返回本机原始唯一ID(Linux /etc/machine-id 或 hostname+MAC) +func GetDeviceID() string { + // 尝试读取 Linux /etc/machine-id + if data, err := os.ReadFile("/etc/machine-id"); err == nil { + s := strings.TrimSpace(string(data)) + if s != "" { + return s + } + } + + // fallback: hostname + first non零MAC + hn, _ := os.Hostname() + mac := getFirstMac() + return hn + "|" + mac +} + +func getFirstMac() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + for _, it := range ifaces { + if len(it.HardwareAddr) == 0 { + continue + } + mac := it.HardwareAddr.String() + if mac != "" && mac != "00:00:00:00:00:00" { + return mac + } + } + return "" +} diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go new file mode 100644 index 0000000..e3c984e --- /dev/null +++ b/pkg/docker/docker.go @@ -0,0 +1,184 @@ +package docker + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "sentinel/pkg/config" + "sentinel/pkg/device" + "time" + + "sentinel/pkg/log" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/client" +) + +type DockerManager struct { + registry string + username string + password string + cli *client.Client + timeout time.Duration +} + +func NewDockerManager() (*DockerManager, error) { + cli, err := client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, err + } + + return &DockerManager{ + cli: cli, + registry: config.DOCKER_REGISTRY, + username: config.DOCKER_USERNAME, + password: config.DOCKER_PASSWORD, + timeout: config.DOCKER_TIME_OUT, + }, nil +} + +func (d *DockerManager) PullImage(ctx context.Context, refStr string) error { + auth := registry.AuthConfig{ + ServerAddress: d.registry, + Username: d.username, + Password: d.password, + } + authBytes, _ := json.Marshal(auth) + authStr := base64.StdEncoding.EncodeToString(authBytes) + + reader, err := d.cli.ImagePull(ctx, refStr, image.PullOptions{ + RegistryAuth: authStr, + }) + if err != nil { + return err + } + defer reader.Close() + // 消费输出,避免挂起 + _, _ = io.Copy(io.Discard, reader) + return nil +} + +func (d *DockerManager) RunContainer(ctx context.Context) error { + // 停止并删除已有容器 + if err := d.StopAndRemoveContainer(ctx); err != nil { + return err + } + // 创建容器 + log.Println("正在启动名为<", config.DOCKER_CONTAINER_NAME, ">的容器") + resp, err := d.cli.ContainerCreate( + ctx, + &container.Config{ + Image: config.DOCKER_IMAGE, + Env: []string{ + "DEVICE_ID=" + device.GetDeviceID(), + "LD_LIBRARY_PATH=/opt/nvidia/deepstream/deepstream/lib" + + ":/opt/nvidia/deepstream/deepstream/lib/triton" + + ":/opt/nvidia/deepstream/deepstream/lib/rivermax" + + ":/opt/nvidia/vpi3/lib/aarch64-linux-gnu" + + ":/usr/lib/aarch64-linux-gnu" + + ":/usr/lib/aarch64-linux-gnu/nvidia" + + ":/usr/local/cuda-12.6/lib64", + "GST_PLUGIN_PATH=/opt/nvidia/deepstream/deepstream/lib/gst-plugins", + }, + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD-SHELL", "echo ok"}, + Interval: 5 * time.Second, + Timeout: 2 * time.Second, + Retries: 3, + }, + }, + &container.HostConfig{ + Runtime: "nvidia", + Privileged: true, + Binds: []string{ + "/usr/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu:ro", + "/opt/nvidia/deepstream/deepstream/lib:/opt/nvidia/deepstream/deepstream/lib:ro", + "/opt/nvidia/vpi3/lib/aarch64-linux-gnu/:/opt/nvidia/vpi3/lib/aarch64-linux-gnu/:ro", + "/usr/local/cuda-12.6/lib64/:/usr/local/cuda-12.6/lib64/:ro", + "/tmp/argus_socket:/tmp/argus_socket", + }, + NetworkMode: "host", + }, + nil, + nil, + config.DOCKER_CONTAINER_NAME, + ) + if err != nil { + return err + } + + // 启动容器 + if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + return err + } + log.Println("容器已成功运行") + return nil +} + +// StopAndRemoveContainer 停止并删除指定容器 +func (d *DockerManager) StopAndRemoveContainer(ctx context.Context) error { + + containers, err := d.cli.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters.NewArgs( + filters.KeyValuePair{Key: "name", Value: config.DOCKER_CONTAINER_NAME}, + ), + }) + if err != nil { + return err + } + + for _, c := range containers { + log.Println("正在停止名为<", config.DOCKER_CONTAINER_NAME, ">的容器:", c.ID) + timeout := 10 * time.Second + seconds := int(timeout.Seconds()) + if err := d.cli.ContainerStop(ctx, c.ID, container.StopOptions{ + Timeout: &seconds, + }); err != nil { + log.Println("Failed to stop container %s: %v", c.ID, err) + return err + } + if err := d.cli.ContainerRemove(ctx, c.ID, container.RemoveOptions{}); err != nil { + log.Println("Failed to remove container %s: %v", c.ID, err) + return err + } + } + + return nil +} + +func (d *DockerManager) CheckContainerHealth(ctx context.Context, containerName string) (string, error) { + if containerName == "" { + return "", errors.New("containerName must not be empty") + } + containers, err := d.cli.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters.NewArgs( + filters.KeyValuePair{Key: "name", Value: containerName}, + ), + }) + if err != nil { + return "", err + } + if len(containers) == 0 { + return "", errors.New("container not found") + } + + inspect, err := d.cli.ContainerInspect(ctx, containers[0].ID) + if err != nil { + return "", err + } + + if inspect.State.Health != nil { + return inspect.State.Health.Status, nil + } + return "unknown", nil +} diff --git a/pkg/log/logger.go b/pkg/log/logger.go new file mode 100644 index 0000000..9f7a30f --- /dev/null +++ b/pkg/log/logger.go @@ -0,0 +1,110 @@ +package log + +import ( + "fmt" + "os" + "path/filepath" + "sentinel/pkg/config" + "time" +) + +var logDir = "./logs" // 日志目录,可根据需要修改 + +// 初始化日志目录 +func Init(dir string) { + if dir != "" { + logDir = dir + } + if err := os.MkdirAll(logDir, 0755); err != nil { + fmt.Println("create log dir failed:", err) + } + go func() { + ticker := time.NewTicker(config.LOG_CHECK_INTERVAL_HOURS * time.Hour) + defer ticker.Stop() + for range ticker.C { + cleanupOldLogs() + } + }() + +} + +// Cleanup 删除超过7天的日志文件 +func cleanupOldLogs() { + files, err := os.ReadDir(logDir) + if err != nil { + return + } + cutoff := time.Now().AddDate(0, 0, -7) + for _, f := range files { + if f.IsDir() { + continue + } + info, err := f.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(logDir, f.Name())) + } + } +} + +// log 内部写文件 +func logToFile(level, msg string) { + fmt.Println(msg) + t := time.Now() + // 确保日志目录存在 + if err := os.MkdirAll(logDir, 0755); err != nil { + fmt.Println("create log dir failed:", err) + return + } + + filename := filepath.Join(logDir, t.Format("2006-01-02")+".log") + f, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + fmt.Println("open log file failed:", err) + return + } + defer f.Close() + + line := fmt.Sprintf("%s [%s] %s\n", t.Format("2006-01-02 15:04:05"), level, msg) + _, _ = f.WriteString(line) +} + +// 对外接口 +func Info(msg string) { + logToFile("INFO", msg) +} + +// Println 支持多个参数拼接,写 INFO 日志 +func Println(v ...interface{}) { + msg := fmt.Sprint(v...) + logToFile("INFO", msg) +} + +func Debug(v ...interface{}) { + //msg := fmt.Sprint(v...) + //logToFile("DEBUF", msg) +} +func Warn(msg string) { + logToFile("WARN", msg) +} + +func Error(msg string) { + logToFile("ERROR", msg) +} +func Fatal(err error) { + if err == nil { + return + } + logToFile("ERROR", err.Error()) +} + +// Fatal 打印错误日志并退出程序 +func Fatalf(msg string, args ...interface{}) { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + logToFile("FATAL", msg) + os.Exit(1) +} diff --git a/pkg/model/BaseResponse.go b/pkg/model/BaseResponse.go new file mode 100644 index 0000000..7b33824 --- /dev/null +++ b/pkg/model/BaseResponse.go @@ -0,0 +1,7 @@ +package model + +type BaseResponse struct { + Status bool `json:"status"` // 是否成功 + Message string `json:"message"` // 提示信息 + Data interface{} `json:"data,omitempty"` // 泛型数据,用 interface{} 接收任意类型 +} diff --git a/pkg/model/MqttTopic.go b/pkg/model/MqttTopic.go new file mode 100644 index 0000000..ca0627d --- /dev/null +++ b/pkg/model/MqttTopic.go @@ -0,0 +1,64 @@ +package model + +import ( + "errors" + "strings" +) + +type MqttTopic struct { + DeptId string + Domain string + DeviceType string + DeviceID string + Resource string +} + +// 从字符串解析成 MqttTopic +func FromStringToMqttTopic(topic string) *MqttTopic { + parts := strings.Split(topic, "/") + // 补齐不足的部分 + for len(parts) < 5 { + parts = append(parts, "") + } + return &MqttTopic{ + DeptId: parts[0], + Domain: parts[1], + DeviceType: parts[2], + DeviceID: parts[3], + Resource: parts[4], + } +} + +// 从结构体生成 topic 字符串,可用 "+" 表示通配符 +func (m *MqttTopic) ToString() string { + toVal := func(s string) string { + if s == "" { + return "+" + } + return s + } + return strings.Join([]string{ + toVal(m.DeptId), + toVal(m.Domain), + toVal(m.DeviceType), + toVal(m.DeviceID), + toVal(m.Resource), + }, "/") +} + +// 严格生成 topic,不允许 "+" 或空 +func (m *MqttTopic) Build() (string, error) { + parts := []string{m.DeptId, m.Domain, m.DeviceType, m.DeviceID, m.Resource} + for _, p := range parts { + if p == "" || p == "+" { + return "", errors.New("cannot build strict topic, wildcard exists") + } + } + return strings.Join(parts, "/"), nil +} + +// 判断是否为通配符 topic +func (m *MqttTopic) IsWildcard() bool { + topic := m.ToString() + return strings.Contains(topic, "+") || strings.Contains(topic, "#") +} diff --git a/pkg/model/UpdateInfo.go b/pkg/model/UpdateInfo.go new file mode 100644 index 0000000..6019812 --- /dev/null +++ b/pkg/model/UpdateInfo.go @@ -0,0 +1,7 @@ +package model + +type UpdateInfo struct { + Version int `json:"version"` + DownloadURL string `json:"url"` + Notes string `json:"notes"` +} diff --git a/pkg/net/api.go b/pkg/net/api.go new file mode 100644 index 0000000..8de6cf7 --- /dev/null +++ b/pkg/net/api.go @@ -0,0 +1,22 @@ +package api + +import "sentinel/pkg/model" + +const ( + updateCheckURL = "/iot/common/update/check" +) + +func CheckUpdate(deviceID string) (*model.UpdateInfo, error) { + var resp model.UpdateInfo + err := Get( + updateCheckURL, + map[string]string{ + "deviceID": deviceID, + }, + &resp, + ) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/pkg/net/httpclient.go b/pkg/net/httpclient.go new file mode 100644 index 0000000..1cac3bf --- /dev/null +++ b/pkg/net/httpclient.go @@ -0,0 +1,101 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sentinel/pkg/model" + "time" + + "sentinel/pkg/config" + "sentinel/pkg/log" +) + +var client = &http.Client{ + Timeout: 5 * time.Second, +} + +func Get(path string, query map[string]string, out any) error { + return do(http.MethodGet, path, query, nil, out) +} + +func Post(path string, body any, out any) error { + return do(http.MethodPost, path, nil, body, out) +} + +func do(method, path string, query map[string]string, body any, out any) error { + u, err := url.Parse(config.BASE_URL + path) + if err != nil { + log.Error("parse url failed: " + err.Error()) + return err + } + + if len(query) > 0 { + q := u.Query() + for k, v := range query { + q.Set(k, v) + } + u.RawQuery = q.Encode() + } + + var reqBody *bytes.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + log.Error("marshal body failed: " + err.Error()) + return err + } + reqBody = bytes.NewReader(b) + } else { + reqBody = bytes.NewReader(nil) + } + + req, err := http.NewRequest(method, u.String(), reqBody) + if err != nil { + log.Error("create request failed: " + err.Error()) + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("apikey", "NzusyzcLIUoZ22tflHN2sOjHrry3W7zJ") + + resp, err := client.Do(req) + if err != nil { + log.Error("request failed: " + err.Error()) + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Error("http status error: " + resp.Status) + return fmt.Errorf("http status %d", resp.StatusCode) + } + + // 解析成 BaseResponse + var baseResp model.BaseResponse + if err := json.NewDecoder(resp.Body).Decode(&baseResp); err != nil { + log.Error("decode base response failed: " + err.Error()) + return err + } + + if !baseResp.Status { + log.Error("server returned error: " + baseResp.Message) + return fmt.Errorf(baseResp.Message) + } + + if out != nil && baseResp.Data != nil { + // 将 Data 转成业务类型 + b, err := json.Marshal(baseResp.Data) + if err != nil { + log.Error("marshal data failed: " + err.Error()) + return err + } + if err := json.Unmarshal(b, out); err != nil { + log.Error("unmarshal data to out failed: " + err.Error()) + return err + } + } + + return nil +} diff --git a/pkg/platform/Platform_interface.go b/pkg/platform/Platform_interface.go new file mode 100644 index 0000000..fd0162e --- /dev/null +++ b/pkg/platform/Platform_interface.go @@ -0,0 +1,15 @@ +package platform + +type UpdaterLauncher interface { + Start(args []string) error +} + +type MainProgramStarter interface { + Start(targetExe string) error + + GetMainName() string + + IsProcessRunning(pid int) (bool, error) + + KillProcess(pid int) error +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..a6acffc --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,117 @@ +package utils + +import ( + "encoding/json" + "fmt" + "net" + "os" + "runtime" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/mem" +) + +func GetLocalIP() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 { + continue // 接口未启用 + } + if iface.Flags&net.FlagLoopback != 0 { + continue // 忽略回环 + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() { + continue + } + ip = ip.To4() + if ip == nil { + continue + } + return ip.String() + } + } + return "" +} + +func GetHostname() string { + name, err := os.Hostname() + if err != nil { + return "" + } + return name +} +func GetMacAddress() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + mac := iface.HardwareAddr.String() + if mac != "" { + return mac + } + } + return "" +} + +func GetOSInfo() string { + return runtime.GOOS // windows, linux, darwin +} +func GetCPUInfo() string { + info, err := cpu.Info() + if err != nil || len(info) == 0 { + return "" + } + return info[0].ModelName +} + +func GetMemory() string { + v, err := mem.VirtualMemory() + if err != nil { + return "" + } + return fmt.Sprintf("%dMB", v.Total/1024/1024) +} + +func GetDisk() string { + usage, err := disk.Usage("/") + if err != nil { + return "" + } + return fmt.Sprintf("%dGB", usage.Total/1024/1024/1024) +} + +func PayloadToMap(payload []byte) (map[string]interface{}, error) { + var result map[string]interface{} + err := json.Unmarshal(payload, &result) + if err != nil { + return nil, err + } + return result, nil +} +func JSONToString(data map[string]interface{}) string { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "" + } + return string(jsonBytes) +} diff --git a/updater/main.go b/updater/main.go new file mode 100644 index 0000000..f2a3652 --- /dev/null +++ b/updater/main.go @@ -0,0 +1,189 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sentinel/pkg/config" + "sentinel/pkg/device" + "sentinel/pkg/log" + "sentinel/pkg/net" + "sentinel/pkg/platform" + "time" +) + +func main() { + log.Println("[daemon] 设备ID:", device.GetDeviceID()) + + log.Init(config.LOG_FILE_DIC) + + starter := newMainProgramStarter() + // 启动主程序 + initMainProcess(starter) + ticker := time.NewTicker(config.DAEMON_UPDATE_CHECK_INTERVAL_SECONDS * time.Second) + defer ticker.Stop() + + go func() { + for range ticker.C { + //log.Println("[daemon] 定时检查主程序运行状态") + handleProcessCheck(starter) + + cfg := config.LoadConfig() + if cfg.NeedUpdate { + log.Println("[daemon] 配置文件标记需要更新,触发更新") + cfg.NeedUpdate = false + if err := config.WriteLocalConfig(cfg); err != nil { + log.Println("[daemon] 写回配置文件失败:", err) + } + handleUpdate(starter) + } + } + }() + select {} +} + +func initMainProcess(starter platform.MainProgramStarter) { + if err := startMainProgram(starter); err != nil { + log.Println("[daemon] 启动失败,回退到更新:", err) + handleUpdate(starter) + } +} + +var lastState string +var lastPID int + +func handleProcessCheck(starter platform.MainProgramStarter) { + cfg := config.LoadConfig() + + // 1. PID 是否存在 + running, err := starter.IsProcessRunning(cfg.PID) + if cfg.PID <= 0 || err != nil || !running { + if lastState != "NOT_RUNNING" { + log.Println("[daemon] 主程序未运行,尝试启动") + lastState = "NOT_RUNNING" + lastPID = 0 + } + restartMain(starter) + return + } + + // 2. 语义健康判断 + switch cfg.ControlState { + case "DEGRADED": + if lastState != "DEGRADED" { + log.Println("[daemon] 主程序存在错误,等待主程序自行处理") + lastState = "DEGRADED" + lastPID = cfg.PID + } + return + case "CONTROL_LOST": + if lastState != "CONTROL_LOST" { + log.Println("[daemon] 主程序心跳超时,已失控,强制重启") + lastState = "CONTROL_LOST" + lastPID = cfg.PID + } + restartMain(starter) + return + default: + // 3. 健康 + if lastState != "HEALTHY" || lastPID != cfg.PID { + log.Println("[daemon] 主程序健康运行,PID:", cfg.PID) + lastState = "HEALTHY" + lastPID = cfg.PID + } + } +} + +func restartMain(starter platform.MainProgramStarter) { + cfg := config.LoadConfig() + if cfg.PID > 0 { + _ = starter.KillProcess(cfg.PID) + time.Sleep(300 * time.Millisecond) + } + _ = startMainProgram(starter) +} + +func handleUpdate(starter platform.MainProgramStarter) { + cfg := config.LoadConfig() + info, err := api.CheckUpdate(device.GetDeviceID()) + if err != nil { + log.Println("[daemon] 检查更新失败:", err) + return + } + + if info.Version <= cfg.VersionCode { + log.Println("[daemon] 没有新版本,跳过") + return + } + + log.Println("[daemon] 发现新版本 %d\n", info.Version) + + // 停止主程序 + if cfg.PID > 0 { + _ = starter.KillProcess(cfg.PID) + time.Sleep(500 * time.Millisecond) + } + + if err := downloadAndReplace(starter, info.DownloadURL); err != nil { + log.Println("[daemon] 更新失败:", err) + return + } + + if err := startMainProgram(starter); err != nil { + log.Println("[daemon] 更新后启动失败:", err) + return + } + + log.Println("[daemon] 更新完成") +} + +func startMainProgram(starter platform.MainProgramStarter) error { + selfPath, _ := os.Executable() + selfDir := filepath.Dir(selfPath) + targetExe := filepath.Join(selfDir, starter.GetMainName()) + + if _, err := os.Stat(targetExe); os.IsNotExist(err) { + log.Println("[daemon] 主程序文件不存在,触发更新") + handleUpdate(starter) + return nil // 先不启动,等更新完再启动 + } + + err := starter.Start(targetExe) + return err +} + +func downloadAndReplace( + starter platform.MainProgramStarter, + downloadURL string, +) error { + // 获取主程序路径 + selfPath, err := os.Executable() + if err != nil { + return fmt.Errorf("获取自身路径失败: %w", err) + } + selfDir := filepath.Dir(selfPath) + targetExe := filepath.Join(selfDir, starter.GetMainName()) + outFile, err := os.Create(targetExe) + if err != nil { + return fmt.Errorf("创建目标文件失败: %w", err) + } + defer outFile.Close() + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(downloadURL) + if err != nil { + return fmt.Errorf("下载新程序失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载失败, HTTP状态码: %d", resp.StatusCode) + } + + if _, err := io.Copy(outFile, resp.Body); err != nil { + return fmt.Errorf("写入目标文件失败: %w", err) + } + return nil +} diff --git a/updater/main_factory.go b/updater/main_factory.go new file mode 100644 index 0000000..3792836 --- /dev/null +++ b/updater/main_factory.go @@ -0,0 +1,7 @@ +package main + +import "sentinel/pkg/platform" + +func newMainProgramStarter() platform.MainProgramStarter { + return &mainProgramStarter{} +} diff --git a/updater/main_linux.go b/updater/main_linux.go new file mode 100644 index 0000000..9b02961 --- /dev/null +++ b/updater/main_linux.go @@ -0,0 +1,54 @@ +//go:build linux +// +build linux + +package main + +import ( + "os" + "os/exec" + "strconv" +) + +type mainProgramStarter struct{} + +func (s *mainProgramStarter) GetMainName() string { + return "main" +} + +func (s *mainProgramStarter) Start(targetExe string) error { + if err := os.Chmod(targetExe, 0755); err != nil { + return err + } + cmd := exec.Command(targetExe) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Linux 下:先保持最简单,保证能跑 + return cmd.Start() +} + +// 判断进程是否在运行 +func (s *mainProgramStarter) IsProcessRunning(pid int) (bool, error) { + if pid <= 0 { + return false, nil + } + + procPath := "/proc/" + strconv.Itoa(pid) + _, err := os.Stat(procPath) + if err == nil { + return true, nil + } + + if os.IsNotExist(err) { + return false, nil + } + + // 其他错误(例如权限问题) + return false, err +} + +// 杀掉进程 +func (l *mainProgramStarter) KillProcess(pid int) error { + cmd := exec.Command("kill", "-9", strconv.Itoa(pid)) + return cmd.Run() +} diff --git a/updater/main_windows.go b/updater/main_windows.go new file mode 100644 index 0000000..17922fd --- /dev/null +++ b/updater/main_windows.go @@ -0,0 +1,63 @@ +//go:build windows +// +build windows + +package main + +import ( + "os" + "os/exec" + "strconv" + "syscall" + + "golang.org/x/sys/windows" +) + +type mainProgramStarter struct{} + +func (s *mainProgramStarter) GetMainName() string { + return "main.exe" +} + +func (s *mainProgramStarter) Start(targetExe string) error { + cmd := exec.Command(targetExe) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Windows:新进程组,脱离 Ctrl+C / 父进程 + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } + + return cmd.Start() +} + +// 判断进程是否在运行 +func (s *mainProgramStarter) IsProcessRunning(pid int) (bool, error) { + if pid <= 0 { + return false, nil + } + + // 打开进程句柄 + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + // 权限不足,但进程存在 + if err == windows.ERROR_ACCESS_DENIED { + return true, nil + } + // 进程不存在 + if err == windows.ERROR_INVALID_PARAMETER { + return false, nil + } + // 其他错误 + return false, err + } + defer windows.CloseHandle(handle) + + return true, nil +} + +// 杀掉进程 +func (w *mainProgramStarter) KillProcess(pid int) error { + cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F") + return cmd.Run() +}