Skip to content

phucdhh/HeyMac

Repository files navigation

🍏 HeyMac - Apple Silicon Server Manager

HeyMac là ứng dụng quản lý máy chủ dành riêng cho Mac Mini (Apple Silicon M1/M2/M3) chạy Headless. Được thiết kế với giao diện hiện đại, giúp quản lý các dịch vụ giáo dục và web (Moodle, WordPress, Jupyter, Node.js) một cách trực quan qua giao thức HTTPS an toàn.

📋 Mục lục


🌐 Kiến trúc hệ thống

  • Public URL: https://<yourdomain> (Có thể tạo qua Cloudflare Tunnel)
  • Backend: Golang (Gin Framework). Chạy dưới quyền user, sử dụng sudo qua cấu hình visudo để điều khiển dịch vụ hệ thống.
  • Frontend: React (Vite) + Tailwind CSS + Shadcn/UI (Tạo giao diện Dashboard dạng lưới giống CPanel).
  • Database: SQLite (Lưu cấu hình user, danh sách dịch vụ - Không cần cài đặt nặng nề).

📂 Cấu trúc thư mục

HeyMac/
├── backend/                  # Golang Source Code
│   ├── cmd/
│   │   └── server/
│   │       └── main.go       # Entry point
│   ├── internal/
│   │   ├── api/              # API Handlers (Routes)
│   │   ├── auth/             # JWT Authentication
│   │   ├── system/           # Logic gọi lệnh macOS (Exec, Sysctl)
│   │   ├── services/         # Logic quản lý Docker, Brew, PM2
│   │   ├── database/         # SQLite connect
│   │   └── middleware/       # Rate limiting, CORS, Validation
│   ├── config/               # Load file config.yaml
│   └── scripts/              # Các file shell script hỗ trợ
├── frontend/                 # React Source Code
│   ├── src/
│   │   ├── components/       # UI Components (Cards, Charts, Terminal)
│   │   ├── layouts/          # Dashboard Layout (Sidebar + Content)
│   │   ├── pages/            # Dashboard, FileManager, Settings
│   │   ├── services/         # Axios calls tới Backend
│   │   └── stores/           # Zustand (State management)
│   └── package.json
├── deployments/              # File cấu hình triển khai
│   ├── com.heymac.plist      # File LaunchDaemon cho macOS
│   ├── com.cloudflared.plist # LaunchDaemon cho Cloudflare Tunnel
│   └── cloudflared/          # Config cho Cloudflare Tunnel
├── scripts/                  # Scripts hỗ trợ
│   ├── backup.sh             # Backup database và config
│   ├── restore.sh            # Restore từ backup
│   └── check-port.sh         # Check port conflict
├── config.yaml.example       # File cấu hình mẫu
├── Makefile                  # Lệnh build nhanh
├── .gitignore
└── README.md

🔧 Yêu cầu hệ thống

Phần mềm cần thiết

  • macOS: 12.0+ (Monterey trở lên)
  • Go: 1.21+
  • Node.js: 18+ và npm/yarn
  • Homebrew: Để cài các dependencies
  • Docker: (Nếu quản lý Docker containers)
  • PM2: (Nếu quản lý Node.js apps)
  • Cloudflared: Để kết nối Cloudflare Tunnel

Cài đặt Dependencies

# Cài đặt Go (nếu chưa có)
brew install go

# Cài đặt Node.js (nếu chưa có)
brew install node

# Cài đặt Docker (nếu chưa có)
brew install --cask docker

# Cài đặt PM2 (nếu chưa có)
npm install -g pm2

# Cài đặt Cloudflared
brew install cloudflared

# Cài đặt công cụ đo nhiệt độ CPU (tùy chọn)
brew install osx-cpu-temp

🚀 Cài đặt và triển khai

Bước 1: Clone Repository

cd ~
git https://github.com/phucdhh/HeyMac.git
cd HeyMac

Bước 2: Cấu hình

# Copy file config mẫu
cp config.yaml.example config.yaml

# Sửa file config.yaml theo môi trường của bạn
nano config.yaml

Bước 3: Build Backend

cd backend
go mod download
go build -o heymac cmd/server/main.go
cd ..

Bước 4: Build Frontend

cd frontend
npm install
npm run build
cd ..

Bước 5: Kiểm tra Port Conflict

# Kiểm tra port <your port> có đang được sử dụng không
lsof -i :<your port>

# Nếu port đã được sử dụng, đổi port trong config.yaml

Bước 6: Cấp quyền Sudo (Quan trọng)

⚠️ LƯU Ý BẢO MẬT: Chỉ cấp quyền cho các lệnh cụ thể, không cấp quyền quá rộng.

# Mở file sudoers để chỉnh sửa
sudo visudo

# Thêm các dòng sau (thay 'admin' bằng username của bạn):
# Cho phép user 'admin' chạy các lệnh Docker cụ thể không cần pass
admin ALL=(ALL) NOPASSWD: /usr/local/bin/docker restart *, /usr/local/bin/docker start *, /usr/local/bin/docker stop *

# Cho phép user 'admin' chạy các lệnh Brew services cụ thể
admin ALL=(ALL) NOPASSWD: /usr/local/bin/brew services restart *, /usr/local/bin/brew services start *, /usr/local/bin/brew services stop *

# Cho phép user 'admin' chạy các lệnh PM2 cụ thể
admin ALL=(ALL) NOPASSWD: /usr/local/bin/pm2 restart *, /usr/local/bin/pm2 start *, /usr/local/bin/pm2 stop *

# Cho phép graceful reload Nginx (quan trọng để không gián đoạn service)
admin ALL=(ALL) NOPASSWD: /usr/local/bin/nginx -s reload

Lưu ý:

  • Không cấp quyền cho docker * hoặc brew * vì quá rộng
  • Chỉ cấp quyền cho các lệnh cụ thể cần thiết
  • Kiểm tra đường dẫn chính xác của các lệnh: which docker, which brew, which pm2

Bước 7: Tạo thư mục Log và Data

# Tạo thư mục log (không cần root)
mkdir -p ~/Library/Logs/heymac

# Tạo thư mục data cho SQLite
mkdir -p ~/Library/Application\ Support/heymac

# Set permissions
chmod 755 ~/Library/Logs/heymac
chmod 755 ~/Library/Application\ Support/heymac

Bước 8: Test chạy thủ công

# Chạy backend để test
cd backend
./heymac

# Mở browser: http://localhost:<your port>
# Nếu thấy giao diện, backend đã chạy thành công

Bước 9: Tạo LaunchDaemon

# Copy file plist vào thư mục deployments (đã được tạo sẵn)
# Sửa đường dẫn trong file com.heymac.plist cho đúng với hệ thống của bạn

# Copy vào LaunchDaemons (cần sudo)
sudo cp deployments/com.heymac.plist /Library/LaunchDaemons/com.heymac.plist

# Set ownership
sudo chown root:wheel /Library/LaunchDaemons/com.heymac.plist
sudo chmod 644 /Library/LaunchDaemons/com.heymac.plist

# Load service
sudo launchctl load -w /Library/LaunchDaemons/com.heymac.plist

# Kiểm tra status
sudo launchctl list | grep heymac

🛠 Phần 1: Backend (Golang)

1.1. Thư viện yêu cầu (go.mod)

module heymac

go 1.21

require (
    github.com/gin-gonic/gin v1.9.1
    github.com/shirou/gopsutil/v3 v3.23.11
    github.com/glebarez/sqlite v1.10.0
    github.com/golang-jwt/jwt/v5 v5.2.0
    gorm.io/gorm v1.25.5
    github.com/gin-contrib/cors v1.5.0
    github.com/gin-contrib/ratelimit v0.0.0-20230311030328-8b8b1c8b8b8b
    github.com/sirupsen/logrus v1.9.3
    gopkg.in/yaml.v3 v3.0.1
)

1.2. File Cấu hình (config.yaml.example)

# Server Configuration
server:
  port: <your port>
  host: "localhost"
  mode: "release" # debug, release, test

# Security
security:
  jwt_secret: "your-secret-key-change-this-in-production"
  jwt_expire_hours: 24
  rate_limit_per_minute: 60
  cors_allowed_origins:
    - "https://<your domain>"
    - "http://localhost:5173" # Vite dev server

# Database
database:
  path: "~/Library/Application Support/heymac/heymac.db"
  backup_enabled: true
  backup_interval_hours: 24

# Logging
logging:
  level: "info" # debug, info, warn, error
  path: "~/Library/Logs/heymac"
  max_size_mb: 100
  max_backups: 7
  compress: true

# Services Management
services:
  # Whitelist các services được phép quản lý
  docker_whitelist:
    - "moodle"
    - "wordpress"
    - "jupyter"
  
  brew_whitelist:
    - "nginx"
    - "mysql"
  
  pm2_whitelist:
    - "heystat"
    - "cosheet"
  
  # Blacklist các services KHÔNG được phép quản lý
  docker_blacklist: []
  brew_blacklist: []
  pm2_blacklist: []

# System Monitoring
monitoring:
  update_interval_seconds: 5
  disk_alert_threshold_percent: 80
  memory_alert_threshold_percent: 90
  cpu_alert_threshold_percent: 90

# Health Check
health:
  enabled: true
  check_interval_seconds: 30

1.3. Logic cốt lõi: Service Manager

File: backend/internal/services/manager.go

package services

import (
    "context"
    "fmt"
    "os/exec"
    "time"
    
    "github.com/sirupsen/logrus"
)

type ServiceType string

const (
    TypeDocker ServiceType = "docker"
    TypeBrew   ServiceType = "brew"
    TypePM2    ServiceType = "pm2"
    TypePID    ServiceType = "pid"
)

type Service struct {
    ID          string      `json:"id"`
    Name        string      `json:"name"`
    Type        ServiceType `json:"type"`
    Port        int         `json:"port"`
    Status      string      `json:"status"` // running, stopped, error
    LastRestart time.Time   `json:"last_restart"`
}

// CheckServiceStatus kiểm tra trạng thái service
func (s *Service) CheckStatus() (string, error) {
    switch s.Type {
    case TypeDocker:
        cmd := exec.Command("docker", "ps", "--filter", fmt.Sprintf("name=%s", s.Name), "--format", "{{.Names}}")
        output, err := cmd.Output()
        if err != nil {
            return "stopped", err
        }
        if len(output) > 0 {
            return "running", nil
        }
        return "stopped", nil
        
    case TypeBrew:
        cmd := exec.Command("brew", "services", "list", "--json")
        // Parse JSON để check status
        // Implementation chi tiết...
        return "running", nil
        
    case TypePM2:
        cmd := exec.Command("pm2", "jlist")
        // Parse JSON để check status
        // Implementation chi tiết...
        return "running", nil
    }
    return "unknown", fmt.Errorf("unknown service type: %s", s.Type)
}

// Start service với error handling
func (s *Service) Start() error {
    logrus.WithFields(logrus.Fields{
        "service": s.Name,
        "type":    s.Type,
    }).Info("Starting service")
    
    var cmd *exec.Cmd
    
    switch s.Type {
    case TypeDocker:
        cmd = exec.Command("sudo", "docker", "start", s.Name)
    case TypeBrew:
        cmd = exec.Command("sudo", "brew", "services", "start", s.Name)
    case TypePM2:
        cmd = exec.Command("sudo", "pm2", "start", s.Name)
    default:
        return fmt.Errorf("unsupported service type: %s", s.Type)
    }
    
    output, err := cmd.CombinedOutput()
    if err != nil {
        logrus.WithError(err).WithField("output", string(output)).Error("Failed to start service")
        return fmt.Errorf("failed to start %s: %w", s.Name, err)
    }
    
    s.Status = "running"
    s.LastRestart = time.Now()
    logrus.WithField("service", s.Name).Info("Service started successfully")
    return nil
}

// Stop service với error handling
func (s *Service) Stop() error {
    logrus.WithFields(logrus.Fields{
        "service": s.Name,
        "type":    s.Type,
    }).Info("Stopping service")
    
    var cmd *exec.Cmd
    
    switch s.Type {
    case TypeDocker:
        cmd = exec.Command("sudo", "docker", "stop", s.Name)
    case TypeBrew:
        cmd = exec.Command("sudo", "brew", "services", "stop", s.Name)
    case TypePM2:
        cmd = exec.Command("sudo", "pm2", "stop", s.Name)
    default:
        return fmt.Errorf("unsupported service type: %s", s.Type)
    }
    
    output, err := cmd.CombinedOutput()
    if err != nil {
        logrus.WithError(err).WithField("output", string(output)).Error("Failed to stop service")
        return fmt.Errorf("failed to stop %s: %w", s.Name, err)
    }
    
    s.Status = "stopped"
    logrus.WithField("service", s.Name).Info("Service stopped successfully")
    return nil
}

// Restart service với error handling
func (s *Service) Restart() error {
    if err := s.Stop(); err != nil {
        return err
    }
    time.Sleep(2 * time.Second) // Đợi service stop hoàn toàn
    return s.Start()
}

// GracefulReload cho Nginx (quan trọng để không gián đoạn)
func (s *Service) GracefulReload() error {
    if s.Type != TypeBrew || s.Name != "nginx" {
        return fmt.Errorf("graceful reload only supported for nginx")
    }
    
    cmd := exec.Command("sudo", "nginx", "-s", "reload")
    output, err := cmd.CombinedOutput()
    if err != nil {
        logrus.WithError(err).WithField("output", string(output)).Error("Failed to reload nginx")
        return fmt.Errorf("failed to reload nginx: %w", err)
    }
    
    logrus.Info("Nginx reloaded successfully")
    return nil
}

1.4. Logic System Monitor (Apple Silicon)

File: backend/internal/system/monitor.go

package system

import (
    "context"
    "os/exec"
    "strconv"
    "strings"
    "time"
    
    "github.com/gin-gonic/gin"
    "github.com/shirou/gopsutil/v3/cpu"
    "github.com/shirou/gopsutil/v3/disk"
    "github.com/shirou/gopsutil/v3/mem"
    "github.com/sirupsen/logrus"
)

type SystemStats struct {
    CPUModel      string  `json:"cpu_model"`
    CPUUsage      float64 `json:"cpu_usage"`
    MemoryUsage   float64 `json:"memory_usage"`
    MemoryTotal   uint64  `json:"memory_total"`
    MemoryUsed    uint64  `json:"memory_used"`
    DiskUsage    float64 `json:"disk_usage"`
    DiskTotal    uint64  `json:"disk_total"`
    DiskUsed     uint64  `json:"disk_used"`
    Temperature  string  `json:"temperature"`
    Uptime       string  `json:"uptime"`
    Timestamp    time.Time `json:"timestamp"`
}

var lastStats *SystemStats
var statsCacheDuration = 2 * time.Second

func GetSystemStats() (gin.H, error) {
    // Cache để giảm CPU usage
    if lastStats != nil && time.Since(lastStats.Timestamp) < statsCacheDuration {
        return statsToMap(lastStats), nil
    }
    
    stats := &SystemStats{
        Timestamp: time.Now(),
    }
    
    // CPU Info
    cpuInfo, err := cpu.Info()
    if err != nil {
        logrus.WithError(err).Warn("Failed to get CPU info")
    } else if len(cpuInfo) > 0 {
        stats.CPUModel = cpuInfo[0].ModelName
    }
    
    // CPU Usage
    cpuPercent, err := cpu.Percent(time.Second, false)
    if err != nil {
        logrus.WithError(err).Warn("Failed to get CPU usage")
    } else if len(cpuPercent) > 0 {
        stats.CPUUsage = cpuPercent[0]
    }
    
    // Memory
    memInfo, err := mem.VirtualMemory()
    if err != nil {
        logrus.WithError(err).Warn("Failed to get memory info")
    } else {
        stats.MemoryUsage = memInfo.UsedPercent
        stats.MemoryTotal = memInfo.Total
        stats.MemoryUsed = memInfo.Used
    }
    
    // Disk
    diskInfo, err := disk.Usage("/")
    if err != nil {
        logrus.WithError(err).Warn("Failed to get disk info")
    } else {
        stats.DiskUsage = diskInfo.UsedPercent
        stats.DiskTotal = diskInfo.Total
        stats.DiskUsed = diskInfo.Used
    }
    
    // Temperature (cần cài osx-cpu-temp)
    tempOut, err := exec.Command("osx-cpu-temp").Output()
    if err != nil {
        logrus.WithError(err).Debug("Failed to get temperature (osx-cpu-temp not installed?)")
        stats.Temperature = "N/A"
    } else {
        stats.Temperature = strings.TrimSpace(string(tempOut))
    }
    
    // Uptime
    uptimeOut, err := exec.Command("uptime").Output()
    if err != nil {
        logrus.WithError(err).Warn("Failed to get uptime")
        stats.Uptime = "N/A"
    } else {
        stats.Uptime = strings.TrimSpace(string(uptimeOut))
    }
    
    lastStats = stats
    return statsToMap(stats), nil
}

func statsToMap(stats *SystemStats) gin.H {
    return gin.H{
        "cpu_model":    stats.CPUModel,
        "cpu_usage":    stats.CPUUsage,
        "memory_usage": stats.MemoryUsage,
        "memory_total": stats.MemoryTotal,
        "memory_used":  stats.MemoryUsed,
        "disk_usage":   stats.DiskUsage,
        "disk_total":   stats.DiskTotal,
        "disk_used":    stats.DiskUsed,
        "temperature": stats.Temperature,
        "uptime":       stats.Uptime,
        "timestamp":    stats.Timestamp,
    }
}

1.5. Middleware: Rate Limiting & CORS

File: backend/internal/middleware/ratelimit.go

package middleware

import (
    "github.com/gin-contrib/ratelimit"
    "github.com/gin-gonic/gin"
    "time"
)

func RateLimitMiddleware() gin.HandlerFunc {
    // Giới hạn 60 requests/phút
    return ratelimit.New(ratelimit.Store{
        New: func() ratelimit.Limiter {
            return ratelimit.NewLimiter(ratelimit.PerMinute(60))
        },
    })
}

File: backend/internal/middleware/cors.go

package middleware

import (
    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "time"
)

func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc {
    return cors.New(cors.Config{
        AllowOrigins:     allowedOrigins,
        AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
        ExposeHeaders:    []string{"Content-Length"},
        AllowCredentials: true,
        MaxAge:           12 * time.Hour,
    })
}

🎨 Phần 2: Frontend (React)

2.1. Công nghệ

  • Framework: React 18+ (Vite)
  • CSS: Tailwind CSS
  • UI Components: Shadcn/UI
  • Icons: Lucide React
  • Terminal Web: xterm.js
  • State Management: Zustand
  • HTTP Client: Axios
  • Real-time: WebSocket hoặc Polling

2.2. Setup Project

cd frontend
npm create vite@latest . -- --template react-ts
npm install
npm install -D tailwindcss postcss autoprefixer
npm install axios zustand lucide-react
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu
npm install xterm xterm-addon-fit
npx shadcn-ui@latest init

2.3. Bố cục Dashboard

┌─────────────────────────────────────────┐
│ Header: Logo | Search | User Profile  │
├──────────┬──────────────────────────────┤
│          │                              │
│ Sidebar  │  Main Content (Grid)        │
│          │                              │
│ - Dashboard│  ┌──────────┬──────────┐  │
│ - Services │  │ CPU/RAM  │ Disk     │  │
│ - Terminal │  │ Charts   │ Usage    │  │
│ - Logs     │  └──────────┴──────────┘  │
│ - Settings │  ┌──────────────────────┐  │
│            │  │ Service Cards Grid   │  │
│            │  │ (HeyTeX, AIThink...) │  │
│            │  └──────────────────────┘  │
└──────────┴──────────────────────────────┘

2.4. Component mẫu: Service Card

File: frontend/src/components/ServiceCard.tsx

import { Play, Square, Settings, ExternalLink } from "lucide-react";
import { useState } from "react";

interface ServiceCardProps {
  name: string;
  status: "running" | "stopped" | "error";
  type: string;
  port?: number;
  url?: string;
  onToggle: () => Promise<void>;
  onConfig?: () => void;
}

export default function ServiceCard({
  name,
  status,
  type,
  port,
  url,
  onToggle,
  onConfig,
}: ServiceCardProps) {
  const [loading, setLoading] = useState(false);
  const isRunning = status === "running";

  const handleToggle = async () => {
    setLoading(true);
    try {
      await onToggle();
    } catch (error) {
      console.error("Failed to toggle service:", error);
      // Show error toast
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="border rounded-lg p-4 shadow-sm bg-card hover:shadow-md transition-all">
      <div className="flex justify-between items-center mb-2">
        <h3 className="font-bold text-lg">{name}</h3>
        <span
          className={`text-xs px-2 py-1 rounded ${
            isRunning
              ? "bg-green-100 text-green-800"
              : "bg-red-100 text-red-800"
          }`}
        >
          {status.toUpperCase()}
        </span>
      </div>
      
      <div className="text-gray-500 text-sm mb-4 space-y-1">
        <p>Type: {type}</p>
        {port && <p>Port: {port}</p>}
      </div>

      <div className="flex gap-2">
        <button
          onClick={handleToggle}
          disabled={loading}
          className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm text-white transition ${
            isRunning
              ? "bg-red-500 hover:bg-red-600"
              : "bg-green-600 hover:bg-green-700"
          } disabled:opacity-50`}
        >
          {isRunning ? <Square size={16} /> : <Play size={16} />}
          {loading ? "..." : isRunning ? "Stop" : "Start"}
        </button>
        
        {onConfig && (
          <button
            onClick={onConfig}
            className="flex items-center gap-1 px-3 py-1.5 rounded text-sm border hover:bg-gray-50"
          >
            <Settings size={16} />
            Config
          </button>
        )}
        
        {url && isRunning && (
          <a
            href={url}
            target="_blank"
            rel="noopener noreferrer"
            className="flex items-center gap-1 px-3 py-1.5 rounded text-sm border hover:bg-gray-50"
          >
            <ExternalLink size={16} />
            Open
          </a>
        )}
      </div>
    </div>
  );
}

2.5. Error Boundary

File: frontend/src/components/ErrorBoundary.tsx

import React, { Component, ErrorInfo, ReactNode } from "react";

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  public state: State = {
    hasError: false,
  };

  public static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Uncaught error:", error, errorInfo);
  }

  public render() {
    if (this.state.hasError) {
      return (
        <div className="flex items-center justify-center min-h-screen">
          <div className="text-center">
            <h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
            <p className="text-gray-600 mb-4">{this.state.error?.message}</p>
            <button
              onClick={() => window.location.reload()}
              className="px-4 py-2 bg-blue-500 text-white rounded"
            >
              Reload Page
            </button>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

☁️ Phần 3: Kết nối Cloudflare Tunnel (nếu cần)

3.1. Cài đặt Cloudflared

brew install cloudflared

3.2. Login và tạo Tunnel

# Login vào Cloudflare (mở browser để authenticate)
cloudflared tunnel login

# Tạo tunnel mới
cloudflared tunnel create heymac-server

# Lưu ý: Ghi lại UUID được tạo ra

3.3. Cấu hình Tunnel

File: ~/.cloudflared/config.yml

tunnel: <UUID-vừa-tạo>
credentials-file: /Users/youruser/.cloudflared/<UUID>.json

ingress:
  # Map domain chính vào Web HeyMac
  - hostname: <your domain>
    service: http://localhost:<port>
  
  # Catch-all rule (phải ở cuối)
  - service: http_status:404

Lưu ý:

  • Thay <UUID-vừa-tạo> bằng UUID thực tế
  • Thay youruser bằng username của bạn
  • Nếu đã có tunnel khác, có thể thêm ingress rule vào tunnel hiện có

3.4. Cấu hình DNS

# Tạo DNS record trong Cloudflare Dashboard
# Hoặc dùng lệnh:
cloudflared tunnel route dns heymac-server your-domain

3.5. Tạo LaunchDaemon cho Cloudflare Tunnel

File: deployments/com.cloudflared.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.cloudflared</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/cloudflared</string>
        <string>tunnel</string>
        <string>run</string>
        <string>heymac-server</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</true>
    <key>StandardOutPath</key>
    <string>/Users/admin/Library/Logs/cloudflared.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/admin/Library/Logs/cloudflared.err</string>
</dict>
</plist>

Kích hoạt:

sudo cp deployments/com.cloudflared.plist /Library/LaunchDaemons/com.cloudflared.plist
sudo chown root:wheel /Library/LaunchDaemons/com.cloudflared.plist
sudo chmod 644 /Library/LaunchDaemons/com.cloudflared.plist
sudo launchctl load -w /Library/LaunchDaemons/com.cloudflared.plist

3.6. Test Tunnel

# Chạy thủ công để test
cloudflared tunnel run heymac-server

# Mở browser: https://your-domain
# Nếu thấy giao diện HeyMac, tunnel đã hoạt động

🔒 Phần 5: Bảo mật

5.1. JWT Authentication

File: backend/internal/auth/jwt.go

package auth

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    Username string `json:"username"`
    Role     string `json:"role"`
    jwt.RegisteredClaims
}

func GenerateToken(username, role, secret string) (string, error) {
    claims := Claims{
        Username: username,
        Role:     role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(secret))
}

func ValidateToken(tokenString, secret string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        return []byte(secret), nil
    })
    
    if err != nil {
        return nil, err
    }
    
    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        return claims, nil
    }
    
    return nil, jwt.ErrSignatureInvalid
}

5.2. Input Validation

File: backend/internal/middleware/validation.go

package middleware

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func ValidateServiceName() gin.HandlerFunc {
    return func(c *gin.Context) {
        var req struct {
            Name string `json:"name" binding:"required,min=1,max=100"`
        }
        
        if err := c.ShouldBindJSON(&req); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            c.Abort()
            return
        }
        
        // Whitelist check
        // Blacklist check
        
        c.Set("service_name", req.Name)
        c.Next()
    }
}

5.3. Service Whitelist/Blacklist

Trong backend/internal/services/manager.go:

func (s *Service) IsAllowed(whitelist, blacklist []string) bool {
    // Check blacklist first
    for _, item := range blacklist {
        if s.Name == item {
            return false
        }
    }
    
    // If whitelist exists, check it
    if len(whitelist) > 0 {
        for _, item := range whitelist {
            if s.Name == item {
                return true
            }
        }
        return false
    }
    
    // No whitelist = allow all (except blacklist)
    return true
}

📊 Phần 6: Monitoring & Health Checks

6.1. Health Check Endpoint

File: backend/internal/api/health.go

package api

import (
    "net/http"
    "github.com/gin-gonic/gin"
    "github.com/sirupsen/logrus"
)

func HealthCheck(c *gin.Context) {
    // Check database connection
    // Check disk space
    // Check critical services
    
    c.JSON(http.StatusOK, gin.H{
        "status": "healthy",
        "timestamp": time.Now(),
    })
}

6.2. Service Health Check

func (s *Service) HealthCheck() (bool, error) {
    if s.Port == 0 {
        return false, fmt.Errorf("port not configured")
    }
    
    // Try to connect to port
    conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", s.Port), 2*time.Second)
    if err != nil {
        return false, err
    }
    defer conn.Close()
    
    return true, nil
}

6.3. Disk Usage Alert

func CheckDiskUsage(threshold float64) (bool, error) {
    diskInfo, err := disk.Usage("/")
    if err != nil {
        return false, err
    }
    
    if diskInfo.UsedPercent > threshold {
        logrus.Warnf("Disk usage exceeded threshold: %.2f%%", diskInfo.UsedPercent)
        // Send alert (email, webhook, etc.)
        return false, fmt.Errorf("disk usage too high: %.2f%%", diskInfo.UsedPercent)
    }
    
    return true, nil
}

⚠️ Phần 7: Xử lý xung đột

7.1. Kiểm tra Port Conflict

Script: scripts/check-port.sh

#!/bin/bash

PORT=${1:-5656}

if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null ; then
    echo "Port $PORT is already in use"
    echo "Process using port:"
    lsof -Pi :$PORT -sTCP:LISTEN
    exit 1
else
    echo "Port $PORT is available"
    exit 0
fi

7.2. Kiểm Tra Services đang chạy

Trước khi restart service, cần:

  1. Check connections (cho database services)
  2. Check active users (cho web services)
  3. Warning dialog trong frontend
  4. Graceful shutdown khi có thể

7.3. Docker Container Conflict

func CheckDockerContainer(name string) (bool, error) {
    cmd := exec.Command("docker", "ps", "--filter", fmt.Sprintf("name=%s", name), "--format", "{{.Names}}")
    output, err := cmd.Output()
    if err != nil {
        return false, err
    }
    
    if len(output) == 0 {
        return false, fmt.Errorf("container %s not found", name)
    }
    
    return true, nil
}

7.4. Nginx Graceful Reload

Luôn dùng nginx -s reload thay vì brew services restart nginx để không gián đoạn connections:

if s.Name == "nginx" {
    return s.GracefulReload()
}

💾 Phần 8: Backup & Restore

8.1. Backup Script

File: scripts/backup.sh

#!/bin/bash

BACKUP_DIR="$HOME/backups/heymac"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_PATH="$BACKUP_DIR/heymac_backup_$TIMESTAMP"

mkdir -p "$BACKUP_DIR"

# Backup database
DB_PATH="$HOME/Library/Application Support/heymac/heymac.db"
if [ -f "$DB_PATH" ]; then
    cp "$DB_PATH" "$BACKUP_PATH.db"
    echo "Database backed up to $BACKUP_PATH.db"
fi

# Backup config
CONFIG_PATH="$HOME/HeyMac/config.yaml"
if [ -f "$CONFIG_PATH" ]; then
    cp "$CONFIG_PATH" "$BACKUP_PATH.yaml"
    echo "Config backed up to $BACKUP_PATH.yaml"
fi

# Keep only last 7 backups
cd "$BACKUP_DIR"
ls -t heymac_backup_* | tail -n +8 | xargs rm -f

echo "Backup completed: $BACKUP_PATH"

8.2. Restore Script

File: scripts/restore.sh

#!/bin/bash

BACKUP_FILE=$1

if [ -z "$BACKUP_FILE" ]; then
    echo "Usage: $0 <backup_file.db>"
    exit 1
fi

DB_PATH="$HOME/Library/Application Support/heymac/heymac.db"

# Stop HeyMac service
sudo launchctl unload /Library/LaunchDaemons/com.heymac.plist

# Backup current database
if [ -f "$DB_PATH" ]; then
    mv "$DB_PATH" "${DB_PATH}.old"
fi

# Restore
cp "$BACKUP_FILE" "$DB_PATH"

# Start HeyMac service
sudo launchctl load /Library/LaunchDaemons/com.heymac.plist

echo "Restore completed"

8.3. Tự động Backup (Cron)

# Thêm vào crontab
crontab -e

# Backup mỗi ngày lúc 2:00 AM
0 2 * * * /Users/admin/HeyMac/scripts/backup.sh >> /Users/admin/Library/Logs/heymac/backup.log 2>&1

🔧 Troubleshooting

Port đã dùng

# Tìm process đang dùng port
lsof -i :<port>

# Kill process (cẩn thận!)
kill -9 <PID>

# Hoặc đổi port trong config.yaml

Permission Denied

# Kiểm tra sudo permissions
sudo -l

# Kiểm tra file permissions
ls -la /Library/LaunchDaemons/com.heymac.plist

# Kiểm tra log
tail -f ~/Library/Logs/heymac.log

Cloudflare Tunnel không kết nối

# Kiểm tra tunnel status
cloudflared tunnel list

# Test tunnel
cloudflared tunnel run heymac-server

# Kiểm tra DNS
dig <your-domain>

# Kiểm tra log
tail -f ~/Library/Logs/cloudflared.log

Service không Start/Stop

# Kiểm tra sudo permissions cho lệnh cụ thể
sudo docker ps
sudo brew services list
sudo pm2 list

# Kiểm tra log
tail -f ~/Library/Logs/heymac.log

# Test lệnh thủ công
sudo docker restart <container-name>

Database Locked

# SQLite có thể bị lock nếu nhiều process truy cập
# Kiểm tra processes đang dùng database
lsof ~/Library/Application\ Support/heymac/heymac.db

# Restart HeyMac service
sudo launchctl unload /Library/LaunchDaemons/com.heymac.plist
sudo launchctl load /Library/LaunchDaemons/com.heymac.plist

📚 Tài Liệu Tham Khảo


📄 License

AGPL3, please see in LICENSE.md


Chúc bạn triển khai thành công! 🚀

HeyMac

About

A small control panel for Mac mini / Mac Studio headless server

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published