diff --git a/.env.example b/.env.example index 23bb369..99fb310 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,8 @@ DATABASE_URL_DOCKER=postgres://panel:panel_secret@postgres:5432/panel?sslmode=di # HTTP API панели HTTP_PORT=8000 + +# Локальный сервер (создаётся при первичной настройке) +DEFAULT_SERVER_NAME=local +DEFAULT_SERVER_HOSTNAME=server1 +DEFAULT_SERVER_IP=127.0.0.1 diff --git a/README.md b/README.md index 0a30c16..0866676 100644 --- a/README.md +++ b/README.md @@ -67,21 +67,35 @@ export HTTP_PORT=8000 go run ./cmd/panel ``` -Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации. +Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации. После входа — панель управления сайтами. -API: -- `GET /health` — healthcheck -- `GET /api/v1/setup/status` — `{"setup_required": true|false}` -- `POST /api/v1/setup/register` — создание первого суперадмина (только пока нет пользователей) +### API + +**Авторизация** +- `POST /api/v1/auth/login` — `{"username":"admin","password":"..."}` → cookie `panel_session` +- `POST /api/v1/auth/logout` — выход (требует сессию) +- `GET /api/v1/auth/me` — текущий пользователь + +**Сайты** (требуют авторизацию) +- `GET /api/v1/sites` — список сайтов +- `POST /api/v1/sites` — создать сайт ```json { - "email": "admin@example.com", - "username": "admin", - "password": "secret123" + "name": "mysite", + "domain": "example.com", + "php_version": "8.3" } ``` +**PHP** +- `GET /api/v1/php-versions` — доступные версии PHP + +**Setup** +- `GET /health` — healthcheck +- `GET /api/v1/setup/status` — `{"setup_required": true|false}` +- `POST /api/v1/setup/register` — создание первого суперадмина (только пока нет пользователей) + ## Структура проекта ``` diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 71521c2..a6e8f19 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -3,7 +3,6 @@ package main import ( "context" "errors" - "fmt" "log" "net/http" "os" @@ -31,8 +30,7 @@ func main() { } defer pool.Close() - addr := fmt.Sprintf(":%s", cfg.HTTPPort) - srv, err := server.New(pool, addr) + srv, err := server.New(pool, cfg) if err != nil { log.Fatal(err) } diff --git a/docker-compose.yml b/docker-compose.yml index f3b1e14..2863e44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,9 @@ services: environment: DATABASE_URL: ${DATABASE_URL_DOCKER:-postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable} HTTP_PORT: "8000" + DEFAULT_SERVER_NAME: ${DEFAULT_SERVER_NAME:-local} + DEFAULT_SERVER_HOSTNAME: ${DEFAULT_SERVER_HOSTNAME:-server1} + DEFAULT_SERVER_IP: ${DEFAULT_SERVER_IP:-127.0.0.1} ports: - "${HTTP_PORT:-8000}:8000" depends_on: diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..c797377 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,34 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + + "golang.org/x/crypto/bcrypt" +) + +var ErrInvalidCredentials = errors.New("invalid credentials") + +func CheckPassword(hash, password string) error { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return ErrInvalidCredentials + } + return nil +} + +func NewSessionToken() (raw string, hash string, err error) { + b := make([]byte, 32) + if _, err = rand.Read(b); err != nil { + return "", "", err + } + raw = hex.EncodeToString(b) + hash = HashToken(raw) + return raw, hash, nil +} + +func HashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/authsvc/service.go b/internal/authsvc/service.go new file mode 100644 index 0000000..2477ade --- /dev/null +++ b/internal/authsvc/service.go @@ -0,0 +1,100 @@ +package authsvc + +import ( + "context" + "errors" + "time" + + "github.com/panelhosting/panel/internal/auth" + "github.com/panelhosting/panel/internal/models" + "github.com/panelhosting/panel/internal/repository" +) + +type Service struct { + users *repository.UserRepository + sessions *repository.SessionRepository + ttl time.Duration +} + +func NewService(users *repository.UserRepository, sessions *repository.SessionRepository, ttlHours int) *Service { + return &Service{ + users: users, + sessions: sessions, + ttl: time.Duration(ttlHours) * time.Hour, + } +} + +type LoginInput struct { + Username string + Password string + IP string + UserAgent string +} + +type LoginResult struct { + Token string + User *models.User +} + +func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error) { + user, err := s.users.GetByUsername(ctx, in.Username) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return nil, auth.ErrInvalidCredentials + } + return nil, err + } + + if user.Status != models.UserStatusActive { + return nil, auth.ErrInvalidCredentials + } + + if err := auth.CheckPassword(user.PasswordHash, in.Password); err != nil { + return nil, auth.ErrInvalidCredentials + } + + raw, hash, err := auth.NewSessionToken() + if err != nil { + return nil, err + } + + expires := time.Now().Add(s.ttl) + if err := s.sessions.Create(ctx, user.ID, hash, in.IP, in.UserAgent, expires); err != nil { + return nil, err + } + + _ = s.users.UpdateLastLogin(ctx, user.ID) + user.PasswordHash = "" + + return &LoginResult{Token: raw, User: user}, nil +} + +func (s *Service) Logout(ctx context.Context, token string) error { + if token == "" { + return nil + } + return s.sessions.DeleteByToken(ctx, auth.HashToken(token)) +} + +func (s *Service) UserByToken(ctx context.Context, token string) (*models.User, error) { + if token == "" { + return nil, repository.ErrUserNotFound + } + userID, err := s.sessions.GetUserIDByToken(ctx, auth.HashToken(token)) + if err != nil { + return nil, err + } + user, err := s.users.GetByID(ctx, userID) + if err != nil { + return nil, err + } + if user.Status != models.UserStatusActive { + return nil, repository.ErrUserNotFound + } + user.PasswordHash = "" + return user, nil +} + +func (s *Service) IsAdmin(user *models.User) bool { + return user.Role == models.RoleSuperAdmin || user.Role == models.RoleAdmin +} diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go new file mode 100644 index 0000000..99287b8 --- /dev/null +++ b/internal/bootstrap/server.go @@ -0,0 +1,28 @@ +package bootstrap + +import ( + "context" + + "github.com/panelhosting/panel/internal/config" + "github.com/panelhosting/panel/internal/repository" +) + +func EnsureDefaultServer(ctx context.Context, servers *repository.ServerRepository, cfg *config.Config) error { + count, err := servers.Count(ctx) + if err != nil { + return err + } + if count > 0 { + return nil + } + _, err = servers.CreateDefault(ctx, cfg.DefaultServerName, cfg.DefaultServerHost, cfg.DefaultServerIP) + return err +} + +func GrantServerAccess(ctx context.Context, servers *repository.ServerRepository, userID int64) error { + srv, err := servers.GetFirst(ctx) + if err != nil { + return err + } + return servers.GrantAccess(ctx, userID, srv.ID, true) +} diff --git a/internal/config/config.go b/internal/config/config.go index 8a37c19..ff673b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,8 +6,13 @@ import ( ) type Config struct { - DatabaseURL string - HTTPPort string + DatabaseURL string + HTTPPort string + DefaultServerName string + DefaultServerHost string + DefaultServerIP string + SessionCookieName string + SessionTTLHours int } func Load() (*Config, error) { @@ -21,8 +26,33 @@ func Load() (*Config, error) { port = "8000" } + serverName := os.Getenv("DEFAULT_SERVER_NAME") + if serverName == "" { + serverName = "local" + } + + serverHost := os.Getenv("DEFAULT_SERVER_HOSTNAME") + if serverHost == "" { + serverHost = "localhost" + } + + serverIP := os.Getenv("DEFAULT_SERVER_IP") + if serverIP == "" { + serverIP = "127.0.0.1" + } + + cookieName := os.Getenv("SESSION_COOKIE_NAME") + if cookieName == "" { + cookieName = "panel_session" + } + return &Config{ - DatabaseURL: url, - HTTPPort: port, + DatabaseURL: url, + HTTPPort: port, + DefaultServerName: serverName, + DefaultServerHost: serverHost, + DefaultServerIP: serverIP, + SessionCookieName: cookieName, + SessionTTLHours: 168, // 7 days }, nil } diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..e5542c1 --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,89 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/panelhosting/panel/internal/auth" + "github.com/panelhosting/panel/internal/authsvc" + "github.com/panelhosting/panel/internal/config" + "github.com/panelhosting/panel/internal/middleware" +) + +type AuthHandler struct { + svc *authsvc.Service + cfg *config.Config +} + +func NewAuthHandler(svc *authsvc.Service, cfg *config.Config) *AuthHandler { + return &AuthHandler{svc: svc, cfg: cfg} +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + + result, err := h.svc.Login(r.Context(), authsvc.LoginInput{ + Username: req.Username, + Password: req.Password, + IP: r.RemoteAddr, + UserAgent: r.UserAgent(), + }) + if err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + writeError(w, http.StatusUnauthorized, "invalid credentials") + return + } + writeError(w, http.StatusInternalServerError, "login failed") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: h.cfg.SessionCookieName, + Value: result.Token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: h.cfg.SessionTTLHours * 3600, + }) + + writeJSON(w, http.StatusOK, map[string]any{"user": result.User}) +} + +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + token := "" + if c, err := r.Cookie(h.cfg.SessionCookieName); err == nil { + token = c.Value + } + _ = h.svc.Logout(r.Context(), token) + + http.SetCookie(w, &http.Cookie{ + Name: h.cfg.SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) + + writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) +} + +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + user, ok := middleware.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} diff --git a/internal/handler/health.go b/internal/handler/health.go index f6d78e9..b88ab73 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -1,8 +1,10 @@ package handler import ( + "fmt" "net/http" + "github.com/panelhosting/panel/internal/middleware" "github.com/panelhosting/panel/internal/setup" ) @@ -12,107 +14,129 @@ func Health(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"status":"ok"}`)) } -func IndexPage(svc *setup.Service) http.HandlerFunc { +func IndexPage(setupSvc *setup.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - required, err := svc.Status(r.Context()) + required, err := setupSvc.Status(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "internal error") return } - index(required)(w, r) - } -} - -func index(setupRequired bool) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if setupRequired { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(setupPageHTML)) + if required { + serveHTML(w, setupPageHTML) return } - writeJSON(w, http.StatusOK, map[string]string{ - "name": "PanelHosting", - "version": "0.1.0", - "status": "running", - }) + if user, ok := middleware.UserFromContext(r.Context()); ok { + serveHTML(w, dashboardPageHTML(user.Username)) + return + } + serveHTML(w, loginPageHTML) } } +func serveHTML(w http.ResponseWriter, html string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(html)) +} + +const baseCSS = ` + * { box-sizing: border-box; } + body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; min-height: 100vh; } + a { color: #60a5fa; } + .wrap { max-width: 960px; margin: 0 auto; padding: 1.5rem; } + .card { background: #1e293b; border-radius: 12px; padding: 1.5rem; box-shadow: 0 12px 32px rgba(0,0,0,.25); margin-bottom: 1rem; } + h1 { margin: 0 0 .5rem; font-size: 1.5rem; } + h2 { margin: 0 0 1rem; font-size: 1.1rem; color: #cbd5e1; } + p.sub { color: #94a3b8; margin: 0 0 1.25rem; } + label { display: block; margin-bottom: .35rem; font-size: .85rem; color: #cbd5e1; } + input, select { width: 100%; padding: .65rem .75rem; margin-bottom: 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #f8fafc; font-size: 1rem; } + input:focus, select:focus { outline: 2px solid #3b82f6; border-color: transparent; } + button { padding: .65rem 1rem; background: #3b82f6; color: #fff; border: none; border-radius: 8px; font-size: .95rem; font-weight: 600; cursor: pointer; } + button:hover { background: #2563eb; } + button:disabled { opacity: .6; cursor: not-allowed; } + button.secondary { background: #334155; } + button.secondary:hover { background: #475569; } + .row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } + @media (max-width: 640px) { .row { grid-template-columns: 1fr; } } + .msg { margin-top: 1rem; padding: .75rem; border-radius: 8px; font-size: .9rem; display: none; } + .msg.error { display: block; background: #7f1d1d; color: #fecaca; } + .msg.ok { display: block; background: #14532d; color: #bbf7d0; } + .topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } + table { width: 100%; border-collapse: collapse; font-size: .9rem; } + th, td { text-align: left; padding: .6rem .5rem; border-bottom: 1px solid #334155; } + th { color: #94a3b8; font-weight: 500; } + .badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; background: #14532d; color: #bbf7d0; } + .center-card { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; } + .center-card .card { width: 100%; max-width: 420px; } +` + const setupPageHTML = ` - - - - - PanelHosting — первичная настройка - - - -
-

PanelHosting

-

Создайте учётную запись суперадминистратора

-
- - - - - - - -
-
-
- - -` + +PanelHosting — настройка +
+

PanelHosting

Создайте суперадминистратора

+
+ + + + +
+
+` + +const loginPageHTML = ` + +PanelHosting — вход +
+

PanelHosting

Войдите в панель

+
+ + + +
+
+` + +func dashboardPageHTML(username string) string { + return fmt.Sprintf(` + +PanelHosting +
+

PanelHosting

%s

+
+

Новый сайт

+
+
+
+
+
+
+

Сайты

+ +
ИмяДоменPHPСтатусПуть
+
+`, baseCSS, username) +} diff --git a/internal/handler/site.go b/internal/handler/site.go new file mode 100644 index 0000000..1fc1a51 --- /dev/null +++ b/internal/handler/site.go @@ -0,0 +1,85 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/panelhosting/panel/internal/middleware" + "github.com/panelhosting/panel/internal/sitesvc" +) + +type SiteHandler struct { + svc *sitesvc.Service +} + +func NewSiteHandler(svc *sitesvc.Service) *SiteHandler { + return &SiteHandler{svc: svc} +} + +type createSiteRequest struct { + Name string `json:"name"` + Domain string `json:"domain"` + PHPVersion string `json:"php_version"` + ServerID int64 `json:"server_id,omitempty"` +} + +func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) { + user, ok := middleware.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + sites, err := h.svc.List(r.Context(), user, middleware.IsAdmin(user)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list sites") + return + } + writeJSON(w, http.StatusOK, map[string]any{"sites": sites}) +} + +func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) { + user, ok := middleware.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req createSiteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + + site, err := h.svc.Create(r.Context(), sitesvc.CreateInput{ + Name: req.Name, + Domain: req.Domain, + PHPVersion: req.PHPVersion, + ServerID: req.ServerID, + OwnerID: user.ID, + IsAdmin: middleware.IsAdmin(user), + }) + if err != nil { + switch { + case errors.Is(err, sitesvc.ErrInvalidSiteName), + errors.Is(err, sitesvc.ErrInvalidDomain), + errors.Is(err, sitesvc.ErrInvalidPHPVersion): + writeError(w, http.StatusBadRequest, err.Error()) + default: + writeError(w, http.StatusInternalServerError, "failed to create site") + } + return + } + + writeJSON(w, http.StatusCreated, map[string]any{"site": site}) +} + +func (h *SiteHandler) PHPVersions(w http.ResponseWriter, r *http.Request) { + versions, err := h.svc.ListPHPVersions(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list php versions") + return + } + writeJSON(w, http.StatusOK, map[string]any{"versions": versions}) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..ec3676b --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/panelhosting/panel/internal/authsvc" + "github.com/panelhosting/panel/internal/config" + "github.com/panelhosting/panel/internal/models" +) + +type ctxKey int + +const userKey ctxKey = 1 + +func UserFromContext(ctx context.Context) (*models.User, bool) { + u, ok := ctx.Value(userKey).(*models.User) + return u, ok +} + +type Auth struct { + auth *authsvc.Service + cfg *config.Config +} + +func NewAuth(auth *authsvc.Service, cfg *config.Config) *Auth { + return &Auth{auth: auth, cfg: cfg} +} + +func (a *Auth) Require(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := tokenFromRequest(r, a.cfg.SessionCookieName) + user, err := a.auth.UserByToken(r.Context(), token) + if err != nil { + writeUnauthorized(w) + return + } + ctx := context.WithValue(r.Context(), userKey, user) + next(w, r.WithContext(ctx)) + } +} + +func (a *Auth) Optional(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := tokenFromRequest(r, a.cfg.SessionCookieName) + user, err := a.auth.UserByToken(r.Context(), token) + if err == nil { + ctx := context.WithValue(r.Context(), userKey, user) + r = r.WithContext(ctx) + } + next(w, r) + } +} + +func tokenFromRequest(r *http.Request, cookieName string) string { + if c, err := r.Cookie(cookieName); err == nil && c.Value != "" { + return c.Value + } + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + return "" +} + +func writeUnauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) +} + +func IsAdmin(user *models.User) bool { + return user.Role == models.RoleSuperAdmin || user.Role == models.RoleAdmin +} diff --git a/internal/repository/server.go b/internal/repository/server.go new file mode 100644 index 0000000..1fdc769 --- /dev/null +++ b/internal/repository/server.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/panelhosting/panel/internal/models" +) + +var ErrServerNotFound = errors.New("server not found") + +type ServerRepository struct { + pool *pgxpool.Pool +} + +func NewServerRepository(pool *pgxpool.Pool) *ServerRepository { + return &ServerRepository{pool: pool} +} + +func (r *ServerRepository) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM servers`).Scan(&count) + return count, err +} + +func (r *ServerRepository) CreateDefault(ctx context.Context, name, hostname, ip string) (*models.Server, error) { + const q = ` + INSERT INTO servers (name, hostname, ip_address, status) + VALUES ($1, $2, $3::inet, 'online') + RETURNING id, uuid, name, hostname, ip_address::text, ssh_port, status, + agent_version, os_info, resources, settings, last_seen_at, created_at, updated_at + ` + var s models.Server + err := r.pool.QueryRow(ctx, q, name, hostname, ip).Scan( + &s.ID, &s.UUID, &s.Name, &s.Hostname, &s.IPAddress, &s.SSHPort, &s.Status, + &s.AgentVersion, &s.OSInfo, &s.Resources, &s.Settings, &s.LastSeenAt, &s.CreatedAt, &s.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create server: %w", err) + } + return &s, nil +} + +func (r *ServerRepository) GetFirst(ctx context.Context) (*models.Server, error) { + const q = ` + SELECT id, uuid, name, hostname, ip_address::text, ssh_port, status, + agent_version, os_info, resources, settings, last_seen_at, created_at, updated_at + FROM servers ORDER BY id LIMIT 1 + ` + var s models.Server + err := r.pool.QueryRow(ctx, q).Scan( + &s.ID, &s.UUID, &s.Name, &s.Hostname, &s.IPAddress, &s.SSHPort, &s.Status, + &s.AgentVersion, &s.OSInfo, &s.Resources, &s.Settings, &s.LastSeenAt, &s.CreatedAt, &s.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrServerNotFound + } + if err != nil { + return nil, fmt.Errorf("get first server: %w", err) + } + return &s, nil +} + +func (r *ServerRepository) GetByID(ctx context.Context, id int64) (*models.Server, error) { + const q = ` + SELECT id, uuid, name, hostname, ip_address::text, ssh_port, status, + agent_version, os_info, resources, settings, last_seen_at, created_at, updated_at + FROM servers WHERE id = $1 + ` + var s models.Server + err := r.pool.QueryRow(ctx, q, id).Scan( + &s.ID, &s.UUID, &s.Name, &s.Hostname, &s.IPAddress, &s.SSHPort, &s.Status, + &s.AgentVersion, &s.OSInfo, &s.Resources, &s.Settings, &s.LastSeenAt, &s.CreatedAt, &s.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrServerNotFound + } + if err != nil { + return nil, fmt.Errorf("get server: %w", err) + } + return &s, nil +} + +func (r *ServerRepository) GrantAccess(ctx context.Context, userID, serverID int64, canManage bool) error { + const q = ` + INSERT INTO server_access (user_id, server_id, can_manage) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, server_id) DO NOTHING + ` + _, err := r.pool.Exec(ctx, q, userID, serverID, canManage) + return err +} diff --git a/internal/repository/session.go b/internal/repository/session.go new file mode 100644 index 0000000..fdb51f6 --- /dev/null +++ b/internal/repository/session.go @@ -0,0 +1,98 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/panelhosting/panel/internal/models" +) + +var ErrUserNotFound = errors.New("user not found") + +func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) { + const q = ` + SELECT id, uuid, email, username, password_hash, role, status, locale, timezone, last_login_at, created_at, updated_at + FROM users WHERE username = $1 + ` + var u models.User + err := r.pool.QueryRow(ctx, q, username).Scan( + &u.ID, &u.UUID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.Status, + &u.Locale, &u.Timezone, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("get user by username: %w", err) + } + return &u, nil +} + +func (r *UserRepository) GetByID(ctx context.Context, id int64) (*models.User, error) { + const q = ` + SELECT id, uuid, email, username, password_hash, role, status, locale, timezone, last_login_at, created_at, updated_at + FROM users WHERE id = $1 + ` + var u models.User + err := r.pool.QueryRow(ctx, q, id).Scan( + &u.ID, &u.UUID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.Status, + &u.Locale, &u.Timezone, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("get user by id: %w", err) + } + return &u, nil +} + +func (r *UserRepository) UpdateLastLogin(ctx context.Context, id int64) error { + _, err := r.pool.Exec(ctx, `UPDATE users SET last_login_at = now() WHERE id = $1`, id) + return err +} + +type SessionRepository struct { + pool *pgxpool.Pool +} + +func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository { + return &SessionRepository{pool: pool} +} + +func (r *SessionRepository) Create(ctx context.Context, userID int64, tokenHash string, ip, userAgent string, expiresAt time.Time) error { + const q = ` + INSERT INTO sessions (user_id, token_hash, ip_address, user_agent, expires_at) + VALUES ($1, $2, NULLIF($3, '')::inet, NULLIF($4, ''), $5) + ` + _, err := r.pool.Exec(ctx, q, userID, tokenHash, ip, userAgent, expiresAt) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + return nil +} + +func (r *SessionRepository) GetUserIDByToken(ctx context.Context, tokenHash string) (int64, error) { + const q = ` + SELECT user_id FROM sessions + WHERE token_hash = $1 AND expires_at > now() + ` + var userID int64 + err := r.pool.QueryRow(ctx, q, tokenHash).Scan(&userID) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrUserNotFound + } + if err != nil { + return 0, fmt.Errorf("get session: %w", err) + } + return userID, nil +} + +func (r *SessionRepository) DeleteByToken(ctx context.Context, tokenHash string) error { + _, err := r.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, tokenHash) + return err +} diff --git a/internal/repository/site.go b/internal/repository/site.go new file mode 100644 index 0000000..da70521 --- /dev/null +++ b/internal/repository/site.go @@ -0,0 +1,156 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/panelhosting/panel/internal/models" +) + +type SiteRepository struct { + pool *pgxpool.Pool +} + +func NewSiteRepository(pool *pgxpool.Pool) *SiteRepository { + return &SiteRepository{pool: pool} +} + +type SiteWithDomain struct { + models.Site + PrimaryDomain string `json:"primary_domain"` +} + +func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain string) (*SiteWithDomain, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + const siteQ = ` + INSERT INTO sites (server_id, owner_id, name, document_root, php_version, status) + VALUES ($1, $2, $3, $4, $5, 'active') + RETURNING id, uuid, server_id, owner_id, name, document_root, php_version, status, + settings, disk_quota_mb, created_at, updated_at + ` + var s models.Site + var phpVer *string + if phpVersion != "" { + phpVer = &phpVersion + } + err = tx.QueryRow(ctx, siteQ, serverID, ownerID, name, documentRoot, phpVer).Scan( + &s.ID, &s.UUID, &s.ServerID, &s.OwnerID, &s.Name, &s.DocumentRoot, &s.PHPVersion, &s.Status, + &s.Settings, &s.DiskQuotaMB, &s.CreatedAt, &s.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create site: %w", err) + } + + const domainQ = ` + INSERT INTO domains (site_id, domain, is_primary, ssl_enabled) + VALUES ($1, $2, true, true) + ` + if _, err = tx.Exec(ctx, domainQ, s.ID, domain); err != nil { + return nil, fmt.Errorf("create domain: %w", err) + } + + if err = tx.Commit(ctx); err != nil { + return nil, err + } + + return &SiteWithDomain{Site: s, PrimaryDomain: domain}, nil +} + +func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin bool) ([]SiteWithDomain, error) { + var q string + var args []any + if isAdmin { + q = siteListQuery + ` ORDER BY s.created_at DESC` + } else { + q = siteListQuery + ` WHERE s.owner_id = $1 ORDER BY s.created_at DESC` + args = append(args, userID) + } + + rows, err := r.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list sites: %w", err) + } + defer rows.Close() + + return scanSiteList(rows) +} + +const siteListQuery = ` + SELECT s.id, s.uuid, s.server_id, s.owner_id, s.name, s.document_root, s.php_version, s.status, + s.settings, s.disk_quota_mb, s.created_at, s.updated_at, + COALESCE(d.domain::text, '') + FROM sites s + LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true +` + +func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) { + var list []SiteWithDomain + for rows.Next() { + var item SiteWithDomain + err := rows.Scan( + &item.ID, &item.UUID, &item.ServerID, &item.OwnerID, &item.Name, &item.DocumentRoot, + &item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB, + &item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain, + ) + if err != nil { + return nil, err + } + list = append(list, item) + } + return list, rows.Err() +} + +type PHPVersionRepository struct { + pool *pgxpool.Pool +} + +func NewPHPVersionRepository(pool *pgxpool.Pool) *PHPVersionRepository { + return &PHPVersionRepository{pool: pool} +} + +func (r *PHPVersionRepository) ListActive(ctx context.Context) ([]string, error) { + rows, err := r.pool.Query(ctx, ` + SELECT version FROM php_versions WHERE is_active = true ORDER BY sort_order + `) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return defaultPHPVersions(), nil + } + return nil, err + } + defer rows.Close() + + var versions []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, err + } + versions = append(versions, v) + } + if len(versions) == 0 { + return defaultPHPVersions(), nil + } + return versions, rows.Err() +} + +func (r *PHPVersionRepository) IsActive(ctx context.Context, version string) (bool, error) { + var active bool + err := r.pool.QueryRow(ctx, `SELECT is_active FROM php_versions WHERE version = $1`, version).Scan(&active) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return active, err +} + +func defaultPHPVersions() []string { + return []string{"8.1", "8.2", "8.3", "8.4"} +} diff --git a/internal/server/server.go b/internal/server/server.go index 0d30085..0921672 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,19 +8,35 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/panelhosting/panel/internal/authsvc" + "github.com/panelhosting/panel/internal/bootstrap" + "github.com/panelhosting/panel/internal/config" "github.com/panelhosting/panel/internal/handler" + "github.com/panelhosting/panel/internal/middleware" "github.com/panelhosting/panel/internal/repository" "github.com/panelhosting/panel/internal/setup" + "github.com/panelhosting/panel/internal/sitesvc" ) type Server struct { httpServer *http.Server } -func New(pool *pgxpool.Pool, addr string) (*Server, error) { +func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) { users := repository.NewUserRepository(pool) - setupSvc := setup.NewService(users) + servers := repository.NewServerRepository(pool) + sessions := repository.NewSessionRepository(pool) + sites := repository.NewSiteRepository(pool) + phpVers := repository.NewPHPVersionRepository(pool) + + setupSvc := setup.NewService(users, servers, cfg) + authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours) + siteSvc := sitesvc.NewService(sites, servers, phpVers) + setupHandler := handler.NewSetupHandler(setupSvc) + authHandler := handler.NewAuthHandler(authSvc, cfg) + siteHandler := handler.NewSiteHandler(siteSvc) + authMW := middleware.NewAuth(authSvc, cfg) setupRequired, err := setupSvc.Status(context.Background()) if err != nil { @@ -30,12 +46,27 @@ func New(pool *pgxpool.Pool, addr string) (*Server, error) { log.Println("setup required: open / to create first admin") } + if err := bootstrap.EnsureDefaultServer(context.Background(), servers, cfg); err != nil { + return nil, fmt.Errorf("bootstrap server: %w", err) + } + mux := http.NewServeMux() mux.HandleFunc("GET /health", handler.Health) + mux.HandleFunc("GET /api/v1/setup/status", setupHandler.Status) mux.HandleFunc("POST /api/v1/setup/register", setupHandler.Register) - mux.HandleFunc("GET /{$}", handler.IndexPage(setupSvc)) + mux.HandleFunc("POST /api/v1/auth/login", authHandler.Login) + mux.HandleFunc("POST /api/v1/auth/logout", authMW.Require(authHandler.Logout)) + mux.HandleFunc("GET /api/v1/auth/me", authMW.Require(authHandler.Me)) + + mux.HandleFunc("GET /api/v1/php-versions", siteHandler.PHPVersions) + mux.HandleFunc("GET /api/v1/sites", authMW.Require(siteHandler.List)) + mux.HandleFunc("POST /api/v1/sites", authMW.Require(siteHandler.Create)) + + mux.HandleFunc("GET /{$}", authMW.Optional(handler.IndexPage(setupSvc))) + + addr := fmt.Sprintf(":%s", cfg.HTTPPort) return &Server{ httpServer: &http.Server{ Addr: addr, diff --git a/internal/setup/service.go b/internal/setup/service.go index 1a2b839..576dee3 100644 --- a/internal/setup/service.go +++ b/internal/setup/service.go @@ -6,6 +6,8 @@ import ( "fmt" "github.com/panelhosting/panel/internal/auth" + "github.com/panelhosting/panel/internal/bootstrap" + "github.com/panelhosting/panel/internal/config" "github.com/panelhosting/panel/internal/models" "github.com/panelhosting/panel/internal/repository" ) @@ -13,11 +15,13 @@ import ( var ErrSetupCompleted = errors.New("setup already completed") type Service struct { - users *repository.UserRepository + users *repository.UserRepository + servers *repository.ServerRepository + cfg *config.Config } -func NewService(users *repository.UserRepository) *Service { - return &Service{users: users} +func NewService(users *repository.UserRepository, servers *repository.ServerRepository, cfg *config.Config) *Service { + return &Service{users: users, servers: servers, cfg: cfg} } func (s *Service) Status(ctx context.Context) (bool, error) { @@ -59,5 +63,13 @@ func (s *Service) RegisterFirstAdmin(ctx context.Context, in RegisterInput) (*mo if err != nil { return nil, fmt.Errorf("register admin: %w", err) } + + if err := bootstrap.EnsureDefaultServer(ctx, s.servers, s.cfg); err != nil { + return nil, fmt.Errorf("bootstrap server: %w", err) + } + if err := bootstrap.GrantServerAccess(ctx, s.servers, user.ID); err != nil { + return nil, fmt.Errorf("grant server access: %w", err) + } + return user, nil } diff --git a/internal/sitesvc/service.go b/internal/sitesvc/service.go new file mode 100644 index 0000000..e9a08ed --- /dev/null +++ b/internal/sitesvc/service.go @@ -0,0 +1,90 @@ +package sitesvc + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/panelhosting/panel/internal/models" + "github.com/panelhosting/panel/internal/repository" +) + +var ( + ErrInvalidSiteName = errors.New("site name must be 3-64 chars: lowercase letters, digits, hyphen") + ErrInvalidDomain = errors.New("invalid domain") + ErrInvalidPHPVersion = errors.New("unsupported php version") +) + +var ( + siteNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`) + domainRegex = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) +) + +type Service struct { + sites *repository.SiteRepository + servers *repository.ServerRepository + phpVers *repository.PHPVersionRepository +} + +func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository) *Service { + return &Service{sites: sites, servers: servers, phpVers: php} +} + +type CreateInput struct { + Name string + Domain string + PHPVersion string + ServerID int64 + OwnerID int64 + IsAdmin bool +} + +func (s *Service) List(ctx context.Context, user *models.User, isAdmin bool) ([]repository.SiteWithDomain, error) { + return s.sites.ListForUser(ctx, user.ID, isAdmin) +} + +func (s *Service) ListPHPVersions(ctx context.Context) ([]string, error) { + return s.phpVers.ListActive(ctx) +} + +func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteWithDomain, error) { + name := strings.ToLower(strings.TrimSpace(in.Name)) + domain := strings.ToLower(strings.TrimSpace(in.Domain)) + phpVersion := strings.TrimSpace(in.PHPVersion) + + if !siteNameRegex.MatchString(name) { + return nil, ErrInvalidSiteName + } + if !domainRegex.MatchString(domain) { + return nil, ErrInvalidDomain + } + if phpVersion == "" { + return nil, ErrInvalidPHPVersion + } + + active, err := s.phpVers.IsActive(ctx, phpVersion) + if err != nil { + return nil, err + } + if !active { + return nil, ErrInvalidPHPVersion + } + + serverID := in.ServerID + if serverID == 0 { + srv, err := s.servers.GetFirst(ctx) + if err != nil { + return nil, fmt.Errorf("no server available: %w", err) + } + serverID = srv.ID + } else { + if _, err := s.servers.GetByID(ctx, serverID); err != nil { + return nil, err + } + } + + documentRoot := fmt.Sprintf("/var/www/%s/public", name) + return s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain) +} diff --git a/migrations/000002_php_versions.down.sql b/migrations/000002_php_versions.down.sql new file mode 100644 index 0000000..8707df5 --- /dev/null +++ b/migrations/000002_php_versions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS php_versions; diff --git a/migrations/000002_php_versions.up.sql b/migrations/000002_php_versions.up.sql new file mode 100644 index 0000000..51f1ade --- /dev/null +++ b/migrations/000002_php_versions.up.sql @@ -0,0 +1,15 @@ +-- PHP versions available for sites + +CREATE TABLE php_versions ( + version VARCHAR(16) PRIMARY KEY, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0 +); + +INSERT INTO php_versions (version, is_active, sort_order) VALUES + ('7.4', false, 1), + ('8.0', false, 2), + ('8.1', true, 3), + ('8.2', true, 4), + ('8.3', true, 5), + ('8.4', true, 6);