129 Commits

Author SHA1 Message Date
ruslan 18bb4bfaf4 Update description text: Партнеры MONT и их заказчики 2026-06-15 13:27:03 +00:00
ruslan 2a494dfa5e Reduce left panel title font size to fit narrow column 2026-06-08 09:39:06 +00:00
ruslan 8bf6c44485 Fix left panel overflow: top-align content, allow vertical scroll 2026-06-08 09:37:18 +00:00
ruslan dff17ad56a Move logo inside left panel to fix overlap at any screen size 2026-06-08 09:33:20 +00:00
ruslan 0d22003716 Fix logo overlap on mobile, fix cookie banner persistence 2026-06-08 09:29:39 +00:00
ruslan 986dcf5c84 Update favicon: new SVG and PNG versions 2026-05-29 16:11:26 +00:00
ruslan efa1c26e5d Email improvements: domain-aware portal URL, embedded logo, fix product list color
- Store request origin domain in PendingAccessRequest.portal_url
- Use per-request portal URL in approval/rejection emails
- Embed logo as base64 so it displays without external image loading
- Fix 'Предоставлен доступ к продуктам' text color to match body color
- Switch Telegram polling to 30-second interval with single-worker flock fix
2026-05-29 16:10:40 +00:00
ruslan e5ea23487e Add Telegram approval flow: inline buttons, user creation, email notifications 2026-05-29 14:41:42 +00:00
ruslan ad1e781040 Add cookie consent banner with localStorage persistence 2026-05-28 11:42:24 +00:00
ruslan 4a16813942 Update logo link to www.mont.ru 2026-05-28 11:17:12 +00:00
ruslan 202b609b3e Update Made by Galyaviev email to ruslan@ipcom.su 2026-05-28 11:12:32 +00:00
ruslan 56cc2495a6 Logo links to mont.ru or 4mont.ru depending on domain 2026-05-28 11:10:35 +00:00
ruslan 59bfb66ae4 Open maps.mont.ru when accessed via stand.mont.ru 2026-05-28 10:44:33 +00:00
ruslan 7918c16a59 Replace contact modal with mailto link on Made by Galyaviev 2026-05-28 10:29:17 +00:00
ruslan de49bffc1b Update privacy redirect to mont.ru/ru-ru/privacy 2026-05-28 10:22:54 +00:00
ruslan 7765d666ef Replace privacy page with 301 redirect to mont.ru/ru-ru/agreement 2026-05-28 10:22:02 +00:00
ruslan 46cc29fd4a Remove hardcoded domains from privacy policy 2026-05-28 09:54:55 +00:00
ruslan 1c4f351f10 Replace mont.com with mont.ru 2026-05-28 09:53:53 +00:00
ruslan 9d2a25af10 Make canonical/OG URLs domain-aware via x-forwarded-proto 2026-05-28 09:53:21 +00:00
ruslan a10f2c240a Force white color on consent label span 2026-05-28 09:49:37 +00:00
ruslan 823b28983c Set consent label text color to white 2026-05-28 09:48:08 +00:00
ruslan 984f8c324f Improve consent checkbox text visibility 2026-05-28 09:47:14 +00:00
ruslan e88e33e7e8 Add privacy policy page and consent checkbox to both modals (152-FZ compliance) 2026-05-28 09:44:17 +00:00
ruslan 9de7538309 Remove redundant tls.domains labels, NPM handles TLS 2026-05-28 09:02:23 +00:00
ruslan df12c54c76 Add stand.mont.ru as second domain with TLS cert 2026-05-28 09:00:34 +00:00
ruslan 8ab7df12a1 Replace logo.png with new version, rename МОНТ→MONT everywhere 2026-05-27 17:39:16 +00:00
ruslan dd7288beaf Add SVG favicon 120x120 with MONT branding, add SVG link to all templates 2026-05-18 07:19:50 +00:00
ruslan 5c06440e4d Add Yandex Webmaster verification file 2026-05-15 13:02:42 +00:00
ruslan 3d531238d7 SEO: meta tags, OG, JSON-LD, robots.txt, sitemap, keywords in content 2026-05-15 12:50:55 +00:00
ruslan 4b2618191d Add Telegram config vars to config.py 2026-05-14 08:19:56 +00:00
ruslan a4b69b0018 Fix real IP: trust upstream forwardedHeaders in Traefik, use X-Forwarded-For[0] 2026-05-14 07:41:51 +00:00
ruslan 73c7d006c7 Fix _get_real_ip: use X-Real-IP from NPM instead of X-Forwarded-For 2026-05-14 07:33:49 +00:00
ruslan 1aa9db8e2a Add real IP + geo location to Telegram notifications 2026-05-14 07:27:23 +00:00
ruslan 4b5b9906a8 Remove access modal subtitle 2026-05-14 07:00:42 +00:00
ruslan d65b7a0d35 Fix submit forms: use getElementById instead of stale closures, fix texts 2026-05-14 06:52:20 +00:00
ruslan a60279ae3e Fix JS syntax errors in modal success buttons (broken single quotes) 2026-05-14 06:45:16 +00:00
ruslan b36b3f6325 Add contact modal, success messages, form reset on open 2026-05-14 06:42:09 +00:00
ruslan ba8f3cf753 Validate all modal fields at once with per-field highlighting 2026-05-14 06:34:29 +00:00
ruslan eb05bcac53 Add email and phone validation to request-access modal 2026-05-14 06:29:37 +00:00
ruslan beb2781123 Fix request-access: add Telegram env to compose, fix log_event calls 2026-05-14 06:28:58 +00:00
ruslan a0b1754ddb Rename modal title to Запрос на доступ 2026-05-14 06:25:04 +00:00
ruslan ce39573618 Fix login-request-btn width after a→button change 2026-05-14 06:24:11 +00:00
ruslan f740420a77 Add request access modal on login page with Telegram notification
- Modal form: name, company, email, phone (required), manager (optional), product checkboxes
- Products loaded from DB via GET /api/public/services-by-category (public route)
- POST /api/request-access sends styled Telegram message with divider and emojis
- Dark-themed modal matching login page design
- CSS: overlay, card, fields, checkbox list, error, footer buttons
2026-05-14 06:22:39 +00:00
ruslan 9530f3e957 fix: autofill dispatches focus/blur/keyup/InputEvent for SPA frameworks 2026-05-13 12:14:44 +00:00
ruslan 3e640fbe15 revert: restore CSS to working state before logo column experiments 2026-05-12 13:29:08 +00:00
ruslan eda342cf43 fix: logo in own grid column, content never overlaps 2026-05-12 13:27:09 +00:00
ruslan e8d1515f89 fix: reserve space for fixed page-logo, prevent content overlap 2026-05-12 13:24:05 +00:00
ruslan 4f52ae8566 style: add gap between avatar and username in header 2026-05-12 13:20:47 +00:00
ruslan 30ce37b906 fix: remove first_name/last_name from all models except User 2026-05-12 13:01:29 +00:00
ruslan 4268b19a37 fix: remove first_name/last_name from Service model (was added by mistake) 2026-05-12 12:59:38 +00:00
ruslan 6aa40eb5c2 feat: add first_name/last_name to users, avatar in header, neutral dashboard bg 2026-05-12 12:51:47 +00:00
ruslan dedf4aea77 dashboard: replace informal welcome text with product name 2026-05-12 12:44:44 +00:00
ruslan fff7ecdce2 login: left panel 1/4, distrib button, text tweaks, dashboard light theme polish 2026-05-12 12:42:12 +00:00
ruslan 666093f1c6 login: logo only in top-left corner, left panel 1/3 right panel 2/3 2026-05-11 08:54:25 +00:00
ruslan 020793a3e2 redesign: stylish two-column login page (dark navy split layout) 2026-05-11 08:50:02 +00:00
ruslan 55da535f44 feat: project description block on login page 2026-05-11 08:43:50 +00:00
ruslan d7716fa569 design: stylish request-access button on login page 2026-05-08 13:05:02 +00:00
ruslan 116ffba42d feat: add Yandex Metrika counter (id=109119977) to all pages 2026-05-08 13:03:46 +00:00
ruslan b9f1e375d3 feat: request access button on login page (mailto rgalyaviev) 2026-05-08 12:59:15 +00:00
ruslan e516cc4aeb feat: Russian locale (ru_RU.UTF-8) in universal-runtime for Chromium UI language 2026-05-08 12:54:16 +00:00
ruslan 52cb1fd3d6 feat: fullscreen button in nav panel for web and rdp services 2026-05-08 12:00:39 +00:00
ruslan 1dc5a0eb34 fix: replace favicon with correct local file 2026-05-07 07:26:15 +00:00
ruslan 983065ac9f fix: use favicon.png instead of svg 2026-05-07 07:23:56 +00:00
ruslan 7e94ddaf8d fix: rdp target field readonly, host/port/domain/sec oninput rebuilds target 2026-05-06 11:43:26 +00:00
ruslan 2edb804660 fix: autofill login first then password, continuous re-fill for SPA re-renders 2026-05-05 11:05:09 +00:00
ruslan f994674327 merge: refactor/split-main-py into main 2026-05-04 14:46:05 +00:00
ruslan a44422f43b feat: draggable nav panel in web runtime (universal-runtime) 2026-05-04 14:46:00 +00:00
ruslan a137729704 design: username left in header, white elegant font 2026-05-04 13:35:32 +00:00
ruslan bbe1e27582 design: logo fixed left below header 2026-05-04 13:33:55 +00:00
ruslan 16c06ac166 design: move logo below header strip, scrolls with page 2026-05-04 13:32:43 +00:00
ruslan 0b37d5245c feat: draggable nav panel with position saved to localStorage 2026-05-04 13:28:10 +00:00
ruslan 535d71709e fix: dark header background, original logo color 2026-05-04 13:08:24 +00:00
ruslan 045b21c514 design: dark minimal header 2026-05-04 13:06:29 +00:00
ruslan d8f9f4c87f fix: anti-idle click+shift every 60s 2026-05-04 12:50:16 +00:00
ruslan d7c3b35502 fix: anti-idle click at y=80 (2cm from top) 2026-05-04 10:34:08 +00:00
ruslan 4dec5a09ce fix: anti-idle uses mouse click instead of shift key, interval 60s 2026-05-04 08:41:40 +00:00
ruslan 204bb02011 fix: revert anti-idle interval to 30s (3min caused Red OS to lock) 2026-05-04 08:09:47 +00:00
ruslan dddeb26946 fix: persist should_be_connected state to disk, restore on manager restart 2026-05-04 07:15:31 +00:00
ruslan bff5ffac1c perf: compress logo and favicon (1.7MB -> 7KB / 610B) 2026-05-04 06:32:16 +00:00
ruslan b838c814ba fix: change anti-idle interval from 30s to 3min 2026-05-04 06:20:55 +00:00
ruslan 359a0c7636 fix: add shift key press in anti-idle loop for OS that ignore mouse movement 2026-05-03 12:10:59 +00:00
ruslan 3d8ccd30b6 fix: add hash_password to auth imports in main.py 2026-05-01 16:47:48 +00:00
ruslan dc90569631 fix: call connect_rdp_slot on session reuse
Previously connect_rdp_slot was only called when creating a new session.
If the API container restarted, existing sessions had should_be_connected=false
and xfreerdp never started. Now connect is triggered on every /go/<slug> visit
when an RDP session already exists.
2026-05-01 16:11:30 +00:00
ruslan fc3a4c6efb fix: increase mouse jiggle to 10px for reliable screensaver prevention 2026-05-01 16:06:13 +00:00
ruslan ccf7401f71 fix: anti-idle uses mouse jiggle instead of Shift key
Mouse movement works universally on any remote OS (Windows, Ubuntu,
RED OS, Astra). Alternates between (960,540) and (961,541) every 30s
inside xfreerdp window via xdotool mousemove --window.
2026-05-01 16:05:04 +00:00
ruslan 34972af7c0 fix: add tini as PID 1 to prevent zombie processes in containers 2026-05-01 14:58:51 +00:00
ruslan 96b7dff7cd fix: anti-idle uses xdotool --window; remove creds from URL
rdp-proxy/manager.py: anti_idle_loop gets window ID first, then sends
key --window ID --clearmodifiers shift (was broken chain syntax).
universal-runtime/manager.py: removed credentials from URL - they break
SPA fetch() calls causing white screen (e.g. CGP).
2026-05-01 14:44:08 +00:00
ruslan 38dc206f5a fix: add missing user_is_valid import from auth in main.py 2026-05-01 12:56:19 +00:00
ruslan fb4af8cfe6 fix: add missing import secrets in main.py 2026-05-01 12:55:02 +00:00
ruslan 4ab49cd10f fix: restore anti-idle in manager.py, fix ENTRYPOINT quoting in Dockerfile
- manager.py: anti_idle_loop sends xdotool Shift to xfreerdp window every 30s
  while session is active; mousemove fallback if window not found
- Dockerfile: restore xdotool package; fix ENTRYPOINT JSON quoting lost in heredoc
2026-05-01 11:16:53 +00:00
ruslan 58cb8b1035 feat: on-demand RDP - connect xfreerdp only when session opens
Replaces always-on xfreerdp with on-demand model (load 12 to under 1 at idle).
- rdp-proxy/manager.py: HTTP server port 7001 managing xfreerdp lifecycle
- rdp-proxy/entrypoint.sh: starts Xvfb+x11vnc+websockify+manager, no auto-connect
- rdp-proxy/Dockerfile: adds python3, copies manager.py, exposes 7001
- runtime.py: connect_rdp_slot and disconnect_rdp_slot via manager HTTP API
- terminate_session_record: disconnect instead of container restart
- main.py: calls connect_rdp_slot in background thread on session create
- maintenance.py: cleanup_loop disconnects on expire, run_maintenance_service
  includes RDP slot init, maintenance_runner fixed to import maintenance
2026-05-01 10:12:52 +00:00
ruslan 82024a36c4 fix: add missing sqlalchemy imports (select, text, delete, update) to main.py 2026-05-01 09:51:24 +00:00
ruslan b8dd023233 fix: add missing runtime imports (route_ready, docker_client, ensure_universal_pool, get_universal_pool_status) 2026-05-01 09:48:42 +00:00
ruslan 1c7caec021 fix: add missing config imports to main.py (GO_*_LOCK_TIMEOUT, WEB_POOL_BUFFER) 2026-05-01 09:45:55 +00:00
ruslan c8c77048c7 refactor: split main.py into modules (config, database, models, utils, auth, runtime, maintenance)
main.py was ~3000 lines with models, routes, Docker ops, maintenance all mixed.
Split into 7 focused modules:
- config.py: env vars and constants
- database.py: SQLAlchemy engine, SessionLocal, Base, get_db
- models.py: ORM models and enums
- utils.py: logging, formatting, icon handling, misc helpers
- auth.py: password hashing, cookies, CSRF, user dependency
- runtime.py: all Docker operations, pool management, session lifecycle
- maintenance.py: cleanup loop, schema bootstrap, startup logic
- main.py: FastAPI app, middleware, all route handlers only
2026-05-01 09:40:06 +00:00
ruslan 9bd38ed6db Improve autofill extension: better field detection + Basic Auth support
- Split filled flag into userFilled/passFilled for independent tracking
- Add findUserFieldNearPassword() for DOM-relative lookup near password field
- Add isVisible() helper to skip disabled/hidden/offscreen inputs
- Add console.log tracing for debugging
- Add background.js service worker with webRequest.onAuthRequired for Basic Auth
- Add _url_with_credentials() to embed login:pass in URL for HTTP Basic Auth
- Use /usr/lib/chromium/chromium binary directly (bypass Debian wrapper)
- Add --enable-logging=stderr for console.log capture in chromium logs
2026-05-01 04:44:15 +00:00
ruslan d57acb416b Replace Login Data injection with autofill via Chromium extension
The previous approach pre-populated Chromiums Login Data SQLite with
schema version 30 and AES-128-CBC v10 encrypted passwords. Chromium 147
expects schema version 43, fails to migrate (Unable to migrate database
from 30 to 43), and refuses to open Login Data altogether. Result: the
row was written but Chromium never read it, so autofill never worked.

Instead generate a tiny Manifest V3 extension per session in a temp dir
with a content_script that finds username and password fields, sets
their values, and dispatches input/change events. Pass it via
--load-extension and --disable-extensions-except so it is the only
extension loaded.

Benefits:
- Independent of Chromium version and Login Database schema
- Works on SPAs (MutationObserver re-runs on DOM changes)
- Credentials live only in a temp file alongside the profile, removed
  on session end via _stop_current
- No SQLite or cryptography dependency
- Removes the silent failure mode of Login Data migration

Removes _chrome_encrypt_v10, sqlite3, hashlib, urlparse imports.
Adds _create_autofill_extension and tracks extension_dir alongside
profile_dir in _state for cleanup symmetry.
2026-04-30 17:47:10 +00:00
ruslan cf68bc848f Fix CSRF SameSite=Strict breaking login on iPad/Safari
Safari (iPadOS/iOS) blocks SameSite=Strict cookies on the initial
top-level navigation when it considers the request cross-site (links
from messengers, email, QR codes). The CSRF cookie was therefore never
set on first visit, and the subsequent login POST failed with 403
"CSRF failed".

Switch the CSRF cookie to SameSite=Lax — this is the OWASP recommended
default and matches industry practice. The auth (session) cookie keeps
SameSite=Strict, since it is only issued after a successful first-party
login POST and needs the stricter binding.
2026-04-30 17:38:20 +00:00
ruslan be65be8fdb Fix Chromium autofill timing bug + RDP anti-idle to prevent lock screen
- universal-runtime: set _state[profile_dir] AFTER _start_process so
  _stop_current does not delete the freshly-created profile before
  Chromium reads it. Without this, Login Data was being wiped.
- rdp-proxy: add xdotool dependency and background anti_idle_loop that
  sends Shift to the xfreerdp window every 30s, forwarded over RDP to
  reset the remote idle timer and keep the lock screen from kicking in.
2026-04-30 14:05:04 +00:00
ruslan 23c1f6e342 Chromium: Russian language, autofill passwords from svc_login/svc_password via Login Data 2026-04-30 07:22:31 +00:00
ruslan d7c956e10b rdp-proxy: monitor xfreerdp, auto-restart container on disconnect 2026-04-30 06:53:27 +00:00
ruslan 3f20fe5991 Fix: category delete button broken by double-quote conflict in onclick attr 2026-04-28 21:07:14 +00:00
ruslan 154ec35384 Block mobile devices: show desktop-only page 2026-04-28 20:52:24 +00:00
ruslan 8f3617afdd Remove copy buttons from credentials panels 2026-04-28 13:49:47 +00:00
ruslan beb6828520 Add credentials panel on view page; remove copy buttons from dashboard cards 2026-04-28 13:47:46 +00:00
ruslan 4cc19b32d8 chore: ignore uploaded service icons 2026-04-28 13:32:28 +00:00
ruslan 2d65d98116 fix: link z-index:0, tile z-index:1 pointer-events:none, scroll restored 2026-04-28 13:29:53 +00:00
ruslan 1ab5af28b5 fix: link overlay pattern — credentials never trigger navigation 2026-04-28 13:17:32 +00:00
ruslan bfcf5f565b fix: card height 672px (-30%) 2026-04-28 13:08:29 +00:00
ruslan af02c0d059 fix: card height 960px, credentials always visible, only comment scrolls 2026-04-28 13:02:19 +00:00
ruslan 530d901a45 fix: fixed card height 480px, icon constrained, info-area scrolls 2026-04-28 12:57:21 +00:00
ruslan 7a7c6e30e3 fix: tile-info-area shares space between credentials+comment, scroll restored 2026-04-28 12:48:59 +00:00
ruslan 6ccba89216 fix: equal card height — categories always at bottom, comment clips to fill 2026-04-28 12:33:12 +00:00
ruslan a64d49a8c1 feat: reorder card layout + svc_cred_hint field for credentials note 2026-04-28 12:20:37 +00:00
ruslan b06620a793 fix: move credentials and comment inside card, right below icon 2026-04-28 12:16:02 +00:00
ruslan b9d13733c9 feat: service credentials (login/password) on dashboard cards with copy button 2026-04-28 12:10:40 +00:00
ruslan fa88f7f4e4 feat: full Markdown support in service card comments (mistune) 2026-04-28 11:57:44 +00:00
ruslan b951f6c68e docs: add full deployment guide in Russian 2026-04-28 07:08:05 +00:00
ruslan d0ff949828 fix: remove stale Xvfb lock file on container restart
On restart, /tmp/.X1-lock remains from previous run causing Xvfb to fail
with 'Server is already active for display 1', which then breaks xfreerdp
and x11vnc. Clean up lock and socket before starting Xvfb.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:50:56 +00:00
ruslan a4a96c45b0 fix: RDP slot occupancy and cleanup_loop always running
- admin_page: slot shown as occupied based on ACTIVE status only (no time cutoff)
- go_service: busy slots checked by ACTIVE status (no cutoff) — cleanup_loop handles expiry
- startup_event: cleanup_loop starts regardless of ENABLE_STARTUP_MAINTENANCE flag;
  pool/container init guarded by the flag separately
- cleanup_loop: RDPSLOT sessions expire correctly and trigger container restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:48:00 +00:00
ruslan 552898e3e9 fix: cleanup_loop correctly frees and restarts RDP slots on session expiry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:34:04 +00:00
ruslan 6847cbc078 feat: RDP slot pool — multi-user RDP with per-account containers
- New RdpSlot model (rdp_slots table): service_id, rdp_username,
  rdp_password, container_name
- Each slot gets a dedicated portal-rdpslot-<slug>-<id> container with
  Traefik route /rdp/<slot_id>/ and restart_policy=unless-stopped
- go_service: RDP services with slots use pool allocation — finds first
  free slot (not occupied by active session), returns 503 if all busy
- session_status + session_view: handle RDPSLOT: container_id prefix
- terminate_session_record: restarts slot container in background on close
- session_redirect_url: RDPSLOT sessions redirect to /s/<id>/view
- startup_event: starts containers for all configured slots on boot
- Admin: POST /api/admin/services/{id}/rdp-slots, DELETE /api/admin/rdp-slots/{id}
- admin.html: slot management UI (list, add, delete); removed ACL exclusivity
- set_acl: removed RDP 1-user exclusivity — RDP services now assignable to many

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:32:02 +00:00
ruslan 67d361c5c9 feat: login page polish — remove welcome text, add spacer, resize corner brand
- login.html: removed 'Добро пожаловать' heading, added 3.5rem spacer below logo
- style.css: .login-corner-brand font-size set to 1.5rem

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:31:24 +00:00
ruslan 56cf2d6830 feat: responsive mobile wall with logo and footer
- #mobile-wall uses width:100vw/height:100vh/overflow:hidden to prevent
  text overflow on narrow screens
- All font sizes via clamp(), word-break:break-word on text elements
- MONT logo pinned to top (position:absolute), height clamp(4rem,16vw,6rem)
- Made by Galyaviev footer pinned to bottom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:20:35 +00:00
ruslan 6f17193312 feat: loading overlay on dashboard, RDP pooled session routing fix
- dashboard.html: overlay div moved before <script> so getElementById works;
  double rAF ensures browser paints spinner before navigation
- main.py: pooled_rdp route fix — session_status now returns /svc/<slug>/
  route and redirect_url for POOL: RDP sessions (was always ready instantly)
- docker-compose.yml: parametrise env vars via .env for easier tuning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:07:55 +00:00
ruslan 419b495020 feat: RDP ACL exclusivity, mobile wall, nav buttons, resolution xrandr
- RDP сервис может быть назначен только одному пользователю в ACL
- Мобильная заглушка на dashboard при ширине < 1024px
- rdp-proxy: кнопки навигации, спиннер Ожидайте, реконнект
- session_wait_page: тёмная тема, CSS спиннер
- kiosk/universal-runtime manager.py: xrandr + cvt --newmode для resolution
- Dockerfiles: x11-xserver-utils, x11-utils
2026-04-27 18:49:06 +00:00
ruslan 445d025de2 Add configurable X11VNC_FLAGS env passthrough 2026-04-25 19:07:49 +00:00
ruslan d927e1c947 chore: stop tracking local project context doc 2026-04-25 18:45:44 +00:00
ruslan bd4350b2e0 docs: add ncache-related resolution issue note 2026-04-25 18:44:37 +00:00
37 changed files with 6510 additions and 1907 deletions
+1
View File
@@ -15,3 +15,4 @@ PROJECT_CONTEXT.md
*.bak* *.bak*
*.env.bak* *.env.bak*
docs/CONTEXT_TEST.md docs/CONTEXT_TEST.md
app/static/service-icons/
+601
View File
@@ -0,0 +1,601 @@
# Инструкция по развёртыванию — МОНТ Инфраструктурный Полигон
Версия проекта: **0.6.0**
---
## Содержание
1. [Что это такое](#1-что-это-такое)
2. [Архитектура](#2-архитектура)
3. [Требования к серверу](#3-требования-к-серверу)
4. [Установка зависимостей](#4-установка-зависимостей)
5. [Клонирование репозитория](#5-клонирование-репозитория)
6. [Настройка переменных окружения](#6-настройка-переменных-окружения)
7. [Настройка Traefik](#7-настройка-traefik)
8. [Сборка Docker-образов рантаймов](#8-сборка-docker-образов-рантаймов)
9. [Первый запуск](#9-первый-запуск)
10. [Инициализация базы данных](#10-инициализация-базы-данных)
11. [Проверка работоспособности](#11-проверка-работоспособности)
12. [Настройка через админку](#12-настройка-через-админку)
13. [Настройка RDP-слотов](#13-настройка-rdp-слотов)
14. [Обновление проекта](#14-обновление-проекта)
15. [Описание всех переменных окружения](#15-описание-всех-переменных-окружения)
16. [Частые проблемы и решения](#16-частые-проблемы-и-решения)
---
## 1. Что это такое
Веб-портал для выдачи пользователям браузерного доступа к стендам и сервисам.
Поддерживает три типа сервисов:
| Тип | Описание |
|-----|----------|
| **WEB** | Открывает веб-сайт в браузере Chromium внутри виртуального дисплея (noVNC-стриминг) |
| **VNC** | Подключается по VNC к внешнему хосту |
| **RDP** | Подключается по RDP к внешнему хосту; пул слотов — несколько пользователей одновременно |
Стек: **FastAPI + PostgreSQL + Traefik + Docker**.
---
## 2. Архитектура
```
Интернет
│ HTTPS :443
┌─────────┐
│ Traefik │ — edge-прокси, TLS, маршрутизация по URL
└────┬────┘
│ docker network: portal_net
├──────────────────────────────┐
▼ ▼
┌─────────┐ ┌────────────┐
│ api │ FastAPI/uvicorn │ maintenance│ (отдельный процесс очистки)
└────┬────┘ └────────────┘
├─ /var/run/docker.sock (API управляет контейнерами напрямую)
┌──────────┐
│ db │ PostgreSQL 16
└──────────┘
Динамически создаваемые контейнеры (не в compose):
portal-webpool-N — WEB-сессии (portal-universal-runtime)
portal-universal-N — VNC-сессии (portal-universal-runtime)
portal-rdpslot-SLUG-N — RDP-слоты (portal-rdp-proxy)
```
Traefik читает метки (`labels`) у динамически создаваемых контейнеров и автоматически добавляет маршруты без перезапуска.
---
## 3. Требования к серверу
| Параметр | Минимум | Рекомендовано |
|----------|---------|---------------|
| ОС | Ubuntu 22.04 / Debian 12 | Ubuntu 24.04 |
| CPU | 2 ядра | 4+ ядра |
| RAM | 4 ГБ | 8+ ГБ |
| Диск | 20 ГБ | 40+ ГБ (Docker-образы большие) |
| Docker | 24+ | 29+ |
| Docker Compose | v2.20+ | v2.40+ |
| Внешний IP | Обязателен | — |
| DNS-запись | A-запись домена → IP сервера | — |
| Порты открыты | 80, 443 | — |
> **Важно**: домен и DNS-запись нужны для автоматического получения TLS-сертификата через Let's Encrypt.
> Без домена — использовать самоподписанный сертификат (см. [раздел 7](#7-настройка-traefik)).
---
## 4. Установка зависимостей
```bash
# Обновляем систему
sudo apt-get update && sudo apt-get upgrade -y
# Устанавливаем Docker (официальный способ)
curl -fsSL https://get.docker.com | sh
# Добавляем текущего пользователя в группу docker (без sudo)
sudo usermod -aG docker $USER
newgrp docker
# Проверяем
docker --version # Docker version 29.x.x
docker compose version # Docker Compose version 2.x.x
```
---
## 5. Клонирование репозитория
Все исходники находятся в git-репозитории:
```
https://git.ruslan.xyz/ruslan/Stend_mont
```
```bash
# Рабочий каталог — можно любой, например /opt/stand или ~/docker/stand
mkdir -p ~/docker && cd ~/docker
# Репозиторий приватный — нужны credentials
git clone https://USER:TOKEN@git.ruslan.xyz/ruslan/Stend_mont.git stand
cd stand
```
> Запросите логин и токен у владельца репозитория.
Структура проекта после клонирования:
```
stand/
├── app/ # FastAPI-приложение (Dockerfile + main.py + шаблоны)
├── rdp-proxy/ # Docker-образ RDP-рантайма (xvfb + xfreerdp + noVNC)
├── universal-runtime/ # Docker-образ WEB/VNC-рантайма (Chromium + x11vnc + noVNC)
├── kiosk/ # Docker-образ kiosk-режима (опционально)
├── traefik/ # Конфиг Traefik (traefik.yml + dynamic/)
├── scripts/ # SQL-схема БД
├── docker-compose.yml
└── .env.example # Шаблон переменных окружения
```
---
## 6. Настройка переменных окружения
Скопируйте шаблон и отредактируйте:
```bash
cp .env.example .env
nano .env
```
**Минимальный набор значений, которые нужно поменять:**
```dotenv
# Домен, на котором будет работать портал
PUBLIC_HOST=your-domain.example.com
# Email для Let's Encrypt (уведомления об истечении сертификата)
LETSENCRYPT_EMAIL=admin@example.com
# Пароль PostgreSQL (придумайте сами, не менее 16 символов)
POSTGRES_PASSWORD=supersecretdbpassword
# Секретный ключ для подписи сессий (минимум 32 случайных символа)
# Генерация: python3 -c "import secrets; print(secrets.token_hex(32))"
SIGNING_KEY=your_random_signing_key_here
# Логин и пароль администратора портала
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your_admin_password
```
**Остальные переменные** — описание см. в [разделе 15](#15-описание-всех-переменных-окружения).
---
## 7. Настройка Traefik
### 7.1 Файл `traefik/traefik.yml`
Откройте и проверьте email в секции `certificatesResolvers`:
```bash
nano traefik/traefik.yml
```
```yaml
certificatesResolvers:
letsencrypt:
acme:
email: admin@example.com # ← ваш email
storage: /letsencrypt/acme.json
tlsChallenge: {}
```
Порты по умолчанию: **HTTP :8288**, **HTTPS :2288** (нестандартные, прописаны в `docker-compose.yml`).
Если нужны стандартные 80/443:
```bash
nano docker-compose.yml
# Найдите секцию traefik -> ports и замените:
# - "0.0.0.0:8288:80" → - "80:80"
# - "0.0.0.0:2288:443" → - "443:443"
```
### 7.2 Создание файла хранилища сертификатов
```bash
mkdir -p traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
```
### 7.3 Работа без домена (самоподписанный сертификат)
Если домена нет и нужен только HTTP или самоподписанный HTTPS:
В `traefik/traefik.yml` удалите секцию `certificatesResolvers`.
В `docker-compose.yml` в labels контейнера `api` замените:
```yaml
- traefik.http.routers.portal.entrypoints=websecure
- traefik.http.routers.portal.tls=true
- traefik.http.routers.portal.tls.certresolver=letsencrypt
```
на:
```yaml
- traefik.http.routers.portal.entrypoints=web
```
---
## 8. Сборка Docker-образов рантаймов
Рантаймы — это образы для WEB/VNC/RDP-сессий. Они **не входят** в основной `docker compose up`, их нужно собрать отдельно.
```bash
cd ~/docker/stand
# Образ для WEB и VNC сессий (Chromium + x11vnc + noVNC)
docker build -t portal-universal-runtime:latest ./universal-runtime/
# Образ для RDP сессий (xvfb + xfreerdp + x11vnc + noVNC)
docker build -t portal-rdp-proxy:latest ./rdp-proxy/
# Образ kiosk (опционально, если используется kiosk-режим)
docker build -t portal-kiosk:latest ./kiosk/
```
> Сборка занимает 3–10 минут в зависимости от скорости интернета (скачивается Chromium и другие пакеты).
Проверьте что образы появились:
```bash
docker images | grep portal
# Должно быть:
# portal-universal-runtime latest ...
# portal-rdp-proxy latest ...
# portal-kiosk latest ...
```
---
## 9. Первый запуск
```bash
cd ~/docker/stand
# Запускаем всё (traefik, db, api, maintenance)
docker compose up -d --build
# Смотрим логи запуска
docker compose logs -f api
```
Дождитесь строки:
```
INFO: Application startup complete.
```
Затем откройте браузер: `https://your-domain.example.com`
---
## 10. Инициализация базы данных
База данных инициализируется **автоматически** при первом старте API через SQLAlchemy (ORM создаёт все таблицы).
Если нужно создать таблицы вручную (на случай сбоя):
```bash
# Подключитесь к контейнеру PostgreSQL
docker exec -it stend_mont-db-1 psql -U portal -d portal
# Выполните скрипт схемы
\i /dev/stdin
# вставьте содержимое scripts/schema.sql и нажмите Ctrl+D
```
Или через файл:
```bash
docker exec -i stend_mont-db-1 psql -U portal -d portal < scripts/schema.sql
```
> Таблица `rdp_slots` создаётся автоматически ORM, её нет в `schema.sql` — это нормально.
---
## 11. Проверка работоспособности
```bash
# Статус всех контейнеров
docker compose ps
# Логи отдельных сервисов
docker compose logs api # FastAPI
docker compose logs db # PostgreSQL
docker compose logs traefik # Traefik (маршруты, сертификаты)
docker compose logs maintenance # Фоновая очистка сессий
# Проверка что база доступна
docker exec stend_mont-db-1 pg_isready -U portal
# Проверка API (изнутри сети)
docker exec stend_mont-api-1 curl -s http://localhost:8000/health
```
Ожидаемый вывод `docker compose ps`:
```
NAME STATUS
stend_mont-api-1 Up
stend_mont-db-1 Up (healthy)
stend_mont-maintenance-1 Up
stend_mont-traefik-1 Up
```
---
## 12. Настройка через админку
1. Откройте `https://your-domain.example.com/admin`
2. Войдите с данными `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env`
### Создание пользователей
**Пользователи → Добавить пользователя:**
- Логин, пароль, срок действия аккаунта
- После создания — назначить права доступа к сервисам (раздел ACL)
### Создание сервисов
**Сервисы → Добавить сервис:**
| Поле | Описание |
|------|----------|
| Название | Отображаемое название |
| Slug | URL-идентификатор (латиница, цифры, дефис) |
| Тип | WEB / VNC / RDP |
| Адрес | `host:port` для VNC/RDP; URL для WEB |
| Иконка | PNG/SVG, загружается через форму |
| Размер пула | Для WEB: количество прогретых контейнеров (0 = по запросу) |
### Назначение доступа (ACL)
**ACL → выберите пользователя → отметьте сервисы → Сохранить**
---
## 13. Настройка RDP-слотов
RDP-сервисы используют **пул слотов**: каждый слот = отдельный RDP-пользователь + отдельный контейнер.
Пользователи занимают свободные слоты, при их нехватке получают ошибку 503.
### Шаг 1: Создайте RDP-сервис
В админке создайте сервис с типом **RDP**:
- **Адрес**: `hostname_или_ip:3389` (без протокола)
- Домен, безопасность — если нужны
### Шаг 2: Добавьте слоты (RDP-пользователей)
В карточке сервиса появится раздел **«RDP пользователи (слоты пула)»**:
- Введите RDP-логин и пароль пользователя на RDP-сервере
- Нажмите **Добавить**
- Повторите для каждого параллельного пользователя (слота)
### Шаг 3: Запустите контейнеры слотов
```bash
# Перезапустите API с флагом инициализации
cd ~/docker/stand
ENABLE_STARTUP_MAINTENANCE=1 docker compose up -d api maintenance
```
После запуска появятся контейнеры вида `portal-rdpslot-SLUG-N`.
Проверка:
```bash
docker ps | grep rdpslot
```
### Назначение прав на RDP-сервис
В разделе ACL назначьте нужным пользователям доступ к RDP-сервису (обычный чекбокс).
Любой пользователь с доступом может занять любой свободный слот.
---
## 14. Обновление проекта
```bash
cd ~/docker/stand
# Получаем изменения
git pull
# Пересобираем API (если менялся main.py или шаблоны)
docker compose up -d --build api maintenance
# Пересобираем рантаймы (если менялись rdp-proxy/, universal-runtime/, kiosk/)
docker build -t portal-rdp-proxy:latest ./rdp-proxy/
docker build -t portal-universal-runtime:latest ./universal-runtime/
docker build -t portal-kiosk:latest ./kiosk/
# После пересборки рантаймов — перезапустить слот-контейнеры
# Старые запущенные сессии продолжат работать, новые получат новый образ
docker compose down api maintenance
ENABLE_STARTUP_MAINTENANCE=1 docker compose up -d api maintenance
```
> **Важно**: `docker compose up -d api` **без** `--build` НЕ обновит код, если контейнер уже запущен.
> Всегда используйте `--build` после изменений в `app/`.
---
## 15. Описание всех переменных окружения
| Переменная | По умолчанию | Описание |
|------------|-------------|----------|
| `COMPOSE_PROJECT_NAME` | `stend_mont` | Префикс имён контейнеров Docker |
| `PUBLIC_HOST` | — | **Обязательно.** Домен сайта, напр. `stand.example.com` |
| `LETSENCRYPT_EMAIL` | — | Email для Let's Encrypt |
| `POSTGRES_DB` | `portal` | Имя базы данных |
| `POSTGRES_USER` | `portal` | Пользователь PostgreSQL |
| `POSTGRES_PASSWORD` | — | **Обязательно.** Пароль PostgreSQL |
| `SIGNING_KEY` | — | **Обязательно.** Секрет для подписи сессионных токенов (мин. 32 символа) |
| `ADMIN_USERNAME` | `admin` | Логин администратора |
| `ADMIN_PASSWORD` | — | **Обязательно.** Пароль администратора |
| `ADMIN_TTL_DAYS` | `3650` | Срок действия аккаунта admin (дни) |
| `SESSION_IDLE_SECONDS` | `7200` | Тайм-аут сессии по бездействию (секунды). Рекомендуется `300` (5 мин) |
| `UVICORN_WORKERS` | `6` | Количество воркеров uvicorn |
| `WEB_POOL_SIZE` | `20` | Максимальное число WEB-контейнеров в пуле |
| `WEB_POOL_BUFFER` | `2` | Сколько прогретых WEB-контейнеров держать в запасе |
| `PREWARM_POOL_SIZE` | `2` | Размер пула прогрева для VNC |
| `UNIVERSAL_POOL_SIZE` | `0` | Размер универсального пула |
| `MAX_ACTIVE_SERVICES_PER_USER` | `4` | Максимум одновременных сессий на одного пользователя |
| `ENABLE_STARTUP_MAINTENANCE` | `0` | `1` = при старте запустить/переинициализировать все пул-контейнеры |
| `TRAEFIK_INTERNAL_URL` | `http://traefik` | URL Traefik изнутри Docker-сети (не менять без нужды) |
| `LOG_LEVEL` | `INFO` | Уровень логирования: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `LOG_SLOW_REQUEST_MS` | `2000` | Запросы дольше этого (мс) логируются как медленные |
| `GO_USER_LOCK_TIMEOUT_SECONDS` | `8` | Тайм-аут блокировки при запуске сессии пользователем |
| `GO_POOL_LOCK_TIMEOUT_SECONDS` | `20` | Тайм-аут блокировки при захвате слота пула |
| `POOL_DISPATCH_RETRIES` | `6` | Число попыток занять слот пула |
| `POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS` | `2.0` | Тайм-аут одного запроса к пул-контейнеру |
| `POOL_DISPATCH_SLEEP_SECONDS` | `0.3` | Пауза между попытками диспетчеризации |
| `X11VNC_FLAGS` | `-wait 5 -defer 5 -threads` | Дополнительные флаги x11vnc |
| `WEB_RESOLUTION_MIN_WIDTH` | `1024` | Минимальная ширина разрешения для WEB-сессий |
| `WEB_RESOLUTION_MIN_HEIGHT` | `720` | Минимальная высота |
| `WEB_RESOLUTION_MAX_WIDTH` | `3840` | Максимальная ширина |
| `WEB_RESOLUTION_MAX_HEIGHT` | `2160` | Максимальная высота |
---
## 16. Частые проблемы и решения
### Сертификат не получается (Let's Encrypt)
**Симптом:** Браузер показывает ошибку TLS, в логах Traefik: `acme: error`.
**Причины и решения:**
1. DNS не указывает на сервер — проверьте A-запись: `nslookup your-domain.example.com`
2. Порт 80 или 443 закрыт фаерволом — откройте: `sudo ufw allow 80 && sudo ufw allow 443`
3. Файл `acme.json` не имеет прав 600: `chmod 600 traefik/letsencrypt/acme.json`
4. Слишком много запросов к LE — подождите час и попробуйте снова
---
### API не стартует (ошибка подключения к БД)
**Симптом:** В логах `api`: `connection refused` или `could not connect to server`.
```bash
# Проверьте что база запущена
docker compose ps db
docker compose logs db
# Убедитесь что переменные совпадают в .env
grep POSTGRES .env
```
Если база не успела подняться — подождите 5–10 секунд и перезапустите API:
```bash
docker compose restart api
```
---
### RDP-сессия не подключается (чёрный экран)
**Симптом:** noVNC открывается, но экран чёрный или появляется ошибка.
```bash
# Найдите имя контейнера слота
docker ps | grep rdpslot
# Смотрите логи внутри контейнера
docker exec portal-rdpslot-SLUG-N cat /tmp/xfreerdp.log
docker exec portal-rdpslot-SLUG-N cat /tmp/xvfb.log
docker exec portal-rdpslot-SLUG-N cat /tmp/x11vnc.log
```
Типичные ошибки:
| Ошибка | Причина | Решение |
|--------|---------|---------|
| `Server is already active for display :1` | Старый lock-файл Xvfb | Обновите `rdp-proxy/entrypoint.sh` (уже исправлено в v0.6.0) |
| `Authentication failure` | Неверный RDP логин/пароль | Проверьте слот в админке |
| `failed to open display :1` | Xvfb не запустился | Перезапустите контейнер слота |
| `Connection refused` к `:3389` | RDP-сервер недоступен | Проверьте `host:port` сервиса |
Перезапуск конкретного слота:
```bash
docker restart portal-rdpslot-SLUG-N
```
---
### Слот показывается свободным, но пользователь всё ещё в сессии
**Симптом:** В админке слот «Свободен», но пользователь зашёл в /view.
Это возникает если сессия осталась в статусе `ACTIVE` без обновления `last_access_at`.
Принудительно истечь сессию через PostgreSQL:
```bash
docker exec -it stend_mont-db-1 psql -U portal -d portal -c \
"UPDATE sessions SET status='EXPIRED' WHERE status='ACTIVE' AND service_id=<ID>;"
```
---
### Контейнеры пула не создаются при старте
**Симптом:** После запуска нет контейнеров `portal-rdpslot-*` или `portal-webpool-*`.
Убедитесь что стартовали с флагом:
```bash
ENABLE_STARTUP_MAINTENANCE=1 docker compose up -d api maintenance
```
Или добавьте в `.env`:
```dotenv
ENABLE_STARTUP_MAINTENANCE=1
```
и перезапустите:
```bash
docker compose up -d api maintenance
```
---
### Полная переустановка (сброс данных)
> **Внимание**: удалятся все пользователи, сессии, сервисы!
```bash
cd ~/docker/stand
docker compose down -v # -v удаляет volume с данными PostgreSQL
docker compose up -d --build
```
---
## Итоговый чеклист первого развёртывания
- [ ] Сервер с Ubuntu 22.04+ и публичным IP
- [ ] DNS A-запись `ваш-домен → IP сервера`
- [ ] Установлен Docker 24+ и Docker Compose v2
- [ ] Репозиторий склонирован
- [ ] Заполнен `.env` (PUBLIC_HOST, POSTGRES_PASSWORD, SIGNING_KEY, ADMIN_PASSWORD)
- [ ] Создан `traefik/letsencrypt/acme.json` с правами 600
- [ ] Собраны образы рантаймов (`portal-rdp-proxy`, `portal-universal-runtime`, `portal-kiosk`)
- [ ] Выполнен `docker compose up -d --build`
- [ ] Открывается `https://ваш-домен/admin` → вход в систему
- [ ] Созданы пользователи и сервисы
- [ ] Для RDP: добавлены слоты, перезапущен API с `ENABLE_STARTUP_MAINTENANCE=1`
View File
+102
View File
@@ -0,0 +1,102 @@
import secrets
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from itsdangerous import BadSignature, URLSafeTimedSerializer
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from config import COOKIE_MAX_AGE, COOKIE_NAME, CSRF_COOKIE
from database import get_db
from models import User, UserServiceAccess
from utils import now_utc
from sqlalchemy import select
import os
_SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32))
serializer = URLSafeTimedSerializer(_SIGNING_KEY, salt="portal-auth")
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def user_is_valid(user: User) -> bool:
return bool(user.active and user.expires_at > now_utc())
def issue_auth_cookie(response: RedirectResponse, user: User) -> None:
token = serializer.dumps({"user_id": user.id})
response.set_cookie(
key=COOKIE_NAME,
value=token,
httponly=True,
secure=True,
samesite="strict",
max_age=COOKIE_MAX_AGE,
path="/",
)
def issue_csrf_cookie(response: RedirectResponse) -> str:
token = secrets.token_urlsafe(24)
response.set_cookie(
key=CSRF_COOKIE,
value=token,
httponly=False,
secure=True,
samesite="lax",
max_age=COOKIE_MAX_AGE,
path="/",
)
return token
def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
raw = request.cookies.get(COOKIE_NAME)
if not raw:
return None
try:
payload = serializer.loads(raw, max_age=COOKIE_MAX_AGE)
except BadSignature:
return None
user = db.get(User, int(payload["user_id"]))
if not user or not user_is_valid(user):
return None
return user
def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
return user
def require_admin(user: User = Depends(require_user)) -> User:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
return user
def validate_csrf(request: Request) -> None:
cookie = request.cookies.get(CSRF_COOKIE)
form_val = request.headers.get("X-CSRF-Token")
if request.headers.get("content-type", "").startswith("application/x-www-form-urlencoded"):
return
if not cookie or not form_val or cookie != form_val:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF failed")
def has_access(db: Session, user_id: int, service_id: int) -> bool:
q = select(UserServiceAccess).where(
UserServiceAccess.user_id == user_id,
UserServiceAccess.service_id == service_id,
)
return db.scalar(q) is not None
+47
View File
@@ -0,0 +1,47 @@
import os
from pathlib import Path
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db:5432/portal")
COOKIE_NAME = "portal_auth"
CSRF_COOKIE = "csrf_token"
COOKIE_MAX_AGE = 8 * 60 * 60
SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "7200"))
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0"))
GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "20.0"))
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "6"))
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "2"))
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "20"))
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
X11VNC_FLAGS = os.getenv("X11VNC_FLAGS", "-wait 5 -defer 5 -threads")
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024"))
WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720"))
WEB_RESOLUTION_MAX_WIDTH = int(os.getenv("WEB_RESOLUTION_MAX_WIDTH", "3840"))
WEB_RESOLUTION_MAX_HEIGHT = int(os.getenv("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
ENABLE_STARTUP_MAINTENANCE = os.getenv("ENABLE_STARTUP_MAINTENANCE", "1") == "1"
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
ICON_UPLOAD_TYPES = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
SERVICE_ICONS_DIR = Path("static/service-icons")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org/bot")
SMTP_HOST = os.getenv("SMTP_HOST", "mail.hosting.reg.ru")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru")
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT")
PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru")
+101
View File
@@ -0,0 +1,101 @@
import os
import sys
# SQLite in-memory for tests — no PostgreSQL needed
os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db")
os.environ.setdefault("SIGNING_KEY", "test-signing-key-32chars-padding!!")
os.environ.setdefault("ADMIN_USERNAME", "admin")
os.environ.setdefault("ADMIN_PASSWORD", "testpass123")
os.environ.setdefault("PUBLIC_HOST", "http://localhost")
os.environ.setdefault("ENABLE_STARTUP_MAINTENANCE", "0")
os.environ.setdefault("LOG_LEVEL", "ERROR")
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Patch docker before importing app modules
_docker_mock = MagicMock()
_docker_mock.containers.get.side_effect = Exception("no docker in tests")
_docker_mock.containers.list.return_value = []
_docker_mock.containers.run.return_value = MagicMock(id="test-container-id", status="running", name="test")
sys.modules.setdefault("docker", MagicMock())
with patch("docker.from_env", return_value=_docker_mock):
with patch("runtime.ensure_schema_compatibility", lambda: None):
from database import Base, get_db
import main as app_module
engine = create_engine(
"sqlite:///./test.db",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(bind=engine)
Base.metadata.create_all(bind=engine)
# Create admin user
from auth import hash_password
from models import User
with TestingSessionLocal() as db:
if not db.query(User).filter(User.username == "admin").first():
import datetime as _dt
db.add(User(
username="admin",
password_hash=hash_password("testpass123"),
is_admin=True,
active=True,
expires_at=_dt.datetime(2099, 1, 1, tzinfo=_dt.timezone.utc),
))
db.commit()
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app_module.app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(scope="session")
def client():
with patch("runtime.docker_client", return_value=_docker_mock), \
patch("runtime.ensure_schema_compatibility", lambda: None):
with TestClient(app_module.app, raise_server_exceptions=False, base_url="https://testserver") as c:
yield c
def _extract_csrf(client) -> str:
"""GET / → берём CSRF из HTML и ставим куку вручную."""
import re
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
m = re.search(r'name=["\']csrf_token["\'][^>]*value=["\']([^"\']+)["\']'
r'|value=["\']([^"\']+)["\'][^>]*name=["\']csrf_token["\']', r.text)
if not m:
m = re.search(r'csrf_token["\']?\s*[=:]\s*["\']([^"\']{10,})["\']', r.text)
assert m, f"csrf_token not found in HTML: {r.text[:500]}"
csrf = m.group(1) or m.group(2)
client.cookies.set("portal_csrf", csrf, domain="testserver")
return csrf
@pytest.fixture(scope="session")
def auth_client(client):
"""Client with admin session cookie."""
csrf = _extract_csrf(client)
r = client.post("/login", data={
"username": "admin",
"password": "testpass123",
"csrf_token": csrf,
}, follow_redirects=True)
assert r.status_code == 200, f"login failed: {r.status_code} {r.text[:300]}"
return client
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from config import DATABASE_URL
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+1022 -1578
View File
File diff suppressed because one or more lines are too long
+212
View File
@@ -0,0 +1,212 @@
import datetime as dt
import fcntl
import logging
import os
import threading
import time
import docker
from sqlalchemy import select
from config import ENABLE_STARTUP_MAINTENANCE, SESSION_IDLE_SECONDS, WEB_POOL_SIZE
from database import Base, SessionLocal, engine
from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User
from utils import ensure_icons_dir, now_utc
from auth import hash_password
from runtime import (
_rdp_slot_container_name,
disconnect_rdp_slot,
docker_client,
ensure_schema_compatibility,
ensure_universal_pool,
ensure_warm_pool,
ensure_web_pool,
start_rdp_slot_container,
stop_runtime_container,
)
logger = logging.getLogger("portal")
maintenance_lock_file = None
def cleanup_loop():
while True:
time.sleep(60)
db = SessionLocal()
try:
ensure_universal_pool()
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)
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
q = select(SessionModel).where(
SessionModel.status == SessionStatus.ACTIVE,
SessionModel.last_access_at < cutoff,
)
stale = db.scalars(q).all()
rdp_slots_to_restart: list[int] = []
for sess in stale:
cid = sess.container_id or ""
if cid.startswith("RDPSLOT:"):
try:
rdp_slots_to_restart.append(int(cid.split(":", 1)[1]))
except Exception:
pass
elif cid and not (
cid.startswith("POOL:")
or cid.startswith("POOLIDX:")
or cid.startswith("WEBPOOLIDX:")
):
stop_runtime_container(cid)
sess.status = SessionStatus.EXPIRED
if stale:
db.commit()
for slot_id in rdp_slots_to_restart:
threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start()
except Exception:
db.rollback()
logger.exception("cleanup_loop_failed")
finally:
db.close()
def bootstrap_admin():
admin_user = os.getenv("ADMIN_USERNAME", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "change_me")
ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
db = SessionLocal()
try:
existing = db.scalar(select(User).where(User.username == admin_user))
if not existing:
db.add(
User(
username=admin_user,
password_hash=hash_password(admin_password),
active=True,
is_admin=True,
expires_at=now_utc() + dt.timedelta(days=ttl_days),
)
)
db.commit()
finally:
db.close()
def try_acquire_maintenance_leader() -> bool:
global maintenance_lock_file
if maintenance_lock_file is not None:
return True
lock_file = open("/tmp/portal-maintenance.lock", "w")
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
lock_file.close()
return False
maintenance_lock_file = lock_file
return True
def run_maintenance_service() -> None:
logger.info("maintenance_service_bootstrap_started")
with open("/tmp/portal-schema.lock", "w") as lock_file:
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()
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:
ensure_universal_pool()
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:
db.close()
logger.info("maintenance_service_loop_started")
cleanup_loop()
def on_startup() -> None:
with open("/tmp/portal-schema.lock", "w") as lock_file:
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():
logger.info("maintenance_leader_skipped")
return
if ENABLE_STARTUP_MAINTENANCE:
db = SessionLocal()
try:
ensure_universal_pool()
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:
db.close()
thread = threading.Thread(target=cleanup_loop, daemon=True)
thread.start()
logger.info("maintenance_leader_started")
+2 -3
View File
@@ -1,5 +1,4 @@
import main import maintenance
if __name__ == "__main__": if __name__ == "__main__":
main.run_maintenance_service() maintenance.run_maintenance_service()
+132
View File
@@ -0,0 +1,132 @@
import datetime as dt
import enum
from typing import Optional
from sqlalchemy import (
Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class ServiceType(str, enum.Enum):
WEB = "WEB"
VNC = "VNC"
RDP = "RDP"
class SessionStatus(str, enum.Enum):
ACTIVE = "ACTIVE"
EXPIRED = "EXPIRED"
TERMINATED = "TERMINATED"
ROTATED = "ROTATED"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
first_name: Mapped[str] = mapped_column(String(64), default="")
last_name: Mapped[str] = mapped_column(String(64), default="")
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class Service(Base):
__tablename__ = "services"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128))
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
target: Mapped[str] = mapped_column(Text)
comment: Mapped[str] = mapped_column(Text, default="")
svc_login: Mapped[str] = mapped_column(String(256), default="")
svc_password: Mapped[str] = mapped_column(String(256), default="")
svc_cred_hint: Mapped[str] = mapped_column(Text, default="")
icon_path: Mapped[str] = mapped_column(Text, default="")
active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class ServiceCategory(Base):
__tablename__ = "service_categories"
__table_args__ = (UniqueConstraint("service_id", "category_id", name="uq_service_category"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"), index=True)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class UserServiceAccess(Base):
__tablename__ = "user_service_access"
__table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class RdpSlot(Base):
__tablename__ = "rdp_slots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
rdp_username: Mapped[str] = mapped_column(String(128))
rdp_password: Mapped[str] = mapped_column(String(256), default="")
container_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class SessionModel(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
status: Mapped[SessionStatus] = mapped_column(Enum(SessionStatus), default=SessionStatus.ACTIVE, index=True)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
last_access_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
container_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
action: Mapped[str] = mapped_column(String(128), index=True)
details: Mapped[str] = mapped_column(Text)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
class PendingAccessRequest(Base):
__tablename__ = "pending_access_requests"
id: Mapped[str] = mapped_column(String(12), primary_key=True)
name: Mapped[str] = mapped_column(String(256))
company: Mapped[str] = mapped_column(String(256))
email: Mapped[str] = mapped_column(String(256))
phone: Mapped[str] = mapped_column(String(64))
manager: Mapped[str] = mapped_column(String(256), default="")
products_json: Mapped[str] = mapped_column(Text, default="[]")
portal_url: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
+1
View File
@@ -7,3 +7,4 @@ jinja2==3.1.6
passlib[argon2]==1.7.4 passlib[argon2]==1.7.4
docker==7.1.0 docker==7.1.0
itsdangerous==2.2.0 itsdangerous==2.2.0
mistune==3.1.3
+1163
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 13 KiB

+22 -8
View File
@@ -1,11 +1,25 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
<title id="title">4MONT favicon</title>
<desc id="desc">A compact favicon inspired by the 4MONT logo: a blue geometric 4 and bold black M on a clean rounded square.</desc>
<defs> <defs>
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'> <linearGradient id="blue" x1="5" y1="8" x2="38" y2="58" gradientUnits="userSpaceOnUse">
<stop offset='0%' stop-color='#1e6aa8'/> <stop offset="0" stop-color="#0C5CAD"/>
<stop offset='100%' stop-color='#2f8ec8'/> <stop offset="0.45" stop-color="#004C92"/>
<stop offset="1" stop-color="#002F62"/>
</linearGradient> </linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#001A33" flood-opacity="0.16"/>
</filter>
</defs> </defs>
<rect width='64' height='64' rx='14' fill='#eaf3fb'/>
<rect x='14' y='14' width='36' height='36' transform='rotate(45 32 32)' fill='url(#g)'/> <rect x="3" y="3" width="58" height="58" rx="14" fill="#FFFFFF"/>
<rect x='34' y='9' width='14' height='14' transform='rotate(45 41 16)' fill='#b7c0c9'/> <rect x="3.5" y="3.5" width="57" height="57" rx="13.5" fill="none" stroke="#E6EAF0"/>
</svg>
<g filter="url(#softShadow)">
<!-- Stylized 4 -->
<path fill="url(#blue)" d="M7 38.7 27.4 10.2h10.4v28.5h6.3v8.9h-6.3v7.3H27.9v-7.3H7v-8.9Zm20.9 0V25.2L18 38.7h9.9Z"/>
<!-- Compact M -->
<path fill="#050505" d="M39.2 54.9V10.2h9.4l5.7 16.1 5.7-16.1h9.1v44.7h-8.7V30.2l-4.7 13.3h-3.1l-4.8-13.3v24.7h-8.6Z" transform="translate(-5.4 0)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 32 KiB

+11
View File
@@ -0,0 +1,11 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Disallow: /go/
Disallow: /s/
Disallow: /w/
Disallow: /u/
Disallow: /rdp/
Sitemap: https://stend.4mont.ru/sitemap.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://stend.4mont.ru/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+974 -122
View File
File diff suppressed because it is too large Load Diff
+149 -35
View File
@@ -6,13 +6,25 @@
<title>Администрирование</title> <title>Администрирование</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/static/favicon.png" />
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</head> </head>
<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;">
<img src="/static/logo.png" alt="MONT" class="header-logo" /> <a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="header-logo" /></a>
<div>МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}</div> <div>MONT - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
</div> </div>
<a href="/" class="btn-link secondary">Главная панель</a> <a href="/" class="btn-link secondary">Главная панель</a>
</header> </header>
@@ -42,8 +54,8 @@
<input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" /> <input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" />
<div class="list-box" id="users_list"> <div class="list-box" id="users_list">
{% for u in users %} {% for u in users %}
<button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}})'> <button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}}, {{u.first_name|tojson}}, {{u.last_name|tojson}})'>
<div>{{u.username}}</div> <div>{{u.username}}{% if u.first_name or u.last_name %} <small style="opacity:.6">— {{ (u.first_name + ' ' + u.last_name)|trim }}</small>{% endif %}</div>
<small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small> <small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small>
</button> </button>
{% endfor %} {% endfor %}
@@ -55,6 +67,8 @@
<div class="form-grid"> <div class="form-grid">
<input id="u_id" type="hidden" /> <input id="u_id" type="hidden" />
<input id="u_name" placeholder="username" /> <input id="u_name" placeholder="username" />
<input id="u_first_name" placeholder="Имя" />
<input id="u_last_name" placeholder="Фамилия" />
<input id="u_exp" type="date" required /> <input id="u_exp" type="date" required />
<input id="u_pwd" placeholder="new password (optional)" type="password" /> <input id="u_pwd" placeholder="new password (optional)" type="password" />
<select id="u_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="u_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -70,7 +84,7 @@
<div class="list-title">ACL выбранного пользователя</div> <div class="list-title">ACL выбранного пользователя</div>
<div class="acl-grid"> <div class="acl-grid">
{% for s in services %} {% for s in services %}
<label><input type="checkbox" class="acl_service" value="{{s.id}}" /> {{s.name}} ({{s.slug}})</label> <label><input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" /> {{s.name}} ({{s.slug}})<span class="acl-owner"></span></label>
{% endfor %} {% endfor %}
</div> </div>
<button onclick="saveAclForSelectedUser()">Save ACL</button> <button onclick="saveAclForSelectedUser()">Save ACL</button>
@@ -80,6 +94,8 @@
<div class="list-title">Добавить пользователя</div> <div class="list-title">Добавить пользователя</div>
<div class="form-grid"> <div class="form-grid">
<input id="new_u_name" placeholder="username" /> <input id="new_u_name" placeholder="username" />
<input id="new_u_first_name" placeholder="Имя" />
<input id="new_u_last_name" placeholder="Фамилия" />
<input id="new_u_pwd" placeholder="password" type="password" /> <input id="new_u_pwd" placeholder="password" type="password" />
<input id="new_u_exp" type="date" required /> <input id="new_u_exp" type="date" required />
<select id="new_u_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_u_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -123,7 +139,7 @@
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" /> <input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
<div class="list-box" id="web_list"> <div class="list-box" id="web_list">
{% for s in web_services %} {% for s in web_services %}
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}})'> <button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -158,6 +174,21 @@
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea> <textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
</label> </label>
<label class="field-col">
<span>Логин сервиса (показывается на карточке)</span>
<input id="w_svc_login" placeholder="Например: admin" />
</label>
<label class="field-col">
<span>Пароль сервиса (показывается на карточке)</span>
<input id="w_svc_password" placeholder="Пароль для входа в сервис" />
</label>
<label class="field-col">
<span>Подсказка к логину/паролю (необязательно)</span>
<input id="w_svc_cred_hint" placeholder="Например: учётная запись гостя, сбрасывается раз в месяц" />
</label>
<label class="field-col"> <label class="field-col">
<span>Статус</span> <span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -231,6 +262,9 @@
<label class="field-col"> <label class="field-col">
<span>Описание для пользователя</span> <span>Описание для пользователя</span>
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea> <textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
<input id="new_w_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_w_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_w_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
</label> </label>
<label class="field-col"> <label class="field-col">
@@ -257,7 +291,7 @@
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" /> <input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
<div class="list-box" id="rdp_list"> <div class="list-box" id="rdp_list">
{% for s in rdp_services %} {% for s in rdp_services %}
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}})'> <button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -277,19 +311,20 @@
<input id="r_id" type="hidden" /> <input id="r_id" type="hidden" />
<input id="r_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" /> <input id="r_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" />
<input id="r_slug" placeholder="Системный slug" /> <input id="r_slug" placeholder="Системный slug" />
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" /> <input id="r_host" placeholder="RDP host (например 192.168.1.60)" oninput="buildRdpTarget('r')" />
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" /> <input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" oninput="buildRdpTarget('r')" />
<input id="r_user" placeholder="Логин (опционально)" /> <input id="r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('r')" />
<input id="r_pass" placeholder="Пароль (опционально)" type="password" /> <select id="r_sec" onchange="buildRdpTarget('r')">
<input id="r_domain" placeholder="Домен (опционально)" />
<select id="r_sec">
<option value="">auto</option> <option value="">auto</option>
<option value="nla">nla</option> <option value="nla">nla</option>
<option value="tls">tls</option> <option value="tls">tls</option>
<option value="rdp">rdp</option> <option value="rdp">rdp</option>
</select> </select>
<input id="r_target" placeholder="Собранный target (авто)" /> <input id="r_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
<input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" /> <input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -342,25 +377,40 @@
</div> </div>
</div> </div>
<div id="rdp_slots_box" style="display:none; margin-top:1rem;">
<div class="list-title">RDP пользователи (слоты пула)</div>
<div class="field-help">Каждый пользователь — отдельный контейнер. Пользователи портала берут свободный слот.</div>
<table class="admin-table" id="rdp_slots_table" style="margin-bottom:.7rem">
<thead><tr><th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Занят</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap;">
<input id="new_slot_user" placeholder="Логин RDP" style="max-width:160px" />
<input id="new_slot_pass" type="password" placeholder="Пароль RDP" style="max-width:160px" />
<button onclick="addRdpSlot()">+ Добавить слот</button>
</div>
</div>
<hr> <hr>
<div class="list-title">Добавить RDP</div> <div class="list-title">Добавить RDP</div>
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div> <div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
<div class="form-grid"> <div class="form-grid">
<input id="new_r_name" placeholder="Название сервиса" oninput="autogenSlug('new_r_name','new_r_slug')" /> <input id="new_r_name" placeholder="Название сервиса" oninput="autogenSlug('new_r_name','new_r_slug')" />
<input id="new_r_slug" placeholder="Системный slug" /> <input id="new_r_slug" placeholder="Системный slug" />
<input id="new_r_host" placeholder="RDP host" /> <input id="new_r_host" placeholder="RDP host" oninput="buildRdpTarget('new_r')" />
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" /> <input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" oninput="buildRdpTarget('new_r')" />
<input id="new_r_user" placeholder="Логин (опционально)" /> <input id="new_r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('new_r')" />
<input id="new_r_pass" placeholder="Пароль (опционально)" type="password" /> <select id="new_r_sec" onchange="buildRdpTarget('new_r')">
<input id="new_r_domain" placeholder="Домен (опционально)" />
<select id="new_r_sec">
<option value="">auto</option> <option value="">auto</option>
<option value="nla">nla</option> <option value="nla">nla</option>
<option value="tls">tls</option> <option value="tls">tls</option>
<option value="rdp">rdp</option> <option value="rdp">rdp</option>
</select> </select>
<input id="new_r_target" placeholder="Собранный target (авто)" /> <input id="new_r_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" /> <input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -390,7 +440,7 @@
<div>{{ c.name }}</div> <div>{{ c.name }}</div>
<small>{{ c.slug }}</small> <small>{{ c.slug }}</small>
</div> </div>
<button onclick="deleteCategory({{ c.id }}, {{ c.name|tojson }})">Delete</button> <button onclick='deleteCategory({{ c.id }}, {{ c.name|tojson }})'>Delete</button>
</div> </div>
{% else %} {% else %}
<div class="list-item">Категории пока не созданы</div> <div class="list-item">Категории пока не созданы</div>
@@ -513,6 +563,7 @@
const csrf = "{{ csrf_token }}"; const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }}; const aclMap = {{ acl | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }}; const serviceCategoryMap = {{ service_category_map | tojson }};
const rdpSlotsMap = {{ rdp_slots | tojson }};
const placeholderIcon = '/static/service-placeholder.svg'; const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users'; let activeTab = 'users';
@@ -622,9 +673,11 @@
return r.json(); return r.json();
} }
function selectUser(id, username, active, isAdmin, expiresIso) { function selectUser(id, username, active, isAdmin, expiresIso, firstName, lastName) {
document.getElementById('u_id').value = id; document.getElementById('u_id').value = id;
document.getElementById('u_name').value = username; document.getElementById('u_name').value = username;
document.getElementById('u_first_name').value = firstName || '';
document.getElementById('u_last_name').value = lastName || '';
document.getElementById('u_exp').value = dateFromIso(expiresIso); document.getElementById('u_exp').value = dateFromIso(expiresIso);
document.getElementById('u_pwd').value = ''; document.getElementById('u_pwd').value = '';
document.getElementById('u_active').value = String(active); document.getElementById('u_active').value = String(active);
@@ -638,6 +691,8 @@
if (!expDate) return alert('Выберите дату деактивации'); if (!expDate) return alert('Выберите дату деактивации');
await api('/api/admin/users', 'POST', { await api('/api/admin/users', 'POST', {
username: document.getElementById('new_u_name').value, username: document.getElementById('new_u_name').value,
first_name: document.getElementById('new_u_first_name').value,
last_name: document.getElementById('new_u_last_name').value,
password: document.getElementById('new_u_pwd').value, password: document.getElementById('new_u_pwd').value,
expires_at: expiryToApi(expDate), expires_at: expiryToApi(expDate),
active: document.getElementById('new_u_active').value === 'true', active: document.getElementById('new_u_active').value === 'true',
@@ -653,6 +708,8 @@
if (!expDate) return alert('Выберите дату деактивации'); if (!expDate) return alert('Выберите дату деактивации');
const payload = { const payload = {
username: document.getElementById('u_name').value, username: document.getElementById('u_name').value,
first_name: document.getElementById('u_first_name').value,
last_name: document.getElementById('u_last_name').value,
expires_at: expiryToApi(expDate), expires_at: expiryToApi(expDate),
active: document.getElementById('u_active').value === 'true', active: document.getElementById('u_active').value === 'true',
is_admin: document.getElementById('u_admin').value === 'true', is_admin: document.getElementById('u_admin').value === 'true',
@@ -683,7 +740,10 @@
const userId = parseInt(document.getElementById('u_id').value || '0', 10); const userId = parseInt(document.getElementById('u_id').value || '0', 10);
const allowed = new Set((aclMap[userId] || [])); const allowed = new Set((aclMap[userId] || []));
document.querySelectorAll('.acl_service').forEach((box) => { document.querySelectorAll('.acl_service').forEach((box) => {
box.checked = allowed.has(parseInt(box.value, 10)); const sid = parseInt(box.value, 10);
box.checked = allowed.has(sid);
box.disabled = false;
box.closest('label').style.opacity = '';
}); });
} }
@@ -695,12 +755,15 @@
location.reload(); location.reload();
} }
function selectWebService(id, name, slug, target, comment, iconPath, active) { function selectWebService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword, svcCredHint) {
document.getElementById('w_id').value = id; document.getElementById('w_id').value = id;
document.getElementById('w_name').value = name; document.getElementById('w_name').value = name;
document.getElementById('w_slug').value = slug; document.getElementById('w_slug').value = slug;
document.getElementById('w_target').value = target; document.getElementById('w_target').value = target;
document.getElementById('w_comment').value = comment || ''; document.getElementById('w_comment').value = comment || '';
document.getElementById('w_svc_login').value = svcLogin || '';
document.getElementById('w_svc_password').value = svcPassword || '';
document.getElementById('w_svc_cred_hint').value = svcCredHint || '';
document.getElementById('w_active').value = String(active); document.getElementById('w_active').value = String(active);
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon; document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []); setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
@@ -723,6 +786,9 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('new_w_target').value, target: document.getElementById('new_w_target').value,
comment: document.getElementById('new_w_comment').value, comment: document.getElementById('new_w_comment').value,
svc_login: document.getElementById('new_w_svc_login').value,
svc_password: document.getElementById('new_w_svc_password').value,
svc_cred_hint: document.getElementById('new_w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_w_cat'), category_ids: checkedCategoryIds('.new_w_cat'),
active: document.getElementById('new_w_active').value === 'true', active: document.getElementById('new_w_active').value === 'true',
}); });
@@ -739,6 +805,9 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('w_target').value, target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value, comment: document.getElementById('w_comment').value,
svc_login: document.getElementById('w_svc_login').value,
svc_password: document.getElementById('w_svc_password').value,
svc_cred_hint: document.getElementById('w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.w_cat'), category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true', active: document.getElementById('w_active').value === 'true',
}); });
@@ -746,7 +815,7 @@
} }
function clearWebForm() { function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment'].forEach(id => document.getElementById(id).value = ''); ['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password','w_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true'; document.getElementById('w_active').value = 'true';
setCategoryChecks('.w_cat', []); setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon; document.getElementById('w_icon_preview').src = placeholderIcon;
@@ -777,23 +846,59 @@
function buildRdpTarget(prefix) { function buildRdpTarget(prefix) {
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim(); const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389'; const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389';
const user = (document.getElementById(`${prefix}_user`)?.value || '').trim();
const pass = (document.getElementById(`${prefix}_pass`)?.value || '').trim();
const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim(); const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim();
const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim(); const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
const targetInput = document.getElementById(`${prefix}_target`); const targetInput = document.getElementById(`${prefix}_target`);
if (!host) return (targetInput?.value || '').trim(); if (!host) return (targetInput?.value || '').trim();
const creds = user ? `${encodeURIComponent(user)}${pass ? `:${encodeURIComponent(pass)}` : ''}@` : '';
const query = new URLSearchParams(); const query = new URLSearchParams();
if (domain) query.set('domain', domain); if (domain) query.set('domain', domain);
if (sec) query.set('sec', sec); if (sec) query.set('sec', sec);
const q = query.toString(); const q = query.toString();
const target = `rdp://${creds}${host}:${port}${q ? `?${q}` : ''}`; const target = `rdp://${host}:${port}${q ? `?${q}` : ''}`;
if (targetInput) targetInput.value = target; if (targetInput) targetInput.value = target;
return target; return target;
} }
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) { function renderRdpSlots(serviceId) {
const box = document.getElementById('rdp_slots_box');
const tbody = document.querySelector('#rdp_slots_table tbody');
const slots = rdpSlotsMap[serviceId] || [];
box.style.display = 'block';
tbody.innerHTML = '';
if (!slots.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
return;
}
slots.forEach(s => {
const statusBadge = s.running
? '<span style="color:#4caf50">&#9679; running</span>'
: '<span style="color:#e07b39">&#9679; stopped</span>';
const occupiedCell = s.occupied_username
? `<span style="color:#e07b39">${s.occupied_username}</span>`
: '<span style="color:#888">свободен</span>';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
tbody.appendChild(tr);
});
}
async function addRdpSlot() {
const serviceId = document.getElementById('r_id').value;
if (!serviceId) return alert('Выберите RDP сервис');
const rdp_username = document.getElementById('new_slot_user').value.trim();
const rdp_password = document.getElementById('new_slot_pass').value.trim();
if (!rdp_username) return alert('Введите логин RDP');
await api(`/api/admin/services/${serviceId}/rdp-slots`, 'POST', {rdp_username, rdp_password});
location.reload();
}
async function deleteRdpSlot(slotId) {
if (!confirm('Удалить RDP слот и остановить контейнер?')) return;
await api(`/api/admin/rdp-slots/${slotId}`, 'DELETE', {});
location.reload();
}
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint) {
const cfg = parseRdpTarget(target); const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id; document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name; document.getElementById('r_name').value = name;
@@ -801,17 +906,19 @@
document.getElementById('r_target').value = target; document.getElementById('r_target').value = target;
document.getElementById('r_host').value = cfg.host; document.getElementById('r_host').value = cfg.host;
document.getElementById('r_port').value = cfg.port; document.getElementById('r_port').value = cfg.port;
document.getElementById('r_user').value = cfg.user;
document.getElementById('r_pass').value = cfg.pass;
document.getElementById('r_domain').value = cfg.domain; document.getElementById('r_domain').value = cfg.domain;
document.getElementById('r_sec').value = cfg.sec; document.getElementById('r_sec').value = cfg.sec;
document.getElementById('r_comment').value = comment || ''; document.getElementById('r_comment').value = comment || '';
document.getElementById('r_svc_login').value = svcLogin || '';
document.getElementById('r_svc_password').value = svcPassword || '';
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active); document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool; document.getElementById('r_pool').value = pool;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []); setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon; document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block'; document.getElementById('r_health_box').style.display = 'block';
markSelected('.rdp-item', 'data-service-id', id); markSelected('.rdp-item', 'data-service-id', id);
renderRdpSlots(id);
refreshSelectedServiceStatus('rdp'); refreshSelectedServiceStatus('rdp');
} }
@@ -824,6 +931,9 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('new_r_comment').value, comment: document.getElementById('new_r_comment').value,
svc_login: document.getElementById('new_r_svc_login').value,
svc_password: document.getElementById('new_r_svc_password').value,
svc_cred_hint: document.getElementById('new_r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_r_cat'), category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true', active: document.getElementById('new_r_active').value === 'true',
@@ -841,6 +951,9 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('r_comment').value, comment: document.getElementById('r_comment').value,
svc_login: document.getElementById('r_svc_login').value,
svc_password: document.getElementById('r_svc_password').value,
svc_cred_hint: document.getElementById('r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.r_cat'), category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true', active: document.getElementById('r_active').value === 'true',
@@ -849,7 +962,8 @@
} }
function clearRdpForm() { function clearRdpForm() {
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = ''); ['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password','r_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = ''; document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true'; document.getElementById('r_active').value = 'true';
setCategoryChecks('.r_cat', []); setCategoryChecks('.r_cat', []);
+115 -29
View File
@@ -3,29 +3,66 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфрастуктурный полигон</title> <title>MONT - инфрастуктурный полигон</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/static/favicon.png" />
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</head> </head>
<body class="dashboard-page"> <body class="dashboard-page">
{% raw %}<style>
#mobile-wall{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;background:linear-gradient(135deg,#0d1b2a 0%,#1a2e45 60%,#0f2137 100%);flex-direction:column;align-items:center;justify-content:center;padding:clamp(1rem,6vw,2.5rem);text-align:center;font-family:sans-serif;box-sizing:border-box;overflow:hidden}
@media(max-width:1023px){#mobile-wall{display:flex}}
.mw-icon{font-size:clamp(2.2rem,12vw,3.5rem);margin-bottom:clamp(.5rem,2vw,1.2rem);filter:drop-shadow(0 0 18px rgba(42,140,214,.5))}
.mw-title{font-size:clamp(1rem,5.5vw,1.5rem);font-weight:800;color:#fff;margin-bottom:.6rem;letter-spacing:.01em;word-break:break-word;width:100%}
.mw-sub{font-size:clamp(.8rem,3.8vw,.95rem);color:#a0b8cc;width:100%;max-width:320px;line-height:1.6;margin-bottom:clamp(.8rem,4vw,2rem);word-break:break-word;overflow-wrap:break-word}
.mw-badge{display:inline-flex;align-items:center;gap:.45rem;background:rgba(42,140,214,.15);border:1px solid rgba(42,140,214,.4);border-radius:999px;padding:.45rem .9rem;color:#6bbfff;font-size:clamp(.7rem,3.2vw,.85rem);font-weight:600;max-width:88vw;flex-wrap:wrap;justify-content:center;word-break:break-word}
.mw-badge svg{width:16px;height:16px;flex-shrink:0}
.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 %}
<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>
<div class="mw-icon">🖥️</div>
<div class="mw-title">Только для компьютера</div>
<div class="mw-sub">Инфраструктурный полигон MONT оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
<div class="mw-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Минимальная ширина экрана: 1024 px
</div>
<div class="mw-footer"><a href="mailto:ruslan@ipcom.su" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div>
</div>
<header class="header"> <header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;"> <div class="header-left">
<img src="/static/logo.png" alt="MONT" class="header-logo" /> <div class="user-avatar">{{ ((user.first_name[0] if user.first_name else user.username[0]) + (user.last_name[0] if user.last_name else ''))|upper }}</div>
<div>{{ user.username }}</div> <span class="header-username">{{ (user.first_name + ' ' + user.last_name)|trim or user.username }}</span>
</div> </div>
<div style="display:flex; gap:0.5rem;"> <div class="header-right">
{% if user.is_admin %} {% if user.is_admin %}
<a href="/admin" class="btn-link secondary">Администрирование</a> <a href="/admin" class="header-btn">Администрирование</a>
{% endif %} {% endif %}
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit">Выход</button> <button type="submit" class="header-btn header-btn-logout">Выход</button>
</form> </form>
</div> </div>
</header> </header>
<div class="page-logo-wrap">
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="page-logo" /></a>
</div>
<main class="admin-layout"> <main class="admin-layout">
<section class="panel"> <section class="panel">
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div> <div class="admin-intro">Инфраструктурный полигон MONT</div>
{% if session_notice %} {% if session_notice %}
<div class="session-notice">{{ session_notice }}</div> <div class="session-notice">{{ session_notice }}</div>
{% endif %} {% endif %}
@@ -55,23 +92,46 @@
<section class="grid service-grid"> <section class="grid service-grid">
{% for service in services %} {% for service in services %}
{% set svc_cats = service_categories.get(service.id, []) %} {% set svc_cats = service_categories.get(service.id, []) %}
<a class="tile" href="/go/{{ service.slug }}"> <div class="tile-wrap">
<div class="tile-icon-box"> <a class="tile-link" href="/go/{{ service.slug }}" aria-label="{{ service.name }}"></a>
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <div class="tile">
</div> <div class="tile-icon-box">
<h3>{{ service.name }}</h3> <img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<p>Открыть сервис</p>
{% if service.comment %}
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
{% endif %}
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div> </div>
{% endif %} <h3>{{ service.name }}</h3>
</a> <div class="tile-info-area">
{% if service.svc_login or service.svc_password %}
<div class="svc-credentials">
{% if service.svc_login %}
<div class="svc-cred-row">
<span class="svc-cred-label">Логин</span>
<span class="svc-cred-value">{{ service.svc_login }}</span>
</div>
{% endif %}
{% if service.svc_password %}
<div class="svc-cred-row">
<span class="svc-cred-label">Пароль</span>
<span class="svc-cred-value svc-cred-masked">{{ service.svc_password }}</span>
</div>
{% endif %}
{% if service.svc_cred_hint %}
<p class="svc-cred-hint">{{ service.svc_cred_hint }}</p>
{% endif %}
</div>
{% endif %}
{% if service.comment %}
<div class="tile-comment">{{ service_comment_html.get(service.id, '') }}</div>
{% endif %}
</div>
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% else %} {% else %}
<div class="tile"> <div class="tile">
{% if selected_category_slug %} {% if selected_category_slug %}
@@ -82,8 +142,20 @@
</div> </div>
{% endfor %} {% endfor %}
</section> </section>
<footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer> <footer class="made-by-wrap"><a class="made-by" href="mailto:ruslan@ipcom.su">Made by Galyaviev</a></footer>
</main> </main>
<style>
#loading-overlay{display:none;position:fixed;inset:0;z-index:8888;background:rgba(10,18,28,.88);
backdrop-filter:blur(4px);flex-direction:column;align-items:center;justify-content:center;gap:1.2rem}
#loading-overlay .lo-spinner{width:52px;height:52px;border:4px solid rgba(220,232,245,.15);
border-top-color:#2a8cd6;border-radius:50%;animation:lo-spin .85s linear infinite}
#loading-overlay .lo-text{color:#a0b8cc;font:600 1rem sans-serif}
@keyframes lo-spin{to{transform:rotate(360deg)}}
</style>
<div id="loading-overlay">
<div class="lo-spinner"></div>
<div class="lo-text">Ожидайте...</div>
</div>
<script> <script>
(function () { (function () {
const username = {{ user.username|tojson }}; const username = {{ user.username|tojson }};
@@ -134,16 +206,30 @@
return sp; return sp;
} }
const loadingOverlay = document.getElementById('loading-overlay');
document.querySelectorAll('a.tile[href^="/go/"]').forEach(function (link) { document.querySelectorAll('a.tile[href^="/go/"]').forEach(function (link) {
link.addEventListener('click', function () { link.addEventListener('click', function (e) {
e.preventDefault();
let href = link.getAttribute('href');
try { try {
const url = new URL(link.getAttribute('href'), window.location.origin); const url = new URL(href, window.location.origin);
const params = currentScreenParams(); const params = currentScreenParams();
url.search = params.toString(); url.search = params.toString();
link.setAttribute('href', url.pathname + '?' + url.searchParams.toString()); href = url.pathname + '?' + url.searchParams.toString();
} catch (e) {} } catch (e) {}
if (loadingOverlay) loadingOverlay.style.display = 'flex';
requestAnimationFrame(function () {
requestAnimationFrame(function () {
window.location.href = href;
});
});
}, { capture: true }); }, { capture: true });
}); });
window.addEventListener('pageshow', function (e) {
if (loadingOverlay) loadingOverlay.style.display = 'none';
});
})(); })();
</script> </script>
</body> </body>
+501 -18
View File
@@ -1,31 +1,514 @@
{% set _scheme = request.headers.get('x-forwarded-proto', request.url.scheme) %}
{% set base_url = _scheme + '://' + request.url.netloc + '/' %}
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфрастуктурный полигон</title> <title>Инфраструктурный полигон MONT — демо и пилоты российского ПО</title>
<meta name="description" content="Инфраструктурный полигон MONT: демонстрация и пилотное тестирование российского ПО для партнёров и заказчиков. Браузерный доступ к рабочим стендам — без установки и настройки." />
<meta name="keywords" content="инфраструктурный полигон MONT, пилоты MONT, демо MONT, партнёры MONT, демонстрация MONT, российское ПО демо, отечественное ПО тестирование, демостенд ПО" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="{{ base_url }}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ base_url }}" />
<meta property="og:title" content="Инфраструктурный полигон MONT — демо и пилоты российского ПО" />
<meta property="og:description" content="Демонстрация и тестирование российского ПО для партнёров и заказчиков MONT. Доступ к рабочим стендам прямо в браузере." />
<meta property="og:image" content="{{ base_url }}static/logo.png?v=2" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="Полигон MONT" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Инфраструктурный полигон MONT" />
<meta name="twitter:description" content="Демо и пилоты российского ПО для партнёров и заказчиков MONT." />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Инфраструктурный полигон MONT",
"url": "{{ base_url }}",
"description": "Платформа для демонстрации и пилотного тестирования российского программного обеспечения. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам.",
"publisher": {
"@type": "Organization",
"name": "MONT",
"url": "https://www.mont.ru/"
}
}
</script>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/static/favicon.png" />
<style>
body { background: #070f1c; overflow: hidden; height: 100vh; }
@media (max-width: 820px) { body { overflow: auto; height: auto; } }
</style>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
</head> </head>
<body> <body>
<div class="login-corner-brand">МОНТ - инфрастуктурный полигон</div> <div class="login-wrap">
<main class="center-box login-page"> <aside class="login-left">
<section class="login-shell"> <div class="login-left-glow login-left-glow-top"></div>
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" /> <div class="login-left-glow login-left-glow-bottom"></div>
<h1 class="login-title">Добро пожаловать</h1> <div class="login-left-inner">
{% if session_notice %}<div class="session-notice">{{ session_notice }}</div>{% endif %} <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>
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %} <h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1>
<form method="post" action="/login" class="panel login-panel"> <p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <ul class="login-features">
<label>Логин</label> <li class="login-feature">
<input type="text" name="username" placeholder="Введите логин" required /> <span class="login-feature-icon">🖥</span>
<label>Пароль</label> <span>Доступ к рабочим столам ОС</span>
<input type="password" name="password" placeholder="Введите пароль" required /> </li>
<button type="submit">Войти</button> <li class="login-feature">
<span class="login-feature-icon">🌐</span>
<span>Веб-интерфейсы сервисов</span>
</li>
<li class="login-feature">
<span class="login-feature-icon"></span>
<span>Доступ в один клик</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">🔒</span>
<span>Защищённый контур</span>
</li>
</ul>
<button type="button" class="login-distrib-btn" onclick="window.open(location.hostname==='stand.mont.ru'?'https://maps.mont.ru':'https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
</div>
</aside>
<main class="login-right">
<div class="login-right-inner">
<div class="login-form-title">Вход в систему</div>
<div class="login-form-subtitle">Инфраструктурный полигон</div>
{% if session_notice %}<div class="session-notice lp-session-notice">{{ session_notice }}</div>{% endif %}
{% if login_error %}<div class="auth-error lp-auth-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="login-field">
<label>Логин</label>
<input type="text" name="username" placeholder="Введите логин" required autocomplete="username" />
</div>
<div class="login-field">
<label>Пароль</label>
<input type="password" name="password" placeholder="Введите пароль" required autocomplete="current-password" />
</div>
<button type="submit" class="login-submit">Войти</button>
</form> </form>
</section> <button type="button" class="login-request-btn" id="btn-request-access" data-open-access-modal="1">Запросить доступ</button>
</div>
<footer class="login-footer">
<a href="mailto:ruslan@ipcom.su" class="login-footer-link">Made by Galyaviev</a>
</footer>
</main> </main>
<footer class="login-made-by-wrap"><a class="made-by login-made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer> </div>
<!-- Request Access Modal -->
<div id="access-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
<div class="access-modal">
<div class="access-modal-header">
<div class="access-modal-title">Запрос на доступ</div>
</div>
<div class="access-modal-body">
<div class="access-field">
<label>Имя и фамилия <span class="req">*</span></label>
<input id="am-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Название компании <span class="req">*</span></label>
<input id="am-company" type="text" placeholder="ООО Компания" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="am-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Ваш менеджер в MONT</label>
<input id="am-manager" type="text" placeholder="Если известно — укажите имя" />
</div>
<div class="access-field">
<label>Интересующие продукты</label>
<div id="am-products" class="access-products-wrap">
<div class="access-products-loading">Загрузка...</div>
</div>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="am-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="am-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer">
<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('access-modal');
const btnCancel = document.getElementById('am-cancel');
const btnSubmit = document.getElementById('am-submit');
const errEl = document.getElementById('am-error');
let productsLoaded = false;
function resetAccessForm() {
if (!document.getElementById('am-name')) {
document.querySelector('.access-modal-body').innerHTML = `
<div class="access-field"><label>Имя и фамилия <span class="req">*</span></label><input id="am-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Название компании <span class="req">*</span></label><input id="am-company" type="text" placeholder="ООО Компания" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="am-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Ваш менеджер в MONT</label><input id="am-manager" type="text" placeholder="Если известно — укажите имя" /></div>
<div class="access-field"><label>Интересующие продукты</label><div id="am-products" class="access-products-wrap"><div class="access-products-loading">Загрузка...</div></div></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="am-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="am-error" class="access-modal-error" style="display:none"></div>`;
document.querySelector('.access-modal-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button><button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>`;
document.getElementById('am-cancel').addEventListener('click', closeModal);
document.getElementById('am-submit').addEventListener('click', submitForm);
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
productsLoaded = false;
} else {
['am-name','am-company','am-email','am-phone','am-manager'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked=false; });
var err=document.getElementById('am-error'); if(err) err.style.display='none';
var btn=document.getElementById('am-submit'); if(btn){btn.disabled=false;btn.textContent='Запросить доступ';}
}
}
function openModal() {
resetAccessForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!productsLoaded) loadProducts();
}
function closeModal() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('.am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeAccessModal = function() { closeModal(); productsLoaded = false; };
async function loadProducts() {
const wrap = document.getElementById('am-products');
try {
const res = await fetch('/api/public/services-by-category');
const data = await res.json();
wrap.innerHTML = '';
for (const [cat, svcs] of Object.entries(data)) {
const group = document.createElement('div');
group.className = 'access-products-group';
group.innerHTML = '<div class="access-products-cat">' + cat + '</div>';
for (const svc of svcs) {
const lbl = document.createElement('label');
lbl.className = 'access-product-item';
lbl.innerHTML = '<input type="checkbox" value="' + svc.name.replace(/"/g, '&quot;') + '" /><span>' + svc.name + '</span>';
group.appendChild(lbl);
}
wrap.appendChild(group);
}
productsLoaded = true;
} catch(e) {
wrap.innerHTML = '<div class="access-products-loading">Не удалось загрузить список</div>';
}
}
async function submitForm() {
const nameEl = document.getElementById('am-name');
const companyEl = document.getElementById('am-company');
const emailEl = document.getElementById('am-email');
const phoneEl = document.getElementById('am-phone');
const managerEl = document.getElementById('am-manager');
const submitBtn = document.getElementById('am-submit');
const errorEl = document.getElementById('am-error');
const name = nameEl ? nameEl.value.trim() : '';
const company = companyEl ? companyEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const manager = managerEl ? managerEl.value.trim() : '';
const checked = [...document.querySelectorAll('#am-products input[type=checkbox]:checked')];
const products = checked.map(c => c.value);
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('am-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя и фамилию' },
{ el: companyEl, check: () => !!company, msg: 'Введите название компании' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
];
const errors = [];
fields.forEach(f => {
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
else if (f.el) f.el.classList.remove('am-invalid');
});
if (!consentEl || !consentEl.checked) {
errors.push('Необходимо согласие на обработку персональных данных');
const cf = document.querySelector('.access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('.access-consent-field');
if (cf) cf.classList.remove('am-invalid-consent');
}
if (errors.length) {
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
return;
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/request-access', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, company, email, phone, manager, products}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
const body = document.querySelector('#access-modal .access-modal-body');
const footer = document.querySelector('#access-modal .access-modal-footer');
body.innerHTML = '<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Запрос отправлен</div>' +
'<div class="am-success-sub">После утверждения доступы придут на электронную почту <strong>' + email + '</strong></div>' +
'</div>';
footer.innerHTML = '<button type="button" class="access-btn-cancel" onclick="window._closeAccessModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('am-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('am-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Запросить доступ'; }
}
}
// Clear invalid highlight on input
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
// Wire up request-access button
document.querySelectorAll('.login-request-btn, [data-open-access-modal]').forEach(el => {
el.addEventListener('click', function(e) {
e.preventDefault();
openModal();
});
});
btnCancel.addEventListener('click', closeModal);
btnSubmit.addEventListener('click', submitForm);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
})();
</script>
<!-- Contact Ruslan Modal -->
<div id="contact-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
<div class="access-modal">
<div class="access-modal-header">
<div class="access-modal-title">Связаться с Русланом</div>
</div>
<div class="access-modal-body" id="cm-body">
<div class="access-field">
<label>Ваше имя <span class="req">*</span></label>
<input id="cm-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="cm-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Сообщение <span class="req">*</span></label>
<textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="cm-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer" id="cm-footer">
<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('contact-modal');
const btnCancel = document.getElementById('cm-cancel');
const btnSubmit = document.getElementById('cm-submit');
const errEl = document.getElementById('cm-error');
function resetContactForm() {
if (!document.getElementById('cm-name')) {
document.getElementById('cm-body').innerHTML = `
<div class="access-field"><label>Ваше имя <span class="req">*</span></label><input id="cm-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="cm-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Сообщение <span class="req">*</span></label><textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="cm-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>`;
document.getElementById('cm-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button><button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>`;
document.getElementById('cm-cancel').addEventListener('click', closeContact);
document.getElementById('cm-submit').addEventListener('click', submitContact);
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
} else {
['cm-name','cm-email','cm-phone','cm-text'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
var err=document.getElementById('cm-error'); if(err) err.style.display='none';
var btn=document.getElementById('cm-submit'); if(btn){btn.disabled=false;btn.textContent='Отправить';}
}
}
function openContact() {
resetContactForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeContact() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('#contact-modal .am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeContactModal = closeContact;
async function submitContact() {
const nameEl = document.getElementById('cm-name');
const emailEl = document.getElementById('cm-email');
const phoneEl = document.getElementById('cm-phone');
const textEl = document.getElementById('cm-text');
const submitBtn = document.getElementById('cm-submit');
const errorEl = document.getElementById('cm-error');
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const text = textEl ? textEl.value.trim() : '';
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('cm-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
{ el: textEl, check: () => !!text, msg: 'Введите сообщение' },
];
const errors = [];
fields.forEach(f => {
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
else if (f.el) f.el.classList.remove('am-invalid');
});
if (!consentEl || !consentEl.checked) {
errors.push('Необходимо согласие на обработку персональных данных');
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.remove('am-invalid-consent');
}
if (errors.length) {
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
return;
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, email, phone, text}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
document.getElementById('cm-body').innerHTML =
'<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Отправлено</div>' +
'<div class="am-success-sub">Постараюсь ответить в ближайшее время</div>' +
'</div>';
document.getElementById('cm-footer').innerHTML =
'<button type="button" class="access-btn-cancel" onclick="window._closeContactModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('cm-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('cm-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Отправить'; }
}
}
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
document.getElementById('btn-contact-ruslan').addEventListener('click', function(e) {
e.preventDefault();
openContact();
});
btnCancel.addEventListener('click', closeContact);
btnSubmit.addEventListener('click', submitContact);
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeContact(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.style.display !== 'none') closeContact(); });
})();
</script>
<!-- Cookie Banner -->
<div id="cookie-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:99999;background:rgba(7,15,28,0.97);border-top:1px solid rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:14px 24px;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
<p style="margin:0;font-size:0.85rem;color:#c8d8ea;line-height:1.5;flex:1;min-width:200px;">
Мы используем файлы cookie, чтобы сделать работу с сайтом удобнее. Нажмите «Принять», чтобы согласиться с использованием файлов cookie в соответствии с <a href="https://www.mont.ru/ru-ru/confidential" target="_blank" style="color:#5b9bd5;text-decoration:underline;">Политикой конфиденциальности</a>.
</p>
<button onclick="document.getElementById('cookie-banner').style.display='none';localStorage.setItem('cookie_accepted','1');" style="flex-shrink:0;padding:8px 22px;background:linear-gradient(135deg,#1a5db5,#2d8cf0);color:#fff;border:none;border-radius:8px;font-size:0.88rem;font-weight:600;cursor:pointer;white-space:nowrap;">Принять</button>
</div>
<script>
if (!localStorage.getItem('cookie_accepted')) {
document.getElementById('cookie-banner').style.display = 'flex';
}
</script>
</body> </body>
</html> </html>
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Политика конфиденциальности — Полигон MONT</title>
<meta name="robots" content="noindex"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<link rel="icon" type="image/png" href="/static/favicon.png"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: linear-gradient(160deg, #070f1c 0%, #0a1f3a 100%);
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #c8d8ea;
padding: 40px 20px 60px;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #5b9bd5;
text-decoration: none;
font-size: 0.9rem;
margin-bottom: 32px;
opacity: 0.85;
transition: opacity 0.2s;
}
.back-link:hover { opacity: 1; }
h1 {
font-size: 1.7rem;
font-weight: 700;
color: #e8f1fb;
margin-bottom: 6px;
}
.subtitle {
font-size: 0.9rem;
color: #6a8aaa;
margin-bottom: 36px;
}
h2 {
font-size: 1.05rem;
font-weight: 600;
color: #d0e4f7;
margin: 28px 0 10px;
}
p, li {
font-size: 0.95rem;
line-height: 1.7;
color: #a8bdd4;
}
ul {
padding-left: 20px;
margin-top: 6px;
}
li { margin-bottom: 4px; }
a { color: #5b9bd5; }
.divider {
border: none;
border-top: 1px solid rgba(255,255,255,0.07);
margin: 32px 0;
}
.contact-box {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 20px 24px;
margin-top: 28px;
}
.contact-box p { color: #a8bdd4; }
.contact-box strong { color: #d0e4f7; }
</style>
</head>
<body>
<div class="wrap">
<a class="back-link" href="/">&#8592; Вернуться на главную</a>
<h1>Политика конфиденциальности</h1>
<p class="subtitle">Последнее обновление: 28 мая 2026 г.</p>
<p>Настоящая Политика конфиденциальности описывает, как ООО «МОНТ» (далее — «Оператор») осуществляет сбор, использование и хранение персональных данных пользователей, оставивших заявку на получение доступа к Инфраструктурному полигону MONT.</p>
<h2>1. Оператор персональных данных</h2>
<p>ООО «МОНТ»<br>
Юридический адрес: г. Москва<br>
Сайт: <a href="https://www.mont.ru/" target="_blank">www.mont.ru</a><br>
Email для обращений по вопросам ПД: <a href="mailto:privacy@mont.ru">privacy@mont.ru</a></p>
<h2>2. Какие данные мы собираем</h2>
<p>При заполнении формы запроса доступа мы получаем:</p>
<ul>
<li>Имя и фамилия</li>
<li>Название компании</li>
<li>Адрес электронной почты</li>
<li>Номер телефона</li>
<li>Имя вашего менеджера в MONT (необязательно)</li>
<li>Список интересующих продуктов</li>
</ul>
<h2>3. Цели обработки</h2>
<p>Персональные данные обрабатываются исключительно для рассмотрения заявки на доступ к демостендам и последующей связи с вами по вопросам предоставления доступа.</p>
<h2>4. Правовое основание</h2>
<p>Обработка персональных данных осуществляется на основании вашего явного согласия в соответствии со ст. 6, ч. 1, п. 1 и ст. 9 Федерального закона № 152-ФЗ «О персональных данных».</p>
<h2>5. Передача данных третьим лицам</h2>
<p>Персональные данные не хранятся в базе данных портала. После отправки формы данные передаются ответственным сотрудникам MONT по защищённому каналу для обработки заявки. Данные не продаются и не передаются третьим лицам в коммерческих целях.</p>
<h2>6. Срок хранения</h2>
<p>Данные хранятся в течение срока, необходимого для обработки заявки и предоставления доступа, но не более 1 года с момента подачи заявки, если иное не требуется законодательством РФ.</p>
<h2>7. Ваши права</h2>
<p>В соответствии с 152-ФЗ вы вправе:</p>
<ul>
<li>получить информацию об обработке ваших персональных данных;</li>
<li>потребовать уточнения, блокирования или уничтожения ваших данных;</li>
<li>отозвать согласие на обработку персональных данных в любой момент.</li>
</ul>
<p>Для реализации любого из перечисленных прав направьте запрос на <a href="mailto:privacy@mont.ru">privacy@mont.ru</a>.</p>
<h2>8. Защита данных</h2>
<p>Передача данных осуществляется по зашифрованному соединению (HTTPS). Доступ к данным имеют только уполномоченные сотрудники MONT.</p>
<hr class="divider"/>
<div class="contact-box">
<p><strong>Вопросы по обработке персональных данных:</strong><br>
<a href="mailto:privacy@mont.ru">privacy@mont.ru</a></p>
</div>
</div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
pytest==8.3.5
httpx==0.28.1
pytest-asyncio==0.24.0
+103
View File
@@ -0,0 +1,103 @@
"""
Smoke-tests: проверяем что все ключевые роуты не падают с NameError/ImportError.
Не проверяем бизнес-логику — только что страницы отдают ответ.
"""
import pytest
# ── Публичные страницы ──────────────────────────────────────────────────────
def test_index_anonymous(client):
"""Главная без авторизации — либо страница сервисов, либо логин."""
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
def test_login_form_on_index(client):
"""Форма логина рендерится на /."""
r = client.get("/", follow_redirects=True)
assert r.status_code == 200
assert "csrf" in r.text.lower() or "login" in r.text.lower() or "пароль" in r.text.lower()
def _get_csrf(client):
from conftest import _extract_csrf
return _extract_csrf(client)
def test_login_wrong_password(client):
csrf = _get_csrf(client)
r = client.post("/login", data={
"username": "admin",
"password": "wrongpass",
"csrf_token": csrf,
})
assert r.status_code in (200, 401)
def test_login_csrf_fail(client):
r = client.post("/login", data={
"username": "admin",
"password": "testpass123",
"csrf_token": "bad-token",
})
assert r.status_code == 403
def test_login_no_such_method(client):
r = client.get("/login")
assert r.status_code in (200, 405) # только документируем поведение
# ── Авторизованные страницы ─────────────────────────────────────────────────
def test_login_success(auth_client):
r = auth_client.get("/", follow_redirects=True)
assert r.status_code == 200
def test_admin_page(auth_client):
r = auth_client.get("/admin")
assert r.status_code == 200
def test_admin_requires_auth(client):
from fastapi.testclient import TestClient
import main as app_module
fresh = TestClient(app_module.app, raise_server_exceptions=False)
r = fresh.get("/admin", follow_redirects=False)
assert r.status_code in (302, 303, 401, 403)
# ── API роуты ───────────────────────────────────────────────────────────────
def test_api_services_list(auth_client):
r = auth_client.get("/api/admin/services")
assert r.status_code in (200, 404, 405)
def test_api_users_list(auth_client):
r = auth_client.get("/api/admin/users")
assert r.status_code in (200, 404, 405)
def test_api_categories(auth_client):
r = auth_client.get("/api/admin/categories")
assert r.status_code in (200, 404, 405)
# ── Несуществующие роуты ────────────────────────────────────────────────────
def test_404(client):
r = client.get("/this-does-not-exist-xyz")
assert r.status_code == 404
def test_session_unknown(auth_client):
r = auth_client.get("/s/00000000-0000-0000-0000-000000000000/")
assert r.status_code in (200, 302, 303, 404)
def test_go_unknown_slug(auth_client):
r = auth_client.get("/go/nonexistent-service-slug", follow_redirects=False)
assert r.status_code in (302, 303, 404)
+181
View File
@@ -0,0 +1,181 @@
import datetime as dt
import json
import logging
import contextvars
from pathlib import Path
from typing import Optional
from urllib.parse import parse_qs, unquote, urlparse
import mistune
from fastapi import HTTPException, UploadFile
from markupsafe import Markup
from sqlalchemy import select
from sqlalchemy.orm import Session
from config import (
ICON_UPLOAD_MAX_BYTES, ICON_UPLOAD_TYPES, MAX_ACTIVE_SERVICES_PER_USER,
SERVICE_ICONS_DIR, SESSION_IDLE_SECONDS,
)
from models import AuditLog, Category, ServiceCategory, SessionModel, SessionStatus
logger = logging.getLogger("portal")
request_id_ctx = contextvars.ContextVar("request_id", default="-")
def _normalize_log_value(value):
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, dt.datetime):
return value.isoformat()
return str(value)
def log_event(event: str, level: int = logging.INFO, **fields) -> None:
payload = {"event": event, "req_id": request_id_ctx.get()}
for key, value in fields.items():
payload[key] = _normalize_log_value(value)
logger.log(level, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
def now_utc() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def session_closed_reason(sess: SessionModel, db: Session) -> str:
if not sess:
return "idle"
if sess.status == SessionStatus.EXPIRED:
return "idle"
if sess.status == SessionStatus.ROTATED:
return "limit"
if sess.status == SessionStatus.TERMINATED:
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
active_rows = db.scalars(
select(SessionModel).where(
SessionModel.user_id == sess.user_id,
SessionModel.status == SessionStatus.ACTIVE,
SessionModel.last_access_at >= cutoff,
)
).all()
active_service_ids = {row.service_id for row in active_rows}
if len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER and sess.service_id not in active_service_ids:
return "limit"
return "idle"
def normalize_web_target(url: str) -> str:
raw = (url or "").strip()
if not raw:
return raw
if raw.startswith(("http://", "https://")):
return raw
return f"http://{raw}"
_md = mistune.create_markdown(
escape=True,
plugins=["strikethrough", "table", "task_lists"],
)
def format_service_comment(raw_text: str) -> Markup:
raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not raw:
return Markup("")
return Markup(_md(raw))
def parse_rdp_target(target: str) -> dict:
raw = (target or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="Empty RDP target")
parsed = urlparse(raw if "://" in raw else f"//{raw}")
host = parsed.hostname
if not host:
raise HTTPException(status_code=400, detail="Invalid RDP target. Use host:port or rdp://user:pass@host:port")
port = parsed.port or 3389
username = unquote(parsed.username) if parsed.username else ""
password = unquote(parsed.password) if parsed.password else ""
query = parse_qs(parsed.query or "")
if not username:
username = (query.get("u", [""])[0] or query.get("user", [""])[0] or "").strip()
if not password:
password = (query.get("p", [""])[0] or query.get("password", [""])[0] or "").strip()
domain = (query.get("domain", [""])[0] or query.get("d", [""])[0] or "").strip()
security = (query.get("sec", [""])[0] or query.get("security", [""])[0] or "").strip().lower()
if security and security not in {"nla", "tls", "rdp"}:
raise HTTPException(status_code=400, detail="Invalid RDP security. Use one of: nla, tls, rdp")
return {
"host": host,
"port": str(port),
"user": username,
"password": password,
"domain": domain,
"security": security,
}
def set_service_categories(db: Session, service_id: int, category_ids: list[int]) -> None:
normalized = sorted({int(x) for x in (category_ids or [])})
if normalized:
existing_ids = set(db.scalars(select(Category.id).where(Category.id.in_(normalized))).all())
missing = sorted(set(normalized) - existing_ids)
if missing:
raise HTTPException(status_code=400, detail=f"Unknown category ids: {missing}")
existing_links = db.scalars(select(ServiceCategory).where(ServiceCategory.service_id == service_id)).all()
current = {row.category_id: row for row in existing_links}
wanted = set(normalized)
for cat_id in wanted:
if cat_id not in current:
db.add(ServiceCategory(service_id=service_id, category_id=cat_id))
for cat_id, row in current.items():
if cat_id not in wanted:
db.delete(row)
def audit(db: Session, action: str, details: str, user_id: Optional[int] = None) -> None:
db.add(AuditLog(user_id=user_id, action=action, details=details))
db.commit()
def ensure_icons_dir() -> None:
SERVICE_ICONS_DIR.mkdir(parents=True, exist_ok=True)
def remove_icon_file(icon_path: str) -> None:
if not icon_path or not icon_path.startswith("/static/service-icons/"):
return
filename = icon_path.rsplit("/", 1)[-1]
candidate = SERVICE_ICONS_DIR / filename
try:
candidate.unlink(missing_ok=True)
except Exception:
logger.exception("icon_delete_failed path=%s", candidate)
async def store_service_icon(service, upload: UploadFile) -> str:
ensure_icons_dir()
content_type = (upload.content_type or "").lower().strip()
ext = ICON_UPLOAD_TYPES.get(content_type)
if not ext:
raise HTTPException(status_code=400, detail="Unsupported file type. Use PNG/JPG/WEBP")
payload = await upload.read(ICON_UPLOAD_MAX_BYTES + 1)
if len(payload) > ICON_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail="File too large. Max 2MB")
if not payload:
raise HTTPException(status_code=400, detail="Empty file")
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d_%H%M%S")
filename = f"svc_{service.id}_{stamp}.{ext}"
target = SERVICE_ICONS_DIR / filename
target.write_bytes(payload)
return f"/static/service-icons/{filename}"
+50 -12
View File
@@ -30,7 +30,7 @@ services:
api: api:
build: build:
context: ./app context: ./app
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"] command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "${UVICORN_WORKERS:-6}"]
environment: environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SIGNING_KEY: ${SIGNING_KEY} SIGNING_KEY: ${SIGNING_KEY}
@@ -41,13 +41,32 @@ services:
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0} UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20} WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10} WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
X11VNC_FLAGS: ${X11VNC_FLAGS:--wait 5 -defer 5 -threads}
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4} MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
GO_USER_LOCK_TIMEOUT_SECONDS: 8 LOG_SLOW_REQUEST_MS: ${LOG_SLOW_REQUEST_MS:-2000}
GO_POOL_LOCK_TIMEOUT_SECONDS: 20 GO_USER_LOCK_TIMEOUT_SECONDS: ${GO_USER_LOCK_TIMEOUT_SECONDS:-8}
POOL_DISPATCH_RETRIES: 6 GO_POOL_LOCK_TIMEOUT_SECONDS: ${GO_POOL_LOCK_TIMEOUT_SECONDS:-20}
ENABLE_STARTUP_MAINTENANCE: 0 POOL_DISPATCH_RETRIES: ${POOL_DISPATCH_RETRIES:-6}
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS: ${POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS:-2.0}
POOL_DISPATCH_SLEEP_SECONDS: ${POOL_DISPATCH_SLEEP_SECONDS:-0.3}
TRAEFIK_INTERNAL_URL: ${TRAEFIK_INTERNAL_URL:-http://traefik}
WEB_RESOLUTION_MIN_WIDTH: ${WEB_RESOLUTION_MIN_WIDTH:-1024}
WEB_RESOLUTION_MIN_HEIGHT: ${WEB_RESOLUTION_MIN_HEIGHT:-720}
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
depends_on: depends_on:
- db - db
volumes: volumes:
@@ -56,7 +75,7 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=portal_net - traefik.docker.network=portal_net
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`) - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`) || Host(`stand.mont.ru`)
- traefik.http.routers.portal.entrypoints=websecure - traefik.http.routers.portal.entrypoints=websecure
- traefik.http.routers.portal.tls=true - traefik.http.routers.portal.tls=true
- traefik.http.routers.portal.tls.certresolver=letsencrypt - traefik.http.routers.portal.tls.certresolver=letsencrypt
@@ -82,13 +101,32 @@ services:
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0} UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20} WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10} WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
X11VNC_FLAGS: ${X11VNC_FLAGS:--wait 5 -defer 5 -threads}
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4} MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
GO_USER_LOCK_TIMEOUT_SECONDS: 8 LOG_SLOW_REQUEST_MS: ${LOG_SLOW_REQUEST_MS:-2000}
GO_POOL_LOCK_TIMEOUT_SECONDS: 20 GO_USER_LOCK_TIMEOUT_SECONDS: ${GO_USER_LOCK_TIMEOUT_SECONDS:-8}
POOL_DISPATCH_RETRIES: 6 GO_POOL_LOCK_TIMEOUT_SECONDS: ${GO_POOL_LOCK_TIMEOUT_SECONDS:-20}
ENABLE_STARTUP_MAINTENANCE: 0 POOL_DISPATCH_RETRIES: ${POOL_DISPATCH_RETRIES:-6}
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS: ${POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS:-2.0}
POOL_DISPATCH_SLEEP_SECONDS: ${POOL_DISPATCH_SLEEP_SECONDS:-0.3}
TRAEFIK_INTERNAL_URL: ${TRAEFIK_INTERNAL_URL:-http://traefik}
WEB_RESOLUTION_MIN_WIDTH: ${WEB_RESOLUTION_MIN_WIDTH:-1024}
WEB_RESOLUTION_MIN_HEIGHT: ${WEB_RESOLUTION_MIN_HEIGHT:-720}
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
depends_on: depends_on:
- db - db
volumes: volumes:
+2
View File
@@ -11,6 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
websockify \ websockify \
python3 \ python3 \
ca-certificates \ ca-certificates \
x11-xserver-utils \
x11-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
TARGET_URL="${TARGET_URL:-https://example.com}" TARGET_URL="${TARGET_URL:-https://example.com}"
SESSION_ID="${SESSION_ID:-unknown}" SESSION_ID="${SESSION_ID:-unknown}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -threads}" X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -threads}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}" TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}" UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
+76 -1
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os
import subprocess
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
RESOLUTION_MIN_WIDTH = int(os.environ.get("WEB_RESOLUTION_MIN_WIDTH", "1024"))
RESOLUTION_MIN_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MIN_HEIGHT", "720"))
RESOLUTION_MAX_WIDTH = int(os.environ.get("WEB_RESOLUTION_MAX_WIDTH", "3840"))
RESOLUTION_MAX_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
def _json_get(path: str): def _json_get(path: str):
with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp: with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp:
@@ -20,7 +29,6 @@ def chromium_open(url: str) -> None:
encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%') encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%')
opened = _json_put(f"/json/new?{encoded}") opened = _json_put(f"/json/new?{encoded}")
opened_id = opened.get("id") opened_id = opened.get("id")
# Keep exactly one active page tab to prevent tab/memory explosion in warm containers.
pages = _json_get("/json/list") pages = _json_get("/json/list")
for page in pages: for page in pages:
page_id = page.get("id") page_id = page.get("id")
@@ -31,6 +39,70 @@ def chromium_open(url: str) -> None:
pass pass
def _sanitize_resolution(width, height):
if not width or not height:
try:
default_w, default_h = [int(x) for x in CHROME_WINDOW_SIZE.split(",", 1)]
return default_w, default_h
except Exception:
return 1920, 1080
safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH))
safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT))
return safe_w, safe_h
def _xrandr_output_name():
try:
out = subprocess.run(
["xrandr", "-display", DISPLAY],
capture_output=True, text=True, check=False,
).stdout
for line in out.splitlines():
if " connected" in line:
return line.split()[0]
except Exception:
pass
return None
def _add_mode_via_cvt(width: int, height: int, output_name: str) -> bool:
try:
cvt = subprocess.run(
["cvt", str(width), str(height)],
capture_output=True, text=True, check=False,
)
if cvt.returncode != 0:
return False
modeline_line = next((l for l in cvt.stdout.splitlines() if l.startswith("Modeline")), None)
if not modeline_line:
return False
parts = modeline_line.split()
mode_name = parts[1].strip('"')
mode_params = parts[2:]
subprocess.run(["xrandr", "-display", DISPLAY, "--newmode", mode_name] + mode_params,
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["xrandr", "-display", DISPLAY, "--addmode", output_name, mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["xrandr", "-display", DISPLAY, "--output", output_name, "--mode", mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
return False
def apply_resolution(width, height) -> tuple:
safe_w, safe_h = _sanitize_resolution(width, height)
result = subprocess.run(
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
output_name = _xrandr_output_name()
if output_name:
_add_mode_via_cvt(safe_w, safe_h, output_name)
return safe_w, safe_h
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, payload: dict): def _json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
@@ -58,6 +130,9 @@ class Handler(BaseHTTPRequestHandler):
if not url.startswith("http://") and not url.startswith("https://"): if not url.startswith("http://") and not url.startswith("https://"):
self._json(400, {"detail": "Invalid URL"}) self._json(400, {"detail": "Invalid URL"})
return return
width = data.get("width")
height = data.get("height")
apply_resolution(width, height)
chromium_open(url) chromium_open(url)
print(f"open_ok url={url}", flush=True) print(f"open_ok url={url}", flush=True)
self._json(200, {"ok": True, "url": url}) self._json(200, {"ok": True, "url": url})
+6 -2
View File
@@ -3,17 +3,21 @@ FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
python3 \
xvfb \ xvfb \
x11vnc \ x11vnc \
freerdp2-x11 \ freerdp2-x11 \
novnc \ novnc \
websockify \ websockify \
xdotool \
ca-certificates \ ca-certificates \
fonts-dejavu-core \ fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
COPY manager.py /manager.py
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 6080 EXPOSE 6080 7001
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+191 -49
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
RDP_HOST="${RDP_HOST:?RDP_HOST is required}" RDP_HOST="${RDP_HOST:-}"
RDP_PORT="${RDP_PORT:-3389}" RDP_PORT="${RDP_PORT:-3389}"
RDP_USER="${RDP_USER:-}" RDP_USER="${RDP_USER:-}"
RDP_PASSWORD="${RDP_PASSWORD:-}" RDP_PASSWORD="${RDP_PASSWORD:-}"
@@ -24,88 +24,230 @@ cat > /opt/portal/index.html <<HTML
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RDP Session</title> <title>RDP Session</title>
<style>html,body,#screen{margin:0;height:100%;background:#111}</style> <style>
html,body,#screen{margin:0;height:100%;background:#111}
.status{
position:fixed;left:12px;top:12px;z-index:50;padding:8px 10px;border-radius:8px;
background:rgba(16,22,32,.86);border:1px solid rgba(255,255,255,.18);
color:#dce8f5;font:600 13px/1.25 sans-serif;max-width:min(92vw,560px);
}
.status.error{background:rgba(85,20,20,.9);border-color:rgba(255,130,130,.36);color:#ffe3e3}
.status.hidden{display:none}
.spinner{display:inline-block;width:12px;height:12px;border:2px solid rgba(220,232,245,.3);
border-top-color:#dce8f5;border-radius:50%;animation:spin .8s linear infinite;margin-right:7px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.nav-panel{
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(5px);
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px;
cursor:grab;user-select:none;touch-action:none
}
.nav-panel.dragging{cursor:grabbing;opacity:.85}
.nav-btn{
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;
background:linear-gradient(180deg,#2a8cd6,#1668a6);color:#fff;font:700 13px/1 sans-serif;
box-shadow:inset 0 1px 0 rgba(255,255,255,.22),0 5px 12px rgba(10,46,78,.45)
}
.nav-btn:hover{filter:brightness(1.08)}
.nav-btn:active{transform:translateY(1px)}
</style>
</head> </head>
<body> <body>
<div id="screen"></div> <div id="screen"></div>
<div id="status" class="status"><span class="spinner"></span>Ожидайте...</div>
<div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
<button class="nav-btn" id="btn-fs" type="button" title="На весь экран">⛶</button>
</div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
const wsBase = location.pathname.replace(/\/+$/, ''); const wsBase = location.pathname.replace(/\/+$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify'; const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
const rfb = new RFB(document.getElementById('screen'), wsUrl); const statusEl = document.getElementById('status');
rfb.viewOnly = false; const XK_ALT_L = 0xffe9;
rfb.scaleViewport = true; const XK_LEFT = 0xff51;
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1"; let rfb = null;
const SESSION_CLOSED_URL = '/?session_closed=idle'; let connected = false;
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close'); let reconnectTimer = null;
function goSessionClosed() { let reconnectAttempts = 0;
try { const MAX_RECONNECT = 12;
if (window.top && window.top !== window) { const DELAYS = [1000,2000,3000,5000,8000];
window.top.location.href = SESSION_CLOSED_URL; let manualDisconnect = false;
return;
} function showStatus(text, isError) {
} catch (e) {} const spinner = isError ? '' : '<span class="spinner"></span>';
window.location.href = SESSION_CLOSED_URL; statusEl.innerHTML = spinner + text;
statusEl.className = 'status' + (isError ? ' error' : '');
} }
function hideStatus() { statusEl.className = 'status hidden'; }
function scheduleReconnect(reason) {
if (manualDisconnect) return;
if (reconnectAttempts >= MAX_RECONNECT) {
showStatus('Соединение потеряно. Переподключение не удалось. Откройте сервис заново.', true);
return;
}
const n = ++reconnectAttempts;
const delay = DELAYS[Math.min(n-1, DELAYS.length-1)];
showStatus(\`\${reason} Повтор \${n}/\${MAX_RECONNECT} через \${Math.ceil(delay/1000)} сек.\`, true);
reconnectTimer = setTimeout(connect, delay);
}
function connect() {
if (manualDisconnect) return;
connected = false;
showStatus('Ожидайте...');
if (rfb) { try { rfb.disconnect(); } catch(e){} }
rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
connected = true;
reconnectAttempts = 0;
clearTimeout(reconnectTimer);
showStatus('Устанавливается соединение с рабочим столом...');
setTimeout(hideStatus, 6000);
});
rfb.addEventListener('disconnect', () => {
connected = false;
if (!manualDisconnect) scheduleReconnect('Соединение потеряно.');
});
}
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const TOUCH_PATH = '${TOUCH_PATH}';
const CLOSE_PATH = TOUCH_PATH.replace(/\/touch$/, '/close');
const SESSION_CLOSED_URL = '/?session_closed=idle';
function goSessionClosed(reason) {
const r = reason === 'limit' ? 'limit' : 'idle';
try {
if (window.top && window.top !== window) { window.top.location.href = '/?session_closed=' + r; return; }
} catch(e) {}
window.location.href = '/?session_closed=' + r;
}
async function touch() { async function touch() {
try { try {
const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'}); const res = await fetch(TOUCH_PATH, {method:'POST', credentials:'include'});
if (!res.ok) { if (!res.ok) {
goSessionClosed(); let reason = 'idle';
try { const p = await res.json(); if (p && typeof p.reason === 'string') reason = p.reason; } catch(e) {}
goSessionClosed(reason);
} }
} catch(e) {} } catch(e) {}
} }
let closing = false; let closing = false;
async function closeSessionNow() { async function closeSessionNow() {
if (closing) return; if (closing) return;
closing = true; closing = true;
try { manualDisconnect = true;
await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true}); clearTimeout(reconnectTimer);
} catch (e) {} try { if (rfb) rfb.disconnect(); } catch(e) {}
try { await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true}); } catch(e) {}
} }
if (enableHeartbeat) { if (enableHeartbeat) {
setInterval(touch, 15000); setInterval(touch, 15000);
touch(); touch();
window.addEventListener('pagehide', closeSessionNow); window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow); window.addEventListener('beforeunload', closeSessionNow);
} }
function chord(mod, key, modCode, keyCode) {
if (!rfb) return;
rfb.sendKey(mod, modCode, true);
rfb.sendKey(key, keyCode, true);
rfb.sendKey(key, keyCode, false);
rfb.sendKey(mod, modCode, false);
}
function goHome() {
manualDisconnect = true;
clearTimeout(reconnectTimer);
try { if (window.top && window.top !== window) { window.top.location.href = '/'; return; } } catch(e) {}
window.location.href = '/';
}
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome);
document.getElementById('btn-fs').addEventListener('click', () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
});
document.addEventListener('fullscreenchange', () => {
document.getElementById('btn-fs').textContent = document.fullscreenElement ? '✕' : '⛶';
document.getElementById('btn-fs').title = document.fullscreenElement ? 'Выйти из полного экрана' : 'На весь экран';
});
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
(function(){
const p = document.querySelector('.nav-panel');
const SK = 'rdp_nav_pos';
try { const s = JSON.parse(localStorage.getItem(SK)); if(s){p.style.left=s.x+'px';p.style.top=s.y+'px';} } catch(e){}
let ox, oy, dragged = false;
p.addEventListener('pointerdown', e => {
if (e.target.closest('button')) return;
dragged = false;
ox = e.clientX - p.getBoundingClientRect().left;
oy = e.clientY - p.getBoundingClientRect().top;
p.setPointerCapture(e.pointerId);
p.classList.add('dragging');
});
p.addEventListener('pointermove', e => {
if (!p.hasPointerCapture(e.pointerId)) return;
dragged = true;
const x = Math.max(0, Math.min(window.innerWidth - p.offsetWidth, e.clientX - ox));
const y = Math.max(0, Math.min(window.innerHeight - p.offsetHeight, e.clientY - oy));
p.style.left = x + 'px';
p.style.top = y + 'px';
});
p.addEventListener('pointerup', () => {
p.classList.remove('dragging');
if (dragged) try { localStorage.setItem(SK, JSON.stringify({x: parseInt(p.style.left), y: parseInt(p.style.top)})); } catch(e){}
});
})();
connect();
</script> </script>
</body> </body>
</html> </html>
HTML HTML
export DISPLAY="$DISPLAY_NUM" export DISPLAY="$DISPLAY_NUM"
DISPLAY_N="${DISPLAY_NUM#:}"
rm -f "/tmp/.X${DISPLAY_N}-lock" "/tmp/.X11-unix/X${DISPLAY_N}" 2>/dev/null || true
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 & Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
XVFB_PID=$!
sleep 1 sleep 1
RDP_ARGS=(
"/v:${RDP_HOST}:${RDP_PORT}"
"/cert:ignore"
"/f"
"/dynamic-resolution"
"/gfx-h264:avc444"
"/network:auto"
"+clipboard"
)
if [ -n "$RDP_SECURITY" ]; then
RDP_ARGS+=("/sec:${RDP_SECURITY}")
fi
if [ -n "$RDP_USER" ]; then
RDP_ARGS+=("/u:${RDP_USER}")
fi
if [ -n "$RDP_PASSWORD" ]; then
RDP_ARGS+=("/p:${RDP_PASSWORD}")
fi
if [ -n "$RDP_DOMAIN" ]; then
RDP_ARGS+=("/d:${RDP_DOMAIN}")
fi
xfreerdp "${RDP_ARGS[@]}" >/tmp/xfreerdp.log 2>&1 &
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 & x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
X11VNC_PID=$!
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
WEBSOCKIFY_PID=$!
python3 /manager.py >/tmp/manager.log 2>&1 &
MANAGER_PID=$!
cleanup() {
python3 -c "
import urllib.request, sys
try:
urllib.request.urlopen('http://localhost:7001/disconnect', b'', timeout=3)
except Exception as e:
sys.stderr.write(str(e) + '\n')
" 2>/dev/null || true
kill "$X11VNC_PID" "$WEBSOCKIFY_PID" "$MANAGER_PID" "$XVFB_PID" 2>/dev/null || true
exit 0
}
trap cleanup TERM INT
wait "$WEBSOCKIFY_PID" || true
cleanup
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""On-demand xfreerdp manager. HTTP on port 7001."""
import json
import logging
import os
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("rdp-manager")
DISPLAY = os.environ.get("DISPLAY", ":1")
RDP_HOST = os.environ.get("RDP_HOST", "")
RDP_PORT = os.environ.get("RDP_PORT", "3389")
RDP_USER = os.environ.get("RDP_USER", "")
RDP_PASSWORD = os.environ.get("RDP_PASSWORD", "")
RDP_DOMAIN = os.environ.get("RDP_DOMAIN", "")
RDP_SECURITY = os.environ.get("RDP_SECURITY", "")
STATE_FILE = "/tmp/rdp_state.json"
_lock = threading.Lock()
_proc: subprocess.Popen | None = None
_should_be_connected = False
def _save_state():
try:
with open(STATE_FILE, "w") as f:
json.dump({"should_be_connected": _should_be_connected}, f)
except Exception:
pass
def _load_state() -> bool:
try:
with open(STATE_FILE) as f:
return json.load(f).get("should_be_connected", False)
except Exception:
return False
def _build_args():
args = [
"xfreerdp",
f"/v:{RDP_HOST}:{RDP_PORT}",
"/cert:ignore",
"/f",
"/dynamic-resolution",
"/gfx-h264:avc444",
"/network:auto",
"+clipboard",
]
if RDP_SECURITY:
args.append(f"/sec:{RDP_SECURITY}")
if RDP_USER:
args.append(f"/u:{RDP_USER}")
if RDP_PASSWORD:
args.append(f"/p:{RDP_PASSWORD}")
if RDP_DOMAIN:
args.append(f"/d:{RDP_DOMAIN}")
return args
def _launch():
global _proc
env = dict(os.environ)
env["DISPLAY"] = DISPLAY
log_file = open("/tmp/xfreerdp.log", "a")
_proc = subprocess.Popen(_build_args(), stdout=log_file, stderr=log_file, env=env)
log.info("xfreerdp started pid=%s target=%s:%s", _proc.pid, RDP_HOST, RDP_PORT)
return _proc
def _monitor_loop():
while True:
time.sleep(5)
with _lock:
if not _should_be_connected:
continue
if _proc is None or _proc.poll() is not None:
log.info("xfreerdp exited unexpectedly, reconnecting in 3s")
time.sleep(3)
_launch()
def _anti_idle_loop():
env = {**os.environ, "DISPLAY": DISPLAY}
toggle = False
while True:
time.sleep(60)
with _lock:
active = _should_be_connected and _proc is not None and _proc.poll() is None
if not active:
continue
try:
r = subprocess.run(
["xdotool", "search", "--name", "FreeRDP"],
env=env, capture_output=True, timeout=5,
)
win_id = r.stdout.decode().strip().splitlines()[0] if r.stdout.strip() else ""
if win_id:
x, y = (5, 80) if toggle else (6, 81)
subprocess.run(
["xdotool", "mousemove", "--window", win_id, str(x), str(y)],
env=env, capture_output=True, timeout=5,
)
subprocess.run(
["xdotool", "click", "--window", win_id, "1"],
env=env, capture_output=True, timeout=5,
)
subprocess.run(
["xdotool", "key", "--window", win_id, "--clearmodifiers", "shift"],
env=env, capture_output=True, timeout=5,
)
toggle = not toggle
log.debug("anti_idle click window=%s pos=%s,%s", win_id, x, y)
else:
log.debug("anti_idle: xfreerdp window not found")
except Exception as e:
log.debug("anti_idle error: %s", e)
threading.Thread(target=_monitor_loop, daemon=True).start()
threading.Thread(target=_anti_idle_loop, daemon=True).start()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _json(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
with _lock:
connected = _proc is not None and _proc.poll() is None
pid = _proc.pid if connected else None
self._json(200, {
"connected": connected,
"pid": pid,
"target": f"{RDP_HOST}:{RDP_PORT}",
"should_be_connected": _should_be_connected,
})
else:
self._json(404, {"error": "not found"})
def do_POST(self):
global _proc, _should_be_connected
if self.path == "/connect":
with _lock:
_should_be_connected = True
_save_state()
if _proc is not None and _proc.poll() is None:
self._json(200, {"ok": True, "pid": _proc.pid, "already": True})
return
proc = _launch()
self._json(200, {"ok": True, "pid": proc.pid})
elif self.path == "/disconnect":
with _lock:
_should_be_connected = False
_save_state()
if _proc is not None:
_proc.terminate()
try:
_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
_proc.kill()
_proc = None
log.info("xfreerdp disconnected")
self._json(200, {"ok": True})
else:
self._json(404, {"error": "not found"})
if __name__ == "__main__":
if not RDP_HOST:
log.warning("RDP_HOST not set — connect calls will fail")
if _load_state():
log.info("restoring state: reconnecting xfreerdp")
_should_be_connected = True
_launch()
log.info("manager started on :7001")
HTTPServer(("0.0.0.0", 7001), Handler).serve_forever()
+10
View File
@@ -1,8 +1,18 @@
entryPoints: entryPoints:
web: web:
address: ":80" address: ":80"
forwardedHeaders:
trustedIPs:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
websecure: websecure:
address: ":443" address: ":443"
forwardedHeaders:
trustedIPs:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
providers: providers:
docker: docker:
+12 -2
View File
@@ -1,8 +1,12 @@
FROM debian:bookworm-slim FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive \
LANG=ru_RU.UTF-8 \
LC_ALL=ru_RU.UTF-8 \
LANGUAGE=ru_RU:ru
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
chromium \ chromium \
xvfb \ xvfb \
x11vnc \ x11vnc \
@@ -12,7 +16,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
websockify \ websockify \
python3 \ python3 \
ca-certificates \ ca-certificates \
x11-xserver-utils \
x11-utils \
fonts-dejavu-core \ fonts-dejavu-core \
python3-cryptography \
locales \
&& sed -i 's/# ru_RU.UTF-8/ru_RU.UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
@@ -20,4 +30,4 @@ COPY manager.py /manager.py
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 6080 EXPOSE 6080
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+40 -2
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -threads}" X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -threads}"
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}" SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
@@ -42,7 +42,8 @@ cat > /opt/portal/index.html <<'HTML'
.nav-panel{ .nav-panel{
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px; position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));border:1px solid rgba(255,255,255,.22);backdrop-filter: blur(5px); background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));border:1px solid rgba(255,255,255,.22);backdrop-filter: blur(5px);
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px;
cursor:grab;user-select:none;touch-action:none
} }
.nav-btn{ .nav-btn{
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;letter-spacing:.01em; border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;letter-spacing:.01em;
@@ -50,6 +51,7 @@ cat > /opt/portal/index.html <<'HTML'
} }
.nav-btn:hover{filter:brightness(1.08)} .nav-btn:hover{filter:brightness(1.08)}
.nav-btn:active{transform:translateY(1px)} .nav-btn:active{transform:translateY(1px)}
.nav-panel.dragging{cursor:grabbing;opacity:.85}
</style> </style>
</head> </head>
<body> <body>
@@ -58,6 +60,7 @@ cat > /opt/portal/index.html <<'HTML'
<div class="nav-panel"> <div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button> <button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button> <button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
<button class="nav-btn" id="btn-fs" type="button" title="На весь экран">⛶</button>
</div> </div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
@@ -251,8 +254,43 @@ cat > /opt/portal/index.html <<'HTML'
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft')); document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome); document.getElementById('btn-home').addEventListener('click', goHome);
document.getElementById('btn-fs').addEventListener('click', () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
});
document.addEventListener('fullscreenchange', () => {
document.getElementById('btn-fs').textContent = document.fullscreenElement ? '✕' : '⛶';
document.getElementById('btn-fs').title = document.fullscreenElement ? 'Выйти из полного экрана' : 'На весь экран';
});
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
(function(){
const p = document.querySelector('.nav-panel');
const SK = 'portal_nav_pos';
try { const s = JSON.parse(localStorage.getItem(SK)); if(s){p.style.left=s.x+'px';p.style.top=s.y+'px';} } catch(e){}
let ox, oy, dragged = false;
p.addEventListener('pointerdown', e => {
if (e.target.closest('button')) return;
dragged = false;
ox = e.clientX - p.getBoundingClientRect().left;
oy = e.clientY - p.getBoundingClientRect().top;
p.setPointerCapture(e.pointerId);
p.classList.add('dragging');
});
p.addEventListener('pointermove', e => {
if (!p.hasPointerCapture(e.pointerId)) return;
dragged = true;
const x = Math.max(0, Math.min(window.innerWidth - p.offsetWidth, e.clientX - ox));
const y = Math.max(0, Math.min(window.innerHeight - p.offsetHeight, e.clientY - oy));
p.style.left = x + 'px';
p.style.top = y + 'px';
});
p.addEventListener('pointerup', () => {
p.classList.remove('dragging');
if (dragged) try { localStorage.setItem(SK, JSON.stringify({x: parseInt(p.style.left), y: parseInt(p.style.top)})); } catch(e){}
});
})();
connectRfb('Подключение к слоту...'); connectRfb('Подключение к слоту...');
</script> </script>
</body> </body>
+322 -45
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os import os
import shutil
import signal import signal
import subprocess import subprocess
import tempfile
import threading import threading
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, urlunparse, quote
DISPLAY = os.environ.get("DISPLAY", ":1") DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
@@ -18,24 +21,233 @@ _state = {
"mode": "idle", "mode": "idle",
"target": "", "target": "",
"resolution": CHROME_WINDOW_SIZE, "resolution": CHROME_WINDOW_SIZE,
"profile_dir": None,
"extension_dir": None,
} }
_lock = threading.Lock() _lock = threading.Lock()
_AUTOFILL_CONTENT_JS = r"""
(function() {
const CREDS = __CREDS__;
console.log('[PortalAutofill] loaded for', location.href);
function isVisible(el) {
if (!el) return false;
if (el.disabled || el.readOnly) return false;
if (el.offsetParent === null && el.type !== 'email') return false;
return true;
}
function findUserFieldByAttrs() {
const candidates = document.querySelectorAll(
'input[type="email"], ' +
'input[autocomplete*="username"], ' +
'input[autocomplete*="email"], ' +
'input[name*="user" i]:not([type="password"]):not([type="hidden"]), ' +
'input[name*="login" i]:not([type="password"]):not([type="hidden"]), ' +
'input[name*="email" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="user" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="login" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="email" i]:not([type="password"]):not([type="hidden"])'
);
for (const el of candidates) {
if (isVisible(el)) return el;
}
return null;
}
function findUserFieldNearPassword(passEl) {
if (!passEl) return null;
// Walk up to find the closest form-like container, then look for
// any text/email input that comes BEFORE the password element.
let container = passEl.closest('form');
if (!container) {
let cur = passEl.parentElement;
while (cur && cur !== document.body) {
if (cur.querySelectorAll('input').length >= 2) { container = cur; break; }
cur = cur.parentElement;
}
}
if (!container) container = document.body;
const inputs = container.querySelectorAll('input');
let candidate = null;
for (const el of inputs) {
if (el === passEl) break;
const t = (el.type || 'text').toLowerCase();
if (t === 'password' || t === 'hidden' || t === 'submit' || t === 'button' ||
t === 'checkbox' || t === 'radio' || t === 'file') continue;
if (!isVisible(el)) continue;
candidate = el;
}
return candidate;
}
function findUserField(passEl) {
return findUserFieldByAttrs() || findUserFieldNearPassword(passEl) ||
Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
.find(isVisible) || null;
}
function findPassField() {
const list = document.querySelectorAll('input[type="password"]');
for (const el of list) {
if (isVisible(el)) return el;
}
return list[0] || null;
}
function setNativeValue(el, v) {
if (!el) return false;
if (el.value === v) return true;
el.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
const proto = Object.getPrototypeOf(el);
const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
if (desc && desc.set) desc.set.call(el, v); else el.value = v;
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: v, inputType: 'insertText' }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: v.slice(-1) }));
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
return true;
}
function tryFill() {
const p = findPassField();
const u = findUserField(p);
// Fill login FIRST so password-triggered re-render doesn't clear it
if (CREDS.login && u) {
if (setNativeValue(u, CREDS.login)) {
console.log('[PortalAutofill] user filled');
}
}
if (CREDS.password && p) {
if (setNativeValue(p, CREDS.password)) {
console.log('[PortalAutofill] password filled');
}
}
}
// Watch for SPA re-renders clearing the fields and re-fill continuously
let _filling = false;
function scheduleFill() {
if (_filling) return;
_filling = true;
requestAnimationFrame(() => { tryFill(); _filling = false; });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryFill);
} else {
tryFill();
}
// Periodic check for first 30s in case of async SPA resets
let _checks = 0;
const _interval = setInterval(() => {
tryFill();
if (++_checks >= 30) clearInterval(_interval);
}, 1000);
const obs = new MutationObserver(scheduleFill);
if (document.documentElement) {
obs.observe(document.documentElement, { childList: true, subtree: true });
}
const resetAndRefill = () => {
_checks = 0;
setTimeout(tryFill, 150);
setTimeout(tryFill, 600);
};
['pushState', 'replaceState'].forEach(fn => {
const orig = history[fn];
history[fn] = function() { const r = orig.apply(this, arguments); resetAndRefill(); return r; };
});
window.addEventListener('popstate', resetAndRefill);
})();
"""
_AUTOFILL_MANIFEST = {
"manifest_version": 3,
"name": "Portal Autofill",
"version": "1.0",
"description": "Auto-fill credentials for portal session",
"background": {"service_worker": "background.js"},
"permissions": ["webRequest"],
"host_permissions": ["<all_urls>"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": True,
}
],
}
_AUTOFILL_BACKGROUND_JS = r"""
const CREDS = __CREDS__;
if (CREDS.login || CREDS.password) {
chrome.webRequest.onAuthRequired.addListener(
function(details, callback) {
callback({ authCredentials: { username: CREDS.login || '', password: CREDS.password || '' } });
},
{ urls: ['<all_urls>'] },
['asyncBlocking']
);
}
"""
def _create_autofill_extension(login: str, password: str) -> str | None:
if not login and not password:
return None
ext_dir = tempfile.mkdtemp(prefix="chrome-autofill-ext-")
creds_json = json.dumps({"login": login, "password": password})
content_js = _AUTOFILL_CONTENT_JS.replace("__CREDS__", creds_json)
background_js = _AUTOFILL_BACKGROUND_JS.replace("__CREDS__", creds_json)
with open(os.path.join(ext_dir, "manifest.json"), "w") as f:
json.dump(_AUTOFILL_MANIFEST, f)
with open(os.path.join(ext_dir, "content.js"), "w") as f:
f.write(content_js)
with open(os.path.join(ext_dir, "background.js"), "w") as f:
f.write(background_js)
return ext_dir
def _create_chrome_profile() -> str:
profile_dir = tempfile.mkdtemp(prefix="chrome-profile-")
default_dir = os.path.join(profile_dir, "Default")
os.makedirs(default_dir, exist_ok=True)
prefs = {
"intl": {"accept_languages": "ru-RU,ru,en", "selected_languages": "ru-RU,ru"},
"translate": {"enabled": False},
"translate_blocked_languages": ["ru"],
}
with open(os.path.join(default_dir, "Preferences"), "w") as f:
json.dump(prefs, f)
return profile_dir
def _stop_current() -> None: def _stop_current() -> None:
proc = _state.get("proc") proc = _state.get("proc")
if not proc: if proc:
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.wait(timeout=4)
except Exception:
try: try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.wait(timeout=4)
except Exception: except Exception:
pass try:
finally: os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
_state["proc"] = None except Exception:
pass
finally:
_state["proc"] = None
for key in ("profile_dir", "extension_dir"):
path = _state.get(key)
if path and os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
_state[key] = None
def _start_process(cmd: list[str], mode: str, target: str) -> None: def _start_process(cmd: list[str], mode: str, target: str) -> None:
@@ -62,52 +274,103 @@ def _sanitize_resolution(width: int | None, height: int | None) -> tuple[int, in
return default_w, default_h return default_w, default_h
except Exception: except Exception:
return 1920, 1080 return 1920, 1080
safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH)) safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH))
safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT)) safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT))
return safe_w, safe_h return safe_w, safe_h
def _xrandr_output_name() -> str | None:
try:
out = subprocess.run(
["xrandr", "-display", DISPLAY],
capture_output=True, text=True, check=False,
).stdout
for line in out.splitlines():
if " connected" in line:
return line.split()[0]
except Exception:
pass
return None
def _add_mode_via_cvt(width: int, height: int, output_name: str) -> bool:
try:
cvt = subprocess.run(
["cvt", str(width), str(height)],
capture_output=True, text=True, check=False,
)
if cvt.returncode != 0:
return False
modeline_line = next(
(l for l in cvt.stdout.splitlines() if l.startswith("Modeline")), None
)
if not modeline_line:
return False
parts = modeline_line.split()
mode_name = parts[1].strip('"')
mode_params = parts[2:]
subprocess.run(
["xrandr", "-display", DISPLAY, "--newmode", mode_name] + mode_params,
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
subprocess.run(
["xrandr", "-display", DISPLAY, "--addmode", output_name, mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
subprocess.run(
["xrandr", "-display", DISPLAY, "--output", output_name, "--mode", mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return True
except Exception:
return False
def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]: def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
safe_w, safe_h = _sanitize_resolution(width, height) safe_w, safe_h = _sanitize_resolution(width, height)
# Best effort: Xvfb usually exposes RandR and accepts xrandr -s. result = subprocess.run(
applied = False ["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
try: check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
result = subprocess.run( # noqa: S603 )
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"], if result.returncode != 0:
check=False, output_name = _xrandr_output_name()
stdout=subprocess.DEVNULL, if output_name:
stderr=subprocess.DEVNULL, _add_mode_via_cvt(safe_w, safe_h, output_name)
)
applied = result.returncode == 0
except Exception:
applied = False
if not applied:
# Fallback to default geometry if requested mode is unsupported.
try:
fallback_w, fallback_h = [int(x) for x in CHROME_WINDOW_SIZE.split(",", 1)]
except Exception:
fallback_w, fallback_h = 1920, 1080
safe_w, safe_h = _sanitize_resolution(fallback_w, fallback_h)
try:
subprocess.run( # noqa: S603
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
_state["resolution"] = f"{safe_w},{safe_h}" _state["resolution"] = f"{safe_w},{safe_h}"
return safe_w, safe_h return safe_w, safe_h
def open_web(url: str, width: int | None = None, height: int | None = None) -> None: def _url_with_credentials(url: str, login: str, password: str) -> str:
"""Embed login:password into URL so Chromium auto-handles Basic Auth."""
if not login and not password:
return url
parsed = urlparse(url)
netloc = parsed.hostname or ""
if parsed.port:
netloc += f":{parsed.port}"
user_info = quote(login, safe="") + ":" + quote(password, safe="")
return urlunparse(parsed._replace(netloc=f"{user_info}@{netloc}"))
def open_web(
url: str,
width: int | None = None,
height: int | None = None,
login: str = "",
password: str = "",
) -> None:
safe_w, safe_h = apply_resolution(width, height) safe_w, safe_h = apply_resolution(width, height)
profile_dir = _create_chrome_profile()
extension_dir = _create_autofill_extension(login, password)
url_with_creds = url # credentials in URL break SPA fetch; extension handles auth
# Use the real Chromium binary directly to avoid the Debian wrapper which
# injects an empty `--load-extension=` from /etc/chromium.d/extensions.
chromium_bin = "/usr/lib/chromium/chromium"
if not os.path.isfile(chromium_bin):
chromium_bin = "chromium"
cmd = [ cmd = [
"chromium", chromium_bin,
"--no-sandbox", "--no-sandbox",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-gpu",
@@ -123,9 +386,21 @@ def open_web(url: str, width: int | None = None, height: int | None = None) -> N
f"--window-size={safe_w},{safe_h}", f"--window-size={safe_w},{safe_h}",
"--no-first-run", "--no-first-run",
"--no-default-browser-check", "--no-default-browser-check",
url, "--enable-logging=stderr",
"--v=0",
"--lang=ru-RU",
"--accept-lang=ru-RU,ru",
"--password-store=basic",
f"--user-data-dir={profile_dir}",
] ]
if extension_dir:
cmd.append(f"--load-extension={extension_dir}")
cmd.append(f"--disable-extensions-except={extension_dir}")
cmd.append(url_with_creds)
_start_process(cmd, "web", url) _start_process(cmd, "web", url)
_state["profile_dir"] = profile_dir
_state["extension_dir"] = extension_dir
def open_rdp(payload: dict) -> None: def open_rdp(payload: dict) -> None:
@@ -203,8 +478,10 @@ class Handler(BaseHTTPRequestHandler):
return return
width = data.get("width") width = data.get("width")
height = data.get("height") height = data.get("height")
login = (data.get("login") or "").strip()
password = (data.get("password") or "").strip()
with _lock: with _lock:
open_web(url, width=width, height=height) open_web(url, width=width, height=height, login=login, password=password)
self._json( self._json(
200, 200,
{ {