Compare commits

...

5 Commits

Author SHA1 Message Date
MengMengCode 49a6981e28 Import existing LXC containers into CLICD 2026-06-05 20:01:24 +08:00
MengMengCode 7613d9135b Sync CLI and Web container state 2026-06-05 19:58:42 +08:00
MengMengCode db347aeeb6 Add installer uninstall mode 2026-06-05 19:54:25 +08:00
MengMengCode 3c9ee3552e commit 2026-06-05 19:50:58 +08:00
MengMengCode 48dee79129 commit 2026-06-05 19:45:54 +08:00
9 changed files with 802 additions and 170 deletions
+1
View File
@@ -62,3 +62,4 @@ backend/tmp/
# OS
.DS_Store
Thumbs.db
linux.txt
+14 -54
View File
@@ -37,7 +37,13 @@ CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、C
## 安装
推荐使用 GitHub Actions 构建出的 Release 产物。下载 `clicd-linux-amd64.tar.gz`在目标服务器上执行:
推荐使用最新 Release 一键安装。在目标服务器上执行:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
也可以下载 GitHub Actions 构建出的 Release 产物 `clicd-linux-amd64.tar.gz` 后手动安装:
```bash
tar -xzf clicd-linux-amd64.tar.gz
@@ -66,64 +72,18 @@ Password: 随机 16 位密码
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:"
```
## GitHub Actions 构建
仓库内置 `.github/workflows/build.yml`
- 推送到 `main``master` 时自动构建 Linux amd64 产物。
- 创建 `v*` 标签时自动发布 GitHub Release。
- 支持手动 `workflow_dispatch` 构建。
发布版本示例:
卸载 CLICD
```bash
git tag v1.0.0
git push origin v1.0.0
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
```
Release 会包含
```text
clicd-linux-amd64.tar.gz
clicd-linux-amd64
SHA256SUMS
```
## CLI 模式
进入 CLI
默认只删除 CLICD 服务和 `/usr/local/bin/clicd`,保留 `/root/.clicd` 配置数据和 `/var/lib/lxc` 容器。需要同时删除配置数据时
```bash
clicd cli
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall --purge-data
```
仅使用 CLI,不自动拉起 Web 服务:
```bash
systemctl stop clicd
systemctl disable clicd
clicd cli --no-web
```
重新启用 Web 控制台:
```bash
systemctl enable --now clicd
```
## 常用服务命令
```bash
systemctl status clicd
systemctl restart clicd
journalctl -u clicd -f
```
## 注意事项
- 需要 root 权限安装和运行。
- 宿主机需要支持 LXC。
- NAT 和端口映射依赖 iptables。
- 安全告警依赖 conntrack 或 `/proc/net/nf_conntrack`
- IPv6 分配要求宿主机拥有可用公网 IPv6 地址段。
- 配置文件位于 `/root/.clicd/config.json`,其中包含敏感信息,不要提交到公开仓库。
![alt text](/img/image.png)
![alt text](/img/image-1.png)
![alt text](/img/image-2.png)
+198 -11
View File
@@ -19,9 +19,13 @@ func Run() {
reader := bufio.NewReader(os.Stdin)
for {
if _, err := config.InitConfig(); err != nil {
fmt.Printf("Failed to reload config: %v\n", err)
waitEnter(reader)
}
clearScreen()
printMenu()
fmt.Print("\nSelect action [1-9,0/q]: ")
fmt.Print("\nSelect action [1-11,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
@@ -62,6 +66,14 @@ func Run() {
clearScreen()
cliToggleWebPanel()
waitEnter(reader)
case "10":
clearScreen()
cliImportExistingContainers()
waitEnter(reader)
case "11":
clearScreen()
cliUninstall(reader)
return
case "0":
clearScreen()
cliShowInfo()
@@ -101,6 +113,8 @@ func printMenu() {
fmt.Println(" 7. Reinstall container")
fmt.Println(" 8. Reset web admin password")
fmt.Printf(" 9. %s web panel\n", webStatus)
fmt.Println(" 10. Import existing LXC containers")
fmt.Println(" 11. Uninstall CLICD")
fmt.Println(" 0. System info")
fmt.Println(" q. Quit")
}
@@ -174,6 +188,7 @@ func cliCreateContainer(reader *bufio.Reader) {
if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
}
restartWebPanelForConfigChange()
}
func cliStartContainer(reader *bufio.Reader) {
@@ -227,6 +242,7 @@ func cliDeleteContainer(reader *bufio.Reader) {
return
}
fmt.Printf("Container %s deleted\n", name)
restartWebPanelForConfigChange()
}
func cliReinstallContainer(reader *bufio.Reader) {
@@ -258,6 +274,7 @@ func cliReinstallContainer(reader *bufio.Reader) {
return
}
fmt.Printf("Container %s reinstalled\n", name)
restartWebPanelForConfigChange()
}
func cliResetPassword(reader *bufio.Reader) {
@@ -276,13 +293,13 @@ func cliResetPassword(reader *bufio.Reader) {
fmt.Printf("Reset failed: %v\n", err)
return
}
fmt.Println("Admin password reset. Restart the web service for it to take effect.")
fmt.Println("Admin password reset.")
restartWebPanelForConfigChange()
}
func cliToggleWebPanel() {
if isWebPanelRunning() {
cmd := exec.Command("systemctl", "stop", "clicd")
if err := cmd.Run(); err != nil {
if err := stopService("clicd"); err != nil {
fmt.Printf("Failed to stop web panel: %v\n", err)
return
}
@@ -290,8 +307,7 @@ func cliToggleWebPanel() {
return
}
cmd := exec.Command("systemctl", "start", "clicd")
if err := cmd.Run(); err != nil {
if err := startService("clicd"); err != nil {
fmt.Printf("Failed to start web panel: %v\n", err)
return
}
@@ -299,12 +315,183 @@ func cliToggleWebPanel() {
}
func isWebPanelRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
if err != nil {
return false
if commandExists("systemctl") {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
if err == nil && strings.TrimSpace(string(output)) == "active" {
return true
}
}
return strings.TrimSpace(string(output)) == "active"
if commandExists("rc-service") {
cmd := exec.Command("rc-service", "clicd", "status")
return cmd.Run() == nil
}
return false
}
func cliImportExistingContainers() {
fmt.Println("\n--- Import existing LXC containers ---")
fmt.Println("This imports containers from /var/lib/lxc into CLICD config.")
fmt.Println("Imported containers keep their real LXC name so Web and CLI manage the same container.")
imported, err := manager.ImportExistingClicdContainers()
if err != nil {
fmt.Printf("Import failed: %v\n", err)
return
}
if len(imported) == 0 {
fmt.Println("No new ct-* containers found to import.")
return
}
fmt.Printf("Imported %d container(s):\n", len(imported))
for _, c := range imported {
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
}
restartWebPanelForConfigChange()
}
func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- Uninstall CLICD ---")
fmt.Println("This removes the CLICD service and /usr/local/bin/clicd.")
fmt.Println("LXC containers and /root/.clicd are kept unless you explicitly choose to delete them.")
if os.Geteuid() != 0 {
fmt.Println("Uninstall must be run as root.")
fmt.Println("Run: sudo clicd cli --no-web")
return
}
confirm := promptString(reader, "Type uninstall to continue", "no")
if strings.ToLower(confirm) != "uninstall" {
fmt.Println("Canceled")
return
}
removeData := strings.ToLower(promptString(reader, "Delete /root/.clicd config/data? Type delete-data", "no")) == "delete-data"
removeContainers := strings.ToLower(promptString(reader, "Destroy CLICD-managed LXC containers? Type delete-containers", "no")) == "delete-containers"
if removeContainers {
destroyManagedContainers()
}
stopAndRemoveService()
removePath("/usr/local/bin/clicd")
removePath("/etc/sysctl.d/99-clicd.conf")
removePath("/var/log/clicd.log")
removePath("/var/log/clicd.err")
if removeData {
removePath("/root/.clicd")
}
reloadSysctl()
fmt.Println()
fmt.Println("CLICD has been uninstalled.")
if !removeData {
fmt.Println("Kept data: /root/.clicd")
}
if !removeContainers {
fmt.Println("Kept LXC containers under /var/lib/lxc")
}
}
func destroyManagedContainers() {
containers := append([]config.Container(nil), config.AppConfig.Containers...)
if len(containers) == 0 {
fmt.Println("No CLICD-managed containers found in config.")
return
}
for _, c := range containers {
fmt.Printf("Destroying container %s...\n", c.Name)
if err := manager.DestroyContainer(c.ID); err != nil {
fmt.Printf("Failed to destroy %s: %v\n", c.Name, err)
}
}
}
func stopAndRemoveService() {
if commandExists("systemctl") {
runQuiet("systemctl", "stop", "clicd")
runQuiet("systemctl", "disable", "clicd")
removePath("/etc/systemd/system/clicd.service")
runQuiet("systemctl", "daemon-reload")
runQuiet("systemctl", "reset-failed", "clicd")
}
if commandExists("rc-service") {
runQuiet("rc-service", "clicd", "stop")
}
if commandExists("rc-update") {
runQuiet("rc-update", "del", "clicd", "default")
}
removePath("/etc/init.d/clicd")
}
func removePath(path string) {
if _, err := os.Lstat(path); os.IsNotExist(err) {
return
}
if err := os.RemoveAll(path); err != nil {
fmt.Printf("Failed to remove %s: %v\n", path, err)
return
}
fmt.Printf("Removed %s\n", path)
}
func reloadSysctl() {
if commandExists("sysctl") {
runQuiet("sysctl", "--system")
}
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run()
}
func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil {
fmt.Printf("Web panel reload skipped: %v\n", err)
return
}
fmt.Println("Web panel reloaded to pick up config changes.")
}
func stopService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "stop", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "stop").Run()
}
return fmt.Errorf("no supported service manager found")
}
func startService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "start", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "start").Run()
}
return fmt.Errorf("no supported service manager found")
}
func restartService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "restart", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "restart").Run()
}
return fmt.Errorf("no supported service manager found")
}
func cliShowInfo() {
+4
View File
@@ -68,6 +68,7 @@ type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
LXCName string `json:"lxc_name,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
@@ -97,6 +98,9 @@ type Container struct {
// LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string {
if c.LXCName != "" {
return c.LXCName
}
return fmt.Sprintf("ct-%d", c.ID)
}
+99
View File
@@ -1915,6 +1915,105 @@ func (m *Manager) ListContainers() ([]config.Container, error) {
return containers, nil
}
// ImportExistingClicdContainers imports existing LXC containers into the CLICD
// config. Native CLICD containers keep ct-{id}; arbitrary LXC names are stored
// in Container.LXCName so Web and CLI can manage the same imported container.
func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
entries, err := os.ReadDir(m.LxcPath)
if err != nil {
return nil, err
}
existingIDs := make(map[int]bool)
existingNames := make(map[string]bool)
existingLXCNames := make(map[string]bool)
maxID := config.AppConfig.NextContainerID - 1
for _, c := range config.AppConfig.Containers {
existingIDs[c.ID] = true
existingNames[c.Name] = true
existingLXCNames[c.LxcName()] = true
if c.ID > maxID {
maxID = c.ID
}
}
re := regexp.MustCompile(`^ct-([0-9]+)$`)
imported := make([]config.Container, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
lxcName := entry.Name()
if existingLXCNames[lxcName] {
continue
}
id := 0
if matches := re.FindStringSubmatch(lxcName); len(matches) == 2 {
if parsed, err := strconv.Atoi(matches[1]); err == nil && parsed > 0 && !existingIDs[parsed] {
id = parsed
}
}
if id == 0 {
id = maxID + 1
for existingIDs[id] {
id++
}
}
name := lxcName
if existingNames[name] {
name = fmt.Sprintf("imported-%d", id)
}
status, err := m.GetContainerStatus(lxcName)
if err != nil || status == "" {
status = "unknown"
}
c := config.Container{
ID: id,
UUID: config.NewContainerUUID(),
Name: name,
LXCName: lxcName,
Template: "imported",
VCPU: 1,
RAMMB: 512,
DiskGB: 10,
NetworkBWMbps: 100,
MonthlyTrafficGB: 1000,
TrafficMode: "total",
Status: status,
CreatedAt: time.Now().Format(time.RFC3339),
PortMappingLimit: 2,
}
if status == "running" {
if ip, err := m.GetContainerIP(lxcName); err == nil {
c.IP = ip
}
}
config.AppConfig.Containers = append(config.AppConfig.Containers, c)
imported = append(imported, c)
existingIDs[id] = true
existingNames[name] = true
existingLXCNames[lxcName] = true
if id > maxID {
maxID = id
}
}
if len(imported) > 0 {
config.AppConfig.NextContainerID = maxID + 1
if err := config.SaveConfig(); err != nil {
return nil, err
}
}
return imported, nil
}
// ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error {
c := config.FindContainer(id)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

+486 -105
View File
@@ -1,105 +1,428 @@
#!/bin/bash
set -euo pipefail
#!/bin/sh
set -eu
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}"
echo "====================================="
echo " CLICD Installation"
echo " CLICD Installer"
echo "====================================="
if [ "$EUID" -ne 0 ]; then
log() {
echo "[clicd] $*"
}
die() {
echo "ERROR: $*" >&2
exit 1
}
has_cmd() {
command -v "$1" >/dev/null 2>&1
}
is_systemd() {
has_cmd systemctl && [ -d /run/systemd/system ]
}
is_openrc() {
has_cmd rc-service && has_cmd rc-update
}
if [ "$(id -u)" -ne 0 ]; then
echo "Please run as root: sudo ./install.sh"
echo "Or: curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo bash"
echo "Or: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
echo "Uninstall: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
exit 1
fi
if [ ! -f "./clicd" ]; then
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz"
if [ "$VERSION" = "latest" ]; then
DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
else
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}"
fi
echo "clicd binary not found in current directory."
echo "Downloading release package: ${DOWNLOAD_URL}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
elif command -v wget >/dev/null 2>&1; then
wget -O "$TMP_DIR/$ASSET" "$DOWNLOAD_URL"
else
echo "ERROR: curl or wget is required to download the release package."
exit 1
fi
tar -xzf "$TMP_DIR/$ASSET" -C "$TMP_DIR"
cd "$TMP_DIR/clicd-linux-amd64"
OS_ID="unknown"
OS_LIKE=""
if [ -r /etc/os-release ]; then
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_LIKE="${ID_LIKE:-}"
fi
if ! command -v lxc-create >/dev/null 2>&1; then
echo "LXC is not installed. Installing dependencies..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y lxc lxc-templates bridge-utils xz-utils quota
elif command -v yum >/dev/null 2>&1; then
yum install -y epel-release
yum install -y lxc lxc-templates xz quota
elif command -v dnf >/dev/null 2>&1; then
dnf install -y lxc lxc-templates xz quota
else
echo "Could not detect package manager. Please install LXC manually."
exit 1
usage() {
cat << EOF
Usage:
./install.sh
./install.sh uninstall [--purge-data] [--delete-containers]
Environment:
CLICD_REPO=owner/repo
CLICD_VERSION=latest|v1.0.0
Examples:
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
EOF
}
remove_path() {
path="$1"
if [ ! -e "$path" ] && [ ! -L "$path" ]; then
return
fi
fi
rm -rf "$path"
log "Removed $path"
}
# Setup subordinate UID/GID for unprivileged containers
echo "Setting up subordinate UID/GID ranges..."
grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
uninstall_clicd() {
purge_data=0
delete_containers=0
# Enable ext4 project quota if supported
if tune2fs -l /dev/sda1 2>/dev/null | grep -q 'Filesystem features'; then
echo "Enabling ext4 project quota..."
mkdir -p /etc/initramfs-tools/hooks /etc/initramfs-tools/scripts/local-premount
# Hook to copy tune2fs into initramfs
cat > /etc/initramfs-tools/hooks/tune2fs-hook << 'HOOK'
#!/bin/sh
PREREQ=""
prereqs() { echo "$PREREQ"; }
case "$1" in prereqs) prereqs; exit 0;; esac
. /usr/share/initramfs-tools/hook-functions
copy_exec /sbin/tune2fs /sbin/tune2fs
copy_exec /usr/sbin/setquota /usr/sbin/setquota
HOOK
chmod +x /etc/initramfs-tools/hooks/tune2fs-hook
# Script to run tune2fs before mount
cat > /etc/initramfs-tools/scripts/local-premount/prjquota << 'SCRIPT'
#!/bin/sh
PREREQ=""
prereqs() { echo "$PREREQ"; }
case "$1" in prereqs) prereqs; exit 0;; esac
/sbin/tune2fs -O project -Q prjquota /dev/sda1 2>/dev/null
SCRIPT
chmod +x /etc/initramfs-tools/scripts/local-premount/prjquota
update-initramfs -u -k all 2>/dev/null || true
# Add prjquota to fstab if not already there
grep -q 'prjquota' /etc/fstab 2>/dev/null || sed -i 's|ext4 rw,|ext4 rw,prjquota,|' /etc/fstab
fi
shift || true
while [ "$#" -gt 0 ]; do
case "$1" in
--purge-data)
purge_data=1
;;
--delete-containers)
delete_containers=1
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown uninstall option: $1"
;;
esac
shift
done
cp ./clicd /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd
echo "Installed binary: /usr/local/bin/clicd"
log "Uninstalling CLICD..."
cat > /etc/systemd/system/clicd.service << 'EOF'
if has_cmd systemctl; then
systemctl stop clicd >/dev/null 2>&1 || true
systemctl disable clicd >/dev/null 2>&1 || true
fi
if has_cmd rc-service; then
rc-service clicd stop >/dev/null 2>&1 || true
fi
if has_cmd rc-update; then
rc-update del clicd default >/dev/null 2>&1 || true
fi
if [ "$delete_containers" -eq 1 ]; then
log "Destroying CLICD-style LXC containers named ct-*..."
for container_dir in /var/lib/lxc/ct-*; do
[ -d "$container_dir" ] || continue
container_name="$(basename "$container_dir")"
lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true
lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true
remove_path "$container_dir"
done
fi
remove_path /etc/systemd/system/clicd.service
remove_path /etc/init.d/clicd
remove_path /usr/local/bin/clicd
remove_path /etc/sysctl.d/99-clicd.conf
remove_path /var/log/clicd.log
remove_path /var/log/clicd.err
if [ "$purge_data" -eq 1 ]; then
remove_path /root/.clicd
fi
if has_cmd systemctl; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl reset-failed clicd >/dev/null 2>&1 || true
fi
if has_cmd sysctl; then
sysctl --system >/dev/null 2>&1 || true
fi
echo ""
echo "====================================="
echo " CLICD Uninstalled"
echo "====================================="
if [ "$purge_data" -eq 0 ]; then
echo " Kept data: /root/.clicd"
fi
if [ "$delete_containers" -eq 0 ]; then
echo " Kept LXC containers: /var/lib/lxc"
echo " To delete CLICD-style ct-* containers too:"
echo " ./install.sh uninstall --delete-containers"
fi
echo "====================================="
}
case "$ACTION" in
install|"")
;;
uninstall|remove)
uninstall_clicd "$@"
exit 0
;;
-h|--help|help)
usage
exit 0
;;
*)
die "Unknown action: $ACTION"
;;
esac
install_apk() {
log "Installing dependencies with apk..."
apk update
apk add --no-cache \
ca-certificates \
curl \
wget \
tar \
gzip \
xz \
lxc \
lxc-download \
lxc-openrc \
lxc-bridge \
lxc-templates \
bridge-utils \
iproute2 \
iptables \
dnsmasq
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
done
}
install_apt() {
log "Installing dependencies with apt..."
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y \
ca-certificates \
curl \
wget \
tar \
gzip \
xz-utils \
lxc \
lxc-templates \
lxcfs \
bridge-utils \
uidmap \
iproute2 \
iptables \
conntrack \
quota \
e2fsprogs \
xfsprogs \
dnsmasq-base
}
enable_el_repos() {
if has_cmd dnf; then
dnf install -y 'dnf-command(config-manager)' >/dev/null 2>&1 || true
dnf install -y epel-release || true
dnf config-manager --set-enabled crb >/dev/null 2>&1 || true
dnf config-manager --set-enabled powertools >/dev/null 2>&1 || true
elif has_cmd yum; then
yum install -y yum-utils >/dev/null 2>&1 || true
yum install -y epel-release || true
yum-config-manager --enable powertools >/dev/null 2>&1 || true
fi
}
install_dnf() {
log "Installing dependencies with dnf..."
enable_el_repos
dnf install -y \
ca-certificates \
curl \
wget \
tar \
gzip \
xz \
lxc \
lxc-templates \
bridge-utils \
iproute \
iptables \
conntrack-tools \
shadow-utils \
quota \
e2fsprogs \
xfsprogs \
dnsmasq
dnf install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
}
install_yum() {
log "Installing dependencies with yum..."
enable_el_repos
yum install -y \
ca-certificates \
curl \
wget \
tar \
gzip \
xz \
lxc \
lxc-templates \
bridge-utils \
iproute \
iptables \
conntrack-tools \
shadow-utils \
quota \
e2fsprogs \
xfsprogs \
dnsmasq
yum install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
}
install_dependencies() {
case "$OS_ID" in
ubuntu|debian)
install_apt
;;
alpine)
install_apk
;;
centos|rhel|rocky|almalinux|fedora)
if has_cmd dnf; then
install_dnf
elif has_cmd yum; then
install_yum
else
die "dnf/yum not found on $OS_ID"
fi
;;
*)
if has_cmd apt-get; then
install_apt
elif has_cmd apk; then
install_apk
elif has_cmd dnf; then
install_dnf
elif has_cmd yum; then
install_yum
else
die "Unsupported Linux distribution: ${OS_ID} ${OS_LIKE}"
fi
;;
esac
has_cmd lxc-create || die "lxc-create is still missing after dependency installation."
has_cmd iptables || die "iptables is still missing after dependency installation."
has_cmd ip || die "iproute2/ip command is still missing after dependency installation."
}
configure_kernel_networking() {
log "Enabling kernel forwarding settings..."
cat > /etc/sysctl.d/99-clicd.conf << 'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
net.bridge.bridge-nf-call-iptables = 0
net.bridge.bridge-nf-call-ip6tables = 0
EOF
modprobe br_netfilter >/dev/null 2>&1 || true
sysctl --system >/dev/null 2>&1 || true
}
setup_lxc_services() {
log "Configuring LXC services..."
if is_systemd; then
systemctl enable --now lxcfs >/dev/null 2>&1 || true
systemctl enable --now lxc-net >/dev/null 2>&1 || true
systemctl enable --now lxc >/dev/null 2>&1 || true
return
fi
if is_openrc; then
rc-update add cgroups default >/dev/null 2>&1 || true
rc-service cgroups start >/dev/null 2>&1 || true
rc-update add lxc default >/dev/null 2>&1 || true
rc-service lxc start >/dev/null 2>&1 || true
rc-update add lxcfs default >/dev/null 2>&1 || true
rc-service lxcfs start >/dev/null 2>&1 || true
return
fi
die "No supported service manager found. CLICD supports systemd or OpenRC."
}
setup_subids() {
log "Setting up subordinate UID/GID ranges..."
touch /etc/subuid /etc/subgid
grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
}
try_enable_project_quota() {
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
log "Project quota auto-enable skipped for root filesystem: ${root_fs:-unknown}"
return
fi
if ! has_cmd tune2fs; then
log "Project quota auto-enable skipped because tune2fs is unavailable."
return
fi
if tune2fs -l "$root_src" 2>/dev/null | grep -q 'project'; then
log "Ext4 project quota support already appears to be enabled."
return
fi
log "Ext4 project quota is not enabled. Disk limits will fall back to loopback images."
}
download_release_if_needed() {
if [ -f "./clicd" ]; then
return
fi
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
download_url="https://github.com/${REPO}/releases/latest/download/${ASSET}"
else
download_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${ASSET}"
fi
log "clicd binary not found in current directory."
log "Downloading release package: ${download_url}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' 0
if has_cmd curl; then
curl -fL "$download_url" -o "$tmp_dir/$ASSET"
elif has_cmd wget; then
wget -O "$tmp_dir/$ASSET" "$download_url"
else
die "curl or wget is required to download the release package."
fi
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir"
cd "$tmp_dir/clicd-linux-amd64"
[ -f "./clicd" ] || die "Downloaded release package did not contain clicd."
}
install_binary() {
cp ./clicd /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd
log "Installed binary: /usr/local/bin/clicd"
}
install_systemd_service() {
cat > /etc/systemd/system/clicd.service << 'EOF'
[Unit]
Description=CLICD - LXC Container Manager
After=network.target lxc.service
@@ -115,23 +438,81 @@ Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable clicd
systemctl restart clicd
systemctl daemon-reload
systemctl enable clicd
systemctl restart clicd
}
install_openrc_service() {
cat > /etc/init.d/clicd << 'EOF'
#!/sbin/openrc-run
name="CLICD"
description="CLICD - LXC Container Manager"
command="/usr/local/bin/clicd"
command_args="server"
command_background=true
pidfile="/run/clicd.pid"
output_log="/var/log/clicd.log"
error_log="/var/log/clicd.err"
depend() {
need net
after lxc
}
EOF
chmod +x /etc/init.d/clicd
rc-update add clicd default
rc-service clicd restart
}
install_service() {
log "Installing CLICD service..."
if is_systemd; then
install_systemd_service
elif is_openrc; then
install_openrc_service
else
die "No supported service manager found. CLICD supports systemd or OpenRC."
fi
}
print_summary() {
echo ""
echo "====================================="
echo " Installation Complete"
echo "====================================="
echo " Web: http://YOUR_SERVER_IP:8999"
echo " Binary: /usr/local/bin/clicd"
if is_systemd; then
echo " Service: systemctl {start|stop|restart|status} clicd"
echo " Logs: journalctl -u clicd -f"
elif is_openrc; then
echo " Service: rc-service clicd {start|stop|restart|status}"
echo " Logs: tail -f /var/log/clicd.log /var/log/clicd.err"
fi
echo "====================================="
echo ""
echo "Initial credentials, if this was the first run:"
if is_systemd; then
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
else
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
fi
echo ""
echo "If no password is shown, this server already had /root/.clicd/config.json."
echo "The existing admin password cannot be recovered from the bcrypt hash."
}
install_dependencies
configure_kernel_networking
setup_lxc_services
setup_subids
try_enable_project_quota
download_release_if_needed
install_binary
install_service
sleep 2
echo ""
echo "====================================="
echo " Installation Complete"
echo "====================================="
echo " Web: http://YOUR_SERVER_IP:8999"
echo " Service: systemctl {start|stop|restart|status} clicd"
echo " Logs: journalctl -u clicd -f"
echo "====================================="
echo ""
echo "Initial credentials, if this was the first run:"
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
echo ""
echo "If no password is shown, this server already had /root/.clicd/config.json."
echo "The existing admin password cannot be recovered from the bcrypt hash."
print_summary