mirror of
https://github.com/acepanel/helper.git
synced 2026-02-03 17:47:16 +08:00
feat: init
This commit is contained in:
44
.github/workflows/build.yml
vendored
Normal file
44
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goarch: [ amd64, arm64 ]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
cache: true
|
||||
go-version: 'stable'
|
||||
- name: Install dependencies
|
||||
run: go mod tidy
|
||||
- name: Build ${{ matrix.goarch }}
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: linux
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
LDFLAGS="-s -w --extldflags '-static'"
|
||||
go build -trimpath -buildvcs=false -ldflags "${LDFLAGS}" -o helper-${{ matrix.goarch }} ./cmd/helper
|
||||
- name: Compress ${{ matrix.goarch }}
|
||||
run: |
|
||||
upx --best --lzma helper-${{ matrix.goarch }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: helper-${{ matrix.goarch }}
|
||||
path: |
|
||||
helper-${{ matrix.goarch }}
|
||||
27
.github/workflows/goreleaser.yml
vendored
Normal file
27
.github/workflows/goreleaser.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
cache: true
|
||||
go-version: 'stable'
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
47
.github/workflows/l10n.yml
vendored
Normal file
47
.github/workflows/l10n.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: L10n
|
||||
on:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: l10n
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
l10n:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
cache: true
|
||||
go-version: 'stable'
|
||||
- name: Install gettext
|
||||
run: |
|
||||
sudo apt-get install -y gettext
|
||||
- name: Install xgotext
|
||||
run: |
|
||||
go install github.com/leonelquinteros/gotext/cli/xgotext@latest
|
||||
- name: Generate pot files
|
||||
run: |
|
||||
~/go/bin/xgotext -default helper -pkg-tree ./cmd/helper -out ./pkg/embed/locales
|
||||
- uses: stefanzweifel/git-auto-commit-action@v7
|
||||
name: Commit changes
|
||||
with:
|
||||
commit_message: "chore(l10n): update pot files"
|
||||
- name: Sync with Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
export_only_approved: true
|
||||
create_pull_request: true
|
||||
pull_request_title: 'l10n: sync translations with Crowdin'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
40
.github/workflows/lint.yml
vendored
Normal file
40
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Lint
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
golangci:
|
||||
name: golanci-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
cache: true
|
||||
go-version: 'stable'
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
skip-cache: true
|
||||
version: latest
|
||||
args: --timeout=30m ./...
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
cache: true
|
||||
go-version: 'stable'
|
||||
- name: Install Govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- name: Run Govulncheck
|
||||
run: govulncheck ./...
|
||||
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# `go test -c` 生成的二进制文件
|
||||
*.test
|
||||
|
||||
# go coverage 工具
|
||||
*.out
|
||||
*.prof
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
# 编译文件
|
||||
/helper
|
||||
*.com
|
||||
*.class
|
||||
*.dll
|
||||
*.exe
|
||||
*.o
|
||||
*.so
|
||||
|
||||
# 压缩包
|
||||
# Git 自带压缩,如果这些压缩包里有代码,建议解压后 commit
|
||||
*.7z
|
||||
*.dmg
|
||||
*.gz
|
||||
*.iso
|
||||
*.jar
|
||||
*.rar
|
||||
*.tar
|
||||
*.zip
|
||||
|
||||
# 日志文件和数据库及配置
|
||||
*.log
|
||||
*.sqlite
|
||||
*.db
|
||||
config.yml
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
.tmp/
|
||||
|
||||
# 系统生成文件
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
.TemporaryItems
|
||||
.fseventsd
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# IDE 和编辑器
|
||||
.idea/
|
||||
/go_build_*
|
||||
out/
|
||||
.vscode/
|
||||
.vscode/settings.json
|
||||
*.sublime*
|
||||
__debug_bin
|
||||
.project
|
||||
42
.goreleaser.yaml
Normal file
42
.goreleaser.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
version: 2
|
||||
project_name: helper
|
||||
|
||||
builds:
|
||||
- id: helper
|
||||
main: ./cmd/helper
|
||||
binary: helper
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
flags:
|
||||
- -trimpath
|
||||
- -buildvcs=false
|
||||
ldflags:
|
||||
- -s -w --extldflags "-static"
|
||||
|
||||
upx:
|
||||
- enabled: true
|
||||
# Filter by build ID.
|
||||
ids:
|
||||
- helper
|
||||
# Compress argument.
|
||||
# Valid options are from '1' (faster) to '9' (better), and 'best'.
|
||||
compress: best
|
||||
# Whether to try LZMA (slower).
|
||||
lzma: true
|
||||
# Whether to try all methods and filters (slow).
|
||||
brute: false
|
||||
|
||||
archives:
|
||||
- id: helper
|
||||
ids:
|
||||
- helper
|
||||
formats: ["zip"]
|
||||
wrap_in_directory: false
|
||||
strip_binary_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
38
LICENSE
Normal file
38
LICENSE
Normal file
@@ -0,0 +1,38 @@
|
||||
Copyright (c) 2022-2025, AcePanel contributors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
免责声明
|
||||
|
||||
本软件是按照现有技术和条件所能达到的现状提供的,用户出于自愿而使用本软件,树新峰(耗子科技)不作任何形式的保证,包括但不限于:
|
||||
|
||||
1. 树新峰不对于因使用或无法使用本软件而造成的任何直接、间接、偶然、附带、特殊及后续的「损害」承担责任,即使已被告知此类「损害」的可能性;
|
||||
2. 树新峰无组织本软件的交流社区的义务与责任,不承担因技术交流导致某一方故障而产生的「损害」;
|
||||
3. 树新峰不对本软件的无故障、适用性、可用性、准确性、质量满意度等做任何形式的保证;
|
||||
4. 树新峰不保证本软件可以满足您的要求,也不保证其内容、服务或功能无任何错误或不会中断,不保证任何缺陷将得到更正;
|
||||
5. 树新峰对本软件提供的任何信息或建议均不构成任何担保;
|
||||
6. 用户因使用本软件违反国家法律法规的,树新峰不承担任何责任;
|
||||
7. 本条款所指的「损害」包括但不限于经济损失、利润损失、收入损失、业务中断、数据丢失等。
|
||||
16
cmd/helper/main.go
Normal file
16
cmd/helper/main.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
func main() {
|
||||
helper, err := initHelper()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = helper.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
19
cmd/helper/wire.go
Normal file
19
cmd/helper/wire.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/acepanel/helper/internal/app"
|
||||
"github.com/acepanel/helper/internal/service"
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
)
|
||||
|
||||
func initHelper() (*app.Helper, error) {
|
||||
panic(wire.Build(
|
||||
system.ProviderSet,
|
||||
service.ProviderSet,
|
||||
app.ProviderSet,
|
||||
))
|
||||
}
|
||||
32
cmd/helper/wire_gen.go
Normal file
32
cmd/helper/wire_gen.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/acepanel/helper/internal/app"
|
||||
"github.com/acepanel/helper/internal/service"
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
)
|
||||
|
||||
import (
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
|
||||
func initHelper() (*app.Helper, error) {
|
||||
executor := system.NewExecutor()
|
||||
detector := system.NewDetector(executor)
|
||||
firewall := system.NewFirewall(executor, detector)
|
||||
systemd := system.NewSystemd(executor)
|
||||
userManager := system.NewUserManager(executor)
|
||||
installer := service.NewInstaller(detector, executor, firewall, systemd, userManager)
|
||||
uninstaller := service.NewUninstaller(detector, executor, systemd)
|
||||
mounter := service.NewMounter(detector, executor)
|
||||
helper := app.NewHelper(installer, uninstaller, mounter)
|
||||
return helper, nil
|
||||
}
|
||||
5
crowdin.yml
Normal file
5
crowdin.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
files:
|
||||
- source: /pkg/embed/locales/*.pot
|
||||
translation: /pkg/embed/locales/%locale_with_underscore%/%file_name%.po
|
||||
43
go.mod
Normal file
43
go.mod
Normal file
@@ -0,0 +1,43 @@
|
||||
module github.com/acepanel/helper
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/go-resty/resty/v2 v2.17.1
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/leonelquinteros/gotext v1.7.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.4 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20260116010723-b770f9f0bfed // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.7.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
)
|
||||
90
go.sum
Normal file
90
go.sum
Normal file
@@ -0,0 +1,90 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
|
||||
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20260116010723-b770f9f0bfed h1:pzOOuU7EBl363fKRy1cEpm6iwuzp0dkL9D0+ykomuIg=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20260116010723-b770f9f0bfed/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
|
||||
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/leonelquinteros/gotext v1.7.2 h1:bDPndU8nt+/kRo1m4l/1OXiiy2v7Z7dfPQ9+YP7G1Mc=
|
||||
github.com/leonelquinteros/gotext v1.7.2/go.mod h1:9/haCkm5P7Jay1sxKDGJ5WIg4zkz8oZKw4ekNpALob8=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
31
internal/app/helper.go
Normal file
31
internal/app/helper.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/acepanel/helper/internal/service"
|
||||
"github.com/acepanel/helper/internal/ui"
|
||||
)
|
||||
|
||||
type Helper struct {
|
||||
installer service.Installer
|
||||
uninstaller service.Uninstaller
|
||||
mounter service.Mounter
|
||||
}
|
||||
|
||||
func NewHelper(installer service.Installer, uninstaller service.Uninstaller, mounter service.Mounter) *Helper {
|
||||
return &Helper{
|
||||
installer: installer,
|
||||
uninstaller: uninstaller,
|
||||
mounter: mounter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Helper) Run() error {
|
||||
app := ui.NewApp(h.installer, h.uninstaller, h.mounter)
|
||||
p := tea.NewProgram(app, tea.WithAltScreen())
|
||||
app.SetProgram(p)
|
||||
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
7
internal/app/wire.go
Normal file
7
internal/app/wire.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package app
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewHelper,
|
||||
)
|
||||
467
internal/service/installer.go
Normal file
467
internal/service/installer.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// Installer 安装器接口
|
||||
type Installer interface {
|
||||
Install(ctx context.Context, cfg *types.InstallConfig, progress chan<- types.Progress) error
|
||||
}
|
||||
|
||||
type installer struct {
|
||||
detector system.Detector
|
||||
executor system.Executor
|
||||
firewall system.Firewall
|
||||
systemd system.Systemd
|
||||
userMgr system.UserManager
|
||||
}
|
||||
|
||||
// NewInstaller 创建安装器
|
||||
func NewInstaller(
|
||||
detector system.Detector,
|
||||
executor system.Executor,
|
||||
firewall system.Firewall,
|
||||
systemd system.Systemd,
|
||||
userMgr system.UserManager,
|
||||
) Installer {
|
||||
return &installer{
|
||||
detector: detector,
|
||||
executor: executor,
|
||||
firewall: firewall,
|
||||
systemd: systemd,
|
||||
userMgr: userMgr,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *installer) Install(ctx context.Context, cfg *types.InstallConfig, progress chan<- types.Progress) error {
|
||||
steps := []struct {
|
||||
name string
|
||||
weight float64
|
||||
fn func(ctx context.Context, cfg *types.InstallConfig) error
|
||||
}{
|
||||
{i18n.T().Get("Checking system requirements"), 0.05, i.checkSystem},
|
||||
{i18n.T().Get("Creating www user"), 0.02, i.createUser},
|
||||
{i18n.T().Get("Optimizing system settings"), 0.08, i.optimizeSystem},
|
||||
{i18n.T().Get("Installing dependencies"), 0.20, i.installDeps},
|
||||
{i18n.T().Get("Creating swap file"), 0.05, i.createSwap},
|
||||
{i18n.T().Get("Downloading panel"), 0.30, i.downloadPanel},
|
||||
{i18n.T().Get("Configuring firewall"), 0.10, i.configureFirewall},
|
||||
{i18n.T().Get("Creating systemd service"), 0.10, i.createService},
|
||||
{i18n.T().Get("Initializing panel"), 0.08, i.initPanel},
|
||||
{i18n.T().Get("Detecting installed apps"), 0.02, i.detectApps},
|
||||
}
|
||||
|
||||
var currentProgress float64
|
||||
for _, step := range steps {
|
||||
progress <- types.Progress{
|
||||
Step: step.name,
|
||||
Percent: currentProgress,
|
||||
Message: step.name + "...",
|
||||
}
|
||||
|
||||
if err := step.fn(ctx, cfg); err != nil {
|
||||
progress <- types.Progress{
|
||||
Step: step.name,
|
||||
Percent: currentProgress,
|
||||
Message: fmt.Sprintf("%s: %v", i18n.T().Get("Error"), err),
|
||||
IsError: true,
|
||||
Error: err,
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
currentProgress += step.weight
|
||||
progress <- types.Progress{
|
||||
Step: step.name,
|
||||
Percent: currentProgress,
|
||||
Message: step.name + " " + i18n.T().Get("completed"),
|
||||
}
|
||||
}
|
||||
|
||||
progress <- types.Progress{
|
||||
Step: i18n.T().Get("Installation complete"),
|
||||
Percent: 1.0,
|
||||
Message: i18n.T().Get("Panel installed successfully"),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) checkSystem(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 检查root权限
|
||||
if err := i.detector.CheckRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检测系统信息
|
||||
info, err := i.detector.Detect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查OS
|
||||
if info.OS == types.OSUnknown {
|
||||
return errors.New(i18n.T().Get("Unsupported operating system"))
|
||||
}
|
||||
|
||||
// 检查架构
|
||||
if info.Arch == types.ArchUnknown {
|
||||
return errors.New(i18n.T().Get("Unsupported CPU architecture"))
|
||||
}
|
||||
|
||||
// 检查CPU特性
|
||||
if err := i.detector.CheckCPUFeatures(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查内核版本
|
||||
if info.KernelVersion != "" {
|
||||
parts := strings.Split(info.KernelVersion, ".")
|
||||
if len(parts) > 0 {
|
||||
major := 0
|
||||
_, _ = fmt.Sscanf(parts[0], "%d", &major)
|
||||
if major < 4 {
|
||||
return errors.New(i18n.T().Get("Kernel version too old, requires 4.x or above"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否64位
|
||||
if !info.Is64Bit {
|
||||
return errors.New(i18n.T().Get("Requires 64-bit system"))
|
||||
}
|
||||
|
||||
// 检查是否已安装
|
||||
if i.detector.CheckPanelInstalled(cfg.SetupPath) {
|
||||
return errors.New(i18n.T().Get("Panel is already installed"))
|
||||
}
|
||||
|
||||
// 保存系统信息到配置
|
||||
cfg.InChina = info.InChina
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) createUser(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
return i.userMgr.EnsureUserAndGroup(ctx, "www", "www")
|
||||
}
|
||||
|
||||
func (i *installer) optimizeSystem(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 设置时区
|
||||
_, _ = i.executor.Run(ctx, "timedatectl", "set-timezone", "Asia/Shanghai")
|
||||
|
||||
// 禁用SELinux
|
||||
_, _ = i.executor.Run(ctx, "setenforce", "0")
|
||||
_, _ = i.executor.Run(ctx, "sed", "-i", "s/SELINUX=enforcing/SELINUX=disabled/g", "/etc/selinux/config")
|
||||
|
||||
// 系统参数优化
|
||||
_, _ = i.executor.Run(ctx, "sysctl", "-w", "vm.overcommit_memory=1")
|
||||
_, _ = i.executor.Run(ctx, "sysctl", "-w", "net.core.somaxconn=1024")
|
||||
|
||||
// 写入sysctl配置
|
||||
sysctlConf := `fs.file-max = 2147483584
|
||||
net.core.somaxconn = 1024
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
`
|
||||
_ = os.WriteFile("/etc/sysctl.d/99-panel.conf", []byte(sysctlConf), 0644)
|
||||
|
||||
// 写入limits配置
|
||||
limitsConf := `* soft nofile 1048576
|
||||
* hard nofile 1048576
|
||||
* soft nproc 1048576
|
||||
* hard nproc 1048576
|
||||
`
|
||||
// 追加到limits.conf
|
||||
f, err := os.OpenFile("/etc/security/limits.conf", os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(limitsConf)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
// 重载sysctl
|
||||
_, _ = i.executor.Run(ctx, "sysctl", "-p")
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "restart", "systemd-sysctl")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) installDeps(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
pkgMgr := system.NewPackageManager(info.OS, i.executor)
|
||||
if pkgMgr == nil {
|
||||
return errors.New(i18n.T().Get("Unsupported operating system"))
|
||||
}
|
||||
|
||||
// 设置镜像源
|
||||
if err := pkgMgr.SetMirror(ctx, cfg.InChina); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
if err := pkgMgr.UpdateCache(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 安装EPEL (RHEL系)
|
||||
if info.OS == types.OSRHEL {
|
||||
_ = pkgMgr.EnableEPEL(ctx, cfg.InChina)
|
||||
}
|
||||
|
||||
// 安装依赖
|
||||
var packages []string
|
||||
if info.OS == types.OSRHEL {
|
||||
packages = []string{"bash", "curl", "wget", "zip", "unzip", "tar", "git", "jq", "make", "sudo"}
|
||||
} else {
|
||||
packages = []string{"bash", "curl", "wget", "zip", "unzip", "tar", "git", "jq", "make", "sudo"}
|
||||
}
|
||||
|
||||
return pkgMgr.Install(ctx, packages...)
|
||||
}
|
||||
|
||||
func (i *installer) createSwap(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
|
||||
// 如果已有swap或内存>=4G,跳过
|
||||
if info.Swap > 1 || info.Memory >= 3900 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !cfg.AutoSwap {
|
||||
return nil
|
||||
}
|
||||
|
||||
swapFile := cfg.SetupPath + "/swap"
|
||||
|
||||
// 创建swap文件
|
||||
_, _ = i.executor.Run(ctx, "dd", "if=/dev/zero", "of="+swapFile, "bs=8M", "count=256")
|
||||
_, _ = i.executor.Run(ctx, "chmod", "600", swapFile)
|
||||
_, _ = i.executor.Run(ctx, "mkswap", "-f", swapFile)
|
||||
_, _ = i.executor.Run(ctx, "swapon", swapFile)
|
||||
|
||||
// 添加到fstab
|
||||
fstabEntry := fmt.Sprintf("%s swap swap defaults 0 0\n", swapFile)
|
||||
f, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(fstabEntry)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) downloadPanel(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 创建目录
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/panel", 0755)
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/server/webhook", 0755)
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/server/cron/logs", 0755)
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/projects", 0755)
|
||||
|
||||
// 获取最新版本信息
|
||||
client := resty.New()
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
Get("https://api.acepanel.net/version/latest")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to get version info"), err)
|
||||
}
|
||||
|
||||
var versionResp struct {
|
||||
Data struct {
|
||||
Version string `json:"version"`
|
||||
Downloads []struct {
|
||||
Arch string `json:"arch"`
|
||||
URL string `json:"url"`
|
||||
Checksum string `json:"checksum"`
|
||||
} `json:"downloads"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(resp.Body(), &versionResp); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to parse version info"), err)
|
||||
}
|
||||
|
||||
// 根据架构选择下载链接
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
var downloadURL string
|
||||
arch := "amd64"
|
||||
if info.Arch == types.ArchARM64 {
|
||||
arch = "arm64"
|
||||
}
|
||||
|
||||
for _, dl := range versionResp.Data.Downloads {
|
||||
if dl.Arch == arch {
|
||||
downloadURL = dl.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
return errors.New(i18n.T().Get("No download URL found for architecture %s", arch))
|
||||
}
|
||||
|
||||
// 下载面板
|
||||
zipPath := cfg.SetupPath + "/panel/panel.zip"
|
||||
resp, err = client.R().
|
||||
SetContext(ctx).
|
||||
SetOutput(zipPath).
|
||||
Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to download panel"), err)
|
||||
}
|
||||
|
||||
// 解压
|
||||
result, err := i.executor.Run(ctx, "unzip", "-o", zipPath, "-d", cfg.SetupPath+"/panel")
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to unzip panel"))
|
||||
}
|
||||
|
||||
// 删除zip文件
|
||||
_ = os.Remove(zipPath)
|
||||
|
||||
// 移动配置文件
|
||||
_ = os.Rename(cfg.SetupPath+"/panel/config.example.yml", cfg.SetupPath+"/panel/storage/config.yml")
|
||||
|
||||
// 替换配置中的路径
|
||||
_, _ = i.executor.Run(ctx, "sed", "-i", fmt.Sprintf("s|/opt/ace|%s|g", cfg.SetupPath), cfg.SetupPath+"/panel/storage/config.yml")
|
||||
|
||||
// 设置权限
|
||||
_, _ = i.executor.Run(ctx, "chmod", "-R", "700", cfg.SetupPath+"/panel")
|
||||
_, _ = i.executor.Run(ctx, "chmod", "600", cfg.SetupPath+"/panel/storage/config.yml")
|
||||
|
||||
// 移动CLI工具
|
||||
_ = os.Rename(cfg.SetupPath+"/panel/cli", "/usr/local/sbin/acepanel")
|
||||
_, _ = i.executor.Run(ctx, "chmod", "+x", "/usr/local/sbin/acepanel")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) configureFirewall(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 安装firewalld
|
||||
if err := i.firewall.Install(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 启用
|
||||
if err := i.firewall.Enable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取SSH端口
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
|
||||
// 添加端口
|
||||
ports := []struct {
|
||||
port int
|
||||
protocol string
|
||||
}{
|
||||
{22, "tcp"},
|
||||
{80, "tcp"},
|
||||
{443, "tcp"},
|
||||
{443, "udp"},
|
||||
{info.SSHPort, "tcp"},
|
||||
}
|
||||
|
||||
for _, p := range ports {
|
||||
_ = i.firewall.AddPort(ctx, p.port, p.protocol)
|
||||
}
|
||||
|
||||
// 重载
|
||||
return i.firewall.Reload(ctx)
|
||||
}
|
||||
|
||||
func (i *installer) createService(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
serviceContent := fmt.Sprintf(`[Unit]
|
||||
Description=AcePanel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s/panel/ace
|
||||
WorkingDirectory=%s/panel
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, cfg.SetupPath, cfg.SetupPath)
|
||||
|
||||
if err := i.systemd.WriteServiceFile("acepanel", serviceContent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := i.systemd.DaemonReload(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return i.systemd.Enable(ctx, "acepanel")
|
||||
}
|
||||
|
||||
func (i *installer) initPanel(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 初始化面板
|
||||
result, err := i.executor.Run(ctx, "/usr/local/sbin/acepanel", "init")
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to initialize panel"))
|
||||
}
|
||||
|
||||
// 同步
|
||||
result, err = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "sync")
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to sync panel"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) detectApps(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 检测Docker
|
||||
result, _ := i.executor.Run(ctx, "which", "docker")
|
||||
if result != nil && result.ExitCode == 0 {
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "docker")
|
||||
versionResult, _ := i.executor.Run(ctx, "docker", "-v")
|
||||
if versionResult != nil {
|
||||
parts := strings.Fields(versionResult.Stdout)
|
||||
if len(parts) >= 3 {
|
||||
version := strings.TrimSuffix(parts[2], ",")
|
||||
_, _ = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "app", "write", "docker", "stable", version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测Podman
|
||||
result, _ = i.executor.Run(ctx, "which", "podman")
|
||||
if result != nil && result.ExitCode == 0 {
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "podman")
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "podman.socket")
|
||||
versionResult, _ := i.executor.Run(ctx, "podman", "-v")
|
||||
if versionResult != nil {
|
||||
parts := strings.Fields(versionResult.Stdout)
|
||||
if len(parts) >= 3 {
|
||||
_, _ = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "app", "write", "podman", "stable", parts[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测Fail2ban
|
||||
result, _ = i.executor.Run(ctx, "which", "fail2ban-server")
|
||||
if result != nil && result.ExitCode == 0 {
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "fail2ban")
|
||||
versionResult, _ := i.executor.Run(ctx, "fail2ban-server", "-V")
|
||||
if versionResult != nil {
|
||||
_, _ = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "app", "write", "fail2ban", "stable", strings.TrimSpace(versionResult.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
172
internal/service/mounter.go
Normal file
172
internal/service/mounter.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// Mounter 磁盘挂载器接口
|
||||
type Mounter interface {
|
||||
ListDisks(ctx context.Context) ([]types.DiskInfo, error)
|
||||
IsPartitioned(disk string) bool
|
||||
Mount(ctx context.Context, cfg *types.MountConfig, progress ProgressCallback) error
|
||||
}
|
||||
|
||||
type mounter struct {
|
||||
detector system.Detector
|
||||
executor system.Executor
|
||||
}
|
||||
|
||||
// NewMounter 创建挂载器
|
||||
func NewMounter(detector system.Detector, executor system.Executor) Mounter {
|
||||
return &mounter{
|
||||
detector: detector,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mounter) ListDisks(ctx context.Context) ([]types.DiskInfo, error) {
|
||||
return m.detector.ListDisks(ctx)
|
||||
}
|
||||
|
||||
func (m *mounter) IsPartitioned(disk string) bool {
|
||||
_, err := os.Stat("/dev/" + disk + "1")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *mounter) Mount(ctx context.Context, cfg *types.MountConfig, progress ProgressCallback) error {
|
||||
// 检查root权限
|
||||
if err := m.detector.CheckRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查磁盘是否存在
|
||||
if !m.detector.CheckDiskExists(cfg.Disk) {
|
||||
return errors.New(i18n.T().Get("Disk not found"))
|
||||
}
|
||||
|
||||
// 检查是否为系统盘
|
||||
if m.detector.IsSystemDisk(cfg.Disk) {
|
||||
return errors.New(i18n.T().Get("Cannot operate on system disk"))
|
||||
}
|
||||
|
||||
// 安装分区工具
|
||||
progress(i18n.T().Get("Installing partition tools"), i18n.T().Get("Installing partition tools..."))
|
||||
info, _ := m.detector.Detect(ctx)
|
||||
pkgMgr := system.NewPackageManager(info.OS, m.executor)
|
||||
if pkgMgr != nil {
|
||||
if info.OS == types.OSRHEL {
|
||||
if err := pkgMgr.Install(ctx, "xfsprogs", "e2fsprogs", "util-linux"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := pkgMgr.Install(ctx, "xfsprogs", "e2fsprogs", "fdisk", "util-linux"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建挂载点
|
||||
progress(i18n.T().Get("Creating mount point"), i18n.T().Get("Creating %s...", cfg.MountPoint))
|
||||
if err := os.MkdirAll(cfg.MountPoint, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查挂载点是否为空
|
||||
entries, err := os.ReadDir(cfg.MountPoint)
|
||||
if err == nil && len(entries) > 0 {
|
||||
return errors.New(i18n.T().Get("Mount point is not empty"))
|
||||
}
|
||||
|
||||
// 卸载已有分区
|
||||
_, _ = m.executor.Run(ctx, "umount", "/dev/"+cfg.Disk+"1")
|
||||
|
||||
// 删除所有分区
|
||||
progress(i18n.T().Get("Deleting existing partitions"), i18n.T().Get("Deleting existing partitions..."))
|
||||
fdiskInput := ""
|
||||
// 获取现有分区数
|
||||
result, _ := m.executor.Run(ctx, "lsblk", "-no", "NAME", "/dev/"+cfg.Disk)
|
||||
if result != nil {
|
||||
lines := strings.Split(strings.TrimSpace(result.Stdout), "\n")
|
||||
partCount := len(lines) - 1 // 减去磁盘本身
|
||||
for i := 0; i < partCount; i++ {
|
||||
fdiskInput += "d\n"
|
||||
}
|
||||
}
|
||||
fdiskInput += "w\n"
|
||||
_, _ = m.executor.RunWithInput(ctx, fdiskInput, "fdisk", "/dev/"+cfg.Disk)
|
||||
|
||||
// 创建新分区
|
||||
progress(i18n.T().Get("Creating partition"), i18n.T().Get("Creating partition on /dev/%s...", cfg.Disk))
|
||||
partitionInput := "g\nn\n1\n\n\nw\n"
|
||||
result, err = m.executor.RunWithInput(ctx, partitionInput, "fdisk", "/dev/"+cfg.Disk)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to create partition"), err)
|
||||
}
|
||||
|
||||
// 格式化
|
||||
progress(i18n.T().Get("Formatting partition"), i18n.T().Get("Formatting /dev/%s1 as %s...", cfg.Disk, cfg.FSType))
|
||||
switch cfg.FSType {
|
||||
case types.FSTypeExt4:
|
||||
result, err = m.executor.Run(ctx, "mkfs.ext4", "-F", "/dev/"+cfg.Disk+"1")
|
||||
case types.FSTypeXFS:
|
||||
result, err = m.executor.Run(ctx, "mkfs.xfs", "-f", "/dev/"+cfg.Disk+"1")
|
||||
default:
|
||||
return errors.New(i18n.T().Get("Unsupported filesystem type: %s", cfg.FSType))
|
||||
}
|
||||
if err != nil || (result != nil && result.ExitCode != 0) {
|
||||
return errors.New(i18n.T().Get("Format failed"))
|
||||
}
|
||||
|
||||
// 重载systemd
|
||||
_, _ = m.executor.Run(ctx, "systemctl", "daemon-reload")
|
||||
|
||||
// 挂载
|
||||
progress(i18n.T().Get("Mounting partition"), i18n.T().Get("Mounting /dev/%s1 to %s...", cfg.Disk, cfg.MountPoint))
|
||||
result, err = m.executor.Run(ctx, "mount", "/dev/"+cfg.Disk+"1", cfg.MountPoint)
|
||||
if err != nil || (result != nil && result.ExitCode != 0) {
|
||||
return errors.New(i18n.T().Get("Mount failed"))
|
||||
}
|
||||
|
||||
// 获取UUID
|
||||
progress(i18n.T().Get("Updating fstab"), i18n.T().Get("Updating /etc/fstab for auto-mount..."))
|
||||
result, err = m.executor.Run(ctx, "blkid", "-s", "UUID", "-o", "value", "/dev/"+cfg.Disk+"1")
|
||||
if err != nil || result == nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to get UUID"))
|
||||
}
|
||||
uuid := strings.TrimSpace(result.Stdout)
|
||||
|
||||
// 更新fstab
|
||||
// 先删除旧条目
|
||||
_, _ = m.executor.Run(ctx, "sed", "-i", fmt.Sprintf("\\|/dev/%s1|d", cfg.Disk), "/etc/fstab")
|
||||
_, _ = m.executor.Run(ctx, "sed", "-i", fmt.Sprintf("\\|%s|d", cfg.MountPoint), "/etc/fstab")
|
||||
|
||||
// 添加新条目
|
||||
fstabEntry := fmt.Sprintf("UUID=%s %s %s defaults 0 0\n", uuid, cfg.MountPoint, cfg.FSType)
|
||||
f, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(f *os.File) { _ = f.Close() }(f)
|
||||
_, err = f.WriteString(fstabEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重载并挂载
|
||||
_, _ = m.executor.Run(ctx, "systemctl", "daemon-reload")
|
||||
result, err = m.executor.Run(ctx, "mount", "-a")
|
||||
if err != nil || (result != nil && result.ExitCode != 0) {
|
||||
return errors.New(i18n.T().Get("fstab configuration error"))
|
||||
}
|
||||
|
||||
progress(i18n.T().Get("Mount complete"), i18n.T().Get("Disk partition and mount successful"))
|
||||
return nil
|
||||
}
|
||||
86
internal/service/uninstaller.go
Normal file
86
internal/service/uninstaller.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// ProgressCallback 进度回调
|
||||
type ProgressCallback func(step, message string)
|
||||
|
||||
// Uninstaller 卸载器接口
|
||||
type Uninstaller interface {
|
||||
Uninstall(ctx context.Context, setupPath string, progress ProgressCallback) error
|
||||
}
|
||||
|
||||
type uninstaller struct {
|
||||
detector system.Detector
|
||||
executor system.Executor
|
||||
systemd system.Systemd
|
||||
}
|
||||
|
||||
// NewUninstaller 创建卸载器
|
||||
func NewUninstaller(
|
||||
detector system.Detector,
|
||||
executor system.Executor,
|
||||
systemd system.Systemd,
|
||||
) Uninstaller {
|
||||
return &uninstaller{
|
||||
detector: detector,
|
||||
executor: executor,
|
||||
systemd: systemd,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *uninstaller) Uninstall(ctx context.Context, setupPath string, progress ProgressCallback) error {
|
||||
// 检查root权限
|
||||
if err := u.detector.CheckRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否已安装
|
||||
if !u.detector.CheckPanelInstalled(setupPath) {
|
||||
return fmt.Errorf(i18n.T().Get("Panel is not installed"))
|
||||
}
|
||||
|
||||
// 停止服务
|
||||
progress(i18n.T().Get("Stopping panel service"), i18n.T().Get("Stopping acepanel service..."))
|
||||
_ = u.systemd.Stop(ctx, "acepanel")
|
||||
_ = u.systemd.Disable(ctx, "acepanel")
|
||||
|
||||
// 删除服务文件
|
||||
progress(i18n.T().Get("Removing service file"), i18n.T().Get("Removing systemd service file..."))
|
||||
_ = u.systemd.RemoveServiceFile("acepanel")
|
||||
_ = u.systemd.DaemonReload(ctx)
|
||||
|
||||
// 删除CLI工具
|
||||
progress(i18n.T().Get("Removing CLI tool"), i18n.T().Get("Removing /usr/local/sbin/acepanel..."))
|
||||
_ = os.Remove("/usr/local/sbin/acepanel")
|
||||
|
||||
// 移除swap
|
||||
progress(i18n.T().Get("Removing swap file"), i18n.T().Get("Removing swap file..."))
|
||||
swapFile := setupPath + "/swap"
|
||||
if _, err := os.Stat(swapFile); err == nil {
|
||||
_, _ = u.executor.Run(ctx, "swapoff", swapFile)
|
||||
_ = os.Remove(swapFile)
|
||||
// 从fstab中删除swap条目
|
||||
_, _ = u.executor.Run(ctx, "sed", "-i", "/swap/d", "/etc/fstab")
|
||||
}
|
||||
|
||||
// 验证fstab
|
||||
result, _ := u.executor.Run(ctx, "mount", "-a")
|
||||
if result != nil && result.ExitCode != 0 {
|
||||
return fmt.Errorf(i18n.T().Get("fstab configuration error, please check /etc/fstab"))
|
||||
}
|
||||
|
||||
// 删除安装目录
|
||||
progress(i18n.T().Get("Removing installation directory"), i18n.T().Get("Removing %s...", setupPath))
|
||||
_ = os.RemoveAll(setupPath)
|
||||
|
||||
progress(i18n.T().Get("Uninstallation complete"), i18n.T().Get("Panel uninstalled successfully"))
|
||||
return nil
|
||||
}
|
||||
9
internal/service/wire.go
Normal file
9
internal/service/wire.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package service
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewInstaller,
|
||||
NewUninstaller,
|
||||
NewMounter,
|
||||
)
|
||||
305
internal/system/detector.go
Normal file
305
internal/system/detector.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// Detector 系统检测器接口
|
||||
type Detector interface {
|
||||
// Detect 检测系统信息
|
||||
Detect(ctx context.Context) (*types.SystemInfo, error)
|
||||
// CheckRoot 检查root权限
|
||||
CheckRoot() error
|
||||
// CheckCPUFeatures 检查CPU特性(x86-64-v2)
|
||||
CheckCPUFeatures(ctx context.Context) error
|
||||
// CheckPanelInstalled 检查面板是否已安装
|
||||
CheckPanelInstalled(path string) bool
|
||||
// ListDisks 列出可用磁盘
|
||||
ListDisks(ctx context.Context) ([]types.DiskInfo, error)
|
||||
// CheckDiskExists 检查磁盘是否存在
|
||||
CheckDiskExists(disk string) bool
|
||||
// IsSystemDisk 检查是否为系统盘
|
||||
IsSystemDisk(disk string) bool
|
||||
}
|
||||
|
||||
type detector struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
// NewDetector 创建检测器
|
||||
func NewDetector(executor Executor) Detector {
|
||||
return &detector{executor: executor}
|
||||
}
|
||||
|
||||
func (d *detector) Detect(ctx context.Context) (*types.SystemInfo, error) {
|
||||
info := &types.SystemInfo{}
|
||||
|
||||
// 检测OS类型
|
||||
info.OS = d.detectOS()
|
||||
// 检测架构
|
||||
info.Arch = d.detectArch()
|
||||
// 检测内核版本
|
||||
info.KernelVersion = d.detectKernelVersion(ctx)
|
||||
// 检测是否64位
|
||||
info.Is64Bit = d.detect64Bit(ctx)
|
||||
// 检测内存
|
||||
info.Memory = d.detectMemory(ctx)
|
||||
// 检测Swap
|
||||
info.Swap = d.detectSwap(ctx)
|
||||
// 检测是否在中国
|
||||
info.InChina = d.detectInChina(ctx)
|
||||
// 检测SSH端口
|
||||
info.SSHPort = d.detectSSHPort()
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (d *detector) detectOS() types.OSType {
|
||||
// 读取 /etc/os-release
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return types.OSUnknown
|
||||
}
|
||||
defer func(file *os.File) { _ = file.Close() }(file)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var id string
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch id {
|
||||
case "debian":
|
||||
return types.OSDebian
|
||||
case "ubuntu":
|
||||
return types.OSUbuntu
|
||||
case "rhel", "centos", "rocky", "almalinux", "fedora":
|
||||
return types.OSRHEL
|
||||
default:
|
||||
// 检查ID_LIKE
|
||||
_, _ = file.Seek(0, 0)
|
||||
scanner = bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "ID_LIKE=") {
|
||||
idLike := strings.Trim(strings.TrimPrefix(line, "ID_LIKE="), "\"")
|
||||
if strings.Contains(idLike, "debian") {
|
||||
return types.OSDebian
|
||||
}
|
||||
if strings.Contains(idLike, "rhel") || strings.Contains(idLike, "fedora") {
|
||||
return types.OSRHEL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return types.OSUnknown
|
||||
}
|
||||
|
||||
func (d *detector) detectArch() types.ArchType {
|
||||
arch := runtime.GOARCH
|
||||
switch arch {
|
||||
case "amd64":
|
||||
return types.ArchAMD64
|
||||
case "arm64":
|
||||
return types.ArchARM64
|
||||
default:
|
||||
return types.ArchUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (d *detector) detectKernelVersion(ctx context.Context) string {
|
||||
result, err := d.executor.Run(ctx, "uname", "-r")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout)
|
||||
}
|
||||
|
||||
func (d *detector) detect64Bit(ctx context.Context) bool {
|
||||
result, err := d.executor.Run(ctx, "getconf", "LONG_BIT")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout) == "64"
|
||||
}
|
||||
|
||||
func (d *detector) detectMemory(ctx context.Context) int64 {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
kb, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return kb / 1024 // 转换为MB
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *detector) detectSwap(ctx context.Context) int64 {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "SwapTotal:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
kb, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return kb / 1024 // 转换为MB
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *detector) detectInChina(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://perfops.cloudflareperf.com/cdn-cgi/trace", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
if scanner.Text() == "loc=CN" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *detector) detectSSHPort() int {
|
||||
file, err := os.Open("/etc/ssh/sshd_config")
|
||||
if err != nil {
|
||||
return 22
|
||||
}
|
||||
defer func(file *os.File) { _ = file.Close() }(file)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "Port ") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
port, err := strconv.Atoi(fields[1])
|
||||
if err == nil {
|
||||
return port
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 22
|
||||
}
|
||||
|
||||
func (d *detector) CheckRoot() error {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentUser.Uid != "0" {
|
||||
return errors.New(i18n.T().Get("Please run with root privileges"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *detector) CheckCPUFeatures(ctx context.Context) error {
|
||||
// 只有x86_64需要检查
|
||||
if runtime.GOARCH != "amd64" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否支持ssse3 (x86-64-v2的标志之一)
|
||||
if !strings.Contains(string(data), "ssse3") {
|
||||
return errors.New(i18n.T().Get("CPU must support at least x86-64-v2 instruction set"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *detector) CheckPanelInstalled(path string) bool {
|
||||
_, err := os.Stat(path + "/panel/web")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (d *detector) ListDisks(ctx context.Context) ([]types.DiskInfo, error) {
|
||||
result, err := d.executor.Run(ctx, "lsblk", "-dno", "NAME,SIZE,TYPE")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var disks []types.DiskInfo
|
||||
lines := strings.Split(strings.TrimSpace(result.Stdout), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[2] == "disk" {
|
||||
// 排除系统盘
|
||||
if d.IsSystemDisk(fields[0]) {
|
||||
continue
|
||||
}
|
||||
disks = append(disks, types.DiskInfo{
|
||||
Name: fields[0],
|
||||
Size: fields[1],
|
||||
Type: fields[2],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
func (d *detector) CheckDiskExists(disk string) bool {
|
||||
_, err := os.Stat("/dev/" + disk)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (d *detector) IsSystemDisk(disk string) bool {
|
||||
// 系统盘通常以a结尾 (sda, vda, nvme0n1)
|
||||
matched, _ := regexp.MatchString(`^(sd|vd|hd)a$`, disk)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
matched, _ = regexp.MatchString(`^nvme0n1$`, disk)
|
||||
return matched
|
||||
}
|
||||
87
internal/system/executor.go
Normal file
87
internal/system/executor.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// CommandResult 命令执行结果
|
||||
type CommandResult struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
// Executor 命令执行器接口
|
||||
type Executor interface {
|
||||
// Run 执行命令并等待完成
|
||||
Run(ctx context.Context, name string, args ...string) (*CommandResult, error)
|
||||
// RunWithInput 执行命令并提供输入
|
||||
RunWithInput(ctx context.Context, input string, name string, args ...string) (*CommandResult, error)
|
||||
// RunStream 执行命令并流式输出
|
||||
RunStream(ctx context.Context, stdout, stderr io.Writer, name string, args ...string) error
|
||||
}
|
||||
|
||||
type executor struct{}
|
||||
|
||||
// NewExecutor 创建执行器
|
||||
func NewExecutor() Executor {
|
||||
return &executor{}
|
||||
}
|
||||
|
||||
func (e *executor) Run(ctx context.Context, name string, args ...string) (*CommandResult, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
result := &CommandResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *executor) RunWithInput(ctx context.Context, input string, name string, args ...string) (*CommandResult, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin = bytes.NewBufferString(input)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
result := &CommandResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *executor) RunStream(ctx context.Context, stdout, stderr io.Writer, name string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
96
internal/system/firewall.go
Normal file
96
internal/system/firewall.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// Firewall 防火墙接口
|
||||
type Firewall interface {
|
||||
// Install 安装防火墙
|
||||
Install(ctx context.Context) error
|
||||
// Enable 启用防火墙
|
||||
Enable(ctx context.Context) error
|
||||
// AddPort 添加端口
|
||||
AddPort(ctx context.Context, port int, protocol string) error
|
||||
// RemovePort 移除端口
|
||||
RemovePort(ctx context.Context, port int, protocol string) error
|
||||
// Reload 重载配置
|
||||
Reload(ctx context.Context) error
|
||||
}
|
||||
|
||||
type firewall struct {
|
||||
executor Executor
|
||||
detector Detector
|
||||
}
|
||||
|
||||
// NewFirewall 创建防火墙管理器
|
||||
func NewFirewall(executor Executor, detector Detector) Firewall {
|
||||
return &firewall{
|
||||
executor: executor,
|
||||
detector: detector,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *firewall) Install(ctx context.Context) error {
|
||||
info, err := f.detector.Detect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgMgr := NewPackageManager(info.OS, f.executor)
|
||||
if pkgMgr == nil {
|
||||
return fmt.Errorf("%s", i18n.T().Get("Unsupported operating system"))
|
||||
}
|
||||
return pkgMgr.Install(ctx, "firewalld")
|
||||
}
|
||||
|
||||
func (f *firewall) Enable(ctx context.Context) error {
|
||||
result, err := f.executor.Run(ctx, "systemctl", "enable", "--now", "firewalld")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("Failed to enable firewalld"), result.Stderr)
|
||||
}
|
||||
|
||||
// 设置默认zone
|
||||
_, err = f.executor.Run(ctx, "firewall-cmd", "--set-default-zone=public")
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *firewall) AddPort(ctx context.Context, port int, protocol string) error {
|
||||
portStr := fmt.Sprintf("%d/%s", port, protocol)
|
||||
result, err := f.executor.Run(ctx, "firewall-cmd", "--permanent", "--zone=public", "--add-port="+portStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to add port"), portStr, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *firewall) RemovePort(ctx context.Context, port int, protocol string) error {
|
||||
portStr := fmt.Sprintf("%d/%s", port, protocol)
|
||||
result, err := f.executor.Run(ctx, "firewall-cmd", "--permanent", "--zone=public", "--remove-port="+portStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to remove port"), portStr, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *firewall) Reload(ctx context.Context) error {
|
||||
result, err := f.executor.Run(ctx, "firewall-cmd", "--reload")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("Failed to reload firewall"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
229
internal/system/package.go
Normal file
229
internal/system/package.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// PackageManager 包管理器接口
|
||||
type PackageManager interface {
|
||||
// UpdateCache 更新软件源缓存
|
||||
UpdateCache(ctx context.Context) error
|
||||
// Install 安装软件包
|
||||
Install(ctx context.Context, packages ...string) error
|
||||
// Remove 移除软件包
|
||||
Remove(ctx context.Context, packages ...string) error
|
||||
// IsInstalled 检查是否已安装
|
||||
IsInstalled(ctx context.Context, pkg string) bool
|
||||
// SetMirror 设置镜像源
|
||||
SetMirror(ctx context.Context, inChina bool) error
|
||||
// EnableEPEL 启用EPEL源 (仅RHEL系)
|
||||
EnableEPEL(ctx context.Context, inChina bool) error
|
||||
}
|
||||
|
||||
// NewPackageManager 根据OS类型创建包管理器
|
||||
func NewPackageManager(osType types.OSType, executor Executor) PackageManager {
|
||||
switch osType {
|
||||
case types.OSRHEL:
|
||||
return &dnfManager{executor: executor}
|
||||
case types.OSDebian, types.OSUbuntu:
|
||||
return &aptManager{executor: executor, osType: osType}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// dnfManager DNF包管理器 (RHEL系)
|
||||
type dnfManager struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
func (m *dnfManager) UpdateCache(ctx context.Context) error {
|
||||
result, err := m.executor.Run(ctx, "dnf", "makecache", "-y")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("dnf makecache failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) Install(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"install", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "dnf", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("dnf install failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) Remove(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"remove", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "dnf", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("dnf remove failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) IsInstalled(ctx context.Context, pkg string) bool {
|
||||
result, _ := m.executor.Run(ctx, "rpm", "-q", pkg)
|
||||
return result != nil && result.ExitCode == 0
|
||||
}
|
||||
|
||||
func (m *dnfManager) SetMirror(ctx context.Context, inChina bool) error {
|
||||
if !inChina {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rocky Linux
|
||||
m.sedReplace("/etc/yum.repos.d/[Rr]ocky*.repo",
|
||||
"s|^mirrorlist=|#mirrorlist=|g",
|
||||
"s|^#baseurl=http://dl.rockylinux.org/$contentdir|baseurl=https://mirrors.tencent.com/rocky|g",
|
||||
)
|
||||
|
||||
// AlmaLinux
|
||||
m.sedReplace("/etc/yum.repos.d/[Aa]lmalinux*.repo",
|
||||
"s|^mirrorlist=|#mirrorlist=|g",
|
||||
"s|^#baseurl=https://repo.almalinux.org|baseurl=https://mirrors.tencent.com|g",
|
||||
)
|
||||
|
||||
// CentOS Stream
|
||||
m.sedReplace("/etc/yum.repos.d/[Cc]ent*.repo",
|
||||
"s|^mirrorlist=|#mirrorlist=|g",
|
||||
"s|^#baseurl=http://mirror.centos.org/$contentdir|baseurl=https://mirrors.tencent.com/centos-stream|g",
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) EnableEPEL(ctx context.Context, inChina bool) error {
|
||||
// 启用CRB
|
||||
_, _ = m.executor.Run(ctx, "dnf", "config-manager", "--set-enabled", "crb")
|
||||
_, _ = m.executor.Run(ctx, "/usr/bin/crb", "enable")
|
||||
|
||||
// 安装EPEL
|
||||
result, _ := m.executor.Run(ctx, "dnf", "install", "-y", "epel-release")
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
// 手动安装
|
||||
var url string
|
||||
if inChina {
|
||||
url = "https://mirrors.tencent.com/epel/epel-release-latest-9.noarch.rpm"
|
||||
} else {
|
||||
url = "https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm"
|
||||
}
|
||||
_, _ = m.executor.Run(ctx, "dnf", "install", "-y", url)
|
||||
}
|
||||
|
||||
if inChina {
|
||||
// 删除无镜像的repo
|
||||
_ = os.Remove("/etc/yum.repos.d/epel-cisco-openh264.repo")
|
||||
// 设置EPEL镜像
|
||||
m.sedReplace("/etc/yum.repos.d/epel*.repo",
|
||||
"s|^#baseurl=https://download.example/pub|baseurl=https://mirrors.tencent.com|g",
|
||||
"s|^metalink|#metalink|g",
|
||||
)
|
||||
}
|
||||
|
||||
_, _ = m.executor.Run(ctx, "dnf", "config-manager", "--set-enabled", "epel")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) sedReplace(filePattern string, expressions ...string) {
|
||||
for _, expr := range expressions {
|
||||
_, _ = m.executor.Run(context.Background(), "sed", "-i", expr, filePattern)
|
||||
}
|
||||
}
|
||||
|
||||
// aptManager APT包管理器 (Debian/Ubuntu)
|
||||
type aptManager struct {
|
||||
executor Executor
|
||||
osType types.OSType
|
||||
}
|
||||
|
||||
func (m *aptManager) UpdateCache(ctx context.Context) error {
|
||||
result, err := m.executor.Run(ctx, "apt-get", "update", "-y")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("apt-get update failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) Install(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"install", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "apt-get", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("apt-get install failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) Remove(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"purge", "--auto-remove", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "apt-get", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("apt-get remove failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) IsInstalled(ctx context.Context, pkg string) bool {
|
||||
result, _ := m.executor.Run(ctx, "dpkg", "-s", pkg)
|
||||
return result != nil && result.ExitCode == 0 && strings.Contains(result.Stdout, "Status: install ok installed")
|
||||
}
|
||||
|
||||
func (m *aptManager) SetMirror(ctx context.Context, inChina bool) error {
|
||||
if !inChina {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.osType == types.OSDebian {
|
||||
// Debian
|
||||
m.sedReplace("/etc/apt/sources.list", "s/deb.debian.org/mirrors.tencent.com/g")
|
||||
m.sedReplace("/etc/apt/sources.list.d/debian.sources", "s/deb.debian.org/mirrors.tencent.com/g")
|
||||
m.sedReplace("/etc/apt/sources.list",
|
||||
"s|security.debian.org/\\? |security.debian.org/debian-security |g",
|
||||
"s|security.debian.org|mirrors.tencent.com|g",
|
||||
)
|
||||
} else {
|
||||
// Ubuntu
|
||||
m.sedReplace("/etc/apt/sources.list", "s@//.*archive.ubuntu.com@//mirrors.tencent.com@g")
|
||||
m.sedReplace("/etc/apt/sources.list.d/ubuntu.sources", "s@//.*archive.ubuntu.com@//mirrors.tencent.com@g")
|
||||
m.sedReplace("/etc/apt/sources.list", "s/security.ubuntu.com/mirrors.tencent.com/g")
|
||||
m.sedReplace("/etc/apt/sources.list.d/ubuntu.sources", "s/security.ubuntu.com/mirrors.tencent.com/g")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) EnableEPEL(ctx context.Context, inChina bool) error {
|
||||
// APT不需要EPEL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) sedReplace(file string, expressions ...string) {
|
||||
for _, expr := range expressions {
|
||||
_, _ = m.executor.Run(context.Background(), "sed", "-i", expr, file)
|
||||
}
|
||||
}
|
||||
122
internal/system/systemd.go
Normal file
122
internal/system/systemd.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// Systemd systemd服务管理接口
|
||||
type Systemd interface {
|
||||
// Start 启动服务
|
||||
Start(ctx context.Context, service string) error
|
||||
// Stop 停止服务
|
||||
Stop(ctx context.Context, service string) error
|
||||
// Enable 启用服务
|
||||
Enable(ctx context.Context, service string) error
|
||||
// Disable 禁用服务
|
||||
Disable(ctx context.Context, service string) error
|
||||
// Restart 重启服务
|
||||
Restart(ctx context.Context, service string) error
|
||||
// IsActive 检查服务是否运行
|
||||
IsActive(ctx context.Context, service string) bool
|
||||
// DaemonReload 重载systemd配置
|
||||
DaemonReload(ctx context.Context) error
|
||||
// WriteServiceFile 写入服务文件
|
||||
WriteServiceFile(name string, content string) error
|
||||
// RemoveServiceFile 删除服务文件
|
||||
RemoveServiceFile(name string) error
|
||||
}
|
||||
|
||||
type systemd struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
// NewSystemd 创建systemd管理器
|
||||
func NewSystemd(executor Executor) Systemd {
|
||||
return &systemd{executor: executor}
|
||||
}
|
||||
|
||||
func (s *systemd) Start(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "start", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to start"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Stop(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "stop", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to stop"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Enable(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "enable", "--now", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to enable"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Disable(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "disable", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to disable"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Restart(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "restart", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to restart"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) IsActive(ctx context.Context, service string) bool {
|
||||
result, _ := s.executor.Run(ctx, "systemctl", "is-active", service)
|
||||
return result != nil && strings.TrimSpace(result.Stdout) == "active"
|
||||
}
|
||||
|
||||
func (s *systemd) DaemonReload(ctx context.Context) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "daemon-reload")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("Failed to daemon-reload"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) WriteServiceFile(name string, content string) error {
|
||||
path := fmt.Sprintf("/etc/systemd/system/%s.service", name)
|
||||
return os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (s *systemd) RemoveServiceFile(name string) error {
|
||||
path := fmt.Sprintf("/etc/systemd/system/%s.service", name)
|
||||
return os.Remove(path)
|
||||
}
|
||||
87
internal/system/user.go
Normal file
87
internal/system/user.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// UserManager 用户管理接口
|
||||
type UserManager interface {
|
||||
// UserExists 检查用户是否存在
|
||||
UserExists(ctx context.Context, username string) bool
|
||||
// GroupExists 检查组是否存在
|
||||
GroupExists(ctx context.Context, groupname string) bool
|
||||
// CreateUser 创建用户
|
||||
CreateUser(ctx context.Context, username, groupname string, nologin bool) error
|
||||
// CreateGroup 创建组
|
||||
CreateGroup(ctx context.Context, groupname string) error
|
||||
// EnsureUserAndGroup 确保用户和组存在
|
||||
EnsureUserAndGroup(ctx context.Context, username, groupname string) error
|
||||
}
|
||||
|
||||
type userManager struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
// NewUserManager 创建用户管理器
|
||||
func NewUserManager(executor Executor) UserManager {
|
||||
return &userManager{executor: executor}
|
||||
}
|
||||
|
||||
func (u *userManager) UserExists(ctx context.Context, username string) bool {
|
||||
result, _ := u.executor.Run(ctx, "id", "-u", username)
|
||||
return result != nil && result.ExitCode == 0
|
||||
}
|
||||
|
||||
func (u *userManager) GroupExists(ctx context.Context, groupname string) bool {
|
||||
result, _ := u.executor.Run(ctx, "getent", "group", groupname)
|
||||
return result != nil && result.ExitCode == 0
|
||||
}
|
||||
|
||||
func (u *userManager) CreateUser(ctx context.Context, username, groupname string, nologin bool) error {
|
||||
args := []string{"-g", groupname}
|
||||
if nologin {
|
||||
args = append(args, "-s", "/sbin/nologin")
|
||||
}
|
||||
args = append(args, username)
|
||||
|
||||
result, err := u.executor.Run(ctx, "useradd", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to create user"), username, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *userManager) CreateGroup(ctx context.Context, groupname string) error {
|
||||
result, err := u.executor.Run(ctx, "groupadd", groupname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to create group"), groupname, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *userManager) EnsureUserAndGroup(ctx context.Context, username, groupname string) error {
|
||||
// 确保组存在
|
||||
if !u.GroupExists(ctx, groupname) {
|
||||
if err := u.CreateGroup(ctx, groupname); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 确保用户存在
|
||||
if !u.UserExists(ctx, username) {
|
||||
if err := u.CreateUser(ctx, username, groupname, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
11
internal/system/wire.go
Normal file
11
internal/system/wire.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package system
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewExecutor,
|
||||
NewDetector,
|
||||
NewFirewall,
|
||||
NewSystemd,
|
||||
NewUserManager,
|
||||
)
|
||||
872
internal/ui/app.go
Normal file
872
internal/ui/app.go
Normal file
@@ -0,0 +1,872 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/acepanel/helper/pkg/embed"
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
|
||||
"github.com/acepanel/helper/internal/service"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// ViewState 视图状态
|
||||
type ViewState int
|
||||
|
||||
const (
|
||||
ViewLanguageSelect ViewState = iota
|
||||
ViewMainMenu
|
||||
ViewInstall
|
||||
ViewUninstall
|
||||
ViewMount
|
||||
)
|
||||
|
||||
// MenuChoice 菜单选项
|
||||
type MenuChoice int
|
||||
|
||||
const (
|
||||
MenuInstall MenuChoice = iota
|
||||
MenuUninstall
|
||||
MenuMount
|
||||
MenuExit
|
||||
)
|
||||
|
||||
// 消息类型
|
||||
type (
|
||||
progressMsg types.Progress
|
||||
installCompleteMsg struct{ err error }
|
||||
uninstallCompleteMsg struct{ err error }
|
||||
mountCompleteMsg struct{ err error }
|
||||
countdownTickMsg struct{ remaining int }
|
||||
disksLoadedMsg struct {
|
||||
disks []types.DiskInfo
|
||||
err error
|
||||
}
|
||||
)
|
||||
|
||||
// App 主应用
|
||||
type App struct {
|
||||
state ViewState
|
||||
width int
|
||||
height int
|
||||
program *tea.Program
|
||||
installer service.Installer
|
||||
uninstaller service.Uninstaller
|
||||
mounter service.Mounter
|
||||
|
||||
// 语言选择
|
||||
langForm *huh.Form
|
||||
langChoice string
|
||||
|
||||
// 主菜单
|
||||
menuForm *huh.Form
|
||||
menuChoice MenuChoice
|
||||
|
||||
// 安装
|
||||
installForm *huh.Form
|
||||
installConfirmed bool
|
||||
setupPath string
|
||||
installSpinner spinner.Model
|
||||
installProgress progress.Model
|
||||
installStep string
|
||||
installPercent float64
|
||||
installLogs []string
|
||||
installErr error
|
||||
installRunning bool
|
||||
installDone bool
|
||||
|
||||
// 卸载
|
||||
uninstallCountdown int
|
||||
uninstallSkipCount int // 连按enter跳过倒计时的计数
|
||||
uninstallForm *huh.Form
|
||||
uninstallConfirm bool
|
||||
uninstallSpinner spinner.Model
|
||||
uninstallStep string
|
||||
uninstallLogs []string
|
||||
uninstallErr error
|
||||
uninstallRunning bool
|
||||
uninstallDone bool
|
||||
uninstallState int // 0=warning, 1=countdown, 2=confirm, 3=running, 4=done
|
||||
|
||||
// 磁盘分区
|
||||
disks []types.DiskInfo
|
||||
mountDiskForm *huh.Form
|
||||
mountPointForm *huh.Form
|
||||
mountFSForm *huh.Form
|
||||
mountConfirmForm *huh.Form
|
||||
mountConfirmed bool
|
||||
mountConfig types.MountConfig
|
||||
mountSpinner spinner.Model
|
||||
mountStep string
|
||||
mountLogs []string
|
||||
mountErr error
|
||||
mountRunning bool
|
||||
mountDone bool
|
||||
mountState int // 0=loading, 1=selectDisk, 2=selectMount, 3=selectFS, 4=confirm, 5=running, 6=done
|
||||
}
|
||||
|
||||
// NewApp 创建应用
|
||||
func NewApp(installer service.Installer, uninstaller service.Uninstaller, mounter service.Mounter) *App {
|
||||
app := &App{
|
||||
state: ViewLanguageSelect,
|
||||
langChoice: "zh_CN",
|
||||
installer: installer,
|
||||
uninstaller: uninstaller,
|
||||
mounter: mounter,
|
||||
setupPath: "/opt/ace",
|
||||
mountConfig: types.MountConfig{
|
||||
MountPoint: "/opt/ace",
|
||||
FSType: types.FSTypeExt4,
|
||||
},
|
||||
}
|
||||
|
||||
// 初始化 spinner
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
|
||||
app.installSpinner = s
|
||||
app.uninstallSpinner = s
|
||||
app.mountSpinner = s
|
||||
|
||||
// 初始化进度条
|
||||
app.installProgress = progress.New(progress.WithDefaultGradient())
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) SetProgram(p *tea.Program) {
|
||||
a.program = p
|
||||
}
|
||||
|
||||
func (a *App) Init() tea.Cmd {
|
||||
a.langForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select Language / 选择语言").
|
||||
Options(
|
||||
huh.NewOption("简体中文", "zh_CN"),
|
||||
huh.NewOption("繁體中文", "zh_TW"),
|
||||
huh.NewOption("English", "en_US"),
|
||||
).
|
||||
Value(&a.langChoice),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return a.langForm.Init()
|
||||
}
|
||||
|
||||
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
a.installProgress.Width = msg.Width - 10
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return a, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
switch a.state {
|
||||
case ViewLanguageSelect:
|
||||
return a.updateLanguageSelect(msg)
|
||||
case ViewMainMenu:
|
||||
return a.updateMainMenu(msg)
|
||||
case ViewInstall:
|
||||
return a.updateInstall(msg)
|
||||
case ViewUninstall:
|
||||
return a.updateUninstall(msg)
|
||||
case ViewMount:
|
||||
return a.updateMount(msg)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) View() string {
|
||||
switch a.state {
|
||||
case ViewLanguageSelect:
|
||||
return a.viewLanguageSelect()
|
||||
case ViewMainMenu:
|
||||
return a.viewMainMenu()
|
||||
case ViewInstall:
|
||||
return a.viewInstall()
|
||||
case ViewUninstall:
|
||||
return a.viewUninstall()
|
||||
case ViewMount:
|
||||
return a.viewMount()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ========== 语言选择 ==========
|
||||
|
||||
func (a *App) updateLanguageSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
form, cmd := a.langForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.langForm = f
|
||||
}
|
||||
|
||||
if a.langForm.State == huh.StateCompleted {
|
||||
locale := gotext.NewLocaleFSWithPath(a.langChoice, embed.LocalesFS, "locales")
|
||||
locale.AddDomain("helper")
|
||||
i18n.Init(locale)
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *App) viewLanguageSelect() string {
|
||||
return RenderLogo() + "\n" + a.langForm.View()
|
||||
}
|
||||
|
||||
// ========== 主菜单 ==========
|
||||
|
||||
func (a *App) initMainMenu() tea.Cmd {
|
||||
a.menuForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[MenuChoice]().
|
||||
Title(i18n.T().Get("Select Operation")).
|
||||
Options(
|
||||
huh.NewOption(i18n.T().Get("Install Panel"), MenuInstall),
|
||||
huh.NewOption(i18n.T().Get("Uninstall Panel"), MenuUninstall),
|
||||
huh.NewOption(i18n.T().Get("Disk Partition"), MenuMount),
|
||||
huh.NewOption(i18n.T().Get("Exit"), MenuExit),
|
||||
).
|
||||
Value(&a.menuChoice),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return a.menuForm.Init()
|
||||
}
|
||||
|
||||
func (a *App) updateMainMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
form, cmd := a.menuForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.menuForm = f
|
||||
}
|
||||
|
||||
if a.menuForm.State == huh.StateCompleted {
|
||||
switch a.menuChoice {
|
||||
case MenuInstall:
|
||||
a.state = ViewInstall
|
||||
a.installRunning = false
|
||||
a.installDone = false
|
||||
a.installErr = nil
|
||||
a.installLogs = nil
|
||||
return a, a.initInstall()
|
||||
case MenuUninstall:
|
||||
a.state = ViewUninstall
|
||||
a.uninstallState = 0
|
||||
a.uninstallRunning = false
|
||||
a.uninstallDone = false
|
||||
a.uninstallErr = nil
|
||||
a.uninstallLogs = nil
|
||||
a.uninstallCountdown = 60
|
||||
return a, nil
|
||||
case MenuMount:
|
||||
a.state = ViewMount
|
||||
a.mountState = 0
|
||||
a.mountRunning = false
|
||||
a.mountDone = false
|
||||
a.mountErr = nil
|
||||
a.mountLogs = nil
|
||||
return a, tea.Batch(a.mountSpinner.Tick, a.loadDisks())
|
||||
case MenuExit:
|
||||
return a, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *App) viewMainMenu() string {
|
||||
return RenderLogo() + "\n" + a.menuForm.View()
|
||||
}
|
||||
|
||||
// ========== 安装面板 ==========
|
||||
|
||||
func (a *App) initInstall() tea.Cmd {
|
||||
a.installForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(i18n.T().Get("Confirm Installation")).
|
||||
Description(i18n.T().Get("Panel will be installed to %s", a.setupPath)).
|
||||
Affirmative(i18n.T().Get("Yes")).
|
||||
Negative(i18n.T().Get("No")).
|
||||
Value(&a.installConfirmed),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return a.installForm.Init()
|
||||
}
|
||||
|
||||
func (a *App) updateInstall(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "esc" && !a.installRunning {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
if msg.String() == "enter" && a.installDone {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
|
||||
case progressMsg:
|
||||
a.installStep = msg.Step
|
||||
a.installPercent = msg.Percent
|
||||
if msg.Message != "" {
|
||||
a.installLogs = append(a.installLogs, msg.Message)
|
||||
if len(a.installLogs) > 8 {
|
||||
a.installLogs = a.installLogs[1:]
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case installCompleteMsg:
|
||||
a.installRunning = false
|
||||
a.installDone = true
|
||||
a.installErr = msg.err
|
||||
return a, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if a.installRunning {
|
||||
var cmd tea.Cmd
|
||||
a.installSpinner, cmd = a.installSpinner.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
if !a.installRunning && !a.installDone {
|
||||
form, cmd := a.installForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.installForm = f
|
||||
}
|
||||
|
||||
if a.installForm.State == huh.StateCompleted {
|
||||
if a.installConfirmed {
|
||||
a.installRunning = true
|
||||
return a, tea.Batch(a.installSpinner.Tick, a.startInstall())
|
||||
}
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) startInstall() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
progressCh := make(chan types.Progress, 10)
|
||||
|
||||
go func() {
|
||||
for p := range progressCh {
|
||||
if a.program != nil {
|
||||
a.program.Send(progressMsg(p))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cfg := &types.InstallConfig{
|
||||
SetupPath: a.setupPath,
|
||||
AutoSwap: true,
|
||||
}
|
||||
|
||||
err := a.installer.Install(ctx, cfg, progressCh)
|
||||
close(progressCh)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return installCompleteMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) viewInstall() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(RenderLogo())
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(RenderTitle(i18n.T().Get("Install Panel")))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if a.installDone {
|
||||
if a.installErr != nil {
|
||||
sb.WriteString(RenderError(i18n.T().Get("Installation failed")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(ErrorBoxStyle.Render(a.installErr.Error()))
|
||||
} else {
|
||||
sb.WriteString(RenderSuccess(i18n.T().Get("Installation successful")))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Back")))
|
||||
} else if a.installRunning {
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.installSpinner.View(), a.installStep))
|
||||
sb.WriteString(a.installProgress.ViewAs(a.installPercent))
|
||||
sb.WriteString("\n\n")
|
||||
for _, log := range a.installLogs {
|
||||
sb.WriteString(LogStyle.Render(log))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(a.installForm.View())
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ========== 卸载面板 ==========
|
||||
|
||||
func (a *App) updateUninstall(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if !a.uninstallRunning {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
case "enter":
|
||||
if a.uninstallState == 0 {
|
||||
a.uninstallState = 1
|
||||
a.uninstallSkipCount = 0
|
||||
return a, a.tickCountdown()
|
||||
}
|
||||
if a.uninstallState == 1 {
|
||||
// 倒计时期间连按10次enter可跳过
|
||||
a.uninstallSkipCount++
|
||||
if a.uninstallSkipCount >= 10 {
|
||||
a.uninstallState = 2
|
||||
a.initUninstallConfirm()
|
||||
return a, a.uninstallForm.Init()
|
||||
}
|
||||
}
|
||||
if a.uninstallDone {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
}
|
||||
|
||||
case countdownTickMsg:
|
||||
a.uninstallCountdown = msg.remaining
|
||||
if a.uninstallCountdown <= 0 {
|
||||
a.uninstallState = 2
|
||||
a.initUninstallConfirm()
|
||||
return a, a.uninstallForm.Init()
|
||||
}
|
||||
return a, a.tickCountdown()
|
||||
|
||||
case progressMsg:
|
||||
a.uninstallStep = msg.Step
|
||||
if msg.Message != "" {
|
||||
a.uninstallLogs = append(a.uninstallLogs, msg.Message)
|
||||
if len(a.uninstallLogs) > 8 {
|
||||
a.uninstallLogs = a.uninstallLogs[1:]
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case uninstallCompleteMsg:
|
||||
a.uninstallRunning = false
|
||||
a.uninstallDone = true
|
||||
a.uninstallState = 4
|
||||
a.uninstallErr = msg.err
|
||||
return a, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if a.uninstallRunning {
|
||||
var cmd tea.Cmd
|
||||
a.uninstallSpinner, cmd = a.uninstallSpinner.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
if a.uninstallState == 2 {
|
||||
form, cmd := a.uninstallForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.uninstallForm = f
|
||||
}
|
||||
|
||||
if a.uninstallForm.State == huh.StateCompleted {
|
||||
if a.uninstallConfirm {
|
||||
a.uninstallState = 3
|
||||
a.uninstallRunning = true
|
||||
return a, tea.Batch(a.uninstallSpinner.Tick, a.startUninstall())
|
||||
}
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) initUninstallConfirm() {
|
||||
a.uninstallConfirm = false
|
||||
a.uninstallForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(i18n.T().Get("Confirm Uninstallation")).
|
||||
Description(i18n.T().Get("Are you sure you want to uninstall the panel?")).
|
||||
Affirmative(i18n.T().Get("Yes")).
|
||||
Negative(i18n.T().Get("No")).
|
||||
Value(&a.uninstallConfirm),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) tickCountdown() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return countdownTickMsg{remaining: a.uninstallCountdown - 1}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) startUninstall() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := a.uninstaller.Uninstall(ctx, a.setupPath, func(step, message string) {
|
||||
if a.program != nil {
|
||||
a.program.Send(progressMsg{Step: step, Message: message})
|
||||
}
|
||||
})
|
||||
return uninstallCompleteMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) viewUninstall() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(RenderLogo())
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(RenderTitle(i18n.T().Get("Uninstall Panel")))
|
||||
sb.WriteString("\n")
|
||||
|
||||
switch a.uninstallState {
|
||||
case 0: // warning
|
||||
sb.WriteString(WarningBoxStyle.Render(
|
||||
RenderWarning(i18n.T().Get("High-risk operation")) + "\n\n" +
|
||||
i18n.T().Get("Please backup all data before uninstalling.") + "\n" +
|
||||
i18n.T().Get("All data will be cleared and cannot be recovered!"),
|
||||
))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Continue"), "Esc", i18n.T().Get("Back")))
|
||||
|
||||
case 1: // countdown
|
||||
sb.WriteString(WarningBoxStyle.Render(
|
||||
fmt.Sprintf("%s\n\n%s %d %s",
|
||||
i18n.T().Get("For safety, please wait before proceeding"),
|
||||
i18n.T().Get("Waiting:"),
|
||||
a.uninstallCountdown,
|
||||
i18n.T().Get("seconds"),
|
||||
),
|
||||
))
|
||||
sb.WriteString("\n\n")
|
||||
if a.uninstallSkipCount > 0 {
|
||||
sb.WriteString(MutedStyle.Render(fmt.Sprintf("Press Enter %d more times to skip", 10-a.uninstallSkipCount)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(RenderHelp("Esc", i18n.T().Get("Cancel"), "Enter×10", i18n.T().Get("Skip")))
|
||||
|
||||
case 2: // confirm
|
||||
sb.WriteString(a.uninstallForm.View())
|
||||
|
||||
case 3: // running
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.uninstallSpinner.View(), a.uninstallStep))
|
||||
for _, log := range a.uninstallLogs {
|
||||
sb.WriteString(LogStyle.Render(log))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
case 4: // done
|
||||
if a.uninstallErr != nil {
|
||||
sb.WriteString(RenderError(i18n.T().Get("Uninstallation failed")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(ErrorBoxStyle.Render(a.uninstallErr.Error()))
|
||||
} else {
|
||||
sb.WriteString(RenderSuccess(i18n.T().Get("Uninstallation successful")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(i18n.T().Get("Thank you for using AcePanel."))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Back")))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ========== 磁盘分区 ==========
|
||||
|
||||
func (a *App) loadDisks() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
disks, err := a.mounter.ListDisks(ctx)
|
||||
return disksLoadedMsg{disks: disks, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) updateMount(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if !a.mountRunning {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
case "enter":
|
||||
if a.mountDone {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
}
|
||||
|
||||
case disksLoadedMsg:
|
||||
if msg.err != nil {
|
||||
a.mountDone = true
|
||||
a.mountErr = msg.err
|
||||
return a, nil
|
||||
}
|
||||
if len(msg.disks) == 0 {
|
||||
a.mountDone = true
|
||||
a.mountErr = fmt.Errorf(i18n.T().Get("No available disks found"))
|
||||
return a, nil
|
||||
}
|
||||
a.disks = msg.disks
|
||||
a.mountState = 1
|
||||
a.initDiskForm()
|
||||
return a, a.mountDiskForm.Init()
|
||||
|
||||
case progressMsg:
|
||||
a.mountStep = msg.Step
|
||||
if msg.Message != "" {
|
||||
a.mountLogs = append(a.mountLogs, msg.Message)
|
||||
if len(a.mountLogs) > 8 {
|
||||
a.mountLogs = a.mountLogs[1:]
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case mountCompleteMsg:
|
||||
a.mountRunning = false
|
||||
a.mountDone = true
|
||||
a.mountErr = msg.err
|
||||
return a, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if a.mountState == 0 || a.mountRunning {
|
||||
var cmd tea.Cmd
|
||||
a.mountSpinner, cmd = a.mountSpinner.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch a.mountState {
|
||||
case 1: // select disk
|
||||
form, cmd := a.mountDiskForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountDiskForm = f
|
||||
}
|
||||
if a.mountDiskForm.State == huh.StateCompleted {
|
||||
a.mountState = 2
|
||||
a.initMountPointForm()
|
||||
return a, a.mountPointForm.Init()
|
||||
}
|
||||
return a, cmd
|
||||
|
||||
case 2: // select mount point
|
||||
form, cmd := a.mountPointForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountPointForm = f
|
||||
}
|
||||
if a.mountPointForm.State == huh.StateCompleted {
|
||||
a.mountState = 3
|
||||
a.initFSForm()
|
||||
return a, a.mountFSForm.Init()
|
||||
}
|
||||
return a, cmd
|
||||
|
||||
case 3: // select fs
|
||||
form, cmd := a.mountFSForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountFSForm = f
|
||||
}
|
||||
if a.mountFSForm.State == huh.StateCompleted {
|
||||
a.mountState = 4
|
||||
a.initMountConfirmForm()
|
||||
return a, a.mountConfirmForm.Init()
|
||||
}
|
||||
return a, cmd
|
||||
|
||||
case 4: // confirm
|
||||
form, cmd := a.mountConfirmForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountConfirmForm = f
|
||||
}
|
||||
if a.mountConfirmForm.State == huh.StateCompleted {
|
||||
if a.mountConfirmed {
|
||||
a.mountState = 5
|
||||
a.mountRunning = true
|
||||
return a, tea.Batch(a.mountSpinner.Tick, a.startMount())
|
||||
}
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) initDiskForm() {
|
||||
options := make([]huh.Option[string], len(a.disks))
|
||||
for i, disk := range a.disks {
|
||||
options[i] = huh.NewOption(fmt.Sprintf("%s (%s)", disk.Name, disk.Size), disk.Name)
|
||||
}
|
||||
|
||||
a.mountDiskForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(i18n.T().Get("Select Disk")).
|
||||
Description(i18n.T().Get("Select a disk to partition and mount")).
|
||||
Options(options...).
|
||||
Value(&a.mountConfig.Disk),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) initMountPointForm() {
|
||||
a.mountPointForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title(i18n.T().Get("Mount Point")).
|
||||
Description(i18n.T().Get("Enter the mount point path (e.g. /opt/ace)")).
|
||||
Value(&a.mountConfig.MountPoint).
|
||||
Validate(func(s string) error {
|
||||
if len(s) == 0 || s[0] != '/' {
|
||||
return fmt.Errorf(i18n.T().Get("Please enter an absolute path"))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) initFSForm() {
|
||||
a.mountFSForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[types.FSType]().
|
||||
Title(i18n.T().Get("File System")).
|
||||
Description(i18n.T().Get("Select the file system type")).
|
||||
Options(
|
||||
huh.NewOption(fmt.Sprintf("ext4 (%s)", i18n.T().Get("Recommended")), types.FSTypeExt4),
|
||||
huh.NewOption("xfs", types.FSTypeXFS),
|
||||
).
|
||||
Value(&a.mountConfig.FSType),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) initMountConfirmForm() {
|
||||
a.mountConfirmed = false
|
||||
a.mountConfirmForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(i18n.T().Get("Confirm Operation")).
|
||||
Description(i18n.T().Get("Disk: %s, Mount Point: %s, File System: %s",
|
||||
a.mountConfig.Disk,
|
||||
a.mountConfig.MountPoint,
|
||||
a.mountConfig.FSType,
|
||||
)).
|
||||
Affirmative(i18n.T().Get("Yes")).
|
||||
Negative(i18n.T().Get("No")).
|
||||
Value(&a.mountConfirmed),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) startMount() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := a.mounter.Mount(ctx, &a.mountConfig, func(step, message string) {
|
||||
if a.program != nil {
|
||||
a.program.Send(progressMsg{Step: step, Message: message})
|
||||
}
|
||||
})
|
||||
return mountCompleteMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) viewMount() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(RenderLogo())
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(RenderTitle(i18n.T().Get("Disk Partition")))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if a.mountDone {
|
||||
if a.mountErr != nil {
|
||||
sb.WriteString(RenderError(i18n.T().Get("Operation failed")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(ErrorBoxStyle.Render(a.mountErr.Error()))
|
||||
} else {
|
||||
sb.WriteString(RenderSuccess(i18n.T().Get("Partition and mount successful")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(BoxStyle.Render(fmt.Sprintf(
|
||||
"%s: /dev/%s1\n%s: %s\n%s: %s",
|
||||
i18n.T().Get("Device"), a.mountConfig.Disk,
|
||||
i18n.T().Get("Mount Point"), a.mountConfig.MountPoint,
|
||||
i18n.T().Get("File System"), a.mountConfig.FSType,
|
||||
)))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Back")))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
switch a.mountState {
|
||||
case 0: // loading
|
||||
sb.WriteString(fmt.Sprintf("%s %s", a.mountSpinner.View(), i18n.T().Get("Loading disk list...")))
|
||||
|
||||
case 1: // select disk
|
||||
sb.WriteString(i18n.T().Get("Available disks:"))
|
||||
sb.WriteString("\n\n")
|
||||
for _, disk := range a.disks {
|
||||
sb.WriteString(fmt.Sprintf(" • %s (%s)\n", disk.Name, disk.Size))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(a.mountDiskForm.View())
|
||||
|
||||
case 2: // select mount point
|
||||
sb.WriteString(a.mountPointForm.View())
|
||||
|
||||
case 3: // select fs
|
||||
sb.WriteString(a.mountFSForm.View())
|
||||
|
||||
case 4: // confirm
|
||||
sb.WriteString(WarningBoxStyle.Render(i18n.T().Get("Warning: This will erase all data on the disk!")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(a.mountConfirmForm.View())
|
||||
|
||||
case 5: // running
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.mountSpinner.View(), a.mountStep))
|
||||
for _, log := range a.mountLogs {
|
||||
sb.WriteString(LogStyle.Render(log))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
112
internal/ui/styles.go
Normal file
112
internal/ui/styles.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// 颜色定义
|
||||
var (
|
||||
ColorPrimary = lipgloss.Color("#18a058") // 绿色主色
|
||||
ColorSecondary = lipgloss.Color("#36ad6a") // 浅绿色
|
||||
ColorSuccess = lipgloss.Color("#18a058") // 绿色成功
|
||||
ColorWarning = lipgloss.Color("#f0a020") // 橙黄色警告
|
||||
ColorError = lipgloss.Color("#d03050") // 红色错误
|
||||
ColorMuted = lipgloss.Color("#909399") // 灰色次要
|
||||
ColorHighlight = lipgloss.Color("#2080f0") // 蓝色高亮
|
||||
)
|
||||
|
||||
// Logo ASCII艺术
|
||||
const Logo = `
|
||||
_ ____ _
|
||||
/ \ ___ ___ | _ \ __ _ _ __ ___| |
|
||||
/ _ \ / __/ _ \ | |_) / _` + "`" + ` | '_ \ / _ \ |
|
||||
/ ___ \ (_| __/ | __/ (_| | | | | __/ |
|
||||
/_/ \_\___\___| |_| \__,_|_| |_|\___|_|
|
||||
`
|
||||
|
||||
// 样式定义
|
||||
var (
|
||||
LogoStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorPrimary).
|
||||
MarginBottom(1)
|
||||
|
||||
TitleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorPrimary).
|
||||
MarginBottom(1)
|
||||
|
||||
SuccessStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorSuccess).
|
||||
Bold(true)
|
||||
|
||||
WarningStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorWarning).
|
||||
Bold(true)
|
||||
|
||||
ErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorError).
|
||||
Bold(true)
|
||||
|
||||
MutedStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted)
|
||||
|
||||
BoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorMuted).
|
||||
Padding(1, 2)
|
||||
|
||||
WarningBoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorWarning).
|
||||
Padding(1, 2)
|
||||
|
||||
ErrorBoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorError).
|
||||
Padding(1, 2)
|
||||
|
||||
LogStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
PaddingLeft(4)
|
||||
|
||||
KeyStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
Background(lipgloss.Color("#374151")).
|
||||
Padding(0, 1)
|
||||
|
||||
HelpStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
func RenderLogo() string {
|
||||
return LogoStyle.Render(Logo)
|
||||
}
|
||||
|
||||
func RenderTitle(title string) string {
|
||||
return TitleStyle.Render(title)
|
||||
}
|
||||
|
||||
func RenderSuccess(msg string) string {
|
||||
return SuccessStyle.Render("✓ " + msg)
|
||||
}
|
||||
|
||||
func RenderError(msg string) string {
|
||||
return ErrorStyle.Render("✗ " + msg)
|
||||
}
|
||||
|
||||
func RenderWarning(msg string) string {
|
||||
return WarningStyle.Render("⚠ " + msg)
|
||||
}
|
||||
|
||||
func RenderHelp(keys ...string) string {
|
||||
var result string
|
||||
for i := 0; i < len(keys); i += 2 {
|
||||
if i > 0 {
|
||||
result += " "
|
||||
}
|
||||
if i+1 < len(keys) {
|
||||
result += KeyStyle.Render(keys[i]) + " " + MutedStyle.Render(keys[i+1])
|
||||
}
|
||||
}
|
||||
return HelpStyle.Render(result)
|
||||
}
|
||||
6
pkg/embed/embed.go
Normal file
6
pkg/embed/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package embed
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:locales/*
|
||||
var LocalesFS embed.FS
|
||||
0
pkg/embed/locales/.gitkeep
Normal file
0
pkg/embed/locales/.gitkeep
Normal file
398
pkg/embed/locales/helper.pot
Normal file
398
pkg/embed/locales/helper.pot
Normal file
@@ -0,0 +1,398 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: \n"
|
||||
"X-Generator: xgotext\n"
|
||||
|
||||
#: internal/ui/app.go:558
|
||||
msgid "All data will be cleared and cannot be recovered!"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:840
|
||||
msgid "Available disks:"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:415
|
||||
#: internal/ui/app.go:561
|
||||
#: internal/ui/app.go:600
|
||||
#: internal/ui/app.go:831
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:577
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:58
|
||||
msgid "Cannot operate on system disk"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:55
|
||||
msgid "Checking system requirements"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:61
|
||||
msgid "Configuring firewall"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:305
|
||||
msgid "Confirm Installation"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:783
|
||||
msgid "Confirm Operation"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:561
|
||||
msgid "Continue"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:78
|
||||
msgid "Creating %s..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:78
|
||||
msgid "Creating mount point"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:108
|
||||
msgid "Creating partition"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:108
|
||||
msgid "Creating partition on /dev/%s..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:59
|
||||
msgid "Creating swap file"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:62
|
||||
msgid "Creating systemd service"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:56
|
||||
msgid "Creating www user"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:93
|
||||
msgid "Deleting existing partitions"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:93
|
||||
msgid "Deleting existing partitions..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:64
|
||||
msgid "Detecting installed apps"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:825
|
||||
msgid "Device"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:245
|
||||
#: internal/ui/app.go:812
|
||||
msgid "Disk Partition"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:53
|
||||
msgid "Disk not found"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:171
|
||||
msgid "Disk partition and mount successful"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:784
|
||||
msgid "Disk: %s, Mount Point: %s, File System: %s"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:60
|
||||
msgid "Downloading panel"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:522
|
||||
msgid "Enter 'y' to confirm uninstallation"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:751
|
||||
msgid "Enter the mount point path (e.g. /opt/ace)"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:79
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:246
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:112
|
||||
msgid "Failed to create partition"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:143
|
||||
msgid "Failed to get UUID"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:767
|
||||
#: internal/ui/app.go:827
|
||||
msgid "File System"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:566
|
||||
msgid "For safety, please wait before proceeding"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:126
|
||||
msgid "Format failed"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:116
|
||||
msgid "Formatting /dev/%s1 as %s..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:116
|
||||
msgid "Formatting partition"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:556
|
||||
msgid "High-risk operation"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:63
|
||||
msgid "Initializing panel"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:243
|
||||
#: internal/ui/app.go:403
|
||||
msgid "Install Panel"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:95
|
||||
msgid "Installation complete"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:408
|
||||
msgid "Installation failed"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:412
|
||||
msgid "Installation successful"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:58
|
||||
msgid "Installing dependencies"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:62
|
||||
msgid "Installing partition tools"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:62
|
||||
msgid "Installing partition tools..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:837
|
||||
msgid "Loading disk list..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:750
|
||||
#: internal/ui/app.go:826
|
||||
msgid "Mount Point"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:171
|
||||
msgid "Mount complete"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:136
|
||||
msgid "Mount failed"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:86
|
||||
msgid "Mount point is not empty"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:133
|
||||
msgid "Mounting /dev/%s1 to %s..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:133
|
||||
msgid "Mounting partition"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:308
|
||||
#: internal/ui/app.go:790
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:640
|
||||
msgid "No available disks found"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:817
|
||||
msgid "Operation failed"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:57
|
||||
msgid "Optimizing system settings"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:97
|
||||
msgid "Panel installed successfully"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:48
|
||||
msgid "Panel is not installed"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:85
|
||||
msgid "Panel uninstalled successfully"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:306
|
||||
msgid "Panel will be installed to %s"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:821
|
||||
msgid "Partition and mount successful"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:557
|
||||
msgid "Please backup all data before uninstalling."
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:755
|
||||
msgid "Please enter an absolute path"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:770
|
||||
msgid "Recommended"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:82
|
||||
msgid "Removing %s..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:62
|
||||
msgid "Removing /usr/local/sbin/acepanel..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:62
|
||||
msgid "Removing CLI tool"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:82
|
||||
msgid "Removing installation directory"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:57
|
||||
msgid "Removing service file"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:66
|
||||
msgid "Removing swap file"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:66
|
||||
msgid "Removing swap file..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:57
|
||||
msgid "Removing systemd service file..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:738
|
||||
msgid "Select Disk"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:241
|
||||
msgid "Select Operation"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:739
|
||||
msgid "Select a disk to partition and mount"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:768
|
||||
msgid "Select the file system type"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:577
|
||||
msgid "Skip"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:52
|
||||
msgid "Stopping acepanel service..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:52
|
||||
msgid "Stopping panel service"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:597
|
||||
msgid "Thank you for using AcePanel."
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:244
|
||||
#: internal/ui/app.go:550
|
||||
msgid "Uninstall Panel"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:85
|
||||
msgid "Uninstallation complete"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:591
|
||||
msgid "Uninstallation failed"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:595
|
||||
msgid "Uninstallation successful"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:123
|
||||
msgid "Unsupported filesystem type: %s"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:140
|
||||
msgid "Updating /etc/fstab for auto-mount..."
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:140
|
||||
msgid "Updating fstab"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:567
|
||||
msgid "Waiting:"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:855
|
||||
msgid "Warning: This will erase all data on the disk!"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:307
|
||||
#: internal/ui/app.go:789
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/installer.go:90
|
||||
msgid "completed"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/mounter.go:168
|
||||
msgid "fstab configuration error"
|
||||
msgstr ""
|
||||
|
||||
#: internal/service/uninstaller.go:78
|
||||
msgid "fstab configuration error, please check /etc/fstab"
|
||||
msgstr ""
|
||||
|
||||
#: internal/ui/app.go:569
|
||||
msgid "seconds"
|
||||
msgstr ""
|
||||
26
pkg/i18n/i18n.go
Normal file
26
pkg/i18n/i18n.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/leonelquinteros/gotext"
|
||||
)
|
||||
|
||||
var (
|
||||
locale *gotext.Locale
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// Init 设置全局 locale
|
||||
func Init(l *gotext.Locale) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
locale = l
|
||||
}
|
||||
|
||||
// T 获取全局 locale
|
||||
func T() *gotext.Locale {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return locale
|
||||
}
|
||||
81
pkg/types/types.go
Normal file
81
pkg/types/types.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package types
|
||||
|
||||
// OSType 操作系统类型
|
||||
type OSType string
|
||||
|
||||
const (
|
||||
OSUnknown OSType = "unknown"
|
||||
OSRHEL OSType = "rhel" // RHEL/CentOS/Rocky/Alma
|
||||
OSDebian OSType = "debian" // Debian
|
||||
OSUbuntu OSType = "ubuntu" // Ubuntu
|
||||
)
|
||||
|
||||
// ArchType CPU架构类型
|
||||
type ArchType string
|
||||
|
||||
const (
|
||||
ArchUnknown ArchType = "unknown"
|
||||
ArchAMD64 ArchType = "x86_64"
|
||||
ArchARM64 ArchType = "aarch64"
|
||||
)
|
||||
|
||||
// FSType 文件系统类型
|
||||
type FSType string
|
||||
|
||||
const (
|
||||
FSTypeExt4 FSType = "ext4"
|
||||
FSTypeXFS FSType = "xfs"
|
||||
)
|
||||
|
||||
// DiskInfo 磁盘信息
|
||||
type DiskInfo struct {
|
||||
Name string // 设备名 (如 sda, vdb)
|
||||
Size string // 大小
|
||||
Type string // 类型 (disk)
|
||||
Partitions []PartitionInfo // 分区列表
|
||||
}
|
||||
|
||||
// PartitionInfo 分区信息
|
||||
type PartitionInfo struct {
|
||||
Name string // 分区名 (如 sda1)
|
||||
Size string // 大小
|
||||
FSType string // 文件系统类型
|
||||
MountPoint string // 挂载点
|
||||
UUID string // UUID
|
||||
}
|
||||
|
||||
// SystemInfo 系统信息
|
||||
type SystemInfo struct {
|
||||
OS OSType // 操作系统类型
|
||||
Arch ArchType // CPU架构
|
||||
KernelVersion string // 内核版本
|
||||
Is64Bit bool // 是否64位
|
||||
Memory int64 // 内存大小 (MB)
|
||||
Swap int64 // Swap大小 (MB)
|
||||
InChina bool // 是否在中国
|
||||
SSHPort int // SSH端口
|
||||
}
|
||||
|
||||
// InstallConfig 安装配置
|
||||
type InstallConfig struct {
|
||||
SetupPath string // 安装路径
|
||||
InChina bool // 是否使用中国镜像
|
||||
AutoSwap bool // 是否自动创建swap
|
||||
}
|
||||
|
||||
// MountConfig 挂载配置
|
||||
type MountConfig struct {
|
||||
Disk string // 磁盘设备名
|
||||
MountPoint string // 挂载点
|
||||
FSType FSType // 文件系统类型
|
||||
Repartition bool // 是否重新分区
|
||||
}
|
||||
|
||||
// Progress 进度信息
|
||||
type Progress struct {
|
||||
Step string // 当前步骤
|
||||
Message string // 消息
|
||||
Percent float64 // 进度百分比 (0-1)
|
||||
IsError bool // 是否错误
|
||||
Error error // 错误信息
|
||||
}
|
||||
32
renovate.json
Normal file
32
renovate.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"labels": [
|
||||
"🤖 Dependencies"
|
||||
],
|
||||
"commitMessagePrefix": "chore(deps): ",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"automerge": true
|
||||
},
|
||||
"platformAutomerge": true,
|
||||
"postUpdateOptions": [
|
||||
"gomodTidy",
|
||||
"gomodUpdateImportPaths"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "non-major dependencies",
|
||||
"matchUpdateTypes": [
|
||||
"digest",
|
||||
"pin",
|
||||
"patch",
|
||||
"minor"
|
||||
],
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"ignoreDeps": []
|
||||
}
|
||||
Reference in New Issue
Block a user