7 Commits

Author SHA1 Message Date
admin 3c729c08ff Обновить RELEASES.md 2026-04-23 06:59:43 +00:00
admin b561412102 Обновить README.md 2026-04-23 06:58:54 +00:00
admin d84f1a93bf Обновить README.md 2026-04-23 06:48:33 +00:00
admin 1a5c27dbe9 Загрузить файлы в «/» 2026-04-23 06:40:34 +00:00
admin 9caa996d31 Загрузить файлы в «/» 2026-04-23 06:39:59 +00:00
admin 36a84bae10 Загрузить файлы в «/» 2026-04-23 06:34:49 +00:00
admin e9251782c4 0.10 2026-04-23 06:32:14 +00:00
8 changed files with 601 additions and 88 deletions
+128 -88
View File
@@ -1,88 +1,128 @@
# RemnaWave VPN Panel (Go)
MVP panel on Go with:
- main page
- registration
- authorization
- personal cabinet
- VPN config purchase via real external API and permanent key
## One-click install/uninstall/reinstall (Ubuntu)
Requirements:
- Docker Engine + Docker Compose plugin installed
- `bash` available (default on Ubuntu)
First setup:
1. Copy `.env.example` to `.env`
2. Fill real values in `.env`:
- `API_BASE_URL`
- `API_BUY_PATH`
- `API_KEY`
Make scripts executable once:
```bash
chmod +x install.sh uninstall.sh reinstall.sh
```
One-click commands:
```bash
./install.sh
./uninstall.sh
./reinstall.sh
```
- `install.sh` - build and start panel
- `uninstall.sh` - stop and remove container(s)
- `reinstall.sh` - full restart with rebuild
After install: `http://localhost:8080`
## Run locally without Docker
1. Copy env file:
- `.env.example` -> `.env`
2. Set real values:
- `API_BASE_URL`
- `API_BUY_PATH`
- `API_KEY`
3. Start:
```bash
go run .
```
Open: `http://localhost:8080`
## Run in Docker Compose manually
```bash
docker compose up --build
```
## External API contract expected
POST `${API_BASE_URL}${API_BUY_PATH}`
Headers:
- `Authorization: Bearer <API_KEY>`
- `Content-Type: application/json`
Request JSON:
```json
{
"email": "user@example.com",
"plan": "monthly"
}
```
Response JSON:
```json
{
"config": "vpn://...."
}
```
# RemnaWave VPN Panel (Go)
MVP panel on Go with:
- main page
- registration
- authorization
- personal cabinet
- VPN config purchase via real external API and permanent key
## One-click install/uninstall/reinstall (Ubuntu)
Requirements:
- Docker Engine + Docker Compose plugin installed
- `bash` available (default on Ubuntu)
First setup:
1. Copy `.env.example` to `.env`
2. Fill real values in `.env`:
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `DATABASE_URL`
- `API_BASE_URL`
- `API_BUY_PATH`
- `API_KEY`
- `ADMIN_EMAIL`
- `ADMIN_PASSWORD`
Make scripts executable once:
```bash
chmod +x install.sh uninstall.sh reinstall.sh
```
One-click commands:
```bash
./install.sh
./uninstall.sh
./reinstall.sh
```
- `install.sh` - build and start panel
- `uninstall.sh` - stop and remove container(s)
- `reinstall.sh` - full restart with rebuild
If you see `/usr/bin/env: 'bash\r': No such file or directory`, convert line endings:
```bash
sed -i 's/\r$//' install.sh uninstall.sh reinstall.sh
chmod +x install.sh uninstall.sh reinstall.sh
```
After install: `http://localhost:3050`
## Run locally without Docker
1. Copy env file:
- `.env.example` -> `.env`
2. Set real values:
- `DATABASE_URL` (example: `postgres://postgres:postgres@localhost:5432/remnawave?sslmode=disable`)
- `API_BASE_URL`
- `API_BUY_PATH`
- `API_KEY`
- `ADMIN_EMAIL`
- `ADMIN_PASSWORD`
3. Start:
```bash
go run .
```
Open: `http://localhost:3050`
## Run in Docker Compose manually
```bash
docker compose up --build
```
## Database (PostgreSQL 17)
- PostgreSQL 17 runs as `postgres` service in `docker-compose.yml`
- App uses `DATABASE_URL` to connect
- Tables are auto-created on startup:
- `users`
- `purchases`
- `servers`
- Admin user is auto-created/updated from:
- `ADMIN_EMAIL`
- `ADMIN_PASSWORD`
## External API contract expected
POST `${API_BASE_URL}${API_BUY_PATH}`
Headers:
- `Authorization: Bearer <API_KEY>`
- `Content-Type: application/json`
Request JSON:
```json
{
"email": "user@example.com",
"plan": "monthly"
}
```
## Roles and admin panel
- Default registered users get role: `user`
- Built-in administrator is created from env:
- `ADMIN_EMAIL`
- `ADMIN_PASSWORD`
- Admin panel URL: `/admin`
- Admin features:
- Add VPN servers
- Toggle server status online/offline
- Delete servers
Response JSON:
```json
{
"config": "vpn://...."
}
```
+90
View File
@@ -0,0 +1,90 @@
# Releases
## v0.5.0 - PostgreSQL 17 integration
### Added
- PostgreSQL 17 service in `docker-compose.yml`.
- Persistent volume `remnawave_pgdata`.
- DB env variables in `.env.example`:
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `DATABASE_URL`
### Changed
- Application storage migrated from in-memory to PostgreSQL for:
- users
- purchases
- admin servers
- Auto DB initialization on startup (`CREATE TABLE IF NOT EXISTS`).
- Admin user is upserted from `ADMIN_EMAIL`/`ADMIN_PASSWORD` at startup.
- Documentation updated with PostgreSQL section.
---
## v0.4.0 - Default port changed to 3050
### Changed
- Application default bind port changed from `8080` to `3050`.
- Docker and docs updated to use `3050`:
- `main.go` default `APP_ADDR` -> `:3050`
- `docker-compose.yml` port mapping -> `3050:3050`
- `Dockerfile` `EXPOSE 3050`
- `install.sh`/`reinstall.sh` URL output -> `http://localhost:3050`
- `README.md` URLs updated to `http://localhost:3050`
---
## 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`
+13
View File
@@ -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
BIN
View File
Binary file not shown.
+13
View File
@@ -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"
+337
View File
@@ -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
}
+8
View File
@@ -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"
+12
View File
@@ -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."