102 Commits

Author SHA1 Message Date
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
GL.iNet-Yongping.Xie 72f1986309 Merge branch 'dev' 2025-11-12 23:48:34 -08:00
GL.iNet-Yongping.Xie e9e773408d docs: update README with OIDC feature support details
1. Updated the README documentation to include clearer and more complete
   information about OIDC feature support.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 23:47:27 -08:00
GL.iNet-Yongping.Xie f6f06582dd feat: add whitelist support for subid and group
1. Added whitelist functionality for subid, group, and related fields to
   allow finer-grained access control and improved authentication
   flexibility.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 23:08:45 -08:00
GL.iNet-Yongping.Xie 1ab54dae68 feat: add token signature verification logic
1. Implemented token signature verification to ensure the integrity and
   authenticity of tokens before processing authentication requests.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 18:51:34 -08:00
GL.iNet-Yongping.Xie d9a66add94 feat: switch code comments from Chinese to English
1. Converted all code comments from Chinese to English to improve
   readability and better support international and English-speaking
   users.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 17:53:48 -08:00
GL.iNet-Yongping.Xie de48a9041e feat: add OIDC whitelist feature
1. Added OIDC whitelist functionality to allow specific users or domains
   to bypass standard authentication checks for controlled access.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 03:19:33 -08:00
GL.iNet-Yongping.Xie a31607ad09 feat: add support for custom OIDC login flow
Added support for a customizable OIDC login flow. Verified successful authentication using Google as the OIDC provider.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 01:54:04 -08:00
pengyu.lu a9711565cd feat: Add the function of logging in with OIDC 2025-11-12 17:51:54 +08:00
GL.iNet-Yongping.Xie b29ca0c117 doc: update Chinese and English README for LDAP login
1. Updated both Chinese and English README files to document LDAP login
   support.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-30 04:12:49 -07:00
iclannad 7ce942d0d3 Merge pull request #8 from CU-Jon/feature/LDAP
feat: add optional LDAP authentication support
2025-09-30 18:39:16 +08:00
Jon Agramonte 5e16e10e9a feat: add optional LDAP authentication support
- Introduced LDAP authentication configuration options in the README and Docker Compose files.
- Updated API to handle dual authentication methods (LDAP and legacy).
- Implemented LDAP authentication logic in a new ldap.go file.
- Enhanced user login flow to support username and password for LDAP.
- Added error handling for authorization and authentication failures.
- Updated UI to include username input when LDAP is enabled and provide authentication options.
- Added localization for new authentication messages in English and Chinese.
2025-09-29 09:59:14 -04:00
GL.iNet-Yongping.Xie 3e9e5bb93a feat: support multi-level subdomain certificates
1. Added support for multi-level subdomains and certificates, such as
   *.example.com, *.level1.example.com, and *.level2.level1.example.com.
2. Enabled remote device access through domains like
   devId.example.com, devId.level1.example.com, and
   devId.level2.level1.example.com.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-26 02:29:17 -07:00
pengyu.lu 3c6c214479 fix: Fixed the issue where the gl/main dependency could not be downloaded 2025-09-25 11:40:26 +08:00
GL.iNet-Yongping.Xie ed3da45ddf feat: release version v1.0.1
1. Published official Docker images to Docker Hub.
2. Added docker-compose configuration template for easier deployment.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-15 21:00:58 -07:00
GL.iNet-Yongping.Xie b089169270 doc: update docker-compose YAML file
1. Official image has been pushed to Docker Hub.
2. Updated the docker-compose YAML file to reference the official image.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-15 19:17:00 -07:00
GL.iNet-Yongping.Xie 2349554c22 doc: update docker-compose startup instructions
1. Updated the documentation for starting services with docker-compose,
   making the description clearer and more accurate.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-15 01:24:49 -07:00
GL.iNet-Yongping.Xie 77886cf052 doc: update docker-compose startup instructions
1. Updated the documentation for starting services with docker-compose,
   making the description clearer and more accurate.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-15 01:19:29 -07:00
GL.iNet-Yongping.Xie 9be7a854ce feat: add initial docker-compose.yml for testing
1. Added support for docker-compose.yml to simplify deployment and
   service management.
2. Tested environment variable configurations to validate compatibility
   and ensure correct behavior.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-15 01:05:21 -07:00
GL.iNet-Yongping.Xie 2b6aecf72a fix: remove docker-compose.yml file
1. The glkvm-cloud image has not yet been pushed to the repository and
   cannot be used directly.
2. Temporarily removed the docker-compose.yml file to prevent customers
   from misusing it.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-10 01:08:44 -07:00
GL.iNet-Yongping.Xie efa73caf75 fix: update Chinese and English README
1. Added clarification about dedicated bandwidth for self-hosted
   deployments.
2. Corrected grammar errors in the English documentation.
3. Added server bandwidth configuration requirements to guide proper
   deployment.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-09-07 20:08:56 -07:00
202 changed files with 15961 additions and 4460 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)
+58 -25
View File
@@ -1,41 +1,74 @@
# 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 \
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
docker-build: ui build
docker build -t glkvm:v1 .
docker-buildx-full: ui
@$(MAKE) docker-buildx
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"
+80 -13
View File
@@ -6,14 +6,19 @@ 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
- **Batch Operations** - Batch command execution capabilities
- **Rapid Deployment** - Quick self-deployment with simple operations
- **Data Security** - Private deployment with full data control
- **Dedicated Bandwidth** - Exclusive bandwidth for self-hosted deployments
- **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 OpenWrt, ImmortalWrt, Raspberry Pi, Linux VPS, macOS, and Windows hosts into self-hosted GLKVM Cloud for centralized management, and using them as HTTP/HTTPS web proxy nodes for NAT traversal access
## Self-Hosting Guide
@@ -34,9 +39,10 @@ The following mainstream operating systems have been tested and verified
| Component | Minimum Requirement |
| :-----------------: | :-----------------: |
| CPU | 1 cores or above |
| CPU | 1 core or above |
| Memory | ≥ 1 GB |
| Storage | ≥ 40 GB |
| Network Bandwidth | ≥ 3 Mbps |
| KVM Device Firmware | ≥ v1.5.0 |
#### 🔐 Cloud Security Group Settings
@@ -56,29 +62,44 @@ If your server provider uses a **cloud security group** (e.g., AWS, Aliyun, etc.
------
### 📦 Installation
Run the following command **as root** to install GLKVM Cloud:
We provide **two** ways to install GLKVM Cloud:
#### 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**:
```bash
( 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 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
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)
@@ -108,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)
@@ -151,9 +176,51 @@ Replace the following files in:
⚠️ **Make sure the filenames remain unchanged.**
#### 🔄 Restart Services After Certificate Replacement
#### 🔐 LDAP Authentication Configuration (Optional)
After replacing the certificates, restart the GLKVM Cloud services to apply the 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:
```bash
cd ~/glkvm_cloud
@@ -172,4 +239,4 @@ Once everything is configured, you can access the platform via your domain:
```
https://www.your-domain.com
```
```
+74 -10
View File
@@ -7,14 +7,19 @@
#### 主要功能与特性
* **设备管理** - 实时查看设备在线状态
* **用户组和设备支持** - 支持用户组管理特定的设备组设备,实现不同用户管理不同设备
* **脚本部署** - 通过脚本快速添加设备
* **远程 SSH** - Web SSH 远程连接
* **远程控制** - Web远程桌面控制
* **批量操作** - 支持批量执行命令
* **快速部署** - 简单命令即可完成自部署
* **数据安全** - 私有化部署,数据完全可控
* **独享带宽** - 自部署环境下可享受专属带宽
* **轻量设计** - 专为小型企业和个人优化
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
- **部署与平台兼容性** - 同时支持 **内网部署** 和 **公网部署**,并兼容 **x86_64** 与 **arm64** 平台
- **HTTP/HTTPS Web代理功能支持** - 支持 OpenWrt、ImmortalWrt、树莓派、Linux VPS、macOS、Windows 等主机接入自部署 GLKVM Cloud 进行统一管理,并可作为 HTTP/HTTPS Web 代理节点实现内网穿透访问
## 自部署指南
@@ -34,10 +39,11 @@
#### 系统要求
| 组件 | 最低配置要求 |
| ------------ | ------------ |
| :------------: | :------------: |
| CPU | 1 核及以上 |
| 内存 | ≥ 1 GB |
| 存储 | ≥ 40 GB |
| 网络带宽 | ≥ 3 Mbps |
| KVM 固件版本 | ≥ v1.5.0 |
#### 🔐 云安全组端口要求
@@ -56,29 +62,45 @@
## 📦 安装
我们提供 **两种** 安装 GLKVM Cloud 的方式:
#### A) 一键安装脚本(推荐,仅支持 x86_64 / amd64)
> **注意:** 一键安装脚本基于 **Docker**。它会自动完成 Docker / Docker Compose 的安装、拉取镜像、根据模板渲染配置文件,并启动所有服务。
>
> **平台支持:** 当前仅支持 **x86_64(amd64)** 平台。
使用 **root 权限** 运行以下命令安装 GLKVM 轻量云:
```bash
( 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)
@@ -107,6 +129,10 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
![远程桌面截图](img/web.png)
#### Web代理功能
![Web代理功能截图](img/httpproxy.png)
## 使用自有 SSL 证书(可选)
⚠️ **可选配置**:
@@ -147,9 +173,47 @@ 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 轻量云服务以应用更改:
```bash
cd ~/glkvm_cloud
-411
View File
@@ -1,411 +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 {
Password string `json:"password"`
}
creds := credentials{}
err := c.BindJSON(&creds)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
if httpLogin(cfg, creds.Password) {
sid := utils.GenUniqueID()
httpSessions.Set(sid, true, cache.WithEx(httpSessionExpire))
c.SetCookie("sid", sid, 0, "", "", false, true)
c.Status(http.StatusOK)
return
}
c.Status(http.StatusUnauthorized)
})
r.GET("/alive", func(c *gin.Context) {
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.Status(http.StatusOK)
}
})
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 {
// 没有端口时直接使用 host
hostname = host
}
c.JSON(http.StatusOK, gin.H{
"hostname": hostname,
"port": cfg.AddrDev,
"token": cfg.Token,
"webrtcIP": cfg.WebrtcIP,
"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 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
-27
View File
@@ -1,27 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G
h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV
6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj
v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc
MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL
pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp
eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH
pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7
s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu
h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv
YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8
ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0
LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+
EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY
Ig46v9mFmBvyH04=
-----END CERTIFICATE-----
@@ -1,22 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDkzCCAxmgAwIBAgISBQsXxrfK6EpotI2fXWteV3kMMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
NjAeFw0yNTA3MjgwNTM5MjNaFw0yNTEwMjYwNTM5MjJaMBcxFTATBgNVBAMTDGNs
YW54aWUubGlmZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKcExKR+nZ21Hpm6
rqR/QuJ35yRwhBtTim+5cColIDIocjasMhB1ho49IINEa/hp5EHqTsMsgSkGEZ6R
unr3D0qjggIoMIICJDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYBBQUH
AwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFKlsFBhG/CpyaZvz
3/gFZdP0vxsWMB8GA1UdIwQYMBaAFJMnRpgDqVFojpjWxEJI2yO/WJTSMDIGCCsG
AQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL2U2LmkubGVuY3Iub3JnLzAn
BgNVHREEIDAegg4qLmNsYW54aWUubGlmZYIMY2xhbnhpZS5saWZlMBMGA1UdIAQM
MAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQwIqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNy
Lm9yZy8xMy5jcmwwggECBgorBgEEAdZ5AgQCBIHzBIHwAO4AdQDtPEvW6AbCpKIA
V9vLJOI4Ad9RL+3EhsVwDyDdtz4/4AAAAZhPwIK1AAAEAwBGMEQCIGoi+WQh9Bhb
4f68AFmojUBHdj0cJhH8Wgv26QxqKUXZAiAyrxtJ9viGLjivjDUPm8NjmgquuLeH
UAMkhSdvZmKK1QB1AA3h8jAr0w3BQGISCepVLvxHdHyx1+kw7w5CHrR+Tqo0AAAB
mE/AmiYAAAQDAEYwRAIgbXMFtU65Mrr1ZaQSdX3Jc+5YO1Y/yApwx7vbGKOzriQC
IFgfpun9rGzPSXUBW+oEjkvUeN1Yf2Thu4YIvkUfuceiMAoGCCqGSM49BAMDA2gA
MGUCMCU9wGtvD/A/LqIdeNFtS8/Um7Sv0iyAn8JGcAu/GCI2oavEQk90mPvqMuqX
W2rUQgIxANHH0WTA3121/bK4Rhu5w8kXUI8AWnxizZ4hDjGCKi6WmJCGhLqtleYK
hhrMK8WjmQ==
-----END CERTIFICATE-----
@@ -1,15 +0,0 @@
Le_Domain='clanxie.life'
Le_Alt='*.clanxie.life'
Le_Webroot='dns_cf'
Le_PreHook=''
Le_PostHook=''
Le_RenewHook=''
Le_API='https://acme-v02.api.letsencrypt.org/directory'
Le_Keylength='ec-256'
Le_OrderFinalize='https://acme-v02.api.letsencrypt.org/acme/finalize/2400063957/411715523371'
Le_LinkOrder='https://acme-v02.api.letsencrypt.org/acme/order/2400063957/411715523371'
Le_LinkCert='https://acme-v02.api.letsencrypt.org/acme/cert/050b17c6b7cae84a68b48d9f5d6b5e57790c'
Le_CertCreateTime='1753684679'
Le_CertCreateTimeStr='2025-07-28T06:37:59Z'
Le_NextRenewTimeStr='2025-09-25T06:37:59Z'
Le_NextRenewTime='1758782279'
@@ -1,9 +0,0 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBKzCB0gIBADAXMRUwEwYDVQQDDAxjbGFueGllLmxpZmUwWTATBgcqhkjOPQIB
BggqhkjOPQMBBwNCAASnBMSkfp2dtR6Zuq6kf0Lid+ckcIQbU4pvuXAqJSAyKHI2
rDIQdYaOPSCDRGv4aeRB6k7DLIEpBhGekbp69w9KoFkwVwYJKoZIhvcNAQkOMUow
SDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwJwYDVR0RBCAwHoIMY2xh
bnhpZS5saWZlgg4qLmNsYW54aWUubGlmZTAKBggqhkjOPQQDAgNIADBFAiEAvyNm
66wN/4Ni5cLfH8KpwHcgoVGXDoVAawiVaMdmsD0CIAKsdhG5SWWqvNYhYjKIVKbh
Fv8cJ8R1rsaQPO98zj5k
-----END CERTIFICATE REQUEST-----
@@ -1,8 +0,0 @@
[ req_distinguished_name ]
[ req ]
distinguished_name = req_distinguished_name
req_extensions = v3_req
[ v3_req ]
extendedKeyUsage=serverAuth,clientAuth
subjectAltName=DNS:clanxie.life,DNS:*.clanxie.life
@@ -1,5 +0,0 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJW3lmfzDnyLpgkX0Jlu0J3Bo1OyVa610GHAdvQmah8loAoGCCqGSM49
AwEHoUQDQgAEpwTEpH6dnbUembqupH9C4nfnJHCEG1OKb7lwKiUgMihyNqwyEHWG
jj0gg0Rr+GnkQepOwyyBKQYRnpG6evcPSg==
-----END EC PRIVATE KEY-----
@@ -1,49 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDkzCCAxmgAwIBAgISBQsXxrfK6EpotI2fXWteV3kMMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
NjAeFw0yNTA3MjgwNTM5MjNaFw0yNTEwMjYwNTM5MjJaMBcxFTATBgNVBAMTDGNs
YW54aWUubGlmZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKcExKR+nZ21Hpm6
rqR/QuJ35yRwhBtTim+5cColIDIocjasMhB1ho49IINEa/hp5EHqTsMsgSkGEZ6R
unr3D0qjggIoMIICJDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYBBQUH
AwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFKlsFBhG/CpyaZvz
3/gFZdP0vxsWMB8GA1UdIwQYMBaAFJMnRpgDqVFojpjWxEJI2yO/WJTSMDIGCCsG
AQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL2U2LmkubGVuY3Iub3JnLzAn
BgNVHREEIDAegg4qLmNsYW54aWUubGlmZYIMY2xhbnhpZS5saWZlMBMGA1UdIAQM
MAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQwIqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNy
Lm9yZy8xMy5jcmwwggECBgorBgEEAdZ5AgQCBIHzBIHwAO4AdQDtPEvW6AbCpKIA
V9vLJOI4Ad9RL+3EhsVwDyDdtz4/4AAAAZhPwIK1AAAEAwBGMEQCIGoi+WQh9Bhb
4f68AFmojUBHdj0cJhH8Wgv26QxqKUXZAiAyrxtJ9viGLjivjDUPm8NjmgquuLeH
UAMkhSdvZmKK1QB1AA3h8jAr0w3BQGISCepVLvxHdHyx1+kw7w5CHrR+Tqo0AAAB
mE/AmiYAAAQDAEYwRAIgbXMFtU65Mrr1ZaQSdX3Jc+5YO1Y/yApwx7vbGKOzriQC
IFgfpun9rGzPSXUBW+oEjkvUeN1Yf2Thu4YIvkUfuceiMAoGCCqGSM49BAMDA2gA
MGUCMCU9wGtvD/A/LqIdeNFtS8/Um7Sv0iyAn8JGcAu/GCI2oavEQk90mPvqMuqX
W2rUQgIxANHH0WTA3121/bK4Rhu5w8kXUI8AWnxizZ4hDjGCKi6WmJCGhLqtleYK
hhrMK8WjmQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G
h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV
6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj
v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc
MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL
pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp
eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH
pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7
s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu
h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv
YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8
ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0
LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+
EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY
Ig46v9mFmBvyH04=
-----END CERTIFICATE-----
+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())
}
}
-150
View File
@@ -1,150 +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 (
"fmt"
"strconv"
"github.com/kylelemons/go-gypsy/yaml"
"github.com/urfave/cli/v3"
)
type Config struct {
AddrDev string
AddrUser string
AddrHttpProxy string
HttpProxyRedirURL string
HttpProxyRedirDomain string
Token string
DevHookUrl string
UserHookUrl string
LocalAuth bool
Password string
AllowOrigins bool
PprofAddr string
SslCert string
SslKey string
WebrtcIP string
WebrtcPort string
WebrtcUsername string
WebrtcPassword string
}
// docker mode fixed path for reading certificate
const (
SslCert = "/home/certificate/glkvm_cer"
SslKey = "/home/certificate/glkvm_key"
)
func (cfg *Config) Parse(c *cli.Command) error {
conf := c.String("conf")
if conf != "" {
err := parseYamlCfg(cfg, conf)
if err != nil {
return err
}
}
getFlagOpt(c, "addr-dev", &cfg.AddrDev)
getFlagOpt(c, "addr-user", &cfg.AddrUser)
getFlagOpt(c, "addr-http-proxy", &cfg.AddrHttpProxy)
getFlagOpt(c, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getFlagOpt(c, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getFlagOpt(c, "dev-hook-url", &cfg.DevHookUrl)
getFlagOpt(c, "user-hook-url", &cfg.UserHookUrl)
getFlagOpt(c, "local-auth", &cfg.LocalAuth)
getFlagOpt(c, "token", &cfg.Token)
getFlagOpt(c, "password", &cfg.Password)
getFlagOpt(c, "allow-origins", &cfg.AllowOrigins)
getFlagOpt(c, "pprof", &cfg.PprofAddr)
cfg.SslCert = SslCert
cfg.SslKey = SslKey
getFlagOpt(c, "webrtc-ip", &cfg.WebrtcIP)
getFlagOpt(c, "webrtc-port", &cfg.WebrtcPort)
getFlagOpt(c, "webrtc-username", &cfg.WebrtcUsername)
getFlagOpt(c, "webrtc-password", &cfg.WebrtcPassword)
return nil
}
func getConfigOpt(yamlCfg *yaml.File, name string, opt any) {
val, err := yamlCfg.Get(name)
if err != nil {
return
}
switch opt := opt.(type) {
case *string:
*opt = val
case *int:
*opt, _ = strconv.Atoi(val)
case *bool:
*opt, _ = strconv.ParseBool(val)
}
}
func parseYamlCfg(cfg *Config, conf string) error {
yamlCfg, err := yaml.ReadFile(conf)
if err != nil {
return fmt.Errorf(`read config file: %s`, err.Error())
}
getConfigOpt(yamlCfg, "addr-dev", &cfg.AddrDev)
getConfigOpt(yamlCfg, "addr-user", &cfg.AddrUser)
getConfigOpt(yamlCfg, "addr-http-proxy", &cfg.AddrHttpProxy)
getConfigOpt(yamlCfg, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getConfigOpt(yamlCfg, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getConfigOpt(yamlCfg, "token", &cfg.Token)
getConfigOpt(yamlCfg, "dev-hook-url", &cfg.DevHookUrl)
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
getConfigOpt(yamlCfg, "password", &cfg.Password)
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
getConfigOpt(yamlCfg, "webrtc-ip", &cfg.WebrtcIP)
getConfigOpt(yamlCfg, "webrtc-port", &cfg.WebrtcPort)
getConfigOpt(yamlCfg, "webrtc-username", &cfg.WebrtcUsername)
getConfigOpt(yamlCfg, "webrtc-password", &cfg.WebrtcPassword)
return nil
}
func getFlagOpt(c *cli.Command, name string, opt any) {
if !c.IsSet(name) {
return
}
switch opt := opt.(type) {
case *string:
*opt = c.String(name)
case *int:
*opt = c.Int(name)
case *bool:
*opt = c.Bool(name)
}
}
-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
}
-29
View File
@@ -1,29 +0,0 @@
version: '3.8'
services:
rttys:
container_name: glkvm_cloud
image: glkvm:v1
ports:
- "443:443"
- "10443:10443"
- "5912:5912"
volumes:
- ./rttys.conf:/home/rttys.conf:ro
- ./certificate/glkvm.cer:/home/certificate/glkvm_cer:ro
- ./certificate/glkvm.key:/home/certificate/glkvm_key:ro
command: ["-c", "/home/rttys.conf"]
restart: always
coturn:
image: coturn/coturn:edge-alpine
container_name: glkvm_coturn
restart: always
ports:
- "3478:3478"
- "3478:3478/udp"
- "5349:5349"
- "5349:5349/udp"
volumes:
- ./turnserver.conf:/etc/turnserver.conf:ro
command: ["-c", "/etc/turnserver.conf"]
+128
View File
@@ -0,0 +1,128 @@
# 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
# =====================================================
# 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
# 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=
+127
View File
@@ -0,0 +1,127 @@
# Images
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
# =====================================================
# 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
# 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=
+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>
```
+147
View File
@@ -0,0 +1,147 @@
# Quick Start
This guide shows how to deploy **glkvm-cloud** using the provided Docker Compose environment template.
1. **Clone the repository and prepare the environment template**
```bash
git clone https://github.com/gl-inet/glkvm-cloud.git
cd glkvm-cloud/docker-compose/
```
* For **x86_64 (amd64)**:
```bash
cp .env.example .env
```
* For **arm64 (AArch64)**:
```bash
cp .env.arm64.example .env
```
2. **Configure environment variables**
Edit `.env` and update the required parameters:
- `RTTYS_TOKEN`: device connection token (leave empty to use the default)
- `RTTYS_PASS`: web management password (leave empty to use the default **StrongP@ssw0rd**)
- `TURN_USER` / `TURN_PASS`: coturn authentication credentials (leave empty to use the default)
- `GLKVM_ACCESS_IP`: glkvm cloud access address (leave empty to auto-detect at startup)
**LDAP Authentication (Optional):**
- `LDAP_ENABLED`: set to `true` to enable LDAP authentication (default: `false`)
- `LDAP_SERVER`: LDAP server hostname or IP address
- `LDAP_PORT`: LDAP server port (default: `389`, for TLS use `636`)
- `LDAP_USE_TLS`: set to `true` to enable TLS encryption (default: `false`)
- `LDAP_BIND_DN`: service account distinguished name
- `LDAP_BIND_PASSWORD`: service account password
- `LDAP_BASE_DN`: search base for user queries
- `LDAP_USER_FILTER`: LDAP query filter (default: `(uid=%s)`)
- `LDAP_ALLOWED_GROUPS`: comma-separated list of authorized groups (optional)
- `LDAP_ALLOWED_USERS`: comma-separated list of authorized users (optional)
⚠️ **Note:** All configuration should be done in the `.env` file.
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
**OIDC Authentication (Optional):**
- `OIDC_ENABLED`: set to `true` to enable OIDC authentication (default: `false`)
- `OIDC_ISSUER`: OIDC issuer URL provided by your identity provider
(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
- `OIDC_AUTH_URL`: authorization endpoint URL
- `OIDC_TOKEN_URL`: token endpoint URL
- `OIDC_REDIRECT_URL`: redirect (callback) URL registered in your OIDC provider
Domain is user-defined, but the path must be fixed: `/auth/oidc/callback`
Example: `https://your-domain.example.com/auth/oidc/callback`
- `OIDC_SCOPES`: space-separated list of requested scopes (default: `"openid profile email"`)
- `OIDC_ALLOWED_USERS`: comma-separated list of allowed emails or domains (optional)
Example: `user@example.com,@example.com`
- `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)
#### 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**
```bash
docker-compose up -d
```
If you modify `.env` or template files, make sure to apply the updates:
```bash
docker-compose down && docker-compose up -d
```
4. **Platform Access**
Once the installation is complete, access the platform via:
```bash
https://<your_server_public_ip>
```
+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;
+91
View File
@@ -0,0 +1,91 @@
version: "2.0"
services:
rttys:
image: ${GLKVM_IMAGE:-glzhitong/glkvm-cloud:latest}
container_name: glkvm_cloud
restart: always
environment:
# Preferred: set GLKVM_ACCESS_IP explicitly; if empty, entrypoint will auto-detect once.
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
# ---- 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
RTTYS_WEBUI_PORT: ${RTTYS_WEBUI_PORT:-443} # addr-user
RTTYS_HTTP_PROXY_PORT: ${RTTYS_HTTP_PROXY_PORT:-10443} # addr-http-proxy
# WebRTC / TURN reference (used by rttys template)
TURN_PORT: ${TURN_PORT:-3478}
TURN_USER: ${TURN_USER:-glkvmcloudwebrtcuser}
TURN_PASS: ${TURN_PASS:-AnotherS3cret}
# ---- LDAP Configuration ----
LDAP_ENABLED: ${LDAP_ENABLED:-false}
LDAP_SERVER: ${LDAP_SERVER:-}
LDAP_PORT: ${LDAP_PORT:-389}
LDAP_USE_TLS: ${LDAP_USE_TLS:-false}
LDAP_BIND_DN: ${LDAP_BIND_DN:-}
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
LDAP_BASE_DN: ${LDAP_BASE_DN:-}
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
# ---- OIDC Authentication ----
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_AUTH_URL: ${OIDC_AUTH_URL:-}
OIDC_TOKEN_URL: ${OIDC_TOKEN_URL:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
# Whitelists (optional)
OIDC_ALLOWED_USERS: ${OIDC_ALLOWED_USERS:-}
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
# ---- 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:
- "${RTTYS_WEBUI_PORT:-443}:${RTTYS_WEBUI_PORT:-443}"
- "${RTTYS_HTTP_PROXY_PORT:-10443}:${RTTYS_HTTP_PROXY_PORT:-10443}"
- "${RTTYS_DEVICE_PORT:-5912}:${RTTYS_DEVICE_PORT:-5912}"
coturn:
image: ${COTURN_IMAGE:-coturn/coturn:edge-alpine}
container_name: glkvm_coturn
restart: always
environment:
# Same semantics as above: prefer explicit value, else auto-detect
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
TURN_PORT: ${TURN_PORT:-3478}
TURN_USER: ${TURN_USER:-glkvmcloudwebrtcuser}
TURN_PASS: ${TURN_PASS:-AnotherS3cret}
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
command: ["coturn"]
volumes:
- ./templates/turnserver.conf.template:/tpl/turnserver.conf.tmpl:ro
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
ports:
- "${TURN_PORT:-3478}:3478/tcp"
- "${TURN_PORT:-3478}:3478/udp"
+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;
}
}
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
set -e
# -------- GLKVM access IP resolver (IPv4) --------
resolve_glkvm_access_ip() {
# 1) Prefer user-provided env
if [ -n "${GLKVM_ACCESS_IP:-}" ]; then
echo "${GLKVM_ACCESS_IP}"
return 0
fi
is_ipv4() { echo "$1" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; }
try_cmd() {
val="$("$@" 2>/dev/null | tr -d '\r\n')"
if is_ipv4 "$val"; then
echo "$val"; return 0
fi
return 1
}
# 2) Best-effort auto-detection from multiple sources
if command -v wget >/dev/null 2>&1; then
try_cmd wget -qO- --timeout=3 https://api.ipify.org && return 0
try_cmd wget -qO- --timeout=3 https://ifconfig.me && return 0
fi
if command -v curl >/dev/null 2>&1; then
try_cmd curl -fsS --max-time 3 https://api.ipify.org && return 0
try_cmd curl -fsS --max-time 3 https://ifconfig.me && return 0
fi
if command -v dig >/dev/null 2>&1; then
try_cmd sh -c "dig +short -4 myip.opendns.com @resolver1.opendns.com" && return 0
fi
# 3) Fallback
echo "127.0.0.1"
}
# -------- Minimal template renderer ({{KEY}}) --------
render() {
in="$1"; out="$2"; shift 2
cp "$in" "$out"
for key in "$@"; do
val="$(printenv "$key" || true)"
# Special-case: GLKVM_ACCESS_IP prefers env, else auto-detect once
if [ "$key" = "GLKVM_ACCESS_IP" ] && [ -z "$val" ]; then
val="$(resolve_glkvm_access_ip)"
fi
# Escape '/' and '&' for sed
esc=$(printf '%s' "$val" | sed -e 's/[\/&]/\\&/g')
sed -i "s/{{${key}}}/${esc}/g" "$out"
done
}
# -------- Dispatch by first arg --------
case "$1" in
rttys)
: "${RTTYS_DEVICE_PORT:=5912}"
: "${RTTYS_WEBUI_PORT:=443}"
: "${RTTYS_HTTP_PROXY_PORT:=10443}"
: "${TURN_PORT:=3478}"
render /tpl/rttys.conf.tmpl /home/rttys.conf \
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 \
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
exec rttys -c /home/rttys.conf
;;
coturn)
: "${TURN_PORT:=3478}"
mkdir -p /tmp
render /tpl/turnserver.conf.tmpl /tmp/turnserver.conf \
GLKVM_ACCESS_IP TURN_PORT TURN_USER TURN_PASS
exec turnserver -c /tmp/turnserver.conf
;;
*)
exec "$@"
;;
esac
+52
View File
@@ -0,0 +1,52 @@
# Authentication token for device connections
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}}
webrtc-username: {{TURN_USER}}
webrtc-password: {{TURN_PASS}}
# Listen addresses
addr-dev: :{{RTTYS_DEVICE_PORT}}
addr-user: :{{RTTYS_WEBUI_PORT}}
addr-http-proxy: :{{RTTYS_HTTP_PROXY_PORT}}
# LDAP Authentication
ldap-enabled: {{LDAP_ENABLED}}
ldap-server: {{LDAP_SERVER}}
ldap-port: {{LDAP_PORT}}
ldap-use-tls: {{LDAP_USE_TLS}}
ldap-bind-dn: {{LDAP_BIND_DN}}
# Note: ldap-bind-password is read directly from LDAP_BIND_PASSWORD environment variable
ldap-base-dn: {{LDAP_BASE_DN}}
ldap-user-filter: {{LDAP_USER_FILTER}}
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
# OIDC Authentication (generic OIDC provider)
oidc-enabled: {{OIDC_ENABLED}}
oidc-generic-issuer: {{OIDC_ISSUER}}
oidc-generic-client-id: {{OIDC_CLIENT_ID}}
# Note: oidc-generic-client-secret is read directly from the OIDC_CLIENT_SECRET
# environment variable and is intentionally not parsed from YAML to avoid
# leaking secrets in config files.
oidc-generic-auth-url: {{OIDC_AUTH_URL}}
oidc-generic-token-url: {{OIDC_TOKEN_URL}}
oidc-generic-redirect-url: {{OIDC_REDIRECT_URL}}
oidc-generic-scopes: {{OIDC_SCOPES}}
# Whitelists (all are optional)
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}}
+7
View File
@@ -0,0 +1,7 @@
listening-port={{TURN_PORT}}
lt-cred-mech
user={{TURN_USER}}:{{TURN_PASS}}
realm=glkvm
no-multicast-peers
allowed-peer-ip=0.0.0.0/0
external-ip={{GLKVM_ACCESS_IP}}
+19
View File
@@ -3,11 +3,15 @@ module rttys
go 1.24.4
require (
github.com/coreos/go-oidc/v3 v3.16.0
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76
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
github.com/gorilla/websocket v1.5.3
github.com/json-iterator/go v1.1.12
github.com/kylelemons/go-gypsy v1.0.0
@@ -16,18 +20,27 @@ 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/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
github.com/go-playground/universal-translator v0.18.1 // indirect
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
@@ -35,13 +48,19 @@ 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/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
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sys v0.34.0 // indirect
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
)
+106 -10
View File
@@ -1,3 +1,7 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
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/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=
@@ -6,11 +10,15 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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=
@@ -23,6 +31,16 @@ 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=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -37,10 +55,35 @@ 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=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
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=
@@ -72,6 +115,9 @@ 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/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=
@@ -81,6 +127,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -95,37 +142,86 @@ github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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=
-626
View File
@@ -1,626 +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"
"sync"
"sync/atomic"
"time"
"rttys/utils"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"github.com/valyala/bytebufferpool"
"golang.org/x/net/publicsuffix"
)
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 {
// 域名访问,拼接 devid 子域名
eTLDPlusOne, err := publicsuffix.EffectiveTLDPlusOne(hostname)
if err != nil {
log.Info().Msgf("Error parsing domain: %v", err)
eTLDPlusOne = hostname // fallback
}
location = fmt.Sprintf("https://%s.%s%s?sid=%s", devid, eTLDPlusOne, 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))
}
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)
}
+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
}
}
+33
View File
@@ -0,0 +1,33 @@
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"
)
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)
}
+23
View File
@@ -0,0 +1,23 @@
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
}
+14
View File
@@ -0,0 +1,14 @@
package user
import "context"
type Repository interface {
FindByID(ctx context.Context, id int64) (*User, error)
FindByUsername(ctx context.Context, username 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)
}
+110
View File
@@ -0,0 +1,110 @@
package user
import (
"context"
"errors"
"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)
}
+13
View File
@@ -0,0 +1,13 @@
package dto
type LoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod,omitempty"`
}
type LoginResp struct {
Token string `json:"token"`
}
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"`
}
+13
View File
@@ -0,0 +1,13 @@
package dto
type MeUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
}
type MeResp struct {
User MeUser `json:"user"`
Permissions []string `json:"permissions"`
}
+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"`
}
+41
View File
@@ -0,0 +1,41 @@
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"`
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{}
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"rttys/internal/pkg/ldap"
"rttys/xconfig"
"strings"
"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"
)
type AuthHandler struct {
userSvc *user.Service
sessionStore *memory.SessionStore
}
func NewAuthHandler(userSvc *user.Service, sessionStore *memory.SessionStore) *AuthHandler {
return &AuthHandler{userSvc: userSvc, sessionStore: sessionStore}
}
// 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 := 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
}
sysAdmin, err := h.userSvc.GetSystemAdmin(c.Request.Context())
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "System admin not found", nil))
return
}
userID = sysAdmin.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
}
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)
dto.Write(c, dto.Ok(traceID, dto.LoginResp{
Token: sid,
}))
}
// 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{}))
}
+335
View File
@@ -0,0 +1,335 @@
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)
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{}{}))
}
+28
View File
@@ -0,0 +1,28 @@
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),
},
Permissions: p.PermissionKeys,
}))
}
+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}))
}
+235
View File
@@ -0,0 +1,235 @@
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,
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
}
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
}
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}
}
+130
View File
@@ -0,0 +1,130 @@
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"`
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
}
c.Set(PrincipalKey, Principal{
UserID: u.ID,
Username: u.Username,
DisplayName: displayName,
Role: u.Role,
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 ""
}
+165
View File
@@ -0,0 +1,165 @@
package http
import (
"net"
"rttys/internal/domain/device"
"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
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)
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)
// 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
}
}
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,
}
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)
// 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)
// 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"`
}
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)
}
+376
View File
@@ -0,0 +1,376 @@
/*
* @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)
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, error) {
if !l.config.LdapEnabled {
return false, fmt.Errorf("LDAP authentication is disabled")
}
if username == "" || password == "" {
return false, fmt.Errorf("username and password are required")
}
// 连接到LDAP服务器 (Connect to LDAP server)
conn, err := l.connect()
if err != nil {
return 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, 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, 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, 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, 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, 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, fmt.Errorf("authorization check failed: %v", err)
}
if !authorized {
return false, fmt.Errorf("user not authorized")
}
return true, 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
}
// 执行用户认证,支持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
}
// 执行用户认证并返回错误类型,支持LDAP和传统密码认证 (Perform user authentication with error type, supporting LDAP and legacy password authentication)
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string) {
// 处理LDAP认证 (Handle LDAP authentication)
if cfg.LdapEnabled && authMethod == "ldap" && username != "" {
ldapAuth := NewLDAPAuthenticator(cfg)
success, 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"
}
return false, "authentication"
}
return success, ""
}
if authMethod == "legacy" || authMethod == "" {
if cfg.Password == password {
return true, ""
}
return false, "authentication"
}
return false, "authentication"
}
+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
}
+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
}
+407
View File
@@ -0,0 +1,407 @@
/*
* 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/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
}
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())
userSvc := user.NewService(userRepo)
devSvc := device.NewService(deviceRepo, groupRepo)
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,
Cfg: cfg,
CloudVersion: KVMCloudVersion,
})
c := &AppContainer{
DB: appDB,
DeviceMetaRepo: deviceMetaRepo,
UserSvc: userSvc,
}
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()),
})
// ===== 添加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
}
+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)
}
+706
View File
@@ -0,0 +1,706 @@
/*
* 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"
"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)
}
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 (dev *Device) setClientInfo(data []byte) {
dev.clientInfoMu.Lock()
dev.clientInfo = append(dev.clientInfo[:0], data...)
dev.clientInfoMu.Unlock()
}
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
}
+735
View File
@@ -0,0 +1,735 @@
/*
* 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/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()
// 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)
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)
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 {
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)
}
+485
View File
@@ -0,0 +1,485 @@
package server
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
oidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/rs/zerolog/log"
"io"
"math/rand"
"net/http"
"net/url"
"rttys/internal/domain/user"
"rttys/internal/pkg/randtoken"
"rttys/xconfig"
"strings"
"time"
)
var (
// Session store for OAuth flow
oauthStore *sessions.CookieStore
// Global OIDC verifier
oidcVerifier *oidc.IDTokenVerifier
)
// Register OIDC routes
func RegisterOIDCRoutes(r *gin.Engine, cfg *xconfig.Config, userSvc *user.Service) {
if !cfg.OIDCEnabled {
return
}
// ===== Initialize OIDC provider & ID token verifier =====
issuer := strings.TrimSpace(cfg.OIDCGenericIssuer)
if issuer == "" {
log.Error().Msg("OIDC is enabled but issuer (OIDCGenericIssuer) is empty")
return
}
ctx := context.Background()
//// Normalize issuer to always end with a single '/'
//issuer = strings.TrimRight(issuer, "/") + "/"
cfg.OIDCGenericIssuer = issuer
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
log.Error().Err(err).Msg("Failed to initialize OIDC provider")
return
}
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: cfg.OIDCGenericClientID,
// You can set SkipIssuerCheck, SkipClientIDCheck here if needed, but not recommended.
})
// Initialize session store
sessionSecret := generateRandomString(32)
oauthStore = sessions.NewCookieStore([]byte(sessionSecret))
// Configure session options
oauthStore.Options = &sessions.Options{
Path: "/",
MaxAge: 300, // 5 minutes, enough to complete the OAuth flow
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // Important: allow cookies on cross-site navigation (OIDC redirect)
Domain: "", // Empty means current host/domain will be used
}
// OIDC auth routes (public, no existing auth required)
r.GET("/auth/oidc/login", oidcLoginHandler(cfg))
r.GET("/auth/oidc/callback", oidcCallbackHandler(cfg, userSvc))
}
// Start OIDC login
func oidcLoginHandler(cfg *xconfig.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Generate state and nonce
nonce := generateRandomString(32)
state := generateRandomString(32)
log.Info().Msgf("OIDC login initiated: nonce=%s, state=%s...", nonce[:10], state[:10])
// Save to session
session, _ := oauthStore.Get(c.Request, "oidc-session")
session.Values["state"] = state
session.Values["nonce"] = nonce
if err := session.Save(c.Request, c.Writer); err != nil {
log.Error().Err(err).Msg("Failed to save OIDC session")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"})
return
}
// Build authorization URL
params := url.Values{}
params.Add("client_id", cfg.OIDCGenericClientID)
params.Add("redirect_uri", cfg.OIDCGenericRedirectURL)
params.Add("response_type", "code")
params.Add("scope", strings.Join(cfg.OIDCGenericScopes, " "))
params.Add("state", state)
params.Add("nonce", nonce)
authURL := cfg.OIDCGenericAuthURL + "?" + params.Encode()
log.Info().Msgf("OIDC login redirect=%s", authURL[:100]+"...")
c.Redirect(http.StatusFound, authURL)
}
}
// Handle OIDC callback
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")
if err != nil {
log.Error().Err(err).Msg("Failed to get OIDC session")
c.Redirect(http.StatusFound, "/?error=session_error")
return
}
// Validate state
state := c.Query("state")
savedState, ok := session.Values["state"].(string)
if !ok || state != savedState {
log.Warn().Msg("OIDC state mismatch")
c.Redirect(http.StatusFound, "/?error=invalid_state")
return
}
// Check OAuth error
if errorMsg := c.Query("error"); errorMsg != "" {
errorDesc := c.Query("error_description")
log.Warn().Msgf("OIDC error: %s - %s", errorMsg, errorDesc)
c.Redirect(http.StatusFound, "/?error="+errorMsg)
return
}
// Read authorization code
code := c.Query("code")
if code == "" {
c.Redirect(http.StatusFound, "/?error=no_code")
return
}
// Exchange authorization code for tokens
tokens, err := exchangeCodeForTokens(cfg, code)
if err != nil {
log.Error().Err(err).Msg("Failed to exchange code for tokens")
c.Redirect(http.StatusFound, "/?error=token_exchange_failed")
return
}
// Extract user info
var userEmail string
var userName string
// Standard OIDC verify and parse ID token
rawIDToken, ok := tokens["id_token"].(string)
if !ok {
log.Error().Msg("No ID token in response")
c.Redirect(http.StatusFound, "/?error=no_id_token")
return
}
if oidcVerifier == nil {
log.Error().Msg("OIDC verifier is not initialized")
c.Redirect(http.StatusFound, "/?error=server_config")
return
}
// ==== Signature + standard claims verification ====
idToken, err := oidcVerifier.Verify(c.Request.Context(), rawIDToken)
if err != nil {
log.Error().Err(err).Msg("Failed to verify ID token signature/claims")
c.Redirect(http.StatusFound, "/?error=invalid_token")
return
}
// Decode claims into a map
claims := map[string]interface{}{}
if err := idToken.Claims(&claims); err != nil {
log.Error().Err(err).Msg("Failed to parse ID token claims")
c.Redirect(http.StatusFound, "/?error=invalid_token")
return
}
// Pretty-print all claims as JSON for debugging
if claimsJSON, err := json.MarshalIndent(claims, "", " "); err == nil {
log.Info().Msg("========== OIDC ID Token Claims ==========")
log.Info().Msg(string(claimsJSON))
log.Info().Msg("==========================================")
} else {
log.Warn().Err(err).Msg("Failed to marshal OIDC claims to JSON")
}
// Validate nonce
if savedNonce, ok := session.Values["nonce"].(string); ok {
if claims["nonce"] != savedNonce {
log.Warn().Msg("OIDC nonce mismatch")
c.Redirect(http.StatusFound, "/?error=invalid_nonce")
return
}
}
sub, _ := claims["sub"].(string)
userEmail, _ = claims["email"].(string)
userName, _ = claims["name"].(string)
if userName == "" {
// Fallback: use email or sub as display name
if userEmail != "" {
userName = userEmail
} else {
userName = sub
}
}
// sub is required by OIDC spec and should never be empty
if sub == "" {
log.Error().Msg("OIDC token is missing 'sub' claim")
c.Redirect(http.StatusFound, "/?error=user_info_failed")
return
}
log.Info().Msgf("OIDC login successful: email=%s", userEmail)
// ====== OIDC whitelist enforcement ======
if !isOIDCUserAllowed(cfg, claims) {
log.Warn().Msgf("OIDC user not allowed by whitelist rules, sub=%v, email=%v", claims["sub"], claims["email"])
c.Redirect(http.StatusFound, "/?error=authorization")
return
}
// ==== Create application session (new session_store, same as LDAP) ====
sid, err := randtoken.New() // randtoken.New()
if err != nil {
log.Error().Err(err).Msg("Failed to create session token")
c.Redirect(http.StatusFound, "/?error=internal_error")
return
}
sysAdmin, err := userSvc.GetSystemAdmin(c.Request.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to find system admin user")
c.Redirect(http.StatusFound, "/?error=internal_error")
return
}
sessionStore.Create(sid, sysAdmin.ID)
c.SetCookie("sid", sid, 0, "/", "", cfg.SslCert != "", false)
// Clean up OAuth session
session.Options.MaxAge = -1
session.Save(c.Request, c.Writer)
// Redirect to home page
c.Redirect(http.StatusFound, "/")
}
}
// Exchange authorization code for tokens
func exchangeCodeForTokens(cfg *xconfig.Config, code string) (map[string]interface{}, error) {
data := url.Values{}
data.Set("code", code)
data.Set("client_id", cfg.OIDCGenericClientID)
data.Set("client_secret", cfg.OIDCGenericClientSecret)
data.Set("redirect_uri", cfg.OIDCGenericRedirectURL)
data.Set("grant_type", "authorization_code")
req, err := http.NewRequest("POST", cfg.OIDCGenericTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token request failed: %s", string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
// Generate a random string with given length
func generateRandomString(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)[:length]
}
func isOIDCUserAllowed(cfg *xconfig.Config, claims map[string]interface{}) bool {
email, _ := claims["email"].(string)
sub, _ := claims["sub"].(string)
preferredUsername, _ := claims["preferred_username"].(string)
name, _ := claims["name"].(string)
// 1) Email whitelist
if len(cfg.OIDCGenericAllowedUsers) > 0 {
if !isEmailAllowed(cfg.OIDCGenericAllowedUsers, email) {
return false
}
}
// 2) Sub (subject) whitelist
if len(cfg.OIDCGenericAllowedSubs) > 0 {
if !contains(cfg.OIDCGenericAllowedSubs, sub) {
return false
}
}
// 3) Username whitelist (preferred_username > name)
if len(cfg.OIDCGenericAllowedUsernames) > 0 {
u := preferredUsername
if u == "" {
u = name
}
if !contains(cfg.OIDCGenericAllowedUsernames, u) {
return false
}
}
// 4) Groups whitelist
if len(cfg.OIDCGenericAllowedGroups) > 0 {
groups := extractStringSlice(claims["groups"])
if !intersects(groups, cfg.OIDCGenericAllowedGroups) {
return false
}
}
return true
}
// Email whitelist check rules:
// - Exact match (case-insensitive)
// - Simple domain match: entries starting with "@example.com" or "*@example.com"
// mean "allow all users under this domain"
func isEmailAllowed(allowed []string, email string) bool {
if len(allowed) == 0 {
return true
}
e := strings.ToLower(strings.TrimSpace(email))
for _, raw := range allowed {
a := strings.ToLower(strings.TrimSpace(raw))
if a == "" {
continue
}
if strings.HasPrefix(a, "*@") || strings.HasPrefix(a, "@") {
// Domain match
dom := strings.TrimPrefix(a, "*@")
dom = strings.TrimPrefix(dom, "@")
if strings.HasSuffix(e, "@"+dom) {
return true
}
continue
}
// Exact match
if e == a {
return true
}
}
return false
}
// contains reports whether slice contains the given value v.
// Comparison is done after trimming spaces on both sides.
func contains(slice []string, v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, s := range slice {
if strings.TrimSpace(s) == v {
return true
}
}
return false
}
// extractStringSlice tries to normalize a generic claim value into a []string.
//
// It supports:
// - []string
// - []interface{} (only string elements are kept)
// - string (split by comma and/or whitespace)
// Any other type will result in an empty slice.
func extractStringSlice(v interface{}) []string {
if v == nil {
return nil
}
switch vv := v.(type) {
case []string:
// Return a shallow copy to avoid accidental modification.
out := make([]string, 0, len(vv))
for _, s := range vv {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
case []interface{}:
out := make([]string, 0, len(vv))
for _, item := range vv {
s, ok := item.(string)
if !ok {
continue
}
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
case string:
// Allow comma- or whitespace-separated group lists.
s := strings.TrimSpace(vv)
if s == "" {
return nil
}
// Replace commas with spaces, then split on whitespace.
s = strings.ReplaceAll(s, ",", " ")
parts := strings.Fields(s)
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
default:
return nil
}
}
// intersects reports whether slice a and b share at least one common element.
// Matching is done after trimming spaces on both sides.
func intersects(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
m := make(map[string]struct{}, len(a))
for _, s := range a {
s = strings.TrimSpace(s)
if s == "" {
continue
}
m[s] = struct{}{}
}
for _, s := range b {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := m[s]; ok {
return true
}
}
return false
}
+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()
}
@@ -1,30 +1,30 @@
//go:build !windows
// +build !windows
package main
import (
"os"
"os/signal"
"syscall"
xlog "rttys/log"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func signalHandle() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGUSR1)
for s := range c {
switch s {
case syscall.SIGUSR1:
xlog.Verbose()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
log.Debug().Msg("Debug mode enabled")
}
}
}
//go:build !windows
// +build !windows
package server
import (
"os"
"os/signal"
"syscall"
xlog "rttys/log"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func signalHandle() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGUSR1)
for s := range c {
switch s {
case syscall.SIGUSR1:
xlog.Verbose()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
log.Debug().Msg("Debug mode enabled")
}
}
}
@@ -1,12 +1,12 @@
//go:build windows
// +build windows
package main
import (
"github.com/rs/zerolog/log"
)
func signalHandle() {
log.Debug().Msg("Signal handling not supported on Windows")
}
//go:build windows
// +build windows
package server
import (
"github.com/rs/zerolog/log"
)
func signalHandle() {
log.Debug().Msg("Signal handling not supported on Windows")
}
+238
View File
@@ -0,0 +1,238 @@
/*
* 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/binary"
"net/http"
"sync"
"sync/atomic"
"time"
"rttys/utils"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
)
type User struct {
conn *websocket.Conn
sid string
dev *Device
pending chan bool
close sync.Once
closed atomic.Bool
}
type UserMsg struct {
Type string `json:"type"`
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
Ack uint16 `json:"ack"`
Size uint32 `json:"size"`
Name string `json:"name"`
}
const (
LoginErrorOffline = 4000
LoginErrorBusy = 4001
LoginErrorTimeout = 4002
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func handleUserConnection(srv *RttyServer, c *gin.Context) {
defer LogPanic()
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Error().Err(err).Msg("upgrade to websocket failed")
return
}
devid := c.Param("devid")
if devid == "" {
log.Error().Msg("device ID is required")
conn.Close()
return
}
user := &User{conn: conn}
dev := srv.GetDevice(c.Query("group"), devid)
if dev == nil {
user.SendCloseMsg(LoginErrorOffline, "device not found")
conn.Close()
return
}
sid := utils.GenUniqueID()
user.sid = sid
user.dev = dev
user.pending = make(chan bool, 1)
dev.pending.Store(sid, user)
defer user.Close()
if err := dev.WriteMsg(msgTypeLogin, sid, nil); err != nil {
log.Error().Msgf("send login msg to device %s fail: %v", dev.id, err)
return
}
ctx, cancel := context.WithCancel(dev.ctx)
go func() {
<-ctx.Done()
user.Close()
}()
defer cancel()
if !waitForLogin(user, dev, ctx, sid) {
return
}
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
if !user.closed.Load() {
closeError, ok := err.(*websocket.CloseError)
if !ok || (closeError.Code != websocket.CloseGoingAway &&
closeError.Code != websocket.CloseAbnormalClosure &&
closeError.Code != websocket.CloseNormalClosure) {
log.Error().Msgf("user read fail: %v", err)
}
}
return
}
if msgType == websocket.BinaryMessage {
if len(data) < 1 {
log.Error().Msgf("invalid msg from user")
return
}
typ := msgTypeTermData
if data[0] == 1 {
typ = msgTypeFile
}
err = dev.WriteMsg(typ, sid, data[1:])
} else {
msg := &UserMsg{}
err = jsoniter.Unmarshal(data, msg)
if err != nil {
log.Error().Msgf("invalid msg from user")
return
}
switch msg.Type {
case "winsize":
b := make([]byte, 4)
binary.BigEndian.PutUint16(b, msg.Cols)
binary.BigEndian.PutUint16(b[2:], msg.Rows)
err = dev.WriteMsg(msgTypeWinsize, sid, b)
case "ack":
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, msg.Ack)
err = dev.WriteMsg(msgTypeAck, sid, b)
case "fileInfo":
b := make([]byte, 4+len(msg.Name))
binary.BigEndian.PutUint32(b, msg.Size)
copy(b[4:], []byte(msg.Name))
err = dev.WriteFileMsg(msgTypeFile, sid, msgTypeFileInfo, b)
case "fileCanceled":
err = dev.WriteFileMsg(msgTypeFile, sid, msgTypeFileAbort, nil)
case "fileAck":
err = dev.WriteFileMsg(msgTypeFile, sid, msgTypeFileAck, nil)
}
}
if err != nil {
log.Error().Msgf("write msg to device '%s' fail: %v", dev.id, err)
return
}
}
}
func (user *User) SendCloseMsg(code int, text string) {
user.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, text), time.Now().Add(time.Second))
}
func (user *User) Close() {
user.close.Do(func() {
dev := user.dev
sid := user.sid
user.closed.Store(true)
if _, loaded := dev.users.LoadAndDelete(sid); loaded {
dev.WriteMsg(msgTypeLogout, sid, nil)
}
dev.pending.Delete(sid)
user.conn.Close()
log.Debug().Msgf("user with session '%s' closed", sid)
})
}
func (user *User) WriteMsg(typ int, data []byte) error {
return user.conn.WriteMessage(typ, data)
}
func waitForLogin(user *User, dev *Device, ctx context.Context, sid string) bool {
for {
select {
case <-ctx.Done():
return false
case ok := <-user.pending:
return ok
case <-time.After(TermLoginTimeout):
if _, loaded := dev.pending.LoadAndDelete(sid); loaded {
log.Error().Msgf("login timeout for session %s of device %s", sid, dev.id)
user.SendCloseMsg(LoginErrorTimeout, "login timeout")
return false
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
package server
const RttysVersion = "5.2.0"
const KVMCloudVersion = "v2.3.0"
var (
GitCommit = ""
BuildTime = ""
)
+42
View File
@@ -0,0 +1,42 @@
package memory
import (
"context"
"rttys/internal/domain/identity"
"rttys/internal/domain/permission"
)
type PermissionRepo struct {
roleToKeys map[identity.Role][]permission.Key
}
func NewPermissionRepo() *PermissionRepo {
// Role defaults are aligned with the design docs:
// - admin: all enabled permissions
// - user : read (and basic auth/me)
return &PermissionRepo{
roleToKeys: map[identity.Role][]permission.Key{
identity.RoleAdmin: {
permission.MeRead, permission.AuthWrite,
permission.DeviceRead, permission.DeviceWrite,
permission.DeviceGroupRead, permission.DeviceGroupWrite,
permission.UserGroupRead, permission.UserGroupWrite,
permission.UserRead, permission.UserWrite,
permission.RelationWrite,
},
identity.RoleUser: {
permission.MeRead, permission.AuthWrite,
permission.DeviceRead,
permission.DeviceGroupRead, permission.UserGroupRead,
},
},
}
}
func (r *PermissionRepo) ListKeysByRole(ctx context.Context, role identity.Role) ([]permission.Key, error) {
keys := r.roleToKeys[role]
out := make([]permission.Key, 0, len(keys))
out = append(out, keys...)
return out, nil
}
+80
View File
@@ -0,0 +1,80 @@
package memory
import (
"sync"
"time"
)
type Session struct {
Token string
UserID int64
ExpiresAt time.Time
}
type SessionStore struct {
mu sync.RWMutex
ttl time.Duration
data map[string]Session
}
func NewSessionStore(ttl time.Duration) *SessionStore {
s := &SessionStore{
ttl: ttl,
data: map[string]Session{},
}
go s.gcLoop()
return s
}
func (s *SessionStore) Create(token string, userID int64) Session {
sess := Session{Token: token, UserID: userID, ExpiresAt: time.Now().Add(s.ttl)}
s.mu.Lock()
s.data[token] = sess
s.mu.Unlock()
return sess
}
func (s *SessionStore) Get(token string) (Session, bool) {
s.mu.RLock()
sess, ok := s.data[token]
s.mu.RUnlock()
if !ok {
return Session{}, false
}
if time.Now().After(sess.ExpiresAt) {
s.Delete(token)
return Session{}, false
}
return sess, true
}
func (s *SessionStore) Delete(token string) {
s.mu.Lock()
delete(s.data, token)
s.mu.Unlock()
}
func (s *SessionStore) DeleteByUserID(userID int64) {
s.mu.Lock()
for k, v := range s.data {
if v.UserID == userID {
delete(s.data, k)
}
}
s.mu.Unlock()
}
func (s *SessionStore) gcLoop() {
t := time.NewTicker(2 * time.Minute)
defer t.Stop()
for range t.C {
now := time.Now()
s.mu.Lock()
for k, v := range s.data {
if now.After(v.ExpiresAt) {
delete(s.data, k)
}
}
s.mu.Unlock()
}
}

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