Add SSH host/login/password/key when creating VPN servers
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add SSH connection fields to vpn_servers
|
||||||
|
|
||||||
|
Revision ID: 0002_ssh_fields
|
||||||
|
Revises: 0001_initial
|
||||||
|
Create Date: 2026-07-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0002_ssh_fields"
|
||||||
|
down_revision: Union[str, None] = "0001_initial"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("vpn_servers", sa.Column("ssh_host", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column("vpn_servers", sa.Column("ssh_port", sa.Integer(), nullable=False, server_default="22"))
|
||||||
|
op.add_column("vpn_servers", sa.Column("ssh_username", sa.String(length=128), nullable=True))
|
||||||
|
op.add_column("vpn_servers", sa.Column("ssh_password", sa.Text(), nullable=True))
|
||||||
|
op.add_column("vpn_servers", sa.Column("ssh_private_key", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("vpn_servers", "ssh_private_key")
|
||||||
|
op.drop_column("vpn_servers", "ssh_password")
|
||||||
|
op.drop_column("vpn_servers", "ssh_username")
|
||||||
|
op.drop_column("vpn_servers", "ssh_port")
|
||||||
|
op.drop_column("vpn_servers", "ssh_host")
|
||||||
@@ -6,7 +6,6 @@ from app.config import settings
|
|||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import AdminUser
|
from app.models import AdminUser
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
from app.services.vpn import ensure_default_servers
|
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap() -> None:
|
async def bootstrap() -> None:
|
||||||
@@ -34,8 +33,6 @@ async def bootstrap() -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
print(f"Admin user '{settings.admin_username}' synced from ENV")
|
print(f"Admin user '{settings.admin_username}' synced from ENV")
|
||||||
|
|
||||||
await ensure_default_servers(session)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(bootstrap())
|
asyncio.run(bootstrap())
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class VpnServer(Base):
|
|||||||
dns: Mapped[str] = mapped_column(String(255), default="1.1.1.1")
|
dns: Mapped[str] = mapped_column(String(255), default="1.1.1.1")
|
||||||
server_private_key: Mapped[str] = mapped_column(Text)
|
server_private_key: Mapped[str] = mapped_column(Text)
|
||||||
server_public_key: Mapped[str] = mapped_column(Text)
|
server_public_key: Mapped[str] = mapped_column(Text)
|
||||||
|
# SSH access to the VPS
|
||||||
|
ssh_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
ssh_port: Mapped[int] = mapped_column(Integer, default=22)
|
||||||
|
ssh_username: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
ssh_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ssh_private_key: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
# AmneziaWG 2.0 obfuscation params (ignored for plain WireGuard)
|
# AmneziaWG 2.0 obfuscation params (ignored for plain WireGuard)
|
||||||
jc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
jc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
jmin: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
jmin: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|||||||
+89
-11
@@ -68,15 +68,56 @@ async def servers_page(request: Request, db: AsyncSession = Depends(get_db), adm
|
|||||||
"request": request,
|
"request": request,
|
||||||
"admin": admin,
|
"admin": admin,
|
||||||
"servers": servers,
|
"servers": servers,
|
||||||
|
"protocols": Protocol,
|
||||||
"app_name": request.app.state.settings.app_name,
|
"app_name": request.app.state.settings.app_name,
|
||||||
|
"flash": request.query_params.get("flash"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers")
|
||||||
|
async def create_server(
|
||||||
|
name: str = Form(""),
|
||||||
|
protocol: str = Form(...),
|
||||||
|
ssh_host: str = Form(...),
|
||||||
|
ssh_port: int = Form(22),
|
||||||
|
ssh_username: str = Form(...),
|
||||||
|
ssh_password: str = Form(""),
|
||||||
|
ssh_private_key: str = Form(""),
|
||||||
|
public_host: str = Form(""),
|
||||||
|
public_port: str = Form(""),
|
||||||
|
dns: str = Form("1.1.1.1"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
if _is_redirect(admin):
|
||||||
|
return admin
|
||||||
|
try:
|
||||||
|
port_value = int(public_port) if public_port.strip() else None
|
||||||
|
await vpn_service.create_server(
|
||||||
|
db,
|
||||||
|
name=name,
|
||||||
|
protocol=protocol,
|
||||||
|
ssh_host=ssh_host,
|
||||||
|
ssh_port=ssh_port,
|
||||||
|
ssh_username=ssh_username,
|
||||||
|
ssh_password=ssh_password,
|
||||||
|
ssh_private_key=ssh_private_key,
|
||||||
|
public_host=public_host or None,
|
||||||
|
public_port=port_value,
|
||||||
|
dns=dns,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/servers?flash=error:{exc}",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
return RedirectResponse("/admin/servers?flash=created", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/servers/{server_id}/sync")
|
@router.post("/servers/{server_id}/sync")
|
||||||
async def sync_server(
|
async def sync_server(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
request: Request,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin=Depends(require_admin),
|
admin=Depends(require_admin),
|
||||||
):
|
):
|
||||||
@@ -86,12 +127,9 @@ async def sync_server(
|
|||||||
return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/servers/{server_id}")
|
@router.post("/servers/{server_id}/delete")
|
||||||
async def update_server(
|
async def delete_server(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
public_host: str = Form(...),
|
|
||||||
public_port: int = Form(...),
|
|
||||||
dns: str = Form("1.1.1.1"),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin=Depends(require_admin),
|
admin=Depends(require_admin),
|
||||||
):
|
):
|
||||||
@@ -99,12 +137,52 @@ async def update_server(
|
|||||||
return admin
|
return admin
|
||||||
server = await db.get(VpnServer, server_id)
|
server = await db.get(VpnServer, server_id)
|
||||||
if server:
|
if server:
|
||||||
server.public_host = public_host.strip()
|
await db.delete(server)
|
||||||
server.public_port = public_port
|
|
||||||
server.dns = dns.strip() or "1.1.1.1"
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await vpn_service.sync_server_config(db, server_id)
|
return RedirectResponse("/admin/servers?flash=deleted", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
@router.post("/servers/{server_id}")
|
||||||
|
async def update_server(
|
||||||
|
server_id: int,
|
||||||
|
name: str = Form(""),
|
||||||
|
public_host: str = Form(...),
|
||||||
|
public_port: int = Form(...),
|
||||||
|
dns: str = Form("1.1.1.1"),
|
||||||
|
ssh_host: str = Form(""),
|
||||||
|
ssh_port: int = Form(22),
|
||||||
|
ssh_username: str = Form(""),
|
||||||
|
ssh_password: str = Form(""),
|
||||||
|
ssh_private_key: str = Form(""),
|
||||||
|
clear_ssh_password: str = Form(""),
|
||||||
|
clear_ssh_private_key: str = Form(""),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
if _is_redirect(admin):
|
||||||
|
return admin
|
||||||
|
try:
|
||||||
|
await vpn_service.update_server_settings(
|
||||||
|
db,
|
||||||
|
server_id,
|
||||||
|
name=name or None,
|
||||||
|
public_host=public_host,
|
||||||
|
public_port=public_port,
|
||||||
|
dns=dns,
|
||||||
|
ssh_host=ssh_host,
|
||||||
|
ssh_port=ssh_port,
|
||||||
|
ssh_username=ssh_username,
|
||||||
|
ssh_password=ssh_password,
|
||||||
|
ssh_private_key=ssh_private_key,
|
||||||
|
clear_ssh_password=clear_ssh_password == "1",
|
||||||
|
clear_ssh_private_key=clear_ssh_private_key == "1",
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/servers?flash=error:{exc}",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
return RedirectResponse("/admin/servers?flash=saved", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clients", response_class=HTMLResponse)
|
@router.get("/clients", response_class=HTMLResponse)
|
||||||
|
|||||||
+157
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -75,6 +75,162 @@ async def ensure_default_servers(session: AsyncSession) -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults_for_protocol(protocol: str) -> dict:
|
||||||
|
if protocol == Protocol.AWG2.value:
|
||||||
|
return {
|
||||||
|
"interface_name": settings.awg_interface,
|
||||||
|
"subnet": settings.awg_subnet,
|
||||||
|
"public_port": settings.awg_public_port,
|
||||||
|
"jc": 4,
|
||||||
|
"jmin": 40,
|
||||||
|
"jmax": 70,
|
||||||
|
"s1": 0,
|
||||||
|
"s2": 0,
|
||||||
|
"h1": 1,
|
||||||
|
"h2": 2,
|
||||||
|
"h3": 3,
|
||||||
|
"h4": 4,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"interface_name": settings.wg_interface,
|
||||||
|
"subnet": settings.wg_subnet,
|
||||||
|
"public_port": settings.public_port,
|
||||||
|
"jc": None,
|
||||||
|
"jmin": None,
|
||||||
|
"jmax": None,
|
||||||
|
"s1": None,
|
||||||
|
"s2": None,
|
||||||
|
"h1": None,
|
||||||
|
"h2": None,
|
||||||
|
"h3": None,
|
||||||
|
"h4": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_server(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
protocol: str,
|
||||||
|
ssh_host: str,
|
||||||
|
ssh_username: str,
|
||||||
|
ssh_port: int = 22,
|
||||||
|
ssh_password: str | None = None,
|
||||||
|
ssh_private_key: str | None = None,
|
||||||
|
public_host: str | None = None,
|
||||||
|
public_port: int | None = None,
|
||||||
|
dns: str = "1.1.1.1",
|
||||||
|
) -> VpnServer:
|
||||||
|
protocol = protocol.strip().lower()
|
||||||
|
if protocol not in {Protocol.WIREGUARD.value, Protocol.AWG2.value}:
|
||||||
|
raise ValueError("Unsupported protocol")
|
||||||
|
|
||||||
|
password = (ssh_password or "").strip() or None
|
||||||
|
private_key = (ssh_private_key or "").strip() or None
|
||||||
|
if not password and not private_key:
|
||||||
|
raise ValueError("Укажите пароль SSH или приватный ключ")
|
||||||
|
|
||||||
|
host = ssh_host.strip()
|
||||||
|
if not host:
|
||||||
|
raise ValueError("Укажите SSH host")
|
||||||
|
username = ssh_username.strip()
|
||||||
|
if not username:
|
||||||
|
raise ValueError("Укажите SSH логин")
|
||||||
|
|
||||||
|
defaults = _defaults_for_protocol(protocol)
|
||||||
|
keys = generate_keypair()
|
||||||
|
# Unique interface per server id is assigned after insert; use temp then update
|
||||||
|
existing_count = await session.scalar(select(func.count()).select_from(VpnServer)) or 0
|
||||||
|
iface_base = "awg" if protocol == Protocol.AWG2.value else "wg"
|
||||||
|
interface_name = f"{iface_base}{existing_count}"
|
||||||
|
|
||||||
|
# Unique subnet per server: 10.{8+n}.0.0/24 or 10.{9+n}.0.0/24
|
||||||
|
octet = (8 if protocol == Protocol.WIREGUARD.value else 9) + int(existing_count)
|
||||||
|
if octet > 250:
|
||||||
|
raise ValueError("Слишком много серверов для авто-подсети")
|
||||||
|
subnet = f"10.{octet}.0.0/24"
|
||||||
|
|
||||||
|
server = VpnServer(
|
||||||
|
name=name.strip() or host,
|
||||||
|
protocol=protocol,
|
||||||
|
public_host=(public_host or host).strip(),
|
||||||
|
public_port=public_port or int(defaults["public_port"]),
|
||||||
|
interface_name=interface_name,
|
||||||
|
subnet=subnet,
|
||||||
|
dns=(dns or "1.1.1.1").strip(),
|
||||||
|
server_private_key=keys.private_key,
|
||||||
|
server_public_key=keys.public_key,
|
||||||
|
ssh_host=host,
|
||||||
|
ssh_port=ssh_port or 22,
|
||||||
|
ssh_username=username,
|
||||||
|
ssh_password=password,
|
||||||
|
ssh_private_key=private_key,
|
||||||
|
jc=defaults["jc"],
|
||||||
|
jmin=defaults["jmin"],
|
||||||
|
jmax=defaults["jmax"],
|
||||||
|
s1=defaults["s1"],
|
||||||
|
s2=defaults["s2"],
|
||||||
|
h1=defaults["h1"],
|
||||||
|
h2=defaults["h2"],
|
||||||
|
h3=defaults["h3"],
|
||||||
|
h4=defaults["h4"],
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
session.add(server)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(server)
|
||||||
|
await sync_server_config(session, server.id)
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
async def update_server_settings(
|
||||||
|
session: AsyncSession,
|
||||||
|
server_id: int,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
public_host: str,
|
||||||
|
public_port: int,
|
||||||
|
dns: str = "1.1.1.1",
|
||||||
|
ssh_host: str | None = None,
|
||||||
|
ssh_port: int = 22,
|
||||||
|
ssh_username: str | None = None,
|
||||||
|
ssh_password: str | None = None,
|
||||||
|
ssh_private_key: str | None = None,
|
||||||
|
clear_ssh_password: bool = False,
|
||||||
|
clear_ssh_private_key: bool = False,
|
||||||
|
) -> VpnServer:
|
||||||
|
server = await session.get(VpnServer, server_id)
|
||||||
|
if not server:
|
||||||
|
raise ValueError("Server not found")
|
||||||
|
|
||||||
|
if name is not None and name.strip():
|
||||||
|
server.name = name.strip()
|
||||||
|
server.public_host = public_host.strip()
|
||||||
|
server.public_port = public_port
|
||||||
|
server.dns = dns.strip() or "1.1.1.1"
|
||||||
|
|
||||||
|
if ssh_host is not None:
|
||||||
|
server.ssh_host = ssh_host.strip() or None
|
||||||
|
server.ssh_port = ssh_port or 22
|
||||||
|
if ssh_username is not None:
|
||||||
|
server.ssh_username = ssh_username.strip() or None
|
||||||
|
|
||||||
|
if clear_ssh_password:
|
||||||
|
server.ssh_password = None
|
||||||
|
elif ssh_password is not None and ssh_password.strip():
|
||||||
|
server.ssh_password = ssh_password.strip()
|
||||||
|
|
||||||
|
if clear_ssh_private_key:
|
||||||
|
server.ssh_private_key = None
|
||||||
|
elif ssh_private_key is not None and ssh_private_key.strip():
|
||||||
|
server.ssh_private_key = ssh_private_key.strip()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(server)
|
||||||
|
await sync_server_config(session, server_id)
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
async def create_client(
|
async def create_client(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
+31
-1
@@ -221,8 +221,38 @@ tr:last-child td { border-bottom: none; }
|
|||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
.form-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid, .form-grid-2 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.form-section {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.check input { width: auto; }
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
color: var(--ink);
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
label { display: grid; gap: 0.35rem; font-size: 0.88rem; color: var(--muted); }
|
label { display: grid; gap: 0.35rem; font-size: 0.88rem; color: var(--muted); }
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
|
|||||||
@@ -5,35 +5,144 @@
|
|||||||
<h1>Серверы</h1>
|
<h1>Серверы</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if flash %}
|
||||||
|
{% if flash.startswith('error:') %}
|
||||||
|
<div class="flash flash-error">{{ flash[6:] }}</div>
|
||||||
|
{% elif flash == 'created' %}
|
||||||
|
<div class="flash">Сервер добавлен</div>
|
||||||
|
{% elif flash == 'saved' %}
|
||||||
|
<div class="flash">Сохранено</div>
|
||||||
|
{% elif flash == 'deleted' %}
|
||||||
|
<div class="flash">Сервер удалён</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="panel" style="margin-bottom:1.25rem">
|
||||||
|
<div class="panel-head"><strong>Добавить сервер</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" action="/admin/servers" class="stack">
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<label>Название
|
||||||
|
<input name="name" placeholder="Мой VPS" />
|
||||||
|
</label>
|
||||||
|
<label>Протокол
|
||||||
|
<select name="protocol" required>
|
||||||
|
<option value="wireguard">WireGuard</option>
|
||||||
|
<option value="awg2">AmneziaWG 2.0</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="form-section">SSH подключение</h3>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<label>Host
|
||||||
|
<input name="ssh_host" placeholder="1.2.3.4 или vps.example.com" required />
|
||||||
|
</label>
|
||||||
|
<label>SSH порт
|
||||||
|
<input type="number" name="ssh_port" value="22" required />
|
||||||
|
</label>
|
||||||
|
<label>Логин
|
||||||
|
<input name="ssh_username" value="root" required />
|
||||||
|
</label>
|
||||||
|
<label>Пароль
|
||||||
|
<input type="password" name="ssh_password" placeholder="или оставьте пустым, если ключ" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Приватный ключ (опционально, вместо пароля)
|
||||||
|
<textarea name="ssh_private_key" rows="5" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"></textarea>
|
||||||
|
</label>
|
||||||
|
<p class="muted" style="margin:0">Нужен пароль <em>или</em> приватный ключ.</p>
|
||||||
|
|
||||||
|
<h3 class="form-section">VPN endpoint</h3>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<label>Public host (если пусто — как SSH host)
|
||||||
|
<input name="public_host" placeholder="тот же IP/домен" />
|
||||||
|
</label>
|
||||||
|
<label>VPN порт
|
||||||
|
<input type="number" name="public_port" placeholder="авто" />
|
||||||
|
</label>
|
||||||
|
<label>DNS
|
||||||
|
<input name="dns" value="1.1.1.1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-accent" type="submit">Добавить сервер</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% for s in servers %}
|
{% for s in servers %}
|
||||||
<div class="panel" style="margin-bottom:1rem">
|
<div class="panel" style="margin-bottom:1rem">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<strong>{{ s.name }} <span class="badge badge-proto">{{ s.protocol }}</span></strong>
|
<strong>{{ s.name }} <span class="badge badge-proto">{{ s.protocol }}</span></strong>
|
||||||
<form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form">
|
<div class="actions">
|
||||||
<button class="btn btn-sm btn-ghost" type="submit">Синхронизировать conf</button>
|
<form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form">
|
||||||
</form>
|
<button class="btn btn-sm btn-ghost" type="submit">Синхронизировать conf</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/servers/{{ s.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить сервер и всех клиентов?')">
|
||||||
|
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<p class="muted" style="margin-top:0">Интерфейс <code>{{ s.interface_name }}</code> · подсеть <code>{{ s.subnet }}</code> · клиентов: {{ s.clients|length }}</p>
|
<p class="muted" style="margin-top:0">
|
||||||
|
Интерфейс <code>{{ s.interface_name }}</code> · подсеть <code>{{ s.subnet }}</code> · клиентов: {{ s.clients|length }}
|
||||||
|
{% if s.ssh_host %}
|
||||||
|
· SSH
|
||||||
|
{% if s.ssh_private_key %}<span class="badge badge-ok">ключ</span>{% endif %}
|
||||||
|
{% if s.ssh_password %}<span class="badge badge-ok">пароль</span>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
· <span class="badge badge-off">SSH не задан</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
<p><code>Public key:</code> <span class="muted">{{ s.server_public_key }}</span></p>
|
<p><code>Public key:</code> <span class="muted">{{ s.server_public_key }}</span></p>
|
||||||
<form method="post" action="/admin/servers/{{ s.id }}" class="form-grid">
|
|
||||||
<label>Public host
|
<form method="post" action="/admin/servers/{{ s.id }}" class="stack">
|
||||||
<input name="public_host" value="{{ s.public_host }}" required />
|
<div class="form-grid-2">
|
||||||
</label>
|
<label>Название
|
||||||
<label>Port
|
<input name="name" value="{{ s.name }}" />
|
||||||
<input type="number" name="public_port" value="{{ s.public_port }}" required />
|
</label>
|
||||||
|
<label>DNS
|
||||||
|
<input name="dns" value="{{ s.dns }}" />
|
||||||
|
</label>
|
||||||
|
<label>Public host
|
||||||
|
<input name="public_host" value="{{ s.public_host }}" required />
|
||||||
|
</label>
|
||||||
|
<label>VPN порт
|
||||||
|
<input type="number" name="public_port" value="{{ s.public_port }}" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="form-section">SSH подключение</h3>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<label>Host
|
||||||
|
<input name="ssh_host" value="{{ s.ssh_host or '' }}" placeholder="1.2.3.4" />
|
||||||
|
</label>
|
||||||
|
<label>SSH порт
|
||||||
|
<input type="number" name="ssh_port" value="{{ s.ssh_port or 22 }}" />
|
||||||
|
</label>
|
||||||
|
<label>Логин
|
||||||
|
<input name="ssh_username" value="{{ s.ssh_username or '' }}" placeholder="root" />
|
||||||
|
</label>
|
||||||
|
<label>Новый пароль
|
||||||
|
<input type="password" name="ssh_password" placeholder="{% if s.ssh_password %}сохранён — введите, чтобы заменить{% else %}не задан{% endif %}" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Новый приватный ключ
|
||||||
|
<textarea name="ssh_private_key" rows="4" placeholder="{% if s.ssh_private_key %}ключ сохранён — вставьте новый, чтобы заменить{% else %}не задан{% endif %}"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="clear_ssh_password" value="1" /> очистить пароль
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="clear_ssh_private_key" value="1" /> очистить ключ
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="btn btn-accent" type="submit">Сохранить</button>
|
<button class="btn btn-accent" type="submit">Сохранить</button>
|
||||||
<label style="grid-column: 1 / -1">DNS
|
|
||||||
<input name="dns" value="{{ s.dns }}" />
|
|
||||||
</label>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Серверов пока нет — добавьте первый через форму выше.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<p class="muted">
|
|
||||||
Конфиги пишутся в volume: <code>/data/wireguard</code> и <code>/data/amneziawg</code>.
|
|
||||||
Подключите их к WG/AWG контейнерам на том же хосте.
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user