127 Commits

Author SHA1 Message Date
e75926bcc6 docs: show CI/CD status badge and mascot in README
SonarQube Scan / SonarQube Trigger (push) Successful in 3m27s
SonarQube Scan / SonarQube Trigger (pull_request) Successful in 3m59s
2026-01-27 16:16:32 +07:00
b9ec59efb6 test(interaction): add unit tests for interaction behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 3m53s
2026-01-27 15:39:45 +07:00
02d869fa11 refactor(dockerfile): split long ldflags line
SonarQube Scan / SonarQube Trigger (push) Successful in 3m33s
2026-01-27 14:22:17 +07:00
d167508a55 refactor(interaction): reduce cognitive complexity and centralize color constants
SonarQube Scan / SonarQube Trigger (push) Successful in 3m49s
2026-01-27 13:43:31 +07:00
7f4f448b47 fix: ensure proper buffer reuse with pointer handling in sync.Pool
SonarQube Scan / SonarQube Trigger (push) Successful in 2m26s
2026-01-26 19:50:34 +07:00
a9d98213ce test: check and handle error for testing
SonarQube Scan / SonarQube Trigger (push) Successful in 3m35s
2026-01-26 19:45:27 +07:00
48b680110a ci: add project source and test path for sonarqube
SonarQube Scan / SonarQube Trigger (push) Successful in 3m42s
2026-01-26 18:13:03 +07:00
bb32f25954 test(forwarder): add unit tests for forwarder behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 2m20s
2026-01-26 11:58:56 +07:00
4410c9b993 chore(tests): migrate to Testify for mocking and assertions
SonarQube Scan / SonarQube Trigger (push) Successful in 2m36s
2026-01-26 11:56:58 +07:00
f1d20905d0 refactor(forwarder): remove CreateForwardedTCPIPPayload method
SonarQube Scan / SonarQube Trigger (push) Successful in 2m32s
- OpenForwardedChannel now privately calls CreateForwardedTCPIPPayload
- Removed an unused function
2026-01-25 20:49:12 +07:00
21b551a66d feat(http): add http header size limit for initial request
SonarQube Scan / SonarQube Trigger (push) Successful in 2m18s
2026-01-25 18:47:54 +07:00
05bf9d6045 refactor(header): NewRequest to accept only []byte
SonarQube Scan / SonarQube Trigger (push) Failing after 54s
2026-01-25 18:45:08 +07:00
5cd3c201f2 test(config): add test for keyloc and header size
SonarQube Scan / SonarQube Trigger (push) Failing after 56s
2026-01-25 18:17:37 +07:00
9cdce24030 test(stream): migrate mocking to testify
SonarQube Scan / SonarQube Trigger (push) Successful in 2m21s
2026-01-25 18:14:29 +07:00
8b44e4db4e refactor: remove duplicate channel management helpers from HTTP handler
SonarQube Scan / SonarQube Trigger (push) Successful in 2m12s
2026-01-25 13:47:57 +07:00
2b488a5ab5 refactor: remove custom parsing functions and use ssh.Marshal/ssh.Unmarshal for serialization
SonarQube Scan / SonarQube Trigger (push) Successful in 2m14s
2026-01-25 12:21:25 +07:00
e59fea6604 test(server): add unit test for handleConnection
SonarQube Scan / SonarQube Trigger (push) Successful in 2m18s
2026-01-24 23:47:51 +07:00
d5f7ebf597 test(lifecycle): add unit tests for lifecycle behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 2m15s
2026-01-24 20:28:30 +07:00
0f5d00f79a test(slug): add unit tests for slug behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 2m10s
2026-01-24 19:16:31 +07:00
f0ab62a753 fix: properly initialize tlsStoragePath in config load
SonarQube Scan / SonarQube Trigger (push) Successful in 2m31s
2026-01-24 17:59:11 +07:00
752bacfe98 fix: correct buffer pool usage to avoid type assertion error
SonarQube Scan / SonarQube Trigger (push) Successful in 2m28s
2026-01-24 17:50:22 +07:00
9c857f6148 refactor(transport): reduce cognitive complexity and clean up public API
SonarQube Scan / SonarQube Trigger (push) Successful in 2m29s
2026-01-24 17:41:41 +07:00
42c428c83d test(bootstrap): add unit tests for initial bootstrap behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 2m30s
2026-01-24 16:15:30 +07:00
1171b18340 refactor: decouple application startup logic from main
SonarQube Scan / SonarQube Trigger (push) Successful in 2m18s
2026-01-24 00:21:40 +07:00
3d43396f3e fix: corrected defer usage to pass buffer pointer
SonarQube Scan / SonarQube Trigger (push) Successful in 2m24s
2026-01-23 20:31:27 +07:00
90423d675c fix: remove unnecessary use of fmt.Sprintf
SonarQube Scan / SonarQube Trigger (push) Successful in 2m34s
2026-01-23 20:28:11 +07:00
b7d6f2bae2 fix: handle error return values for privateKeyFile.Close and pubKeyFile.Close
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
2026-01-23 20:27:20 +07:00
8e8d9eaf39 chore: remove unused headerBuf variable
SonarQube Scan / SonarQube Trigger (push) Successful in 2m13s
2026-01-23 20:05:40 +07:00
636f17cabf ci: sonarqube add linting
SonarQube Scan / SonarQube Trigger (push) Successful in 2m14s
2026-01-23 19:58:09 +07:00
761ee3f099 test(session): add unit tests for session behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m51s
2026-01-23 19:03:01 +07:00
169bc2358c refactor(session): reduce function parameters
SonarQube Scan / SonarQube Trigger (push) Successful in 1m55s
2026-01-23 15:58:50 +07:00
e522ffa44f test(version): add unit tests for version behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m45s
2026-01-23 15:41:00 +07:00
6e6b251a7b test(client): add unit tests for grpc client behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m54s
2026-01-23 15:27:20 +07:00
24ea464c7a fix(client): reduce cognitive complexity and fix typo (go:S3776)
SonarQube Scan / SonarQube Trigger (push) Successful in 1m51s
2026-01-23 14:57:06 +07:00
4334dfe9b4 test(key): add unit tests for key behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 2m1s
2026-01-23 14:17:18 +07:00
dbaf5f4e60 ci: update SonarQube action configuration
SonarQube Scan / SonarQube Trigger (push) Successful in 1m41s
2026-01-23 13:38:50 +07:00
5bed32bda3 test(server): add unit tests for server startup behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m49s
2026-01-22 21:58:25 +07:00
6d57dfa34c test(config): add unit tests for config behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m35s
2026-01-22 21:24:58 +07:00
7159300fa2 test(stream): add unit tests for stream behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m39s
- Fix duplicating EOF error when closing SSH connection
- Add new SessionStatusCLOSED type
2026-01-22 21:04:05 +07:00
d7e1f3fa63 test(header): add unit tests for header behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m39s
2026-01-22 19:47:31 +07:00
8d5e1b970a update: exclude local test coverage
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
2026-01-22 19:46:47 +07:00
b0249c45ae test(transport): add unit tests for transport behavior using Testify
SonarQube Scan / SonarQube Trigger (push) Successful in 1m51s
2026-01-22 19:22:35 +07:00
9d03f5507f test(random): add unit tests for random behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m37s
- Added unit tests to cover random string generation and error handling.
- Introduced Random interface and random struct for better abstraction.
- Updated server, session, and interaction packages to require Random interface for dependency injection.
2026-01-22 13:28:10 +07:00
ae31e573b5 test(port): add unit tests for port behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m35s
2026-01-22 12:39:46 +07:00
a350692e81 test(middleware): add unit tests for middleware behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 1m30s
- remove redundant check on registry.Update and check if slug exist before locking the mutex
- Update SonarQube action to not use Go cache when setting up Go
2026-01-22 12:34:24 +07:00
5a7aa1ecb7 ci: automate Go tests and Sonar coverage reporting
SonarQube Scan / SonarQube Trigger (push) Successful in 10m48s
2026-01-22 01:29:29 +07:00
ed11a0b801 test(registry): add unit tests for registry behavior
SonarQube Scan / SonarQube Trigger (push) Successful in 47s
2026-01-22 00:47:53 +07:00
634c8321ef refactor(registry): define reusable constant errors
SonarQube Scan / SonarQube Trigger (push) Successful in 52s
SonarQube Scan / SonarQube Trigger (pull_request) Successful in 46s
- Introduced package-level error variables in registry to replace repeated fmt.Errorf calls
- Added errors like ErrSessionNotFound, ErrSlugInUse, ErrInvalidSlug, ErrForbiddenSlug, ErrSlugChangeNotAllowed, and ErrSlugUnchanged
2026-01-22 00:39:28 +07:00
9f4c24a3f3 refactor(lifecycle): reorder resource closing and simplify Close()
SonarQube Scan / SonarQube Trigger (push) Successful in 53s
- Close channel and connection first, then remove session
- Close forwarded port and forwarder at the end for TCP tunnels
- Aggregate all errors using errors.Join instead of failing early
2026-01-21 21:59:59 +07:00
1408b80917 ci: add sonarqube scan
SonarQube Scan / SonarQube Trigger (push) Successful in 48s
2026-01-21 21:24:57 +07:00
2bc20dd991 refactor(config): centralize env loading and enforce typed access
- Centralize environment variable loading in config.MustLoad
- Parse and validate all env vars once at initialization
- Make config fields private and read-only
- Remove public Getenv usage in favor of typed accessors
- Improve validation and initialization order
- Normalize enum naming to be idiomatic and avoid constant collisions
2026-01-21 19:43:19 +07:00
1e12373359 chore(restructure): reorganize project layout
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 13m1s
- Reorganize internal packages and overall project structure
- Update imports and wiring to match the new layout
- Separate HTTP parsing and streaming from the server package
- Separate middleware from the server package
- Separate session registry from the session package
- Move HTTP, HTTPS, and TCP servers to the transport package
- Session package no longer starts the TCP server directly
- Server package no longer starts HTTP/HTTPS servers on initialization
- Forwarder no longer handles accepting TCP requests
- Move session details to the types package
- HTTP/HTTPS initialization is now the responsibility of main
2026-01-21 14:06:46 +07:00
9a4539cc02 refactor(httpheader): extract header parsing into dedicated package
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 11m19s
Moved HTTP header parsing and building logic from server package to internal/httpheader
2026-01-20 21:15:34 +07:00
e3ead4d52f refactor: optimize header parsing and remove factory naming
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 11m20s
- Remove factory naming
- Use direct byte indexing instead of bytes.TrimRight
- Extract parseStartLine and setRemainingHeaders helpers
2026-01-20 20:56:08 +07:00
aa1a465178 refactor(forwarder): improve connection handling and cleanup
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Has been cancelled
- Extract copyAndClose method for bidirectional data transfe
- Add closeWriter helper for graceful connection shutdown
- Add handleIncomingConnection helper
- Add openForwardedChannel helper
2026-01-20 19:01:15 +07:00
27f49879af refactor(server): enhance HTTP handler modularity and fix resource leak
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 11m43s
- Rename customWriter struct to httpWriter for clarity
- Add closeWriter field to properly close write side of connections
- Update all cw variable references to hw
- Merge handlerTLS into handler function to reduce code duplication
- Extract handler into smaller, focused methods
- Split Read/Write/forwardRequest into composable functions

Fixes resource leak where connections weren't properly closed on the
write side, matching the forwarder's CloseWrite() pattern.
2026-01-19 22:41:04 +07:00
adb0264bb5 refactor(session): simplify Start() and unify forwarding logic
- Extract helper functions from Start() for better code organization
- Eliminate duplication with finalizeForwarding() method
- Consolidate denial logic into denyForwardingRequest()
- Update all handler methods to return errors instead of logging internally
- Improve error handling consistency across all operations
2026-01-19 15:53:16 +07:00
8fb19af5a6 fix: resolve copy goroutine deadlock on early connection close
- Add proper CloseWrite handling to signal EOF to other goroutine
- Ensure both copy goroutines terminate when either side closes
- Prevent goroutine leaks for SSH forwarded-tcpip channels:
    - Use select with default when sending result to resultChan
    - Close unused SSH channels and discard requests if main goroutine has already timed out
2026-01-19 00:20:28 +07:00
41fdb5639c Merge pull request 'refactor: explicit initialization and dependency injection' (#70) from staging into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 9m49s
Reviewed-on: #70
2026-01-18 21:46:59 +07:00
44d224f491 refactor: explicit initialization and dependency injection
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 10m10s
- Replace init() with config.Load() function when loading env variables
- Inject portRegistry into session, server, and lifecycle structs
- Inject sessionRegistry directly into interaction and lifecycle
- Remove SetSessionRegistry function and global port variables
- Pass ssh.Conn directly to forwarder constructor instead of lifecycle interface
- Pass user and closeFunc callback to interaction constructor instead of lifecycle interface
- Eliminate circular dependencies between lifecycle, forwarder, and interaction
- Remove setter methods (SetLifecycle) from forwarder and interaction interfaces
2026-01-18 21:20:05 +07:00
9be0328e24 Merge pull request 'staging' (#69) from staging into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 9m30s
Reviewed-on: #69
2026-01-17 19:15:40 +07:00
2b9bca65d5 refactor(interaction): separate view and update logic into modular files
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 11m44s
- Extract slug editing logic to slug.go (slugView/slugUpdate)
- Extract commands menu logic to commands.go (commandsView/commandsUpdate)
- Extract coming soon modal to coming_soon.go (comingSoonView/comingSoonUpdate)
- Extract main dashboard logic to dashboard.go (dashboardView/dashboardUpdate)
- Create model.go for shared model struct and helper functions
- Replace math/rand with crypto/rand for random subdomain generation
- Remove legacy TLS cipher suite configuration
2026-01-17 17:33:10 +07:00
6587dc0f39 refactor(interaction): separate view and update logic into modular files
- Extract slug editing logic to slug.go (slugView/slugUpdate)
- Extract commands menu logic to commands.go (commandsView/commandsUpdate)
- Extract coming soon modal to coming_soon.go (comingSoonView/comingSoonUpdate)
- Extract main dashboard logic to dashboard.go (dashboardView/dashboardUpdate)
- Create model.go for shared model struct and helper functions
- Replace math/rand with crypto/rand for random subdomain generation
- Remove legacy TLS cipher suite configuration
2026-01-17 17:30:21 +07:00
f421781f44 Merge pull request 'refactor: convert structs to interfaces and rename accessors' (#68) from staging into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 10m34s
Reviewed-on: #68
2026-01-16 16:41:22 +07:00
6969d6823a Merge branch 'main' into staging
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 11m38s
2026-01-16 16:35:36 +07:00
1a04af8873 Merge branch 'main' into staging
Docker Build and Push / build-and-push-branches (push) Successful in 11m35s
Docker Build and Push / build-and-push-tags (push) Has been skipped
2026-01-16 16:28:39 +07:00
19135ceb42 refactor: convert structs to interfaces and rename accessors
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Has been cancelled
- Convert struct types to interfaces
- Rename getter and setter methods
- Add Close method to server interface
- Merge handler functionality into session file
- Handle lifecycle.Connection().Wait()
- fix panic on nil connection in SSH server
2026-01-16 15:25:31 +07:00
edb11dbc51 Merge pull request 'chore(deps): update golang docker tag to v1.25.6' (#67) from renovate/golang-1.x into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 11m34s
2026-01-16 05:01:06 +07:00
819f044275 chore(deps): update golang docker tag to v1.25.6 2026-01-15 22:01:02 +00:00
a7ebf2c5db Merge pull request 'fix(deps): update module golang.org/x/crypto to v0.47.0' (#66) from renovate/golang.org-x-crypto-0.x into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 10m34s
Reviewed-on: #66
2026-01-14 10:42:52 +00:00
64c1038f4b fix(deps): update module golang.org/x/crypto to v0.47.0 2026-01-14 10:41:47 +00:00
aafea49975 feat: integrate gRPC, session refactor, SSH headless support, and bug fixes
Docker Build and Push / build-and-push-tags (push) Successful in 11m34s
Docker Build and Push / build-and-push-branches (push) Has been skipped
- gRPC integration: slug edit handling, get sessions by user, and session requests from gRPC server
- Refactor gRPC client: simplify processEventStream and handle authenticated user info
- Session management improvements: use session key for registry, forwarder session termination, inject SessionRegistry interface
- SSH enhancements: add headless mode support for SSH -N connections
- Bug fixes:
  - prevent subdomain changes to already-in-use subdomains
  - fix startup order and environment variable keys
  - atomic ClaimPort() to prevent race conditions
- Refactors:
  - consolidate error handling
  - replace Get/Set patterns with idiomatic Go interfaces
  - change enums from string to int
- CI cleanup: remove renovate bot

Reviewed-on: #65
2026-01-14 10:16:43 +00:00
dbdf8094fa refactor: replace Get/Set patterns with idiomatic Go interfaces
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 13m4s
- rename constructors to New
- remove Get/Set-style accessors
- replace string-based enums with iota-backed types
2026-01-14 16:54:10 +07:00
ae3ed52d16 fix(port): add atomic ClaimPort() to prevent race condition
- Replace GetPortStatus/SetPortStatus calls with atomic ClaimPort() operation.
- Fixed a logic error when handling headless tunneling.
2026-01-14 16:51:50 +07:00
fb638636bf refactor: consolidate error handling with fail() function in session handlers
- Replace repetitive error handling code with fail() function in HandleGlobalRequest
- Standardize error response pattern across all handler methods
- Improve code maintainability and reduce duplication
2026-01-14 16:51:50 +07:00
da29df85b7 feat: add headless mode support for SSH -N connections
- use s.lifecycle.GetConnection().Wait() to block until SSH connection closes
- Prevent premature session closure in headless mode

In headless mode (ssh -N), there's no channel interaction to block on,
so the session would immediately return and close. Now blocking on
conn.Wait() keeps the session alive until the client disconnects.
2026-01-14 16:51:50 +07:00
8b0e08c629 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1 2026-01-14 16:51:50 +07:00
f0804d6946 ci: remove renovate 2026-01-14 16:51:50 +07:00
09e526cd1e feat: add authenticated user info and restructure handleConnection
- Display authenticated username in welcome page information box
- Refactor handleConnection function for better structure and clarity
2026-01-14 16:51:50 +07:00
887ebf78b1 refactor(grpc/client): simplify processEventStream with per-event handlers
- Extract eventHandlers dispatch table
- Add per-event handlers: handleSlugChange, handleGetSessions, handleTerminateSession
- Introduce sendNode helper to centralize send/error handling and preserve connection-error propagation
- Add protoToTunnelType for tunnel-type validation
- Map unknown proto.TunnelType to types.UNKNOWN in protoToTunnelType and return a descriptive error
- Reduce boilerplate and improve readability of processEventStream
2026-01-14 16:51:50 +07:00
bef7a49f88 feat: implement forwarder session termination 2026-01-14 16:51:50 +07:00
17633b4e3c refactor: inject SessionRegistry interface instead of individual functions 2026-01-14 16:51:50 +07:00
f25d61d1d1 update: proto file to v1.3.0 2026-01-14 16:51:50 +07:00
8782b77b74 feat(session): use session key for registry 2026-01-14 16:51:50 +07:00
fc3cd886db fix: use correct environment variable key 2026-01-14 16:51:50 +07:00
b0da57db0d fix: startup order 2026-01-14 16:51:50 +07:00
0bd6eeadf3 feat: implement sessions request from grpc server 2026-01-14 16:51:50 +07:00
449f546e04 feat: implement sessions request from grpc server 2026-01-14 16:51:50 +07:00
4644420eee feat: implement get sessions by user 2026-01-14 16:51:50 +07:00
c9bf9e62bd feat(grpc): integrate slug edit handling 2026-01-14 16:51:50 +07:00
57d2136377 WIP: gRPC integration, initial implementation 2026-01-14 16:51:47 +07:00
8a34aaba80 WIP: gRPC integration, initial implementation 2026-01-14 16:51:35 +07:00
ff995a929e revert 01ddc76f7e
revert Merge pull request 'fix(deps): update module github.com/caddyserver/certmagic to v0.25.1' (#58) from renovate/github.com-caddyserver-certmagic-0.x into main
2026-01-14 16:51:35 +07:00
32ac9c1749 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1
# Conflicts:
#	go.mod
2026-01-14 16:51:30 +07:00
07d9f3afe6 refactor: replace Get/Set patterns with idiomatic Go interfaces
Docker Build and Push / build-and-push-tags (push) Successful in 10m59s
Docker Build and Push / build-and-push-branches (push) Has been skipped
- rename constructors to New
- remove Get/Set-style accessors
- replace string-based enums with iota-backed types
2026-01-14 15:28:17 +07:00
e051a5b742 Merge pull request 'fix(deps): update module golang.org/x/crypto to v0.47.0' (#64) from renovate/golang.org-x-crypto-0.x into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 9m51s
renovate / renovate (push) Successful in 55s
2026-01-12 18:20:57 +00:00
d35228759c fix(deps): update module golang.org/x/crypto to v0.47.0 2026-01-12 18:20:53 +00:00
abd103b5ab fix(port): add atomic ClaimPort() to prevent race condition
Docker Build and Push / build-and-push-tags (push) Successful in 3m23s
Docker Build and Push / build-and-push-branches (push) Has been skipped
- Replace GetPortStatus/SetPortStatus calls with atomic ClaimPort() operation.
- Fixed a logic error when handling headless tunneling.
2026-01-12 18:25:35 +07:00
560c98b869 refactor: consolidate error handling with fail() function in session handlers
Docker Build and Push / build-and-push-tags (push) Successful in 3m21s
Docker Build and Push / build-and-push-branches (push) Has been skipped
- Replace repetitive error handling code with fail() function in HandleGlobalRequest
- Standardize error response pattern across all handler methods
- Improve code maintainability and reduce duplication
2026-01-12 14:42:42 +07:00
e1f5d73e03 feat: add headless mode support for SSH -N connections
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m3s
- use s.lifecycle.GetConnection().Wait() to block until SSH connection closes
- Prevent premature session closure in headless mode

In headless mode (ssh -N), there's no channel interaction to block on,
so the session would immediately return and close. Now blocking on
conn.Wait() keeps the session alive until the client disconnects.
2026-01-11 15:21:11 +07:00
19fd6d59d2 Merge pull request 'main' (#62) from main into staging
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 3m32s
Reviewed-on: #62
2026-01-09 12:15:30 +00:00
e3988b339f Merge pull request 'fix(deps): update module github.com/caddyserver/certmagic to v0.25.1' (#61) from renovate/github.com-caddyserver-certmagic-0.x into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 3m21s
Reviewed-on: #61
2026-01-09 12:15:05 +00:00
336948a397 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1 2026-01-09 10:00:35 +00:00
50ae422de8 Merge pull request 'staging' (#60) from staging into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 3m20s
Reviewed-on: #60
2026-01-09 09:33:28 +00:00
8467ed555e revert 01ddc76f7e
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Has been cancelled
revert Merge pull request 'fix(deps): update module github.com/caddyserver/certmagic to v0.25.1' (#58) from renovate/github.com-caddyserver-certmagic-0.x into main
2026-01-09 09:33:04 +00:00
01ddc76f7e Merge pull request 'fix(deps): update module github.com/caddyserver/certmagic to v0.25.1' (#58) from renovate/github.com-caddyserver-certmagic-0.x into main
Docker Build and Push / build-and-push-branches (push) Waiting to run
Docker Build and Push / build-and-push-tags (push) Has been skipped
2026-01-09 09:30:23 +00:00
ffb3565ff5 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1 2026-01-09 09:30:18 +00:00
6d700ef6dd Merge pull request 'feat/grpc-integration' (#59) from feat/grpc-integration into staging
Docker Build and Push / build-and-push-branches (push) Successful in 5m25s
Docker Build and Push / build-and-push-tags (push) Has been skipped
Reviewed-on: #59
2026-01-09 09:24:20 +00:00
b8acb6da4c ci: remove renovate
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Has been cancelled
2026-01-08 13:03:02 +07:00
6b4127f0ef feat: add authenticated user info and restructure handleConnection
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m21s
- Display authenticated username in welcome page information box
- Refactor handleConnection function for better structure and clarity
2026-01-07 23:07:02 +07:00
16d48ff906 refactor(grpc/client): simplify processEventStream with per-event handlers
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m20s
- Extract eventHandlers dispatch table
- Add per-event handlers: handleSlugChange, handleGetSessions, handleTerminateSession
- Introduce sendNode helper to centralize send/error handling and preserve connection-error propagation
- Add protoToTunnelType for tunnel-type validation
- Map unknown proto.TunnelType to types.UNKNOWN in protoToTunnelType and return a descriptive error
- Reduce boilerplate and improve readability of processEventStream
2026-01-06 20:14:56 +07:00
6213ff8a30 feat: implement forwarder session termination
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m36s
2026-01-06 18:32:48 +07:00
4ffaec9d9a refactor: inject SessionRegistry interface instead of individual functions
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m16s
2026-01-05 16:49:17 +07:00
6de0a618ee update: proto file to v1.3.0
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m0s
2026-01-05 00:55:51 +07:00
8cc70fa45e feat(session): use session key for registry 2026-01-05 00:50:42 +07:00
d666ae5545 fix: use correct environment variable key
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m1s
2026-01-04 18:21:34 +07:00
5edb3c8086 fix: startup order
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m51s
2026-01-04 15:19:03 +07:00
5b603d8317 feat: implement sessions request from grpc server
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m7s
2026-01-03 21:17:01 +07:00
5ceade81db Merge pull request 'staging' (#57) from staging into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 3m57s
renovate / renovate (push) Failing after 34s
Reviewed-on: #57
2026-01-03 13:07:49 +00:00
8fd9f8b567 feat: implement sessions request from grpc server
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Has been cancelled
2026-01-03 20:06:14 +07:00
30e84ac3b7 feat: implement get sessions by user 2026-01-02 22:58:54 +07:00
fd6ffc2500 feat(grpc): integrate slug edit handling 2026-01-02 18:27:48 +07:00
e1cd4ed981 WIP: gRPC integration, initial implementation 2026-01-01 21:03:17 +07:00
96d2b88f95 WIP: gRPC integration, initial implementation 2026-01-01 21:01:15 +07:00
8a456d2cde Merge pull request 'staging' (#55) from staging into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 5m50s
renovate / renovate (push) Successful in 35s
Reviewed-on: #55
2025-12-31 08:51:25 +00:00
8841230653 Merge pull request 'fix: prevent subdomain change to already-in-use subdomains' (#54) from staging into main
Docker Build and Push / build-and-push (push) Successful in 5m20s
renovate / renovate (push) Successful in 38s
Reviewed-on: #54
2025-12-30 12:42:05 +00:00
4d0a7deaf2 Merge pull request 'staging' (#53) from staging into main
Docker Build and Push / build-and-push (push) Successful in 3m33s
renovate / renovate (push) Successful in 22s
Reviewed-on: #53
2025-12-29 17:18:25 +00:00
20 changed files with 1376 additions and 656 deletions
-21
View File
@@ -1,21 +0,0 @@
name: renovate
on:
schedule:
- cron: "0 0 * * *"
push:
branches:
- staging
jobs:
renovate:
runs-on: ubuntu-latest
container: git.fossy.my.id/renovate-clanker/renovate:latest
steps:
- uses: actions/checkout@v6
- run: renovate
env:
RENOVATE_CONFIG_FILE: ${{ gitea.workspace }}/renovate-config.js
LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
GITHUB_COM_TOKEN: ${{ secrets.COM_TOKEN }}
+4
View File
@@ -33,6 +33,10 @@ The following environment variables can be configured in the `.env` file:
| `BUFFER_SIZE` | Buffer size for io.Copy operations in bytes (4096-1048576) | `32768` | No |
| `PPROF_ENABLED` | Enable pprof profiling server | `false` | No |
| `PPROF_PORT` | Port for pprof server | `6060` | No |
| `MODE` | Runtime mode: `standalone` (default, no gRPC/auth) or `node` (enable gRPC + auth) | `standalone` | No |
| `GRPC_ADDRESS` | gRPC server address/host used in `node` mode | `localhost` | No |
| `GRPC_PORT` | gRPC server port used in `node` mode | `8080` | No |
| `NODE_TOKEN` | Authentication token sent to controller in `node` mode | - (required in `node`) | Yes (node mode) |
**Note:** All environment variables now use UPPERCASE naming. The application includes sensible defaults for all variables, so you can run it without a `.env` file for basic functionality.
+24 -17
View File
@@ -1,48 +1,55 @@
module tunnel_pls
go 1.24.4
go 1.25.5
require (
github.com/caddyserver/certmagic v0.25.0
git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0
github.com/caddyserver/certmagic v0.25.1
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/joho/godotenv v1.5.1
github.com/libdns/cloudflare v0.2.2
github.com/muesli/termenv v0.16.0
golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/caddyserver/zerossl v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mholt/acmez/v3 v3.1.3 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mholt/acmez/v3 v3.1.4 // indirect
github.com/miekg/dns v1.1.69 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)
+72 -29
View File
@@ -1,3 +1,9 @@
git.fossy.my.id/bagas/tunnel-please-grpc v1.3.0 h1:RhcBKUG41/om4jgN+iF/vlY/RojTeX1QhBa4p4428ec=
git.fossy.my.id/bagas/tunnel-please-grpc v1.3.0/go.mod h1:fG+VkArdkceGB0bNA7IFQus9GetLAwdF5Oi4jdMlXtY=
git.fossy.my.id/bagas/tunnel-please-grpc v1.4.0 h1:tpJSKjaSmV+vxxbVx6qnStjxFVXjj2M0rygWXxLb99o=
git.fossy.my.id/bagas/tunnel-please-grpc v1.4.0/go.mod h1:fG+VkArdkceGB0bNA7IFQus9GetLAwdF5Oi4jdMlXtY=
git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0 h1:3xszIhck4wo9CoeRq9vnkar4PhY7kz9QrR30qj2XszA=
git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0/go.mod h1:Weh6ZujgWmT8XxD3Qba7sJ6r5eyUMB9XSWynqdyOoLo=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -6,30 +12,48 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic=
github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA=
github.com/caddyserver/certmagic v0.25.1 h1:4sIKKbOt5pg6+sL7tEwymE1x2bj6CHr80da1CRRIPbY=
github.com/caddyserver/certmagic v0.25.1/go.mod h1:VhyvndxtVton/Fo/wKhRoC46Rbw1fmjvQ3GjHYSQTEY=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/caddyserver/zerossl v0.1.4 h1:CVJOE3MZeFisCERZjkxIcsqIH4fnFdlYWnPYeFtBHRw=
github.com/caddyserver/zerossl v0.1.4/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -40,18 +64,18 @@ github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0v
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc=
github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ=
github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -60,7 +84,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
@@ -75,22 +98,34 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -101,7 +136,15 @@ golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+421
View File
@@ -0,0 +1,421 @@
package client
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/types"
"tunnel_pls/session"
proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
type GrpcConfig struct {
Address string
UseTLS bool
InsecureSkipVerify bool
Timeout time.Duration
KeepAlive bool
MaxRetries int
KeepAliveTime time.Duration
KeepAliveTimeout time.Duration
PermitWithoutStream bool
}
type Client struct {
conn *grpc.ClientConn
config *GrpcConfig
sessionRegistry session.Registry
eventService proto.EventServiceClient
authorizeConnectionService proto.UserServiceClient
closing bool
}
func DefaultConfig() *GrpcConfig {
return &GrpcConfig{
Address: "localhost:50051",
UseTLS: false,
InsecureSkipVerify: false,
Timeout: 10 * time.Second,
KeepAlive: true,
MaxRetries: 3,
KeepAliveTime: 2 * time.Minute,
KeepAliveTimeout: 10 * time.Second,
PermitWithoutStream: false,
}
}
func New(config *GrpcConfig, sessionRegistry session.Registry) (*Client, error) {
if config == nil {
config = DefaultConfig()
} else {
defaults := DefaultConfig()
if config.Address == "" {
config.Address = defaults.Address
}
if config.Timeout == 0 {
config.Timeout = defaults.Timeout
}
if config.MaxRetries == 0 {
config.MaxRetries = defaults.MaxRetries
}
if config.KeepAliveTime == 0 {
config.KeepAliveTime = defaults.KeepAliveTime
}
if config.KeepAliveTimeout == 0 {
config.KeepAliveTimeout = defaults.KeepAliveTimeout
}
}
var opts []grpc.DialOption
if config.UseTLS {
tlsConfig := &tls.Config{
InsecureSkipVerify: config.InsecureSkipVerify,
}
creds := credentials.NewTLS(tlsConfig)
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
if config.KeepAlive {
kaParams := keepalive.ClientParameters{
Time: config.KeepAliveTime,
Timeout: config.KeepAliveTimeout,
PermitWithoutStream: config.PermitWithoutStream,
}
opts = append(opts, grpc.WithKeepaliveParams(kaParams))
}
opts = append(opts,
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(4*1024*1024),
grpc.MaxCallSendMsgSize(4*1024*1024),
),
)
conn, err := grpc.NewClient(config.Address, opts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to gRPC server at %s: %w", config.Address, err)
}
eventService := proto.NewEventServiceClient(conn)
authorizeConnectionService := proto.NewUserServiceClient(conn)
return &Client{
conn: conn,
config: config,
sessionRegistry: sessionRegistry,
eventService: eventService,
authorizeConnectionService: authorizeConnectionService,
}, nil
}
func (c *Client) SubscribeEvents(ctx context.Context, identity, authToken string) error {
const (
baseBackoff = time.Second
maxBackoff = 30 * time.Second
)
backoff := baseBackoff
wait := func() error {
if backoff <= 0 {
return nil
}
select {
case <-time.After(backoff):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
growBackoff := func() {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
for {
subscribe, err := c.eventService.Subscribe(ctx)
if err != nil {
if errors.Is(err, context.Canceled) || status.Code(err) == codes.Canceled || ctx.Err() != nil {
return err
}
if !c.isConnectionError(err) || status.Code(err) == codes.Unauthenticated {
return err
}
if err = wait(); err != nil {
return err
}
growBackoff()
log.Printf("Reconnect to controller within %v sec", backoff.Seconds())
continue
}
err = subscribe.Send(&proto.Node{
Type: proto.EventType_AUTHENTICATION,
Payload: &proto.Node_AuthEvent{
AuthEvent: &proto.Authentication{
Identity: identity,
AuthToken: authToken,
},
},
})
if err != nil {
log.Println("Authentication failed to send to gRPC server:", err)
if c.isConnectionError(err) {
if err = wait(); err != nil {
return err
}
growBackoff()
continue
}
return err
}
log.Println("Authentication Successfully sent to gRPC server")
backoff = baseBackoff
if err = c.processEventStream(subscribe); err != nil {
if errors.Is(err, context.Canceled) || status.Code(err) == codes.Canceled || ctx.Err() != nil {
return err
}
if c.isConnectionError(err) {
log.Printf("Reconnect to controller within %v sec", backoff.Seconds())
if err = wait(); err != nil {
return err
}
growBackoff()
continue
}
return err
}
}
}
func (c *Client) processEventStream(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events]) error {
handlers := c.eventHandlers(subscribe)
for {
recv, err := subscribe.Recv()
if err != nil {
return err
}
handler, ok := handlers[recv.GetType()]
if !ok {
log.Printf("Unknown event type received: %v", recv.GetType())
continue
}
if err = handler(recv); err != nil {
return err
}
}
}
func (c *Client) eventHandlers(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events]) map[proto.EventType]func(*proto.Events) error {
return map[proto.EventType]func(*proto.Events) error{
proto.EventType_SLUG_CHANGE: func(evt *proto.Events) error { return c.handleSlugChange(subscribe, evt) },
proto.EventType_GET_SESSIONS: func(evt *proto.Events) error { return c.handleGetSessions(subscribe, evt) },
proto.EventType_TERMINATE_SESSION: func(evt *proto.Events) error { return c.handleTerminateSession(subscribe, evt) },
}
}
func (c *Client) handleSlugChange(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], evt *proto.Events) error {
slugEvent := evt.GetSlugEvent()
user := slugEvent.GetUser()
oldSlug := slugEvent.GetOld()
newSlug := slugEvent.GetNew()
userSession, err := c.sessionRegistry.Get(types.SessionKey{Id: oldSlug, Type: types.HTTP})
if err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{Success: false, Message: err.Error()},
},
}, "slug change failure response")
}
if err = c.sessionRegistry.Update(user, types.SessionKey{Id: oldSlug, Type: types.HTTP}, types.SessionKey{Id: newSlug, Type: types.HTTP}); err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{Success: false, Message: err.Error()},
},
}, "slug change failure response")
}
userSession.Interaction().Redraw()
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{Success: true, Message: ""},
},
}, "slug change success response")
}
func (c *Client) handleGetSessions(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], evt *proto.Events) error {
sessions := c.sessionRegistry.GetAllSessionFromUser(evt.GetGetSessionsEvent().GetIdentity())
var details []*proto.Detail
for _, ses := range sessions {
detail := ses.Detail()
details = append(details, &proto.Detail{
Node: config.Getenv("DOMAIN", "localhost"),
ForwardingType: detail.ForwardingType,
Slug: detail.Slug,
UserId: detail.UserID,
Active: detail.Active,
StartedAt: timestamppb.New(detail.StartedAt),
})
}
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_GET_SESSIONS,
Payload: &proto.Node_GetSessionsEvent{
GetSessionsEvent: &proto.GetSessionsResponse{Details: details},
},
}, "send get sessions response")
}
func (c *Client) handleTerminateSession(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], evt *proto.Events) error {
terminate := evt.GetTerminateSessionEvent()
user := terminate.GetUser()
slug := terminate.GetSlug()
tunnelType, err := c.protoToTunnelType(terminate.GetTunnelType())
if err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: false, Message: err.Error()},
},
}, "terminate session invalid tunnel type")
}
userSession, err := c.sessionRegistry.GetWithUser(user, types.SessionKey{Id: slug, Type: tunnelType})
if err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: false, Message: err.Error()},
},
}, "terminate session fetch failed")
}
if err = userSession.Lifecycle().Close(); err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: false, Message: err.Error()},
},
}, "terminate session close failed")
}
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: true, Message: ""},
},
}, "terminate session success response")
}
func (c *Client) sendNode(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], node *proto.Node, context string) error {
if err := subscribe.Send(node); err != nil {
if c.isConnectionError(err) {
return err
}
log.Printf("%s: %v", context, err)
}
return nil
}
func (c *Client) protoToTunnelType(t proto.TunnelType) (types.TunnelType, error) {
switch t {
case proto.TunnelType_HTTP:
return types.HTTP, nil
case proto.TunnelType_TCP:
return types.TCP, nil
default:
return types.UNKNOWN, fmt.Errorf("unknown tunnel type received")
}
}
func (c *Client) GetConnection() *grpc.ClientConn {
return c.conn
}
func (c *Client) AuthorizeConn(ctx context.Context, token string) (authorized bool, user string, err error) {
check, err := c.authorizeConnectionService.Check(ctx, &proto.CheckRequest{AuthToken: token})
if err != nil {
return false, "UNAUTHORIZED", err
}
if check.GetResponse() == proto.AuthorizationResponse_MESSAGE_TYPE_UNAUTHORIZED {
return false, "UNAUTHORIZED", nil
}
return true, check.GetUser(), nil
}
func (c *Client) Close() error {
if c.conn != nil {
log.Printf("Closing gRPC connection to %s", c.config.Address)
c.closing = true
return c.conn.Close()
}
return nil
}
func (c *Client) CheckServerHealth(ctx context.Context) error {
healthClient := grpc_health_v1.NewHealthClient(c.GetConnection())
resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{
Service: "",
})
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if resp.Status != grpc_health_v1.HealthCheckResponse_SERVING {
return fmt.Errorf("server not serving: %v", resp.Status)
}
return nil
}
func (c *Client) GetConfig() *GrpcConfig {
return c.config
}
func (c *Client) isConnectionError(err error) bool {
if c.closing {
return false
}
if err == nil {
return false
}
if errors.Is(err, io.EOF) {
return true
}
switch status.Code(err) {
case codes.Unavailable, codes.Canceled, codes.DeadlineExceeded:
return true
default:
return false
}
}
+16 -6
View File
@@ -13,7 +13,7 @@ type Manager interface {
AddPortRange(startPort, endPort uint16) error
GetUnassignedPort() (uint16, bool)
SetPortStatus(port uint16, assigned bool) error
GetPortStatus(port uint16) (bool, bool)
ClaimPort(port uint16) (claimed bool)
}
type manager struct {
@@ -74,7 +74,6 @@ func (pm *manager) GetUnassignedPort() (uint16, bool) {
for _, port := range pm.sortedPorts {
if !pm.ports[port] {
pm.ports[port] = true
return port, true
}
}
@@ -89,10 +88,21 @@ func (pm *manager) SetPortStatus(port uint16, assigned bool) error {
return nil
}
func (pm *manager) GetPortStatus(port uint16) (bool, bool) {
pm.mu.RLock()
defer pm.mu.RUnlock()
func (pm *manager) ClaimPort(port uint16) (claimed bool) {
pm.mu.Lock()
defer pm.mu.Unlock()
status, exists := pm.ports[port]
return status, exists
if exists && status {
return false
}
if !exists {
pm.ports[port] = true
return true
}
pm.ports[port] = true
return true
}
+76 -4
View File
@@ -1,12 +1,18 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/internal/grpc/client"
"tunnel_pls/internal/key"
"tunnel_pls/server"
"tunnel_pls/session"
@@ -26,6 +32,9 @@ func main() {
log.Printf("Starting %s", version.GetVersion())
mode := strings.ToLower(config.Getenv("MODE", "standalone"))
isNodeMode := mode == "node"
pprofEnabled := config.Getenv("PPROF_ENABLED", "false")
if pprofEnabled == "true" {
pprofPort := config.Getenv("PPROF_PORT", "6060")
@@ -61,9 +70,72 @@ func main() {
sshConfig.AddHostKey(private)
sessionRegistry := session.NewRegistry()
app, err := server.NewServer(sshConfig, sessionRegistry)
if err != nil {
log.Fatalf("Failed to start server: %s", err)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errChan := make(chan error, 2)
shutdownChan := make(chan os.Signal, 1)
signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)
var grpcClient *client.Client
if isNodeMode {
grpcHost := config.Getenv("GRPC_ADDRESS", "localhost")
grpcPort := config.Getenv("GRPC_PORT", "8080")
grpcAddr := fmt.Sprintf("%s:%s", grpcHost, grpcPort)
nodeToken := config.Getenv("NODE_TOKEN", "")
if nodeToken == "" {
log.Fatalf("NODE_TOKEN is required in node mode")
}
c, err := client.New(&client.GrpcConfig{
Address: grpcAddr,
UseTLS: false,
InsecureSkipVerify: false,
Timeout: 10 * time.Second,
KeepAlive: true,
MaxRetries: 3,
}, sessionRegistry)
if err != nil {
log.Fatalf("failed to create grpc client: %v", err)
}
grpcClient = c
healthCtx, healthCancel := context.WithTimeout(ctx, 5*time.Second)
if err := grpcClient.CheckServerHealth(healthCtx); err != nil {
healthCancel()
log.Fatalf("gRPC health check failed: %v", err)
}
healthCancel()
go func() {
identity := config.Getenv("DOMAIN", "localhost")
if err := grpcClient.SubscribeEvents(ctx, identity, nodeToken); err != nil {
errChan <- fmt.Errorf("failed to subscribe to events: %w", err)
}
}()
}
go func() {
app, err := server.NewServer(sshConfig, sessionRegistry, grpcClient)
if err != nil {
errChan <- fmt.Errorf("failed to start server: %s", err)
return
}
app.Start()
}()
select {
case err := <-errChan:
log.Printf("error happen : %s", err)
case sig := <-shutdownChan:
log.Printf("received signal %s, shutting down", sig)
}
cancel()
if grpcClient != nil {
if err := grpcClient.Close(); err != nil {
log.Printf("failed to close grpc conn : %s", err)
}
}
app.Start()
}
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
"endpoint": "https://git.fossy.my.id/api/v1",
"gitAuthor": "Renovate-Clanker <renovate-bot@fossy.my.id>",
"platform": "gitea",
"onboardingConfigFileName": "renovate.json",
"autodiscover": true,
"optimizeForDisabled": true,
};
+12 -8
View File
@@ -13,6 +13,7 @@ import (
"time"
"tunnel_pls/internal/config"
"tunnel_pls/session"
"tunnel_pls/types"
"golang.org/x/crypto/ssh"
)
@@ -313,8 +314,11 @@ func (hs *httpServer) handler(conn net.Conn) {
return
}
sshSession, exist := hs.sessionRegistry.Get(slug)
if !exist {
sshSession, err := hs.sessionRegistry.Get(types.SessionKey{
Id: slug,
Type: types.HTTP,
})
if err != nil {
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) +
"Content-Length: 0\r\n" +
@@ -331,8 +335,8 @@ func (hs *httpServer) handler(conn net.Conn) {
return
}
func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSession *session.SSHSession) {
payload := sshSession.GetForwarder().CreateForwardedTCPIPPayload(cw.GetRemoteAddr())
func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSession session.Session) {
payload := sshSession.Forwarder().CreateForwardedTCPIPPayload(cw.GetRemoteAddr())
type channelResult struct {
channel ssh.Channel
@@ -342,7 +346,7 @@ func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSessi
resultChan := make(chan channelResult, 1)
go func() {
channel, reqs, err := sshSession.GetLifecycle().GetConnection().OpenChannel("forwarded-tcpip", payload)
channel, reqs, err := sshSession.Lifecycle().Connection().OpenChannel("forwarded-tcpip", payload)
resultChan <- channelResult{channel, reqs, err}
}()
@@ -353,14 +357,14 @@ func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSessi
case result := <-resultChan:
if result.err != nil {
log.Printf("Failed to open forwarded-tcpip channel: %v", result.err)
sshSession.GetForwarder().WriteBadGatewayResponse(cw.GetWriter())
sshSession.Forwarder().WriteBadGatewayResponse(cw.GetWriter())
return
}
channel = result.channel
reqs = result.reqs
case <-time.After(5 * time.Second):
log.Printf("Timeout opening forwarded-tcpip channel")
sshSession.GetForwarder().WriteBadGatewayResponse(cw.GetWriter())
sshSession.Forwarder().WriteBadGatewayResponse(cw.GetWriter())
return
}
@@ -386,6 +390,6 @@ func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSessi
return
}
sshSession.GetForwarder().HandleConnection(cw, channel, cw.GetRemoteAddr())
sshSession.Forwarder().HandleConnection(cw, channel, cw.GetRemoteAddr())
return
}
+6 -2
View File
@@ -9,6 +9,7 @@ import (
"net"
"strings"
"tunnel_pls/internal/config"
"tunnel_pls/types"
)
func (hs *httpServer) ListenAndServeTLS() error {
@@ -89,8 +90,11 @@ func (hs *httpServer) handlerTLS(conn net.Conn) {
return
}
sshSession, exist := hs.sessionRegistry.Get(slug)
if !exist {
sshSession, err := hs.sessionRegistry.Get(types.SessionKey{
Id: slug,
Type: types.HTTP,
})
if err != nil {
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) +
"Content-Length: 0\r\n" +
+23 -4
View File
@@ -1,10 +1,14 @@
package server
import (
"context"
"errors"
"fmt"
"log"
"net"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/internal/grpc/client"
"tunnel_pls/session"
"golang.org/x/crypto/ssh"
@@ -14,9 +18,10 @@ type Server struct {
conn *net.Listener
config *ssh.ServerConfig
sessionRegistry session.Registry
grpcClient *client.Client
}
func NewServer(sshConfig *ssh.ServerConfig, sessionRegistry session.Registry) (*Server, error) {
func NewServer(sshConfig *ssh.ServerConfig, sessionRegistry session.Registry, grpcClient *client.Client) (*Server, error) {
listener, err := net.Listen("tcp", fmt.Sprintf(":%s", config.Getenv("PORT", "2200")))
if err != nil {
log.Fatalf("failed to listen on port 2200: %v", err)
@@ -42,6 +47,7 @@ func NewServer(sshConfig *ssh.ServerConfig, sessionRegistry session.Registry) (*
conn: &listener,
config: sshConfig,
sessionRegistry: sessionRegistry,
grpcClient: grpcClient,
}, nil
}
@@ -62,7 +68,7 @@ func (s *Server) handleConnection(conn net.Conn) {
sshConn, chans, forwardingReqs, err := ssh.NewServerConn(conn, s.config)
if err != nil {
log.Printf("failed to establish SSH connection: %v", err)
err := conn.Close()
err = conn.Close()
if err != nil {
log.Printf("failed to close SSH connection: %v", err)
return
@@ -70,9 +76,22 @@ func (s *Server) handleConnection(conn net.Conn) {
return
}
log.Println("SSH connection established:", sshConn.User())
defer func(sshConn *ssh.ServerConn) {
err = sshConn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("failed to close SSH server: %v", err)
}
}(sshConn)
sshSession := session.New(sshConn, forwardingReqs, chans, s.sessionRegistry)
user := "UNAUTHORIZED"
if s.grpcClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
_, u, _ := s.grpcClient.AuthorizeConn(ctx, sshConn.User())
user = u
cancel()
}
log.Println("SSH connection established:", sshConn.User())
sshSession := session.New(sshConn, forwardingReqs, chans, s.sessionRegistry, user)
err = sshSession.Start()
if err != nil {
log.Printf("SSH session ended with error: %v", err)
+30 -30
View File
@@ -30,50 +30,50 @@ func copyWithBuffer(dst io.Writer, src io.Reader) (written int64, err error) {
return io.CopyBuffer(dst, src, buf)
}
type Forwarder struct {
type forwarder struct {
listener net.Listener
tunnelType types.TunnelType
forwardedPort uint16
slugManager slug.Manager
slug slug.Slug
lifecycle Lifecycle
}
func NewForwarder(slugManager slug.Manager) *Forwarder {
return &Forwarder{
func New(slug slug.Slug) Forwarder {
return &forwarder{
listener: nil,
tunnelType: "",
tunnelType: types.UNKNOWN,
forwardedPort: 0,
slugManager: slugManager,
slug: slug,
lifecycle: nil,
}
}
type Lifecycle interface {
GetConnection() ssh.Conn
Connection() ssh.Conn
}
type ForwardingController interface {
AcceptTCPConnections()
type Forwarder interface {
SetType(tunnelType types.TunnelType)
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
SetLifecycle(lifecycle Lifecycle)
SetForwardedPort(port uint16)
SetListener(listener net.Listener)
GetListener() net.Listener
Close() error
Listener() net.Listener
TunnelType() types.TunnelType
ForwardedPort() uint16
HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteAddr net.Addr)
SetLifecycle(lifecycle Lifecycle)
CreateForwardedTCPIPPayload(origin net.Addr) []byte
WriteBadGatewayResponse(dst io.Writer)
AcceptTCPConnections()
Close() error
}
func (f *Forwarder) SetLifecycle(lifecycle Lifecycle) {
func (f *forwarder) SetLifecycle(lifecycle Lifecycle) {
f.lifecycle = lifecycle
}
func (f *Forwarder) AcceptTCPConnections() {
func (f *forwarder) AcceptTCPConnections() {
for {
conn, err := f.GetListener().Accept()
conn, err := f.Listener().Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
@@ -100,7 +100,7 @@ func (f *Forwarder) AcceptTCPConnections() {
resultChan := make(chan channelResult, 1)
go func() {
channel, reqs, err := f.lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload)
channel, reqs, err := f.lifecycle.Connection().OpenChannel("forwarded-tcpip", payload)
resultChan <- channelResult{channel, reqs, err}
}()
@@ -130,7 +130,7 @@ func (f *Forwarder) AcceptTCPConnections() {
}
}
func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteAddr net.Addr) {
func (f *forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteAddr net.Addr) {
defer func() {
_, err := io.Copy(io.Discard, src)
if err != nil {
@@ -174,31 +174,31 @@ func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteA
wg.Wait()
}
func (f *Forwarder) SetType(tunnelType types.TunnelType) {
func (f *forwarder) SetType(tunnelType types.TunnelType) {
f.tunnelType = tunnelType
}
func (f *Forwarder) GetTunnelType() types.TunnelType {
func (f *forwarder) TunnelType() types.TunnelType {
return f.tunnelType
}
func (f *Forwarder) GetForwardedPort() uint16 {
func (f *forwarder) ForwardedPort() uint16 {
return f.forwardedPort
}
func (f *Forwarder) SetForwardedPort(port uint16) {
func (f *forwarder) SetForwardedPort(port uint16) {
f.forwardedPort = port
}
func (f *Forwarder) SetListener(listener net.Listener) {
func (f *forwarder) SetListener(listener net.Listener) {
f.listener = listener
}
func (f *Forwarder) GetListener() net.Listener {
func (f *forwarder) Listener() net.Listener {
return f.listener
}
func (f *Forwarder) WriteBadGatewayResponse(dst io.Writer) {
func (f *forwarder) WriteBadGatewayResponse(dst io.Writer) {
_, err := dst.Write(types.BadGatewayResponse)
if err != nil {
log.Printf("failed to write Bad Gateway response: %v", err)
@@ -206,20 +206,20 @@ func (f *Forwarder) WriteBadGatewayResponse(dst io.Writer) {
}
}
func (f *Forwarder) Close() error {
if f.GetListener() != nil {
func (f *forwarder) Close() error {
if f.Listener() != nil {
return f.listener.Close()
}
return nil
}
func (f *Forwarder) CreateForwardedTCPIPPayload(origin net.Addr) []byte {
func (f *forwarder) CreateForwardedTCPIPPayload(origin net.Addr) []byte {
var buf bytes.Buffer
host, originPort := parseAddr(origin.String())
writeSSHString(&buf, "localhost")
err := binary.Write(&buf, binary.BigEndian, uint32(f.GetForwardedPort()))
err := binary.Write(&buf, binary.BigEndian, uint32(f.ForwardedPort()))
if err != nil {
log.Printf("Failed to write string to buffer: %v", err)
return nil
+94 -133
View File
@@ -15,7 +15,7 @@ import (
var blockedReservedPorts = []uint16{1080, 1433, 1521, 1900, 2049, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9000, 9200, 27017}
func (s *SSHSession) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
func (s *session) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
for req := range GlobalRequest {
switch req.Type {
case "shell", "pty-req":
@@ -56,211 +56,172 @@ func (s *SSHSession) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
}
}
func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
func (s *session) HandleTCPIPForward(req *ssh.Request) {
log.Println("Port forwarding request detected")
fail := func(msg string) {
log.Println(msg)
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
return
}
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
}
reader := bytes.NewReader(req.Payload)
addr, err := readSSHString(reader)
if err != nil {
log.Println("Failed to read address from payload:", err)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
fail(fmt.Sprintf("Failed to read address from payload: %v", err))
return
}
var rawPortToBind uint32
if err := binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil {
log.Println("Failed to read port from payload:", err)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
if err = binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil {
fail(fmt.Sprintf("Failed to read port from payload: %v", err))
return
}
if rawPortToBind > 65535 {
log.Printf("Port %d is larger than allowed port of 65535", rawPortToBind)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
fail(fmt.Sprintf("Port %d is larger than allowed port of 65535", rawPortToBind))
return
}
portToBind := uint16(rawPortToBind)
if isBlockedPort(portToBind) {
log.Printf("Port %d is blocked or restricted", portToBind)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
fail(fmt.Sprintf("Port %d is blocked or restricted", portToBind))
return
}
if portToBind == 80 || portToBind == 443 {
switch portToBind {
case 80, 443:
s.HandleHTTPForward(req, portToBind)
return
default:
s.HandleTCPForward(req, addr, portToBind)
}
if portToBind == 0 {
unassign, success := portUtil.Default.GetUnassignedPort()
portToBind = unassign
if !success {
log.Println("No available port")
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
} else if isUse, isExist := portUtil.Default.GetPortStatus(portToBind); isExist && isUse {
log.Printf("Port %d is already in use or restricted", portToBind)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
err = portUtil.Default.SetPortStatus(portToBind, true)
if err != nil {
log.Println("Failed to set port status:", err)
return
}
s.HandleTCPForward(req, addr, portToBind)
}
func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
slug := random.GenerateRandomString(20)
if !s.registry.Register(slug, s) {
log.Printf("Failed to register client with slug: %s", slug)
err := req.Reply(false, nil)
if err != nil {
func (s *session) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
fail := func(msg string, key *types.SessionKey) {
log.Println(msg)
if key != nil {
s.registry.Remove(*key)
}
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
}
}
slug := random.GenerateRandomString(20)
key := types.SessionKey{Id: slug, Type: types.HTTP}
if !s.registry.Register(key, s) {
fail(fmt.Sprintf("Failed to register client with slug: %s", slug), nil)
return
}
buf := new(bytes.Buffer)
err := binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
log.Println("Failed to write port to buffer:", err)
s.registry.Remove(slug)
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
fail(fmt.Sprintf("Failed to write port to buffer: %v", err), &key)
return
}
log.Printf("HTTP forwarding approved on port: %d", portToBind)
err = req.Reply(true, buf.Bytes())
if err != nil {
log.Println("Failed to reply to request:", err)
s.registry.Remove(slug)
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
fail(fmt.Sprintf("Failed to reply to request: %v", err), &key)
return
}
s.forwarder.SetType(types.HTTP)
s.forwarder.SetForwardedPort(portToBind)
s.slugManager.Set(slug)
s.slug.Set(slug)
s.lifecycle.SetStatus(types.RUNNING)
s.interaction.Start()
}
func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) {
log.Printf("Requested forwarding on %s:%d", addr, portToBind)
listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", portToBind))
if err != nil {
log.Printf("Port %d is already in use or restricted", portToBind)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
err = req.Reply(false, nil)
if err != nil {
func (s *session) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) {
fail := func(msg string) {
log.Println(msg)
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
}
cleanup := func(msg string, port uint16, listener net.Listener, key *types.SessionKey) {
log.Println(msg)
if key != nil {
s.registry.Remove(*key)
}
if port != 0 {
if setErr := portUtil.Default.SetPortStatus(port, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
}
if listener != nil {
if closeErr := listener.Close(); closeErr != nil {
log.Printf("Failed to close listener: %v", closeErr)
}
}
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
}
_ = s.lifecycle.Close()
}
if portToBind == 0 {
unassigned, ok := portUtil.Default.GetUnassignedPort()
if !ok {
fail("No available port")
return
}
portToBind = unassigned
}
if claimed := portUtil.Default.ClaimPort(portToBind); !claimed {
fail(fmt.Sprintf("Port %d is already in use or restricted", portToBind))
return
}
log.Printf("Requested forwarding on %s:%d", addr, portToBind)
listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", portToBind))
if err != nil {
cleanup(fmt.Sprintf("Port %d is already in use or restricted", portToBind), portToBind, nil, nil)
return
}
key := types.SessionKey{Id: fmt.Sprintf("%d", portToBind), Type: types.TCP}
if !s.registry.Register(key, s) {
cleanup(fmt.Sprintf("Failed to register TCP client with id: %s", key.Id), portToBind, listener, nil)
return
}
buf := new(bytes.Buffer)
err = binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
log.Println("Failed to write port to buffer:", err)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
err = listener.Close()
if err != nil {
log.Printf("Failed to close listener: %s", err)
return
}
cleanup(fmt.Sprintf("Failed to write port to buffer: %v", err), portToBind, listener, &key)
return
}
log.Printf("TCP forwarding approved on port: %d", portToBind)
err = req.Reply(true, buf.Bytes())
if err != nil {
log.Println("Failed to reply to request:", err)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
err = listener.Close()
if err != nil {
log.Printf("Failed to close listener: %s", err)
return
}
cleanup(fmt.Sprintf("Failed to reply to request: %v", err), portToBind, listener, &key)
return
}
s.forwarder.SetType(types.TCP)
s.forwarder.SetListener(listener)
s.forwarder.SetForwardedPort(portToBind)
s.slug.Set(key.Id)
s.lifecycle.SetStatus(types.RUNNING)
go s.forwarder.AcceptTCPConnections()
s.interaction.Start()
}
func readSSHString(reader *bytes.Reader) (string, error) {
-152
View File
@@ -1,152 +0,0 @@
package interaction
const (
backspaceChar = 8
deleteChar = 127
enterChar = 13
escapeChar = 27
ctrlC = 3
forwardSlash = '/'
minPrintableChar = 32
maxPrintableChar = 126
minSlugLength = 3
maxSlugLength = 20
clearScreen = "\033[H\033[2J"
clearLine = "\033[K"
clearToLineEnd = "\r\033[K"
backspaceSeq = "\b \b"
minBoxWidth = 50
paddingRight = 4
)
var forbiddenSlugs = map[string]struct{}{
"ping": {},
"staging": {},
"admin": {},
"root": {},
"api": {},
"www": {},
"support": {},
"help": {},
"status": {},
"health": {},
"login": {},
"logout": {},
"signup": {},
"register": {},
"settings": {},
"config": {},
"null": {},
"undefined": {},
"example": {},
"test": {},
"dev": {},
"system": {},
"administrator": {},
"dashboard": {},
"account": {},
"profile": {},
"user": {},
"users": {},
"auth": {},
"oauth": {},
"callback": {},
"webhook": {},
"webhooks": {},
"static": {},
"assets": {},
"cdn": {},
"mail": {},
"email": {},
"ftp": {},
"ssh": {},
"git": {},
"svn": {},
"blog": {},
"news": {},
"about": {},
"contact": {},
"terms": {},
"privacy": {},
"legal": {},
"billing": {},
"payment": {},
"checkout": {},
"cart": {},
"shop": {},
"store": {},
"download": {},
"uploads": {},
"images": {},
"img": {},
"css": {},
"js": {},
"fonts": {},
"public": {},
"private": {},
"internal": {},
"external": {},
"proxy": {},
"cache": {},
"debug": {},
"metrics": {},
"monitoring": {},
"graphql": {},
"rest": {},
"rpc": {},
"socket": {},
"ws": {},
"wss": {},
"app": {},
"apps": {},
"mobile": {},
"desktop": {},
"embed": {},
"widget": {},
"docs": {},
"documentation": {},
"wiki": {},
"forum": {},
"community": {},
"feedback": {},
"report": {},
"abuse": {},
"spam": {},
"security": {},
"verify": {},
"confirm": {},
"reset": {},
"password": {},
"recovery": {},
"unsubscribe": {},
"subscribe": {},
"notifications": {},
"alerts": {},
"messages": {},
"inbox": {},
"outbox": {},
"sent": {},
"draft": {},
"trash": {},
"archive": {},
"search": {},
"explore": {},
"discover": {},
"trending": {},
"popular": {},
"featured": {},
"new": {},
"latest": {},
"top": {},
"best": {},
"hot": {},
"random": {},
"all": {},
"any": {},
"none": {},
"true": {},
"false": {},
}
+112 -102
View File
@@ -23,34 +23,59 @@ import (
type Lifecycle interface {
Close() error
User() string
}
type Controller interface {
type SessionRegistry interface {
Update(user string, oldKey, newKey types.SessionKey) error
}
type Interaction interface {
Mode() types.Mode
SetChannel(channel ssh.Channel)
SetLifecycle(lifecycle Lifecycle)
SetSlugModificator(func(oldSlug, newSlug string) bool)
Start()
SetSessionRegistry(registry SessionRegistry)
SetMode(m types.Mode)
SetWH(w, h int)
Start()
Redraw()
Send(message string) error
}
type Forwarder interface {
Close() error
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
TunnelType() types.TunnelType
ForwardedPort() uint16
}
type Interaction struct {
channel ssh.Channel
slugManager slug.Manager
forwarder Forwarder
lifecycle Lifecycle
updateClientSlug func(oldSlug, newSlug string) bool
program *tea.Program
ctx context.Context
cancel context.CancelFunc
type interaction struct {
channel ssh.Channel
slug slug.Slug
forwarder Forwarder
lifecycle Lifecycle
sessionRegistry SessionRegistry
program *tea.Program
ctx context.Context
cancel context.CancelFunc
mode types.Mode
}
func (i *Interaction) SetWH(w, h int) {
func (i *interaction) SetMode(m types.Mode) {
i.mode = m
}
func (i *interaction) Mode() types.Mode {
return i.mode
}
func (i *interaction) Send(message string) error {
if i.channel != nil {
_, err := i.channel.Write([]byte(message))
return err
}
return nil
}
func (i *interaction) SetWH(w, h int) {
if i.program != nil {
i.program.Send(tea.WindowSizeMsg{
Width: w,
@@ -65,7 +90,6 @@ type commandItem struct {
}
type model struct {
tunnelURL string
domain string
protocol string
tunnelType types.TunnelType
@@ -79,11 +103,18 @@ type model struct {
commandList list.Model
slugInput textinput.Model
slugError string
interaction *Interaction
interaction *interaction
width int
height int
}
func (m *model) getTunnelURL() string {
if m.tunnelType == types.HTTP {
return buildURL(m.protocol, m.interaction.slug.String(), m.domain)
}
return fmt.Sprintf("tcp://%s:%d", m.domain, m.port)
}
type keymap struct {
quit key.Binding
command key.Binding
@@ -92,33 +123,33 @@ type keymap struct {
type tickMsg time.Time
func NewInteraction(slugManager slug.Manager, forwarder Forwarder) *Interaction {
func New(slug slug.Slug, forwarder Forwarder) Interaction {
ctx, cancel := context.WithCancel(context.Background())
return &Interaction{
channel: nil,
slugManager: slugManager,
forwarder: forwarder,
lifecycle: nil,
updateClientSlug: nil,
program: nil,
ctx: ctx,
cancel: cancel,
return &interaction{
channel: nil,
slug: slug,
forwarder: forwarder,
lifecycle: nil,
sessionRegistry: nil,
program: nil,
ctx: ctx,
cancel: cancel,
}
}
func (i *Interaction) SetLifecycle(lifecycle Lifecycle) {
func (i *interaction) SetSessionRegistry(registry SessionRegistry) {
i.sessionRegistry = registry
}
func (i *interaction) SetLifecycle(lifecycle Lifecycle) {
i.lifecycle = lifecycle
}
func (i *Interaction) SetChannel(channel ssh.Channel) {
func (i *interaction) SetChannel(channel ssh.Channel) {
i.channel = channel
}
func (i *Interaction) SetSlugModificator(modificator func(oldSlug, newSlug string) (success bool)) {
i.updateClientSlug = modificator
}
func (i *Interaction) Stop() {
func (i *interaction) Stop() {
if i.cancel != nil {
i.cancel()
}
@@ -163,11 +194,11 @@ func tickCmd(d time.Duration) tea.Cmd {
})
}
func (m model) Init() tea.Cmd {
func (m *model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, tea.WindowSize())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
@@ -211,21 +242,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "enter":
inputValue := m.slugInput.Value()
if isForbiddenSlug(inputValue) {
m.slugError = "This subdomain is reserved. Please choose a different one."
return m, nil
} else if !isValidSlug(inputValue) {
m.slugError = "Invalid subdomain. Follow the rules."
if err := m.interaction.sessionRegistry.Update(m.interaction.lifecycle.User(), types.SessionKey{
Id: m.interaction.slug.String(),
Type: types.HTTP,
}, types.SessionKey{
Id: inputValue,
Type: types.HTTP,
}); err != nil {
m.slugError = err.Error()
return m, nil
}
if !m.interaction.updateClientSlug(m.interaction.slugManager.Get(), inputValue) {
m.slugError = "Someone already uses this subdomain."
return m, nil
}
m.tunnelURL = buildURL(m.protocol, inputValue, m.domain)
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
@@ -259,7 +285,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if item.name == "slug" {
m.showingCommands = false
m.editingSlug = true
m.slugInput.SetValue(m.interaction.slugManager.Get())
m.slugInput.SetValue(m.interaction.slug.String())
m.slugInput.Focus()
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
} else if item.name == "tunnel-type" {
@@ -291,14 +317,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) helpView() string {
func (i *interaction) Redraw() {
if i.program != nil {
i.program.Send(tea.ClearScreen())
}
}
func (m *model) helpView() string {
return "\n" + m.help.ShortHelpView([]key.Binding{
m.keymap.command,
m.keymap.quit,
})
}
func (m model) View() string {
func (m *model) View() string {
if m.quitting {
return ""
}
@@ -659,22 +691,32 @@ func (m model) View() string {
MarginBottom(boxMargin).
Width(boxMaxWidth)
urlDisplay := m.tunnelURL
if shouldUseCompactLayout(m.width, 80) && len(m.tunnelURL) > m.width-20 {
maxLen := m.width - 25
if maxLen > 10 {
urlDisplay = truncateString(m.tunnelURL, maxLen)
}
}
authenticatedUser := m.interaction.lifecycle.User()
userInfoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true)
sectionHeaderStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Bold(true)
addressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA"))
var infoContent string
if shouldUseCompactLayout(m.width, 70) {
infoContent = fmt.Sprintf("🌐 %s", urlBoxStyle.Render(urlDisplay))
} else if isCompact {
infoContent = fmt.Sprintf("🌐 Forwarding to:\n\n %s", urlBoxStyle.Render(urlDisplay))
infoContent = fmt.Sprintf("👤 %s\n\n%s\n%s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(fmt.Sprintf(" %s", urlBoxStyle.Render(m.getTunnelURL()))))
} else {
infoContent = fmt.Sprintf("🌐 F O R W A R D I N G T O:\n\n %s", urlBoxStyle.Render(urlDisplay))
infoContent = fmt.Sprintf("👤 Authenticated as: %s\n\n%s\n %s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(urlBoxStyle.Render(m.getTunnelURL())))
}
b.WriteString(responsiveInfoBox.Render(infoContent))
b.WriteString("\n")
@@ -725,7 +767,10 @@ func (m model) View() string {
return b.String()
}
func (i *Interaction) Start() {
func (i *interaction) Start() {
if i.mode == types.HEADLESS {
return
}
lipgloss.SetColorProfile(termenv.TrueColor)
domain := config.Getenv("DOMAIN", "localhost")
@@ -734,15 +779,8 @@ func (i *Interaction) Start() {
protocol = "https"
}
tunnelType := i.forwarder.GetTunnelType()
port := i.forwarder.GetForwardedPort()
var tunnelURL string
if tunnelType == types.HTTP {
tunnelURL = buildURL(protocol, i.slugManager.Get(), domain)
} else {
tunnelURL = fmt.Sprintf("tcp://%s:%d", domain, port)
}
tunnelType := i.forwarder.TunnelType()
port := i.forwarder.ForwardedPort()
items := []list.Item{
commandItem{name: "slug", desc: "Set custom subdomain"},
@@ -764,8 +802,7 @@ func (i *Interaction) Start() {
ti.CharLimit = 20
ti.Width = 50
m := model{
tunnelURL: tunnelURL,
m := &model{
domain: domain,
protocol: protocol,
tunnelType: tunnelType,
@@ -819,30 +856,3 @@ func buildURL(protocol, subdomain, domain string) string {
func generateRandomSubdomain() string {
return random.GenerateRandomString(20)
}
func isValidSlug(slug string) bool {
if len(slug) < minSlugLength || len(slug) > maxSlugLength {
return false
}
if slug[0] == '-' || slug[len(slug)-1] == '-' {
return false
}
for _, c := range slug {
if !isValidSlugChar(byte(c)) {
return false
}
}
return true
}
func isValidSlugChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
}
func isForbiddenSlug(slug string) bool {
_, ok := forbiddenSlugs[slug]
return ok
}
+80 -47
View File
@@ -4,6 +4,8 @@ import (
"errors"
"io"
"net"
"time"
portUtil "tunnel_pls/internal/port"
"tunnel_pls/session/slug"
"tunnel_pls/types"
@@ -13,88 +15,119 @@ import (
type Forwarder interface {
Close() error
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
TunnelType() types.TunnelType
ForwardedPort() uint16
}
type Lifecycle struct {
status types.Status
conn ssh.Conn
channel ssh.Channel
forwarder Forwarder
slugManager slug.Manager
unregisterClient func(slug string)
type SessionRegistry interface {
Remove(key types.SessionKey)
}
func NewLifecycle(conn ssh.Conn, forwarder Forwarder, slugManager slug.Manager) *Lifecycle {
return &Lifecycle{
status: "",
conn: conn,
channel: nil,
forwarder: forwarder,
slugManager: slugManager,
unregisterClient: nil,
type lifecycle struct {
status types.Status
conn ssh.Conn
channel ssh.Channel
forwarder Forwarder
sessionRegistry SessionRegistry
slug slug.Slug
startedAt time.Time
user string
}
func New(conn ssh.Conn, forwarder Forwarder, slugManager slug.Slug, user string) Lifecycle {
return &lifecycle{
status: types.INITIALIZING,
conn: conn,
channel: nil,
forwarder: forwarder,
slug: slugManager,
sessionRegistry: nil,
startedAt: time.Now(),
user: user,
}
}
func (l *Lifecycle) SetUnregisterClient(unregisterClient func(slug string)) {
l.unregisterClient = unregisterClient
func (l *lifecycle) SetSessionRegistry(registry SessionRegistry) {
l.sessionRegistry = registry
}
type SessionLifecycle interface {
Close() error
SetStatus(status types.Status)
GetConnection() ssh.Conn
GetChannel() ssh.Channel
type Lifecycle interface {
Connection() ssh.Conn
Channel() ssh.Channel
User() string
SetChannel(channel ssh.Channel)
SetUnregisterClient(unregisterClient func(slug string))
SetSessionRegistry(registry SessionRegistry)
SetStatus(status types.Status)
IsActive() bool
StartedAt() time.Time
Close() error
}
func (l *Lifecycle) GetChannel() ssh.Channel {
func (l *lifecycle) User() string {
return l.user
}
func (l *lifecycle) Channel() ssh.Channel {
return l.channel
}
func (l *Lifecycle) SetChannel(channel ssh.Channel) {
func (l *lifecycle) SetChannel(channel ssh.Channel) {
l.channel = channel
}
func (l *Lifecycle) GetConnection() ssh.Conn {
func (l *lifecycle) Connection() ssh.Conn {
return l.conn
}
func (l *Lifecycle) SetStatus(status types.Status) {
func (l *lifecycle) SetStatus(status types.Status) {
l.status = status
if status == types.RUNNING && l.startedAt.IsZero() {
l.startedAt = time.Now()
}
}
func (l *Lifecycle) Close() error {
err := l.forwarder.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
func (l *lifecycle) Close() error {
var firstErr error
tunnelType := l.forwarder.TunnelType()
if err := l.forwarder.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
firstErr = err
}
if l.channel != nil {
err := l.channel.Close()
if err != nil && !errors.Is(err, io.EOF) {
return err
if err := l.channel.Close(); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
if firstErr == nil {
firstErr = err
}
}
}
if l.conn != nil {
err := l.conn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
if err := l.conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
if firstErr == nil {
firstErr = err
}
}
}
clientSlug := l.slugManager.Get()
if clientSlug != "" {
l.unregisterClient(clientSlug)
clientSlug := l.slug.String()
key := types.SessionKey{
Id: clientSlug,
Type: tunnelType,
}
l.sessionRegistry.Remove(key)
if l.forwarder.GetTunnelType() == types.TCP {
err := portUtil.Default.SetPortStatus(l.forwarder.GetForwardedPort(), false)
if err != nil {
return err
if tunnelType == types.TCP {
if err := portUtil.Default.SetPortStatus(l.forwarder.ForwardedPort(), false); err != nil && firstErr == nil {
firstErr = err
}
}
return nil
return firstErr
}
func (l *lifecycle) IsActive() bool {
return l.status == types.RUNNING
}
func (l *lifecycle) StartedAt() time.Time {
return l.startedAt
}
+277 -34
View File
@@ -1,66 +1,309 @@
package session
import "sync"
import (
"fmt"
"sync"
"tunnel_pls/types"
)
type Key = types.SessionKey
type Registry interface {
Get(slug string) (session *SSHSession, exist bool)
Update(oldSlug, newSlug string) (success bool)
Register(slug string, session *SSHSession) (success bool)
Remove(slug string)
Get(key Key) (session Session, err error)
GetWithUser(user string, key Key) (session Session, err error)
Update(user string, oldKey, newKey Key) error
Register(key Key, session Session) (success bool)
Remove(key Key)
GetAllSessionFromUser(user string) []Session
}
type registry struct {
mu sync.RWMutex
clients map[string]*SSHSession
mu sync.RWMutex
byUser map[string]map[Key]Session
slugIndex map[Key]string
}
func NewRegistry() Registry {
return &registry{
clients: make(map[string]*SSHSession),
byUser: make(map[string]map[Key]Session),
slugIndex: make(map[Key]string),
}
}
func (r *registry) Get(slug string) (session *SSHSession, exist bool) {
func (r *registry) Get(key Key) (session Session, err error) {
r.mu.RLock()
defer r.mu.RUnlock()
session, exist = r.clients[slug]
return
}
func (r *registry) Update(oldSlug, newSlug string) (success bool) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.clients[newSlug]; exists && newSlug != oldSlug {
return false
}
client, ok := r.clients[oldSlug]
userID, ok := r.slugIndex[key]
if !ok {
return false
return nil, fmt.Errorf("session not found")
}
delete(r.clients, oldSlug)
client.slugManager.Set(newSlug)
r.clients[newSlug] = client
return true
client, ok := r.byUser[userID][key]
if !ok {
return nil, fmt.Errorf("session not found")
}
return client, nil
}
func (r *registry) Register(slug string, session *SSHSession) (success bool) {
func (r *registry) GetWithUser(user string, key Key) (session Session, err error) {
r.mu.RLock()
defer r.mu.RUnlock()
client, ok := r.byUser[user][key]
if !ok {
return nil, fmt.Errorf("session not found")
}
return client, nil
}
func (r *registry) Update(user string, oldKey, newKey Key) error {
if oldKey.Type != newKey.Type {
return fmt.Errorf("tunnel type cannot change")
}
if newKey.Type != types.HTTP {
return fmt.Errorf("non http tunnel cannot change slug")
}
if isForbiddenSlug(newKey.Id) {
return fmt.Errorf("this subdomain is reserved. Please choose a different one")
}
if !isValidSlug(newKey.Id) {
return fmt.Errorf("invalid subdomain. Follow the rules")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.clients[slug]; exists {
return false
if _, exists := r.slugIndex[newKey]; exists && newKey != oldKey {
return fmt.Errorf("someone already uses this subdomain")
}
client, ok := r.byUser[user][oldKey]
if !ok {
return fmt.Errorf("session not found")
}
r.clients[slug] = session
return true
delete(r.byUser[user], oldKey)
delete(r.slugIndex, oldKey)
client.Slug().Set(newKey.Id)
r.slugIndex[newKey] = user
if r.byUser[user] == nil {
r.byUser[user] = make(map[Key]Session)
}
r.byUser[user][newKey] = client
return nil
}
func (r *registry) Remove(slug string) {
func (r *registry) Register(key Key, session Session) (success bool) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.clients, slug)
if _, exists := r.slugIndex[key]; exists {
return false
}
userID := session.Lifecycle().User()
if r.byUser[userID] == nil {
r.byUser[userID] = make(map[Key]Session)
}
r.byUser[userID][key] = session
r.slugIndex[key] = userID
return true
}
func (r *registry) GetAllSessionFromUser(user string) []Session {
r.mu.RLock()
defer r.mu.RUnlock()
m := r.byUser[user]
if len(m) == 0 {
return []Session{}
}
sessions := make([]Session, 0, len(m))
for _, s := range m {
sessions = append(sessions, s)
}
return sessions
}
func (r *registry) Remove(key Key) {
r.mu.Lock()
defer r.mu.Unlock()
userID, ok := r.slugIndex[key]
if !ok {
return
}
delete(r.byUser[userID], key)
if len(r.byUser[userID]) == 0 {
delete(r.byUser, userID)
}
delete(r.slugIndex, key)
}
func isValidSlug(slug string) bool {
if len(slug) < minSlugLength || len(slug) > maxSlugLength {
return false
}
if slug[0] == '-' || slug[len(slug)-1] == '-' {
return false
}
for _, c := range slug {
if !isValidSlugChar(byte(c)) {
return false
}
}
return true
}
func isValidSlugChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
}
func isForbiddenSlug(slug string) bool {
_, ok := forbiddenSlugs[slug]
return ok
}
var forbiddenSlugs = map[string]struct{}{
"ping": {},
"staging": {},
"admin": {},
"root": {},
"api": {},
"www": {},
"support": {},
"help": {},
"status": {},
"health": {},
"login": {},
"logout": {},
"signup": {},
"register": {},
"settings": {},
"config": {},
"null": {},
"undefined": {},
"example": {},
"test": {},
"dev": {},
"system": {},
"administrator": {},
"dashboard": {},
"account": {},
"profile": {},
"user": {},
"users": {},
"auth": {},
"oauth": {},
"callback": {},
"webhook": {},
"webhooks": {},
"static": {},
"assets": {},
"cdn": {},
"mail": {},
"email": {},
"ftp": {},
"ssh": {},
"git": {},
"svn": {},
"blog": {},
"news": {},
"about": {},
"contact": {},
"terms": {},
"privacy": {},
"legal": {},
"billing": {},
"payment": {},
"checkout": {},
"cart": {},
"shop": {},
"store": {},
"download": {},
"uploads": {},
"images": {},
"img": {},
"css": {},
"js": {},
"fonts": {},
"public": {},
"private": {},
"internal": {},
"external": {},
"proxy": {},
"cache": {},
"debug": {},
"metrics": {},
"monitoring": {},
"graphql": {},
"rest": {},
"rpc": {},
"socket": {},
"ws": {},
"wss": {},
"app": {},
"apps": {},
"mobile": {},
"desktop": {},
"embed": {},
"widget": {},
"docs": {},
"documentation": {},
"wiki": {},
"forum": {},
"community": {},
"feedback": {},
"report": {},
"abuse": {},
"spam": {},
"security": {},
"verify": {},
"confirm": {},
"reset": {},
"password": {},
"recovery": {},
"unsubscribe": {},
"subscribe": {},
"notifications": {},
"alerts": {},
"messages": {},
"inbox": {},
"outbox": {},
"sent": {},
"draft": {},
"trash": {},
"archive": {},
"search": {},
"explore": {},
"discover": {},
"trending": {},
"popular": {},
"featured": {},
"new": {},
"latest": {},
"top": {},
"best": {},
"hot": {},
"random": {},
"all": {},
"any": {},
"none": {},
"true": {},
"false": {},
}
var (
minSlugLength = 3
maxSlugLength = 20
)
+103 -45
View File
@@ -9,91 +9,149 @@ import (
"tunnel_pls/session/interaction"
"tunnel_pls/session/lifecycle"
"tunnel_pls/session/slug"
"tunnel_pls/types"
"golang.org/x/crypto/ssh"
)
type Detail struct {
ForwardingType string `json:"forwarding_type,omitempty"`
Slug string `json:"slug,omitempty"`
UserID string `json:"user_id,omitempty"`
Active bool `json:"active,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
}
type Session interface {
HandleGlobalRequest(ch <-chan *ssh.Request)
HandleTCPIPForward(req *ssh.Request)
HandleHTTPForward(req *ssh.Request, port uint16)
HandleTCPForward(req *ssh.Request, addr string, port uint16)
Lifecycle() lifecycle.Lifecycle
Interaction() interaction.Interaction
Forwarder() forwarder.Forwarder
Slug() slug.Slug
Detail() *Detail
Start() error
}
type SSHSession struct {
initialReq <-chan *ssh.Request
sshReqChannel <-chan ssh.NewChannel
lifecycle lifecycle.SessionLifecycle
interaction interaction.Controller
forwarder forwarder.ForwardingController
slugManager slug.Manager
registry Registry
type session struct {
initialReq <-chan *ssh.Request
sshChan <-chan ssh.NewChannel
lifecycle lifecycle.Lifecycle
interaction interaction.Interaction
forwarder forwarder.Forwarder
slug slug.Slug
registry Registry
}
func (s *SSHSession) GetLifecycle() lifecycle.SessionLifecycle {
func New(conn *ssh.ServerConn, initialReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel, sessionRegistry Registry, user string) Session {
slugManager := slug.New()
forwarderManager := forwarder.New(slugManager)
interactionManager := interaction.New(slugManager, forwarderManager)
lifecycleManager := lifecycle.New(conn, forwarderManager, slugManager, user)
interactionManager.SetLifecycle(lifecycleManager)
forwarderManager.SetLifecycle(lifecycleManager)
interactionManager.SetSessionRegistry(sessionRegistry)
lifecycleManager.SetSessionRegistry(sessionRegistry)
return &session{
initialReq: initialReq,
sshChan: sshChan,
lifecycle: lifecycleManager,
interaction: interactionManager,
forwarder: forwarderManager,
slug: slugManager,
registry: sessionRegistry,
}
}
func (s *session) Lifecycle() lifecycle.Lifecycle {
return s.lifecycle
}
func (s *SSHSession) GetInteraction() interaction.Controller {
func (s *session) Interaction() interaction.Interaction {
return s.interaction
}
func (s *SSHSession) GetForwarder() forwarder.ForwardingController {
func (s *session) Forwarder() forwarder.Forwarder {
return s.forwarder
}
func (s *SSHSession) GetSlugManager() slug.Manager {
return s.slugManager
func (s *session) Slug() slug.Slug {
return s.slug
}
func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel, sessionRegistry Registry) *SSHSession {
slugManager := slug.NewManager()
forwarderManager := forwarder.NewForwarder(slugManager)
interactionManager := interaction.NewInteraction(slugManager, forwarderManager)
lifecycleManager := lifecycle.NewLifecycle(conn, forwarderManager, slugManager)
interactionManager.SetLifecycle(lifecycleManager)
interactionManager.SetSlugModificator(sessionRegistry.Update)
forwarderManager.SetLifecycle(lifecycleManager)
lifecycleManager.SetUnregisterClient(sessionRegistry.Remove)
return &SSHSession{
initialReq: forwardingReq,
sshReqChannel: sshChan,
lifecycle: lifecycleManager,
interaction: interactionManager,
forwarder: forwarderManager,
slugManager: slugManager,
registry: sessionRegistry,
func (s *session) Detail() *Detail {
var tunnelType string
if s.forwarder.TunnelType() == types.HTTP {
tunnelType = "HTTP"
} else if s.forwarder.TunnelType() == types.TCP {
tunnelType = "TCP"
} else {
tunnelType = "UNKNOWN"
}
return &Detail{
ForwardingType: tunnelType,
Slug: s.slug.String(),
UserID: s.lifecycle.User(),
Active: s.lifecycle.IsActive(),
StartedAt: s.lifecycle.StartedAt(),
}
}
func (s *SSHSession) Start() error {
channel := <-s.sshReqChannel
ch, reqs, err := channel.Accept()
if err != nil {
log.Printf("failed to accept channel: %v", err)
return err
func (s *session) Start() error {
var channel ssh.NewChannel
var ok bool
select {
case channel, ok = <-s.sshChan:
if !ok {
log.Println("Forwarding request channel closed")
return nil
}
ch, reqs, err := channel.Accept()
if err != nil {
log.Printf("failed to accept channel: %v", err)
return err
}
go s.HandleGlobalRequest(reqs)
s.lifecycle.SetChannel(ch)
s.interaction.SetChannel(ch)
s.interaction.SetMode(types.INTERACTIVE)
case <-time.After(500 * time.Millisecond):
s.interaction.SetMode(types.HEADLESS)
}
go s.HandleGlobalRequest(reqs)
tcpipReq := s.waitForTCPIPForward()
if tcpipReq == nil {
_, err := ch.Write([]byte(fmt.Sprintf("Port forwarding request not received. Ensure you ran the correct command with -R flag. Example: ssh %s -p %s -R 80:localhost:3000", config.Getenv("DOMAIN", "localhost"), config.Getenv("PORT", "2200"))))
err := s.interaction.Send(fmt.Sprintf("Port forwarding request not received. Ensure you ran the correct command with -R flag. Example: ssh %s -p %s -R 80:localhost:3000", config.Getenv("DOMAIN", "localhost"), config.Getenv("PORT", "2200")))
if err != nil {
return err
}
if err = s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
return fmt.Errorf("no forwarding Request")
}
if (s.interaction.Mode() == types.HEADLESS && config.Getenv("MODE", "standalone") == "standalone") && s.lifecycle.User() == "UNAUTHORIZED" {
if err := tcpipReq.Reply(false, nil); err != nil {
log.Printf("cannot reply to tcpip req: %s\n", err)
return err
}
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
return err
}
return fmt.Errorf("No forwarding Request")
return nil
}
s.lifecycle.SetChannel(ch)
s.interaction.SetChannel(ch)
s.HandleTCPIPForward(tcpipReq)
s.interaction.Start()
s.lifecycle.Connection().Wait()
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
return err
@@ -101,7 +159,7 @@ func (s *SSHSession) Start() error {
return nil
}
func (s *SSHSession) waitForTCPIPForward() *ssh.Request {
func (s *session) waitForTCPIPForward() *ssh.Request {
select {
case req, ok := <-s.initialReq:
if !ok {
+7 -7
View File
@@ -1,24 +1,24 @@
package slug
type Manager interface {
Get() string
type Slug interface {
String() string
Set(slug string)
}
type manager struct {
type slug struct {
slug string
}
func NewManager() Manager {
return &manager{
func New() Slug {
return &slug{
slug: "",
}
}
func (s *manager) Get() string {
func (s *slug) String() string {
return s.slug
}
func (s *manager) Set(slug string) {
func (s *slug) Set(slug string) {
s.slug = slug
}
+19 -7
View File
@@ -1,20 +1,32 @@
package types
type Status string
type Status int
const (
INITIALIZING Status = "INITIALIZING"
RUNNING Status = "RUNNING"
SETUP Status = "SETUP"
INITIALIZING Status = iota
RUNNING
)
type TunnelType string
type Mode int
const (
HTTP TunnelType = "HTTP"
TCP TunnelType = "TCP"
INTERACTIVE Mode = iota
HEADLESS
)
type TunnelType int
const (
UNKNOWN TunnelType = iota
HTTP
TCP
)
type SessionKey struct {
Id string
Type TunnelType
}
var BadGatewayResponse = []byte("HTTP/1.1 502 Bad Gateway\r\n" +
"Content-Length: 11\r\n" +
"Content-Type: text/plain\r\n\r\n" +