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.
- Kiến Trúc Hệ Thống
- Cấu Trúc Thư Mục
- Yêu Cầu Hệ Thống
- Cài Đặt & Triển Khai
- Backend (Golang)
- Frontend (React)
- Cloudflare Tunnel
- Bảo Mật
- Monitoring & Health Checks
- Xử Lý Xung Đột
- Backup & Restore
- Troubleshooting
- Checklist Triển Khai
- 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ề).
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
- 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 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-tempcd ~
git https://github.com/phucdhh/HeyMac.git
cd HeyMac# 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.yamlcd backend
go mod download
go build -o heymac cmd/server/main.go
cd ..cd frontend
npm install
npm run build
cd ..# 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# 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 reloadLưu ý:
- Không cấp quyền cho
docker *hoặcbrew *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
# 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# 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# 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 heymacmodule 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
)# 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: 30File: 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
}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,
}
}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,
})
}- 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
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┌─────────────────────────────────────────┐
│ Header: Logo | Search | User Profile │
├──────────┬──────────────────────────────┤
│ │ │
│ Sidebar │ Main Content (Grid) │
│ │ │
│ - Dashboard│ ┌──────────┬──────────┐ │
│ - Services │ │ CPU/RAM │ Disk │ │
│ - Terminal │ │ Charts │ Usage │ │
│ - Logs │ └──────────┴──────────┘ │
│ - Settings │ ┌──────────────────────┐ │
│ │ │ Service Cards Grid │ │
│ │ │ (HeyTeX, AIThink...) │ │
│ │ └──────────────────────┘ │
└──────────┴──────────────────────────────┘
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>
);
}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;
}
}brew install cloudflared# 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 raFile: ~/.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:404Lưu ý:
- Thay
<UUID-vừa-tạo>bằng UUID thực tế - Thay
youruserbằng username của bạn - Nếu đã có tunnel khác, có thể thêm ingress rule vào tunnel hiện có
# Tạo DNS record trong Cloudflare Dashboard
# Hoặc dùng lệnh:
cloudflared tunnel route dns heymac-server your-domainFile: 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# 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 độngFile: 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
}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()
}
}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
}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(),
})
}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
}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
}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
fiTrước khi restart service, cần:
- Check connections (cho database services)
- Check active users (cho web services)
- Warning dialog trong frontend
- Graceful shutdown khi có thể
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
}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()
}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"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"# 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# 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# 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# 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# 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># 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- Gin Framework Documentation
- React Documentation
- Shadcn/UI
- Cloudflare Tunnel Documentation
- macOS LaunchDaemon
AGPL3, please see in LICENSE.md
Chúc bạn triển khai thành công! 🚀