Compare commits
14 Commits
1e6eadeed2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f5c53f01bf | |||
| de6753b077 | |||
| cbfafe4471 | |||
| 3251ce3380 | |||
| b7ec4b81a5 | |||
| 06643e2d50 | |||
| 0fe2f7d9a9 | |||
| 6f7f28408d | |||
| c3efdc5a8f | |||
| 3092645408 | |||
| fc4b73c536 | |||
| 85bcb304f6 | |||
| 53e18d92ac | |||
| 2e0d4c78ca |
@@ -100,3 +100,45 @@ def has_access(db: Session, user_id: int, service_id: int) -> bool:
|
|||||||
UserServiceAccess.service_id == service_id,
|
UserServiceAccess.service_id == service_id,
|
||||||
)
|
)
|
||||||
return db.scalar(q) is not None
|
return db.scalar(q) is not None
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
_login_attempts: dict = {}
|
||||||
|
_login_lock = threading.Lock()
|
||||||
|
|
||||||
|
_LOGIN_MAX_ATTEMPTS = 5
|
||||||
|
_LOGIN_WINDOW = 300 # сброс счётчика через 5 минут
|
||||||
|
_LOGIN_BLOCK = 900 # блокировка на 15 минут
|
||||||
|
|
||||||
|
|
||||||
|
def check_login_rate_limit(ip: str) -> Optional[float]:
|
||||||
|
"""Возвращает blocked_until (time.monotonic) если IP заблокирован, иначе None."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _login_lock:
|
||||||
|
entry = _login_attempts.get(ip)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
if entry["blocked_until"] > now:
|
||||||
|
return entry["blocked_until"]
|
||||||
|
if now - entry["first"] > _LOGIN_WINDOW:
|
||||||
|
del _login_attempts[ip]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def record_login_failure(ip: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _login_lock:
|
||||||
|
entry = _login_attempts.get(ip)
|
||||||
|
if not entry or now - entry["first"] > _LOGIN_WINDOW:
|
||||||
|
_login_attempts[ip] = {"count": 1, "first": now, "blocked_until": 0.0}
|
||||||
|
entry = _login_attempts[ip]
|
||||||
|
else:
|
||||||
|
entry["count"] += 1
|
||||||
|
if entry["count"] >= _LOGIN_MAX_ATTEMPTS:
|
||||||
|
entry["blocked_until"] = now + _LOGIN_BLOCK
|
||||||
|
|
||||||
|
|
||||||
|
def record_login_success(ip: str) -> None:
|
||||||
|
with _login_lock:
|
||||||
|
_login_attempts.pop(ip, None)
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ from utils import (
|
|||||||
from auth import (
|
from auth import (
|
||||||
get_current_user, has_access, issue_auth_cookie, issue_csrf_cookie,
|
get_current_user, has_access, issue_auth_cookie, issue_csrf_cookie,
|
||||||
require_admin, require_user, user_is_valid, validate_csrf, verify_password, hash_password,
|
require_admin, require_user, user_is_valid, validate_csrf, verify_password, hash_password,
|
||||||
|
check_login_rate_limit,
|
||||||
|
record_login_failure,
|
||||||
|
record_login_success,
|
||||||
)
|
)
|
||||||
from runtime import (
|
from runtime import (
|
||||||
acquire_universal_slot, acquire_web_pool_slot, allocator_lock,
|
acquire_universal_slot, acquire_web_pool_slot, allocator_lock,
|
||||||
@@ -380,7 +383,7 @@ async def _process_callback_query(cq: dict):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
app = FastAPI(title="MONT - инфрастуктурный полигон")
|
app = FastAPI(title="MONT - инфрастуктурный полигон", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
@@ -959,7 +962,7 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
@app.api_route("/favicon.ico", methods=["GET", "HEAD"], include_in_schema=False)
|
||||||
def favicon():
|
def favicon():
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
return FileResponse("static/favicon.ico", media_type="image/x-icon")
|
return FileResponse("static/favicon.ico", media_type="image/x-icon")
|
||||||
@@ -1160,8 +1163,13 @@ def login(
|
|||||||
if not cookie_csrf or csrf_token != cookie_csrf:
|
if not cookie_csrf or csrf_token != cookie_csrf:
|
||||||
raise HTTPException(status_code=403, detail="CSRF failed")
|
raise HTTPException(status_code=403, detail="CSRF failed")
|
||||||
|
|
||||||
|
ip = _get_real_ip(request)
|
||||||
|
if check_login_rate_limit(ip):
|
||||||
|
raise HTTPException(status_code=429, detail="Слишком много попыток входа. Попробуйте через 15 минут.")
|
||||||
|
|
||||||
user = db.scalar(select(User).where(User.username == username))
|
user = db.scalar(select(User).where(User.username == username))
|
||||||
if not user or not verify_password(password, user.password_hash):
|
if not user or not verify_password(password, user.password_hash):
|
||||||
|
record_login_failure(ip)
|
||||||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||||||
response = templates.TemplateResponse(
|
response = templates.TemplateResponse(
|
||||||
"login.html",
|
"login.html",
|
||||||
@@ -1189,6 +1197,7 @@ def login(
|
|||||||
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
|
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
record_login_success(ip)
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
issue_auth_cookie(response, user)
|
issue_auth_cookie(response, user)
|
||||||
issue_csrf_cookie(response)
|
issue_csrf_cookie(response)
|
||||||
@@ -1679,7 +1688,7 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re
|
|||||||
if (data.ready) {{
|
if (data.ready) {{
|
||||||
try {{
|
try {{
|
||||||
const probe = await fetch(iframeSrc, {{method:'HEAD', credentials:'include'}});
|
const probe = await fetch(iframeSrc, {{method:'HEAD', credentials:'include'}});
|
||||||
if (probe.status < 500) {{
|
if (probe.status >= 200 && probe.status < 300) {{
|
||||||
const f = document.getElementById('app-frame');
|
const f = document.getElementById('app-frame');
|
||||||
f.src = iframeSrc;
|
f.src = iframeSrc;
|
||||||
f.style.display = '';
|
f.style.display = '';
|
||||||
|
|||||||
@@ -120,23 +120,17 @@ def try_acquire_maintenance_leader() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def run_maintenance_service() -> None:
|
def _init_schema() -> None:
|
||||||
logger.info("maintenance_service_bootstrap_started")
|
|
||||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
ensure_schema_compatibility()
|
ensure_schema_compatibility()
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
ensure_icons_dir()
|
ensure_icons_dir()
|
||||||
bootstrap_admin()
|
bootstrap_admin()
|
||||||
|
|
||||||
maintenance_lock = open("/tmp/portal-maintenance.lock", "w")
|
|
||||||
fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX)
|
|
||||||
logger.info("maintenance_service_leader_acquired")
|
|
||||||
|
|
||||||
db = SessionLocal()
|
def _startup_pools(db) -> None:
|
||||||
try:
|
|
||||||
ensure_universal_pool()
|
ensure_universal_pool()
|
||||||
ensure_web_pool()
|
ensure_web_pool()
|
||||||
for svc in db.scalars(
|
for svc in db.scalars(
|
||||||
@@ -163,6 +157,19 @@ def run_maintenance_service() -> None:
|
|||||||
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
|
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
|
||||||
if slots:
|
if slots:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def run_maintenance_service() -> None:
|
||||||
|
logger.info("maintenance_service_bootstrap_started")
|
||||||
|
_init_schema()
|
||||||
|
|
||||||
|
maintenance_lock = open("/tmp/portal-maintenance.lock", "w")
|
||||||
|
fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX)
|
||||||
|
logger.info("maintenance_service_leader_acquired")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
_startup_pools(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -171,13 +178,7 @@ def run_maintenance_service() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def on_startup() -> None:
|
def on_startup() -> None:
|
||||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
_init_schema()
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
ensure_schema_compatibility()
|
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
||||||
ensure_icons_dir()
|
|
||||||
bootstrap_admin()
|
|
||||||
if not try_acquire_maintenance_leader():
|
if not try_acquire_maintenance_leader():
|
||||||
logger.info("maintenance_leader_skipped")
|
logger.info("maintenance_leader_skipped")
|
||||||
return
|
return
|
||||||
@@ -185,32 +186,7 @@ def on_startup() -> None:
|
|||||||
if ENABLE_STARTUP_MAINTENANCE:
|
if ENABLE_STARTUP_MAINTENANCE:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ensure_universal_pool()
|
_startup_pools(db)
|
||||||
ensure_web_pool()
|
|
||||||
for svc in db.scalars(
|
|
||||||
select(Service).where(
|
|
||||||
Service.active == True,
|
|
||||||
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
|
||||||
)
|
|
||||||
).all():
|
|
||||||
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
|
||||||
ensure_warm_pool(svc)
|
|
||||||
elif svc.type == ServiceType.RDP:
|
|
||||||
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id)).all()
|
|
||||||
for slot in slots:
|
|
||||||
try:
|
|
||||||
cname = _rdp_slot_container_name(svc.slug, slot.id)
|
|
||||||
try:
|
|
||||||
c = docker_client().containers.get(cname)
|
|
||||||
if c.status != "running":
|
|
||||||
c.start()
|
|
||||||
except docker.errors.NotFound:
|
|
||||||
start_rdp_slot_container(slot, svc)
|
|
||||||
slot.container_name = cname
|
|
||||||
except Exception:
|
|
||||||
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
|
|
||||||
if slots:
|
|
||||||
db.commit()
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -194,16 +194,15 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def get_universal_pool_status() -> dict:
|
def _get_pool_status(desired_size: int, name_fn) -> dict:
|
||||||
desired = max(0, UNIVERSAL_POOL_SIZE)
|
desired = max(0, desired_size)
|
||||||
if desired <= 0:
|
if desired <= 0:
|
||||||
return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
|
return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
|
||||||
d = docker_client()
|
d = docker_client()
|
||||||
names = [universal_container_name(i) for i in range(desired)]
|
|
||||||
containers = []
|
containers = []
|
||||||
for name in names:
|
for i in range(desired):
|
||||||
try:
|
try:
|
||||||
containers.append(d.containers.get(name))
|
containers.append(d.containers.get(name_fn(i)))
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
running = sum(1 for c in containers if c.status == "running")
|
running = sum(1 for c in containers if c.status == "running")
|
||||||
@@ -217,27 +216,12 @@ def get_universal_pool_status() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_universal_pool_status() -> dict:
|
||||||
|
return _get_pool_status(UNIVERSAL_POOL_SIZE, universal_container_name)
|
||||||
|
|
||||||
|
|
||||||
def get_web_pool_status() -> dict:
|
def get_web_pool_status() -> dict:
|
||||||
desired = max(0, WEB_POOL_SIZE)
|
return _get_pool_status(WEB_POOL_SIZE, web_pool_container_name)
|
||||||
if desired <= 0:
|
|
||||||
return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
|
|
||||||
d = docker_client()
|
|
||||||
names = [web_pool_container_name(i) for i in range(desired)]
|
|
||||||
containers = []
|
|
||||||
for name in names:
|
|
||||||
try:
|
|
||||||
containers.append(d.containers.get(name))
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
running = sum(1 for c in containers if c.status == "running")
|
|
||||||
health = "ok" if running >= min(desired, 1) else "down"
|
|
||||||
return {
|
|
||||||
"desired": desired,
|
|
||||||
"running": running,
|
|
||||||
"total": len(containers),
|
|
||||||
"names": sorted(c.name for c in containers),
|
|
||||||
"health": health,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_universal_slot(db: Session) -> int:
|
def acquire_universal_slot(db: Session) -> int:
|
||||||
@@ -303,6 +287,20 @@ def sanitize_client_resolution(width: Optional[int], height: Optional[int]) -> t
|
|||||||
return clamped_width, clamped_height
|
return clamped_width, clamped_height
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_post(url: str, payload: dict) -> None:
|
||||||
|
last_exc = None
|
||||||
|
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
|
||||||
|
if last_exc:
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
def dispatch_universal_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
|
def dispatch_universal_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
|
||||||
name = universal_container_name(slot)
|
name = universal_container_name(slot)
|
||||||
url = ""
|
url = ""
|
||||||
@@ -328,17 +326,7 @@ def dispatch_universal_target(slot: int, service: Service, width: Optional[int]
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
|
raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
|
||||||
|
|
||||||
last_exc = None
|
_dispatch_post(url, payload)
|
||||||
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
|
||||||
try:
|
|
||||||
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
last_exc = exc
|
|
||||||
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
|
|
||||||
if last_exc:
|
|
||||||
raise last_exc
|
|
||||||
|
|
||||||
|
|
||||||
def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
|
def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
|
||||||
@@ -350,17 +338,7 @@ def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] =
|
|||||||
if width and height:
|
if width and height:
|
||||||
payload["width"] = width
|
payload["width"] = width
|
||||||
payload["height"] = height
|
payload["height"] = height
|
||||||
last_exc = None
|
_dispatch_post(url, payload)
|
||||||
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
|
||||||
try:
|
|
||||||
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
last_exc = exc
|
|
||||||
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
|
|
||||||
if last_exc:
|
|
||||||
raise last_exc
|
|
||||||
|
|
||||||
|
|
||||||
def create_runtime_container(service: Service, session_id: str):
|
def create_runtime_container(service: Service, session_id: str):
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "Инфраструктурный полигон MONT",
|
||||||
|
"short_name": "MONT Полигон",
|
||||||
|
"description": "Демонстрация и пилотное тестирование российского ПО",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "browser",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/static/favicon_192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/static/favicon_512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@@ -937,7 +937,7 @@ button {
|
|||||||
|
|
||||||
.login-corner-logo {
|
.login-corner-logo {
|
||||||
display: block;
|
display: block;
|
||||||
height: 36px;
|
height: 54px;
|
||||||
width: auto;
|
width: auto;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
filter: brightness(0) invert(1);
|
filter: brightness(0) invert(1);
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
<link rel="stylesheet" href="/static/style.css?v=2" />
|
<link rel="stylesheet" href="/static/style.css?v=2" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon_32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/static/favicon_192.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="/static/favicon_192.png" />
|
||||||
|
<link rel="manifest" href="/static/manifest.json" />
|
||||||
<!-- Yandex.Metrika counter -->
|
<!-- Yandex.Metrika counter -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
(function(m,e,t,r,i,k,a){
|
(function(m,e,t,r,i,k,a){
|
||||||
@@ -24,7 +27,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||||
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="header-logo" /></a>
|
<a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" class="header-logo" /></a>
|
||||||
<div>MONT - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
|
<div>MONT - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn-link secondary">Главная панель</a>
|
<a href="/" class="btn-link secondary">Главная панель</a>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
<link rel="stylesheet" href="/static/style.css?v=2" />
|
<link rel="stylesheet" href="/static/style.css?v=2" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon_32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/static/favicon_192.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="/static/favicon_192.png" />
|
||||||
|
<link rel="manifest" href="/static/manifest.json" />
|
||||||
<!-- Yandex.Metrika counter -->
|
<!-- Yandex.Metrika counter -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
(function(m,e,t,r,i,k,a){
|
(function(m,e,t,r,i,k,a){
|
||||||
@@ -33,7 +36,7 @@
|
|||||||
.mw-footer{position:absolute;bottom:1.2rem;left:0;width:100%;text-align:center;font-size:clamp(.65rem,2.8vw,.78rem);color:rgba(160,184,204,.45);font-family:sans-serif}
|
.mw-footer{position:absolute;bottom:1.2rem;left:0;width:100%;text-align:center;font-size:clamp(.65rem,2.8vw,.78rem);color:rgba(160,184,204,.45);font-family:sans-serif}
|
||||||
</style>{% endraw %}
|
</style>{% endraw %}
|
||||||
<div id="mobile-wall">
|
<div id="mobile-wall">
|
||||||
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" style="position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);height:clamp(4rem,16vw,6rem);opacity:.9"></a>
|
<a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" style="position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);height:clamp(4rem,16vw,6rem);opacity:.9"></a>
|
||||||
<div class="mw-icon">🖥️</div>
|
<div class="mw-icon">🖥️</div>
|
||||||
<div class="mw-title">Только для компьютера</div>
|
<div class="mw-title">Только для компьютера</div>
|
||||||
<div class="mw-sub">Инфраструктурный полигон MONT оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
|
<div class="mw-sub">Инфраструктурный полигон MONT оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="page-logo-wrap">
|
<div class="page-logo-wrap">
|
||||||
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="page-logo" /></a>
|
<a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" class="page-logo" /></a>
|
||||||
</div>
|
</div>
|
||||||
<main class="admin-layout">
|
<main class="admin-layout">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<meta property="og:url" content="{{ base_url }}" />
|
<meta property="og:url" content="{{ base_url }}" />
|
||||||
<meta property="og:title" content="Инфраструктурный полигон MONT — демо и пилоты российского ПО" />
|
<meta property="og:title" content="Инфраструктурный полигон MONT — демо и пилоты российского ПО" />
|
||||||
<meta property="og:description" content="Демонстрация и тестирование российского ПО для партнёров и заказчиков MONT. Доступ к рабочим стендам прямо в браузере." />
|
<meta property="og:description" content="Демонстрация и тестирование российского ПО для партнёров и заказчиков MONT. Доступ к рабочим стендам прямо в браузере." />
|
||||||
<meta property="og:image" content="{{ base_url }}static/logo.png?v=2" />
|
<meta property="og:image" content="{{ base_url }}static/logo.png?v=4" />
|
||||||
<meta property="og:locale" content="ru_RU" />
|
<meta property="og:locale" content="ru_RU" />
|
||||||
<meta property="og:site_name" content="Полигон MONT" />
|
<meta property="og:site_name" content="Полигон MONT" />
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
@@ -37,7 +37,10 @@
|
|||||||
<link rel="stylesheet" href="/static/style.css?v=2" />
|
<link rel="stylesheet" href="/static/style.css?v=2" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon_32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/static/favicon_192.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="/static/favicon_192.png" />
|
||||||
|
<link rel="manifest" href="/static/manifest.json" />
|
||||||
<style>
|
<style>
|
||||||
body { background: #070f1c; overflow: hidden; height: 100vh; }
|
body { background: #070f1c; overflow: hidden; height: 100vh; }
|
||||||
@media (max-width: 820px) { body { overflow: auto; height: auto; } }
|
@media (max-width: 820px) { body { overflow: auto; height: auto; } }
|
||||||
@@ -61,7 +64,7 @@
|
|||||||
<div class="login-left-glow login-left-glow-top"></div>
|
<div class="login-left-glow login-left-glow-top"></div>
|
||||||
<div class="login-left-glow login-left-glow-bottom"></div>
|
<div class="login-left-glow login-left-glow-bottom"></div>
|
||||||
<div class="login-left-inner">
|
<div class="login-left-inner">
|
||||||
<a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;"><img src="/static/logo.png?v=2" alt="MONT" class="login-corner-logo" /></a>
|
<a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;"><img src="/static/logo.png?v=4" alt="MONT" class="login-corner-logo" /></a>
|
||||||
<h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1>
|
<h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1>
|
||||||
<p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
|
<p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
|
||||||
<ul class="login-features">
|
<ul class="login-features">
|
||||||
|
|||||||