feat: HTTP panel on :8000 with first admin setup

This commit is contained in:
orohi
2026-06-17 04:40:34 +03:00
parent f214aaa48b
commit 0f31c24bf9
14 changed files with 502 additions and 5 deletions
+3
View File
@@ -8,3 +8,6 @@ DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=disable
# Для docker compose migrate (внутри docker-сети) # Для docker compose migrate (внутри docker-сети)
DATABASE_URL_DOCKER=postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable DATABASE_URL_DOCKER=postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable
# HTTP API панели
HTTP_PORT=8000
+19
View File
@@ -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"]
+26 -1
View File
@@ -51,12 +51,37 @@ export DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=d
go run ./cmd/migrate go run ./cmd/migrate
``` ```
### 4. Проверка подключения ### 4. Запуск панели
Через Docker (порт **8000**):
```bash ```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 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"
}
```
## Структура проекта ## Структура проекта
``` ```
+24 -1
View File
@@ -2,13 +2,18 @@ package main
import ( import (
"context" "context"
"errors"
"fmt"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"github.com/panelhosting/panel/internal/config" "github.com/panelhosting/panel/internal/config"
"github.com/panelhosting/panel/internal/database" "github.com/panelhosting/panel/internal/database"
"github.com/panelhosting/panel/internal/server"
) )
func main() { func main() {
@@ -26,6 +31,24 @@ func main() {
} }
defer pool.Close() 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() <-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)
}
} }
+16
View File
@@ -33,5 +33,21 @@ services:
profiles: profiles:
- migrate - 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: volumes:
postgres_data: postgres_data:
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2 github.com/jackc/pgx/v5 v5.7.2
golang.org/x/crypto v0.31.0
) )
require ( require (
@@ -16,7 +17,6 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
go.uber.org/atomic v1.7.0 // 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/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
) )
+45
View File
@@ -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
}
+11 -1
View File
@@ -7,6 +7,7 @@ import (
type Config struct { type Config struct {
DatabaseURL string DatabaseURL string
HTTPPort string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -14,5 +15,14 @@ func Load() (*Config, error) {
if url == "" { if url == "" {
return nil, fmt.Errorf("DATABASE_URL is required") 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
} }
+118
View File
@@ -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 = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PanelHosting — первичная настройка</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 420px; box-shadow: 0 20px 40px rgba(0,0,0,.3); }
h1 { margin: 0 0 .5rem; font-size: 1.5rem; }
p { color: #94a3b8; margin: 0 0 1.5rem; font-size: .95rem; }
label { display: block; margin-bottom: .35rem; font-size: .85rem; color: #cbd5e1; }
input { width: 100%; padding: .65rem .75rem; margin-bottom: 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #f8fafc; font-size: 1rem; }
input:focus { outline: 2px solid #3b82f6; border-color: transparent; }
button { width: 100%; padding: .75rem; background: #3b82f6; color: #fff; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; }
button:hover { background: #2563eb; }
button:disabled { opacity: .6; cursor: not-allowed; }
.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; }
</style>
</head>
<body>
<div class="card">
<h1>PanelHosting</h1>
<p>Создайте учётную запись суперадминистратора</p>
<form id="form">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email">
<label for="username">Логин</label>
<input id="username" name="username" type="text" required pattern="[a-zA-Z0-9_]{3,64}" autocomplete="username">
<label for="password">Пароль (мин. 8 символов)</label>
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password">
<button type="submit" id="btn">Создать администратора</button>
</form>
<div id="msg" class="msg"></div>
</div>
<script>
const form = document.getElementById('form');
const msg = document.getElementById('msg');
const btn = document.getElementById('btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
btn.disabled = true;
msg.className = 'msg';
msg.textContent = '';
const body = {
email: form.email.value.trim(),
username: form.username.value.trim(),
password: form.password.value
};
try {
const res = await fetch('/api/v1/setup/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
msg.className = 'msg error';
msg.textContent = data.error || 'Ошибка регистрации';
btn.disabled = false;
return;
}
msg.className = 'msg ok';
msg.textContent = 'Администратор создан. Панель готова к работе.';
form.reset();
} catch {
msg.className = 'msg error';
msg.textContent = 'Сетевая ошибка';
btn.disabled = false;
}
});
</script>
</body>
</html>`
+65
View File
@@ -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})
}
+44
View File
@@ -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
}
+65
View File
@@ -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))
})
}
+63
View File
@@ -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
}
+2 -1
View File
@@ -20,5 +20,6 @@ fi
docker compose up -d postgres docker compose up -d postgres
docker compose --profile migrate run --rm migrate docker compose --profile migrate run --rm migrate
docker compose up -d panel
echo "Готово. PostgreSQL запущен, миграции применены." echo "Готово. Панель: http://$(hostname -I | awk '{print $1}'):8000"