2
0
mirror of https://github.com/acepanel/helper.git synced 2026-02-03 17:47:16 +08:00

feat: init

This commit is contained in:
2026-01-17 22:58:56 +08:00
commit 3799eaae4b
34 changed files with 3745 additions and 0 deletions

44
.github/workflows/build.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
package app
import "github.com/google/wire"
var ProviderSet = wire.NewSet(
NewHelper,
)

View 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
View 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
}

View 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
View 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
View 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
}

View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
package embed
import "embed"
//go:embed all:locales/*
var LocalesFS embed.FS

View File

View 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
View 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
View 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
View 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": []
}