diff --git a/.env.example b/.env.example
index 391b24c..23bb369 100644
--- a/.env.example
+++ b/.env.example
@@ -8,3 +8,6 @@ DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=disable
# Для docker compose migrate (внутри docker-сети)
DATABASE_URL_DOCKER=postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable
+
+# HTTP API панели
+HTTP_PORT=8000
diff --git a/Dockerfile.panel b/Dockerfile.panel
new file mode 100644
index 0000000..5671dc4
--- /dev/null
+++ b/Dockerfile.panel
@@ -0,0 +1,19 @@
+FROM golang:1.23-alpine AS builder
+
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 go build -o /panel ./cmd/panel
+
+FROM alpine:3.20
+
+RUN apk add --no-cache ca-certificates
+
+COPY --from=builder /panel /panel
+
+EXPOSE 8000
+
+ENTRYPOINT ["/panel"]
diff --git a/README.md b/README.md
index c707024..0a30c16 100644
--- a/README.md
+++ b/README.md
@@ -51,12 +51,37 @@ export DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=d
go run ./cmd/migrate
```
-### 4. Проверка подключения
+### 4. Запуск панели
+
+Через Docker (порт **8000**):
```bash
+docker compose up -d panel
+```
+
+Или локально:
+
+```bash
+export DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=disable
+export HTTP_PORT=8000
go run ./cmd/panel
```
+Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации.
+
+API:
+- `GET /health` — healthcheck
+- `GET /api/v1/setup/status` — `{"setup_required": true|false}`
+- `POST /api/v1/setup/register` — создание первого суперадмина (только пока нет пользователей)
+
+```json
+{
+ "email": "admin@example.com",
+ "username": "admin",
+ "password": "secret123"
+}
+```
+
## Структура проекта
```
diff --git a/cmd/panel/main.go b/cmd/panel/main.go
index e4cabb1..71521c2 100644
--- a/cmd/panel/main.go
+++ b/cmd/panel/main.go
@@ -2,13 +2,18 @@ package main
import (
"context"
+ "errors"
+ "fmt"
"log"
+ "net/http"
"os"
"os/signal"
"syscall"
+ "time"
"github.com/panelhosting/panel/internal/config"
"github.com/panelhosting/panel/internal/database"
+ "github.com/panelhosting/panel/internal/server"
)
func main() {
@@ -26,6 +31,24 @@ func main() {
}
defer pool.Close()
- log.Println("panel database ready (API coming soon)")
+ addr := fmt.Sprintf(":%s", cfg.HTTPPort)
+ srv, err := server.New(pool, addr)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ go func() {
+ if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatal(err)
+ }
+ }()
+
<-ctx.Done()
+ log.Println("shutting down...")
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ log.Fatal(err)
+ }
}
diff --git a/docker-compose.yml b/docker-compose.yml
index fc5a4e3..f3b1e14 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,5 +33,21 @@ services:
profiles:
- migrate
+ panel:
+ build:
+ context: .
+ dockerfile: Dockerfile.panel
+ container_name: panel-app
+ restart: unless-stopped
+ env_file: .env
+ environment:
+ DATABASE_URL: ${DATABASE_URL_DOCKER:-postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable}
+ HTTP_PORT: "8000"
+ ports:
+ - "${HTTP_PORT:-8000}:8000"
+ depends_on:
+ postgres:
+ condition: service_healthy
+
volumes:
postgres_data:
diff --git a/go.mod b/go.mod
index e6b8d26..8bd69ee 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
+ golang.org/x/crypto v0.31.0
)
require (
@@ -16,7 +17,6 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
go.uber.org/atomic v1.7.0 // indirect
- golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
diff --git a/internal/auth/password.go b/internal/auth/password.go
new file mode 100644
index 0000000..5d7fdd0
--- /dev/null
+++ b/internal/auth/password.go
@@ -0,0 +1,45 @@
+package auth
+
+import (
+ "errors"
+ "regexp"
+ "unicode/utf8"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+var (
+ ErrPasswordTooShort = errors.New("password must be at least 8 characters")
+ ErrInvalidEmail = errors.New("invalid email")
+ ErrInvalidUsername = errors.New("username must be 3-64 characters: letters, digits, underscore")
+)
+
+var (
+ emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
+ usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,64}$`)
+)
+
+func HashPassword(password string) (string, error) {
+ if utf8.RuneCountInString(password) < 8 {
+ return "", ErrPasswordTooShort
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ return string(hash), nil
+}
+
+func ValidateEmail(email string) error {
+ if !emailRegex.MatchString(email) {
+ return ErrInvalidEmail
+ }
+ return nil
+}
+
+func ValidateUsername(username string) error {
+ if !usernameRegex.MatchString(username) {
+ return ErrInvalidUsername
+ }
+ return nil
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 935ab12..8a37c19 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -7,6 +7,7 @@ import (
type Config struct {
DatabaseURL string
+ HTTPPort string
}
func Load() (*Config, error) {
@@ -14,5 +15,14 @@ func Load() (*Config, error) {
if url == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
- return &Config{DatabaseURL: url}, nil
+
+ port := os.Getenv("HTTP_PORT")
+ if port == "" {
+ port = "8000"
+ }
+
+ return &Config{
+ DatabaseURL: url,
+ HTTPPort: port,
+ }, nil
}
diff --git a/internal/handler/health.go b/internal/handler/health.go
new file mode 100644
index 0000000..f6d78e9
--- /dev/null
+++ b/internal/handler/health.go
@@ -0,0 +1,118 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/panelhosting/panel/internal/setup"
+)
+
+func Health(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"status":"ok"}`))
+}
+
+func IndexPage(svc *setup.Service) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ required, err := svc.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))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{
+ "name": "PanelHosting",
+ "version": "0.1.0",
+ "status": "running",
+ })
+ }
+}
+
+const setupPageHTML = `
+
+
+
+
+ PanelHosting — первичная настройка
+
+
+
+
+
PanelHosting
+
Создайте учётную запись суперадминистратора
+
+
+
+
+
+`
diff --git a/internal/handler/setup.go b/internal/handler/setup.go
new file mode 100644
index 0000000..de324c6
--- /dev/null
+++ b/internal/handler/setup.go
@@ -0,0 +1,65 @@
+package handler
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/panelhosting/panel/internal/auth"
+ "github.com/panelhosting/panel/internal/setup"
+)
+
+type SetupHandler struct {
+ svc *setup.Service
+}
+
+func NewSetupHandler(svc *setup.Service) *SetupHandler {
+ return &SetupHandler{svc: svc}
+}
+
+func (h *SetupHandler) Status(w http.ResponseWriter, r *http.Request) {
+ required, err := h.svc.Status(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]bool{"setup_required": required})
+}
+
+func (h *SetupHandler) Register(w http.ResponseWriter, r *http.Request) {
+ var in setup.RegisterInput
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+
+ user, err := h.svc.RegisterFirstAdmin(r.Context(), in)
+ if err != nil {
+ switch {
+ case errors.Is(err, setup.ErrSetupCompleted):
+ writeError(w, http.StatusConflict, "setup already completed")
+ case errors.Is(err, auth.ErrPasswordTooShort),
+ errors.Is(err, auth.ErrInvalidEmail),
+ errors.Is(err, auth.ErrInvalidUsername):
+ writeError(w, http.StatusBadRequest, err.Error())
+ default:
+ writeError(w, http.StatusInternalServerError, "registration failed")
+ }
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "message": "admin created",
+ "user": user,
+ })
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeError(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, map[string]string{"error": msg})
+}
diff --git a/internal/repository/user.go b/internal/repository/user.go
new file mode 100644
index 0000000..e60d86e
--- /dev/null
+++ b/internal/repository/user.go
@@ -0,0 +1,44 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/panelhosting/panel/internal/models"
+)
+
+type UserRepository struct {
+ pool *pgxpool.Pool
+}
+
+func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
+ return &UserRepository{pool: pool}
+}
+
+func (r *UserRepository) Count(ctx context.Context) (int64, error) {
+ var count int64
+ err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
+ if err != nil {
+ return 0, fmt.Errorf("count users: %w", err)
+ }
+ return count, nil
+}
+
+func (r *UserRepository) Create(ctx context.Context, email, username, passwordHash string, role models.UserRole, status models.UserStatus) (*models.User, error) {
+ const q = `
+ INSERT INTO users (email, username, password_hash, role, status)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id, uuid, email, username, role, status, locale, timezone, last_login_at, created_at, updated_at
+ `
+
+ var u models.User
+ err := r.pool.QueryRow(ctx, q, email, username, passwordHash, role, status).Scan(
+ &u.ID, &u.UUID, &u.Email, &u.Username, &u.Role, &u.Status,
+ &u.Locale, &u.Timezone, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create user: %w", err)
+ }
+ return &u, nil
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..0d30085
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,65 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/panelhosting/panel/internal/handler"
+ "github.com/panelhosting/panel/internal/repository"
+ "github.com/panelhosting/panel/internal/setup"
+)
+
+type Server struct {
+ httpServer *http.Server
+}
+
+func New(pool *pgxpool.Pool, addr string) (*Server, error) {
+ users := repository.NewUserRepository(pool)
+ setupSvc := setup.NewService(users)
+ setupHandler := handler.NewSetupHandler(setupSvc)
+
+ setupRequired, err := setupSvc.Status(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("check setup status: %w", err)
+ }
+ if setupRequired {
+ log.Println("setup required: open / to create first admin")
+ }
+
+ 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))
+
+ return &Server{
+ httpServer: &http.Server{
+ Addr: addr,
+ Handler: loggingMiddleware(mux),
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ },
+ }, nil
+}
+
+func (s *Server) Start() error {
+ log.Printf("panel listening on %s", s.httpServer.Addr)
+ return s.httpServer.ListenAndServe()
+}
+
+func (s *Server) Shutdown(ctx context.Context) error {
+ return s.httpServer.Shutdown(ctx)
+}
+
+func loggingMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ next.ServeHTTP(w, r)
+ log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
+ })
+}
diff --git a/internal/setup/service.go b/internal/setup/service.go
new file mode 100644
index 0000000..1a2b839
--- /dev/null
+++ b/internal/setup/service.go
@@ -0,0 +1,63 @@
+package setup
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/panelhosting/panel/internal/auth"
+ "github.com/panelhosting/panel/internal/models"
+ "github.com/panelhosting/panel/internal/repository"
+)
+
+var ErrSetupCompleted = errors.New("setup already completed")
+
+type Service struct {
+ users *repository.UserRepository
+}
+
+func NewService(users *repository.UserRepository) *Service {
+ return &Service{users: users}
+}
+
+func (s *Service) Status(ctx context.Context) (bool, error) {
+ count, err := s.users.Count(ctx)
+ if err != nil {
+ return false, err
+ }
+ return count == 0, nil
+}
+
+type RegisterInput struct {
+ Email string `json:"email"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+func (s *Service) RegisterFirstAdmin(ctx context.Context, in RegisterInput) (*models.User, error) {
+ required, err := s.Status(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if !required {
+ return nil, ErrSetupCompleted
+ }
+
+ if err := auth.ValidateEmail(in.Email); err != nil {
+ return nil, err
+ }
+ if err := auth.ValidateUsername(in.Username); err != nil {
+ return nil, err
+ }
+
+ hash, err := auth.HashPassword(in.Password)
+ if err != nil {
+ return nil, err
+ }
+
+ user, err := s.users.Create(ctx, in.Email, in.Username, hash, models.RoleSuperAdmin, models.UserStatusActive)
+ if err != nil {
+ return nil, fmt.Errorf("register admin: %w", err)
+ }
+ return user, nil
+}
diff --git a/scripts/server-setup.sh b/scripts/server-setup.sh
index 5aafda7..129ddd8 100644
--- a/scripts/server-setup.sh
+++ b/scripts/server-setup.sh
@@ -20,5 +20,6 @@ fi
docker compose up -d postgres
docker compose --profile migrate run --rm migrate
+docker compose up -d panel
-echo "Готово. PostgreSQL запущен, миграции применены."
+echo "Готово. Панель: http://$(hostname -I | awk '{print $1}'):8000"