diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..094c2c9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.9" +services: + remnawave-panel: + build: . + container_name: remnawave-panel + ports: + - "8080:8080" + environment: + APP_ADDR: ":8080" + API_BASE_URL: "${API_BASE_URL}" + API_BUY_PATH: "${API_BUY_PATH:-/v1/configs/buy}" + API_KEY: "${API_KEY}" + restart: unless-stopped diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..ca08601 --- /dev/null +++ b/install.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== RemnaWave Panel: install/start ==" + +if [[ ! -f .env ]]; then + cp .env.example .env + echo "Created .env from .env.example" + echo "Edit .env and set API_BASE_URL/API_KEY before first real use." +fi + +docker compose up -d --build +echo "Panel started: http://localhost:8080" diff --git a/main.go b/main.go new file mode 100644 index 0000000..40c6f6b --- /dev/null +++ b/main.go @@ -0,0 +1,337 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "html/template" + "log" + "net/http" + "os" + "strings" + "sync" + "time" +) + +type User struct { + Email string + Password string +} + +type Purchase struct { + Email string + Plan string + ConfigRaw string + CreatedAt time.Time +} + +type Server struct { + mu sync.RWMutex + users map[string]User + sessions map[string]string + purchases map[string][]Purchase + tpls *template.Template + apiClient *APIClient +} + +type APIClient struct { + BaseURL string + BuyPath string + APIKey string + Client *http.Client +} + +type BuyConfigRequest struct { + Email string `json:"email"` + Plan string `json:"plan"` +} + +type BuyConfigResponse struct { + Config string `json:"config"` +} + +func main() { + addr := getenv("APP_ADDR", ":8080") + baseURL := getenv("API_BASE_URL", "https://example.com") + buyPath := getenv("API_BUY_PATH", "/v1/configs/buy") + apiKey := getenv("API_KEY", "") + + tpls, err := template.ParseGlob("templates/*.html") + if err != nil { + log.Fatalf("parse templates: %v", err) + } + + s := &Server{ + users: map[string]User{}, + sessions: map[string]string{}, + purchases: map[string][]Purchase{}, + tpls: tpls, + apiClient: &APIClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + BuyPath: "/" + strings.TrimLeft(buyPath, "/"), + APIKey: apiKey, + Client: &http.Client{ + Timeout: 15 * time.Second, + }, + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("/", s.homeHandler) + mux.HandleFunc("/register", s.registerHandler) + mux.HandleFunc("/login", s.loginHandler) + mux.HandleFunc("/logout", s.logoutHandler) + mux.HandleFunc("/cabinet", s.cabinetHandler) + mux.HandleFunc("/buy", s.buyHandler) + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + log.Printf("server started at %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} + +func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) { + email, _ := s.currentUser(r) + _ = s.render(w, "home.html", map[string]any{ + "Title": "RemnaWave VPN", + "Email": email, + }) +} + +func (s *Server) registerHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = s.render(w, "register.html", map[string]any{"Title": "Registration"}) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + email := strings.TrimSpace(r.FormValue("email")) + password := strings.TrimSpace(r.FormValue("password")) + if email == "" || password == "" { + _ = s.render(w, "register.html", map[string]any{ + "Title": "Registration", "Error": "Fill all fields.", + }) + return + } + + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.users[email]; ok { + _ = s.render(w, "register.html", map[string]any{ + "Title": "Registration", "Error": "User already exists.", + }) + return + } + + s.users[email] = User{Email: email, Password: password} + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) loginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = s.render(w, "login.html", map[string]any{"Title": "Authorization"}) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + email := strings.TrimSpace(r.FormValue("email")) + password := strings.TrimSpace(r.FormValue("password")) + + s.mu.RLock() + user, ok := s.users[email] + s.mu.RUnlock() + if !ok || user.Password != password { + _ = s.render(w, "login.html", map[string]any{ + "Title": "Authorization", "Error": "Invalid credentials.", + }) + return + } + + token, err := newToken() + if err != nil { + http.Error(w, "token error", http.StatusInternalServerError) + return + } + + s.mu.Lock() + s.sessions[token] = email + s.mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400 * 7, + }) + http.Redirect(w, r, "/cabinet", http.StatusSeeOther) +} + +func (s *Server) logoutHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_token") + if err == nil { + s.mu.Lock() + delete(s.sessions, cookie.Value) + s.mu.Unlock() + } + + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) cabinetHandler(w http.ResponseWriter, r *http.Request) { + email, ok := s.currentUser(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + s.mu.RLock() + userPurchases := s.purchases[email] + s.mu.RUnlock() + + _ = s.render(w, "cabinet.html", map[string]any{ + "Title": "Personal cabinet", + "Email": email, + "Purchases": userPurchases, + }) +} + +func (s *Server) buyHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + email, ok := s.currentUser(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + plan := strings.TrimSpace(r.FormValue("plan")) + if plan == "" { + http.Redirect(w, r, "/cabinet", http.StatusSeeOther) + return + } + + config, err := s.apiClient.BuyConfig(email, plan) + if err != nil { + s.mu.RLock() + userPurchases := s.purchases[email] + s.mu.RUnlock() + + _ = s.render(w, "cabinet.html", map[string]any{ + "Title": "Personal cabinet", + "Email": email, + "Purchases": userPurchases, + "Error": "API error: " + err.Error(), + }) + return + } + + p := Purchase{ + Email: email, + Plan: plan, + ConfigRaw: config, + CreatedAt: time.Now(), + } + + s.mu.Lock() + s.purchases[email] = append(s.purchases[email], p) + s.mu.Unlock() + + http.Redirect(w, r, "/cabinet", http.StatusSeeOther) +} + +func (s *Server) currentUser(r *http.Request) (string, bool) { + cookie, err := r.Cookie("session_token") + if err != nil { + return "", false + } + + s.mu.RLock() + email, ok := s.sessions[cookie.Value] + s.mu.RUnlock() + return email, ok +} + +func (s *Server) render(w http.ResponseWriter, page string, data any) error { + if err := s.tpls.ExecuteTemplate(w, page, data); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + return err + } + return nil +} + +func (a *APIClient) BuyConfig(email, plan string) (string, error) { + if a.APIKey == "" { + return "", errors.New("API_KEY is empty") + } + + reqBody, err := json.Marshal(BuyConfigRequest{ + Email: email, + Plan: plan, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequest(http.MethodPost, a.BaseURL+a.BuyPath, bytes.NewBuffer(reqBody)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.APIKey) + + resp, err := a.Client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return "", errors.New("remote status: " + resp.Status) + } + + var out BuyConfigResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if out.Config == "" { + return "", errors.New("empty config in response") + } + return out.Config, nil +} + +func newToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func getenv(k, fallback string) string { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return fallback + } + return v +} diff --git a/reinstall.sh b/reinstall.sh new file mode 100644 index 0000000..2e89807 --- /dev/null +++ b/reinstall.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== RemnaWave Panel: reinstall ==" +docker compose down --remove-orphans +docker compose up -d --build + +echo "Panel reinstalled: http://localhost:8080" diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..1cb2980 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "== RemnaWave Panel: uninstall/stop ==" +docker compose down --remove-orphans + +read -r -p "Remove built Docker images too? (y/N): " remove_images +if [[ "${remove_images:-N}" =~ ^[Yy]$ ]]; then + docker compose down --rmi local --remove-orphans +fi + +echo "Panel removed."