99 Commits

Author SHA1 Message Date
GL.iNet-Yongping.Xie 6e85756350 fix: suppress notification emails during server restart
Skip device online/offline email notifications for 60s after startup
to prevent inbox flooding from reconnect storms. Mirrors the existing
device-log grace window. Remote access notifications are unaffected.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-08 02:41:23 -07:00
GL.iNet-Yongping.Xie c973b41a36 fix: enforce route permission guard and await user info on login
- Add beforeEach route guard to block access to permission-restricted pages
- Await fetchUserInfo() in login flow to ensure permissions load before navigation

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-08 01:25:52 -07:00
GL.iNet-Yongping.Xie e4d5e0baec docs: clean up README and remove verbose auth sections
- Remove detailed LDAP and OIDC configuration sections (refer to docker-compose README)
- Remove excessive emojis from section headings
- Simplify web proxy feature description

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-08 00:12:23 -07:00
GL.iNet-Yongping.Xie 5c7ff5531b refactor: replace hardcoded IP with env variable in Makefile debug target
- Rename debug-cn to debug-dev-server for generic naming
- Use DEBUG_DEV_SERVER_IP environment variable instead of hardcoded IP
- Derive host, path, and dir from IP and user variables

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-07 23:30:02 -07:00
GL.iNet-Yongping.Xie fbc269c63a fix: register Popconfirm component and adjust trusted device column width
- Add Popconfirm to ant-design-vue component registration to fix recipient removal
- Set device name column width to 200px for balanced table layout

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-07 23:22:56 -07:00
GL.iNet-Yongping.Xie 9459b4709c feat: add email notification for device events
- SMTP config with TLS/STARTTLS/plain support and test email
- Notification rules for device online, offline, and remote access
- Recipient management with add/remove endpoints
- Async email sending via goroutines to avoid blocking device runtime
- Admin settings page with SMTP, rules, and recipients sections
- Bell icon added to sidebar menu
- i18n support for all 7 languages

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-07 21:02:24 -07:00
GL.iNet-Yongping.Xie f5a8a6ddb6 feat: add multi-language support (ja, ko, de, fr, es)
- Add locale files for Japanese, Korean, German, French, and Spanish
- Extend language enum and options beyond gl-web-main's zh/en
- Register all locales in i18n and ant-design-vue config
- Auto-detect browser language for all supported locales
- Add language switcher to header after login

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-07 20:08:38 -07:00
GL.iNet-Yongping.Xie 53b95b71e6 feat: add device event logging with admin log viewer
- Record device online/offline, remote SSH, Web and Control sessions
- Add device_event_logs table with MAC, event type and time range indexes
- Distinguish remote_control vs remote_web by device client type
- Admin-only log page with device/access tabs, duration column and filters
- Permission-gated sidebar entry (device_log.read)

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-07 19:41:33 -07:00
GL.iNet-Yongping.Xie f49525ee9c feat: add personal center with TOTP 2FA and trusted devices
- Personal Center page with profile editing and last-login info
- TOTP-based two-factor authentication with QR code setup
- 30-day trusted-device cookie to skip 2FA on familiar browsers
- Friendly browser/OS labels for the trusted device list

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-04-06 21:17:44 -07:00
GL.iNet-Yongping.Xie 40a1d23ea2 feat: support self-hosted firmware WebUI interface
Add support for the self-hosted firmware WebUI interface.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-30 00:12:20 -07:00
GL.iNet-Yongping.Xie 80112c7978 fix: resolve incorrect conflicts when merging dev v2.4.0
Fix incorrect merge conflicts in the dev v2.4.0 branch.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-25 01:06:33 -07:00
GL.iNet-Yongping.Xie b943a18959 fix: test device group sorting and LDAP/OIDC user creation
Verify device group sorting.
Test automatic user creation for LDAP and OIDC login.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-25 01:03:56 -07:00
pengyu.lu ea37a171e9 Merge branch 'main' into dev-ui-0129 2026-03-25 11:45:00 +08:00
pengyu.lu 689793d47f feat: Some UI experiences have been optimized 2026-03-25 11:44:22 +08:00
GL.iNet-Yongping.Xie 55854afba8 feat: add device group sorting and auto-create LDAP/OIDC users
- support device group sorting
- auto-create local users for LDAP and OIDC login

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-24 02:00:09 -07:00
iclannad c8c67d5f0e Merge pull request #34 from PiexlPuck/Docker-compose-file-fixes
chore: remove unused service and update env template
2026-03-18 19:30:49 +08:00
Brayden 66566e74a8 chore: remove unused service and update env template
- Commented out the deprecated configuration line in `docker-compose/.env.example`
- Removed the associated service from Docker Compose files as it is no longer required
2026-03-18 21:28:00 +11:00
pengyu.lu 30a132cb4e Merge branch 'dev-ui-0129' 2026-03-10 11:56:20 +08:00
pengyu.lu 52117f044b feat: Optimize the logic for storing data on the front end 2026-03-10 11:56:03 +08:00
pengyu.lu 884f537ae8 Merge branch 'dev-ui-0129' 2026-03-10 10:46:19 +08:00
pengyu.lu 807bab3e2a fix: Fix the issue where the version number is displayed with double quotes 2026-03-10 10:45:56 +08:00
GL.iNet-Yongping.Xie a1823096cc Merge branch 'feature/devicetable-order' 2026-03-09 19:22:38 -07:00
GL.iNet-Yongping.Xie b1aa8e3cae feat: bump version to v2.3.0
- Update project version to v2.3.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-09 19:20:30 -07:00
pengyu.lu 3d75a87140 Merge branch 'dev-ui-0129' 2026-03-10 10:17:54 +08:00
pengyu.lu d237847f0d feat: Some UI issues have been optimized to enhance user experience 2026-03-10 10:17:26 +08:00
GL.iNet-Yongping.Xie 59021535b7 feat: add sorting support for the device list
Implemented multi-column sorting (ascending and descending) for the device list, supporting ID, IP, MAC, connectedTime, description, and DDNS.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-08 19:37:01 -07:00
GL.iNet-Yongping.Xie 133f344713 Merge branch 'bugfix/v2.1.0' 2026-02-27 18:28:24 -08:00
GL.iNet-Yongping.Xie 7fd9c39ffb fix: resolve issues in v2.1.0
Fix reported bugs and stability issues in v2.1.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-27 18:27:43 -08:00
pengyu.lu 96936ecdd8 Merge branch 'dev-ui-0129' 2026-02-28 10:10:40 +08:00
pengyu.lu a44c9a4833 fix: Fix some UI issues 2026-02-28 10:10:11 +08:00
GL.iNet-Yongping.Xie e6da02f711 feat: allow customizing system admin username
Add support to change the default system admin login name via config file.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-25 18:23:43 -08:00
GL.iNet-Yongping.Xie 75a0416221 feat: bump glkvm cloud to v2.0.0
Release glkvm cloud version v2.0.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-23 23:22:57 -08:00
GL.iNet-Yongping.Xie dc15e534b8 feat: bump glkvm cloud to v2.0.0
Release glkvm cloud version v2.0.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-23 23:08:41 -08:00
GL.iNet-Yongping.Xie ea96caef90 fix: remove test server IP
Remove the test server IP from the Makefile.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-23 18:48:16 -08:00
GL.iNet-Yongping.Xie 13dca8b190 Merge remote-tracking branch 'origin/dev-ui-0129' 2026-02-23 18:14:45 -08:00
GL.iNet-Yongping.Xie 3810f9e72c fix: resolve branch merge conflicts
Resolve merge conflicts between branches.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-23 18:12:38 -08:00
pengyu.lu a8414dd025 fix: Optimize the script logic for adding devices 2026-02-12 10:11:43 +08:00
GL.iNet-Yongping.Xie cea6b19f9a fix: prevent duplicate installation on Linux/macOS clients
Fix an issue where the Linux and macOS clients could be installed multiple times.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-11 06:36:10 -08:00
GL.iNet-Yongping.Xie ba07f6386b fix: correct device migration script logic
Fix the script logic for migrating devices between platforms.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-09 19:55:10 -08:00
GL.iNet-Yongping.Xie 206b0fa68b fix: correct OIDC login flow and admin defaults
Fix the OIDC authentication flow.
Adjust default system admin logic for LDAP and OIDC.
Update LICENSE validity period.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-09 19:31:27 -08:00
GL.iNet-Yongping.Xie e3857e9f61 Merge remote-tracking branch 'origin/dev-ui-0129' into feature/user-device-group 2026-02-09 17:53:24 -08:00
pengyu.lu f3c32b186c fix: Fixed some ui issues 2026-02-10 09:50:01 +08:00
GL.iNet-Yongping.Xie 769b298707 feat: add Linux, macOS, and Windows clients support
GLKVM Light Cloud now supports managing clients on Linux, macOS, and Windows.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-09 02:02:44 -08:00
pengyu.lu 3a503d0e77 feat: Add functions related to device permissions and user permissions 2026-02-06 18:07:17 +08:00
GL.iNet-Yongping.Xie 929e9526d9 fix: reset device status on startup and optimize sorting
Reset all devices to offline on startup and optimize device list sorting.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-06 00:23:40 -08:00
GL.iNet-Yongping.Xie 82a8037244 fix: kick active sessions on user deletion
Terminate active sessions when deleting a user.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-05 23:22:33 -08:00
GL.iNet-Yongping.Xie 9c56f78493 fix: optimize user list logic
Improve the user listing logic.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-05 23:03:12 -08:00
GL.iNet-Yongping.Xie a23912fd89 fix: optimize user deletion logic
Refine user deletion logic to avoid inconsistent states.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-05 20:29:07 -08:00
GL.iNet-Yongping.Xie dff4d06107 fix: sync with frontend and fix bugs
- Align API behavior with frontend integration
- Fix issues found during joint debugging

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-05 18:22:29 -08:00
GL.iNet-Yongping.Xie 359f29484a fix: resolve merge conflicts
- Resolve conflicts introduced during branch merge

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-03 19:16:38 -08:00
GL.iNet-Yongping.Xie f1fb5b52fb fix: remove unused control permission
- Remove obsolete control permission that is no longer needed

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-03 18:59:25 -08:00
GL.iNet-Yongping.Xie c1b987e6b3 fix: remove redundant query parameters from redirect URL
Remove unnecessary and ineffective query parameters when redirecting to the remote web interface.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-03 18:33:16 -08:00
GL.iNet-Yongping.Xie 1320b58cbb fix: resolve frontend integration issues and remove unused APIs
- Fix bugs found during frontend integration
- Clean up redundant and unused endpoints

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-03 18:14:51 -08:00
GL.iNet-Yongping.Xie a1ebef3326 feat: add groupId filtering to device list
Add support for filtering the device list by groupId.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-01 18:43:46 -08:00
GL.iNet-Yongping.Xie fdba252fa8 fix: switch Docker Compose network mode to bridge
Change Docker Compose from host network mode to bridge mode.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-29 19:33:17 -08:00
GL.iNet-Yongping.Xie 9faeac6ff9 fix: remove redundant query parameters from redirect URL
Remove unnecessary and ineffective query parameters when redirecting to the remote web interface.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-29 19:21:11 -08:00
GL.iNet-Yongping.Xie 3e8d898a67 fix: return created device group ID in create API
Update the device group creation API to return the newly created device group ID.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-29 02:37:25 -08:00
GL.iNet-Yongping.Xie b2a9c6c7dd fix: remove and adjust legacy APIs
Remove obsolete interfaces and adjust remaining legacy APIs for consistency.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-28 20:21:49 -08:00
GL.iNet-Yongping.Xie 9c4592e697 fix: migrate legacy APIs to new project structure
Move deprecated interfaces into the updated project layout to align with the new architecture.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-28 19:41:38 -08:00
GL.iNet-Yongping.Xie f783fa14fe fix: resolve issues found during API self-testing
Fix bugs identified during API self-testing and validation.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-27 01:23:44 -08:00
GL.iNet-Yongping.Xie 6a01778bec feat: introduce device and user group management APIs
Add API endpoints to manage devices, device groups, users, and user groups.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-27 00:27:01 -08:00
GL.iNet-Yongping.Xie 3b5360478d git commit -m "fix: adjust Makefile for local debugging
Update the Makefile to improve convenience and usability for local development and debugging.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>"
2026-01-22 11:09:21 +08:00
GL.iNet-Yongping.Xie 39dc768b9c fix: clean up unused files and update Makefile
Remove obsolete files and adjust the Makefile to simplify the project structure.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-21 18:48:42 -08:00
GL.iNet-Yongping.Xie 606377fbf8 fix: remove unused legacy main entry
Remove the obsolete legacy main entry to avoid confusion and reduce maintenance overhead.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-21 02:33:41 -08:00
GL.iNet-Yongping.Xie b331a7c7f3 fix: remove unused legacy main entry
Remove the obsolete legacy main entry to avoid confusion and reduce maintenance overhead.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-21 02:28:05 -08:00
GL.iNet-Yongping.Xie 2b6a6eff77 fix: remove unused legacy main entry
Remove the obsolete legacy main entry to avoid confusion and reduce maintenance overhead.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-21 02:16:29 -08:00
GL.iNet-Yongping.Xie d93e5953ad fix: refactor rtty project structure
Clean up and reorganize the project layout to improve readability and maintainability.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-21 02:04:36 -08:00
GL.iNet-Yongping.Xie 0d130ebc29 fix: remove command-line config parsing logic
Remove redundant and unnecessary configuration parsing from command-line arguments to simplify startup logic.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-21 00:34:36 -08:00
GL.iNet-Yongping.Xie 2d2658383b fix: clarify handler naming to avoid role confusion
Rename handlers to remove the misleading 'admin' term and better reflect their actual responsibilities, avoiding confusion with admin roles.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-19 18:03:04 -08:00
GL.iNet-Yongping.Xie 7931fa0efd fix: refactor legacy login endpoint
Refactor legacy login endpoint and keep compatibility with existing authentication logic.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-19 02:15:07 -08:00
GL.iNet-Yongping.Xie ab77f53a77 feat: add user, group, and device relationship management APIs
Implement editing APIs for user-to-user-group, user-to-device, and device-group-to-device relationships, enabling flexible management of access and resource associations.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-15 18:11:13 -08:00
GL.iNet-Yongping.Xie a5bce3b289 feat: implement resource permission framework
Complete the initial framework for resource-based permission management, providing the foundation for role-based access control and future permission extensions.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-15 01:29:02 -08:00
GL.iNet-Yongping.Xie 6c357f2842 feat: add domain-based access restrictions for security
- Add support for restricting access to the platform Web UI by allowed domain
- Validate that the device access domain matches the target device ID
- Redirect requests with mismatched or invalid domains to an invalid access page

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-07 23:10:27 -08:00
GL.iNet-Yongping.Xie 8703f91ebf feat: support configurable device access domain in proxy mode
Allow device remote access to use a different root domain from the Web UI when
running behind a reverse proxy.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-06 23:13:39 -08:00
GL.iNet-Yongping.Xie 1c473458cf Merge remote-tracking branch 'origin/dev-ui-0105' into feature/version 2026-01-05 18:41:05 -08:00
GL.iNet-Yongping.Xie c6c09dbae1 feat: show version in GUI
Display the current application version in the GUI.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-05 18:40:03 -08:00
GL.iNet-Yongping.Xie 9258aa5f43 feat: show version in GUI
Display the current application version in the GUI.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-05 18:36:16 -08:00
pengyu.lu 8994cccb25 feat: Add the display of version numbers 2026-01-05 14:36:34 +08:00
GL.iNet-Yongping.Xie 60994b9513 fix: update documentation formatting
Update documentation formatting.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-29 00:22:03 -08:00
GL.iNet-Yongping.Xie c99b96ca01 Merge branch 'arm64' 2025-12-29 00:06:08 -08:00
GL.iNet-Yongping.Xie 27792291ed feat: add arm64 platform support
Add support for the arm64 platform and update the documentation
to reflect arm64-specific installation and configuration steps.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-29 00:05:49 -08:00
GL.iNet-Yongping.Xie 4adb10f577 fix: adjust reverse proxy port detection logic
Refine how the front-end proxy port is detected when running
behind Nginx to ensure correct redirect URL generation.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-25 20:15:52 -08:00
GL.iNet-Yongping.Xie cd231e996b fix: clarify reverse proxy mode configuration
Add brief comments explaining the reverse proxy mode switch.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-24 19:12:07 -08:00
GL.iNet-Yongping.Xie 739aa235b3 feat: update reverse proxy mode documentation
Update and clarify the documentation for reverse proxy mode, including usage guidelines and deployment considerations when running behind front-end proxies.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-22 01:46:51 -08:00
GL.iNet-Yongping.Xie 329468bf61 feat: add reverse proxy mode support
Introduce a reverse proxy mode to better integrate with front-end proxies such as Nginx.
In this mode, both port-based access to GLKVM Cloud and direct web access to device UIs are supported simultaneously, improving deployment flexibility behind proxies.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-22 01:22:16 -08:00
GL.iNet-Yongping.Xie f955f4f46f fix: remove unused files
Remove unused files to keep the codebase clean and maintainable.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-10 00:02:17 -08:00
GL.iNet-Yongping.Xie f34e7f3195 fix: prevent nil pointer dereference
Fix a nil pointer dereference issue to avoid unexpected crashes.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-10 00:00:18 -08:00
GL.iNet-Yongping.Xie afd4b19981 fix: prevent nil pointer dereference
Fix a nil pointer dereference issue to avoid unexpected crashes.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:59:41 -08:00
GL.iNet-Yongping.Xie f76e587ed2 Merge branch 'dev' 2025-12-09 23:38:57 -08:00
GL.iNet-Yongping.Xie 5f8d8f50ca fix: preserve device description on reconnect
Fix an issue where the device description was unintentionally reset
when a device came online and updated its metadata.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:32:04 -08:00
GL.iNet-Yongping.Xie 96aa0be17d fix: preserve device description on reconnect
Fix an issue where the device description was unintentionally reset
when a device came online and updated its metadata.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:25:53 -08:00
GL.iNet-Yongping.Xie f665ecf432 fix: preserve SQLite data across container restarts
Ensure the SQLite database file is not dropped or recreated on startup,
so existing data is retained when the container restarts.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:09:29 -08:00
GL.iNet-Yongping.Xie 4991433901 feat: prioritize online devices in device list
Add sorting logic to list online devices before offline ones,
while keeping alphabetical order by device ID within each group.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 22:50:39 -08:00
pengyu.lu 7ed0e263d1 feat: Add the function of deleting devices 2025-12-10 14:24:14 +08:00
GL.iNet-Yongping.Xie 38b35ed80f feat: add device deletion endpoint
Add a new API endpoint to delete device metadata by device_id.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 20:24:32 -08:00
pengyu.lu 61172cc9c4 feat: Optimize the UI and add the function of editing descriptions 2025-12-10 11:43:37 +08:00
GL.iNet-Yongping.Xie 0d1d2b0adf feat: add device online/offline status management
1. Add support for device online and offline status detection based on in-memory connections.
2. Enhance the device list to support updating and displaying device descriptions for easier management.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 02:24:13 -08:00
GL.iNet-Yongping.Xie c2318c1291 fix: update Chinese README documentation
Improve the Chinese version of the installation guide by updating the Docker-based installation instructions to align with the latest deployment workflow and configuration format.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-03 17:57:27 -08:00
GL.iNet-Yongping.Xie 0e5af6fa62 fix: update OIDC login documentation
1. Updated the OIDC login documentation to provide clearer and more
   accurate instructions for configuration and usage.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-23 17:47:28 -08:00
214 changed files with 21819 additions and 5203 deletions
+2
View File
@@ -12,3 +12,5 @@
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/
.vscode
+9
View File
@@ -0,0 +1,9 @@
{
"cSpell.words": [
"ddns",
"glkvm",
"repassword",
"sortablejs",
"webrtc"
]
}
+4 -1
View File
@@ -1,4 +1,7 @@
FROM alpine:latest
WORKDIR /home
COPY ./rttys /usr/bin/rttys
ARG TARGETARCH
COPY ./dist/rttys-linux-${TARGETARCH} /usr/bin/rttys
ENTRYPOINT ["/usr/bin/rttys"]
+1 -1
View File
@@ -9,7 +9,7 @@ Additional Use Grant: You may use the Licensed Work free of charge for
non-production purposes, including development,
testing, personal, or academic use.
Change Date: 2029-01-01
Change Date: 2030-01-01
Change License: GNU General Public License, version 3 (GPLv3)
+73 -28
View File
@@ -1,45 +1,90 @@
# Makefile
# Go binary name
BINARY_NAME = rttys
# ---------------- Project ----------------
BINARY_NAME ?= rttys
UI_DIR ?= ui
GO_MAIN ?= ./cmd/glkvm-cloud
# Go build flags
BUILD_FLAGS := -ldflags "-s -w"
BUILD_FLAGS ?= -ldflags "-s -w"
DIST_DIR ?= dist
# Go build command
GO_BUILD_CMD = go build $(BUILD_FLAGS) -o $(BINARY_NAME)
# Image name
IMAGE_NAME ?= glkvm-cloud
IMAGE_TAG ?= build
# Paths
UI_DIR = ui
CONF_FILE = ./rttys.conf
GOARCH ?= $(shell go env GOARCH)
.PHONY: all ui build run build-run full-run
# ---------------- Commands ----------------
.PHONY: all ui debug-local debug-dev-server \
build-linux-amd64 build-linux-arm64 build-linux-all \
docker-buildx docker-buildx-full
all: build-linux-amd64 build-linux-arm64
# Build frontend files only
ui:
cd $(UI_DIR) && npm install && npm run build
# Build Go binary only
build:
CGO_ENABLED=0 $(GO_BUILD_CMD)
# ---------------- Cross compile (Linux) ----------------
# Produce: dist/rttys-linux-amd64 , dist/rttys-linux-arm64
build-linux-amd64:
@mkdir -p $(DIST_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-amd64 $(GO_MAIN)
# Run Go program only
run:
./$(BINARY_NAME) -c $(CONF_FILE)
build-linux-arm64:
@mkdir -p $(DIST_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-arm64 $(GO_MAIN)
# Build frontend and Go binary
build-all: ui build
# ---------------- Docker Buildx ----------------
# Multi-arch build
# Usage:
# make docker-buildx GOARCH=amd64 IMAGE_TAG=build-amd64
# make docker-buildx GOARCH=arm64 IMAGE_TAG=build-arm64
REGISTRY ?=
# Build Go binary and run
build-run: build run
# If REGISTRY is set, tag becomes: REGISTRY/IMAGE_NAME:IMAGE_TAG
ifdef REGISTRY
IMAGE_REF := $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
else
IMAGE_REF := $(IMAGE_NAME):$(IMAGE_TAG)
endif
# Build frontend, build Go binary, and run
full-run: ui build run
docker-buildx:
@docker buildx version >/dev/null 2>&1 || (echo "docker buildx not available" && exit 1)
@echo "==> buildx (load local image): $(IMAGE_REF) [linux/$(GOARCH)]"
docker buildx build \
--platform linux/$(GOARCH) \
-t $(IMAGE_REF) \
--load .
# Build Docker image without updating ui
docker-build: build
docker build -t glkvm-cloud:build .
docker-buildx-full: ui
@$(MAKE) docker-buildx
# Full Build Docker image
docker-fullbuild: ui build
docker build -t glkvm-cloud:build .
DEBUG_HOST ?= root@xxxxxxxxxx
DEBUG_PATH ?= /root/glkvmcloudbuild.tar
# Local debug bundle (amd64 image + save tar), upload to debug host, then load
debug-local: build-linux-amd64 docker-buildx
docker save $(IMAGE_NAME):$(IMAGE_TAG) -o glkvmcloudbuild.tar
ssh $(DEBUG_HOST) "rm -f $(DEBUG_PATH)"
scp glkvmcloudbuild.tar $(DEBUG_HOST):$(DEBUG_PATH)
ssh $(DEBUG_HOST) "docker load < $(DEBUG_PATH)"
ssh $(DEBUG_HOST) "cd /root/glkvm_cloud && docker-compose down && docker-compose up -d"
# ---------------- Dev server debug ----------------
# Set DEBUG_DEV_SERVER_IP via environment variable, e.g.:
# export DEBUG_DEV_SERVER_IP=1.2.3.4
# make debug-dev-server
DEBUG_DEV_SERVER_IP ?= $(error DEBUG_DEV_SERVER_IP is not set)
DEBUG_DEV_SERVER_USER ?= ubuntu
DEBUG_DEV_SERVER_HOST = $(DEBUG_DEV_SERVER_USER)@$(DEBUG_DEV_SERVER_IP)
DEBUG_DEV_SERVER_PATH ?= /home/$(DEBUG_DEV_SERVER_USER)/glkvmcloudbuild.tar
DEBUG_DEV_SERVER_DIR ?= /home/$(DEBUG_DEV_SERVER_USER)/glkvm_cloud
debug-dev-server: build-linux-amd64 docker-buildx
docker save $(IMAGE_NAME):$(IMAGE_TAG) -o glkvmcloudbuild.tar
ssh $(DEBUG_DEV_SERVER_HOST) "rm -f $(DEBUG_DEV_SERVER_PATH)"
scp glkvmcloudbuild.tar $(DEBUG_DEV_SERVER_HOST):$(DEBUG_DEV_SERVER_PATH)
ssh $(DEBUG_DEV_SERVER_HOST) "sudo docker load < $(DEBUG_DEV_SERVER_PATH)"
ssh $(DEBUG_DEV_SERVER_HOST) "cd $(DEBUG_DEV_SERVER_DIR) && sudo docker-compose down && sudo docker-compose up -d"
+30 -60
View File
@@ -6,7 +6,7 @@ Self-Deployed Lightweight Cloud is a lightweight KVM remote cloud platform tailo
#### Main Functions and Features
- **Device Management** - Online device list monitoring
- **User Groups and Device Support** - Supports user groups managing specific device groups, enabling different users to manage different devices
- **Script Deployment** - Convenient script-based device addition
- **Remote SSH** - Web SSH remote connections
- **Remote Control** - Web remote desktop control
@@ -17,6 +17,9 @@ Self-Deployed Lightweight Cloud is a lightweight KVM remote cloud platform tailo
- **Lightweight Design** - Optimized for small businesses and individual users
- **Enterprise Authentication** - Supports both **LDAP** and **OIDC** login methods for enterprise users.
- **Deployment & Platform Compatibility** - Supports both **internal network** and **public internet** deployments on **x86_64** and **arm64** platforms
- **HTTP/HTTPS Web Proxy Support** - Supports onboarding embedded devices such as OpenWrt, Raspberry Pi, and other Linux-based hosts into self-hosted GLKVM Cloud for centralized management and NAT traversal access
## Self-Hosting Guide
The following mainstream operating systems have been tested and verified
@@ -42,7 +45,7 @@ The following mainstream operating systems have been tested and verified
| Network Bandwidth | ≥ 3 Mbps |
| KVM Device Firmware | ≥ v1.5.0 |
#### 🔐 Cloud Security Group Settings
#### Cloud Security Group Settings
If your server provider uses a **cloud security group** (e.g., AWS, Aliyun, etc.), please make sure the following ports are **open**:
@@ -57,13 +60,15 @@ If your server provider uses a **cloud security group** (e.g., AWS, Aliyun, etc.
These ports will be **used by GLKVM Cloud**. Please ensure **no other applications or services** on your server are binding to these ports, otherwise the lightweight cloud platform may fail to start properly.
------
### 📦 Installation
### Installation
We provide **two** ways to install GLKVM Cloud:
#### A) One-line installer (recommended)
#### A) One-line installer (recommended, x86_64/amd64)
> **Note:** The one-line installer is **Docker-based**. It automates Docker/Compose setup, pulls images, renders configs from templates, and starts services for you.
>
> **Platform:** currently supports **x86_64 (amd64)** only.
Run **as root**:
@@ -74,24 +79,27 @@ Run **as root**:
#### B) Docker manual install
> Full reference: see [`docker-compose/README.md`](https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/README.md)
>
> **Platform:** supports both **x86_64 (amd64)** and **arm64 (AArch64)**.
### 🌐 Platform Access
### Platform Access
Once the installation is complete, access the platform via:
Once the installation is complete, the installer will print the platform URL and admin login credentials in the console. You can access the platform via:
```
https://<your_server_public_ip>
```
⚠️ **Note**: Accessing via IP address will trigger a **browser certificate warning**.
To eliminate the warning, it's recommended to configure a **custom domain** with a valid SSL certificate.
⚠️ **Note**: Accessing via an IP address will trigger a **browser certificate warning**.
To remove the warning, configure your own domain and a valid SSL certificate.
### 🔑 Web UI Login Password
### Web UI Login Credentials
The default login password for the Web UI will be displayed in the installation script output:
At the end of the installation script, the console will display the Web UI administrator username and password (for example):
```
🔐 Please check the installation console for your web login password.
```text
👤 Admin username: admin
🔑 Admin password: <auto-generated-password>
```
![](img/password.png)
@@ -121,6 +129,10 @@ The default login password for the Web UI will be displayed in the installation
![Remote Control Screenshot](img/web.png)
### Web Proxy
![Web Proxy Screenshot](img/httpproxy.png)
## Use your own SSL Certificate (Optional)
@@ -132,7 +144,7 @@ you can **skip** configuring a custom domain and SSL certificate, and still acce
For production use, or if you need to **access multiple KVM devices via subdomains**, it is **strongly recommended** to configure your own **wildcard SSL certificate** (see below).
#### 🌐 Add DNS Records
#### Add DNS Records
To enable full domain-based access, configure the following DNS records for your domain:
@@ -145,7 +157,7 @@ To enable full domain-based access, configure the following DNS records for your
└────────────┴──────┴────────────────────┴─────────────────────────────┘
```
#### 🔧 Using a Custom SSL Certificate
#### Using a Custom SSL Certificate
To avoid browser warnings, replace the default certificates with your own **wildcard SSL certificate**
that supports both:
@@ -164,51 +176,9 @@ Replace the following files in:
⚠️ **Make sure the filenames remain unchanged.**
#### 🔐 LDAP Authentication Configuration (Optional)
#### Restart Services After Configuration Changes
GLKVM Cloud supports LDAP authentication for enterprise environments, allowing you to integrate with existing directory services like Active Directory, OpenLDAP, or FreeIPA.
**Key Features:**
- **Dual Authentication Mode**: Support both LDAP and traditional password authentication simultaneously
- **Group-based Authorization**: Restrict access to specific LDAP groups
- **User-based Authorization**: Allow access for specific users only
- **TLS/SSL Support**: Secure LDAP connections with encryption
- **Multiple LDAP Systems**: Compatible with Active Directory, OpenLDAP, FreeIPA, and generic LDAP servers
**Configuration:**
For detailed LDAP configuration options and setup instructions, see the [Docker Compose README](docker-compose/README.md).
**Note**: When LDAP is enabled, users can choose between:
- **LDAP Authentication**: Enter username and password for directory service authentication
- **Legacy Authentication**: Leave username empty and use the web management password
#### 🔐 OIDC Authentication Configuration (Optional)
GLKVM Cloud provides full support for **OIDC (OpenID Connect)** authentication, allowing seamless integration with modern identity providers such as **Google, Auth0, Authing** and any other standard-compliant OIDC provider.
**Key Features**
- **Modern Authentication**
Secure sign-in through any OIDC provider supporting Authorization Code Flow.
- **Email / Username / Group Whitelisting**
Restrict access based on:
- Email or domain (e.g. *@example.com*)
- Stable user ID (*sub*)
- Username (*preferred_username* or *name*)
- Groups attribute
- **Full OpenID Connect Compliance**
Supports issuer validation, token signature verification, and nonce protection.
- **Flexible Provider Support**
Works with public clouds (Google, Azure AD, Auth0, Okta) and self-hosted solutions.
**Configuration**
For detailed OIDC configuration options and setup instructions, see the
**[Docker Compose README](docker-compose/README.md)**.
#### 🔄 Restart Services After Configuration Changes
After replacing certificates or updating LDAP configuration, restart the GLKVM Cloud services to apply the changes:
After replacing certificates, restart the GLKVM Cloud services to apply the changes:
```bash
cd ~/glkvm_cloud
@@ -221,10 +191,10 @@ Or, on systems with the Docker CLI plugin:
docker compose down && docker compose up -d
```
### Domain-Based Access Example
### Domain-Based Access Example
Once everything is configured, you can access the platform via your domain:
```
https://www.your-domain.com
```
```
+38 -53
View File
@@ -7,7 +7,7 @@
#### 主要功能与特性
* **设备管理** - 实时查看设备在线状态
* **用户组和设备支持** - 支持用户组管理特定的设备组设备,实现不同用户管理不同设备
* **脚本部署** - 通过脚本快速添加设备
* **远程 SSH** - Web SSH 远程连接
* **远程控制** - Web远程桌面控制
@@ -18,6 +18,9 @@
* **轻量设计** - 专为小型企业和个人优化
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
- **部署与平台兼容性** - 同时支持 **内网部署** 和 **公网部署**,并兼容 **x86_64** 与 **arm64** 平台
- **HTTP/HTTPS Web 代理支持** - 支持 OpenWrt、树莓派等嵌入式设备及 Linux 主机接入自部署 GLKVM Cloud,实现统一管理与内网穿透访问
## 自部署指南
以下主流操作系统已通过测试验证:
@@ -43,7 +46,7 @@
| 网络带宽 | ≥ 3 Mbps |
| KVM 固件版本 | ≥ v1.5.0 |
#### 🔐 云安全组端口要求
#### 云安全组端口要求
如果你的服务器提供商(如 AWS、阿里云等)启用了 **云安全组**,请确保以下端口已开放:
@@ -57,7 +60,15 @@
⚠️ **重要提示**:
这些端口将被 **GLKVM 轻量云** 占用,请确保服务器上没有其他程序占用这些端口,否则平台可能无法正常启动。
## 📦 安装
## 安装
我们提供 **两种** 安装 GLKVM Cloud 的方式:
#### A) 一键安装脚本(推荐,仅支持 x86_64 / amd64)
> **注意:** 一键安装脚本基于 **Docker**。它会自动完成 Docker / Docker Compose 的安装、拉取镜像、根据模板渲染配置文件,并启动所有服务。
>
> **平台支持:** 当前仅支持 **x86_64(amd64)** 平台。
使用 **root 权限** 运行以下命令安装 GLKVM 轻量云:
@@ -65,23 +76,31 @@
( command -v curl >/dev/null 2>&1 && curl -fsSL https://kvm-cloud.gl-inet.com/selfhost/install.sh || wget -qO- https://kvm-cloud.gl-inet.com/selfhost/install.sh ) | sudo bash
```
### 🌐 平台访问
#### B) 使用 Docker 手动安装
安装完成后,你可以通过以下方式访问平台:
> 完整参考文档请查看:[`docker-compose/README-CN.md`](https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/README-CN.md)
>
> 平台支持: 同时支持 x86_64(amd64) 与 arm64(AArch64) 平台。
### 平台访问
安装完成后,安装脚本会在控制台输出平台访问地址和管理员登录信息。你可以通过以下方式访问平台:
```
https://<你的服务器公网IP>
```
⚠️ **提示**:通过 IP 访问时,浏览器会提示 **证书不受信任**。
如果想消除该提示,建议配置 **自定义域名 + 有效 SSL 证书**。
如需消除该提示,建议配置 **自定义域名 + 有效 SSL 证书**。
### 🔑 Web UI 登录密码
### Web UI 登录信息
Web UI 的默认登录密码会在安装脚本运行结束时显示:
安装脚本运行结束后,安装控制台会显示 Web UI 管理员用户名和密码(示例):
```
🔐 请在安装控制台查看 Web 登录密码
```text
👤 管理员用户名:admin
🔑 管理员密码:<自动生成密码>
```
![](img/password.png)
@@ -110,6 +129,10 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
![远程桌面截图](img/web.png)
#### Web代理功能
![Web代理功能截图](img/httpproxy.png)
## 使用自有 SSL 证书(可选)
⚠️ **可选配置**:
@@ -119,7 +142,7 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
但在 **生产环境**,或需要通过 **子域名同时访问多台 KVM 设备** 的情况下,
强烈建议配置 **通配符 SSL 证书**(见下文)。
#### 🌐 添加 DNS 记录
#### 添加 DNS 记录
如果需要完整的域名访问,请在域名解析中添加以下记录:
@@ -134,7 +157,7 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
---
#### 🔧 使用自定义 SSL 证书
#### 使用自定义 SSL 证书
如果要消除浏览器证书警告,请使用支持以下域名的 **通配符 SSL 证书**:
@@ -150,47 +173,9 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
* `glkvm.cer`
* `glkvm.key`
#### 🔐 LDAP 身份认证配置(可选)
#### 配置更改后重启服务
GLKVM 轻量云支持 LDAP 身份认证,适用于企业环境,可以与现有的目录服务(如 Active Directory、OpenLDAP 或 FreeIPA)集成。
**主要功能:**
- **双重认证模式**:同时支持 LDAP 和传统密码认证
- **基于组的授权**:限制特定 LDAP 组访问
- **基于用户的授权**:仅允许特定用户访问
- **TLS/SSL 支持**:加密 LDAP 连接
- **多 LDAP 系统支持**:兼容 Active Directory、OpenLDAP、FreeIPA 和通用 LDAP 服务器
**配置方法:**
详细的 LDAP 配置选项和设置说明,请参见 [Docker Compose README](docker-compose/README.md)。
**注意**:启用 LDAP 后,用户可以选择:
- **LDAP 认证**:输入用户名和密码进行目录服务认证
- **传统认证**:留空用户名并使用 Web 管理密码
#### 🔐 OIDC 登录认证配置(可选)
GLKVM Cloud 完整支持 **OIDC(OpenID Connect)** 登录认证,可无缝集成现代身份提供商,例如 **Google、Auth0、Authing**,以及任何符合 OIDC 标准的认证服务。
**主要功能**
- **现代化认证方式**
支持使用任意支持 Authorization Code Flow 的 OIDC 身份提供商进行安全登录。
- **邮箱 / 用户名 / 用户组白名单控制**
可根据以下信息限制用户访问:
- 邮箱或域名(如 *@example.com*)
- 用户 ID(*sub*)
- 用户名(*preferred_username* 或 *name*)
- 用户组
- **完全符合 OpenID Connect 标准**
支持 Issuer 校验、ID Token 签名验证、Nonce 防重放保护等安全机制。
- **高度灵活的提供商支持**
兼容各类公共云 IdP(Google、Azure AD、Auth0、Okta 等)以及自建身份服务(Keycloak、Authentik、Dex 等)。
#### 🔄 配置更改后重启服务
替换证书或更新 LDAP 配置后,需要重启 GLKVM 轻量云服务以应用更改:
替换证书后,需要重启 GLKVM 轻量云服务以应用更改:
```bash
cd ~/glkvm_cloud
@@ -203,7 +188,7 @@ docker-compose down && docker-compose up -d
docker compose down && docker compose up -d
```
### 🌍 域名访问示例
### 域名访问示例
配置完成后,你可以通过以下方式访问平台:
-451
View File
@@ -1,451 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package main
import (
"crypto/tls"
"embed"
"io/fs"
"net"
"net/http"
"path"
"strings"
"time"
"rttys/utils"
"github.com/fanjindong/go-cache"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
var httpSessions = cache.NewMemCache(cache.WithClearInterval(time.Minute))
const httpSessionExpire = 30 * time.Minute
//go:embed all:ui/dist
var staticFs embed.FS
func (srv *RttyServer) ListenAPI() error {
cfg := &srv.cfg
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Next()
log.Debug().Msgf("%s - \"%s %s %s %d\"", c.ClientIP(),
c.Request.Method, c.Request.URL.Path, c.Request.Proto, c.Writer.Status())
})
if cfg.AllowOrigins {
log.Debug().Msg("Allow all origins")
r.Use(cors.Default())
}
authorized := r.Group("/", func(c *gin.Context) {
if !cfg.LocalAuth && isLocalRequest(c) {
return
}
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
})
authorized.GET("/connect/:devid", func(c *gin.Context) {
if !callUserHookUrl(cfg, c) {
c.Status(http.StatusForbidden)
return
}
if c.GetHeader("Upgrade") != "websocket" {
group := c.Query("group")
devid := c.Param("devid")
if dev := srv.GetDevice(group, devid); dev == nil {
c.Redirect(http.StatusFound, "/error/offline")
return
}
url := "/rtty/" + devid
if group != "" {
url += "?group=" + group
}
c.Redirect(http.StatusFound, url)
} else {
handleUserConnection(srv, c)
}
})
authorized.GET("/counts", func(c *gin.Context) {
count := 0
srv.groups.Range(func(key, value any) bool {
count += int(value.(*DeviceGroup).count.Load())
return true
})
c.JSON(http.StatusOK, gin.H{"count": count})
})
authorized.GET("/groups", func(c *gin.Context) {
groups := []string{""}
srv.groups.Range(func(key, value any) bool {
if key != "" {
groups = append(groups, key.(string))
}
return true
})
c.JSON(http.StatusOK, groups)
})
authorized.GET("/devs", func(c *gin.Context) {
devs := make([]*DeviceInfo, 0)
g := srv.GetGroup(c.Query("group"), false)
if g == nil {
c.JSON(http.StatusOK, devs)
return
}
g.devices.Range(func(key, value any) bool {
dev := value.(*Device)
devs = append(devs, &DeviceInfo{
Group: dev.group,
ID: dev.id,
Desc: dev.desc,
Connected: uint32(time.Now().Unix() - dev.timestamp),
Uptime: dev.uptime,
Proto: dev.proto,
IPaddr: dev.conn.RemoteAddr().(*net.TCPAddr).IP.String(),
})
return true
})
c.JSON(http.StatusOK, devs)
})
authorized.GET("/dev/:devid", func(c *gin.Context) {
if dev := srv.GetDevice(c.Query("group"), c.Param("devid")); dev != nil {
info := &DeviceInfo{
ID: dev.id,
Desc: dev.desc,
Connected: uint32(time.Now().Unix() - dev.timestamp),
Uptime: dev.uptime,
Proto: dev.proto,
IPaddr: dev.conn.RemoteAddr().(*net.TCPAddr).IP.String(),
}
c.JSON(http.StatusOK, info)
} else {
c.Status(http.StatusNotFound)
}
})
authorized.POST("/cmd/:devid", func(c *gin.Context) {
if !callUserHookUrl(cfg, c) {
c.Status(http.StatusForbidden)
return
}
cmdInfo := &CommandReqInfo{}
err := c.BindJSON(&cmdInfo)
if err != nil || cmdInfo.Cmd == "" || cmdInfo.Username == "" {
cmdErrResp(c, rttyCmdErrInvalid)
return
}
dev := srv.GetDevice(c.Query("group"), c.Param("devid"))
if dev == nil {
cmdErrResp(c, rttyCmdErrOffline)
return
}
dev.handleCmdReq(c, cmdInfo)
})
authorized.Any("/web/:devid/:proto/:addr/*path", func(c *gin.Context) {
httpProxyRedirect(srv, c, "")
})
authorized.Any("/web2/:group/:devid/:proto/:addr/*path", func(c *gin.Context) {
group := c.Param("group")
httpProxyRedirect(srv, c, group)
})
authorized.GET("/signout", func(c *gin.Context) {
sid, err := c.Cookie("sid")
if err != nil || !httpSessions.Exists(sid) {
return
}
httpSessions.Del(sid)
c.Status(http.StatusOK)
})
r.POST("/signin", func(c *gin.Context) {
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod"`
}
creds := credentials{}
err := c.BindJSON(&creds)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
// 自动确定认证方法或使用指定的方法 (Auto-determine auth method or use specified method)
authMethod := creds.AuthMethod
if authMethod == "" {
// 基于是否提供用户名进行自动检测 (Auto-detect based on whether username is provided)
if creds.Username != "" && cfg.LdapEnabled {
authMethod = "ldap"
} else {
authMethod = "legacy"
}
}
success, errorType := AuthenticateUserWithError(cfg, creds.Username, creds.Password, authMethod)
if success {
sid := utils.GenUniqueID()
httpSessions.Set(sid, true, cache.WithEx(httpSessionExpire))
c.SetCookie("sid", sid, 0, "", "", false, true)
c.Status(http.StatusOK)
return
}
// 根据错误类型返回适当的错误信息 (Return appropriate error message based on error type)
if errorType == "authorization" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authorized"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
}
})
r.GET("/auth-config", func(c *gin.Context) {
authConfig := gin.H{
"ldapEnabled": cfg.LdapEnabled,
"legacyPassword": cfg.Password != "",
"oidcEnabled": cfg.OIDCEnabled,
}
c.JSON(http.StatusOK, authConfig)
})
r.GET("/alive", func(c *gin.Context) {
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.Status(http.StatusOK)
}
})
// ===== 添加OIDC路由 =====
RegisterOIDCRoutes(r, cfg)
fs, err := fs.Sub(staticFs, "ui/dist")
if err != nil {
return err
}
root := http.FS(fs)
fh := http.FileServer(root)
r.NoRoute(func(c *gin.Context) {
upath := path.Clean(c.Request.URL.Path)
if strings.HasSuffix(upath, ".js") || strings.HasSuffix(upath, ".css") {
if strings.Contains(c.Request.Header.Get("Accept-Encoding"), "gzip") {
f, err := root.Open(upath + ".gz")
if err == nil {
f.Close()
c.Request.URL.Path += ".gz"
if strings.HasSuffix(upath, ".js") {
c.Writer.Header().Set("Content-Type", "application/javascript")
} else if strings.HasSuffix(upath, ".css") {
c.Writer.Header().Set("Content-Type", "text/css")
}
c.Writer.Header().Set("Content-Encoding", "gzip")
}
}
} else if upath != "/" {
f, err := root.Open(upath)
if err != nil {
c.Request.URL.Path = "/"
r.HandleContext(c)
return
}
defer f.Close()
}
fh.ServeHTTP(c.Writer, c.Request)
})
r.GET("/get/scriptInfo", func(c *gin.Context) {
// Get domain info
host := c.Request.Host
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // Use host directly if no port
}
// Choose value by priority:
// 1) If request host is a domain (not an IP), keep it.
// 2) Else if it's an IP and cfg.WebrtcIP is set, use cfg.WebrtcIP.
// 3) Else keep the request IP.
chosen := hostname
if isIP(hostname) && cfg.WebrtcIP != "" {
chosen = cfg.WebrtcIP
}
c.JSON(http.StatusOK, gin.H{
"hostname": chosen, // reuse the same chosen value
"port": cfg.AddrDev,
"token": cfg.Token,
"webrtcIP": chosen, // same as hostname
"webrtcPort": cfg.WebrtcPort,
"webrtcUsername": cfg.WebrtcUsername,
"webrtcPassword": cfg.WebrtcPassword,
})
})
ln, err := net.Listen("tcp", cfg.AddrUser)
if err != nil {
return err
}
defer ln.Close()
if cfg.SslCert != "" && cfg.SslKey != "" {
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
if err != nil {
log.Fatal().Msg(err.Error())
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{crt}}
ln = tls.NewListener(ln, tlsConfig)
}
log.Info().Msgf("Listen users on: %s", ln.Addr().(*net.TCPAddr))
return r.RunListener(ln)
}
func isIP(addr string) bool {
return net.ParseIP(addr) != nil
}
func callUserHookUrl(cfg *Config, c *gin.Context) bool {
if cfg.UserHookUrl == "" {
return true
}
upath := c.Request.URL.RawPath
// Create HTTP request with original headers
req, err := http.NewRequest("GET", cfg.UserHookUrl, nil)
if err != nil {
log.Error().Err(err).Msgf("create hook request for \"%s\" fail", upath)
return false
}
// Copy all headers from original request
for key, values := range c.Request.Header {
lowerKey := strings.ToLower(key)
if lowerKey == "upgrade" || lowerKey == "connection" || lowerKey == "accept-encoding" {
continue
}
for _, value := range values {
req.Header.Add(key, value)
}
}
// Add custom headers for hook identification
req.Header.Set("X-Rttys-Hook", "true")
req.Header.Set("X-Original-Method", c.Request.Method)
req.Header.Set("X-Original-URL", c.Request.URL.String())
cli := &http.Client{
Timeout: 3 * time.Second,
}
resp, err := cli.Do(req)
if err != nil {
log.Error().Err(err).Msgf("call user hook url for \"%s\" fail", upath)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Error().Msgf("call user hook url for \"%s\", StatusCode: %d", upath, resp.StatusCode)
return false
}
return true
}
func httpLogin(cfg *Config, password string) bool {
return cfg.Password == password
}
func isLocalRequest(c *gin.Context) bool {
addr, _ := net.ResolveTCPAddr("tcp", c.Request.RemoteAddr)
return addr.IP.IsLoopback()
}
func httpAuth(cfg *Config, c *gin.Context) bool {
if !cfg.LocalAuth && isLocalRequest(c) {
return true
}
if cfg.Password == "" {
return true
}
sid, err := c.Cookie("sid")
if err != nil || !httpSessions.Exists(sid) {
return false
}
httpSessions.Expire(sid, httpSessionExpire)
return true
}
-88
View File
@@ -1,88 +0,0 @@
#!/bin/bash
# RTTYS Ubuntu Package Build Script
set -e
# Configuration
PACKAGE_NAME="rttys"
VERSION=$(grep 'const RttysVersion' main.go | cut -d'"' -f2 | sed 's/^v//')
MAINTAINER="Jianhui Zhao <zhaojh329@gmail.com>"
DESCRIPTION="Access your device's terminal from anywhere via the web"
URL="https://github.com/zhaojh329/rttys"
GitCommit=$(git log --pretty=format:"%h" -1)
BuildTime=$(date +%FT%T%z)
ARCH="$1" # Pass architecture as an argument, e.g., amd64 or arm64
[ -z "$ARCH" ] && {
echo "Usage: $0 <arch>";
echo "Example: $0 amd64"
exit 1;
}
# Build directory
BUILD_DIR="build-deb"
INSTALL_DIR="$BUILD_DIR/usr"
echo "Building RTTYS v$VERSION for Ubuntu..."
# Clean and create build directory
rm -rf $BUILD_DIR
mkdir -p $BUILD_DIR/{usr/bin,etc/rttys,lib/systemd/system}
# Build the binary
echo "Building binary..."
CGO_ENABLED=0 GOOS=linux GOARCH=$ARCH go build -ldflags "-s -w -X main.GitCommit=$GitCommit -X main.BuildTime=$BuildTime" -o $INSTALL_DIR/bin/rttys .
# Copy configuration files
echo "Copying configuration files..."
cp rttys.conf $BUILD_DIR/etc/rttys/
cp rttys.service $BUILD_DIR/lib/systemd/system/
# Create postinstall script
cat > $BUILD_DIR/postinstall.sh << 'EOF'
#!/bin/bash
# Enable and start the service
systemctl daemon-reload
systemctl enable rttys
echo "RTTYS installed successfully!"
echo "Configuration file: /etc/rttys/rttys.conf"
echo "Start service: sudo systemctl start rttys"
echo "View logs: sudo journalctl -u rttys -f"
EOF
# Create preremove script
cat > $BUILD_DIR/preremove.sh << 'EOF'
#!/bin/bash
# Stop and disable the service
systemctl stop rttys || true
systemctl disable rttys || true
EOF
# Create postremove script
cat > $BUILD_DIR/postremove.sh << 'EOF'
#!/bin/bash
EOF
# Build the package
echo "Creating .deb package..."
fpm -s dir -t deb \
--name "$PACKAGE_NAME" \
--version "$VERSION" \
--maintainer "$MAINTAINER" \
--description "$DESCRIPTION" \
--url "$URL" \
--architecture "$ARCH" \
--depends "systemd" \
--deb-no-default-config-files \
--config-files "/etc/rttys/rttys.conf" \
--after-install "$BUILD_DIR/postinstall.sh" \
--before-remove "$BUILD_DIR/preremove.sh" \
--after-remove "$BUILD_DIR/postremove.sh" \
-C "$BUILD_DIR" \
.
echo "Package created: ${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
echo "Install with: sudo dpkg -i ${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
-37
View File
@@ -1,37 +0,0 @@
#!/bin/sh
VERSION=$(grep 'const RttysVersion' main.go | cut -d'"' -f2 | sed 's/^v//')
GitCommit=$(git log --pretty=format:"%h" -1)
BuildTime=$(date +%FT%T%z)
[ $# -lt 2 ] && {
echo "Usage: $0 linux amd64"
exit 1
}
generate() {
local os="$1"
local arch="$2"
local dir="rttys-$VERSION-$os-$arch"
local bin="rttys"
rm -rf $dir
mkdir $dir
cp rttys.conf $dir
[ "$os" = "windows" ] && {
bin="rttys.exe"
}
GOOS=$os GOARCH=$arch CGO_ENABLED=0 go build -ldflags="-s -w -X main.GitCommit=$GitCommit -X main.BuildTime=$BuildTime" -o $dir/$bin && cp rttys.service $dir
[ -n "$COMPRESS" ] && {
tar -jcvf $dir.tar.bz2 $dir
rm -rf $dir
}
exit 0
}
generate $1 $2
+14
View File
@@ -0,0 +1,14 @@
package main
import (
_ "net/http/pprof"
"rttys/internal/server"
"github.com/rs/zerolog/log"
)
func main() {
if err := server.RunFromEnv(); err != nil {
log.Fatal().Msg(err.Error())
}
}
-612
View File
@@ -1,612 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package main
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"rttys/utils"
"github.com/gorilla/websocket"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
)
type DeviceInfo struct {
Group string `json:"group"`
ID string `json:"id"`
Connected uint32 `json:"connected"`
Uptime uint32 `json:"uptime"`
Desc string `json:"description"`
Proto uint8 `json:"proto"`
IPaddr string `json:"ipaddr"`
}
type Device struct {
group string
id string
proto uint8
desc string
timestamp int64
uptime uint32
token string
heartbeat time.Duration
users sync.Map
pending sync.Map
commands sync.Map
https sync.Map
conn net.Conn
br *bufio.Reader
readBuf []byte
close sync.Once
ctx context.Context
cancel context.CancelFunc
}
const (
msgTypeRegister = byte(iota)
msgTypeLogin
msgTypeLogout
msgTypeTermData
msgTypeWinsize
msgTypeCmd
msgTypeHeartbeat
msgTypeFile
msgTypeHttp
msgTypeAck
)
const (
msgTypeFileSend = byte(iota)
msgTypeFileRecv
msgTypeFileInfo
msgTypeFileData
msgTypeFileAck
msgTypeFileAbort
)
const (
msgRegAttrHeartbeat = iota
msgRegAttrDevid
msgRegAttrDescription
msgRegAttrToken
msgRegAttrGroup
)
const (
msgHeartbeatAttrUptime = iota
)
const (
devRegErrUnsupportedProto = iota + 1
devRegErrInvalidToken
devRegErrHookFailed
devRegErrIdConflicting
)
const (
RttyProtoRequired uint8 = 3
WaitRegistTimeout = 5 * time.Second
DefaultHeartbeat = 5 * time.Second
TermLoginTimeout = 5 * time.Second
CommandTimeout = 30
)
var DevRegErrMsg = map[byte]string{
0: "Success",
devRegErrUnsupportedProto: "Unsupported protocol",
devRegErrInvalidToken: "Invalid token",
devRegErrHookFailed: "Hook failed",
devRegErrIdConflicting: "ID conflict",
}
var DeviceMsgHandlers = map[byte]func(*Device, []byte) error{
msgTypeHeartbeat: handleHeartbeatMsg,
msgTypeLogin: handleLoginMsg,
msgTypeLogout: handleLogoutMsg,
msgTypeTermData: handleTermDataMsg,
msgTypeFile: handleFileMsg,
msgTypeCmd: handleCmdMsg,
msgTypeHttp: handleHttpMsg,
}
func (srv *RttyServer) ListenDevices() {
cfg := &srv.cfg
ln, err := net.Listen("tcp", cfg.AddrDev)
if err != nil {
log.Fatal().Msg(err.Error())
}
defer ln.Close()
if cfg.SslCert != "" && cfg.SslKey != "" {
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
if err != nil {
log.Fatal().Msg(err.Error())
}
tlsConfig := &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
// 忽略 SNI,始终返回唯一证书
return &crt, nil
},
}
ln = tls.NewListener(ln, tlsConfig)
}
log.Info().Msgf("Listen devices on: %s", ln.Addr().(*net.TCPAddr))
for {
conn, err := ln.Accept()
if err != nil {
log.Error().Msg(err.Error())
continue
}
go handleDeviceConnection(srv, conn)
}
}
func handleDeviceConnection(srv *RttyServer, conn net.Conn) {
defer logPanic()
dev := &Device{
conn: conn,
heartbeat: DefaultHeartbeat,
timestamp: time.Now().Unix(),
br: bufio.NewReader(conn),
}
defer dev.Close(srv)
dev.ctx, dev.cancel = context.WithCancel(context.Background())
log.Debug().Msgf("new device '%s' connected", conn.RemoteAddr())
conn.SetReadDeadline(time.Now().Add(WaitRegistTimeout))
typ, data, err := dev.ReadMsg()
if err != nil {
log.Error().Msgf("read register msg fail: %v", err)
return
}
if typ != msgTypeRegister {
log.Error().Msg("register msg expected first")
return
}
if !dev.ParseRegister(data) {
log.Error().Msg("invalid device info")
return
}
code := dev.Register(srv)
err = dev.WriteMsg(msgTypeRegister, "", append([]byte{code}, DevRegErrMsg[code]...))
if err != nil {
log.Printf("send register to device '%s' fail: %v", dev.id, err)
return
}
if code != 0 {
return
}
log.Info().Msgf("device '%s' registered, group '%s' proto %d, heartbeat %v",
dev.id, dev.group, dev.proto, dev.heartbeat)
for {
conn.SetReadDeadline(time.Now().Add(dev.heartbeat * 3 / 2))
typ, data, err = dev.ReadMsg()
if err != nil {
if err != io.EOF {
log.Error().Msgf("read msg from device '%s' fail: %v", dev.id, err)
}
return
}
log.Debug().Msgf("device msg %s from device %s", msgTypeName(typ), dev.id)
handler, ok := DeviceMsgHandlers[typ]
if !ok {
log.Error().Msgf("unexpected message '%s' from device '%s'", msgTypeName(typ), dev.id)
return
}
err = handler(dev, data)
if err != nil {
log.Error().Msg(err.Error())
return
}
}
}
func msgTypeName(typ byte) string {
switch typ {
case msgTypeRegister:
return "register"
case msgTypeLogin:
return "login"
case msgTypeLogout:
return "logout"
case msgTypeTermData:
return "termdata"
case msgTypeWinsize:
return "winsize"
case msgTypeCmd:
return "cmd"
case msgTypeHeartbeat:
return "heartbeat"
case msgTypeFile:
return "file"
case msgTypeHttp:
return "http"
case msgTypeAck:
return "ack"
default:
return fmt.Sprintf("unknown(%d)", typ)
}
}
func (dev *Device) ReadMsg() (byte, []byte, error) {
head := make([]byte, 3)
br := dev.br
_, err := io.ReadFull(br, head)
if err != nil {
return 0, nil, err
}
typ := head[0]
msgLen := binary.BigEndian.Uint16(head[1:])
if cap(dev.readBuf) < int(msgLen) {
dev.readBuf = make([]byte, msgLen)
} else {
dev.readBuf = dev.readBuf[:msgLen]
}
_, err = io.ReadFull(br, dev.readBuf)
if err != nil {
return 0, nil, err
}
return typ, dev.readBuf, nil
}
func (dev *Device) WriteMsg(typ byte, sid string, data []byte) error {
bb := bytebufferpool.Get()
defer bytebufferpool.Put(bb)
b := []byte{typ, 0, 0}
binary.BigEndian.PutUint16(b[1:], uint16(len(sid)+len(data)))
bb.Write(b)
bb.WriteString(sid)
bb.Write(data)
_, err := bb.WriteTo(dev.conn)
return err
}
func (dev *Device) WriteFileMsg(typ byte, sid string, fileType byte, data []byte) error {
bb := bytebufferpool.Get()
defer bytebufferpool.Put(bb)
bb.WriteByte(fileType)
bb.Write(data)
return dev.WriteMsg(typ, sid, bb.Bytes())
}
func (dev *Device) Close(srv *RttyServer) {
dev.close.Do(func() {
log.Error().Msgf("device '%s' disconnected", dev.id)
srv.DelDevice(dev)
dev.cancel()
dev.conn.Close()
})
}
func (dev *Device) ParseRegister(b []byte) bool {
if len(b) < 1 {
return false
}
dev.proto = b[0]
if dev.proto > 4 {
attrs := utils.ParseTLV(b[1:])
if attrs == nil {
return false
}
for typ, val := range attrs {
switch typ {
case msgRegAttrHeartbeat:
dev.heartbeat = time.Duration(val[0]) * time.Second
case msgRegAttrDevid:
dev.id = string(val)
case msgRegAttrDescription:
dev.desc = string(val)
case msgRegAttrToken:
dev.token = string(val)
case msgRegAttrGroup:
dev.group = string(val)
}
}
return true
}
b = b[1:]
fields := bytes.Split(b, []byte{0})
if len(fields) < 3 {
return false
}
dev.id = string(fields[0])
dev.desc = string(fields[1])
dev.token = string(fields[2])
return true
}
func (dev *Device) Register(srv *RttyServer) byte {
cfg := &srv.cfg
if dev.proto < RttyProtoRequired {
log.Error().Msgf("minimum proto required %d, found %d for device '%s'", RttyProtoRequired, dev.proto, dev.id)
return devRegErrHookFailed
}
log.Info().Msgf("cfg.Token:%s,dev.token:%s", cfg.Token, dev.token)
if cfg.Token != "" && dev.token != cfg.Token {
log.Error().Msgf("invalid token for device '%s'", dev.id)
return devRegErrInvalidToken
}
devHookUrl := cfg.DevHookUrl
if devHookUrl != "" {
cli := &http.Client{
Timeout: 3 * time.Second,
}
data := fmt.Sprintf(`{"group":"%s", "devid":"%s", "token":"%s"}`, dev.group, dev.id, dev.token)
resp, err := cli.Post(devHookUrl, "application/json", strings.NewReader(data))
if err != nil {
log.Error().Msgf("call device hook url fail for device %s: %v", dev.id, err)
return devRegErrHookFailed
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Error().Msgf("call device hook url for device '%s', StatusCode: %d", dev.id, resp.StatusCode)
return devRegErrHookFailed
}
}
if !srv.AddDevice(dev) {
return devRegErrIdConflicting
}
return 0
}
func handleHeartbeatMsg(dev *Device, data []byte) error {
if !parseHeartbeat(dev, data) {
return fmt.Errorf("invalid heartbeat msg from device '%s'", dev.id)
}
return dev.WriteMsg(msgTypeHeartbeat, "", nil)
}
func parseHeartbeat(dev *Device, data []byte) bool {
if dev.proto > 4 {
attrs := utils.ParseTLV(data)
if attrs == nil {
return false
}
for typ, val := range attrs {
switch typ {
case msgHeartbeatAttrUptime:
dev.uptime = binary.BigEndian.Uint32(val)
}
}
} else {
if len(data) < 4 {
return false
}
dev.uptime = binary.BigEndian.Uint32(data[:4])
}
return true
}
func handleLogoutMsg(dev *Device, data []byte) error {
if len(data) < 32 {
return fmt.Errorf("invalid logout msg from device '%s'", dev.id)
}
sid := string(data[:32])
if val, loaded := dev.users.LoadAndDelete(sid); loaded {
user := val.(*User)
user.Close()
}
return nil
}
func handleLoginMsg(dev *Device, data []byte) error {
if len(data) < 33 {
return fmt.Errorf("invalid login msg from device '%s'", dev.id)
}
sid := string(data[:32])
code := data[32]
if val, loaded := dev.pending.LoadAndDelete(sid); loaded {
user := val.(*User)
ok := code == 0
errCode := 0
if ok {
log.Debug().Msgf("login session '%s' for device '%s' success", sid, dev.id)
dev.users.Store(sid, user)
} else {
errCode = LoginErrorBusy
log.Error().Msgf("login session '%s' for device '%s' fail, due to device busy", sid, dev.id)
}
if errCode == 0 {
user.WriteMsg(websocket.TextMessage, []byte(fmt.Appendf(nil, `{"type":"login"}`)))
} else {
user.SendCloseMsg(LoginErrorBusy, "device busy")
}
user.pending <- ok
}
return nil
}
func handleTermDataMsg(dev *Device, data []byte) error {
if len(data) < 32 {
return fmt.Errorf("invalid term data msg from device '%s'", dev.id)
}
sid := string(data[:32])
if val, ok := dev.users.Load(sid); ok {
user := val.(*User)
data[31] = 0
user.WriteMsg(websocket.BinaryMessage, data[31:])
}
return nil
}
func handleFileMsg(dev *Device, data []byte) error {
if len(data) < 33 {
return fmt.Errorf("invalid file msg from device '%s'", dev.id)
}
sid := string(data[:32])
typ := data[32]
if val, ok := dev.users.Load(sid); ok {
user := val.(*User)
switch typ {
case msgTypeFileSend:
user.WriteMsg(websocket.TextMessage,
fmt.Appendf(nil, `{"type":"sendfile", "name": "%s"}`, string(data[33:])))
case msgTypeFileRecv:
user.WriteMsg(websocket.TextMessage, []byte(`{"type":"recvfile"}`))
case msgTypeFileData:
data[32] = 1
user.WriteMsg(websocket.BinaryMessage, data[32:])
case msgTypeFileAck:
user.WriteMsg(websocket.TextMessage, []byte(`{"type":"fileAck"}`))
case msgTypeFileAbort:
user.WriteMsg(websocket.BinaryMessage, []byte{1})
}
}
return nil
}
func handleHttpMsg(dev *Device, data []byte) error {
if len(data) < 18 {
return fmt.Errorf("invalid http msg from device '%s'", dev.id)
}
addr := data[:18]
data = data[18:]
if c, ok := dev.https.Load(string(addr)); ok {
c := c.(net.Conn)
if len(data) == 0 {
c.Close()
} else {
c.Write(data)
}
}
return nil
}
func handleCmdMsg(dev *Device, data []byte) error {
info := &CommandRespInfo{}
err := jsoniter.Unmarshal(data, info)
if err != nil {
return fmt.Errorf("parse command resp info error: %v", err)
}
var attrs map[string]any
err = jsoniter.Unmarshal(info.Attrs, &attrs)
if err != nil {
return fmt.Errorf("parse command resp attrs error: %v", err)
}
attrs["devid"] = dev.id
if val, ok := dev.commands.Load(info.Token); ok {
req := val.(*CommandReq)
req.acked = true
req.c.JSON(http.StatusOK, attrs)
req.cancel()
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
# Images
GLKVM_IMAGE=glzhitong/glkvm-cloud:latest-arm64
COTURN_IMAGE=coturn/coturn:edge-alpine-arm64v8
# Enable reverse proxy mode (e.g. Nginx in front of GLKVM Cloud).
# When enabled, TLS is handled by the proxy and GLKVM Cloud runs in plain HTTP.
#
# Note:
# In reverse-proxy mode, remote device access depends on the correct forwarded headers
# from the front-end proxy. If these headers are missing or incorrect, GLKVM Cloud may
# generate redirect URLs with the internal port (e.g. :10443).
#
# Please make sure your Nginx config includes:
# proxy_set_header Host $host;
# proxy_set_header X-Forwarded-Host $host;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-Port $server_port;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#
# Reference (verified working example):
# https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/nginx-reverse-proxy-example.conf
REVERSE_PROXY_ENABLED=false
# =====================================================
# Selfhost WebUI URL (Optional)
# =====================================================
# The full URL (including scheme) of the self-hosted cloud WebUI.
# This URL is written to the KVM device as /etc/kvmd/user/selfhost-cloud.json
# so firmware can read it and create a navigation link in the device's web page.
#
# If left empty, the URL is automatically derived from the browser's current
# address when copying the installation script.
#
# Examples:
# SELFHOST_WEBUI_URL=https://kvm.example.com
# SELFHOST_WEBUI_URL=https://192.168.1.100
SELFHOST_WEBUI_URL=
# =====================================================
# Device Remote Access Domain (Reverse Proxy Mode Only)
# =====================================================
# This option is used to generate the Remote Control URL for devices when
# running behind a reverse proxy.
#
# Effective ONLY when:
# REVERSE_PROXY_ENABLED=true
#
# When set, GLKVM Cloud will generate device access addresses as:
# https://<deviceId>.<DEVICE_ENDPOINT_HOST>/... (scheme is taken from X-Forwarded-Proto)
#
# Examples:
# DEVICE_ENDPOINT_HOST=kvm.example.com
# DEVICE_ENDPOINT_HOST=kvm.example.com:443
#
# Notes:
# - Do NOT include scheme (http:// or https://)
# - Do NOT include path (/xxx)
#
# Leave empty to derive the host/port from X-Forwarded-* headers (auto-detect).
DEVICE_ENDPOINT_HOST=
# =====================================================
# Platform Access Domain Restriction
# =====================================================
# Restrict the domain used to access the GLKVM Cloud platform.
#
# When set, only requests with a matching domain are allowed to access
# the Web UI and API. Requests using other domains will be rejected
# as invalid access.
#
# Examples:
# WEB_UI_HOST=www.example.com
#
# Notes:
# - Do NOT include scheme (http:// or https://)
# - Do NOT include path (/xxx)
# - Leave empty to disable domain restriction (allow access via any domain)
WEB_UI_HOST=
# GLKVM access IP seen by devices/users.
# Leave empty to auto-detect at container start.
GLKVM_ACCESS_IP=
# rttys
RTTYS_TOKEN=DeviceTokenYouCanChangeMe
RTTYS_PASS=StrongP@ssw0rd
# Admin username (leave empty to default to "admin")
# Only letters and digits are allowed (e.g. admin, Admin01). No spaces or special characters.
RTTYS_ADMIN_NAME=
RTTYS_DEVICE_PORT=5912
RTTYS_WEBUI_PORT=443
RTTYS_HTTP_PROXY_PORT=10443
# TURN
TURN_PORT=3478
TURN_USER=glkvmcloudwebrtcuser
TURN_PASS=AnotherS3cret
# LDAP Authentication (Optional)
LDAP_ENABLED=false
LDAP_SERVER=your-ldap-server.com
LDAP_PORT=389
LDAP_USE_TLS=false
LDAP_BIND_DN=cn=service-account,ou=users,dc=company,dc=com
LDAP_BIND_PASSWORD=service-password
LDAP_BASE_DN=ou=users,dc=company,dc=com
# User filter examples for different LDAP implementations:
# Active Directory: (&(objectClass=person)(sAMAccountName=%s))
# OpenLDAP: (&(objectClass=inetOrgPerson)(uid=%s))
# FreeIPA: (&(objectClass=person)(uid=%s))
# Generic LDAP: (uid=%s)
LDAP_USER_FILTER=(uid=%s)
LDAP_ALLOWED_GROUPS=admins,operators
LDAP_ALLOWED_USERS=user1,user2
# LDAP admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
LDAP_ADMIN_GROUP=
# LDAP admin users: these usernames are directly assigned the "admin" role.
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
LDAP_ADMIN_USERS=
# OIDC Authentication (Optional, generic OIDC provider)
OIDC_ENABLED=false
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URL=
OIDC_TOKEN_URL=
# Redirect URL registered in your OIDC provider.
# The path part (/auth/oidc/callback) is fixed by GLKVM Cloud and must not be changed.
# Example:
# OIDC_REDIRECT_URL=https://your-domain.example.com/auth/oidc/callback
OIDC_REDIRECT_URL=
OIDC_SCOPES="openid profile email"
# Email-based whitelist (exact email or domain like @example.com)
OIDC_ALLOWED_USERS=
# Subject (sub) whitelist (stable user IDs)
OIDC_ALLOWED_SUBS=
# Username whitelist (preferred_username or name)
OIDC_ALLOWED_USERNAMES=
# Groups whitelist (e.g. admin, devops)
OIDC_ALLOWED_GROUPS=
# OIDC admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
OIDC_ADMIN_GROUP=
# OIDC admin users: these users are directly assigned the "admin" role.
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
OIDC_ADMIN_USERS=
+94
View File
@@ -2,6 +2,83 @@
GLKVM_IMAGE=glzhitong/glkvm-cloud:latest
COTURN_IMAGE=coturn/coturn:edge-alpine
# Enable reverse proxy mode (e.g. Nginx in front of GLKVM Cloud).
# When enabled, TLS is handled by the proxy and GLKVM Cloud runs in plain HTTP.
#
# Note:
# In reverse-proxy mode, remote device access depends on the correct forwarded headers
# from the front-end proxy. If these headers are missing or incorrect, GLKVM Cloud may
# generate redirect URLs with the internal port (e.g. :10443).
#
# Please make sure your Nginx config includes:
# proxy_set_header Host $host;
# proxy_set_header X-Forwarded-Host $host;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-Port $server_port;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#
# Reference (verified working example):
# https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/nginx-reverse-proxy-example.conf
REVERSE_PROXY_ENABLED=false
# =====================================================
# Selfhost WebUI URL (Optional)
# =====================================================
# The full URL (including scheme) of the self-hosted cloud WebUI.
# This URL is written to the KVM device as /etc/kvmd/user/selfhost-cloud.json
# so firmware can read it and create a navigation link in the device's web page.
#
# If left empty, the URL is automatically derived from the browser's current
# address when copying the installation script.
#
# Examples:
# SELFHOST_WEBUI_URL=https://kvm.example.com
# SELFHOST_WEBUI_URL=https://192.168.1.100
SELFHOST_WEBUI_URL=
# =====================================================
# Device Remote Access Domain (Reverse Proxy Mode Only)
# =====================================================
# This option is used to generate the Remote Control URL for devices when
# running behind a reverse proxy.
#
# Effective ONLY when:
# REVERSE_PROXY_ENABLED=true
#
# When set, GLKVM Cloud will generate device access addresses as:
# https://<deviceId>.<DEVICE_ENDPOINT_HOST>/... (scheme is taken from X-Forwarded-Proto)
#
# Examples:
# DEVICE_ENDPOINT_HOST=kvm.example.com
# DEVICE_ENDPOINT_HOST=kvm.example.com:443
#
# Notes:
# - Do NOT include scheme (http:// or https://)
# - Do NOT include path (/xxx)
#
# Leave empty to derive the host/port from X-Forwarded-* headers (auto-detect).
DEVICE_ENDPOINT_HOST=
# =====================================================
# Platform Access Domain Restriction
# =====================================================
# Restrict the domain used to access the GLKVM Cloud platform.
#
# When set, only requests with a matching domain are allowed to access
# the Web UI and API. Requests using other domains will be rejected
# as invalid access.
#
# Examples:
# WEB_UI_HOST=www.example.com
#
# Notes:
# - Do NOT include scheme (http:// or https://)
# - Do NOT include path (/xxx)
# - Leave empty to disable domain restriction (allow access via any domain)
WEB_UI_HOST=
# GLKVM access IP seen by devices/users.
# Leave empty to auto-detect at container start.
GLKVM_ACCESS_IP=
@@ -9,6 +86,9 @@ GLKVM_ACCESS_IP=
# rttys
RTTYS_TOKEN=DeviceTokenYouCanChangeMe
RTTYS_PASS=StrongP@ssw0rd
# Admin username (leave empty to default to "admin")
# Only letters and digits are allowed (e.g. admin, Admin01). No spaces or special characters.
RTTYS_ADMIN_NAME=
RTTYS_DEVICE_PORT=5912
RTTYS_WEBUI_PORT=443
RTTYS_HTTP_PROXY_PORT=10443
@@ -37,6 +117,13 @@ LDAP_USER_FILTER=(uid=%s)
LDAP_ALLOWED_GROUPS=admins,operators
LDAP_ALLOWED_USERS=user1,user2
# LDAP admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
LDAP_ADMIN_GROUP=
# LDAP admin users: these usernames are directly assigned the "admin" role.
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
LDAP_ADMIN_USERS=
# OIDC Authentication (Optional, generic OIDC provider)
OIDC_ENABLED=false
OIDC_ISSUER=
@@ -61,3 +148,10 @@ OIDC_ALLOWED_SUBS=
OIDC_ALLOWED_USERNAMES=
# Groups whitelist (e.g. admin, devops)
OIDC_ALLOWED_GROUPS=
# OIDC admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
OIDC_ADMIN_GROUP=
# OIDC admin users: these users are directly assigned the "admin" role.
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
OIDC_ADMIN_USERS=
+142
View File
@@ -0,0 +1,142 @@
# 快速开始(Quick Start)
本指南展示如何使用提供的 Docker Compose 环境模板部署 **glkvm-cloud**。
### 1. **克隆仓库并准备环境模板**
```bash
git clone https://github.com/gl-inet/glkvm-cloud.git
cd glkvm-cloud/docker-compose/
```
* **x86_64(amd64)平台**:
```bash
cp .env.example .env
```
* **arm64(AArch64)平台**:
```bash
cp .env.arm64.example .env
```
### 2. **配置环境变量**
编辑 `.env` 文件,并根据需求更新关键参数:
- `RTTYS_TOKEN`:设备连接令牌(留空则使用默认值)
- `RTTYS_PASS`:Web 管理密码(留空则使用默认值 **StrongP@ssw0rd**)
- `TURN_USER` / `TURN_PASS`:coturn 鉴权凭据(留空则使用默认值)
- `GLKVM_ACCESS_IP`:GLKVM Cloud 访问地址(留空则启动时自动检测)
#### **LDAP 认证(可选)**
- `LDAP_ENABLED`:设为 `true` 启用 LDAP(默认 `false`)
- `LDAP_SERVER`:LDAP 服务器域名或 IP
- `LDAP_PORT`:端口(默认 `389`,TLS 使用 `636`)
- `LDAP_USE_TLS`:设为 `true` 启用 TLS 加密(默认 `false`)
- `LDAP_BIND_DN`:服务账号 DN
- `LDAP_BIND_PASSWORD`:服务账号密码
- `LDAP_BASE_DN`:用户查询的 Base DN
- `LDAP_USER_FILTER`:用户查询过滤器(默认 `(uid=%s)`)
- `LDAP_ALLOWED_GROUPS`:允许访问的群组列表(可选)
- `LDAP_ALLOWED_USERS`:允许访问的用户列表(可选)
⚠️ **注意:所有配置均需在 `.env` 中完成,不需要修改 `docker-compose.yml`、模板或脚本。**
#### **OIDC 认证(可选)**
- `OIDC_ENABLED`:设为 `true` 启用 OIDC(默认 `false`)
- `OIDC_ISSUER`:OIDC Issuer 地址
示例:`https://accounts.google.com`、`https://your-tenant.auth0.com/`
- `OIDC_CLIENT_ID`:OIDC 客户端 ID
- `OIDC_CLIENT_SECRET`:OIDC 客户端密钥
- `OIDC_AUTH_URL`:授权端点 URL
- `OIDC_TOKEN_URL`:令牌端点 URL
- `OIDC_REDIRECT_URL`:OIDC 回调地址
域名可自定义,但路径必须为 `/auth/oidc/callback`
示例:`https://your-domain.example.com/auth/oidc/callback`
- `OIDC_SCOPES`:请求的 OIDC Scope(默认 `"openid profile email"`)
- `OIDC_ALLOWED_USERS`:允许的邮箱或域(可选)
示例:`user@example.com,@example.com`
- `OIDC_ALLOWED_SUBS`:允许的 OIDC `sub` ID 列表(可选)
- `OIDC_ALLOWED_USERNAMES`:允许的用户名列表(可选)
- `OIDC_ALLOWED_GROUPS`:允许的用户组列表(可选)
#### 反向代理模式(可选)
```env
REVERSE_PROXY_ENABLED=false
```
启用后(`REVERSE_PROXY_ENABLED=true`):
- GLKVM Cloud 运行在反向代理(如 Nginx)之后
- TLS 由反向代理终止,GLKVM Cloud 内部使用 HTTP
- Web UI 与设备远程访问可共用同一个 HTTPS 端口(通常为 443)
##### 必需的反向代理请求头
反向代理必须转发以下请求头,否则可能生成包含内部端口(如 `:10443`)的访问地址:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
##### 设备远程访问域名(可选)
```env
DEVICE_ENDPOINT_HOST=
```
- **仅在** `REVERSE_PROXY_ENABLED=true` 时生效
- 用于指定设备远程访问使用的域名
- 生成的设备访问地址格式为:
```text
https://<deviceId>.<DEVICE_ENDPOINT_HOST>/
```
**说明:**
- 不需要包含 `http(s)://` 或路径
- 可与 Web UI 域名不同
- 留空时,将从 `X-Forwarded-*` 请求头自动推导
**示例:**
```text
https://www.example.com → Web UI
https://<deviceId>.kvm.example.com → 设备远程访问
DEVICE_ENDPOINT_HOST=kvm.example.com
```
⚠️ **注意:所有配置均需在 `.env` 中完成,不需要修改 `docker-compose.yml`、模板或脚本。**
### 3. **启动服务**
```bash
docker-compose up -d
```
如果你修改了 `.env` 或模板文件,请重新加载服务:
```bash
docker-compose down && docker-compose up -d
```
### 4. **访问平台**
安装完成后,通过以下地址访问平台:
```bash
https://<你的服务器公网 IP>
```
+71 -3
View File
@@ -7,8 +7,16 @@
```bash
git clone https://github.com/gl-inet/glkvm-cloud.git
cd glkvm-cloud/docker-compose/
cp .env.example .env
```
* For **x86_64 (amd64)**:
```bash
cp .env.example .env
```
* For **arm64 (AArch64)**:
```bash
cp .env.arm64.example .env
```
2. **Configure environment variables**
@@ -42,7 +50,6 @@
(e.g. `https://accounts.google.com`, `https://your-tenant.auth0.com/`)
- `OIDC_CLIENT_ID`: client ID issued by your OIDC provider
- `OIDC_CLIENT_SECRET`: client secret issued by your OIDC provider
*(recommended to set via environment variable rather than YAML files)*
- `OIDC_AUTH_URL`: authorization endpoint URL
- `OIDC_TOKEN_URL`: token endpoint URL
- `OIDC_REDIRECT_URL`: redirect (callback) URL registered in your OIDC provider
@@ -54,8 +61,69 @@
- `OIDC_ALLOWED_SUBS`: comma-separated list of allowed OIDC subject (`sub`) IDs (optional)
- `OIDC_ALLOWED_USERNAMES`: comma-separated list of allowed usernames (`preferred_username` or `name`) (optional)
- `OIDC_ALLOWED_GROUPS`: comma-separated list of allowed OIDC groups (optional)
⚠️ **Note:** All configuration should be done in the `.env` file.
#### Reverse Proxy Mode (Optional)
```env
REVERSE_PROXY_ENABLED=false
```
When enabled (`REVERSE_PROXY_ENABLED=true`):
- GLKVM Cloud runs behind a reverse proxy (e.g. Nginx)
- TLS is terminated at the reverse proxy; GLKVM Cloud uses plain HTTP internally
- The Web UI and remote device access can share the same HTTPS port (usually 443)
##### Required Reverse Proxy Headers
The reverse proxy **must** forward the following headers; otherwise, GLKVM Cloud may generate URLs containing internal ports (e.g. `:10443`):
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
##### Device Remote Access Domain (Optional)
```env
DEVICE_ENDPOINT_HOST=
```
- **Effective only when** `REVERSE_PROXY_ENABLED=true`
- Used to specify the domain for device remote access
- Device access URLs are generated as:
```text
https://<deviceId>.<DEVICE_ENDPOINT_HOST>/
```
**Notes:**
- Do not include the scheme (`http://` or `https://`)
- Do not include any path
- The domain may differ from the Web UI domain
- If left empty, the host/port will be derived from `X-Forwarded-*` headers
**Example:**
```text
https://www.example.com → Web UI
https://<deviceId>.kvm.example.com → Device remote access
DEVICE_ENDPOINT_HOST=kvm.example.com
```
⚠️ **Note:** All configuration should be done in the `.env` file.
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
3. **Start the services**
+100
View File
@@ -0,0 +1,100 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT,
description TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin','user')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
is_system INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS user_group_members (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ugm_group_id ON user_group_members(group_id);
CREATE TABLE IF NOT EXISTS device_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS user_group_device_group_links (
user_group_id INTEGER NOT NULL,
device_group_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (user_group_id, device_group_id),
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (device_group_id) REFERENCES device_groups(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ug_dg_device_group_id
ON user_group_device_group_links(device_group_id);
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ddns TEXT NOT NULL UNIQUE,
mac TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
client TEXT NOT NULL DEFAULT '',
device_group_id INTEGER NULL,
status TEXT NOT NULL DEFAULT 'online' CHECK (status IN ('online','offline','disabled')),
last_seen_at INTEGER NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (device_group_id) REFERENCES device_groups(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_devices_group_id ON devices(device_group_id);
CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status);
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen_at);
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
UPDATE users SET updated_at = unixepoch() WHERE id = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS trg_user_groups_updated_at
AFTER UPDATE ON user_groups
FOR EACH ROW
BEGIN
UPDATE user_groups SET updated_at = unixepoch() WHERE id = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS trg_device_groups_updated_at
AFTER UPDATE ON device_groups
FOR EACH ROW
BEGIN
UPDATE device_groups SET updated_at = unixepoch() WHERE id = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS trg_devices_updated_at
AFTER UPDATE ON devices
FOR EACH ROW
BEGIN
UPDATE devices SET updated_at = unixepoch() WHERE id = OLD.id;
END;
+17 -2
View File
@@ -1,5 +1,3 @@
version: "2.0"
services:
rttys:
image: ${GLKVM_IMAGE:-glzhitong/glkvm-cloud:latest}
@@ -12,6 +10,7 @@ services:
# ---- rttys ----
RTTYS_TOKEN: ${RTTYS_TOKEN:-DeviceTokenYouCanChangeMe}
RTTYS_PASS: ${RTTYS_PASS:-StrongP@ssw0rd}
RTTYS_ADMIN_NAME: ${RTTYS_ADMIN_NAME:-}
# Ports inside container (mirrored to host via `ports` below)
RTTYS_DEVICE_PORT: ${RTTYS_DEVICE_PORT:-5912} # addr-dev
@@ -34,6 +33,8 @@ services:
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
LDAP_ADMIN_GROUP: ${LDAP_ADMIN_GROUP:-}
LDAP_ADMIN_USERS: ${LDAP_ADMIN_USERS:-}
# ---- OIDC Authentication ----
OIDC_ENABLED: ${OIDC_ENABLED:-false}
@@ -49,11 +50,25 @@ services:
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
OIDC_ADMIN_USERS: ${OIDC_ADMIN_USERS:-}
# ---- Selfhost WebUI URL ----
SELFHOST_WEBUI_URL: ${SELFHOST_WEBUI_URL:-}
# ---- Reverse Proxy ----
REVERSE_PROXY_ENABLED: ${REVERSE_PROXY_ENABLED:-false}
# ---- Device Endpoint Host ----
DEVICE_ENDPOINT_HOST: ${DEVICE_ENDPOINT_HOST:-}
# ---- Web UI Host ----
WEB_UI_HOST: ${WEB_UI_HOST:-}
volumes:
- ./templates/rttys.conf.template:/tpl/rttys.conf.tmpl:ro
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
- ./certificate/glkvm.cer:/home/certificate/glkvm_cer:ro
- ./certificate/glkvm.key:/home/certificate/glkvm_key:ro
- ./database:/home/database:rw
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
command: ["rttys"]
ports:
+87
View File
@@ -0,0 +1,87 @@
# =========================================================
# GLKVM Cloud - Reverse Proxy Mode (Nginx Example)
#
# This configuration shows how to run GLKVM Cloud behind
# Nginx in reverse proxy mode.
#
# - TLS is terminated by Nginx
# - GLKVM Cloud listens on plain HTTP internally
# - Web UI and remote device access share the same HTTPS port
# - Routing is based on the requested domain name
# =========================================================
# WebSocket connection helper
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# --- Web UI: https://www.example.com ---
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
proxy_http_version 1.1;
# Required forwarded headers for reverse proxy mode
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# GLKVM Cloud web service (HTTP)
proxy_pass http://127.0.0.1:1443;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# --- Device Access: https://<device_id>.example.com ---
server {
listen 443 ssl http2;
server_name *.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
proxy_http_version 1.1;
# Required forwarded headers for reverse proxy mode
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# GLKVM Cloud device access service (HTTP)
proxy_pass http://127.0.0.1:10443;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
+4 -2
View File
@@ -60,15 +60,17 @@ case "$1" in
: "${TURN_PORT:=3478}"
render /tpl/rttys.conf.tmpl /home/rttys.conf \
RTTYS_TOKEN RTTYS_PASS \
RTTYS_TOKEN RTTYS_PASS RTTYS_ADMIN_NAME \
GLKVM_ACCESS_IP TURN_PORT TURN_USER TURN_PASS \
RTTYS_DEVICE_PORT RTTYS_WEBUI_PORT RTTYS_HTTP_PROXY_PORT \
LDAP_ENABLED LDAP_SERVER LDAP_PORT LDAP_USE_TLS \
LDAP_BIND_DN LDAP_BIND_PASSWORD LDAP_BASE_DN \
LDAP_USER_FILTER LDAP_ALLOWED_GROUPS LDAP_ALLOWED_USERS \
LDAP_ADMIN_GROUP LDAP_ADMIN_USERS \
OIDC_ENABLED OIDC_CLIENT_ID OIDC_AUTH_URL OIDC_TOKEN_URL \
OIDC_REDIRECT_URL OIDC_CLIENT_SECRET OIDC_SCOPES OIDC_ALLOWED_USERS OIDC_ISSUER \
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS \
OIDC_ADMIN_GROUP OIDC_ADMIN_USERS
exec rttys -c /home/rttys.conf
;;
@@ -4,6 +4,9 @@ token: {{RTTYS_TOKEN}}
# Web management password
password: {{RTTYS_PASS}}
# Admin username (leave empty to default to "admin")
admin-name: {{RTTYS_ADMIN_NAME}}
# WebRTC
webrtc-ip: {{GLKVM_ACCESS_IP}}
webrtc-port: {{TURN_PORT}}
@@ -26,6 +29,8 @@ ldap-base-dn: {{LDAP_BASE_DN}}
ldap-user-filter: {{LDAP_USER_FILTER}}
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
ldap-admin-group: {{LDAP_ADMIN_GROUP}}
ldap-admin-users: {{LDAP_ADMIN_USERS}}
# OIDC Authentication (generic OIDC provider)
oidc-enabled: {{OIDC_ENABLED}}
@@ -47,3 +52,5 @@ oidc-generic-allowed-users: {{OIDC_ALLOWED_USERS}}
oidc-generic-allowed-subs: {{OIDC_ALLOWED_SUBS}}
oidc-generic-allowed-usernames: {{OIDC_ALLOWED_USERNAMES}}
oidc-generic-allowed-groups: {{OIDC_ALLOWED_GROUPS}}
oidc-admin-group: {{OIDC_ADMIN_GROUP}}
oidc-admin-users: {{OIDC_ADMIN_USERS}}
+13
View File
@@ -8,6 +8,7 @@ require (
github.com/fanjindong/go-cache v0.0.6
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.8
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.2.1
@@ -19,15 +20,19 @@ require (
github.com/urfave/cli/v3 v3.3.8
github.com/valyala/bytebufferpool v1.0.0
golang.org/x/term v0.33.0
gorm.io/gorm v1.31.1
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -35,6 +40,8 @@ require (
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -42,6 +49,8 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
golang.org/x/arch v0.18.0 // indirect
@@ -52,4 +61,8 @@ require (
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+29
View File
@@ -2,6 +2,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -17,6 +19,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76 h1:eObfFy0e/9OQCd5tHy+855jrW7zTihdgIPD7hf2SOQ0=
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76/go.mod h1:WPzFRpaqRmrZAD1vMpqUGZR24FE1EBoSG9lHKQyZOMM=
github.com/fanjindong/go-cache v0.0.6 h1:4xl8MnfW8pFLH9cRjs0uNfVbFNqV342yl/pgX3Ql9gM=
@@ -29,6 +33,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
@@ -49,6 +57,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -72,6 +82,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -103,6 +117,11 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -199,4 +218,14 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
-658
View File
@@ -1,658 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package main
import (
"bufio"
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"rttys/utils"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
)
type HttpProxySession struct {
expire atomic.Int64
ctx context.Context
cancel context.CancelFunc
devid string
group string
destaddr string
https bool
}
var httpProxySessions = sync.Map{}
const httpProxySessionsExpire = 15 * time.Minute
func (ses *HttpProxySession) Expire() {
ses.expire.Store(time.Now().Add(httpProxySessionsExpire).Unix())
}
func (ses *HttpProxySession) String() string {
return fmt.Sprintf("{devid: %s, group: %s, destaddr: %s, https: %v}",
ses.devid, ses.group, ses.destaddr, ses.https)
}
func (srv *RttyServer) ListenHttpProxy() {
cfg := &srv.cfg
if cfg.AddrHttpProxy != "" {
addr, err := net.ResolveTCPAddr("tcp", cfg.AddrHttpProxy)
if err != nil {
log.Warn().Msg("invalid http proxy addr: " + err.Error())
} else {
srv.httpProxyPort = addr.Port
}
}
ln, err := net.Listen("tcp", cfg.AddrHttpProxy)
if err != nil {
log.Fatal().Msg(err.Error())
}
defer ln.Close()
if cfg.SslCert != "" && cfg.SslKey != "" {
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
if err != nil {
log.Fatal().Msg(err.Error())
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{crt}}
ln = tls.NewListener(ln, tlsConfig)
}
srv.httpProxyPort = ln.Addr().(*net.TCPAddr).Port
log.Info().Msgf("Listen http proxy on: %s", ln.Addr().(*net.TCPAddr))
go httpProxySessionsClean()
for {
c, err := ln.Accept()
if err != nil {
log.Error().Msg(err.Error())
continue
}
go doHttpProxy(srv, c)
}
}
func httpProxySessionsClean() {
for {
time.Sleep(time.Second * 30)
httpProxySessions.Range(func(key, value any) bool {
ses := value.(*HttpProxySession)
if time.Now().Unix() > ses.expire.Load() {
log.Debug().Msgf("Http proxy session '%s' expired", key)
ses.cancel()
httpProxySessions.Delete(key)
}
return true
})
}
}
func doHttpProxy(srv *RttyServer, c net.Conn) {
defer logPanic()
defer c.Close()
br := bufio.NewReader(c)
req, err := http.ReadRequest(br)
if err != nil {
return
}
// 获取 URL 查询参数
queryParams := req.URL.Query()
name := queryParams.Get("sid")
if name != "" {
location := "/"
location += fmt.Sprintf("?_=%d", time.Now().Unix())
Write302WithCookie(c, location, "rtty-http-sid", name)
return
}
cookie, err := req.Cookie("rtty-http-sid")
if err != nil {
log.Debug().Msgf(`not found cookie "rtty-http-sid"`)
sendHTTPErrorResponse(c, "invalid")
return
}
sid := cookie.Value
sesVal, ok := httpProxySessions.Load(sid)
if !ok {
log.Debug().Msgf(`not found httpProxySession "%s"`, sid)
sendHTTPErrorResponse(c, "unauthorized")
return
}
ses := sesVal.(*HttpProxySession)
dev := srv.GetDevice(ses.group, ses.devid)
if dev == nil {
log.Debug().Msgf(`device "%s" group "%s" offline`, ses.devid, ses.group)
sendHTTPErrorResponse(c, "offline")
return
}
hostHeaderRewrite := ses.destaddr
destAddr := genDestAddr(hostHeaderRewrite)
srcAddr := tcpAddr2Bytes(c.RemoteAddr().(*net.TCPAddr))
ctx, cancel := context.WithCancel(ses.ctx)
defer cancel()
go func() {
<-ctx.Done()
c.Close()
log.Debug().Msgf("http proxy conn closed: %s", ses)
dev.https.Delete(string(srcAddr))
sendHttpReq(dev, ses.https, srcAddr[:], destAddr, nil)
}()
log.Debug().Msgf("new http proxy conn: %s", ses)
dev.https.Store(string(srcAddr), c)
hpw := &HttpProxyWriter{destAddr, srcAddr, hostHeaderRewrite, dev, ses.https}
req.Host = hostHeaderRewrite
hpw.WriteRequest(req)
if req.Header.Get("Upgrade") == "websocket" {
b := make([]byte, 4096)
for {
n, err := c.Read(b)
if err != nil {
return
}
sendHttpReq(dev, ses.https, srcAddr, destAddr, b[:n])
ses.Expire()
}
} else {
for {
req, err := http.ReadRequest(br)
if err != nil {
return
}
hpw.WriteRequest(req)
ses.Expire()
}
}
}
func httpProxyRedirect(srv *RttyServer, c *gin.Context, group string) {
cfg := &srv.cfg
devid := c.Param("devid")
proto := c.Param("proto")
addr := c.Param("addr")
rawPath := c.Param("path")
log.Info().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
if !callUserHookUrl(cfg, c) {
c.Status(http.StatusForbidden)
return
}
log.Debug().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
_, _, err := httpProxyVaildAddr(addr)
if err != nil {
log.Debug().Msgf("invalid addr: %s", addr)
c.Status(http.StatusBadRequest)
return
}
path, err := url.Parse(rawPath)
if err != nil {
log.Debug().Msgf("invalid path: %s", rawPath)
c.Status(http.StatusBadRequest)
return
}
dev := srv.GetDevice(group, devid)
if dev == nil {
c.Redirect(http.StatusFound, "/error/offline")
return
}
location := c.Request.Header.Get("HttpProxyRedir")
log.Info().Msgf("HttpProxyRedir location: %s, devid: %s", location, devid)
if location == "" {
location = cfg.HttpProxyRedirURL
if location != "" {
log.Debug().Msgf("use HttpProxyRedirURL from config: %s, devid: %s", location, devid)
}
} else {
log.Debug().Msgf("use HttpProxyRedir from HTTP header: %s, devid: %s", location, devid)
}
if location == "" {
host, _, err := net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
location = "http://" + host
if srv.httpProxyPort != 80 {
location += fmt.Sprintf(":%d", srv.httpProxyPort)
}
}
location += path.Path
location += fmt.Sprintf("?_=%d", time.Now().Unix())
if path.RawQuery != "" {
location += "&" + path.RawQuery
}
sid, err := c.Cookie("rtty-http-sid")
log.Info().Msgf("rtty-http-sid: %s", sid)
if err == nil {
if v, loaded := httpProxySessions.LoadAndDelete(sid); loaded {
s := v.(*HttpProxySession)
s.cancel()
log.Debug().Msgf(`del old httpProxySession "%s" for device "%s"`, sid, devid)
}
}
sid = utils.GenUniqueID()
log.Info().Msgf("rtty-http-sid: %s", sid)
ctx, cancel := context.WithCancel(dev.ctx)
ses := &HttpProxySession{
ctx: ctx,
cancel: cancel,
devid: devid,
group: group,
destaddr: addr,
https: proto == "https",
}
ses.Expire()
httpProxySessions.Store(sid, ses)
log.Debug().Msgf(`new httpProxySession "%s" for device "%s"`, sid, devid)
domain := c.Request.Header.Get("HttpProxyRedirDomain")
if domain == "" {
domain = cfg.HttpProxyRedirDomain
if domain != "" {
log.Debug().Msgf("set cookie domain from config: %s, devid: %s", domain, devid)
}
} else {
log.Debug().Msgf("set cookie domain from HTTP header: %s, devid: %s", domain, devid)
}
// Get domain info
host := c.Request.Host
hostname, _, err := net.SplitHostPort(host)
if err != nil {
// 没有端口时直接使用 host
hostname = host
}
log.Info().Msgf("hostname: %s", hostname)
// 检查是否是 IP 地址
ip := net.ParseIP(hostname)
isIP := ip != nil
if isIP {
// IP 访问,直接跳转
location = fmt.Sprintf("https://%s%s?sid=%s", hostname, cfg.AddrHttpProxy, sid)
log.Info().Msgf("Using IP redirect: %s", location)
} else {
redirHost := buildRedirectHost(hostname, devid)
location = fmt.Sprintf("https://%s%s?sid=%s", redirHost, cfg.AddrHttpProxy, sid)
log.Info().Msgf("Using domain redirect: %s", location)
}
log.Info().Msgf("Final redirect location: %s", location)
c.Redirect(http.StatusFound, location)
}
func sendHttpReq(dev *Device, https bool, srcAddr []byte, destAddr []byte, data []byte) {
bb := bytebufferpool.Get()
defer bytebufferpool.Put(bb)
if dev.proto > 3 {
if https {
bb.WriteByte(1)
} else {
bb.WriteByte(0)
}
}
bb.Write(srcAddr)
bb.Write(destAddr)
bb.Write(data)
dev.WriteMsg(msgTypeHttp, "", bb.Bytes())
}
func genDestAddr(addr string) []byte {
destIP, destPort, err := httpProxyVaildAddr(addr)
if err != nil {
return nil
}
b := make([]byte, 6)
copy(b, destIP)
binary.BigEndian.PutUint16(b[4:], destPort)
return b
}
func tcpAddr2Bytes(addr *net.TCPAddr) []byte {
b := make([]byte, 18)
binary.BigEndian.PutUint16(b[:2], uint16(addr.Port))
copy(b[2:], addr.IP)
return b
}
func httpProxyVaildAddr(addr string) (net.IP, uint16, error) {
ips, ports, err := net.SplitHostPort(addr)
if err != nil {
ips = addr
ports = "80"
}
ip := net.ParseIP(ips)
if ip == nil {
return nil, 0, errors.New("invalid IPv4 Addr")
}
ip = ip.To4()
if ip == nil {
return nil, 0, errors.New("invalid IPv4 Addr")
}
port, _ := strconv.Atoi(ports)
return ip, uint16(port), nil
}
type HttpProxyWriter struct {
destAddr []byte
srcAddr []byte
hostHeaderRewrite string
dev *Device
https bool
}
func (rw *HttpProxyWriter) Write(p []byte) (n int, err error) {
sendHttpReq(rw.dev, rw.https, rw.srcAddr, rw.destAddr, p)
return len(p), nil
}
func (rw *HttpProxyWriter) WriteRequest(req *http.Request) {
req.Host = rw.hostHeaderRewrite
req.Write(rw)
}
func generateErrorHTML(errorType string) string {
return fmt.Sprintf(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTTY</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #555;
line-height: 1.6;
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-icon {
margin-bottom: 2rem;
animation: fadeIn 0.8s ease-in-out;
}
.error-icon svg {
width: 90px;
height: 90px;
fill: #f56565;
}
.error-content {
max-width: 700px;
animation: slideUp 0.8s ease-out 0.2s both;
}
.error-title {
font-size: 1.8rem;
font-weight: 600;
color: #7a8fb0;
margin-bottom: 1rem;
line-height: 1.2;
}
.error-message {
font-size: 1rem;
color: #b6c1d3;
margin-bottom: 2rem;
line-height: 1.6;
text-align: left;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">
<svg viewBox="0 0 24 24">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
</svg>
</div>
<div class="error-content">
<h2 class="error-title" id="errorTitle"></h2>
<p class="error-message" id="errorMessage"></p>
</div>
</div>
<script>
const translations = {
en: {
'Device Unavailable': 'Device Unavailable',
'Invalid Request': 'Invalid Request',
'Unauthorized Access': 'Unauthorized Access',
'Device offline message': 'The device is currently offline. Please check the device status and try again.',
'Invalid request message': 'The request is invalid or malformed',
'Unauthorized request message': 'You are not authorized to access this resource. Please check your session and try again.'
},
'zh-CN': {
'Device Unavailable': '设备不可用',
'Invalid Request': '无效请求',
'Unauthorized Access': '未授权访问',
'Device offline message': '设备当前离线,请检查设备状态后重试。',
'Invalid request message': '请求无效或格式错误',
'Unauthorized request message': '您无权访问此资源。请检查您的会话并重试。'
}
};
function t(key, lang) {
return translations[lang][key] || translations.en[key] || key;
}
function updateContent() {
const errorType = '%s';
const lang = navigator.language === 'zh-CN' ? 'zh-CN' : 'en';
let title = '', message = '';
switch (errorType) {
case 'offline':
title = t('Device Unavailable', lang);
message = t('Device offline message', lang);
break;
case 'invalid':
title = t('Invalid Request', lang);
message = t('Invalid request message', lang);
break;
case 'unauthorized':
title = t('Unauthorized Access', lang);
message = t('Unauthorized request message', lang);
break;
}
document.getElementById('errorTitle').textContent = title;
document.getElementById('errorMessage').textContent = message;
// Update page title
if (title) {
document.title = title + ' - RTTY';
} else {
document.title = 'Error - RTTY';
}
}
// Initialize page on load
document.addEventListener('DOMContentLoaded', updateContent);
</script>
</body>
</html>`, errorType)
}
func sendHTTPErrorResponse(conn net.Conn, errorType string) {
htmlContent := generateErrorHTML(errorType)
response := "HTTP/1.1 200 OK\r\n"
response += "Content-Type: text/html; charset=utf-8\r\n"
response += fmt.Sprintf("Content-Length: %d\r\n", len(htmlContent))
response += "Connection: close\r\n"
response += "\r\n"
response += htmlContent
conn.Write([]byte(response))
}
func Write302WithCookie(conn net.Conn, location, cookieName, cookieValue string) {
cookie := fmt.Sprintf("%s=%s; Path=/; HttpOnly", cookieName, cookieValue)
response := fmt.Sprintf(
"HTTP/1.1 302 Found\r\n"+
"Location: %s\r\n"+
"Set-Cookie: %s\r\n"+
"Content-Length: 0\r\n"+
"Connection: close\r\n"+
"\r\n",
location, cookie,
)
_, _ = conn.Write([]byte(response))
}
// buildRedirectHost removes the first label of the hostname and prepends devid.
// Rules:
// - "www.example.com" -> "devid.example.com"
// - "www.l1.example.com" -> "devid.l1.example.com"
// - "www.l1.l2.example.com" -> "devid.l1.l2.example.com"
// - Two-level domain "example.com" -> "devid.example.com"
// - Single label / abnormal cases -> "devid." + hostname (fallback)
//
// The input hostname must be a pure hostname without port.
func buildRedirectHost(hostname, devid string) string {
// Allow FQDN with trailing dot like "example.com."
hostname = strings.TrimSuffix(hostname, ".")
// Split into labels
labels := strings.Split(hostname, ".")
// Remove empty labels (in case of consecutive dots)
compact := make([]string, 0, len(labels))
for _, l := range labels {
if l != "" {
compact = append(compact, l)
}
}
labels = compact
switch len(labels) {
case 0:
return devid // extreme case: just return devid
case 1:
// Single label (e.g., "localhost") — keep original as suffix
return devid + "." + labels[0]
default:
// >=2: drop the leftmost label
suffix := strings.Join(labels[1:], ".")
return devid + "." + suffix
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 44 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

+82
View File
@@ -0,0 +1,82 @@
package app
import (
"context"
"database/sql"
"rttys/internal/pkg/password"
)
type App struct {
srv *Server
}
func (a *App) Start(ctx context.Context) error { return a.srv.Run(ctx) }
func (a *App) Shutdown(ctx context.Context) error { return a.srv.Shutdown(ctx) }
func SeedIfEmpty(ctx context.Context, db *sql.DB) error {
var n int
if err := db.QueryRowContext(ctx, `SELECT COUNT(1) FROM users`).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
// users
adminHash, err := password.HashPassword("admin")
if err != nil {
return err
}
userHash, err := password.HashPassword("user")
if err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
INSERT INTO users(username, description, password_hash, role, status, is_system) VALUES
('admin','Admin', ?, 'admin', 'active', 1),
('user1','User One', ?, 'user', 'active', 0)`,
adminHash, userHash,
); err != nil {
return err
}
// device groups
if _, err := db.ExecContext(ctx, `
INSERT INTO device_groups(name, description) VALUES
('dg1','Device Group 1'),
('dg2','Device Group 2')`); err != nil {
return err
}
// user groups
if _, err := db.ExecContext(ctx, `
INSERT INTO user_groups(name, description) VALUES
('ug1','User Group 1'),
('ug2','User Group 2')`); err != nil {
return err
}
// memberships: user1 in ug1
if _, err := db.ExecContext(ctx, `
INSERT INTO user_group_members(user_id, group_id) VALUES (2, 1)`); err != nil {
return err
}
// links: ug1 -> dg1
if _, err := db.ExecContext(ctx, `
INSERT INTO user_group_device_group_links(user_group_id, device_group_id) VALUES (1, 1)`); err != nil {
return err
}
// devices: one in dg1, one in dg2, one ungrouped
if _, err := db.ExecContext(ctx, `
INSERT INTO devices(ddns, mac, name, description, device_group_id, status, last_seen_at) VALUES
('dev-001','00:11:22:33:44:55','Device 1','in dg1', 1, 'online', unixepoch()),
('dev-002','00:11:22:33:44:66','Device 2','in dg2', 2, 'offline', unixepoch()),
('dev-003','00:11:22:33:44:77','Device 3','ungrouped', NULL, 'online', unixepoch())`); err != nil {
return err
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package app
import (
"context"
"net/http"
"time"
)
type Server struct {
httpServer *http.Server
}
func NewServer(addr string, handler http.Handler) *Server {
return &Server{
httpServer: &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
},
}
}
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
errCh <- s.httpServer.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.httpServer.Shutdown(shutdownCtx)
return nil
case err := <-errCh:
return err
}
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
+16
View File
@@ -0,0 +1,16 @@
package config
import "time"
type Config struct {
HTTP struct{ Addr string }
Auth struct{ SessionTTL time.Duration }
DB struct{ DSN string }
}
func MustLoad() Config {
var cfg Config
cfg.Auth.SessionTTL = 24 * time.Hour
cfg.DB.DSN = "file:rttys.db?_pragma=foreign_keys(1)"
return cfg
}
+22
View File
@@ -0,0 +1,22 @@
package device
type Status string
const (
StatusOnline Status = "online"
StatusOffline Status = "offline"
StatusDisabled Status = "disabled"
)
type Device struct {
ID int64
Ddns string
Mac string
Name string
Description string
IP string
Client string
DeviceGroupID *int64 // nil means ungrouped (admin-only visibility)
Status Status
LastSeenAt *int64
}
+8
View File
@@ -0,0 +1,8 @@
package device
import "context"
type Repository interface {
ListAll(ctx context.Context) ([]Device, error)
ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64) ([]Device, error)
}
+36
View File
@@ -0,0 +1,36 @@
package device
import (
"context"
"rttys/internal/domain/identity"
"rttys/internal/domain/group"
)
type Service struct {
repo Repository
grepo group.Repository
}
func NewService(repo Repository, grepo group.Repository) *Service {
return &Service{repo: repo, grepo: grepo}
}
// ListVisible implements the scope rule:
// - admin: all devices, including ungrouped
// - user : only devices whose device_group_id is linked via (user -> user_groups -> device_groups)
func (s *Service) ListVisible(ctx context.Context, role identity.Role, userID int64) ([]Device, error) {
if role == identity.RoleAdmin {
return s.repo.ListAll(ctx)
}
dgIDs, err := s.grepo.ListDeviceGroupIDsByUser(ctx, userID)
if err != nil || len(dgIDs) == 0 {
return []Device{}, nil
}
return s.repo.ListByDeviceGroupIDs(ctx, dgIDs)
}
func (s *Service) ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64) ([]Device, error) {
return s.repo.ListByDeviceGroupIDs(ctx, groupIDs)
}
+7
View File
@@ -0,0 +1,7 @@
package devicegroup
type DeviceGroup struct {
ID int64
Name string
Description string
}
+7
View File
@@ -0,0 +1,7 @@
package devicegroup
import "context"
type Repository interface {
ListDeviceGroupsVisibleToUser(ctx context.Context, userID int64, isAdmin bool) ([]DeviceGroup, error)
}
+46
View File
@@ -0,0 +1,46 @@
package devicelog
// EventType identifies the kind of device event being logged.
type EventType string
const (
EventDeviceOnline EventType = "device_online"
EventDeviceOffline EventType = "device_offline"
EventRemoteSSH EventType = "remote_ssh"
EventRemoteWeb EventType = "remote_web"
EventRemoteControl EventType = "remote_control"
)
// IsSession reports whether the event represents a long-running session
// (SSH / Web / Control) for which we track both started_at and ended_at.
func (e EventType) IsSession() bool {
return e == EventRemoteSSH || e == EventRemoteWeb || e == EventRemoteControl
}
// Log is a single device event row.
//
// For point events (online/offline) EndedAt is always 0.
// For session events (SSH/Web) CreatedAt is the session start and EndedAt
// is the session end (0 while still active).
type Log struct {
ID int64
DeviceID string
DeviceMac string
EventType EventType
ActorUserID int64
ActorName string
ClientIP string
Detail string // JSON-encoded extra fields
CreatedAt int64
EndedAt int64
}
// Query holds the filter parameters for listing logs.
type Query struct {
Mac string // substring match (LIKE %mac%)
EventTypes []EventType // empty = no filter
From int64 // unix seconds, 0 = no lower bound
To int64 // unix seconds, 0 = no upper bound
Page int // 1-based
PageSize int
}
+9
View File
@@ -0,0 +1,9 @@
package devicelog
import "context"
type Repository interface {
Create(ctx context.Context, l *Log) (int64, error)
UpdateEndedAt(ctx context.Context, id int64, ts int64) error
List(ctx context.Context, q Query) (items []Log, total int64, err error)
}
+193
View File
@@ -0,0 +1,193 @@
// Package devicelog records four kinds of device events: online, offline,
// remote SSH session and remote Web session. Records are queryable by
// MAC, event type and time range.
//
// The service deliberately swallows errors so that logging never blocks the
// device runtime — failures are reported via the standard logger.
package devicelog
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/rs/zerolog/log"
)
// startupGraceWindow is how long after the service boots we silently drop
// device online/offline events. The goal is to avoid the reconnect storm
// that happens right after a server restart from filling the log with
// noise. SSH/Web events are user-initiated and never suppressed.
const startupGraceWindow = 60 * time.Second
// Detail field length cap to keep rows bounded against malicious input.
const maxDetailLen = 2000
type Service struct {
repo Repository
startupTime time.Time
}
func NewService(repo Repository) *Service {
return &Service{repo: repo, startupTime: time.Now()}
}
// normalizeMac strips colons and lowercases, matching the format in the
// devices table so that MAC-based searches work correctly.
func normalizeMac(mac string) string {
return strings.ReplaceAll(strings.ToLower(mac), ":", "")
}
// inGracePeriod reports whether we are still inside the post-startup quiet
// window during which device on/off events are dropped.
func (s *Service) inGracePeriod() bool {
return time.Since(s.startupTime) < startupGraceWindow
}
// RecordDeviceOnline records a device-online event. Dropped during the
// startup grace window.
func (s *Service) RecordDeviceOnline(ctx context.Context, deviceID, mac, ip string) {
if s == nil || s.repo == nil {
return
}
if s.inGracePeriod() {
return
}
if _, err := s.repo.Create(ctx, &Log{
DeviceID: deviceID,
DeviceMac: normalizeMac(mac),
EventType: EventDeviceOnline,
ClientIP: ip,
CreatedAt: time.Now().Unix(),
}); err != nil {
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: record online failed")
}
}
// RecordDeviceOffline records a device-offline event. Dropped during the
// startup grace window.
func (s *Service) RecordDeviceOffline(ctx context.Context, deviceID, mac, ip string) {
if s == nil || s.repo == nil {
return
}
if s.inGracePeriod() {
return
}
if _, err := s.repo.Create(ctx, &Log{
DeviceID: deviceID,
DeviceMac: normalizeMac(mac),
EventType: EventDeviceOffline,
ClientIP: ip,
CreatedAt: time.Now().Unix(),
}); err != nil {
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: record offline failed")
}
}
// StartRemoteSSHSession records the start of an SSH session and returns
// the row ID so the caller can later mark it ended via EndSession.
// Returns 0 if recording failed.
func (s *Service) StartRemoteSSHSession(ctx context.Context, deviceID, mac string, userID int64, userName, ip string) int64 {
if s == nil || s.repo == nil {
return 0
}
id, err := s.repo.Create(ctx, &Log{
DeviceID: deviceID,
DeviceMac: normalizeMac(mac),
EventType: EventRemoteSSH,
ActorUserID: userID,
ActorName: userName,
ClientIP: ip,
CreatedAt: time.Now().Unix(),
})
if err != nil {
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: start ssh session failed")
return 0
}
return id
}
// StartRemoteWebSession records the start of a web-proxy session.
// addr/proto are stored as a small JSON detail blob.
func (s *Service) StartRemoteWebSession(ctx context.Context, deviceID, mac string, userID int64, userName, ip, addr, proto string) int64 {
if s == nil || s.repo == nil {
return 0
}
detail := encodeDetail(map[string]string{"addr": addr, "proto": proto})
id, err := s.repo.Create(ctx, &Log{
DeviceID: deviceID,
DeviceMac: normalizeMac(mac),
EventType: EventRemoteWeb,
ActorUserID: userID,
ActorName: userName,
ClientIP: ip,
Detail: detail,
CreatedAt: time.Now().Unix(),
})
if err != nil {
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: start web session failed")
return 0
}
return id
}
// StartRemoteControlSession records the start of a remote-control session
// (KVM web UI). No detail blob is stored for this event type.
func (s *Service) StartRemoteControlSession(ctx context.Context, deviceID, mac string, userID int64, userName, ip string) int64 {
if s == nil || s.repo == nil {
return 0
}
id, err := s.repo.Create(ctx, &Log{
DeviceID: deviceID,
DeviceMac: normalizeMac(mac),
EventType: EventRemoteControl,
ActorUserID: userID,
ActorName: userName,
ClientIP: ip,
CreatedAt: time.Now().Unix(),
})
if err != nil {
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: start control session failed")
return 0
}
return id
}
// EndSession stamps ended_at on a session row. Safe to call with id == 0
// (no-op) so callers can write `defer logSvc.EndSession(ctx, id)` without
// branching on whether the start succeeded.
func (s *Service) EndSession(ctx context.Context, id int64) {
if s == nil || s.repo == nil || id <= 0 {
return
}
if err := s.repo.UpdateEndedAt(ctx, id, time.Now().Unix()); err != nil {
log.Warn().Err(err).Int64("id", id).Msg("devicelog: end session failed")
}
}
// Query lists logs matching the filter. Page/PageSize are normalized:
// page defaults to 1, pageSize is clamped to [1, 200].
func (s *Service) Query(ctx context.Context, q Query) ([]Log, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize <= 0 {
q.PageSize = 20
}
if q.PageSize > 200 {
q.PageSize = 200
}
return s.repo.List(ctx, q)
}
func encodeDetail(m map[string]string) string {
b, err := json.Marshal(m)
if err != nil {
return ""
}
if len(b) > maxDetailLen {
return string(b[:maxDetailLen])
}
return string(b)
}
+7
View File
@@ -0,0 +1,7 @@
package group
type UserGroup struct {
ID int64
Name string
Description string
}
+8
View File
@@ -0,0 +1,8 @@
package group
import "context"
type Repository interface {
ListUserGroupsVisibleToUser(ctx context.Context, userID int64, isAdmin bool) ([]UserGroup, error)
ListDeviceGroupIDsByUser(ctx context.Context, userID int64) ([]int64, error)
}
+17
View File
@@ -0,0 +1,17 @@
package identity
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
func RoleFromString(v string) Role {
switch v {
case string(RoleAdmin):
return RoleAdmin
default:
return RoleUser
}
}
+165
View File
@@ -0,0 +1,165 @@
package notification
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// SendEmail delivers an HTML email via the given SMTP config.
func SendEmail(cfg *SMTPConfig, to []string, subject, htmlBody string) error {
if cfg == nil || cfg.Host == "" || len(to) == 0 {
return fmt.Errorf("invalid smtp config or empty recipients")
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
from := cfg.FromEmail
if from == "" {
from = cfg.Username
}
msg := buildMIME(from, to, subject, htmlBody)
switch strings.ToLower(cfg.Encryption) {
case "tls":
return sendTLS(addr, cfg, from, to, msg)
case "starttls":
return sendSTARTTLS(addr, cfg, from, to, msg)
default:
return sendPlain(addr, cfg, from, to, msg)
}
}
func buildMIME(from string, to []string, subject, htmlBody string) []byte {
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + strings.Join(to, ",") + "\r\n")
b.WriteString("Subject: " + subject + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
b.WriteString("Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n")
b.WriteString("\r\n")
b.WriteString(htmlBody)
return []byte(b.String())
}
func authOrNil(cfg *SMTPConfig) smtp.Auth {
if cfg.Username == "" && cfg.Password == "" {
return nil
}
return smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
// sendTLS connects via implicit TLS (port 465 typical).
func sendTLS(addr string, cfg *SMTPConfig, from string, to []string, msg []byte) error {
tlsCfg := &tls.Config{ServerName: cfg.Host}
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
return smtpSend(c, cfg, from, to, msg)
}
// sendSTARTTLS connects plain then upgrades (port 587 typical).
func sendSTARTTLS(addr string, cfg *SMTPConfig, from string, to []string, msg []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
defer c.Close()
if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
return fmt.Errorf("starttls: %w", err)
}
return smtpSend(c, cfg, from, to, msg)
}
// sendPlain sends without encryption.
func sendPlain(addr string, cfg *SMTPConfig, from string, to []string, msg []byte) error {
auth := authOrNil(cfg)
return smtp.SendMail(addr, auth, from, to, msg)
}
func smtpSend(c *smtp.Client, cfg *SMTPConfig, from string, to []string, msg []byte) error {
if auth := authOrNil(cfg); auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("mail from: %w", err)
}
for _, addr := range to {
if err := c.Rcpt(addr); err != nil {
return fmt.Errorf("rcpt %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
if _, err = w.Write(msg); err != nil {
return fmt.Errorf("write: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}
return c.Quit()
}
// RenderNotificationEmail produces a simple HTML email body.
func RenderNotificationEmail(title string, fields []EmailField) string {
var rows strings.Builder
for _, f := range fields {
rows.WriteString(fmt.Sprintf(
`<tr><td style="padding:8px 12px;color:#666;width:140px;border-bottom:1px solid #f0f0f0;">%s</td>`+
`<td style="padding:8px 12px;color:#333;border-bottom:1px solid #f0f0f0;">%s</td></tr>`,
f.Label, f.Value))
}
return fmt.Sprintf(`<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f5f5f5;padding:20px;margin:0;">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;">
<div style="background:#1890ff;padding:20px 24px;">
<h2 style="color:#fff;margin:0;font-size:18px;">🔔 GLKVM Cloud Notification</h2>
</div>
<div style="padding:24px;">
<p style="color:#333;font-size:16px;font-weight:bold;margin:0 0 16px;">%s</p>
<table style="width:100%%;border-collapse:collapse;">%s</table>
</div>
<div style="padding:16px 24px;border-top:1px solid #f0f0f0;">
<p style="color:#999;font-size:12px;margin:0;">This is an automated notification from GLKVM Cloud. Please do not reply.</p>
</div>
</div>
</body></html>`, title, rows.String())
}
// EmailField is a label/value pair for the email template.
type EmailField struct {
Label string
Value string
}
// RenderTestEmail produces a test email body.
func RenderTestEmail() (subject, body string) {
subject = "[GLKVM Cloud] Test Notification"
body = RenderNotificationEmail("SMTP Configuration Test", []EmailField{
{Label: "Status", Value: "✅ Success"},
{Label: "Message", Value: "Your SMTP settings are configured correctly. You will receive notifications at this email address."},
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
})
return
}
+28
View File
@@ -0,0 +1,28 @@
package notification
// SMTPConfig holds mail server settings. Only one row exists (singleton).
type SMTPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromEmail string `json:"fromEmail"`
Encryption string `json:"encryption"` // "none", "tls", "starttls"
Enabled bool `json:"enabled"`
UpdatedAt int64 `json:"updatedAt"`
}
// NotifyRules controls which event categories trigger email notifications.
type NotifyRules struct {
DeviceOnline bool `json:"deviceOnline"`
DeviceOffline bool `json:"deviceOffline"`
RemoteAccess bool `json:"remoteAccess"` // SSH + Web + Control
UpdatedAt int64 `json:"updatedAt"`
}
// Recipient is a notification email address.
type Recipient struct {
ID int64 `json:"id"`
Email string `json:"email"`
CreatedAt int64 `json:"createdAt"`
}
+16
View File
@@ -0,0 +1,16 @@
package notification
import "context"
// Repository persists notification configuration.
type Repository interface {
GetSMTPConfig(ctx context.Context) (*SMTPConfig, error)
SaveSMTPConfig(ctx context.Context, cfg *SMTPConfig) error
GetNotifyRules(ctx context.Context) (*NotifyRules, error)
SaveNotifyRules(ctx context.Context, rules *NotifyRules) error
ListRecipients(ctx context.Context) ([]Recipient, error)
AddRecipient(ctx context.Context, email string) (*Recipient, error)
RemoveRecipient(ctx context.Context, id int64) error
}
+169
View File
@@ -0,0 +1,169 @@
// Package notification provides email notification for device events.
// The service swallows errors so notifications never block the device runtime.
package notification
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog/log"
)
// startupGraceWindow mirrors the device-log grace period: device online/
// offline emails are suppressed during this window to avoid a reconnect
// storm flooding inboxes after a server restart.
const startupGraceWindow = 60 * time.Second
type Service struct {
repo Repository
startupTime time.Time
}
func NewService(repo Repository) *Service {
return &Service{repo: repo, startupTime: time.Now()}
}
func (s *Service) inGracePeriod() bool {
return time.Since(s.startupTime) < startupGraceWindow
}
// ─── SMTP config ────────────────────────────────────────────────
func (s *Service) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
return s.repo.GetSMTPConfig(ctx)
}
func (s *Service) SaveSMTPConfig(ctx context.Context, cfg *SMTPConfig) error {
cfg.UpdatedAt = time.Now().Unix()
return s.repo.SaveSMTPConfig(ctx, cfg)
}
func (s *Service) TestSMTP(ctx context.Context, email string) error {
cfg, err := s.repo.GetSMTPConfig(ctx)
if err != nil {
return fmt.Errorf("load smtp config: %w", err)
}
if cfg.Host == "" {
return fmt.Errorf("SMTP is not configured")
}
subj, body := RenderTestEmail()
return SendEmail(cfg, []string{email}, subj, body)
}
// ─── Notification rules ─────────────────────────────────────────
func (s *Service) GetNotifyRules(ctx context.Context) (*NotifyRules, error) {
return s.repo.GetNotifyRules(ctx)
}
func (s *Service) SaveNotifyRules(ctx context.Context, rules *NotifyRules) error {
rules.UpdatedAt = time.Now().Unix()
return s.repo.SaveNotifyRules(ctx, rules)
}
// ─── Recipients ─────────────────────────────────────────────────
func (s *Service) ListRecipients(ctx context.Context) ([]Recipient, error) {
return s.repo.ListRecipients(ctx)
}
func (s *Service) AddRecipient(ctx context.Context, email string) (*Recipient, error) {
return s.repo.AddRecipient(ctx, email)
}
func (s *Service) RemoveRecipient(ctx context.Context, id int64) error {
return s.repo.RemoveRecipient(ctx, id)
}
// ─── Event triggers (called from device runtime) ────────────────
// NotifyDeviceOnline sends a device-online notification if enabled.
// Suppressed during the startup grace window.
func (s *Service) NotifyDeviceOnline(deviceID, mac string) {
if s.inGracePeriod() {
return
}
s.sendEventNotification("deviceOnline", "[GLKVM Cloud] Device Online", "Device Online", []EmailField{
{Label: "Event", Value: "Device Online"},
{Label: "Device ID", Value: deviceID},
{Label: "MAC Address", Value: mac},
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
})
}
// NotifyDeviceOffline sends a device-offline notification if enabled.
// Suppressed during the startup grace window.
func (s *Service) NotifyDeviceOffline(deviceID, mac string) {
if s.inGracePeriod() {
return
}
s.sendEventNotification("deviceOffline", "[GLKVM Cloud] Device Offline", "Device Offline", []EmailField{
{Label: "Event", Value: "Device Offline"},
{Label: "Device ID", Value: deviceID},
{Label: "MAC Address", Value: mac},
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
})
}
// NotifyRemoteAccess sends a remote-access notification if enabled.
func (s *Service) NotifyRemoteAccess(accessType, deviceID, mac, actor, clientIP string) {
s.sendEventNotification("remoteAccess", "[GLKVM Cloud] Remote Access: "+accessType, "Remote Access Detected", []EmailField{
{Label: "Access Type", Value: accessType},
{Label: "Device ID", Value: deviceID},
{Label: "MAC Address", Value: mac},
{Label: "Actor", Value: actor},
{Label: "Client IP", Value: clientIP},
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
})
}
// sendEventNotification is the common helper: check rules → load recipients → send emails.
func (s *Service) sendEventNotification(ruleField, subject, title string, fields []EmailField) {
if s == nil || s.repo == nil {
return
}
go func() {
ctx := context.Background()
cfg, err := s.repo.GetSMTPConfig(ctx)
if err != nil || cfg == nil || !cfg.Enabled || cfg.Host == "" {
return
}
rules, err := s.repo.GetNotifyRules(ctx)
if err != nil || rules == nil {
return
}
if !s.ruleEnabled(rules, ruleField) {
return
}
recipients, err := s.repo.ListRecipients(ctx)
if err != nil || len(recipients) == 0 {
return
}
to := make([]string, 0, len(recipients))
for _, r := range recipients {
to = append(to, r.Email)
}
body := RenderNotificationEmail(title, fields)
if err := SendEmail(cfg, to, subject, body); err != nil {
log.Warn().Err(err).Str("subject", subject).Msg("notification: send email failed")
}
}()
}
func (s *Service) ruleEnabled(rules *NotifyRules, field string) bool {
switch field {
case "deviceOnline":
return rules.DeviceOnline
case "deviceOffline":
return rules.DeviceOffline
case "remoteAccess":
return rules.RemoteAccess
default:
return false
}
}
+38
View File
@@ -0,0 +1,38 @@
package permission
import "rttys/internal/domain/identity"
type Key string
const (
MeRead Key = "me.read"
AuthWrite Key = "auth.write"
DeviceRead Key = "device.read"
DeviceWrite Key = "device.write"
DeviceGroupRead Key = "device_group.read"
DeviceGroupWrite Key = "device_group.write"
UserGroupRead Key = "user_group.read"
UserGroupWrite Key = "user_group.write"
UserRead Key = "user.read"
UserWrite Key = "user.write"
RelationWrite Key = "relation.write"
DeviceLogRead Key = "device_log.read"
NotificationRead Key = "notification.read"
NotificationWrite Key = "notification.write"
)
func DefaultKeysForRole(role identity.Role) []Key {
switch role {
case identity.RoleAdmin:
return []Key{ /* ... */ }
default:
return []Key{ /* ... */ }
}
}
+20
View File
@@ -0,0 +1,20 @@
package permission
import (
"context"
"rttys/internal/domain/identity"
)
type Repository interface {
ListKeysByRole(ctx context.Context, role identity.Role) ([]Key, error)
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service { return &Service{repo: repo} }
func (s *Service) ListByRole(ctx context.Context, role identity.Role) ([]Key, error) {
return s.repo.ListKeysByRole(ctx, role)
}
+12
View File
@@ -0,0 +1,12 @@
package trusteddevice
type Device struct {
ID int64
UserID int64
Token string
DeviceName string
IP string
CreatedAt int64
LastUsedAt int64
ExpiresAt int64
}
+13
View File
@@ -0,0 +1,13 @@
package trusteddevice
import "context"
type Repository interface {
Create(ctx context.Context, d *Device) (int64, error)
FindByToken(ctx context.Context, token string) (*Device, error)
ListByUserID(ctx context.Context, userID int64) ([]Device, error)
Delete(ctx context.Context, id, userID int64) error
DeleteByUserID(ctx context.Context, userID int64) error
TouchLastUsed(ctx context.Context, id int64, ts int64) error
DeleteExpired(ctx context.Context, before int64) error
}
+29
View File
@@ -0,0 +1,29 @@
package user
import (
"rttys/internal/domain/identity"
)
type Status string
const (
StatusActive Status = "active"
StatusDisabled Status = "disabled"
)
type User struct {
ID int64
Username string
Email string
Description string
PasswordHash string
Role identity.Role
Status Status
IsSystem bool
AuthProvider string // "local", "oidc", "ldap"
ExternalSub string // OIDC sub claim / LDAP user DN
LastLoginAt *int64 // unix seconds, nil if never
TotpSecret string // base32 secret; "" when 2FA not enabled
TotpEnabled bool
CreatedAt int64 // unix seconds
}
+20
View File
@@ -0,0 +1,20 @@
package user
import "context"
type Repository interface {
FindByID(ctx context.Context, id int64) (*User, error)
FindByUsername(ctx context.Context, username string) (*User, error)
FindByExternalID(ctx context.Context, provider, externalSub string) (*User, error)
FindSystemAdmin(ctx context.Context) (*User, error)
Create(ctx context.Context, u *User) (int64, error)
Update(ctx context.Context, u *User) error
Delete(ctx context.Context, id int64) error
List(ctx context.Context) ([]User, error)
// Partial updates
UpdateLastLoginAt(ctx context.Context, id int64, ts int64) error
UpdateDescription(ctx context.Context, id int64, description string) error
UpdateTotp(ctx context.Context, id int64, secret string, enabled bool) error
}
+213
View File
@@ -0,0 +1,213 @@
package user
import (
"context"
"errors"
"strconv"
"rttys/internal/domain/identity"
"rttys/internal/pkg/password"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUserDisabled = errors.New("user disabled")
ErrBadPassword = errors.New("bad password")
)
type Service struct{ repo Repository }
func NewService(repo Repository) *Service { return &Service{repo: repo} }
func (s *Service) Authenticate(ctx context.Context, username, pw string) (*User, error) {
u, err := s.repo.FindByUsername(ctx, username)
if err != nil || u == nil {
return nil, ErrUserNotFound
}
if u.Status == StatusDisabled {
return nil, ErrUserDisabled
}
if !password.VerifyPassword(pw, u.PasswordHash) {
return nil, ErrBadPassword
}
return u, nil
}
func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) {
u, err := s.repo.FindByID(ctx, id)
if err != nil || u == nil {
return nil, ErrUserNotFound
}
if u.Status == StatusDisabled {
return nil, ErrUserDisabled
}
return u, nil
}
// FindByID returns user even if disabled.
func (s *Service) FindByID(ctx context.Context, id int64) (*User, error) {
u, err := s.repo.FindByID(ctx, id)
if err != nil || u == nil {
return nil, ErrUserNotFound
}
return u, nil
}
func (s *Service) GetSystemAdmin(ctx context.Context) (*User, error) {
return s.repo.FindSystemAdmin(ctx)
}
func (s *Service) List(ctx context.Context) ([]User, error) {
return s.repo.List(ctx)
}
// CreateUser creates a user; passwordPlain will be hashed.
func (s *Service) CreateUser(ctx context.Context, username, description, passwordPlain, role, status string) (int64, error) {
hash, err := password.HashPassword(passwordPlain)
if err != nil {
return 0, err
}
u := &User{
Username: username,
Description: description,
PasswordHash: hash,
Role: identity.RoleFromString(role),
Status: Status(status),
}
return s.repo.Create(ctx, u)
}
// UpdateUser updates fields; if passwordPlain is empty, keep existing.
func (s *Service) UpdateUser(ctx context.Context, id int64, username, description, passwordPlain, role, status *string) error {
exist, err := s.repo.FindByID(ctx, id)
if err != nil || exist == nil {
return ErrUserNotFound
}
if username != nil && *username != "" {
exist.Username = *username
}
if description != nil {
exist.Description = *description
}
if role != nil && *role != "" {
exist.Role = identity.RoleFromString(*role)
}
if status != nil && *status != "" {
exist.Status = Status(*status)
}
if passwordPlain != nil && *passwordPlain != "" {
hash, err := password.HashPassword(*passwordPlain)
if err != nil {
return err
}
exist.PasswordHash = hash
}
return s.repo.Update(ctx, exist)
}
func (s *Service) DeleteUser(ctx context.Context, id int64) error {
return s.repo.Delete(ctx, id)
}
// UpdateDescription persists a new description (a.k.a. display name) for a user.
func (s *Service) UpdateDescription(ctx context.Context, id int64, description string) error {
return s.repo.UpdateDescription(ctx, id, description)
}
// SetTotp toggles 2FA for a user. Pass enabled=false and secret="" to disable.
func (s *Service) SetTotp(ctx context.Context, id int64, secret string, enabled bool) error {
return s.repo.UpdateTotp(ctx, id, secret, enabled)
}
// TouchLastLogin records a fresh last_login_at timestamp.
func (s *Service) TouchLastLogin(ctx context.Context, id int64, ts int64) error {
return s.repo.UpdateLastLoginAt(ctx, id, ts)
}
// FindOrCreateExternalUser looks up a user by (provider, externalSub).
// If found, it updates email/description and returns the user.
// If not found, it creates a new user with the given role and status=active.
//
// role is determined by the caller based on admin-group/admin-users membership
// and is only applied at user creation time. Existing users keep their current role.
func (s *Service) FindOrCreateExternalUser(ctx context.Context, provider, externalSub, preferredUsername, email, displayName string, role identity.Role) (*User, error) {
u, err := s.repo.FindByExternalID(ctx, provider, externalSub)
if err != nil {
return nil, err
}
if u != nil {
// Update email and display name on each login (IdP may change them).
changed := false
if email != "" && u.Email != email {
u.Email = email
changed = true
}
if displayName != "" && u.Description != displayName {
u.Description = displayName
changed = true
}
if changed {
_ = s.repo.Update(ctx, u)
}
return u, nil
}
// --- Create new user ---
username := s.pickUniqueUsername(ctx, preferredUsername, email, provider)
newUser := &User{
Username: username,
Email: email,
Description: displayName,
PasswordHash: "", // external users never authenticate via password
Role: role,
Status: StatusActive,
AuthProvider: provider,
ExternalSub: externalSub,
}
id, err := s.repo.Create(ctx, newUser)
if err != nil {
return nil, err
}
newUser.ID = id
return newUser, nil
}
// pickUniqueUsername tries candidate usernames until one doesn't conflict.
func (s *Service) pickUniqueUsername(ctx context.Context, preferredUsername, email, provider string) string {
candidates := make([]string, 0, 4)
if preferredUsername != "" {
candidates = append(candidates, preferredUsername)
}
if email != "" && email != preferredUsername {
candidates = append(candidates, email)
}
// Fallback with provider suffix
if preferredUsername != "" {
candidates = append(candidates, preferredUsername+"_"+provider)
}
if email != "" {
candidates = append(candidates, email+"_"+provider)
}
// Last resort
if len(candidates) == 0 {
candidates = append(candidates, provider+"_user")
}
for _, c := range candidates {
existing, _ := s.repo.FindByUsername(ctx, c)
if existing == nil {
return c
}
}
// All candidates taken — append a numeric suffix
base := candidates[0] + "_" + provider
for i := 2; ; i++ {
name := base + "_" + strconv.Itoa(i)
existing, _ := s.repo.FindByUsername(ctx, name)
if existing == nil {
return name
}
}
}
+16
View File
@@ -0,0 +1,16 @@
package dto
type LoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod,omitempty"`
TotpCode string `json:"totpCode,omitempty"`
RememberDevice bool `json:"rememberDevice,omitempty"`
}
type LoginResp struct {
Token string `json:"token,omitempty"`
TwoFactorRequired bool `json:"twoFactorRequired,omitempty"`
}
type LogoutResp struct{}
+59
View File
@@ -0,0 +1,59 @@
package dto
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type Meta struct {
TraceID string `json:"traceId"`
TS int64 `json:"ts"`
}
type Envelope[T any] struct {
Ok bool `json:"ok"`
Code string `json:"code"`
Message string `json:"message"`
Data T `json:"data"`
Meta Meta `json:"meta"`
}
const (
CodeOK = "OK"
CodeInvalidArgument = "INVALID_ARGUMENT"
CodeValidationFailed = "VALIDATION_FAILED"
CodeAuthRequired = "AUTH_REQUIRED"
CodeAuthExpired = "AUTH_EXPIRED"
CodeForbidden = "FORBIDDEN"
CodeNotFound = "NOT_FOUND"
CodeConflict = "CONFLICT"
CodeInternalError = "INTERNAL_ERROR"
)
func NowUnix() int64 { return time.Now().Unix() }
// Write always replies with HTTP 200 per spec.
func Write[T any](c *gin.Context, env Envelope[T]) {
c.JSON(http.StatusOK, env)
}
func Ok[T any](traceID string, data T) Envelope[T] {
return Envelope[T]{
Ok: true,
Code: CodeOK,
Data: data,
Meta: Meta{TraceID: traceID, TS: NowUnix()},
}
}
func Err(traceID, code, msg string, data any) Envelope[any] {
return Envelope[any]{
Ok: false,
Code: code,
Message: msg,
Data: data,
Meta: Meta{TraceID: traceID, TS: NowUnix()},
}
}
+28
View File
@@ -0,0 +1,28 @@
package dto
type Device struct {
ID int64 `json:"id"`
Ddns string `json:"ddns"`
Status string `json:"status"`
ConnectedTime int64 `json:"connectedTime"`
IP string `json:"ip"`
Mac string `json:"mac"`
Description string `json:"description"`
Client string `json:"client"`
DeviceGroupID *int64 `json:"deviceGroupId"`
DeviceGroupName string `json:"deviceGroupName"`
}
type ListDevicesResp struct {
Items []Device `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
}
type MoveDevicesToGroupReq struct {
DeviceIDs []int64 `json:"deviceIds"`
GroupID int64 `json:"groupId"`
}
type MoveDevicesToGroupResp struct{}
+50
View File
@@ -0,0 +1,50 @@
package dto
type DeviceGroupUserGroupRef struct {
UserGroupID int64 `json:"userGroupId"`
UserGroupName string `json:"userGroupName"`
}
type DeviceGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
DeviceCount int64 `json:"deviceCount"`
Description string `json:"description"`
UserGroupList []DeviceGroupUserGroupRef `json:"userGroupList"`
}
type ListDeviceGroupsResp struct {
Items []DeviceGroup `json:"items"`
}
type DeviceGroupOption struct {
GroupID int64 `json:"groupId"`
Name string `json:"name"`
}
type ListDeviceGroupOptionsResp struct {
Items []DeviceGroupOption `json:"items"`
}
type CreateDeviceGroupReq struct {
Name string `json:"name"`
Description string `json:"description"`
UserGroupIDs []int64 `json:"userGroupIds"`
DeviceIDs []int64 `json:"deviceIds"`
}
type CreateDeviceGroupResp struct {
ID int64 `json:"id"`
}
type UpdateDeviceGroupReq struct {
Name string `json:"name"`
Description string `json:"description"`
UserGroupIDs []int64 `json:"userGroupIds"`
}
type DeleteDeviceGroupResp struct{}
type ModifyDeviceGroupDevicesReq struct {
DeviceIDs []int64 `json:"deviceIds"`
}
+19
View File
@@ -0,0 +1,19 @@
package dto
type DeviceEventLog struct {
ID int64 `json:"id"`
DeviceMac string `json:"deviceMac"`
EventType string `json:"eventType"`
ActorName string `json:"actorName"`
ClientIP string `json:"clientIp"`
Detail string `json:"detail"`
CreatedAt int64 `json:"createdAt"`
EndedAt int64 `json:"endedAt"`
}
type ListDeviceEventLogsResp struct {
Items []DeviceEventLog `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
+14
View File
@@ -0,0 +1,14 @@
package dto
type MeUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
AuthProvider string `json:"authProvider"`
}
type MeResp struct {
User MeUser `json:"user"`
Permissions []string `json:"permissions"`
}
+61
View File
@@ -0,0 +1,61 @@
package dto
// ─── SMTP Config ────────────────────────────────────────────────
type SMTPConfigReq struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromEmail string `json:"fromEmail"`
Encryption string `json:"encryption"`
Enabled bool `json:"enabled"`
}
type SMTPConfigResp struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromEmail string `json:"fromEmail"`
Encryption string `json:"encryption"`
Enabled bool `json:"enabled"`
UpdatedAt int64 `json:"updatedAt"`
}
// ─── SMTP Test ──────────────────────────────────────────────────
type SMTPTestReq struct {
Email string `json:"email" binding:"required"`
}
// ─── Notify Rules ───────────────────────────────────────────────
type NotifyRulesReq struct {
DeviceOnline bool `json:"deviceOnline"`
DeviceOffline bool `json:"deviceOffline"`
RemoteAccess bool `json:"remoteAccess"`
}
type NotifyRulesResp struct {
DeviceOnline bool `json:"deviceOnline"`
DeviceOffline bool `json:"deviceOffline"`
RemoteAccess bool `json:"remoteAccess"`
UpdatedAt int64 `json:"updatedAt"`
}
// ─── Recipients ─────────────────────────────────────────────────
type AddRecipientReq struct {
Email string `json:"email" binding:"required"`
}
type RecipientResp struct {
ID int64 `json:"id"`
Email string `json:"email"`
CreatedAt int64 `json:"createdAt"`
}
type ListRecipientsResp struct {
Items []RecipientResp `json:"items"`
}
+50
View File
@@ -0,0 +1,50 @@
package dto
// ---- profile ----
type PersonalProfileResp struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"`
AuthProvider string `json:"authProvider"`
RegistrationTime int64 `json:"registrationTime"`
LastLoginTime *int64 `json:"lastLoginTime"`
TotpEnabled bool `json:"totpEnabled"`
}
type UpdatePersonalProfileReq struct {
DisplayName *string `json:"displayName"`
}
// ---- 2fa ----
type Setup2faResp struct {
Secret string `json:"secret"`
OtpauthURL string `json:"otpauthUrl"`
}
type Enable2faReq struct {
Secret string `json:"secret"`
Code string `json:"code"`
}
type Disable2faReq struct {
Code string `json:"code"`
}
// ---- trusted devices ----
type TrustedDevice struct {
ID int64 `json:"id"`
DeviceName string `json:"deviceName"`
IP string `json:"ip"`
CreatedAt int64 `json:"createdAt"`
LastUsedAt int64 `json:"lastUsedAt"`
ExpiresAt int64 `json:"expiresAt"`
}
type ListTrustedDevicesResp struct {
Items []TrustedDevice `json:"items"`
}
+25
View File
@@ -0,0 +1,25 @@
package dto
type SetUserGroupsReq struct {
GroupIDs []int64 `json:"groupIds"`
}
type SetUserGroupsResp struct {
UserID int64 `json:"userId"`
GroupIDs []int64 `json:"groupIds"`
}
type SetUserGroupDeviceGroupsReq struct {
DeviceGroupIDs []int64 `json:"deviceGroupIds"`
}
type SetUserGroupDeviceGroupsResp struct {
UserGroupID int64 `json:"userGroupId"`
DeviceGroupIDs []int64 `json:"deviceGroupIds"`
}
type SetDeviceGroupDevicesReq struct {
DeviceIDs []int64 `json:"deviceIds"`
}
type SetDeviceGroupDevicesResp struct {
DeviceGroupID int64 `json:"deviceGroupId"`
DeviceIDs []int64 `json:"deviceIds"`
}
+42
View File
@@ -0,0 +1,42 @@
package dto
type UserGroupRef struct {
UserGroupID int64 `json:"userGroupId"`
UserGroupName string `json:"userGroupName"`
}
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Description string `json:"description"`
Role string `json:"role"`
IsSystem bool `json:"isSystem"`
AuthProvider string `json:"authProvider"`
UserGroupList []UserGroupRef `json:"userGroupList"`
}
type ListUsersResp struct {
Items []User `json:"items"`
}
type CreateUserReq struct {
Role string `json:"role"`
Username string `json:"username"`
Description string `json:"description"`
Password string `json:"password"`
Repassword string `json:"repassword"`
UserGroupIDs []int64 `json:"userGroupIds"`
}
type CreateUserResp struct{}
type UpdateUserReq struct {
Role *string `json:"role"`
Username *string `json:"username"`
Description *string `json:"description"`
Password *string `json:"password"`
Repassword *string `json:"repassword"`
UserGroupIDs *[]int64 `json:"userGroupIds"`
}
type DeleteUserResp struct{}
+43
View File
@@ -0,0 +1,43 @@
package dto
type UserGroupDeviceGroupRef struct {
DeviceGroupID int64 `json:"deviceGroupId"`
DeviceGroupName string `json:"deviceGroupName"`
}
type UserGroup struct {
ID int64 `json:"id"`
UserGroup string `json:"userGroup"`
Description string `json:"description"`
UserCount int64 `json:"userCount"`
DeviceGroupList []UserGroupDeviceGroupRef `json:"deviceGroupList"`
}
type ListUserGroupsResp struct {
Items []UserGroup `json:"items"`
}
type UserGroupOption struct {
UserGroupID int64 `json:"userGroupId"`
Name string `json:"name"`
}
type ListUserGroupOptionsResp struct {
Items []UserGroupOption `json:"items"`
}
type CreateUserGroupReq struct {
Name string `json:"name"`
Description string `json:"description"`
}
type CreateUserGroupResp struct {
ID int64 `json:"id"`
}
type UpdateUserGroupReq struct {
Name string `json:"name"`
Description string `json:"description"`
}
type DeleteUserGroupResp struct{}
+220
View File
@@ -0,0 +1,220 @@
package handler
import (
"rttys/internal/domain/identity"
"rttys/internal/domain/trusteddevice"
"rttys/internal/pkg/ldap"
"rttys/internal/pkg/totp"
"rttys/internal/pkg/useragent"
"rttys/xconfig"
"strings"
"time"
"rttys/internal/domain/user"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/pkg/randtoken"
"rttys/internal/store/memory"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
const (
trustedDeviceCookieName = "td"
trustedDeviceTTL = 30 * 24 * time.Hour
)
type AuthHandler struct {
userSvc *user.Service
sessionStore *memory.SessionStore
trustedDeviceRepo trusteddevice.Repository
}
func NewAuthHandler(userSvc *user.Service, sessionStore *memory.SessionStore, tdRepo trusteddevice.Repository) *AuthHandler {
return &AuthHandler{
userSvc: userSvc,
sessionStore: sessionStore,
trustedDeviceRepo: tdRepo,
}
}
// POST /api/login
func (h *AuthHandler) Login(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.LoginReq
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "username/password",
}))
return
}
cfg := xconfig.Must()
var userID int64
// ---- LDAP ----
authMethod := req.AuthMethod
if authMethod == "ldap" {
ok, errorType, userDN, isAdmin := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
if !ok {
if errorType == "authorization" {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "User not authorized", nil))
} else {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Authentication failed", nil))
}
return
}
role := identity.RoleUser
if isAdmin {
role = identity.RoleAdmin
}
ldapUser, err := h.userSvc.FindOrCreateExternalUser(c.Request.Context(), "ldap", userDN, req.Username, "", req.Username, role)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to create LDAP user", nil))
return
}
log.Info().
Str("username", req.Username).
Str("userDN", userDN).
Str("role", string(role)).
Int64("userID", ldapUser.ID).
Msg("LDAP user login completed")
userID = ldapUser.ID
} else {
u, err := h.userSvc.Authenticate(c.Request.Context(), req.Username, req.Password)
if err != nil || u == nil {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Authentication failed", nil))
return
}
// Local user with 2FA enabled: enforce TOTP unless a valid trusted-device cookie is present.
if u.TotpEnabled && u.TotpSecret != "" {
if !h.trustedDeviceCookieValid(c, u.ID) {
if strings.TrimSpace(req.TotpCode) == "" {
dto.Write(c, dto.Ok(traceID, dto.LoginResp{TwoFactorRequired: true}))
return
}
if !totp.Verify(u.TotpSecret, strings.TrimSpace(req.TotpCode)) {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Invalid verification code", nil))
return
}
// Optionally remember this device.
if req.RememberDevice {
h.issueTrustedDevice(c, u.ID)
}
}
}
userID = u.ID
}
sid, err := randtoken.New()
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
h.sessionStore.Create(sid, userID)
// Best-effort: refresh last_login_at. Failure here should not block login.
_ = h.userSvc.TouchLastLogin(c.Request.Context(), userID, time.Now().Unix())
dto.Write(c, dto.Ok(traceID, dto.LoginResp{
Token: sid,
}))
}
// trustedDeviceCookieValid returns true if the request carries a non-expired
// trusted-device cookie that maps to the given user. It also refreshes
// last_used_at as a side effect.
func (h *AuthHandler) trustedDeviceCookieValid(c *gin.Context, userID int64) bool {
if h.trustedDeviceRepo == nil {
return false
}
token, err := c.Cookie(trustedDeviceCookieName)
if err != nil || strings.TrimSpace(token) == "" {
return false
}
dev, err := h.trustedDeviceRepo.FindByToken(c.Request.Context(), strings.TrimSpace(token))
if err != nil || dev == nil {
return false
}
if dev.UserID != userID {
return false
}
now := time.Now().Unix()
if dev.ExpiresAt < now {
_ = h.trustedDeviceRepo.Delete(c.Request.Context(), dev.ID, dev.UserID)
return false
}
_ = h.trustedDeviceRepo.TouchLastUsed(c.Request.Context(), dev.ID, now)
return true
}
// issueTrustedDevice creates a new trusted-device record and writes the token cookie.
func (h *AuthHandler) issueTrustedDevice(c *gin.Context, userID int64) {
if h.trustedDeviceRepo == nil {
return
}
token, err := randtoken.New()
if err != nil {
log.Warn().Err(err).Msg("trusted device: generate token failed")
return
}
now := time.Now()
dev := &trusteddevice.Device{
UserID: userID,
Token: token,
DeviceName: trimToLen(useragent.Friendly(c.Request.UserAgent()), 200),
IP: clientIP(c),
CreatedAt: now.Unix(),
LastUsedAt: now.Unix(),
ExpiresAt: now.Add(trustedDeviceTTL).Unix(),
}
if _, err := h.trustedDeviceRepo.Create(c.Request.Context(), dev); err != nil {
log.Warn().Err(err).Msg("trusted device: create failed")
return
}
c.SetCookie(trustedDeviceCookieName, token, int(trustedDeviceTTL.Seconds()), "/", "", false, true)
}
func clientIP(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
if ip := c.ClientIP(); ip != "" {
return ip
}
return c.Request.RemoteAddr
}
func trimToLen(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
// POST /api/logout
func (h *AuthHandler) Logout(c *gin.Context) {
traceID := middleware.GetTraceID(c)
// 1) Try bearer
authz := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
token := strings.TrimSpace(authz[7:])
if token != "" {
h.sessionStore.Delete(token)
}
}
// 2) Try cookie sid
if sid, err := c.Cookie("sid"); err == nil && strings.TrimSpace(sid) != "" {
h.sessionStore.Delete(strings.TrimSpace(sid))
// 清 cookie
c.SetCookie("sid", "", -1, "/", "", false, true)
}
dto.Write(c, dto.Ok(traceID, dto.LogoutResp{}))
}
+344
View File
@@ -0,0 +1,344 @@
package handler
import (
"errors"
"io"
"sort"
"strconv"
"strings"
"rttys/internal/domain/device"
"rttys/internal/domain/identity"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/store/sqlite"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DeviceHandler struct {
devSvc *device.Service
groupRepo *sqlite.GroupRepo
relationsRepo *sqlite.RelationsRepo
}
func NewDeviceHandler(
devSvc *device.Service,
groupRepo *sqlite.GroupRepo,
relationsRepo *sqlite.RelationsRepo,
) *DeviceHandler {
return &DeviceHandler{
devSvc: devSvc,
groupRepo: groupRepo,
relationsRepo: relationsRepo,
}
}
// GET /api/devices
func (h *DeviceHandler) ListDevices(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
var filterGroupID *int64
if raw := strings.TrimSpace(c.Query("groupId")); raw != "" {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "groupId",
}))
return
}
filterGroupID = &id
}
isAdmin := p.Role == identity.RoleAdmin
var items []device.Device
if isAdmin {
var err error
if filterGroupID != nil {
items, err = h.devSvc.ListByDeviceGroupIDs(c.Request.Context(), []int64{*filterGroupID})
} else {
items, err = h.devSvc.ListVisible(c.Request.Context(), p.Role, p.UserID)
}
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
} else {
if h.groupRepo == nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
dgIDs, err := h.groupRepo.ListDeviceGroupIDsByUser(c.Request.Context(), p.UserID)
if err != nil || len(dgIDs) == 0 {
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
Items: []dto.Device{},
Page: 1,
PageSize: 0,
Total: 0,
}))
return
}
if filterGroupID != nil {
allowed := false
for _, gid := range dgIDs {
if gid == *filterGroupID {
allowed = true
break
}
}
if !allowed {
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
Items: []dto.Device{},
Page: 1,
PageSize: 0,
Total: 0,
}))
return
}
dgIDs = []int64{*filterGroupID}
}
items, err = h.devSvc.ListByDeviceGroupIDs(c.Request.Context(), dgIDs)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
}
groupNameByID := map[int64]string{}
if h.groupRepo != nil {
groups, err := h.groupRepo.ListDeviceGroupsVisibleToUser(c.Request.Context(), p.UserID, isAdmin)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
for _, g := range groups {
groupNameByID[g.ID] = g.Name
}
}
// Parse sort parameters: sortBy and order
sortBy := strings.TrimSpace(c.Query("sortBy")) // id, ip, mac, connectedTime, description, ddns
sortOrder := strings.TrimSpace(c.Query("order")) // asc, desc (default: asc)
ascending := true
if strings.EqualFold(sortOrder, "desc") {
ascending = false
}
sort.SliceStable(items, func(i, j int) bool {
// Online devices always come first regardless of sort field/order
oi := items[i].Status == device.StatusOnline
oj := items[j].Status == device.StatusOnline
if oi != oj {
return oi
}
// Secondary sort by the requested field
// cmp: -1 means i<j, 0 means equal, 1 means i>j
var cmp int
switch sortBy {
case "id":
switch {
case items[i].ID < items[j].ID:
cmp = -1
case items[i].ID > items[j].ID:
cmp = 1
}
case "ip":
cmp = strings.Compare(items[i].IP, items[j].IP)
case "mac":
cmp = strings.Compare(items[i].Mac, items[j].Mac)
case "connectedTime":
var ti, tj int64
if items[i].LastSeenAt != nil {
ti = *items[i].LastSeenAt
}
if items[j].LastSeenAt != nil {
tj = *items[j].LastSeenAt
}
switch {
case ti < tj:
cmp = -1
case ti > tj:
cmp = 1
}
case "description":
cmp = strings.Compare(items[i].Description, items[j].Description)
case "ddns":
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
case "deviceGroupName":
var gi, gj string
if items[i].DeviceGroupID != nil {
gi = groupNameByID[*items[i].DeviceGroupID]
}
if items[j].DeviceGroupID != nil {
gj = groupNameByID[*items[j].DeviceGroupID]
}
cmp = strings.Compare(gi, gj)
default:
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
}
if cmp == 0 {
return false // equal, preserve original order
}
if ascending {
return cmp < 0
}
return cmp > 0
})
out := make([]dto.Device, 0, len(items))
for _, d := range items {
var groupName string
if d.DeviceGroupID != nil {
groupName = groupNameByID[*d.DeviceGroupID]
}
var connectedTime int64
if d.LastSeenAt != nil {
connectedTime = *d.LastSeenAt
}
out = append(out, dto.Device{
ID: d.ID,
Ddns: d.Ddns,
Status: string(d.Status),
ConnectedTime: connectedTime,
IP: d.IP,
Mac: d.Mac,
Description: d.Description,
Client: d.Client,
DeviceGroupID: d.DeviceGroupID,
DeviceGroupName: groupName,
})
}
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
Items: out,
Page: 1,
PageSize: len(out),
Total: len(out),
}))
}
type UpdateDeviceRequest struct {
Description *string `json:"description"`
}
// PUT /api/devices/:id
func (h *DeviceHandler) UpdateDevice(c *gin.Context) {
traceID := middleware.GetTraceID(c)
idStr := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "id",
}))
return
}
var req UpdateDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "description",
"error": err.Error(),
}))
return
}
db := sqlite.MustContainer().Gorm.WithContext(c.Request.Context())
var row struct {
Description string `gorm:"column:description"`
}
tx := db.Table("devices").Select("description").Where("id = ?", id).First(&row)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Device not found", nil))
return
}
if tx.Error != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{
"detail": tx.Error.Error(),
}))
return
}
newDesc := row.Description
if req.Description != nil {
newDesc = *req.Description
}
if req.Description != nil {
res := db.Exec(
`UPDATE devices SET description=? WHERE id=?`,
newDesc, id,
)
if res.Error != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{
"detail": res.Error.Error(),
}))
return
}
if res.RowsAffected == 0 {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Device not found", nil))
return
}
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
// DELETE /api/devices/:id
func (h *DeviceHandler) DeleteDevice(c *gin.Context) {
traceID := middleware.GetTraceID(c)
idStr := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "id",
}))
return
}
db := sqlite.MustContainer().Gorm.WithContext(c.Request.Context())
res := db.Exec(`DELETE FROM devices WHERE id=?`, id)
if res.Error != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{
"detail": res.Error.Error(),
}))
return
}
if res.RowsAffected == 0 {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Device not found", nil))
return
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
// POST /api/devices/move-to-device-group
func (h *DeviceHandler) MoveToDeviceGroup(c *gin.Context) {
traceID := middleware.GetTraceID(c)
_ = middleware.MustPrincipal(c)
if h.relationsRepo == nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
var req dto.MoveDevicesToGroupReq
if err := c.ShouldBindJSON(&req); err != nil || req.GroupID <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "groupId"}))
return
}
if err := h.relationsRepo.AddDevicesToGroup(c.Request.Context(), req.GroupID, req.DeviceIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
dto.Write(c, dto.Ok(traceID, dto.MoveDevicesToGroupResp{}))
}
+215
View File
@@ -0,0 +1,215 @@
package handler
import (
"rttys/internal/domain/devicegroup"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/store/sqlite"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
type DeviceGroupHandler struct {
groupRepo *sqlite.GroupRepo
relationsRepo *sqlite.RelationsRepo
}
func NewDeviceGroupHandler(groupRepo *sqlite.GroupRepo, relationsRepo *sqlite.RelationsRepo) *DeviceGroupHandler {
return &DeviceGroupHandler{groupRepo: groupRepo, relationsRepo: relationsRepo}
}
// GET /api/device-groups
func (h *DeviceGroupHandler) ListDeviceGroups(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
items, err := h.groupRepo.ListDeviceGroupDetails(c.Request.Context(), p.UserID, string(p.Role) == "admin")
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
out := make([]dto.DeviceGroup, 0, len(items))
for _, it := range items {
userGroups := make([]dto.DeviceGroupUserGroupRef, 0, len(it.UserGroups))
for _, ug := range it.UserGroups {
userGroups = append(userGroups, dto.DeviceGroupUserGroupRef{
UserGroupID: ug.ID,
UserGroupName: ug.Name,
})
}
out = append(out, dto.DeviceGroup{
ID: it.ID,
Name: it.Name,
DeviceCount: it.DeviceCount,
Description: it.Description,
UserGroupList: userGroups,
})
}
dto.Write(c, dto.Ok(traceID, dto.ListDeviceGroupsResp{Items: out}))
}
var _ devicegroup.DeviceGroup
// GET /api/device-groups/options
func (h *DeviceGroupHandler) ListOptions(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
items, err := h.groupRepo.ListDeviceGroupsVisibleToUser(c.Request.Context(), p.UserID, string(p.Role) == "admin")
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
out := make([]dto.DeviceGroupOption, 0, len(items))
for _, it := range items {
out = append(out, dto.DeviceGroupOption{GroupID: it.ID, Name: it.Name})
}
dto.Write(c, dto.Ok(traceID, dto.ListDeviceGroupOptionsResp{Items: out}))
}
func (h *DeviceGroupHandler) Create(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.CreateDeviceGroupReq
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "name"}))
return
}
id, err := h.groupRepo.CreateDeviceGroup(c.Request.Context(), req.Name, req.Description)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
dto.Write(c, dto.Err(traceID, dto.CodeConflict, "Name already exists", nil))
return
}
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
if h.relationsRepo != nil {
if err := h.relationsRepo.SetDeviceGroupUserGroups(c.Request.Context(), id, req.UserGroupIDs); err != nil {
_ = h.groupRepo.DeleteDeviceGroup(c.Request.Context(), id)
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
if err := h.relationsRepo.AddDevicesToGroup(c.Request.Context(), id, req.DeviceIDs); err != nil {
_ = h.groupRepo.DeleteDeviceGroup(c.Request.Context(), id)
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
}
dto.Write(c, dto.Ok(traceID, dto.CreateDeviceGroupResp{ID: id}))
}
func (h *DeviceGroupHandler) Update(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
var req dto.UpdateDeviceGroupReq
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "name"}))
return
}
if err := h.groupRepo.UpdateDeviceGroup(c.Request.Context(), id, req.Name, req.Description); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
dto.Write(c, dto.Err(traceID, dto.CodeConflict, "Name already exists", nil))
return
}
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
if h.relationsRepo != nil {
if err := h.relationsRepo.SetDeviceGroupUserGroups(c.Request.Context(), id, req.UserGroupIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
func (h *DeviceGroupHandler) Delete(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
if err := h.groupRepo.DeleteDeviceGroup(c.Request.Context(), id); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.DeleteDeviceGroupResp{}))
}
// POST /api/device-groups/:id/devices
func (h *DeviceGroupHandler) AddDevices(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
var req dto.ModifyDeviceGroupDevicesReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if h.relationsRepo == nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
if err := h.relationsRepo.AddDevicesToGroup(c.Request.Context(), id, req.DeviceIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
// DELETE /api/device-groups/:id/devices
func (h *DeviceGroupHandler) RemoveDevices(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
var req dto.ModifyDeviceGroupDevicesReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if h.relationsRepo == nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
if err := h.relationsRepo.RemoveDevicesFromGroup(c.Request.Context(), id, req.DeviceIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
+117
View File
@@ -0,0 +1,117 @@
package handler
import (
"strconv"
"strings"
"rttys/internal/domain/devicelog"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"github.com/gin-gonic/gin"
)
// DeviceLogHandler exposes /api/device-event-logs for admins.
type DeviceLogHandler struct {
svc *devicelog.Service
}
func NewDeviceLogHandler(svc *devicelog.Service) *DeviceLogHandler {
return &DeviceLogHandler{svc: svc}
}
// GET /api/device-event-logs?mac=&types=device_online,remote_ssh&from=&to=&page=1&pageSize=20
func (h *DeviceLogHandler) List(c *gin.Context) {
traceID := middleware.GetTraceID(c)
if h.svc == nil {
dto.Write(c, dto.Ok(traceID, dto.ListDeviceEventLogsResp{
Items: []dto.DeviceEventLog{}, Total: 0, Page: 1, PageSize: 20,
}))
return
}
q := devicelog.Query{
Mac: strings.TrimSpace(c.Query("mac")),
EventTypes: parseEventTypes(c.Query("types")),
From: parseInt64(c.Query("from")),
To: parseInt64(c.Query("to")),
Page: parseInt(c.Query("page")),
PageSize: parseInt(c.Query("pageSize")),
}
rows, total, err := h.svc.Query(c.Request.Context(), q)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
out := make([]dto.DeviceEventLog, 0, len(rows))
for _, r := range rows {
out = append(out, dto.DeviceEventLog{
ID: r.ID,
DeviceMac: r.DeviceMac,
EventType: string(r.EventType),
ActorName: r.ActorName,
ClientIP: r.ClientIP,
Detail: r.Detail,
CreatedAt: r.CreatedAt,
EndedAt: r.EndedAt,
})
}
page := q.Page
if page < 1 {
page = 1
}
pageSize := q.PageSize
if pageSize <= 0 {
pageSize = 20
}
dto.Write(c, dto.Ok(traceID, dto.ListDeviceEventLogsResp{
Items: out,
Total: total,
Page: page,
PageSize: pageSize,
}))
}
func parseEventTypes(raw string) []devicelog.EventType {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]devicelog.EventType, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
switch devicelog.EventType(p) {
case devicelog.EventDeviceOnline,
devicelog.EventDeviceOffline,
devicelog.EventRemoteSSH,
devicelog.EventRemoteWeb,
devicelog.EventRemoteControl:
out = append(out, devicelog.EventType(p))
}
}
return out
}
func parseInt64(s string) int64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
v, _ := strconv.ParseInt(s, 10, 64)
return v
}
func parseInt(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
v, _ := strconv.Atoi(s)
return v
}
+29
View File
@@ -0,0 +1,29 @@
package handler
import (
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"github.com/gin-gonic/gin"
)
type MeHandler struct{}
func NewMeHandler() *MeHandler { return &MeHandler{} }
// GET /api/me
func (h *MeHandler) GetMe(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
dto.Write(c, dto.Ok(traceID, dto.MeResp{
User: dto.MeUser{
ID: p.UserID,
Username: p.Username,
DisplayName: p.DisplayName,
Role: string(p.Role),
AuthProvider: p.AuthProvider,
},
Permissions: p.PermissionKeys,
}))
}
+180
View File
@@ -0,0 +1,180 @@
package handler
import (
"rttys/internal/domain/notification"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
svc *notification.Service
}
func NewNotificationHandler(svc *notification.Service) *NotificationHandler {
return &NotificationHandler{svc: svc}
}
// GET /api/notification/smtp
func (h *NotificationHandler) GetSMTPConfig(c *gin.Context) {
traceID := middleware.GetTraceID(c)
cfg, err := h.svc.GetSMTPConfig(c.Request.Context())
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to load SMTP config", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.SMTPConfigResp{
Host: cfg.Host,
Port: cfg.Port,
Username: cfg.Username,
Password: cfg.Password,
FromEmail: cfg.FromEmail,
Encryption: cfg.Encryption,
Enabled: cfg.Enabled,
UpdatedAt: cfg.UpdatedAt,
}))
}
// PUT /api/notification/smtp
func (h *NotificationHandler) SaveSMTPConfig(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.SMTPConfigReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
return
}
cfg := &notification.SMTPConfig{
Host: req.Host,
Port: req.Port,
Username: req.Username,
Password: req.Password,
FromEmail: req.FromEmail,
Encryption: req.Encryption,
Enabled: req.Enabled,
}
if err := h.svc.SaveSMTPConfig(c.Request.Context(), cfg); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to save SMTP config", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.SMTPConfigResp{
Host: cfg.Host,
Port: cfg.Port,
Username: cfg.Username,
Password: cfg.Password,
FromEmail: cfg.FromEmail,
Encryption: cfg.Encryption,
Enabled: cfg.Enabled,
UpdatedAt: cfg.UpdatedAt,
}))
}
// POST /api/notification/smtp/test
func (h *NotificationHandler) TestSMTP(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.SMTPTestReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
return
}
if err := h.svc.TestSMTP(c.Request.Context(), req.Email); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, err.Error(), nil))
return
}
dto.Write(c, dto.Ok(traceID, gin.H{"message": "Test email sent successfully"}))
}
// GET /api/notification/rules
func (h *NotificationHandler) GetNotifyRules(c *gin.Context) {
traceID := middleware.GetTraceID(c)
rules, err := h.svc.GetNotifyRules(c.Request.Context())
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to load rules", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.NotifyRulesResp{
DeviceOnline: rules.DeviceOnline,
DeviceOffline: rules.DeviceOffline,
RemoteAccess: rules.RemoteAccess,
UpdatedAt: rules.UpdatedAt,
}))
}
// PUT /api/notification/rules
func (h *NotificationHandler) SaveNotifyRules(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.NotifyRulesReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
return
}
rules := &notification.NotifyRules{
DeviceOnline: req.DeviceOnline,
DeviceOffline: req.DeviceOffline,
RemoteAccess: req.RemoteAccess,
}
if err := h.svc.SaveNotifyRules(c.Request.Context(), rules); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to save rules", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.NotifyRulesResp{
DeviceOnline: rules.DeviceOnline,
DeviceOffline: rules.DeviceOffline,
RemoteAccess: rules.RemoteAccess,
UpdatedAt: rules.UpdatedAt,
}))
}
// GET /api/notification/recipients
func (h *NotificationHandler) ListRecipients(c *gin.Context) {
traceID := middleware.GetTraceID(c)
list, err := h.svc.ListRecipients(c.Request.Context())
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to list recipients", nil))
return
}
items := make([]dto.RecipientResp, 0, len(list))
for _, r := range list {
items = append(items, dto.RecipientResp{
ID: r.ID,
Email: r.Email,
CreatedAt: r.CreatedAt,
})
}
dto.Write(c, dto.Ok(traceID, dto.ListRecipientsResp{Items: items}))
}
// POST /api/notification/recipients
func (h *NotificationHandler) AddRecipient(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.AddRecipientReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
return
}
r, err := h.svc.AddRecipient(c.Request.Context(), req.Email)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to add recipient", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.RecipientResp{
ID: r.ID,
Email: r.Email,
CreatedAt: r.CreatedAt,
}))
}
// DELETE /api/notification/recipients/:id
func (h *NotificationHandler) RemoveRecipient(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id := parseInt64(c.Param("id"))
if id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid ID", nil))
return
}
if err := h.svc.RemoveRecipient(c.Request.Context(), id); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to remove recipient", nil))
return
}
dto.Write(c, dto.Ok(traceID, gin.H{"message": "Recipient removed"}))
}
+244
View File
@@ -0,0 +1,244 @@
package handler
import (
"strconv"
"strings"
"time"
"rttys/internal/domain/trusteddevice"
"rttys/internal/domain/user"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/pkg/totp"
"rttys/internal/pkg/useragent"
"github.com/gin-gonic/gin"
)
// PersonalHandler exposes /api/me/profile and /api/me/2fa/* endpoints
// for the logged-in user to view and edit their own account.
type PersonalHandler struct {
userSvc *user.Service
trustedDeviceSvc trusteddevice.Repository
issuer string
}
func NewPersonalHandler(userSvc *user.Service, tdRepo trusteddevice.Repository, issuer string) *PersonalHandler {
if issuer == "" {
issuer = "GLKVM Cloud"
}
return &PersonalHandler{userSvc: userSvc, trustedDeviceSvc: tdRepo, issuer: issuer}
}
// GET /api/me/profile
func (h *PersonalHandler) GetProfile(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
u, err := h.userSvc.FindByID(c.Request.Context(), p.UserID)
if err != nil || u == nil {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "User not found", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.PersonalProfileResp{
ID: u.ID,
Username: u.Username,
DisplayName: u.Description,
Email: u.Email,
Role: string(u.Role),
AuthProvider: normalizedAuthProvider(u.AuthProvider),
RegistrationTime: u.CreatedAt,
LastLoginTime: u.LastLoginAt,
TotpEnabled: u.TotpEnabled,
}))
}
// PUT /api/me/profile
func (h *PersonalHandler) UpdateProfile(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
var req dto.UpdatePersonalProfileReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if req.DisplayName != nil {
desc := strings.TrimSpace(*req.DisplayName)
if len(desc) > 200 {
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Display name too long", nil))
return
}
if err := h.userSvc.UpdateDescription(c.Request.Context(), p.UserID, desc); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
// POST /api/me/2fa/setup
//
// Generates a fresh TOTP secret and otpauth URL. The secret is NOT persisted
// until the client confirms by calling /api/me/2fa/enable with a valid code.
func (h *PersonalHandler) Setup2fa(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
if !isLocalAuthProvider(p.AuthProvider) {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "2FA is managed by your identity provider", nil))
return
}
secret, url, err := totp.GenerateSecret(h.issuer, p.Username)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to generate secret", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.Setup2faResp{Secret: secret, OtpauthURL: url}))
}
// POST /api/me/2fa/enable
func (h *PersonalHandler) Enable2fa(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
if !isLocalAuthProvider(p.AuthProvider) {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "2FA is managed by your identity provider", nil))
return
}
var req dto.Enable2faReq
if err := c.ShouldBindJSON(&req); err != nil || req.Secret == "" || req.Code == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if !totp.Verify(req.Secret, req.Code) {
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Invalid verification code", nil))
return
}
if err := h.userSvc.SetTotp(c.Request.Context(), p.UserID, req.Secret, true); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
// POST /api/me/2fa/disable
//
// Requires a current valid TOTP code. After disabling, all trusted-device
// records for this user are revoked.
func (h *PersonalHandler) Disable2fa(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
if !isLocalAuthProvider(p.AuthProvider) {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "2FA is managed by your identity provider", nil))
return
}
var req dto.Disable2faReq
if err := c.ShouldBindJSON(&req); err != nil || req.Code == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
u, err := h.userSvc.FindByID(c.Request.Context(), p.UserID)
if err != nil || u == nil {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "User not found", nil))
return
}
if !u.TotpEnabled || u.TotpSecret == "" {
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "2FA is not enabled", nil))
return
}
if !totp.Verify(u.TotpSecret, req.Code) {
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Invalid verification code", nil))
return
}
if err := h.userSvc.SetTotp(c.Request.Context(), p.UserID, "", false); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
if h.trustedDeviceSvc != nil {
_ = h.trustedDeviceSvc.DeleteByUserID(c.Request.Context(), p.UserID)
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
// GET /api/me/2fa/trusted-devices
func (h *PersonalHandler) ListTrustedDevices(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
if h.trustedDeviceSvc == nil {
dto.Write(c, dto.Ok(traceID, dto.ListTrustedDevicesResp{Items: []dto.TrustedDevice{}}))
return
}
// Lazy-clean expired records so the list never shows stale entries.
_ = h.trustedDeviceSvc.DeleteExpired(c.Request.Context(), time.Now().Unix())
rows, err := h.trustedDeviceSvc.ListByUserID(c.Request.Context(), p.UserID)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
out := make([]dto.TrustedDevice, 0, len(rows))
for _, r := range rows {
// Re-parse the stored device name on every read so legacy records
// (which contain the raw User-Agent header) get rendered with the same
// short label as freshly-issued ones — no DB migration needed.
out = append(out, dto.TrustedDevice{
ID: r.ID,
DeviceName: useragent.Friendly(r.DeviceName),
IP: r.IP,
CreatedAt: r.CreatedAt,
LastUsedAt: r.LastUsedAt,
ExpiresAt: r.ExpiresAt,
})
}
dto.Write(c, dto.Ok(traceID, dto.ListTrustedDevicesResp{Items: out}))
}
// DELETE /api/me/2fa/trusted-devices/:id
func (h *PersonalHandler) RevokeTrustedDevice(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if h.trustedDeviceSvc != nil {
if err := h.trustedDeviceSvc.Delete(c.Request.Context(), id, p.UserID); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
func isLocalAuthProvider(p string) bool {
return p == "" || p == "local"
}
func normalizedAuthProvider(p string) string {
if p == "" {
return "local"
}
return p
}
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"strconv"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/store/sqlite"
"github.com/gin-gonic/gin"
)
type RelationsHandler struct{ repo *sqlite.RelationsRepo }
func NewRelationsHandler(repo *sqlite.RelationsRepo) *RelationsHandler {
return &RelationsHandler{repo: repo}
}
func (h *RelationsHandler) SetUserGroups(c *gin.Context) {
traceID := middleware.GetTraceID(c)
_ = middleware.MustPrincipal(c)
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || userID <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
var req dto.SetUserGroupsReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if err := h.repo.SetUserGroups(c.Request.Context(), userID, req.GroupIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
dto.Write(c, dto.Ok(traceID, dto.SetUserGroupsResp{UserID: userID, GroupIDs: req.GroupIDs}))
}
func (h *RelationsHandler) SetUserGroupDeviceGroups(c *gin.Context) {
traceID := middleware.GetTraceID(c)
_ = middleware.MustPrincipal(c)
ugID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || ugID <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
var req dto.SetUserGroupDeviceGroupsReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if err := h.repo.SetUserGroupDeviceGroups(c.Request.Context(), ugID, req.DeviceGroupIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
dto.Write(c, dto.Ok(traceID, dto.SetUserGroupDeviceGroupsResp{UserGroupID: ugID, DeviceGroupIDs: req.DeviceGroupIDs}))
}
func (h *RelationsHandler) SetDeviceGroupDevices(c *gin.Context) {
traceID := middleware.GetTraceID(c)
_ = middleware.MustPrincipal(c)
dgID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || dgID <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
var req dto.SetDeviceGroupDevicesReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if err := h.repo.SetDeviceGroupDevices(c.Request.Context(), dgID, req.DeviceIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
dto.Write(c, dto.Ok(traceID, dto.SetDeviceGroupDevicesResp{DeviceGroupID: dgID, DeviceIDs: req.DeviceIDs}))
}
+248
View File
@@ -0,0 +1,248 @@
package handler
import (
"rttys/internal/domain/identity"
"sort"
"strconv"
"strings"
"rttys/internal/domain/user"
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/store/memory"
"rttys/internal/store/sqlite"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
userSvc *user.Service
groupRepo *sqlite.GroupRepo
relationsRepo *sqlite.RelationsRepo
sessionStore *memory.SessionStore
}
func NewUserHandler(userSvc *user.Service, groupRepo *sqlite.GroupRepo, relationsRepo *sqlite.RelationsRepo, sessionStore *memory.SessionStore) *UserHandler {
return &UserHandler{
userSvc: userSvc,
groupRepo: groupRepo,
relationsRepo: relationsRepo,
sessionStore: sessionStore,
}
}
func (h *UserHandler) ListUsers(c *gin.Context) {
traceID := middleware.GetTraceID(c)
items, err := h.userSvc.List(c.Request.Context())
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
sort.SliceStable(items, func(i, j int) bool {
rank := func(u user.User) int {
if u.IsSystem {
return 0
}
if u.Role == identity.RoleAdmin {
return 1
}
return 2
}
ri := rank(items[i])
rj := rank(items[j])
if ri != rj {
return ri < rj
}
return false
})
userIDs := make([]int64, 0, len(items))
for _, u := range items {
userIDs = append(userIDs, u.ID)
}
var groupsByUserID map[int64][]sqlite.UserGroupBrief
if h.groupRepo != nil {
groupsByUserID, err = h.groupRepo.ListUserGroupsByUserIDs(c.Request.Context(), userIDs)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
}
out := make([]dto.User, 0, len(items))
for _, u := range items {
groups := make([]dto.UserGroupRef, 0)
if list, ok := groupsByUserID[u.ID]; ok {
for _, g := range list {
groups = append(groups, dto.UserGroupRef{
UserGroupID: g.ID,
UserGroupName: g.Name,
})
}
}
out = append(out, dto.User{
ID: u.ID,
Role: string(u.Role),
Username: u.Username,
Description: u.Description,
IsSystem: u.IsSystem,
AuthProvider: u.AuthProvider,
UserGroupList: groups,
})
}
dto.Write(c, dto.Ok(traceID, dto.ListUsersResp{Items: out}))
}
func (h *UserHandler) CreateUser(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.CreateUserReq
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "username/password",
}))
return
}
if req.Repassword != "" && req.Repassword != req.Password {
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Passwords do not match", map[string]any{
"field": "repassword",
}))
return
}
if req.Role == "" {
req.Role = "user"
}
status := "active"
id, err := h.userSvc.CreateUser(c.Request.Context(), req.Username, req.Description, req.Password, req.Role, status)
if err != nil {
// best-effort conflict detection
if strings.Contains(strings.ToLower(err.Error()), "unique") {
dto.Write(c, dto.Err(traceID, dto.CodeConflict, "Username already exists", nil))
return
}
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
if h.relationsRepo != nil {
if err := h.relationsRepo.SetUserGroups(c.Request.Context(), id, req.UserGroupIDs); err != nil {
_ = h.userSvc.DeleteUser(c.Request.Context(), id)
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
}
dto.Write(c, dto.Ok(traceID, dto.CreateUserResp{}))
}
func (h *UserHandler) UpdateUser(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "id",
}))
return
}
var req dto.UpdateUserReq
if err := c.ShouldBindJSON(&req); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
return
}
if req.Password != nil && req.Repassword != nil && *req.Password != *req.Repassword {
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Passwords do not match", map[string]any{
"field": "repassword",
}))
return
}
p := middleware.MustPrincipal(c)
target, err := h.userSvc.FindByID(c.Request.Context(), id)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
return
}
if target.IsSystem {
req.Username = nil
req.Role = nil
req.Password = nil
req.Repassword = nil
}
// Users cannot change their own role
if id == p.UserID {
req.Role = nil
}
// External users (OIDC/LDAP): username and password are managed by the IdP
if target.AuthProvider != "" && target.AuthProvider != "local" {
req.Username = nil
req.Password = nil
req.Repassword = nil
}
if err := h.userSvc.UpdateUser(c.Request.Context(), id, req.Username, req.Description, req.Password, req.Role, nil); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
return
}
if strings.Contains(strings.ToLower(err.Error()), "unique") {
dto.Write(c, dto.Err(traceID, dto.CodeConflict, "Username already exists", nil))
return
}
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
if h.relationsRepo != nil && req.UserGroupIDs != nil {
if err := h.relationsRepo.SetUserGroups(c.Request.Context(), id, *req.UserGroupIDs); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", map[string]any{"detail": err.Error()}))
return
}
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
func (h *UserHandler) DeleteUser(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{
"field": "id",
}))
return
}
if id == p.UserID {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Cannot delete your own account", nil))
return
}
u, err := h.userSvc.FindByID(c.Request.Context(), id)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
return
}
if u.IsSystem {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "System user cannot be deleted", nil))
return
}
if h.sessionStore != nil {
h.sessionStore.DeleteByUserID(id)
}
if err := h.userSvc.DeleteUser(c.Request.Context(), id); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.DeleteUserResp{}))
}
+133
View File
@@ -0,0 +1,133 @@
package handler
import (
"rttys/internal/http/dto"
"rttys/internal/http/middleware"
"rttys/internal/store/sqlite"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
type UserGroupHandler struct {
groupRepo *sqlite.GroupRepo
}
func NewUserGroupHandler(groupRepo *sqlite.GroupRepo) *UserGroupHandler {
return &UserGroupHandler{groupRepo: groupRepo}
}
// GET /api/user-groups
func (h *UserGroupHandler) ListUserGroups(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
items, err := h.groupRepo.ListUserGroupDetails(c.Request.Context(), p.UserID, string(p.Role) == "admin")
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
out := make([]dto.UserGroup, 0, len(items))
for _, it := range items {
deviceGroups := make([]dto.UserGroupDeviceGroupRef, 0, len(it.DeviceGroups))
for _, dg := range it.DeviceGroups {
deviceGroups = append(deviceGroups, dto.UserGroupDeviceGroupRef{
DeviceGroupID: dg.ID,
DeviceGroupName: dg.Name,
})
}
out = append(out, dto.UserGroup{
ID: it.ID,
UserGroup: it.Name,
Description: it.Description,
UserCount: it.UserCount,
DeviceGroupList: deviceGroups,
})
}
dto.Write(c, dto.Ok(traceID, dto.ListUserGroupsResp{Items: out}))
}
// GET /api/user-groups/options
func (h *UserGroupHandler) ListOptions(c *gin.Context) {
traceID := middleware.GetTraceID(c)
p := middleware.MustPrincipal(c)
items, err := h.groupRepo.ListUserGroupsVisibleToUser(c.Request.Context(), p.UserID, string(p.Role) == "admin")
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
out := make([]dto.UserGroupOption, 0, len(items))
for _, it := range items {
out = append(out, dto.UserGroupOption{UserGroupID: it.ID, Name: it.Name})
}
dto.Write(c, dto.Ok(traceID, dto.ListUserGroupOptionsResp{Items: out}))
}
func (h *UserGroupHandler) Create(c *gin.Context) {
traceID := middleware.GetTraceID(c)
var req dto.CreateUserGroupReq
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "name"}))
return
}
id, err := h.groupRepo.CreateUserGroup(c.Request.Context(), req.Name, req.Description)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
dto.Write(c, dto.Err(traceID, dto.CodeConflict, "Name already exists", nil))
return
}
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.CreateUserGroupResp{ID: id}))
}
func (h *UserGroupHandler) Update(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
var req dto.UpdateUserGroupReq
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "name"}))
return
}
if err := h.groupRepo.UpdateUserGroup(c.Request.Context(), id, req.Name, req.Description); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
dto.Write(c, dto.Err(traceID, dto.CodeConflict, "Name already exists", nil))
return
}
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
dto.Write(c, dto.Ok(traceID, struct{}{}))
}
func (h *UserGroupHandler) Delete(c *gin.Context) {
traceID := middleware.GetTraceID(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", map[string]any{"field": "id"}))
return
}
if err := h.groupRepo.DeleteUserGroup(c.Request.Context(), id); err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
return
}
dto.Write(c, dto.Ok(traceID, dto.DeleteUserGroupResp{}))
}
+19
View File
@@ -0,0 +1,19 @@
package handler
import (
"context"
)
type UserGroupAdminHandler struct {
create func(ctx context.Context, name, description string) (int64, error)
update func(ctx context.Context, id int64, name, description string) error
delete func(ctx context.Context, id int64) error
}
func NewUserGroupAdminHandler(
create func(ctx context.Context, name, description string) (int64, error),
update func(ctx context.Context, id int64, name, description string) error,
del func(ctx context.Context, id int64) error,
) *UserGroupAdminHandler {
return &UserGroupAdminHandler{create: create, update: update, delete: del}
}
+137
View File
@@ -0,0 +1,137 @@
package middleware
import (
"net/http"
"rttys/internal/domain/identity"
"strings"
"rttys/internal/domain/permission"
"rttys/internal/domain/user"
"rttys/internal/http/dto"
"rttys/internal/store/memory"
"github.com/gin-gonic/gin"
)
const PrincipalKey = "principal"
type Principal struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role identity.Role `json:"role"`
AuthProvider string `json:"authProvider"`
PermissionKeys []string `json:"permissions"`
}
func MustPrincipal(c *gin.Context) Principal {
v, ok := c.Get(PrincipalKey)
if !ok {
panic("principal missing")
}
return v.(Principal)
}
func Auth(sessionStore *memory.SessionStore, userSvc *user.Service, permSvc *permission.Service) gin.HandlerFunc {
return func(c *gin.Context) {
traceID := GetTraceID(c)
// 1) Prefer Bearer token
token := parseBearer(c.GetHeader("Authorization"))
// 2) Fallback to cookie sid
if token == "" {
sid, err := c.Cookie("sid")
if err == nil {
token = strings.TrimSpace(sid)
}
}
// 3) Fallback to Token header (compat with API docs)
if token == "" {
token = strings.TrimSpace(c.GetHeader("Token"))
}
if token == "" {
dto.Write(c, dto.Err(traceID, dto.CodeAuthRequired, "Please login", nil))
c.Abort()
return
}
sess, ok := sessionStore.Get(token)
if !ok {
dto.Write(c, dto.Err(traceID, dto.CodeAuthExpired, "Session expired", nil))
c.Abort()
return
}
u, err := userSvc.GetByID(c.Request.Context(), sess.UserID)
if err != nil || u == nil {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Permission denied", nil))
c.Abort()
return
}
keys, _ := permSvc.ListByRole(c.Request.Context(), u.Role)
perms := make([]string, 0, len(keys))
for _, k := range keys {
perms = append(perms, string(k))
}
displayName := u.Description
if strings.TrimSpace(displayName) == "" {
displayName = u.Username
}
authProvider := u.AuthProvider
if authProvider == "" {
authProvider = "local"
}
c.Set(PrincipalKey, Principal{
UserID: u.ID,
Username: u.Username,
DisplayName: displayName,
Role: u.Role,
AuthProvider: authProvider,
PermissionKeys: perms,
})
c.Next()
}
}
// Require checks capability keys (frontend/back-end single source of truth).
func Require(required permission.Key) gin.HandlerFunc {
return func(c *gin.Context) {
traceID := GetTraceID(c)
p := MustPrincipal(c)
for _, k := range p.PermissionKeys {
if k == string(required) {
c.Next()
return
}
}
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Permission denied", map[string]any{
"required": string(required),
}))
c.Abort()
}
}
func parseBearer(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
parts := strings.SplitN(v, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
return strings.TrimSpace(parts[1])
}
return ""
}
// Write wrapper for gin to keep consistent HTTP 200.
func Write(c *gin.Context, payload any) {
c.JSON(http.StatusOK, payload)
}
+31
View File
@@ -0,0 +1,31 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
)
const TraceIDKey = "traceId"
func Trace() gin.HandlerFunc {
return func(c *gin.Context) {
b := make([]byte, 8)
_, _ = rand.Read(b)
traceID := hex.EncodeToString(b)
c.Set(TraceIDKey, traceID)
c.Writer.Header().Set("X-Trace-Id", traceID)
c.Next()
}
}
func GetTraceID(c *gin.Context) string {
if v, ok := c.Get(TraceIDKey); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
+211
View File
@@ -0,0 +1,211 @@
package http
import (
"net"
"rttys/internal/domain/device"
"rttys/internal/domain/devicelog"
"rttys/internal/domain/notification"
"rttys/internal/domain/permission"
"rttys/internal/domain/user"
"rttys/internal/http/dto"
"rttys/internal/http/handler"
"rttys/internal/http/middleware"
"rttys/internal/store/memory"
"rttys/internal/store/sqlite"
"rttys/xconfig"
"strings"
"github.com/gin-gonic/gin"
)
type Deps struct {
UserSvc *user.Service
PermSvc *permission.Service
DevSvc *device.Service
GroupRepo *sqlite.GroupRepo
SessionStore *memory.SessionStore
RelationsRepo *sqlite.RelationsRepo
TrustedDeviceRepo *sqlite.TrustedDeviceRepo
DeviceLogSvc *devicelog.Service
NotificationSvc *notification.Service
Cfg *xconfig.Config
CloudVersion string
}
func RegisterAPIRoutes(r *gin.Engine, d Deps) {
cfg := d.Cfg
if cfg == nil {
cfg = xconfig.Must()
}
authH := handler.NewAuthHandler(d.UserSvc, d.SessionStore, d.TrustedDeviceRepo)
meH := handler.NewMeHandler()
devH := handler.NewDeviceHandler(d.DevSvc, d.GroupRepo, d.RelationsRepo)
dgH := handler.NewDeviceGroupHandler(d.GroupRepo, d.RelationsRepo)
ugH := handler.NewUserGroupHandler(d.GroupRepo)
relH := handler.NewRelationsHandler(d.RelationsRepo)
userH := handler.NewUserHandler(d.UserSvc, d.GroupRepo, d.RelationsRepo, d.SessionStore)
personalH := handler.NewPersonalHandler(d.UserSvc, d.TrustedDeviceRepo, "GLKVM Cloud")
devLogH := handler.NewDeviceLogHandler(d.DeviceLogSvc)
notifH := handler.NewNotificationHandler(d.NotificationSvc)
// public
r.GET("/auth-config", func(c *gin.Context) {
traceID := middleware.GetTraceID(c)
data := authConfigResp{
LdapEnabled: cfg.LdapEnabled,
LegacyPassword: cfg.Password != "",
OidcEnabled: cfg.OIDCEnabled,
KVMCloudVersion: d.CloudVersion,
}
dto.Write(c, dto.Ok(traceID, data))
})
// public
r.POST("/api/login", authH.Login)
// authed group
api := r.Group("/api")
api.Use(middleware.Auth(d.SessionStore, d.UserSvc, d.PermSvc))
// device script info (requires login)
api.GET("/script-info", func(c *gin.Context) {
traceID := middleware.GetTraceID(c)
// Get domain info
host := c.Request.Host
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // Use host directly if no port
}
chosen := hostname
// -------- Reverse proxy mode: force IP ----------
if cfg.ReverseProxyEnabled {
// Reverse proxy mode: always use configured WebRTC IP
if strings.TrimSpace(cfg.WebrtcIP) != "" {
chosen = strings.TrimSpace(cfg.WebrtcIP)
}
} else {
// -------- 3) Original behavior (unchanged) ----------
// 1) If hostname is domain, keep it
// 2) If hostname is IP and cfg.WebrtcIP is set, use cfg.WebrtcIP
if isIP(hostname) && cfg.WebrtcIP != "" {
chosen = cfg.WebrtcIP
}
}
// Determine selfhost WebUI URL
webUIURL := strings.TrimSpace(cfg.SelfhostWebUIURL)
if webUIURL == "" {
scheme := "https"
if c.Request.TLS == nil {
scheme = "http"
}
if fwdProto := c.GetHeader("X-Forwarded-Proto"); fwdProto != "" {
scheme = fwdProto
}
webUIURL = scheme + "://" + c.Request.Host
}
data := scriptInfoResp{
Hostname: chosen, // reuse the same chosen value
Port: cfg.AddrDev,
Token: cfg.Token,
WebrtcIP: chosen, // same as hostname
WebrtcPort: cfg.WebrtcPort,
WebrtcUsername: cfg.WebrtcUsername,
WebrtcPassword: cfg.WebrtcPassword,
WebUIURL: webUIURL,
}
dto.Write(c, dto.Ok(traceID, data))
})
// auth
api.POST("/logout", middleware.Require(permission.AuthWrite), authH.Logout)
// me
api.GET("/me", middleware.Require(permission.MeRead), meH.GetMe)
// personal center
api.GET("/me/profile", middleware.Require(permission.MeRead), personalH.GetProfile)
api.PUT("/me/profile", middleware.Require(permission.MeRead), personalH.UpdateProfile)
api.POST("/me/2fa/setup", middleware.Require(permission.MeRead), personalH.Setup2fa)
api.POST("/me/2fa/enable", middleware.Require(permission.MeRead), personalH.Enable2fa)
api.POST("/me/2fa/disable", middleware.Require(permission.MeRead), personalH.Disable2fa)
api.GET("/me/2fa/trusted-devices", middleware.Require(permission.MeRead), personalH.ListTrustedDevices)
api.DELETE("/me/2fa/trusted-devices/:id", middleware.Require(permission.MeRead), personalH.RevokeTrustedDevice)
// device scope list
api.GET("/devices", middleware.Require(permission.DeviceRead), devH.ListDevices)
api.POST("/devices/move-to-device-group", middleware.Require(permission.DeviceGroupWrite), devH.MoveToDeviceGroup)
api.PUT("/devices/:id", middleware.Require(permission.DeviceWrite), devH.UpdateDevice)
api.DELETE("/devices/:id", middleware.Require(permission.DeviceWrite), devH.DeleteDevice)
// --- users ---
api.GET("/users", middleware.Require(permission.UserRead), userH.ListUsers)
api.POST("/users", middleware.Require(permission.UserWrite), userH.CreateUser)
api.PUT("/users/:id", middleware.Require(permission.UserWrite), userH.UpdateUser)
api.DELETE("/users/:id", middleware.Require(permission.UserWrite), userH.DeleteUser)
// user groups
api.GET("/user-groups", middleware.Require(permission.UserGroupRead), ugH.ListUserGroups)
api.GET("/user-groups/options", middleware.Require(permission.UserGroupRead), ugH.ListOptions)
api.POST("/user-groups", middleware.Require(permission.UserGroupWrite), ugH.Create)
api.PUT("/user-groups/:id", middleware.Require(permission.UserGroupWrite), ugH.Update)
api.DELETE("/user-groups/:id", middleware.Require(permission.UserGroupWrite), ugH.Delete)
// device groups list
api.GET("/device-groups", middleware.Require(permission.DeviceGroupRead), dgH.ListDeviceGroups)
api.GET("/device-groups/options", middleware.Require(permission.DeviceGroupRead), dgH.ListOptions)
api.POST("/device-groups", middleware.Require(permission.DeviceGroupWrite), dgH.Create)
api.PUT("/device-groups/:id", middleware.Require(permission.DeviceGroupWrite), dgH.Update)
api.DELETE("/device-groups/:id", middleware.Require(permission.DeviceGroupWrite), dgH.Delete)
api.POST("/device-groups/:id/devices", middleware.Require(permission.DeviceGroupWrite), dgH.AddDevices)
api.DELETE("/device-groups/:id/devices", middleware.Require(permission.DeviceGroupWrite), dgH.RemoveDevices)
// device event logs (admin only)
api.GET("/device-event-logs", middleware.Require(permission.DeviceLogRead), devLogH.List)
// notification settings (admin only)
notifGroup := api.Group("/notification")
notifGroup.GET("/smtp", middleware.Require(permission.NotificationRead), notifH.GetSMTPConfig)
notifGroup.PUT("/smtp", middleware.Require(permission.NotificationWrite), notifH.SaveSMTPConfig)
notifGroup.POST("/smtp/test", middleware.Require(permission.NotificationWrite), notifH.TestSMTP)
notifGroup.GET("/rules", middleware.Require(permission.NotificationRead), notifH.GetNotifyRules)
notifGroup.PUT("/rules", middleware.Require(permission.NotificationWrite), notifH.SaveNotifyRules)
notifGroup.GET("/recipients", middleware.Require(permission.NotificationRead), notifH.ListRecipients)
notifGroup.POST("/recipients", middleware.Require(permission.NotificationWrite), notifH.AddRecipient)
notifGroup.DELETE("/recipients/:id", middleware.Require(permission.NotificationWrite), notifH.RemoveRecipient)
// Relations (cover / set)
api.PUT("/users/:id/user-groups", middleware.Require(permission.UserWrite), relH.SetUserGroups)
api.PUT("/user-groups/:id/device-groups", middleware.Require(permission.UserGroupWrite), relH.SetUserGroupDeviceGroups)
api.PUT("/device-groups/:id/devices", middleware.Require(permission.DeviceGroupWrite), relH.SetDeviceGroupDevices)
}
type authConfigResp struct {
LdapEnabled bool `json:"ldapEnabled"`
LegacyPassword bool `json:"legacyPassword"`
OidcEnabled bool `json:"oidcEnabled"`
KVMCloudVersion string `json:"kvmCloudVersion"`
}
type scriptInfoResp struct {
Hostname string `json:"hostname"`
Port string `json:"port"`
Token string `json:"token"`
WebrtcIP string `json:"webrtcIP"`
WebrtcPort string `json:"webrtcPort"`
WebrtcUsername string `json:"webrtcUsername"`
WebrtcPassword string `json:"webrtcPassword"`
WebUIURL string `json:"webUIURL"`
}
func isIP(addr string) bool {
return net.ParseIP(addr) != nil
}
+37
View File
@@ -0,0 +1,37 @@
package legacy
import (
"context"
"rttys/internal/store/sqlite"
"rttys/model"
)
func SaveOrUpdateDeviceMeta(deviceID, mac, description, ip string) error {
repo := sqlite.MustContainer().DeviceMeta
return repo.SaveOrUpdate(context.Background(), deviceID, mac, description, ip)
}
func UpdateDeviceClient(deviceID, client string) error {
repo := sqlite.MustContainer().DeviceMeta
return repo.UpdateClient(context.Background(), deviceID, client)
}
func GetDeviceMetaByDeviceID(deviceID string) (*model.DeviceMeta, error) {
repo := sqlite.MustContainer().DeviceMeta
return repo.GetByDeviceID(context.Background(), deviceID)
}
func UpdateDeviceDescriptionIfEmpty(deviceID, description string) error {
repo := sqlite.MustContainer().DeviceMeta
return repo.UpdateDescriptionIfEmpty(context.Background(), deviceID, description)
}
func DeleteDeviceMetaByDeviceID(deviceID string) error {
repo := sqlite.MustContainer().DeviceMeta
return repo.DeleteByDeviceID(context.Background(), deviceID)
}
func MarkDeviceOffline(deviceID string) error {
repo := sqlite.MustContainer().DeviceMeta
return repo.MarkOffline(context.Background(), deviceID)
}
+426
View File
@@ -0,0 +1,426 @@
/*
* @Author: CU-Jon
* @Date: 2025-09-26 13:28:12 EDT
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\ldap.go
* @Description: LDAP认证模块 (LDAP authentication module)
*/
package ldap
import (
"crypto/tls"
"fmt"
"rttys/xconfig"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog/log"
)
// LDAP认证器结构体 (LDAP authenticator struct)
type LDAPAuthenticator struct {
config *xconfig.Config
}
// 创建新的LDAP认证器 (Create new LDAP authenticator)
func NewLDAPAuthenticator(config *xconfig.Config) *LDAPAuthenticator {
return &LDAPAuthenticator{config: config}
}
// 执行用户LDAP认证 (Perform LDAP authentication for a user)
// Returns (success, userDN, isAdmin, error). userDN is the distinguished name of the authenticated user.
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, string, bool, error) {
if !l.config.LdapEnabled {
return false, "", false, fmt.Errorf("LDAP authentication is disabled")
}
if username == "" || password == "" {
return false, "", false, fmt.Errorf("username and password are required")
}
// 连接到LDAP服务器 (Connect to LDAP server)
conn, err := l.connect()
if err != nil {
return false, "", false, fmt.Errorf("failed to connect to LDAP server: %v", err)
}
defer conn.Close()
// 使用服务账户进行绑定和搜索 (Use service account for binding and searching)
if l.config.LdapBindDN == "" || l.config.LdapBindPassword == "" {
return false, "", false, fmt.Errorf("service account credentials are required for LDAP authentication - BindDN empty: %v, BindPassword empty: %v", l.config.LdapBindDN == "", l.config.LdapBindPassword == "")
}
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
if err != nil {
return false, "", false, fmt.Errorf("service account bind failed: %v", err)
} // 使用服务账户搜索用户 (Use service account to search for user)
userDN, err := l.findUserDN(conn, username)
if err != nil {
return false, "", false, fmt.Errorf("user search failed: %v", err)
}
// 找到用户,现在用用户凭证验证密码 (Found user, now validate password with user credentials)
err = conn.Bind(userDN, password)
if err != nil {
return false, "", false, fmt.Errorf("password validation failed: %v", err)
}
// 重新绑定为服务账户以进行授权检查 (Rebind as service account for authorization check)
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
if err != nil {
return false, "", false, fmt.Errorf("failed to rebind as service account for authorization: %v", err)
}
// 检查用户授权 (Check user authorization)
authorized, err := l.checkAuthorization(conn, userDN, username)
if err != nil {
return false, "", false, fmt.Errorf("authorization check failed: %v", err)
}
if !authorized {
return false, "", false, fmt.Errorf("user not authorized")
}
// 检查用户是否为管理员 (Check if user is admin by group or username)
isAdmin := l.checkIsAdmin(conn, userDN, username)
log.Info().
Str("username", username).
Str("userDN", userDN).
Str("adminGroup", l.config.LdapAdminGroup).
Str("adminUsers", l.config.LdapAdminUsers).
Bool("isAdmin", isAdmin).
Msg("LDAP authentication successful")
return true, userDN, isAdmin, nil
}
// 建立到LDAP服务器的连接 (Establish connection to LDAP server)
func (l *LDAPAuthenticator) connect() (*ldap.Conn, error) {
address := fmt.Sprintf("%s:%d", l.config.LdapServer, l.config.LdapPort)
var conn *ldap.Conn
var err error
if l.config.LdapUseTLS {
// TLS配置 (TLS configuration)
tlsConfig := &tls.Config{
ServerName: l.config.LdapServer,
InsecureSkipVerify: true, // 跳过证书验证以避免自签名证书问题 (Skip certificate verification to avoid self-signed certificate issues)
}
if l.config.LdapPort == 636 {
// 使用LDAPS (直接TLS连接) (Use LDAPS - direct TLS connection)
conn, err = ldap.DialTLS("tcp", address, tlsConfig)
} else {
// 使用StartTLS (先连接再升级到TLS) (Use StartTLS - connect first then upgrade to TLS)
conn, err = ldap.Dial("tcp", address)
if err == nil {
err = conn.StartTLS(tlsConfig)
}
}
} else {
// 使用普通连接 (Use plain connection)
conn, err = ldap.Dial("tcp", address)
}
if err != nil {
return nil, err
}
// 设置超时时间 (Set timeout)
conn.SetTimeout(10 * time.Second)
return conn, nil
}
// 基于用户名搜索用户DN (Search for user DN based on username)
func (l *LDAPAuthenticator) findUserDN(conn *ldap.Conn, username string) (string, error) {
// 准备搜索过滤器 (Prepare search filter)
filter := fmt.Sprintf(l.config.LdapUserFilter, username)
if l.config.LdapUserFilter == "" {
filter = fmt.Sprintf("(uid=%s)", username)
}
// 执行搜索 (Perform search)
searchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, // 无大小限制 (No size limit)
0, // 无时间限制 (No time limit)
false,
filter,
[]string{"dn"},
nil,
)
sr, err := conn.Search(searchRequest)
if err != nil {
return "", err
}
if len(sr.Entries) == 0 {
return "", fmt.Errorf("user not found")
}
if len(sr.Entries) > 1 {
return "", fmt.Errorf("multiple users found")
}
return sr.Entries[0].DN, nil
}
// 基于组或用户列表检查用户是否授权 (Check if user is authorized based on groups or users list)
func (l *LDAPAuthenticator) checkAuthorization(conn *ldap.Conn, userDN, username string) (bool, error) {
// 如果没有配置限制,则允许所有已认证用户 (If no restrictions are configured, allow all authenticated users)
if l.config.LdapAllowedGroups == "" && l.config.LdapAllowedUsers == "" {
return true, nil
}
// 检查允许的用户列表 (Check allowed users list)
if l.config.LdapAllowedUsers != "" {
allowedUsers := strings.Split(strings.TrimSpace(l.config.LdapAllowedUsers), ",")
for _, allowedUser := range allowedUsers {
if strings.TrimSpace(allowedUser) == username {
return true, nil
}
}
}
// 检查允许的组 (Check allowed groups)
if l.config.LdapAllowedGroups != "" {
return l.checkGroupMembership(conn, userDN, username)
}
return false, nil
}
// 检查用户是否属于任何允许的组 (Check if user belongs to any of the allowed groups)
func (l *LDAPAuthenticator) checkGroupMembership(conn *ldap.Conn, userDN, username string) (bool, error) {
allowedGroups := strings.Split(strings.TrimSpace(l.config.LdapAllowedGroups), ",")
for _, group := range allowedGroups {
group = strings.TrimSpace(group)
if group == "" {
continue
}
// 搜索组成员关系 - 尝试不同的常见LDAP组结构 (Search for group membership - try different common LDAP group structures)
isMember, err := l.isGroupMember(conn, userDN, username, group)
if err != nil {
log.Warn().Msgf("Error checking group membership for %s in %s: %v", username, group, err)
continue
}
if isMember {
return true, nil
}
}
return false, nil
}
// 检查用户是否是指定组的成员 (Check if user is a member of the specified group)
func (l *LDAPAuthenticator) isGroupMember(conn *ldap.Conn, userDN, username, groupName string) (bool, error) {
// 首先查找用户的实际DN,因为我们可能使用了UPN格式进行认证 (First find the user's actual DN, as we may have used UPN format for authentication)
actualUserDN, err := l.findActualUserDN(conn, username)
if err != nil {
actualUserDN = userDN // 回退到原始DN (Fallback to original DN)
}
// 尝试不同的常见组搜索模式 (Try different common group search patterns)
// 模式1:通过CN搜索组并检查成员属性 (Pattern 1: Search for group by CN and check member attribute)
groupFilter := fmt.Sprintf("(cn=%s)", groupName)
groupSearchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
groupFilter,
[]string{"member", "memberUid", "uniqueMember"},
nil,
)
sr, err := conn.Search(groupSearchRequest)
if err != nil {
log.Warn().Msgf("Group search failed: %v", err)
return false, err
}
for _, entry := range sr.Entries {
members := entry.GetAttributeValues("member")
memberUids := entry.GetAttributeValues("memberUid")
uniqueMembers := entry.GetAttributeValues("uniqueMember")
// 检查member属性(完整DN) (Check member attribute - full DN)
for _, member := range members {
if member == userDN || member == actualUserDN {
return true, nil
}
// 也检查是否member DN包含用户名 (Also check if member DN contains the username)
if strings.Contains(strings.ToLower(member), strings.ToLower("cn="+username)) {
return true, nil
}
}
// 检查memberUid属性(仅用户名) (Check memberUid attribute - username only)
for _, memberUid := range memberUids {
if memberUid == username {
return true, nil
}
}
// 检查uniqueMember属性(完整DN) (Check uniqueMember attribute - full DN)
for _, uniqueMember := range uniqueMembers {
if uniqueMember == userDN {
return true, nil
}
}
}
// 模式2:通过用户名搜索用户并检查memberOf属性 (Pattern 2: Search for user by username and check memberOf attribute)
// 使用配置的用户过滤器或默认的uid过滤器 (Use configured user filter or default uid filter)
userFilter := fmt.Sprintf(l.config.LdapUserFilter, username)
if l.config.LdapUserFilter == "" {
userFilter = fmt.Sprintf("(uid=%s)", username)
}
userSearchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
userFilter,
[]string{"memberOf", "distinguishedName"},
nil,
)
sr, err = conn.Search(userSearchRequest)
if err != nil {
log.Warn().Msgf("User search for memberOf failed: %v", err)
} else {
for _, entry := range sr.Entries {
memberOfValues := entry.GetAttributeValues("memberOf")
for _, memberOf := range memberOfValues {
if strings.Contains(strings.ToLower(memberOf), strings.ToLower("cn="+groupName)) {
return true, nil
}
}
}
}
return false, nil
}
// 查找用户的实际DN (Find the user's actual DN)
func (l *LDAPAuthenticator) findActualUserDN(conn *ldap.Conn, username string) (string, error) {
// 使用配置的用户过滤器搜索用户 (Search for user using configured user filter)
userFilter := fmt.Sprintf(l.config.LdapUserFilter, username)
if l.config.LdapUserFilter == "" {
userFilter = fmt.Sprintf("(uid=%s)", username)
}
userSearchRequest := ldap.NewSearchRequest(
l.config.LdapBaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
userFilter,
[]string{"distinguishedName"},
nil,
)
sr, err := conn.Search(userSearchRequest)
if err != nil {
return "", err
}
if len(sr.Entries) == 0 {
return "", fmt.Errorf("user not found")
}
if len(sr.Entries) > 1 {
return "", fmt.Errorf("multiple users found")
}
return sr.Entries[0].DN, nil
}
// checkIsAdmin checks whether the authenticated user should be assigned the admin role,
// by matching against LdapAdminUsers (username list) OR LdapAdminGroup (group membership).
func (l *LDAPAuthenticator) checkIsAdmin(conn *ldap.Conn, userDN, username string) bool {
// 1) Check admin users list
adminUsers := strings.TrimSpace(l.config.LdapAdminUsers)
if adminUsers != "" {
users := strings.Split(adminUsers, ",")
for _, u := range users {
if strings.TrimSpace(u) == username {
return true
}
}
}
// 2) Check admin group membership
adminGroups := strings.TrimSpace(l.config.LdapAdminGroup)
if adminGroups != "" {
groups := strings.Split(adminGroups, ",")
for _, group := range groups {
group = strings.TrimSpace(group)
if group == "" {
continue
}
isMember, err := l.isGroupMember(conn, userDN, username, group)
if err != nil {
log.Warn().Msgf("Error checking admin group membership for %s in %s: %v", username, group, err)
continue
}
if isMember {
return true
}
}
}
return false
}
// 执行用户认证,支持LDAP和传统密码认证 (Perform user authentication with LDAP and legacy password support)
func AuthenticateUser(cfg *xconfig.Config, username, password, authMethod string) bool {
success, _, _, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
return success
}
// AuthenticateUserWithError performs authentication and returns (success, errorType, userDN, isAdmin).
// userDN and isAdmin are only populated for successful LDAP authentication.
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string, string, bool) {
// 处理LDAP认证 (Handle LDAP authentication)
if cfg.LdapEnabled && authMethod == "ldap" && username != "" {
ldapAuth := NewLDAPAuthenticator(cfg)
success, userDN, isAdmin, err := ldapAuth.Authenticate(username, password)
if err != nil {
log.Error().Msgf("LDAP authentication error: %v", err)
// 检查错误类型以区分认证和授权错误 (Check error type to distinguish between authentication and authorization errors)
if strings.Contains(err.Error(), "user not authorized") {
return false, "authorization", "", false
}
return false, "authentication", "", false
}
return success, "", userDN, isAdmin
}
if authMethod == "legacy" || authMethod == "" {
if cfg.Password == password {
return true, "", "", false
}
return false, "authentication", "", false
}
return false, "authentication", "", false
}
+22
View File
@@ -0,0 +1,22 @@
package password
import "golang.org/x/crypto/bcrypt"
const bcryptCost = 12
// HashPassword hashes a plaintext password using bcrypt.
func HashPassword(pw string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword checks a plaintext password against a stored bcrypt hash.
func VerifyPassword(pw, hash string) bool {
if hash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
+16
View File
@@ -0,0 +1,16 @@
package randtoken
import (
"crypto/rand"
"encoding/base64"
)
// New returns a high-entropy random token (URL-safe).
// 32 bytes => 256-bit.
func New() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+46
View File
@@ -0,0 +1,46 @@
// Package totp wraps github.com/pquerna/otp/totp for the cloud server.
//
// We use TOTP (RFC 6238) for two-factor authentication. Secrets are stored
// base32-encoded in the database and verified with ±1 step (30s) skew to
// tolerate clock drift between server and client.
package totp
import (
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
// GenerateSecret creates a fresh TOTP secret for the given account.
// Returns the base32 secret and the otpauth:// URL ready for QR encoding.
func GenerateSecret(issuer, accountName string) (secret string, otpauthURL string, err error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: accountName,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// Verify checks a 6-digit code against the secret with ±1 step skew.
func Verify(secret, code string) bool {
if secret == "" || code == "" {
return false
}
valid, err := totp.ValidateCustom(code, secret, time.Now(), totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return false
}
return valid
}
+111
View File
@@ -0,0 +1,111 @@
// Package useragent provides a tiny, dependency-free helper that turns a raw
// HTTP User-Agent header into a short human-readable label such as
// "Chrome 146 · Windows" or "Safari 17 · iOS 17". It only recognises the
// browsers / OSes that we actually display in the trusted-device list — for
// anything unknown it falls back to a truncated copy of the original UA.
package useragent
import (
"regexp"
"strings"
)
// Friendly turns a raw User-Agent header into a short human-readable label.
// Returns "Unknown" for an empty input.
func Friendly(ua string) string {
ua = strings.TrimSpace(ua)
if ua == "" {
return "Unknown"
}
browser := parseBrowser(ua)
os := parseOS(ua)
switch {
case browser != "" && os != "":
return browser + " · " + os
case browser != "":
return browser
case os != "":
return os
}
// Fallback: truncated raw UA so we never lose information entirely.
if len(ua) > 80 {
return ua[:80] + "…"
}
return ua
}
// --- browser detection ----------------------------------------------------
// Order matters: Edge / Opera / Brave embed "Chrome" in their UA, so they must
// be checked first. Likewise Chrome embeds "Safari", so Safari is last.
var browserPatterns = []struct {
name string
re *regexp.Regexp
}{
{"Edge", regexp.MustCompile(`Edg(?:e|A|iOS)?/(\d+)`)},
{"Opera", regexp.MustCompile(`(?:OPR|Opera)/(\d+)`)},
{"Vivaldi", regexp.MustCompile(`Vivaldi/(\d+)`)},
{"Firefox", regexp.MustCompile(`Firefox/(\d+)`)},
{"Chrome", regexp.MustCompile(`(?:Chrome|CriOS)/(\d+)`)},
{"Safari", regexp.MustCompile(`Version/(\d+)[\d.]*\s+.*Safari/`)},
}
func parseBrowser(ua string) string {
for _, p := range browserPatterns {
m := p.re.FindStringSubmatch(ua)
if len(m) >= 2 {
return p.name + " " + m[1]
}
}
if strings.Contains(ua, "Safari/") {
return "Safari"
}
return ""
}
// --- OS detection ---------------------------------------------------------
var (
reAndroid = regexp.MustCompile(`Android (\d+)`)
reIOS = regexp.MustCompile(`(?:iPhone OS|CPU OS) (\d+)`)
reMac = regexp.MustCompile(`Mac OS X (\d+)[._](\d+)`)
)
func parseOS(ua string) string {
switch {
case strings.Contains(ua, "Windows NT"):
// Windows NT 10.0 covers Windows 10 and 11; Microsoft never bumped the
// NT version, so we can't tell them apart from the UA alone.
return "Windows"
case strings.Contains(ua, "Android"):
if m := reAndroid.FindStringSubmatch(ua); len(m) >= 2 {
return "Android " + m[1]
}
return "Android"
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad"):
if m := reIOS.FindStringSubmatch(ua); len(m) >= 2 {
return "iOS " + m[1]
}
return "iOS"
case strings.Contains(ua, "Mac OS X"):
if m := reMac.FindStringSubmatch(ua); len(m) >= 3 {
major := m[1]
minor := m[2]
// Mac OS X 10.x is "macOS", 11+ is also "macOS" but with the major
// number directly.
if major == "10" {
return "macOS"
}
return "macOS " + major + "." + minor
}
return "macOS"
case strings.Contains(ua, "CrOS"):
return "ChromeOS"
case strings.Contains(ua, "Linux"):
return "Linux"
}
return ""
}
+66
View File
@@ -0,0 +1,66 @@
package useragent
import "testing"
func TestFriendly(t *testing.T) {
cases := []struct {
name string
ua string
want string
}{
{
name: "empty",
ua: "",
want: "Unknown",
},
{
name: "chrome on windows 10",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
want: "Chrome 146 · Windows",
},
{
name: "edge on windows",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0",
want: "Edge 130 · Windows",
},
{
name: "firefox on linux",
ua: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
want: "Firefox 120 · Linux",
},
{
name: "safari on macOS",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
want: "Safari 17 · macOS",
},
{
name: "chrome on android 13",
ua: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
want: "Chrome 120 · Android 13",
},
{
name: "safari on iphone 17",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
want: "Safari 17 · iOS 17",
},
{
name: "opera",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0",
want: "Opera 106 · Windows",
},
{
name: "unknown UA",
ua: "curl/8.0.1",
want: "curl/8.0.1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Friendly(tc.ua)
if got != tc.want {
t.Errorf("Friendly(%q) = %q, want %q", tc.ua, got, tc.want)
}
})
}
}
+232
View File
@@ -0,0 +1,232 @@
package proxy
import (
"net"
"net/http"
"net/url"
"strings"
)
type HostInfo struct {
Host string // pure host without port
Port string // external port if known
Scheme string // http/https
RawHost string // req.Host (may include port)
XFHost string // X-Forwarded-Host (raw)
XFProto string // X-Forwarded-Proto (raw)
XFPort string // X-Forwarded-Port (raw)
}
func GetHostInfoFromRequest(req *http.Request) HostInfo {
hi := HostInfo{
RawHost: req.Host,
XFHost: req.Header.Get("X-Forwarded-Host"),
XFProto: req.Header.Get("X-Forwarded-Proto"),
XFPort: req.Header.Get("X-Forwarded-Port"),
}
// host: prefer X-Forwarded-Host
host := strings.TrimSpace(hi.XFHost)
if host != "" {
host = strings.TrimSpace(strings.Split(host, ",")[0])
} else {
host = strings.TrimSpace(req.Host)
}
// split port if host contains it
if h, p, err := net.SplitHostPort(host); err == nil {
hi.Host = h
hi.Port = p
} else {
hi.Host = strings.TrimSuffix(host, ".")
}
// scheme
proto := strings.TrimSpace(hi.XFProto)
if proto != "" {
proto = strings.ToLower(strings.TrimSpace(strings.Split(proto, ",")[0]))
hi.Scheme = proto
} else if req.TLS != nil {
hi.Scheme = "https"
} else {
hi.Scheme = "http"
}
// forwarded port overrides
fp := strings.TrimSpace(hi.XFPort)
if fp != "" {
hi.Port = strings.TrimSpace(strings.Split(fp, ",")[0])
}
return hi
}
// IsIPHost checks whether host is an IP address.
func IsIPHost(host string) bool {
ip := net.ParseIP(strings.TrimSpace(host))
return ip != nil
}
// DomainAllowed checks whether host is allowed.
// Allow:
// - exact match: base
// - subdomain: *.base
func DomainAllowed(host, base string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
base = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(base), "."))
if host == "" || base == "" {
return false
}
if host == base {
return true
}
return strings.HasSuffix(host, "."+base)
}
// BuildRedirectHost removes the first label of the hostname and prepends devid.
// Rules:
// - "www.example.com" -> "devid.example.com"
// - "www.l1.example.com" -> "devid.l1.example.com"
// - "www.l1.l2.example.com" -> "devid.l1.l2.example.com"
// - Two-level domain "example.com" -> "devid.example.com"
// - Single label / abnormal cases -> "devid." + hostname (fallback)
//
// The input hostname must be a pure hostname without port.
func BuildRedirectHost(hostname, devid string) string {
// Allow FQDN with trailing dot like "example.com."
hostname = strings.TrimSuffix(hostname, ".")
// Split into labels
labels := strings.Split(hostname, ".")
// Remove empty labels (in case of consecutive dots)
compact := make([]string, 0, len(labels))
for _, l := range labels {
if l != "" {
compact = append(compact, l)
}
}
labels = compact
switch len(labels) {
case 0:
return devid // extreme case: just return devid
case 1:
// Single label (e.g., "localhost") keep original as suffix
return devid + "." + labels[0]
default:
// >=2: drop the leftmost label
suffix := strings.Join(labels[1:], ".")
return devid + "." + suffix
}
}
func JoinHostPortIfNeeded(host, scheme, port string) string {
if port == "" {
return host
}
// avoid adding default ports
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
return host
}
return net.JoinHostPort(host, port)
}
func BuildRedirectLocation(scheme, hostPort, path, sid string) string {
if path == "" {
path = "/"
}
u := &url.URL{
Scheme: scheme,
Host: hostPort,
Path: path,
}
q := u.Query()
q.Set("rttysid", sid)
u.RawQuery = q.Encode()
return u.String()
}
// GetRequestHostInfo extracts domain(host), port and scheme(proto) from request headers.
// Priority:
// 1) X-Forwarded-Host / X-Forwarded-Proto / X-Forwarded-Port (reverse proxy)
// 2) Host header / TLS info
func GetRequestHostInfo(req *http.Request) (host string, port string, proto string) {
// 1) Reverse-proxy headers
xfh := strings.TrimSpace(req.Header.Get("X-Forwarded-Host"))
xfp := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto"))
xfport := strings.TrimSpace(req.Header.Get("X-Forwarded-Port"))
// X-Forwarded-Host may contain a comma-separated list. Take the first one.
if xfh != "" {
if i := strings.IndexByte(xfh, ','); i >= 0 {
xfh = strings.TrimSpace(xfh[:i])
}
host = xfh
}
// 2) Fallback to Host header
if host == "" {
host = strings.TrimSpace(req.Host)
}
// Split host:port if present
if h, p, err := net.SplitHostPort(host); err == nil {
host = h
port = p
} else {
// no explicit port in Host header
port = ""
}
// scheme/proto
if xfp != "" {
if i := strings.IndexByte(xfp, ','); i >= 0 {
xfp = strings.TrimSpace(xfp[:i])
}
proto = xfp
} else if req.TLS != nil {
proto = "https"
} else {
proto = "http"
}
// forwarded port overrides parsed port if present
if xfport != "" {
if i := strings.IndexByte(xfport, ','); i >= 0 {
xfport = strings.TrimSpace(xfport[:i])
}
port = xfport
}
return host, port, proto
}
// ExtractDeviceIDFromHost extracts deviceId from hostname.
// Rules:
// - IP address -> ("", false)
// - lv99862.example.com -> ("lv99862", true)
// - lv99862.l1.example.com -> ("lv99862", true)
// - localhost / single label -> ("localhost", true)
func ExtractDeviceIDFromHost(host string) (string, bool) {
host = strings.TrimSpace(host)
if host == "" {
return "", false
}
// remove trailing dot
host = strings.TrimSuffix(host, ".")
// If host is IP, skip
if ip := net.ParseIP(host); ip != nil {
return "", false
}
labels := strings.Split(host, ".")
for _, l := range labels {
if l != "" {
return l, true
}
}
return "", false
}
+454
View File
@@ -0,0 +1,454 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package server
import (
"context"
"crypto/tls"
"fmt"
"io/fs"
"net"
"net/http"
"path"
"rttys/internal/domain/device"
"rttys/internal/domain/devicelog"
"rttys/internal/domain/notification"
"rttys/internal/domain/permission"
"rttys/internal/domain/user"
httpx "rttys/internal/http"
"rttys/internal/http/middleware"
"rttys/internal/pkg/password"
"rttys/internal/proxy"
"rttys/internal/store/memory"
"rttys/internal/store/sqlite"
"rttys/ui"
"rttys/xconfig"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type AppContainer struct {
DB *sqlite.AppDB
DeviceMetaRepo *sqlite.DeviceMetaRepo
UserSvc *user.Service
DeviceLogSvc *devicelog.Service
NotificationSvc *notification.Service
}
var sessionStore *memory.SessionStore
const defaultDBPath = "/home/database/glkvm-cloud.db"
func InitAppContainer(r *gin.Engine) (*AppContainer, error) {
ctx := context.Background()
cfg := xconfig.Must()
// --- DB ---
appDB, err := sqlite.Open(ctx, sqlite.Options{
DSN: defaultDBPath,
MaxOpenConns: 1,
MaxIdleConns: 1,
LogSQL: true,
})
if err != nil {
log.Fatal().Err(err).Msg("open sqlite failed")
}
deviceMetaRepo := sqlite.NewDeviceMetaRepo(appDB.Gorm())
if err := sqlite.InitSchema(ctx, appDB.SQL(), "/home/database/schema.sql"); err != nil {
log.Fatal().Err(err).Msg("init schema failed")
}
if err := ensureAdminUser(ctx, appDB.Gorm(), cfg.AdminName, cfg.Password); err != nil {
log.Fatal().Err(err).Msg("ensure admin user failed")
}
// --- Repos & Services ---
userRepo := sqlite.NewUserRepo(appDB.Gorm())
groupRepo := sqlite.NewGroupRepo(appDB.Gorm())
deviceRepo := sqlite.NewDeviceRepo(appDB.Gorm())
relationsRepo := sqlite.NewRelationsRepo(appDB.Gorm())
trustedDeviceRepo := sqlite.NewTrustedDeviceRepo(appDB.Gorm())
deviceLogRepo := sqlite.NewDeviceLogRepo(appDB.Gorm())
notificationRepo := sqlite.NewNotificationRepo(appDB.Gorm())
userSvc := user.NewService(userRepo)
devSvc := device.NewService(deviceRepo, groupRepo)
deviceLogSvc := devicelog.NewService(deviceLogRepo)
notificationSvc := notification.NewService(notificationRepo)
permRepo := memory.NewPermissionRepo() // permissions stay in-memory
permSvc := permission.NewService(permRepo)
sessionStore = memory.NewSessionStore(cfg.AuthSessionTTL)
httpx.RegisterAPIRoutes(r, httpx.Deps{
UserSvc: userSvc,
PermSvc: permSvc,
DevSvc: devSvc,
GroupRepo: groupRepo,
SessionStore: sessionStore,
RelationsRepo: relationsRepo,
TrustedDeviceRepo: trustedDeviceRepo,
DeviceLogSvc: deviceLogSvc,
NotificationSvc: notificationSvc,
Cfg: cfg,
CloudVersion: KVMCloudVersion,
})
c := &AppContainer{
DB: appDB,
DeviceMetaRepo: deviceMetaRepo,
UserSvc: userSvc,
DeviceLogSvc: deviceLogSvc,
NotificationSvc: notificationSvc,
}
return c, nil
}
func ensureAdminUser(ctx context.Context, db *gorm.DB, adminName, plainPassword string) error {
if db == nil {
return fmt.Errorf("db is nil")
}
hash, err := password.HashPassword(plainPassword)
if err != nil {
return err
}
// First, rename the existing system admin user to the configured name (if changed).
// This handles the case where the admin username was previously "admin" (or another name)
// and the user now wants a different username via RTTYS_ADMIN_NAME.
if err := db.WithContext(ctx).Exec(
`UPDATE users SET username = ? WHERE is_system = 1 AND role = 'admin' AND username != ?`,
adminName, adminName,
).Error; err != nil {
return fmt.Errorf("rename system admin user: %w", err)
}
// Upsert: create the admin user if not exists, or update password/role/status.
// On conflict, also set description to 'System Administrator' if it is currently empty.
return db.WithContext(ctx).Exec(
`INSERT INTO users (username, description, password_hash, role, status, is_system)
VALUES (?, 'System Administrator', ?, 'admin', 'active', 1)
ON CONFLICT(username) DO UPDATE SET
password_hash=excluded.password_hash,
role='admin',
status='active',
is_system=1,
description=CASE WHEN (description IS NULL OR description = '') THEN 'System Administrator' ELSE description END`,
adminName, hash,
).Error
}
func (srv *RttyServer) ListenAPI() error {
cfg := &srv.cfg
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.Trace())
r.Use(func(c *gin.Context) {
hi := proxy.GetHostInfoFromRequest(c.Request)
host := hi.Host
allowedHost := cfg.WebUIHost
// If WebUIHost is configured, enforce host validation
if allowedHost != "" && !proxy.IsIPHost(host) {
if !proxy.DomainAllowed(host, allowedHost) {
html := generateErrorHTML("invalid")
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(html))
c.Abort()
return
}
}
c.Next()
})
if cfg.AllowOrigins {
log.Debug().Msg("Allow all origins")
r.Use(cors.Default())
}
authorized := r.Group("/", func(c *gin.Context) {
if !cfg.LocalAuth && isLocalRequest(c) {
return
}
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
})
authorized.GET("/connect/:devid", func(c *gin.Context) {
if !callUserHookUrl(cfg, c) {
c.Status(http.StatusForbidden)
return
}
if c.GetHeader("Upgrade") != "websocket" {
group := c.Query("group")
devid := c.Param("devid")
if dev := srv.GetDevice(group, devid); dev == nil {
c.Redirect(http.StatusFound, "/error/offline")
return
}
url := "/rtty/" + devid
if group != "" {
url += "?group=" + group
}
c.Redirect(http.StatusFound, url)
} else {
handleUserConnection(srv, c)
}
})
authorized.POST("/cmd/:devid", func(c *gin.Context) {
if !callUserHookUrl(cfg, c) {
c.Status(http.StatusForbidden)
return
}
cmdInfo := &CommandReqInfo{}
err := c.BindJSON(&cmdInfo)
if err != nil || cmdInfo.Cmd == "" || cmdInfo.Username == "" {
cmdErrResp(c, rttyCmdErrInvalid)
return
}
dev := srv.GetDevice(c.Query("group"), c.Param("devid"))
if dev == nil {
cmdErrResp(c, rttyCmdErrOffline)
return
}
dev.handleCmdReq(c, cmdInfo)
})
authorized.Any("/web/:devid/:proto/:addr/*path", func(c *gin.Context) {
httpProxyRedirect(srv, c, "")
})
container, err := InitAppContainer(r)
if err != nil {
return err
}
defer container.DB.Close()
sqlite.SetContainer(&sqlite.Container{
Gorm: container.DB.Gorm(),
DeviceMeta: sqlite.NewDeviceMetaRepo(container.DB.Gorm()),
DeviceLogSvc: container.DeviceLogSvc,
UserSvc: container.UserSvc,
NotificationSvc: container.NotificationSvc,
})
// ===== 添加OIDC路由 =====
RegisterOIDCRoutes(r, cfg, container.UserSvc)
fs, err := fs.Sub(ui.StaticFS, "dist")
if err != nil {
return err
}
root := http.FS(fs)
fh := http.FileServer(root)
r.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "not found"})
return
}
upath := path.Clean(c.Request.URL.Path)
if strings.HasSuffix(upath, ".js") || strings.HasSuffix(upath, ".css") {
if strings.Contains(c.Request.Header.Get("Accept-Encoding"), "gzip") {
f, err := root.Open(upath + ".gz")
if err == nil {
f.Close()
c.Request.URL.Path += ".gz"
if strings.HasSuffix(upath, ".js") {
c.Writer.Header().Set("Content-Type", "application/javascript")
} else if strings.HasSuffix(upath, ".css") {
c.Writer.Header().Set("Content-Type", "text/css")
}
c.Writer.Header().Set("Content-Encoding", "gzip")
}
}
} else if upath != "/" {
f, err := root.Open(upath)
if err != nil {
c.Request.URL.Path = "/"
r.HandleContext(c)
return
}
defer f.Close()
}
fh.ServeHTTP(c.Writer, c.Request)
})
ln, err := net.Listen("tcp", cfg.AddrUser)
if err != nil {
return err
}
defer ln.Close()
// If we're behind a reverse proxy (TLS terminated by nginx), never enable TLS here.
enableTLS := !cfg.ReverseProxyEnabled && cfg.SslCert != "" && cfg.SslKey != ""
if enableTLS {
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
if err != nil {
log.Fatal().Msg(err.Error())
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{crt}}
ln = tls.NewListener(ln, tlsConfig)
}
log.Info().Msgf("Listen users on: %s", ln.Addr().(*net.TCPAddr))
return r.RunListener(ln)
}
func callUserHookUrl(cfg *xconfig.Config, c *gin.Context) bool {
if cfg.UserHookUrl == "" {
return true
}
upath := c.Request.URL.RawPath
// Create HTTP request with original headers
req, err := http.NewRequest("GET", cfg.UserHookUrl, nil)
if err != nil {
log.Error().Err(err).Msgf("create hook request for \"%s\" fail", upath)
return false
}
// Copy all headers from original request
for key, values := range c.Request.Header {
lowerKey := strings.ToLower(key)
if lowerKey == "upgrade" || lowerKey == "connection" || lowerKey == "accept-encoding" {
continue
}
for _, value := range values {
req.Header.Add(key, value)
}
}
// Add custom headers for hook identification
req.Header.Set("X-Rttys-Hook", "true")
req.Header.Set("X-Original-Method", c.Request.Method)
req.Header.Set("X-Original-URL", c.Request.URL.String())
cli := &http.Client{
Timeout: 3 * time.Second,
}
resp, err := cli.Do(req)
if err != nil {
log.Error().Err(err).Msgf("call user hook url for \"%s\" fail", upath)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Error().Msgf("call user hook url for \"%s\", StatusCode: %d", upath, resp.StatusCode)
return false
}
return true
}
func isLocalRequest(c *gin.Context) bool {
addr, _ := net.ResolveTCPAddr("tcp", c.Request.RemoteAddr)
return addr.IP.IsLoopback()
}
func httpAuth(cfg *xconfig.Config, c *gin.Context) bool {
if !cfg.LocalAuth && isLocalRequest(c) {
return true
}
// Keep legacy behavior: if password is not set, no auth required
if cfg.Password == "" {
return true
}
sid, err := c.Cookie("sid")
if err != nil || strings.TrimSpace(sid) == "" {
return false
}
sid = strings.TrimSpace(sid)
// New session-based auth
_, ok := sessionStore.Get(sid)
return ok
}
// principalFromCtx best-effort extracts the logged-in user (id + username)
// from the request, used for tagging device-event logs. Returns (0, "")
// when no session can be resolved. Never blocks the calling path.
func principalFromCtx(c *gin.Context) (int64, string) {
if c == nil || c.Request == nil || sessionStore == nil {
return 0, ""
}
sid, err := c.Cookie("sid")
if err != nil {
return 0, ""
}
sid = strings.TrimSpace(sid)
if sid == "" {
return 0, ""
}
sess, ok := sessionStore.Get(sid)
if !ok {
return 0, ""
}
cont := sqlite.TryContainer()
if cont == nil || cont.UserSvc == nil {
return sess.UserID, ""
}
u, err := cont.UserSvc.FindByID(c.Request.Context(), sess.UserID)
if err != nil || u == nil {
return sess.UserID, ""
}
return sess.UserID, u.Username
}
+86
View File
@@ -0,0 +1,86 @@
package server
import (
"encoding/json"
"os"
"runtime"
xlog "rttys/log"
"rttys/xconfig"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func RunFromEnv() error {
defaultLogPath := "/var/log/rttys.log"
if runtime.GOOS == "windows" {
defaultLogPath = "rttys.log"
}
defer LogPanic()
cfg := xconfig.Config{
AddrDev: ":5912",
AddrUser: ":5913",
LocalAuth: true,
LogPath: defaultLogPath,
LogLevel: "info",
}
confPath := os.Getenv("RTTYS_CONF")
if confPath == "" {
if _, err := os.Stat("rttys.conf"); err == nil {
confPath = "rttys.conf"
}
}
if err := cfg.Load(confPath); err != nil {
return err
}
xlog.SetPath(cfg.LogPath)
switch cfg.LogLevel {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
if cfg.Verbose {
xlog.Verbose()
}
log.Info().Msg("Go Version: " + runtime.Version())
log.Info().Msgf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)
log.Info().Msg("Rttys Version: " + RttysVersion)
if GitCommit != "" {
log.Info().Msg("Git Commit: " + GitCommit)
}
if BuildTime != "" {
log.Info().Msg("Build Time: " + BuildTime)
}
if runtime.GOOS != "windows" {
go StartSignalHandler()
}
{
importJSON, _ := json.MarshalIndent(cfg, "", " ")
log.Info().Msg("==== Loaded Configuration ====")
log.Info().Msg(string(importJSON))
log.Info().Msg("==============================")
}
xconfig.InitGlobal(&cfg)
srv := New(cfg)
return srv.Run()
}
+148 -148
View File
@@ -1,148 +1,148 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package main
import (
"context"
"encoding/json"
"net/http"
"rttys/utils"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
)
type CommandReq struct {
cancel context.CancelFunc
acked bool
c *gin.Context
}
type CommandReqInfo struct {
Cmd string `json:"cmd"`
Username string `json:"username"`
Params []string `json:"params"`
}
type CommandRespInfo struct {
Token string `json:"token"`
Attrs json.RawMessage `json:"attrs"`
}
const (
rttyCmdErrInvalid = 1001
rttyCmdErrOffline = 1002
rttyCmdErrTimeout = 1003
)
var cmdErrMsg = map[int]string{
rttyCmdErrInvalid: "invalid format",
rttyCmdErrOffline: "device offline",
rttyCmdErrTimeout: "timeout",
}
func (dev *Device) handleCmdReq(c *gin.Context, info *CommandReqInfo) {
ctx, cancel := context.WithCancel(dev.ctx)
defer cancel()
req := &CommandReq{
cancel: cancel,
c: c,
}
token := utils.GenUniqueID()
msg := bytebufferpool.Get()
defer bytebufferpool.Put(msg)
BpWriteCString(msg, info.Username)
BpWriteCString(msg, info.Cmd)
BpWriteCString(msg, token)
msg.WriteByte(byte(len(info.Params)))
for _, param := range info.Params {
BpWriteCString(msg, param)
}
log.Debug().Msgf("send cmd request for device '%s', token '%s'", dev.id, token)
err := dev.WriteMsg(msgTypeCmd, "", msg.Bytes())
if err != nil {
cmdErrResp(c, rttyCmdErrOffline)
return
}
waitTime := CommandTimeout
wait := c.Query("wait")
if wait != "" {
waitTime, _ = strconv.Atoi(wait)
}
if waitTime == 0 {
c.Status(http.StatusOK)
return
}
dev.commands.Store(token, req)
if waitTime < 0 || waitTime > CommandTimeout {
waitTime = CommandTimeout
}
tmr := time.NewTimer(time.Second * time.Duration(waitTime))
log.Debug().Msgf("wait for cmd response for device '%s', token '%s', waitTime %ds", dev.id, token, waitTime)
select {
case <-tmr.C:
cmdErrResp(c, rttyCmdErrTimeout)
case <-ctx.Done():
}
dev.commands.Delete(token)
if !req.acked {
cmdErrResp(c, rttyCmdErrOffline)
}
log.Debug().Msgf("handle cmd request for device '%s', token '%s' done", dev.id, token)
}
func cmdErrResp(c *gin.Context, err int) {
c.JSON(http.StatusOK, gin.H{
"err": err,
"msg": cmdErrMsg[err],
})
}
func BpWriteCString(bb *bytebufferpool.ByteBuffer, s string) {
bb.WriteString(s)
bb.WriteByte(0)
}
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package server
import (
"context"
"encoding/json"
"net/http"
"rttys/utils"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
)
type CommandReq struct {
cancel context.CancelFunc
acked bool
c *gin.Context
}
type CommandReqInfo struct {
Cmd string `json:"cmd"`
Username string `json:"username"`
Params []string `json:"params"`
}
type CommandRespInfo struct {
Token string `json:"token"`
Attrs json.RawMessage `json:"attrs"`
}
const (
rttyCmdErrInvalid = 1001
rttyCmdErrOffline = 1002
rttyCmdErrTimeout = 1003
)
var cmdErrMsg = map[int]string{
rttyCmdErrInvalid: "invalid format",
rttyCmdErrOffline: "device offline",
rttyCmdErrTimeout: "timeout",
}
func (dev *Device) handleCmdReq(c *gin.Context, info *CommandReqInfo) {
ctx, cancel := context.WithCancel(dev.ctx)
defer cancel()
req := &CommandReq{
cancel: cancel,
c: c,
}
token := utils.GenUniqueID()
msg := bytebufferpool.Get()
defer bytebufferpool.Put(msg)
BpWriteCString(msg, info.Username)
BpWriteCString(msg, info.Cmd)
BpWriteCString(msg, token)
msg.WriteByte(byte(len(info.Params)))
for _, param := range info.Params {
BpWriteCString(msg, param)
}
log.Debug().Msgf("send cmd request for device '%s', token '%s'", dev.id, token)
err := dev.WriteMsg(msgTypeCmd, "", msg.Bytes())
if err != nil {
cmdErrResp(c, rttyCmdErrOffline)
return
}
waitTime := CommandTimeout
wait := c.Query("wait")
if wait != "" {
waitTime, _ = strconv.Atoi(wait)
}
if waitTime == 0 {
c.Status(http.StatusOK)
return
}
dev.commands.Store(token, req)
if waitTime < 0 || waitTime > CommandTimeout {
waitTime = CommandTimeout
}
tmr := time.NewTimer(time.Second * time.Duration(waitTime))
log.Debug().Msgf("wait for cmd response for device '%s', token '%s', waitTime %ds", dev.id, token, waitTime)
select {
case <-tmr.C:
cmdErrResp(c, rttyCmdErrTimeout)
case <-ctx.Done():
}
dev.commands.Delete(token)
if !req.acked {
cmdErrResp(c, rttyCmdErrOffline)
}
log.Debug().Msgf("handle cmd request for device '%s', token '%s' done", dev.id, token)
}
func cmdErrResp(c *gin.Context, err int) {
c.JSON(http.StatusOK, gin.H{
"err": err,
"msg": cmdErrMsg[err],
})
}
func BpWriteCString(bb *bytebufferpool.ByteBuffer, s string) {
bb.WriteString(s)
bb.WriteByte(0)
}
+739
View File
@@ -0,0 +1,739 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package server
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"rttys/internal/legacy"
"rttys/internal/store/sqlite"
"strings"
"sync"
"time"
"rttys/utils"
"github.com/gorilla/websocket"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
)
type DeviceInfo struct {
ID string `json:"id"`
Mac string `json:"mac"`
Connected uint32 `json:"connected"`
Uptime uint32 `json:"uptime"`
Desc string `json:"description"`
Proto uint8 `json:"proto"`
IPaddr string `json:"ipaddr"`
}
type Device struct {
group string
id string
proto uint8
desc string
timestamp int64
uptime uint32
token string
heartbeat time.Duration
clientInfoMu sync.RWMutex
clientInfo []byte
users sync.Map
pending sync.Map
commands sync.Map
https sync.Map
conn net.Conn
br *bufio.Reader
readBuf []byte
close sync.Once
ctx context.Context
cancel context.CancelFunc
}
const (
msgTypeRegister = byte(iota)
msgTypeLogin
msgTypeLogout
msgTypeTermData
msgTypeWinsize
msgTypeCmd
msgTypeHeartbeat
msgTypeFile
msgTypeHttp
msgTypeAck
)
// Custom extension message types (keep out of upstream range to avoid conflicts).
const (
msgTypeDeviceInfo = byte(0xF0)
)
const (
msgTypeFileSend = byte(iota)
msgTypeFileRecv
msgTypeFileInfo
msgTypeFileData
msgTypeFileAck
msgTypeFileAbort
)
const (
msgRegAttrHeartbeat = iota
msgRegAttrDevid
msgRegAttrDescription
msgRegAttrToken
msgRegAttrGroup
)
const (
msgHeartbeatAttrUptime = iota
)
const (
devRegErrUnsupportedProto = iota + 1
devRegErrInvalidToken
devRegErrHookFailed
devRegErrIdConflicting
)
const (
RttyProtoRequired uint8 = 3
WaitRegistTimeout = 5 * time.Second
DefaultHeartbeat = 5 * time.Second
TermLoginTimeout = 5 * time.Second
CommandTimeout = 30
MaxDeviceInfoSize = 8 * 1024
)
var DevRegErrMsg = map[byte]string{
0: "Success",
devRegErrUnsupportedProto: "Unsupported protocol",
devRegErrInvalidToken: "Invalid token",
devRegErrHookFailed: "Hook failed",
devRegErrIdConflicting: "ID conflict",
}
var DeviceMsgHandlers = map[byte]func(*Device, []byte) error{
msgTypeHeartbeat: handleHeartbeatMsg,
msgTypeLogin: handleLoginMsg,
msgTypeLogout: handleLogoutMsg,
msgTypeTermData: handleTermDataMsg,
msgTypeFile: handleFileMsg,
msgTypeCmd: handleCmdMsg,
msgTypeHttp: handleHttpMsg,
msgTypeDeviceInfo: handleDeviceInfoMsg,
}
func (srv *RttyServer) ListenDevices() {
cfg := &srv.cfg
ln, err := net.Listen("tcp", cfg.AddrDev)
if err != nil {
log.Fatal().Msg(err.Error())
}
defer ln.Close()
if cfg.SslCert != "" && cfg.SslKey != "" {
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
if err != nil {
log.Fatal().Msg(err.Error())
}
tlsConfig := &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
// 忽略 SNI,始终返回唯一证书
return &crt, nil
},
}
ln = tls.NewListener(ln, tlsConfig)
}
log.Info().Msgf("Listen devices on: %s", ln.Addr().(*net.TCPAddr))
for {
conn, err := ln.Accept()
if err != nil {
log.Error().Msg(err.Error())
continue
}
go handleDeviceConnection(srv, conn)
}
}
func handleDeviceConnection(srv *RttyServer, conn net.Conn) {
defer LogPanic()
dev := &Device{
conn: conn,
heartbeat: DefaultHeartbeat,
timestamp: time.Now().Unix(),
br: bufio.NewReader(conn),
}
defer dev.Close(srv)
dev.ctx, dev.cancel = context.WithCancel(context.Background())
log.Debug().Msgf("new device '%s' connected", conn.RemoteAddr())
conn.SetReadDeadline(time.Now().Add(WaitRegistTimeout))
typ, data, err := dev.ReadMsg()
if err != nil {
log.Error().Msgf("read register msg fail: %v", err)
return
}
if typ != msgTypeRegister {
log.Error().Msg("register msg expected first")
return
}
if !dev.ParseRegister(data) {
log.Error().Msg("invalid device info")
return
}
code := dev.Register(srv)
err = dev.WriteMsg(msgTypeRegister, "", append([]byte{code}, DevRegErrMsg[code]...))
if err != nil {
log.Printf("send register to device '%s' fail: %v", dev.id, err)
return
}
if code != 0 {
return
}
deviceRemoteIP := ""
if addr, ok := dev.conn.RemoteAddr().(*net.TCPAddr); ok {
deviceRemoteIP = addr.IP.String()
} else if host, _, err := net.SplitHostPort(dev.conn.RemoteAddr().String()); err == nil {
deviceRemoteIP = host
}
log.Info().Msgf("device '%s' registered, group '%s' proto %d, heartbeat %v, remoteIP '%s'",
dev.id, dev.group, dev.proto, dev.heartbeat, deviceRemoteIP)
// 2. Load existing metadata by device_id
description := ""
meta, err := legacy.GetDeviceMetaByDeviceID(dev.id)
if err == nil && meta != nil {
description = meta.Description
}
if err := legacy.SaveOrUpdateDeviceMeta(
dev.id,
dev.desc, // device register mac info with desc filed
description,
deviceRemoteIP,
); err != nil {
return
}
for {
conn.SetReadDeadline(time.Now().Add(dev.heartbeat * 3 / 2))
typ, data, err = dev.ReadMsg()
if err != nil {
if err != io.EOF {
log.Error().Msgf("read msg from device '%s' fail: %v", dev.id, err)
}
return
}
log.Debug().Msgf("device msg %s from device %s", msgTypeName(typ), dev.id)
handler, ok := DeviceMsgHandlers[typ]
if !ok {
log.Error().Msgf("unexpected message '%s' from device '%s'", msgTypeName(typ), dev.id)
return
}
err = handler(dev, data)
if err != nil {
log.Error().Msg(err.Error())
return
}
}
}
func msgTypeName(typ byte) string {
switch typ {
case msgTypeRegister:
return "register"
case msgTypeLogin:
return "login"
case msgTypeLogout:
return "logout"
case msgTypeTermData:
return "termdata"
case msgTypeWinsize:
return "winsize"
case msgTypeCmd:
return "cmd"
case msgTypeHeartbeat:
return "heartbeat"
case msgTypeFile:
return "file"
case msgTypeHttp:
return "http"
case msgTypeAck:
return "ack"
case msgTypeDeviceInfo:
return "deviceinfo"
default:
return fmt.Sprintf("unknown(%d)", typ)
}
}
func (dev *Device) ReadMsg() (byte, []byte, error) {
head := make([]byte, 3)
br := dev.br
_, err := io.ReadFull(br, head)
if err != nil {
return 0, nil, err
}
typ := head[0]
msgLen := binary.BigEndian.Uint16(head[1:])
if cap(dev.readBuf) < int(msgLen) {
dev.readBuf = make([]byte, msgLen)
} else {
dev.readBuf = dev.readBuf[:msgLen]
}
_, err = io.ReadFull(br, dev.readBuf)
if err != nil {
return 0, nil, err
}
return typ, dev.readBuf, nil
}
func (dev *Device) WriteMsg(typ byte, sid string, data []byte) error {
bb := bytebufferpool.Get()
defer bytebufferpool.Put(bb)
b := []byte{typ, 0, 0}
binary.BigEndian.PutUint16(b[1:], uint16(len(sid)+len(data)))
bb.Write(b)
bb.WriteString(sid)
bb.Write(data)
_, err := bb.WriteTo(dev.conn)
return err
}
func (dev *Device) WriteFileMsg(typ byte, sid string, fileType byte, data []byte) error {
bb := bytebufferpool.Get()
defer bytebufferpool.Put(bb)
bb.WriteByte(fileType)
bb.Write(data)
return dev.WriteMsg(typ, sid, bb.Bytes())
}
func (dev *Device) Close(srv *RttyServer) {
dev.close.Do(func() {
log.Error().Msgf("device '%s' disconnected", dev.id)
srv.DelDevice(dev)
if dev.id != "" {
_ = legacy.MarkDeviceOffline(dev.id)
if c := sqlite.TryContainer(); c != nil && c.DeviceLogSvc != nil {
c.DeviceLogSvc.RecordDeviceOffline(context.Background(), dev.id, dev.desc, "")
if c.NotificationSvc != nil {
c.NotificationSvc.NotifyDeviceOffline(dev.id, dev.desc)
}
}
}
dev.cancel()
dev.conn.Close()
})
}
func (dev *Device) ParseRegister(b []byte) bool {
if len(b) < 1 {
return false
}
dev.proto = b[0]
if dev.proto > 4 {
attrs := utils.ParseTLV(b[1:])
if attrs == nil {
return false
}
for typ, val := range attrs {
switch typ {
case msgRegAttrHeartbeat:
dev.heartbeat = time.Duration(val[0]) * time.Second
case msgRegAttrDevid:
dev.id = string(val)
case msgRegAttrDescription:
dev.desc = string(val)
case msgRegAttrToken:
dev.token = string(val)
case msgRegAttrGroup:
dev.group = string(val)
}
}
return true
}
b = b[1:]
fields := bytes.Split(b, []byte{0})
if len(fields) < 3 {
return false
}
dev.id = string(fields[0])
dev.desc = string(fields[1])
dev.token = string(fields[2])
return true
}
func (dev *Device) Register(srv *RttyServer) byte {
cfg := &srv.cfg
if dev.proto < RttyProtoRequired {
log.Error().Msgf("minimum proto required %d, found %d for device '%s'", RttyProtoRequired, dev.proto, dev.id)
return devRegErrHookFailed
}
log.Info().Msgf("cfg.Token:%s,dev.token:%s", cfg.Token, dev.token)
if cfg.Token != "" && dev.token != cfg.Token {
log.Error().Msgf("invalid token for device '%s'", dev.id)
return devRegErrInvalidToken
}
devHookUrl := cfg.DevHookUrl
if devHookUrl != "" {
cli := &http.Client{
Timeout: 3 * time.Second,
}
data := fmt.Sprintf(`{"group":"%s", "devid":"%s", "token":"%s"}`, dev.group, dev.id, dev.token)
resp, err := cli.Post(devHookUrl, "application/json", strings.NewReader(data))
if err != nil {
log.Error().Msgf("call device hook url fail for device %s: %v", dev.id, err)
return devRegErrHookFailed
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Error().Msgf("call device hook url for device '%s', StatusCode: %d", dev.id, resp.StatusCode)
return devRegErrHookFailed
}
}
if !srv.AddDevice(dev) {
return devRegErrIdConflicting
}
if c := sqlite.TryContainer(); c != nil && c.DeviceLogSvc != nil {
c.DeviceLogSvc.RecordDeviceOnline(context.Background(), dev.id, dev.desc, "")
if c.NotificationSvc != nil {
c.NotificationSvc.NotifyDeviceOnline(dev.id, dev.desc)
}
}
return 0
}
func (dev *Device) setClientInfo(data []byte) {
dev.clientInfoMu.Lock()
dev.clientInfo = append(dev.clientInfo[:0], data...)
dev.clientInfoMu.Unlock()
}
// ClientType returns the "client" value from the device's client info JSON
// (e.g. "rtty-go"). Returns "" if not available or not parseable.
func (dev *Device) ClientType() string {
dev.clientInfoMu.RLock()
raw := make([]byte, len(dev.clientInfo))
copy(raw, dev.clientInfo)
dev.clientInfoMu.RUnlock()
if len(raw) == 0 {
return ""
}
var info struct {
Client string `json:"client"`
}
if err := jsoniter.Unmarshal(raw, &info); err != nil {
return ""
}
return info.Client
}
func handleDeviceInfoMsg(dev *Device, data []byte) error {
if len(data) == 0 {
log.Warn().Msgf("device '%s' sent empty client info", dev.id)
return nil
}
if len(data) > MaxDeviceInfoSize {
log.Warn().Msgf("device '%s' client info too large: %d bytes", dev.id, len(data))
return nil
}
var payload struct {
Client string `json:"client"`
OS string `json:"os"`
Hostname string `json:"hostname"`
LocalIP string `json:"local_ip"`
}
if err := jsoniter.Unmarshal(data, &payload); err != nil {
log.Warn().Msgf("device '%s' client info invalid json: %v", dev.id, err)
return nil
}
dev.setClientInfo(data)
if payload.Client != "" {
if err := legacy.UpdateDeviceClient(dev.id, payload.Client); err != nil {
log.Warn().Err(err).Msgf("device '%s' update client info failed", dev.id)
}
}
// Auto-fill description with os/hostname/local_ip if not already set by user
if payload.OS != "" || payload.Hostname != "" {
parts := make([]string, 0, 3)
if payload.OS != "" {
parts = append(parts, payload.OS)
}
if payload.Hostname != "" {
parts = append(parts, payload.Hostname)
}
if payload.LocalIP != "" {
parts = append(parts, payload.LocalIP)
}
desc := strings.Join(parts, " / ")
if err := legacy.UpdateDeviceDescriptionIfEmpty(dev.id, desc); err != nil {
log.Warn().Err(err).Msgf("device '%s' auto-fill description failed", dev.id)
}
}
log.Info().Msgf("device '%s' client info: %s", dev.id, string(data))
log.Debug().Msgf("device '%s' client info updated", dev.id)
return nil
}
func handleHeartbeatMsg(dev *Device, data []byte) error {
if !parseHeartbeat(dev, data) {
return fmt.Errorf("invalid heartbeat msg from device '%s'", dev.id)
}
return dev.WriteMsg(msgTypeHeartbeat, "", nil)
}
func parseHeartbeat(dev *Device, data []byte) bool {
if dev.proto > 4 {
attrs := utils.ParseTLV(data)
if attrs == nil {
return false
}
for typ, val := range attrs {
switch typ {
case msgHeartbeatAttrUptime:
dev.uptime = binary.BigEndian.Uint32(val)
}
}
} else {
if len(data) < 4 {
return false
}
dev.uptime = binary.BigEndian.Uint32(data[:4])
}
return true
}
func handleLogoutMsg(dev *Device, data []byte) error {
if len(data) < 32 {
return fmt.Errorf("invalid logout msg from device '%s'", dev.id)
}
sid := string(data[:32])
if val, loaded := dev.users.LoadAndDelete(sid); loaded {
user := val.(*User)
user.Close()
}
return nil
}
func handleLoginMsg(dev *Device, data []byte) error {
if len(data) < 33 {
return fmt.Errorf("invalid login msg from device '%s'", dev.id)
}
sid := string(data[:32])
code := data[32]
if val, loaded := dev.pending.LoadAndDelete(sid); loaded {
user := val.(*User)
ok := code == 0
errCode := 0
if ok {
log.Debug().Msgf("login session '%s' for device '%s' success", sid, dev.id)
dev.users.Store(sid, user)
} else {
errCode = LoginErrorBusy
log.Error().Msgf("login session '%s' for device '%s' fail, due to device busy", sid, dev.id)
}
if errCode == 0 {
user.WriteMsg(websocket.TextMessage, []byte(fmt.Appendf(nil, `{"type":"login"}`)))
} else {
user.SendCloseMsg(LoginErrorBusy, "device busy")
}
user.pending <- ok
}
return nil
}
func handleTermDataMsg(dev *Device, data []byte) error {
if len(data) < 32 {
return fmt.Errorf("invalid term data msg from device '%s'", dev.id)
}
sid := string(data[:32])
if val, ok := dev.users.Load(sid); ok {
user := val.(*User)
data[31] = 0
user.WriteMsg(websocket.BinaryMessage, data[31:])
}
return nil
}
func handleFileMsg(dev *Device, data []byte) error {
if len(data) < 33 {
return fmt.Errorf("invalid file msg from device '%s'", dev.id)
}
sid := string(data[:32])
typ := data[32]
if val, ok := dev.users.Load(sid); ok {
user := val.(*User)
switch typ {
case msgTypeFileSend:
user.WriteMsg(websocket.TextMessage,
fmt.Appendf(nil, `{"type":"sendfile", "name": "%s"}`, string(data[33:])))
case msgTypeFileRecv:
user.WriteMsg(websocket.TextMessage, []byte(`{"type":"recvfile"}`))
case msgTypeFileData:
data[32] = 1
user.WriteMsg(websocket.BinaryMessage, data[32:])
case msgTypeFileAck:
user.WriteMsg(websocket.TextMessage, []byte(`{"type":"fileAck"}`))
case msgTypeFileAbort:
user.WriteMsg(websocket.BinaryMessage, []byte{1})
}
}
return nil
}
func handleHttpMsg(dev *Device, data []byte) error {
if len(data) < 18 {
return fmt.Errorf("invalid http msg from device '%s'", dev.id)
}
addr := data[:18]
data = data[18:]
if c, ok := dev.https.Load(string(addr)); ok {
c := c.(net.Conn)
if len(data) == 0 {
c.Close()
} else {
c.Write(data)
}
}
return nil
}
func handleCmdMsg(dev *Device, data []byte) error {
info := &CommandRespInfo{}
err := jsoniter.Unmarshal(data, info)
if err != nil {
return fmt.Errorf("parse command resp info error: %v", err)
}
var attrs map[string]any
err = jsoniter.Unmarshal(info.Attrs, &attrs)
if err != nil {
return fmt.Errorf("parse command resp attrs error: %v", err)
}
attrs["devid"] = dev.id
if val, ok := dev.commands.Load(info.Token); ok {
req := val.(*CommandReq)
req.acked = true
req.c.JSON(http.StatusOK, attrs)
req.cancel()
}
return nil
}
+769
View File
@@ -0,0 +1,769 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package server
import (
"bufio"
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"rttys/internal/proxy"
"rttys/internal/store/sqlite"
"rttys/utils"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
)
type HttpProxySession struct {
expire atomic.Int64
ctx context.Context
cancel context.CancelFunc
devid string
group string
destaddr string
https bool
logID int64 // device-event-log row id (0 if not recorded)
}
var httpProxySessions = sync.Map{}
const httpProxySessionsExpire = 15 * time.Minute
func (ses *HttpProxySession) Expire() {
ses.expire.Store(time.Now().Add(httpProxySessionsExpire).Unix())
}
func (ses *HttpProxySession) String() string {
return fmt.Sprintf("{devid: %s, group: %s, destaddr: %s, https: %v}",
ses.devid, ses.group, ses.destaddr, ses.https)
}
// endWebSessionLog stamps ended_at on the web-session log row, if any.
// Safe to call on a session whose log was never recorded (logID == 0).
func endWebSessionLog(ses *HttpProxySession) {
if ses == nil || ses.logID == 0 {
return
}
cont := sqlite.TryContainer()
if cont == nil || cont.DeviceLogSvc == nil {
return
}
cont.DeviceLogSvc.EndSession(context.Background(), ses.logID)
}
func (srv *RttyServer) ListenHttpProxy() {
cfg := &srv.cfg
if cfg.AddrHttpProxy != "" {
addr, err := net.ResolveTCPAddr("tcp", cfg.AddrHttpProxy)
if err != nil {
log.Warn().Msg("invalid http proxy addr: " + err.Error())
} else {
srv.httpProxyPort = addr.Port
}
}
ln, err := net.Listen("tcp", cfg.AddrHttpProxy)
if err != nil {
log.Fatal().Msg(err.Error())
}
defer ln.Close()
// In reverse proxy mode (TLS terminated by nginx), never enable TLS here.
enableTLS := !cfg.ReverseProxyEnabled && cfg.SslCert != "" && cfg.SslKey != ""
if enableTLS {
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
if err != nil {
log.Fatal().Msg(err.Error())
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{crt}}
ln = tls.NewListener(ln, tlsConfig)
}
srv.httpProxyPort = ln.Addr().(*net.TCPAddr).Port
log.Info().Msgf("Listen http proxy on: %s", ln.Addr().(*net.TCPAddr))
go httpProxySessionsClean()
for {
c, err := ln.Accept()
if err != nil {
log.Error().Msg(err.Error())
continue
}
go doHttpProxy(srv, c)
}
}
func httpProxySessionsClean() {
for {
time.Sleep(time.Second * 30)
httpProxySessions.Range(func(key, value any) bool {
ses := value.(*HttpProxySession)
if time.Now().Unix() > ses.expire.Load() {
log.Debug().Msgf("Http proxy session '%s' expired", key)
endWebSessionLog(ses)
ses.cancel()
httpProxySessions.Delete(key)
}
return true
})
}
}
func doHttpProxy(srv *RttyServer, c net.Conn) {
defer LogPanic()
defer c.Close()
br := bufio.NewReader(c)
req, err := http.ReadRequest(br)
if err != nil {
return
}
domain, port, proto := proxy.GetRequestHostInfo(req)
log.Debug().Msgf("http proxy incoming host=%s port=%s proto=%s uri=%s",
domain, port, proto, req.URL.String())
devID, ok := proxy.ExtractDeviceIDFromHost(domain)
if ok {
log.Debug().Msgf("parsed deviceId from host: %s", devID)
} else {
log.Debug().Msgf("host is IP or invalid, skip deviceId parsing")
}
queryParams := req.URL.Query()
name := queryParams.Get("rttysid")
if name != "" {
location := "/"
Write302WithCookie(c, location, "rtty-http-sid", name)
return
}
cookie, err := req.Cookie("rtty-http-sid")
if err != nil {
log.Debug().Msgf(`not found cookie "rtty-http-sid"`)
sendHTTPErrorResponse(c, "invalid")
return
}
sid := cookie.Value
sesVal, ok := httpProxySessions.Load(sid)
if !ok {
log.Debug().Msgf(`not found httpProxySession "%s"`, sid)
sendHTTPErrorResponse(c, "unauthorized")
return
}
ses := sesVal.(*HttpProxySession)
dev := srv.GetDevice(ses.group, ses.devid)
if dev == nil {
log.Debug().Msgf(`device "%s" group "%s" offline`, ses.devid, ses.group)
sendHTTPErrorResponse(c, "offline")
return
}
// 3) match hostDevID vs session devid, and optionally lookup by hostDevID
if devID != "" {
match := devID == ses.devid
log.Debug().Msgf(
"http proxy devid check: hostDevID=%s sessionDevid=%s match=%v hostDevFound=%v sid=%s group=%s",
devID, ses.devid, match, domain, sid, ses.group,
)
// If you want, you can also log when mismatch happens
if !match {
log.Info().Msgf(
"http proxy devid mismatch: hostDevID=%s sessionDevid=%s sid=%s group=%s host=%s uri=%s",
devID, ses.devid, sid, ses.group, domain, req.URL.String(),
)
sendHTTPErrorResponse(c, "invalid")
}
} else {
log.Debug().Msgf(
"http proxy devid check skipped: no hostDevID (host=%s) sid=%s group=%s sessionDevid=%s",
domain, sid, ses.group, ses.devid,
)
}
hostHeaderRewrite := ses.destaddr
destAddr := genDestAddr(hostHeaderRewrite)
srcAddr := tcpAddr2Bytes(c.RemoteAddr().(*net.TCPAddr))
ctx, cancel := context.WithCancel(ses.ctx)
defer cancel()
go func() {
<-ctx.Done()
c.Close()
log.Debug().Msgf("http proxy conn closed: %s", ses)
dev.https.Delete(string(srcAddr))
sendHttpReq(dev, ses.https, srcAddr[:], destAddr, nil)
}()
log.Debug().Msgf("new http proxy conn: %s", ses)
dev.https.Store(string(srcAddr), c)
hpw := &HttpProxyWriter{destAddr, srcAddr, hostHeaderRewrite, dev, ses.https}
req.Host = hostHeaderRewrite
hpw.WriteRequest(req)
if req.Header.Get("Upgrade") == "websocket" {
b := make([]byte, 4096)
for {
n, err := c.Read(b)
if err != nil {
return
}
sendHttpReq(dev, ses.https, srcAddr, destAddr, b[:n])
ses.Expire()
}
} else {
for {
req, err := http.ReadRequest(br)
if err != nil {
return
}
hpw.WriteRequest(req)
ses.Expire()
}
}
}
func httpProxyRedirect(srv *RttyServer, c *gin.Context, group string) {
cfg := &srv.cfg
devid := c.Param("devid")
proto := c.Param("proto")
addr := c.Param("addr")
rawPath := c.Param("path")
log.Info().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
if !callUserHookUrl(cfg, c) {
c.Status(http.StatusForbidden)
return
}
log.Debug().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
_, _, err := httpProxyVaildAddr(addr)
if err != nil {
log.Debug().Msgf("invalid addr: %s", addr)
c.Status(http.StatusBadRequest)
return
}
path, err := url.Parse(rawPath)
if err != nil {
log.Debug().Msgf("invalid path: %s", rawPath)
c.Status(http.StatusBadRequest)
return
}
dev := srv.GetDevice(group, devid)
if dev == nil {
c.Redirect(http.StatusFound, "/error/offline")
return
}
location := c.Request.Header.Get("HttpProxyRedir")
log.Info().Msgf("HttpProxyRedir location: %s, devid: %s", location, devid)
if location == "" {
location = cfg.HttpProxyRedirURL
if location != "" {
log.Debug().Msgf("use HttpProxyRedirURL from config: %s, devid: %s", location, devid)
}
} else {
log.Debug().Msgf("use HttpProxyRedir from HTTP header: %s, devid: %s", location, devid)
}
if location == "" {
host, _, err := net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
location = "http://" + host
if srv.httpProxyPort != 80 {
location += fmt.Sprintf(":%d", srv.httpProxyPort)
}
}
location += path.Path
if path.RawQuery != "" {
location += "&" + path.RawQuery
}
sid, err := c.Cookie("rtty-http-sid")
log.Info().Msgf("rtty-http-sid: %s", sid)
if err == nil {
if v, loaded := httpProxySessions.LoadAndDelete(sid); loaded {
s := v.(*HttpProxySession)
endWebSessionLog(s)
s.cancel()
log.Debug().Msgf(`del old httpProxySession "%s" for device "%s"`, sid, devid)
}
}
sid = utils.GenUniqueID()
log.Info().Msgf("rtty-http-sid: %s", sid)
ctx, cancel := context.WithCancel(dev.ctx)
ses := &HttpProxySession{
ctx: ctx,
cancel: cancel,
devid: devid,
group: group,
destaddr: addr,
https: proto == "https",
}
if cont := sqlite.TryContainer(); cont != nil && cont.DeviceLogSvc != nil {
actorID, actorName := principalFromCtx(c)
if dev.ClientType() != "rtty-go" {
// Non-rtty-go clients use the KVM control UI → remote_control
ses.logID = cont.DeviceLogSvc.StartRemoteControlSession(
c.Request.Context(), devid, dev.desc, actorID, actorName, c.ClientIP())
if cont.NotificationSvc != nil {
cont.NotificationSvc.NotifyRemoteAccess("Remote Control", devid, dev.desc, actorName, c.ClientIP())
}
} else {
ses.logID = cont.DeviceLogSvc.StartRemoteWebSession(
c.Request.Context(), devid, dev.desc, actorID, actorName, c.ClientIP(), addr, proto)
if cont.NotificationSvc != nil {
cont.NotificationSvc.NotifyRemoteAccess("Remote Web", devid, dev.desc, actorName, c.ClientIP())
}
}
}
ses.Expire()
httpProxySessions.Store(sid, ses)
log.Debug().Msgf(`new httpProxySession "%s" for device "%s"`, sid, devid)
domain := c.Request.Header.Get("HttpProxyRedirDomain")
if domain == "" {
domain = cfg.HttpProxyRedirDomain
if domain != "" {
log.Debug().Msgf("set cookie domain from config: %s, devid: %s", domain, devid)
}
} else {
log.Debug().Msgf("set cookie domain from HTTP header: %s, devid: %s", domain, devid)
}
// Get domain info
host := c.Request.Host
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host
}
log.Info().Msgf("hostname: %s", hostname)
ip := net.ParseIP(hostname)
isIP := ip != nil
if isIP {
location = fmt.Sprintf("https://%s%s?rttysid=%s", hostname, cfg.AddrHttpProxy, sid)
log.Info().Msgf("Using IP redirect: %s", location)
} else {
redirHost := proxy.BuildRedirectHost(hostname, devid)
// Keep original behavior when NOT in reverse proxy mode
if !cfg.ReverseProxyEnabled {
location = fmt.Sprintf("https://%s%s?rttysid=%s", redirHost, cfg.AddrHttpProxy, sid)
log.Info().Msgf("Using domain redirect: %s", location)
} else {
// ---- verify forwarded headers from reverse proxy ----
rawHost := c.GetHeader("Host")
xfHost := c.GetHeader("X-Forwarded-Host")
xfProto := c.GetHeader("X-Forwarded-Proto")
xfPort := c.GetHeader("X-Forwarded-Port")
xRealIP := c.GetHeader("X-Real-IP")
xFF := c.GetHeader("X-Forwarded-For")
log.Info().Msgf(
"reverse-proxy info: method=%s uri=%s host=%q tls=%v remoteIP=%q",
c.Request.Method,
c.Request.URL.String(),
rawHost,
c.Request.TLS != nil,
c.ClientIP(),
)
log.Info().Msgf(
"reverse-proxy headers: Host=%q X-Forwarded-Host=%q X-Forwarded-Proto=%q X-Forwarded-Port=%q X-Real-IP=%q X-Forwarded-For=%q",
rawHost, xfHost, xfProto, xfPort, xRealIP, xFF,
)
// -------------------------------------------------
// Proxy mode:
// 1) If DEVICE_ENDPOINT_HOST is configured, use it directly
// 2) Otherwise, fallback to forwarded-header logic
// -------------------------------------------------
// 0) scheme: follow reverse proxy
scheme := ""
if v := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); v != "" {
scheme = strings.ToLower(strings.Split(v, ",")[0])
} else if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
// [A] Prefer explicit DEVICE_ENDPOINT_HOST if set
if v := strings.TrimSpace(cfg.DeviceEndpointHost); v != "" {
endpoint := v // already normalized when reading env: host[:port] only
baseHost := endpoint
port := ""
if h, p, err := net.SplitHostPort(endpoint); err == nil {
baseHost = h
port = p
}
// Build device host: <deviceId>.<baseHost>
// NOTE: DEVICE_ENDPOINT_HOST is a base domain (host[:port]) for device access,
baseHost = strings.TrimSuffix(strings.TrimSpace(baseHost), ".")
deviceHost := devid
if baseHost != "" {
deviceHost = devid + "." + baseHost
}
hostPort := proxy.JoinHostPortIfNeeded(deviceHost, scheme, port)
redirectPath := c.Request.URL.Path
location = proxy.BuildRedirectLocation(scheme, hostPort, redirectPath, sid)
log.Info().Msgf("Using domain redirect (proxy mode, DEVICE_ENDPOINT_HOST): %s", location)
} else {
// 1) external port: prefer the one user actually accessed
port := ""
if fp := strings.TrimSpace(c.GetHeader("X-Forwarded-Port")); fp != "" {
port = strings.TrimSpace(strings.Split(fp, ",")[0])
} else if fh := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); fh != "" {
fh = strings.TrimSpace(strings.Split(fh, ",")[0])
if _, p, err := net.SplitHostPort(fh); err == nil && p != "" {
port = p
}
}
log.Info().Msgf("port: %s", port)
// 3) Build host: in proxy mode redirect domain to be redirHost
hostPort := proxy.JoinHostPortIfNeeded(redirHost, scheme, port)
redirectPath := c.Request.URL.Path
location = proxy.BuildRedirectLocation(scheme, hostPort, redirectPath, sid)
log.Info().Msgf("Using domain redirect (proxy mode): %s", location)
}
}
}
log.Info().Msgf("Final redirect location: %s", location)
c.Redirect(http.StatusFound, location)
}
func sendHttpReq(dev *Device, https bool, srcAddr []byte, destAddr []byte, data []byte) {
bb := bytebufferpool.Get()
defer bytebufferpool.Put(bb)
if dev.proto > 3 {
if https {
bb.WriteByte(1)
} else {
bb.WriteByte(0)
}
}
bb.Write(srcAddr)
bb.Write(destAddr)
bb.Write(data)
dev.WriteMsg(msgTypeHttp, "", bb.Bytes())
}
func genDestAddr(addr string) []byte {
destIP, destPort, err := httpProxyVaildAddr(addr)
if err != nil {
return nil
}
b := make([]byte, 6)
copy(b, destIP)
binary.BigEndian.PutUint16(b[4:], destPort)
return b
}
func tcpAddr2Bytes(addr *net.TCPAddr) []byte {
b := make([]byte, 18)
binary.BigEndian.PutUint16(b[:2], uint16(addr.Port))
copy(b[2:], addr.IP)
return b
}
func httpProxyVaildAddr(addr string) (net.IP, uint16, error) {
ips, ports, err := net.SplitHostPort(addr)
if err != nil {
ips = addr
ports = "80"
}
ip := net.ParseIP(ips)
if ip == nil {
return nil, 0, errors.New("invalid IPv4 Addr")
}
ip = ip.To4()
if ip == nil {
return nil, 0, errors.New("invalid IPv4 Addr")
}
port, _ := strconv.Atoi(ports)
return ip, uint16(port), nil
}
type HttpProxyWriter struct {
destAddr []byte
srcAddr []byte
hostHeaderRewrite string
dev *Device
https bool
}
func (rw *HttpProxyWriter) Write(p []byte) (n int, err error) {
sendHttpReq(rw.dev, rw.https, rw.srcAddr, rw.destAddr, p)
return len(p), nil
}
func (rw *HttpProxyWriter) WriteRequest(req *http.Request) {
req.Host = rw.hostHeaderRewrite
req.Write(rw)
}
func generateErrorHTML(errorType string) string {
return fmt.Sprintf(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTTY</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #555;
line-height: 1.6;
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-icon {
margin-bottom: 2rem;
animation: fadeIn 0.8s ease-in-out;
}
.error-icon svg {
width: 90px;
height: 90px;
fill: #f56565;
}
.error-content {
max-width: 700px;
animation: slideUp 0.8s ease-out 0.2s both;
}
.error-title {
font-size: 1.8rem;
font-weight: 600;
color: #7a8fb0;
margin-bottom: 1rem;
line-height: 1.2;
}
.error-message {
font-size: 1rem;
color: #b6c1d3;
margin-bottom: 2rem;
line-height: 1.6;
text-align: left;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">
<svg viewBox="0 0 24 24">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
</svg>
</div>
<div class="error-content">
<h2 class="error-title" id="errorTitle"></h2>
<p class="error-message" id="errorMessage"></p>
</div>
</div>
<script>
const translations = {
en: {
'Device Unavailable': 'Device Unavailable',
'Invalid Request': 'Invalid Request',
'Unauthorized Access': 'Unauthorized Access',
'Device offline message': 'The device is currently offline. Please check the device status and try again.',
'Invalid request message': 'The request is invalid or malformed',
'Unauthorized request message': 'You are not authorized to access this resource. Please check your session and try again.'
},
'zh-CN': {
'Device Unavailable': '璁惧涓嶅彲鐢?,
'Invalid Request': '鏃犳晥璇锋眰',
'Unauthorized Access': '鏈巿鏉冭闂?,
'Device offline message': '璁惧褰撳墠绂荤嚎锛岃妫€鏌ヨ澶囩姸鎬佸悗閲嶈瘯銆?,
'Invalid request message': '璇锋眰鏃犳晥鎴栨牸寮忛敊璇?,
'Unauthorized request message': '鎮ㄦ棤鏉冭闂璧勬簮銆傝妫€鏌ユ偍鐨勪細璇濆苟閲嶈瘯銆?
}
};
function t(key, lang) {
return translations[lang][key] || translations.en[key] || key;
}
function updateContent() {
const errorType = '%s';
const lang = navigator.language === 'zh-CN' ? 'zh-CN' : 'en';
let title = '', message = '';
switch (errorType) {
case 'offline':
title = t('Device Unavailable', lang);
message = t('Device offline message', lang);
break;
case 'invalid':
title = t('Invalid Request', lang);
message = t('Invalid request message', lang);
break;
case 'unauthorized':
title = t('Unauthorized Access', lang);
message = t('Unauthorized request message', lang);
break;
}
document.getElementById('errorTitle').textContent = title;
document.getElementById('errorMessage').textContent = message;
// Update page title
if (title) {
document.title = title + ' - RTTY';
} else {
document.title = 'Error - RTTY';
}
}
// Initialize page on load
document.addEventListener('DOMContentLoaded', updateContent);
</script>
</body>
</html>`, errorType)
}
func sendHTTPErrorResponse(conn net.Conn, errorType string) {
htmlContent := generateErrorHTML(errorType)
response := "HTTP/1.1 200 OK\r\n"
response += "Content-Type: text/html; charset=utf-8\r\n"
response += fmt.Sprintf("Content-Length: %d\r\n", len(htmlContent))
response += "Connection: close\r\n"
response += "\r\n"
response += htmlContent
conn.Write([]byte(response))
}
func Write302WithCookie(conn net.Conn, location, cookieName, cookieValue string) {
cookie := fmt.Sprintf("%s=%s; Path=/; HttpOnly", cookieName, cookieValue)
response := fmt.Sprintf(
"HTTP/1.1 302 Found\r\n"+
"Location: %s\r\n"+
"Set-Cookie: %s\r\n"+
"Content-Length: 0\r\n"+
"Connection: close\r\n"+
"\r\n",
location, cookie,
)
_, _ = conn.Write([]byte(response))
}
+41
View File
@@ -0,0 +1,41 @@
package server
import (
"net/http"
"rttys/internal/proxy"
)
type HostInfo = proxy.HostInfo
func getHostInfoFromRequest(req *http.Request) HostInfo {
return proxy.GetHostInfoFromRequest(req)
}
func isIPHost(host string) bool {
return proxy.IsIPHost(host)
}
func domainAllowed(host, base string) bool {
return proxy.DomainAllowed(host, base)
}
func buildRedirectHost(hostname, devid string) string {
return proxy.BuildRedirectHost(hostname, devid)
}
func joinHostPortIfNeeded(host, scheme, port string) string {
return proxy.JoinHostPortIfNeeded(host, scheme, port)
}
func buildRedirectLocation(scheme, hostPort, path, sid string) string {
return proxy.BuildRedirectLocation(scheme, hostPort, path, sid)
}
func getRequestHostInfo(req *http.Request) (host string, port string, proto string) {
return proxy.GetRequestHostInfo(req)
}
func extractDeviceIDFromHost(host string) (string, bool) {
return proxy.ExtractDeviceIDFromHost(host)
}
+66 -18
View File
@@ -1,4 +1,4 @@
package main
package server
import (
"context"
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
oidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/fanjindong/go-cache"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/rs/zerolog/log"
@@ -14,7 +13,10 @@ import (
"math/rand"
"net/http"
"net/url"
"rttys/utils"
"rttys/internal/domain/identity"
"rttys/internal/domain/user"
"rttys/internal/pkg/randtoken"
"rttys/xconfig"
"strings"
"time"
)
@@ -27,7 +29,7 @@ var (
)
// Register OIDC routes
func RegisterOIDCRoutes(r *gin.Engine, cfg *Config) {
func RegisterOIDCRoutes(r *gin.Engine, cfg *xconfig.Config, userSvc *user.Service) {
if !cfg.OIDCEnabled {
return
}
@@ -69,11 +71,11 @@ func RegisterOIDCRoutes(r *gin.Engine, cfg *Config) {
// OIDC auth routes (public, no existing auth required)
r.GET("/auth/oidc/login", oidcLoginHandler(cfg))
r.GET("/auth/oidc/callback", oidcCallbackHandler(cfg))
r.GET("/auth/oidc/callback", oidcCallbackHandler(cfg, userSvc))
}
// Start OIDC login
func oidcLoginHandler(cfg *Config) gin.HandlerFunc {
func oidcLoginHandler(cfg *xconfig.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Generate state and nonce
@@ -109,7 +111,7 @@ func oidcLoginHandler(cfg *Config) gin.HandlerFunc {
}
// Handle OIDC callback
func oidcCallbackHandler(cfg *Config) gin.HandlerFunc {
func oidcCallbackHandler(cfg *xconfig.Config, userSvc *user.Service) gin.HandlerFunc {
return func(c *gin.Context) {
// Get session
session, err := oauthStore.Get(c.Request, "oidc-session")
@@ -155,7 +157,7 @@ func oidcCallbackHandler(cfg *Config) gin.HandlerFunc {
var userEmail string
var userName string
// Standard OIDC – verify and parse ID token
// Standard OIDC verify and parse ID token
rawIDToken, ok := tokens["id_token"].(string)
if !ok {
log.Error().Msg("No ID token in response")
@@ -231,15 +233,61 @@ func oidcCallbackHandler(cfg *Config) gin.HandlerFunc {
return
}
// Create application session
sid := utils.GenUniqueID()
httpSessions.Set(sid, gin.H{
"email": userEmail,
"name": userName,
"oidc": true,
}, cache.WithEx(httpSessionExpire))
// ==== Create application session ====
sid, err := randtoken.New()
if err != nil {
log.Error().Err(err).Msg("Failed to create session token")
c.Redirect(http.StatusFound, "/?error=internal_error")
return
}
c.SetCookie("sid", sid, 0, "", "", cfg.SslCert != "", true)
preferredUsername, _ := claims["preferred_username"].(string)
// Determine role based on admin group / admin users
role := identity.RoleUser
hasAdminRule := len(cfg.OIDCAdminGroup) > 0 || len(cfg.OIDCAdminUsers) > 0
if hasAdminRule {
// Check admin users list (match preferred_username or email)
if len(cfg.OIDCAdminUsers) > 0 {
if contains(cfg.OIDCAdminUsers, preferredUsername) || contains(cfg.OIDCAdminUsers, userEmail) {
role = identity.RoleAdmin
}
}
// Check admin group membership
if role != identity.RoleAdmin && len(cfg.OIDCAdminGroup) > 0 {
groups := extractStringSlice(claims["groups"])
if intersects(groups, cfg.OIDCAdminGroup) {
role = identity.RoleAdmin
}
}
log.Info().
Str("sub", sub).
Str("email", userEmail).
Str("name", userName).
Str("preferredUsername", preferredUsername).
Strs("userGroups", extractStringSlice(claims["groups"])).
Strs("adminGroup", cfg.OIDCAdminGroup).
Strs("adminUsers", cfg.OIDCAdminUsers).
Str("role", string(role)).
Msg("OIDC admin role check")
}
oidcUser, err := userSvc.FindOrCreateExternalUser(c.Request.Context(), "oidc", sub, preferredUsername, userEmail, userName, role)
if err != nil {
log.Error().Err(err).Msg("Failed to find or create OIDC user")
c.Redirect(http.StatusFound, "/?error=internal_error")
return
}
log.Info().
Str("sub", sub).
Str("email", userEmail).
Str("preferredUsername", preferredUsername).
Str("role", string(role)).
Int64("userID", oidcUser.ID).
Msg("OIDC user login completed")
sessionStore.Create(sid, oidcUser.ID)
c.SetCookie("sid", sid, 0, "/", "", cfg.SslCert != "", false)
// Clean up OAuth session
session.Options.MaxAge = -1
@@ -251,7 +299,7 @@ func oidcCallbackHandler(cfg *Config) gin.HandlerFunc {
}
// Exchange authorization code for tokens
func exchangeCodeForTokens(cfg *Config, code string) (map[string]interface{}, error) {
func exchangeCodeForTokens(cfg *xconfig.Config, code string) (map[string]interface{}, error) {
data := url.Values{}
data.Set("code", code)
data.Set("client_id", cfg.OIDCGenericClientID)
@@ -297,7 +345,7 @@ func generateRandomString(length int) string {
return base64.URLEncoding.EncodeToString(b)[:length]
}
func isOIDCUserAllowed(cfg *Config, claims map[string]interface{}) bool {
func isOIDCUserAllowed(cfg *xconfig.Config, claims map[string]interface{}) bool {
email, _ := claims["email"].(string)
sub, _ := claims["sub"].(string)
preferredUsername, _ := claims["preferred_username"].(string)
+20
View File
@@ -0,0 +1,20 @@
package server
import (
"os"
"runtime/debug"
"github.com/rs/zerolog/log"
)
func LogPanic() {
if r := recover(); r != nil {
SaveCrashLog(r, debug.Stack())
os.Exit(2)
}
}
func SaveCrashLog(p any, stack []byte) {
log.Error().Msgf("%v", p)
log.Error().Msg(string(stack))
}
+202
View File
@@ -0,0 +1,202 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package server
import (
"context"
"flag"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"testing"
"time"
"rttys/xconfig"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
func TestRttysStress(t *testing.T) {
duration := 10 * time.Minute
timeoutFlag := flag.Lookup("test.timeout")
if timeoutFlag != nil {
duration = timeoutFlag.Value.(flag.Getter).Get().(time.Duration)
}
cfg := xconfig.Config{
AddrDev: ":5912",
AddrUser: ":5913",
}
srv := &RttyServer{cfg: cfg}
go func() {
err := srv.Run()
if err != nil {
log.Fatal().Msg(err.Error())
}
}()
ctx, cancel := context.WithTimeout(context.Background(), duration-time.Second*2)
defer cancel()
time.Sleep(time.Millisecond * 100)
log.Info().Msg("Waiting for devices to connect for testing...")
devices := &sync.Map{}
for {
select {
case <-ctx.Done():
log.Info().Msg("Test timeout, exiting...")
return
default:
time.Sleep(time.Second * 1)
srv.groups.Range(func(key, value any) bool {
group := key.(string)
g := value.(*DeviceGroup)
g.devices.Range(func(key, value any) bool {
dev := value.(*Device)
if _, loaded := devices.LoadOrStore(dev.id, group+dev.id); !loaded {
go runDeviceTest(ctx, devices, group, dev.id)
}
return true
})
return true
})
}
}
}
func runDeviceTest(ctx context.Context, devices *sync.Map, group, devID string) {
ctx, cancel := context.WithCancel(ctx)
defer func() {
time.Sleep(time.Second)
cancel()
devices.Delete(group + devID)
}()
go runHttpTest(ctx, group, devID)
wg := &sync.WaitGroup{}
for range 7 {
wg.Add(1)
go runWebSocketTest(ctx, group, devID, wg)
}
wg.Wait()
}
func runWebSocketTest(ctx context.Context, group, devID string, wg *sync.WaitGroup) {
conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:5913/connect/"+devID+"?group="+group, nil)
if err != nil {
log.Fatal().Msg(err.Error())
}
defer conn.Close()
defer wg.Done()
go func() {
<-ctx.Done()
conn.Close()
}()
go func() {
msg := []byte{0}
msg = append(msg, []byte("ttttttttttttttttttttttttttttt\n")...)
msg = append(msg, []byte("ttttttttttttttttttttttttttttt\n")...)
for {
err = conn.WriteMessage(websocket.BinaryMessage, msg)
if err != nil {
return
}
time.Sleep(time.Millisecond * 20)
}
}()
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
}
func runHttpTest(ctx context.Context, group, devID string) {
for {
select {
case <-ctx.Done():
return
default:
runHttpTestOnce(ctx, group, devID)
}
}
}
func runHttpTestOnce(ctx context.Context, group, devID string) {
addr := ""
if group == "" {
addr = "http://127.0.0.1:5913/web/"
} else {
addr = "http://127.0.0.1:5913/web2/" + group + "/"
}
addr += devID + "/http/" + encodeURIComponent("127.0.0.1:80/")
jar, _ := cookiejar.New(nil)
client := &http.Client{
Jar: jar,
}
request, _ := http.NewRequestWithContext(ctx, "GET", addr, nil)
for range 10 {
res, err := client.Do(request)
if err != nil {
log.Info().Msg(err.Error())
return
}
defer res.Body.Close()
io.ReadAll(res.Body)
time.Sleep(10 * time.Millisecond)
}
}
func encodeURIComponent(str string) string {
r := url.QueryEscape(str)
r = strings.ReplaceAll(r, "+", "%20")
return r
}
+141 -107
View File
@@ -1,144 +1,178 @@
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package main
/*
* MIT License
*
* Copyright (c) 2019 Jianhui Zhao <zhaojh329@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package server
import (
"context"
"net"
"net/http"
"rttys/internal/store/sqlite"
"rttys/xconfig"
"strings"
"sync"
"sync/atomic"
"github.com/rs/zerolog/log"
)
type RttyServer struct {
mu sync.RWMutex
groups sync.Map
cfg Config
cfg xconfig.Config
httpProxyPort int
}
type DeviceGroup struct {
devices sync.Map
count atomic.Int32
}
func New(cfg xconfig.Config) *RttyServer {
return &RttyServer{cfg: cfg}
}
func (srv *RttyServer) Run() error {
log.Debug().Msgf("%+v", srv.cfg)
if err := markAllDevicesOffline(); err != nil {
log.Warn().Err(err).Msg("mark all devices offline failed")
}
if srv.cfg.PprofAddr != "" {
go srv.ListenPprof()
}
log.Info().Msgf("SslCert: %s,SslKey: %s", srv.cfg.SslCert, srv.cfg.SslKey)
log.Info().Msgf("SslCert: %s,SslKey: %s", srv.cfg.SslCert, srv.cfg.SslKey)
go srv.ListenDevices()
go srv.ListenHttpProxy()
return srv.ListenAPI()
}
func (srv *RttyServer) ListenPprof() {
ln, err := net.Listen("tcp", srv.cfg.PprofAddr)
func markAllDevicesOffline() error {
db, err := sqlite.Open(context.Background(), sqlite.Options{
DSN: defaultDBPath,
MaxOpenConns: 1,
MaxIdleConns: 1,
LogSQL: false,
})
if err != nil {
log.Error().Err(err).Msgf("Failed to start pprof server")
return
return err
}
defer ln.Close()
defer db.Close()
addr := ln.Addr().(*net.TCPAddr)
log.Info().Msgf("Starting pprof server on: %s", addr)
host := addr.IP.String()
if host == "0.0.0.0" || host == "::" {
host = "localhost"
}
log.Info().Msgf("Access pprof at: http://%s:%d/debug/pprof/", host, addr.Port)
err = http.Serve(ln, nil)
if err != nil {
log.Error().Err(err).Msgf("pprof server failed")
}
}
func (srv *RttyServer) GetDevice(group, id string) *Device {
srv.mu.RLock()
defer srv.mu.RUnlock()
g := srv.GetGroup(group, false)
if g == nil {
return nil
}
if v, ok := g.devices.Load(id); ok {
return v.(*Device)
}
return nil
}
func (srv *RttyServer) AddDevice(dev *Device) bool {
srv.mu.Lock()
defer srv.mu.Unlock()
g := srv.GetGroup(dev.group, true)
if _, loaded := g.devices.LoadOrStore(dev.id, dev); loaded {
return false
}
g.count.Add(1)
return true
}
func (srv *RttyServer) DelDevice(dev *Device) {
srv.mu.Lock()
defer srv.mu.Unlock()
g := srv.GetGroup(dev.group, false)
if g == nil {
return
}
if deleted := g.devices.CompareAndDelete(dev.id, dev); deleted {
if g.count.Add(-1) == 0 {
srv.groups.Delete(dev.group)
}
}
}
func (srv *RttyServer) GetGroup(group string, create bool) *DeviceGroup {
if create {
val, _ := srv.groups.LoadOrStore(group, &DeviceGroup{})
return val.(*DeviceGroup)
} else {
val, ok := srv.groups.Load(group)
if !ok {
res := db.Gorm().Exec(`UPDATE devices SET status='offline' WHERE status='online'`)
if res.Error != nil {
if strings.Contains(res.Error.Error(), "no such table") {
return nil
}
return val.(*DeviceGroup)
return res.Error
}
return nil
}
func (srv *RttyServer) ListenPprof() {
ln, err := net.Listen("tcp", srv.cfg.PprofAddr)
if err != nil {
log.Error().Err(err).Msgf("Failed to start pprof server")
return
}
defer ln.Close()
addr := ln.Addr().(*net.TCPAddr)
log.Info().Msgf("Starting pprof server on: %s", addr)
host := addr.IP.String()
if host == "0.0.0.0" || host == "::" {
host = "localhost"
}
log.Info().Msgf("Access pprof at: http://%s:%d/debug/pprof/", host, addr.Port)
err = http.Serve(ln, nil)
if err != nil {
log.Error().Err(err).Msgf("pprof server failed")
}
}
func (srv *RttyServer) GetDevice(group, id string) *Device {
srv.mu.RLock()
defer srv.mu.RUnlock()
g := srv.GetGroup(group, false)
if g == nil {
return nil
}
if v, ok := g.devices.Load(id); ok {
return v.(*Device)
}
return nil
}
func (srv *RttyServer) AddDevice(dev *Device) bool {
srv.mu.Lock()
defer srv.mu.Unlock()
g := srv.GetGroup(dev.group, true)
if _, loaded := g.devices.LoadOrStore(dev.id, dev); loaded {
return false
}
g.count.Add(1)
return true
}
func (srv *RttyServer) DelDevice(dev *Device) {
srv.mu.Lock()
defer srv.mu.Unlock()
g := srv.GetGroup(dev.group, false)
if g == nil {
return
}
if deleted := g.devices.CompareAndDelete(dev.id, dev); deleted {
if g.count.Add(-1) == 0 {
srv.groups.Delete(dev.group)
}
}
}
func (srv *RttyServer) GetGroup(group string, create bool) *DeviceGroup {
if create {
val, _ := srv.groups.LoadOrStore(group, &DeviceGroup{})
return val.(*DeviceGroup)
} else {
val, ok := srv.groups.Load(group)
if !ok {
return nil
}
return val.(*DeviceGroup)
}
}
+6
View File
@@ -0,0 +1,6 @@
package server
// StartSignalHandler enables runtime signal handling (noop on Windows).
func StartSignalHandler() {
signalHandle()
}

Some files were not shown because too many files have changed in this diff Show More