Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5c27dbe9 | |||
| 9caa996d31 | |||
| 36a84bae10 | |||
| e9251782c4 |
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# Releases
|
||||||
|
|
||||||
|
## v0.3.0 - Ubuntu one-click lifecycle
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Ubuntu scripts for full lifecycle:
|
||||||
|
- `install.sh` (build + start)
|
||||||
|
- `uninstall.sh` (stop + remove containers, optional image cleanup)
|
||||||
|
- `reinstall.sh` (rebuild + restart)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `README.md` updated from Windows flow to Ubuntu flow.
|
||||||
|
- Added executable step for scripts:
|
||||||
|
- `chmod +x install.sh uninstall.sh reinstall.sh`
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Windows-specific scripts removed:
|
||||||
|
- `install.bat`, `uninstall.bat`, `reinstall.bat`
|
||||||
|
- `install.ps1`, `uninstall.ps1`, `reinstall.ps1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2.0 - One-click automation (initial)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial one-click lifecycle scripts (first implementation).
|
||||||
|
- Documentation for install/uninstall/reinstall workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1.0 - MVP RemnaWave VPN Panel
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Go web app with routes:
|
||||||
|
- `/` home page
|
||||||
|
- `/register` registration
|
||||||
|
- `/login` authorization
|
||||||
|
- `/logout` sign out
|
||||||
|
- `/cabinet` personal cabinet
|
||||||
|
- `/buy` VPN config purchase
|
||||||
|
- Session auth via HTTP-only cookie token.
|
||||||
|
- In-memory users and purchases storage for MVP.
|
||||||
|
- Purchase integration with real external API using env key:
|
||||||
|
- `API_BASE_URL`
|
||||||
|
- `API_BUY_PATH`
|
||||||
|
- `API_KEY`
|
||||||
|
- Docker support:
|
||||||
|
- `Dockerfile`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- UI templates and styles:
|
||||||
|
- `templates/*.html`
|
||||||
|
- `static/styles.css`
|
||||||
|
- Setup docs:
|
||||||
|
- `.env.example`
|
||||||
|
- `README.md`
|
||||||
@@ -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
|
||||||
+13
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
Reference in New Issue
Block a user