Initial commit: VPN panel on Go, PostgreSQL 17, Docker, Xray-core

This commit is contained in:
vpn-panel
2026-05-21 18:55:14 +03:00
commit 3c2f5226d1
27 changed files with 1778 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
package handlers
import (
"net/http"
"strings"
"vpn-panel/internal/auth"
"vpn-panel/internal/session"
"vpn-panel/internal/store"
)
func (h *Handler) currentUser(r *http.Request) *session.Data {
c, err := r.Cookie(session.CookieName())
if err != nil {
return nil
}
d, err := session.Verify(h.secret, c.Value)
if err != nil {
return nil
}
return d
}
func (h *Handler) RegisterAdmin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
has, err := h.users.HasAdmin(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if has {
flashSet(w, "Администратор уже создан. Регистрация закрыта.", "error")
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.Method == http.MethodGet {
h.render(w, "register", h.pageData(w, r, "Регистрация администратора", nil))
return
}
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
password := r.FormValue("password")
confirm := r.FormValue("password_confirm")
if email == "" || len(password) < 8 {
flashSet(w, "Email обязателен, пароль — минимум 8 символов.", "error")
http.Redirect(w, r, "/register", http.StatusSeeOther)
return
}
if password != confirm {
flashSet(w, "Пароли не совпадают.", "error")
http.Redirect(w, r, "/register", http.StatusSeeOther)
return
}
hash, err := auth.HashPassword(password)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
u, err := h.users.CreateAdmin(ctx, email, hash)
if err == store.ErrAdminExists {
flashSet(w, "Администратор уже существует.", "error")
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
token, err := session.Sign(h.secret, session.Data{
UserID: u.ID,
Email: u.Email,
Role: u.Role,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: session.CookieName(),
Value: token,
Path: "/",
MaxAge: int((7 * 24 * 60 * 60)),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
})
flashSet(w, "Администратор успешно создан. Добро пожаловать!", "success")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
h.render(w, "login", h.pageData(w, r, "Вход", nil))
return
}
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
password := r.FormValue("password")
u, err := h.users.GetByEmail(r.Context(), email)
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
flashSet(w, "Неверный email или пароль.", "error")
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
token, err := session.Sign(h.secret, session.Data{
UserID: u.ID,
Email: u.Email,
Role: u.Role,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: session.CookieName(),
Value: token,
Path: "/",
MaxAge: 7 * 24 * 60 * 60,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName(), Path: "/", MaxAge: -1,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+40
View File
@@ -0,0 +1,40 @@
package handlers
import (
"net/http"
)
const flashCookie = "vpn_panel_flash"
func flashSet(w http.ResponseWriter, msg, level string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: level + ":" + msg,
Path: "/",
MaxAge: 30,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func flashGet(w http.ResponseWriter, r *http.Request) map[string]string {
c, err := r.Cookie(flashCookie)
if err != nil {
return nil
}
http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1})
parts := splitFlash(c.Value)
if len(parts) != 2 {
return nil
}
return map[string]string{"Level": parts[0], "Message": parts[1]}
}
func splitFlash(v string) []string {
for i := 0; i < len(v); i++ {
if v[i] == ':' {
return []string{v[:i], v[i+1:]}
}
}
return nil
}
+101
View File
@@ -0,0 +1,101 @@
package handlers
import (
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"vpn-panel/internal/config"
"vpn-panel/internal/store"
"vpn-panel/web"
)
type Handler struct {
cfg *config.Config
pool *pgxpool.Pool
users *store.UserStore
tmpl map[string]*template.Template
secret string
domain string
}
func New(cfg *config.Config, pool *pgxpool.Pool) (*Handler, error) {
tmpl, err := loadTemplates()
if err != nil {
return nil, err
}
return &Handler{
cfg: cfg,
pool: pool,
users: store.NewUserStore(pool),
tmpl: tmpl,
secret: cfg.SecretKey,
domain: cfg.AppDomain,
}, nil
}
func loadTemplates() (map[string]*template.Template, error) {
funcs := template.FuncMap{
"eq": func(a, b any) bool { return a == b },
}
out := make(map[string]*template.Template)
err := fs.WalkDir(web.Templates, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
return err
}
name := strings.TrimSuffix(filepath.Base(path), ".html")
content, err := web.Templates.ReadFile(path)
if err != nil {
return err
}
base, err := web.Templates.ReadFile("templates/layout.html")
if err != nil {
return err
}
t, err := template.New("layout").Funcs(funcs).Parse(string(base))
if err != nil {
return err
}
if _, err := t.Parse(string(content)); err != nil {
return err
}
out[name] = t
return nil
})
return out, err
}
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
t := h.tmpl[name]
if t == nil {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *Handler) pageData(w http.ResponseWriter, r *http.Request, title string, extra map[string]any) map[string]any {
hasAdmin, _ := h.users.HasAdmin(r.Context())
data := map[string]any{
"Title": title,
"Domain": h.domain,
"Year": "2026",
"Flash": flashGet(w, r),
"CanRegister": !hasAdmin,
"HasAdmin": hasAdmin,
}
if sess := h.currentUser(r); sess != nil {
data["User"] = sess
}
for k, v := range extra {
data[k] = v
}
return data
}
+24
View File
@@ -0,0 +1,24 @@
package handlers
import (
"net/http"
)
func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hasAdmin, _ := h.users.HasAdmin(ctx)
userCount, _ := h.users.CountUsers(ctx)
h.render(w, "index", h.pageData(w, r, "VPN Панель — Xray", map[string]any{
"HasAdmin": hasAdmin,
"CanRegister": !hasAdmin,
"UserCount": userCount,
"XrayVersion": "Xray-core",
"Installed": h.cfg.Installed,
}))
}
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","core":"xray"}`))
}