114 Commits

Author SHA1 Message Date
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
All checks were successful
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
All checks were successful
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
Some checks failed
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
Some checks are pending
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
All checks were successful
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 2026-01-08 13:03:02 +07:00
6b4127f0ef feat: add authenticated user info and restructure handleConnection
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
All checks were successful
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
Some checks failed
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
Some checks failed
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
2e8767f17a chore: upgrade TLS configuration to TLS 1.3
Some checks failed
renovate / renovate (push) Successful in 1m34s
Docker Build and Push / build-and-push-tags (push) Successful in 6m4s
Docker Build and Push / build-and-push-branches (push) Failing after 14m58s
2026-01-01 00:57:48 +07:00
7716eb7f29 perf: optimize header parsing with zero-copy ReadSlice
All checks were successful
renovate / renovate (push) Successful in 35s
Docker Build and Push / build-and-push-branches (push) Successful in 4m39s
Docker Build and Push / build-and-push-tags (push) Successful in 4m52s
- Replace ReadString with ReadSlice to eliminate allocations
- Use bytes operations instead of strings
- Add FromBytes variant for in-memory parsing
2025-12-31 23:18:53 +07:00
b115369913 fix: wait for both goroutines before cleanup in HandleConnection
All checks were successful
renovate / renovate (push) Successful in 1m42s
Docker Build and Push / build-and-push-branches (push) Successful in 4m46s
Docker Build and Push / build-and-push-tags (push) Successful in 4m51s
Only waited for one of two copy goroutines, leaking the second. Now waits
for both to complete before closing connections.

Fixes file descriptor leak causing 'too many open files' under load.

Fixes: #56
2025-12-31 22:22:51 +07:00
9276430fae refactor(session): add registry to manage SSH sessions
All checks were successful
renovate / renovate (push) Successful in 36s
Docker Build and Push / build-and-push-branches (push) Successful in 4m41s
Docker Build and Push / build-and-push-tags (push) Successful in 4m38s
- Implement thread-safe session registry with sync.RWMutex
- Add Registry interface for session management operations
- Support Get, Register, Update, and Remove session operations
- Enable dynamic slug updates for existing sessions
- Fix Connection closed by remote because HandleTCPIPForward run on a goroutine
2025-12-31 18:33:47 +07:00
f8a6f0bafe refactor(session): add registry to manage SSH sessions
All checks were successful
renovate / renovate (push) Successful in 39s
Docker Build and Push / build-and-push-branches (push) Successful in 4m27s
Docker Build and Push / build-and-push-tags (push) Successful in 4m22s
- Implement thread-safe session registry with sync.RWMutex
- Add Registry interface for session management operations
- Support Get, Register, Update, and Remove session operations
- Enable dynamic slug updates for existing sessions
2025-12-31 17:47:35 +07:00
8a456d2cde Merge pull request 'staging' (#55) from staging into main
All checks were successful
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
acd02aadd3 refactor: restructure project architecture
All checks were successful
renovate / renovate (push) Successful in 45s
Docker Build and Push / build-and-push-branches (push) Successful in 5m54s
Docker Build and Push / build-and-push-tags (push) Successful in 6m21s
2025-12-31 15:49:37 +07:00
878664e0ac update: multi version build
All checks were successful
renovate / renovate (push) Successful in 35s
Docker Build and Push / build-and-push-branches (push) Successful in 6m7s
Docker Build and Push / build-and-push-tags (push) Successful in 6m6s
2025-12-31 13:48:36 +07:00
20a88df330 update: multi version build
All checks were successful
Docker Build and Push / build-and-push-tags (push) Has been skipped
renovate / renovate (push) Successful in 38s
Docker Build and Push / build-and-push-branches (push) Successful in 4m45s
2025-12-31 13:32:16 +07:00
075dd7ecad feat: add versioning system
Some checks failed
renovate / renovate (push) Successful in 38s
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Has been cancelled
2025-12-31 12:31:31 +07:00
8841230653 Merge pull request 'fix: prevent subdomain change to already-in-use subdomains' (#54) from staging into main
All checks were successful
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
ab34b34765 fix: prevent subdomain change to already-in-use subdomains
All checks were successful
renovate / renovate (push) Successful in 35s
Docker Build and Push / build-and-push (push) Successful in 5m42s
2025-12-30 19:41:33 +07:00
4d0a7deaf2 Merge pull request 'staging' (#53) from staging into main
All checks were successful
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
514c4f9de1 Merge branch 'staging' of https://git.fossy.my.id/bagas/tunnel-please into staging
All checks were successful
renovate / renovate (push) Successful in 20s
Docker Build and Push / build-and-push (push) Successful in 3m35s
2025-12-30 00:09:36 +07:00
d8330c684f feat: make SSH interaction UI fully responsive 2025-12-30 00:09:18 +07:00
fbf182025b Merge pull request 'main' (#52) from main into staging
All checks were successful
renovate / renovate (push) Successful in 21s
Reviewed-on: #52
2025-12-29 14:58:10 +00:00
1038c0861e Merge pull request 'chore(config): migrate Renovate config' (#51) from renovate/migrate-config into main
Reviewed-on: #51
2025-12-29 14:57:45 +00:00
64e0d5805e chore(config): migrate config renovate.json 2025-12-29 14:57:09 +00:00
85f21e7698 feat(tui): update interaction layer to Bubble Tea TUI
All checks were successful
renovate / renovate (push) Successful in 27s
Docker Build and Push / build-and-push (push) Successful in 3m49s
2025-12-29 21:55:39 +07:00
08565d845f Merge pull request 'staging' (#50) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m26s
Reviewed-on: #50
2025-12-29 10:17:00 +00:00
a7d9b2ab8a update: renovate target pr to staging branch
All checks were successful
renovate / renovate (push) Successful in 20s
2025-12-29 17:15:42 +07:00
bc8c5127a6 Merge pull request 'main' (#49) from main into staging
All checks were successful
renovate / renovate (push) Successful in 23s
Docker Build and Push / build-and-push (push) Successful in 3m26s
Reviewed-on: #49
2025-12-29 10:14:30 +00:00
a49b53e56f Merge pull request 'chore(deps): update actions/checkout action to v6' (#48) from renovate/actions-checkout-6.x into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 1m23s
Reviewed-on: #48
2025-12-29 10:04:10 +00:00
e5b5cc3ae5 chore(deps): update actions/checkout action to v6 2025-12-29 10:03:19 +00:00
b0b00764cf Update .gitea/workflows/renovate.yml
All checks were successful
renovate / renovate (push) Successful in 23s
2025-12-29 10:02:56 +00:00
8b6cdef2e9 Merge pull request 'chore(config): migrate Renovate config' (#47) from renovate/migrate-config into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 1m25s
Reviewed-on: #47
2025-12-29 09:49:10 +00:00
653517f5be chore(config): migrate config renovate.json 2025-12-29 09:48:12 +00:00
f11a92fb3b ci: configure workflow to ignore non-Go file changes
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m30s
renovate / renovate (push) Successful in 22s
2025-12-29 15:58:24 +07:00
ac283626d3 docs: add Docker deployment section 2025-12-29 15:57:31 +07:00
ad7c5985b1 feat: add docker compose deployment configurations 2025-12-29 15:57:16 +07:00
2644b4521c refactor: improve encapsulation
All checks were successful
renovate / renovate (push) Successful in 20s
Docker Build and Push / build-and-push (push) Successful in 3m25s
2025-12-29 12:37:03 +07:00
d23ed27a4a Merge pull request 'staging' (#46) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 1m21s
renovate / renovate (push) Successful in 21s
Reviewed-on: #46
2025-12-28 13:19:25 +00:00
b5862bd7a0 feat: add configurable HTTP port
All checks were successful
renovate / renovate (push) Successful in 20s
Docker Build and Push / build-and-push (push) Successful in 1m45s
2025-12-28 20:09:31 +07:00
bf7f7bd8da feat: add configurable HTTPS port
All checks were successful
renovate / renovate (push) Successful in 19s
Docker Build and Push / build-and-push (push) Successful in 1m24s
2025-12-28 20:03:49 +07:00
c3a469be64 refactor: use relative paths for certificates instead of absolute paths
All checks were successful
renovate / renovate (push) Successful in 19s
Docker Build and Push / build-and-push (push) Successful in 1m32s
2025-12-28 19:53:03 +07:00
eee04daf80 feat: optimize Docker build for production
All checks were successful
renovate / renovate (push) Successful in 19s
Docker Build and Push / build-and-push (push) Successful in 1m30s
2025-12-28 19:29:32 +07:00
1d918ef2aa feat(port): disable TCP forwarding by default and refactor port manager
All checks were successful
renovate / renovate (push) Successful in 28s
Docker Build and Push / build-and-push (push) Successful in 5m27s
2025-12-28 19:03:26 +07:00
a2676a4f30 Merge branch 'staging' of https://git.fossy.my.id/bagas/tunnel-please into staging
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m36s
renovate / renovate (push) Successful in 20s
2025-12-28 18:45:46 +07:00
83657d3206 refactor: remove unnecessary caching of environment data 2025-12-28 18:45:22 +07:00
6710aec4bf Merge pull request 'main' (#45) from main into staging
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m33s
renovate / renovate (push) Successful in 19s
Reviewed-on: #45
2025-12-28 08:08:58 +00:00
0ca6285ef5 Update .gitea/workflows/renovate.yml
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m32s
2025-12-28 08:08:10 +00:00
28cc069fdb Merge pull request 'chore(deps): update golang docker tag to v1.25.5' (#42) from renovate/golang-1.x into main
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
renovate / renovate (push) Successful in 21s
Reviewed-on: #42
2025-12-28 08:07:44 +00:00
fe58e35e91 Merge pull request 'fix(deps): update module golang.org/x/crypto to v0.46.0' (#43) from renovate/golang.org-x-crypto-0.x into main
Some checks failed
renovate / renovate (push) Has been cancelled
Docker Build and Push / build-and-push (push) Has been cancelled
Reviewed-on: #43
2025-12-28 08:07:31 +00:00
6cac64412c fix(deps): update module golang.org/x/crypto to v0.46.0 2025-12-28 08:01:56 +00:00
318003ac9f chore(deps): update golang docker tag to v1.25.5 2025-12-28 08:01:36 +00:00
14fa237027 Merge pull request 'staging' (#41) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m32s
renovate / renovate (push) Successful in 45s
Reviewed-on: #41
2025-12-28 07:55:00 +00:00
9a2a373eb3 feat: add renovate
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m3s
2025-12-28 14:51:00 +07:00
1b248a2957 Merge pull request 'feat/sync' (#39) from feat/sync into staging
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m30s
Reviewed-on: #39
2025-12-28 06:28:02 +00:00
7348bdafb7 Delete test 2025-12-28 06:24:40 +00:00
cb8529f13e Update test 2025-12-28 06:20:21 +00:00
fa6b097d66 Add test file 2025-12-28 13:19:45 +07:00
e3c4f59a77 Add GitHub to Gitea sync workflow 2025-12-28 13:15:05 +07:00
c69cd68820 feat: add certmagic for automatic TLS certificate management
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m28s
2025-12-26 23:44:50 +07:00
76d1202b8e fix: correct logic when checking tcpip-forward request
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 5m34s
2025-12-26 23:17:13 +07:00
6dff735216 fix: prevent OOM by bounding io.Copy buffer usage
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m47s
2025-12-18 21:09:12 +07:00
7bc5a01ba7 feat: add pprof for debuging
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m51s
2025-12-18 18:30:49 +07:00
2a43f1441c Merge pull request 'fix: potential resource leak' (#38) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m27s
Reviewed-on: #38
2025-12-17 14:38:40 +00:00
6451304ed7 fix: potential resource leak
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m17s
2025-12-17 21:38:00 +07:00
5c6826fe89 Merge pull request 'fix: add nil check in Forwarder.Close to avoid panic (issue #36)' (#37) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m9s
Reviewed-on: #37
2025-12-12 08:36:51 +00:00
2725975d82 fix: add nil check in Forwarder.Close to avoid panic (issue #36)
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m8s
2025-12-12 15:36:07 +07:00
b484981017 Merge pull request 'staging' (#35) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m22s
Reviewed-on: #35
2025-12-09 16:40:44 +00:00
102c975388 Merge branch 'staging' of https://git.fossy.my.id/bagas/tunnel-please into staging
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m17s
2025-12-09 23:40:06 +07:00
ad034ef681 fix: resolve random SSH disconnections caused by 'unexpected bytes remain' errors 2025-12-09 23:39:22 +07:00
aceecfd14c Merge pull request 'Update LICENSE.md' (#34) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m44s
Reviewed-on: #34
2025-12-08 16:03:33 +00:00
a2a688fc4e Update LICENSE.md
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m51s
2025-12-08 16:02:51 +00:00
1de7155771 Merge pull request 'fix: prevent race condition when channel is used before being set' (#33) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 3m46s
Reviewed-on: #33
2025-12-08 15:47:01 +00:00
c951c41a9b fix: prevent race condition when channel is used before being set
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m24s
2025-12-08 22:46:10 +07:00
79d77497a0 Merge pull request 'update: readme' (#32) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m22s
Reviewed-on: #32
2025-12-08 14:13:23 +00:00
cb08bb7673 update: readme
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m24s
2025-12-08 21:13:16 +07:00
20b90c1727 Merge pull request 'Update .gitea/workflows/build.yml' (#31) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 5m9s
Reviewed-on: #31
2025-12-08 13:37:07 +00:00
5d9f7aee92 Update .gitea/workflows/build.yml
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 5m26s
2025-12-08 13:35:46 +00:00
a1e920f6b5 Merge pull request 'refactor: remove magic numbers and replace with named constants' (#30) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m10s
Reviewed-on: bagas/tunnl_please#30
2025-12-08 12:46:56 +00:00
dd96c8fe75 refactor: remove magic numbers and replace with named constants
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m20s
2025-12-08 19:46:04 +07:00
b5045409cb Merge pull request 'feat: add droping conn command' (#29) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m30s
Reviewed-on: bagas/tunnl_please#29
2025-12-07 09:30:47 +00:00
ba5f702e36 feat: add droping conn command
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m38s
2025-12-07 15:26:37 +07:00
33e6ad08d7 Merge pull request 'staging' (#28) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m0s
Reviewed-on: bagas/tunnl_please#28
2025-12-06 16:47:20 +00:00
8c8fdf251d refactor: remove error shadowing
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m13s
2025-12-06 23:47:02 +07:00
03d3c8e4fb feat: make interaction more interactive 2025-12-06 23:46:09 +07:00
fd513d7bc9 Merge pull request 'refactor: instantiate new session object once forwarding is approved' (#27) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m2s
Reviewed-on: bagas/tunnl_please#27
2025-12-06 16:14:22 +00:00
9bd2bead9e refactor: instantiate new session object once forwarding is approved
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 6m6s
2025-12-06 23:14:13 +07:00
73e7df6a3b Merge pull request 'fix: correct read/write handling in CustomWriter' (#26) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 5m14s
Reviewed-on: bagas/tunnl_please#26
2025-12-06 15:20:11 +00:00
69c3e78728 fix: correct read/write handling in CustomWriter
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 5m7s
2025-12-06 22:18:43 +07:00
d2c5b2a4db Merge pull request 'staging' (#25) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m21s
Reviewed-on: bagas/tunnl_please#25
2025-12-05 17:09:59 +00:00
cad22cd25a Merge pull request 'refactor: change to gitea action' (#21) from staging into main
All checks were successful
Docker Build and Push / build-and-push (push) Successful in 4m19s
Reviewed-on: bagas/tunnl_please#21
2025-12-03 14:21:50 +00:00
0cb02f5220 Merge pull request 'refactor: remove profiling' (#20) from staging into main
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Reviewed-on: bagas/tunnl_please#20
2025-12-03 14:20:52 +00:00
7bee2f2c9b Merge pull request 'staging' (#19) from staging into main
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 2s
Reviewed-on: bagas/tunnl_please#19
2025-12-03 14:18:20 +00:00
54069ad305 Merge pull request 'fix: http forwarding stuck' (#9) from staging into main
Some checks failed
Docker Build and Push / build-and-push (push) Failing after 3s
Reviewed-on: bagas/tunnl_please#9
2025-11-28 12:36:00 +00:00
5bf618aa32 Merge pull request 'fix: logic error in port checking' (#8) from staging into main
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Reviewed-on: bagas/tunnl_please#8
2025-11-28 10:34:28 +00:00
34041a9fe6 Merge pull request 'refactor: handle error' (#7) from staging into main
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Reviewed-on: bagas/tunnl_please#7
2025-11-28 09:57:47 +00:00
c6d64aff3a Merge pull request 'refactor: separate session responsibilities and inject dependencies' (#6) from staging into main
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Reviewed-on: bagas/tunnl_please#6
2025-11-28 06:30:09 +00:00
2e5a4e0b71 Merge pull request 'fix: close connection on TCP/IP request timeout' (#5) from staging into main
Some checks failed
Docker Build and Push / build-and-push (push) Has been cancelled
Reviewed-on: bagas/tunnl_please#5
2025-11-27 16:04:55 +00:00
41 changed files with 3901 additions and 1517 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
.git/
.github/
.gitea/
.gitignore
.idea/
*.swp
*.swo
*~
tmp/
*.log
.env
.env.*
certs/
id_rsa*
*.pub
README.md
LICENSE.md
*.md
renovate.json
renovate-config.js
*_test.go
testdata/
app

View File

@@ -5,14 +5,25 @@ on:
branches:
- main
- staging
tags:
- 'v*'
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- 'Dockerfile'
- 'Dockerfile.*'
- '.dockerignore'
- '.gitea/workflows/build.yml'
jobs:
build-and-push:
build-and-push-branches:
runs-on: ubuntu-latest
if: github.ref_type == 'branch'
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -24,14 +35,29 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set version variables
id: vars
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "VERSION=dev-main" >> $GITHUB_OUTPUT
else
echo "VERSION=dev-staging" >> $GITHUB_OUTPUT
fi
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
echo "COMMIT=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Build and push Docker image for main
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnl_please:latest
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:latest
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.vars.outputs.VERSION }}
BUILD_DATE=${{ steps.vars.outputs.BUILD_DATE }}
COMMIT=${{ steps.vars.outputs.COMMIT }}
if: github.ref == 'refs/heads/main'
- name: Build and push Docker image for staging
@@ -40,6 +66,87 @@ jobs:
context: .
push: true
tags: |
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnl_please:staging
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:staging
platforms: linux/amd64,linux/arm64
if: github.ref == 'refs/heads/staging'
build-args: |
VERSION=${{ steps.vars.outputs.VERSION }}
BUILD_DATE=${{ steps.vars.outputs.BUILD_DATE }}
COMMIT=${{ steps.vars.outputs.COMMIT }}
if: github.ref == 'refs/heads/staging'
build-and-push-tags:
runs-on: ubuntu-latest
if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: git.fossy.my.id
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract version and determine release type
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
echo "COMMIT=${{ github.sha }}" >> $GITHUB_OUTPUT
if echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
echo "MAJOR=$MAJOR" >> $GITHUB_OUTPUT
echo "MINOR=$MINOR" >> $GITHUB_OUTPUT
if echo "$VERSION" | grep -q '-'; then
echo "IS_PRERELEASE=true" >> $GITHUB_OUTPUT
echo "ADDITIONAL_TAG=staging" >> $GITHUB_OUTPUT
else
echo "IS_PRERELEASE=false" >> $GITHUB_OUTPUT
echo "ADDITIONAL_TAG=latest" >> $GITHUB_OUTPUT
fi
else
echo "Invalid version format: $VERSION"
exit 1
fi
- name: Build and push Docker image for release
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:v${{ steps.version.outputs.VERSION }}
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:v${{ steps.version.outputs.MAJOR }}.${{ steps.version.outputs.MINOR }}
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:v${{ steps.version.outputs.MAJOR }}
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:${{ steps.version.outputs.ADDITIONAL_TAG }}
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.version.outputs.VERSION }}
BUILD_DATE=${{ steps.version.outputs.BUILD_DATE }}
COMMIT=${{ steps.version.outputs.COMMIT }}
if: steps.version.outputs.IS_PRERELEASE == 'false'
- name: Build and push Docker image for pre-release
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:v${{ steps.version.outputs.VERSION }}
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:${{ steps.version.outputs.ADDITIONAL_TAG }}
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.version.outputs.VERSION }}
BUILD_DATE=${{ steps.version.outputs.BUILD_DATE }}
COMMIT=${{ steps.version.outputs.COMMIT }}
if: steps.version.outputs.IS_PRERELEASE == 'true'

27
.github/workflows/sync-to-gitea.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Sync to Gitea
on:
push:
branches:
- '**'
jobs:
sync:
runs-on: ubuntu-latest
if: |
github.repository == 'fossyy/tunnel-please' &&
!contains(github.event.head_commit.message, '[skip-gitea]')
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to Gitea
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: git.fossy.my.id
run: |
git remote add gitea https://${{ secrets.GITEA_USERNAME }}:${GITEA_TOKEN}@${GITEA_URL}/bagas/tunnel-please.git
git push gitea ${{ github.ref }} --force

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ id_rsa*
.idea
.env
tmp
certs
app

View File

@@ -1,20 +1,59 @@
FROM golang:1.24.4-alpine AS go_builder
FROM golang:1.25.5-alpine AS go_builder
ARG VERSION=dev
ARG BUILD_DATE=unknown
ARG COMMIT=unknown
RUN apk update && apk upgrade && \
apk add --no-cache ca-certificates tzdata git && \
update-ca-certificates
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download && go mod verify
COPY . .
RUN apk update && apk upgrade && apk add --no-cache ca-certificates tzdata
RUN update-ca-certificates
RUN go build -o ./tmp/main
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux \
go build -trimpath \
-ldflags="-w -s -X tunnel_pls/version.Version=${VERSION} -X tunnel_pls/version.BuildDate=${BUILD_DATE} -X tunnel_pls/version.Commit=${COMMIT}" \
-o /app/tunnel_pls \
.
RUN adduser -D -u 10001 -g '' appuser && \
mkdir -p /app/certs/ssh /app/certs/tls && \
chown -R appuser:appuser /app
FROM scratch
WORKDIR /src
ARG VERSION=dev
ARG BUILD_DATE=unknown
ARG COMMIT=unknown
COPY --from=go_builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=go_builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=go_builder /src/tmp/main /src
COPY --from=go_builder /etc/passwd /etc/passwd
COPY --from=go_builder /etc/group /etc/group
COPY --from=go_builder --chown=appuser:appuser /app /app
ENV TZ Asia/Jakarta
WORKDIR /app
ENTRYPOINT ["./main"]
USER appuser
ENV TZ=Asia/Jakarta
EXPOSE 2200 8080 8443
LABEL org.opencontainers.image.title="Tunnel Please" \
org.opencontainers.image.description="SSH-based tunnel server" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${COMMIT}" \
org.opencontainers.image.created="${BUILD_DATE}"
ENTRYPOINT ["/app/tunnel_pls"]

View File

@@ -1,157 +1,157 @@
# Attribution-NonCommercial-NoDerivatives 4.0 International
> *Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.*
>
> ### Using Creative Commons Public Licenses
>
> Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
>
> * __Considerations for licensors:__ Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors).
>
> * __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensors permission is not necessary for any reasonfor example, because of any applicable exception or limitation to copyrightthen that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).
## Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
### Section 1 Definitions.
a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
h. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
i. __NonCommercial__ means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
j. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
k. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
l. __You__ means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
### Section 2 Scope.
a. ___License grant.___
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
B. produce and reproduce, but not Share, Adapted Material for NonCommercial purposes only.
2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. __Term.__ The term of this Public License is specified in Section 6(a).
4. __Media and formats; technical modifications allowed.__ The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. __Downstream recipients.__
A. __Offer from the Licensor Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. ___Other rights.___
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes.
### Section 3 License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. ___Attribution.___
1. If You Share the Licensed Material, You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
For the avoidance of doubt, You do not have permission under this Public License to Share Adapted Material.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
### Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only and provided You do not Share Adapted Material;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
### Section 5 Disclaimer of Warranties and Limitation of Liability.
a. __Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.__
b. __To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.__
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
### Section 6 Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
### Section 7 Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
### Section 8 Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
> Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
>
# Attribution-NonCommercial-NoDerivatives 4.0 International
> *Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.*
>
> ### Using Creative Commons Public Licenses
>
> Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
>
> * __Considerations for licensors:__ Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors).
>
> * __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensors permission is not necessary for any reasonfor example, because of any applicable exception or limitation to copyrightthen that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).
## Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
### Section 1 Definitions.
a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
h. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
i. __NonCommercial__ means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
j. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
k. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
l. __You__ means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
### Section 2 Scope.
a. ___License grant.___
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
B. produce and reproduce, but not Share, Adapted Material for NonCommercial purposes only.
2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. __Term.__ The term of this Public License is specified in Section 6(a).
4. __Media and formats; technical modifications allowed.__ The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. __Downstream recipients.__
A. __Offer from the Licensor Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. ___Other rights.___
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes.
### Section 3 License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. ___Attribution.___
1. If You Share the Licensed Material, You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
For the avoidance of doubt, You do not have permission under this Public License to Share Adapted Material.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
### Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only and provided You do not Share Adapted Material;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
### Section 5 Disclaimer of Warranties and Limitation of Liability.
a. __Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.__
b. __To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.__
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
### Section 6 Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
### Section 7 Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
### Section 8 Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
> Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
>
> Creative Commons may be contacted at [creativecommons.org](http://creativecommons.org).

265
README.md
View File

@@ -1,93 +1,244 @@
# tunnel_pls
# Tunnel Please
A lightweight SSH-based tunnel server written in Go that enables secure TCP and HTTP forwarding with an interactive terminal interface for managing connections and custom subdomains.
## Features
## Getting started
- SSH interactive session with real-time command handling
- Custom subdomain management for HTTP tunnels
- Dual protocol support: HTTP and TCP tunnels
- Real-time connection monitoring
## Requirements
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
- Go 1.18 or higher
- Valid domain name for subdomain routing
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Environment Variables
## Add your files
The following environment variables can be configured in the `.env` file:
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `DOMAIN` | Domain name for subdomain routing | `localhost` | No |
| `PORT` | SSH server port | `2200` | No |
| `HTTP_PORT` | HTTP server port | `8080` | No |
| `HTTPS_PORT` | HTTPS server port | `8443` | No |
| `TLS_ENABLED` | Enable TLS/HTTPS | `false` | No |
| `TLS_REDIRECT` | Redirect HTTP to HTTPS | `false` | No |
| `ACME_EMAIL` | Email for Let's Encrypt registration | `admin@<DOMAIN>` | No |
| `CF_API_TOKEN` | Cloudflare API token for DNS-01 challenge | - | Yes (if auto-cert) |
| `ACME_STAGING` | Use Let's Encrypt staging server | `false` | No |
| `CORS_LIST` | Comma-separated list of allowed CORS origins | - | No |
| `ALLOWED_PORTS` | Port range for TCP tunnels (e.g., 40000-41000) | `40000-41000` | No |
| `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) |
```
cd existing_repo
git remote add origin http://git.fossy.my.id/bagas/tunnel_pls.git
git branch -M main
git push -uf origin main
**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.
### Automatic TLS Certificate Management
The server supports automatic TLS certificate generation and renewal using [CertMagic](https://github.com/caddyserver/certmagic) with Cloudflare DNS-01 challenge. This is required for wildcard certificate support (`*.yourdomain.com`).
**Certificate Storage:**
- TLS certificates are stored in `certs/tls/` (relative to application directory)
- User-provided certificates: `certs/tls/cert.pem` and `certs/tls/privkey.pem`
- CertMagic automatic certificates: `certs/tls/certmagic/`
- SSH keys are stored separately in `certs/ssh/`
**How it works:**
1. If user-provided certificates exist at `certs/tls/cert.pem` and `certs/tls/privkey.pem` and cover both `DOMAIN` and `*.DOMAIN`, they will be used
2. If certificates are missing, expired, expiring within 30 days, or don't cover the required domains, CertMagic will automatically obtain new certificates from Let's Encrypt
3. Certificates are automatically renewed before expiration
4. User-provided certificates support hot-reload (changes detected every 30 seconds)
**Cloudflare API Token Setup:**
To use automatic certificate generation, you need a Cloudflare API token with the following permissions:
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens)
2. Click "Create Token"
3. Use "Create Custom Token" with these permissions:
- **Zone → Zone → Read** (for all zones or specific zone)
- **Zone → DNS → Edit** (for all zones or specific zone)
4. Copy the token and set it as `CF_API_TOKEN` environment variable
**Example configuration for automatic certificates:**
```env
DOMAIN=example.com
TLS_ENABLED=true
CF_API_TOKEN=your_cloudflare_api_token_here
ACME_EMAIL=admin@example.com
# ACME_STAGING=true # Uncomment for testing to avoid rate limits
```
## Integrate with your tools
### SSH Key Auto-Generation
- [ ] [Set up project integrations](http://git.fossy.my.id/bagas/tunnel_pls/-/settings/integrations)
The application will automatically generate a new 4096-bit RSA key pair at `certs/ssh/id_rsa` if it doesn't exist. This makes it easier to get started without manually creating SSH keys. SSH keys are stored separately from TLS certificates.
## Collaborate with your team
### Memory Optimization
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
The application uses a buffer pool with controlled buffer sizes to prevent excessive memory usage under high concurrent loads. The `BUFFER_SIZE` environment variable controls the size of buffers used for io.Copy operations:
## Test and Deploy
- **Default:** 32768 bytes (32 KB) - Good balance for most scenarios
- **Minimum:** 4096 bytes (4 KB) - Lower memory usage, more CPU overhead
- **Maximum:** 1048576 bytes (1 MB) - Higher throughput, more memory usage
Use the built-in continuous integration in GitLab.
**Recommended settings based on load:**
- **Low traffic (<100 concurrent):** `BUFFER_SIZE=32768` (default)
- **High traffic (>100 concurrent):** `BUFFER_SIZE=16384` or `BUFFER_SIZE=8192`
- **Very high traffic (>1000 concurrent):** `BUFFER_SIZE=8192` or `BUFFER_SIZE=4096`
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
The buffer pool reuses buffers across connections, preventing memory fragmentation and reducing garbage collection pressure.
***
### Profiling with pprof
# Editing this README
To enable profiling for performance analysis:
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
1. Set `PPROF_ENABLED=true` in your `.env` file
2. Optionally set `PPROF_PORT` to your desired port (default: 6060)
3. Access profiling data at `http://localhost:6060/debug/pprof/`
## Suggestions for a good README
Common pprof endpoints:
- `/debug/pprof/` - Index page with available profiles
- `/debug/pprof/heap` - Memory allocation profile
- `/debug/pprof/goroutine` - Stack traces of all current goroutines
- `/debug/pprof/profile` - CPU profile (30-second sample by default)
- `/debug/pprof/trace` - Execution trace
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
Example usage with `go tool pprof`:
```bash
# Analyze CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
## Name
Choose a self-explaining name for your project.
# Analyze memory heap
go tool pprof http://localhost:6060/debug/pprof/heap
```
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Docker Deployment
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
Three Docker Compose configurations are available for different deployment scenarios. Each configuration uses the image `git.fossy.my.id/bagas/tunnel-please:latest`.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
### Configuration Options
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
#### 1. Root with Host Networking (RECOMMENDED)
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
**File:** `docker-compose.root.yml`
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
**Advantages:**
- Full TCP port forwarding support (ports 40000-41000)
- Direct binding to privileged ports (80, 443, 2200)
- Best performance with no NAT overhead
- Maximum flexibility for all tunnel types
- No port mapping limitations
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
**Use Case:** Production deployments where you need unrestricted TCP forwarding and maximum performance.
**Deploy:**
```bash
docker-compose -f docker-compose.root.yml up -d
```
#### 2. Standard (HTTP/HTTPS Only)
**File:** `docker-compose.standard.yml`
**Advantages:**
- Runs with unprivileged user (more secure)
- Standard port mappings (2200, 80, 443)
- Simple and predictable networking
- TCP port forwarding disabled (`ALLOWED_PORTS=none`)
**Use Case:** Deployments where you only need HTTP/HTTPS tunneling without custom TCP port forwarding.
**Deploy:**
```bash
docker-compose -f docker-compose.standard.yml up -d
```
#### 3. Limited TCP Forwarding
**File:** `docker-compose.tcp.yml`
**Advantages:**
- Runs with unprivileged user (more secure)
- Standard port mappings (2200, 80, 443)
- Limited TCP forwarding (ports 30000-31000)
- Controlled port range exposure
**Use Case:** Deployments where you need both HTTP/HTTPS tunneling and limited TCP forwarding within a specific port range.
**Deploy:**
```bash
docker-compose -f docker-compose.tcp.yml up -d
```
### Quick Start
1. **Choose your configuration** based on your requirements
2. **Edit the environment variables** in the chosen compose file:
- `DOMAIN`: Your domain name (e.g., `example.com`)
- `ACME_EMAIL`: Your email for Let's Encrypt
- `CF_API_TOKEN`: Your Cloudflare API token (if using automatic TLS)
3. **Deploy:**
```bash
docker-compose -f docker-compose.root.yml up -d
```
4. **Check logs:**
```bash
docker-compose -f docker-compose.root.yml logs -f
```
5. **Stop the service:**
```bash
docker-compose -f docker-compose.root.yml down
```
### Volume Management
All configurations use a named volume `certs` for persistent storage:
- SSH keys: `/app/certs/ssh/`
- TLS certificates: `/app/certs/tls/`
To backup certificates:
```bash
docker run --rm -v tunnel_pls_certs:/data -v $(pwd):/backup alpine tar czf /backup/certs-backup.tar.gz -C /data .
```
To restore certificates:
```bash
docker run --rm -v tunnel_pls_certs:/data -v $(pwd):/backup alpine tar xzf /backup/certs-backup.tar.gz -C /data
```
### Recommendation
**Use `docker-compose.root.yml`** for production deployments if you need:
- Full TCP port forwarding capabilities
- Any port range configuration
- Direct port binding without mapping overhead
- Maximum performance and flexibility
This is the recommended configuration for most use cases as it provides the complete feature set without limitations.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
Contributions are welcome!
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
If you'd like to contribute to this project, please follow the workflow below:
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
1. **Fork** the repository
2. Create a new branch for your changes
3. Commit and push your updates
4. Open a **Pull Request** targeting the **`staging`** branch
5. Clearly describe your changes and the reasoning behind them
## License
For open source projects, say how it is licensed.
This project is licensed under the [Attribution-NonCommercial-NoDerivatives 4.0 International (CC BY-NC-ND 4.0)](https://creativecommons.org/licenses/by-nc-nd/4.0/) license.
## Author
**Bagas (fossyy)**
- Website: [fossy.my.id](https://fossy.my.id)
- GitHub: [@fossyy](https://github.com/fossyy)
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

View File

@@ -1,26 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEWzCCA0OgAwIBAgIUDYTdEDHwVznxV/qnn0/WVlHNMZAwDQYJKoZIhvcNAQEL
BQAwgZYxCzAJBgNVBAYTAklEMQ8wDQYDVQQIDAZCYW50ZW4xGjAYBgNVBAcMEVRh
bmdlcmFuZyBTZWxhdGFuMRIwEAYDVQQKDAlGb3NzeSBMVFMxDjAMBgNVBAsMBUZv
c3N5MRQwEgYDVQQDDAtmb3NzeS5teS5pZDEgMB4GCSqGSIb3DQEJARYRYmFnYXNA
Zm9zc3kubXkuaWQwHhcNMjUwMjA3MTYyMTU1WhcNMjYwMjA3MTYyMTU1WjCBljEL
MAkGA1UEBhMCSUQxDzANBgNVBAgMBkJhbnRlbjEaMBgGA1UEBwwRVGFuZ2VyYW5n
IFNlbGF0YW4xEjAQBgNVBAoMCUZvc3N5IExUUzEOMAwGA1UECwwFRm9zc3kxFDAS
BgNVBAMMC2Zvc3N5Lm15LmlkMSAwHgYJKoZIhvcNAQkBFhFiYWdhc0Bmb3NzeS5t
eS5pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMU3pCA0eP+VR2CA
+p3p0BgKw33xCKQmQx52jnqNbvwW4NMMDc3mS6a+wb6QB6v0WLGMI1g22TtJPatx
7dcy4PCSOCV9xJ2Yfq5HlQgDHYoyE+juy4/pGlMjo45thJ0yI8zOSzaz2esosP22
XkfFwj7oVMXXPIY6UovcAlGU+DFtwVrNa76/esUwJs+7M3tBubjkpcal0wXR+SIX
3fmw5v0YzKV8qLGMGOvX6+OyLQCx4r9gB0d+WOrT6EfrPfAzo07NKjzWG9aWl1rk
Q+h0i38hke2MTFxId7za57L+NlXRjLo3ESbBF0hjYquTQOG3jx/UvWs+I1NEfsdu
vj8beiMCAwEAAaOBnjCBmzAfBgNVHSMEGDAWgBT6nj69+I+GSdgScjfOqnVFzX7G
aTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAs
BgNVHREEJTAjgglsb2NhbGhvc3SCCTEyNy4wLjAuMYILKi5sb2NhbGhvc3QwHQYD
VR0OBBYEFD9ci20pAUTRLVWxvVXjfE2GKwSAMA0GCSqGSIb3DQEBCwUAA4IBAQAT
rkjU+GzQy9B3/nd/79N7ozK80ygzmnRnj3ou/bbqUHOIYQQgKM1cuN6zh57ovRh/
u45s6pZUOUVN59POFUCvqUiOgsDkY/auXGbKtzzqzoZABuvm85ySd6zurOOx8tA5
e7ArX1ppy3LgzSb+cXANvzYC9bCwp70w2YylMFhwHBAp5TXRVqsqG6jujD8GMLoS
zDSDx8M6o8gqWmPQve7Saim9mgLJUvvCYBzvowuNjzZrJfAeGoIfLV127xCQiylm
fzUQ1Ac6udldWm9scA32nteSQMWg2d1nW3RG1nRondp13WUkgGQ490/c97D3MKLt
D1HB5dLIYkRVHVhCKHT1
-----END CERTIFICATE-----

View File

@@ -1,21 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDjDCCAnSgAwIBAgIUPpuvw4ZdpnDBbwOZBt2ALfZykuYwDQYJKoZIhvcNAQEL
BQAwPDELMAkGA1UEBhMCVUsxDzANBgNVBAgMBkxvbmRvbjEPMA0GA1UEBwwGTG9u
ZG9uMQswCQYDVQQLDAJIUTAeFw0yNDExMTkxNjQxNTNaFw0zNDExMTcxNjQxNTNa
MDwxCzAJBgNVBAYTAlVLMQ8wDQYDVQQIDAZMb25kb24xDzANBgNVBAcMBkxvbmRv
bjELMAkGA1UECwwCSFEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDD
L/aeaBzgkYxDNiQq7+nt6tKDfGnPDfBXunJlr1xbZfIVpJeDqwrNLWaZ0gtHci5E
sHptIpu5/uTBFaFyZVH604etY/YHIsff7BT2y32OobJYiKy2lvAI3/IDR4TGeDgA
HKryOwMcB5DheBVdeggxj36m8OjxhVADaiKp7BNXE2eqUqk8f2QpwqLQYe9UaU9r
WSJllNHOFk+RH17YBDiyyQ8CD1Vf5HcVSmPItWOMQytHcSgy0DHVXQCde86mky8t
8Ik74GeNrM6f+vWR4OfQ8dU2WSyTUE4c7czagkToheMX5fbbzWJkJcd2SD9wvyIk
tOot0YiZGQAoOedtGSEnAgMBAAGjgYUwgYIwCQYDVR0TBAIwADALBgNVHQ8EBAMC
BeAwSQYDVR0RBEIwQIIQbG9jYWxob3N0LmRpcmVjdIISKi5sb2NhbGhvc3QuZGly
ZWN0ghhTUy5jZXJ0LmxvY2FsaG9zdC5kaXJlY3QwHQYDVR0OBBYEFKBVeirQGZ4D
4AKVPd7LGfCF1wEZMA0GCSqGSIb3DQEBCwUAA4IBAQCRpvsc5DrQBo8yATmUS0OK
xfUXfZR28u3xYY+qMHi+ngIVT2TKJ1yoBJezV6WwQLkcGdWacULvPYt3jCFUtaP7
+hzfs5y1FssFsXDx+r3pQxYyE6BX3BhlrJPJhLRyG1siTTgN439Qu40/TsTzNgAT
A9GbAro+W6+qA4H92mBlyfQEOBossID/Kk95uvDnQguUOp0ZBLgFNRfE6Ra9+yC+
ufAOksYDrisPE6kZId0Ra4Ln/GmrIXKjjmLCitq2q2REC/70JSCnaJcBYeThSJLZ
AZ24AF+JteakSJ8FEgRGxvSu0wdZfnMobNoelsjai1p5l5mCTiD8GH9sQCkslnp3
-----END CERTIFICATE-----

View File

@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDL/aeaBzgkYxD
NiQq7+nt6tKDfGnPDfBXunJlr1xbZfIVpJeDqwrNLWaZ0gtHci5EsHptIpu5/uTB
FaFyZVH604etY/YHIsff7BT2y32OobJYiKy2lvAI3/IDR4TGeDgAHKryOwMcB5Dh
eBVdeggxj36m8OjxhVADaiKp7BNXE2eqUqk8f2QpwqLQYe9UaU9rWSJllNHOFk+R
H17YBDiyyQ8CD1Vf5HcVSmPItWOMQytHcSgy0DHVXQCde86mky8t8Ik74GeNrM6f
+vWR4OfQ8dU2WSyTUE4c7czagkToheMX5fbbzWJkJcd2SD9wvyIktOot0YiZGQAo
OedtGSEnAgMBAAECggEASXibb2MnQ4zl7ELL+HGYb5sNpLrHJU5M4ujmuMn6jNjh
+C2dbs2KYlMtpMcAweMD8Y0weDYnwiplNx0KSYJECpNnJehTqrn33J0EAyXz3CWX
eWXxBUXpkp2hfoSEQSTth3VDD60Q7ZMXgRdvi2EtBmLKPNLADHGu/aoM5ENdwE9/
E80X68E+MT2czOY/sEI/w2Tf/S2ZVOHRvFOsmvTLFlbiElWG8pmguajpJwdae7uX
c5VD87b0oYicSUvaHe6xOCCyyeBVq06sWk+vh6Tzrw6K/B+q0SYvzXdrdsJxbzUD
PvhVi9rf9AC1Ncb6lFOP2ZjGfqYQvfgGXKqaVxXWQQKBgQD8frATynMSeR15oERa
+Maa7r3GlwWV3tkUblX7/FxBo9UhAsivnWZprccZR43YPowbWNeU3AQppf/YDxmx
70/RVTCMjXPGyspSS2iFtcp+K2KnIZA4BuAG/s1EBrAUW2KrtaTaXgYu9usgb+Fq
dJUEBDWrL59XXtQUwSM1laH0BwKBgQDF5Z31VY96xOS7flmolq8Ag+frSMH0G4np
3nUnTlUkgpFXE5FkmUccbYZv9QialAVriBBUNANPDBQH4PRrNnX8Z6B2HJbpAy30
II9jPMTNKnXT3RKFcJCamNkOQhT0sBAwm4gTsx+7gbpdZxinxH0Cr/08sfU4lbee
EtMV/J1h4QKBgC4MLLBvS20jCW0U/WJZ3F6FC7cb87jRW2WOeb/q1ihiaIwMpezh
F7xOJPFHS2cUgRi7qxVKyreNvor4tgbtTfEvSBtZ8LNgaGV5uyYncTZxUxyH0nVl
S5X7AhRV4+bSg7ws9FOesiH+hgL0ZHe1qzeATQlbNgQJF0RxtKohD9ghAoGAcM0N
WIZInoYUivreSEZ7wiNt0qNKSsZXukLfLGRuC72Q8r1opprn+cBEXRSirtmorT6F
cDmlmS0dTdBgAaytXA4FXM23B2KUkw7sLHi7BOcq+nSM1hrvke+F6aapI0AoOkyt
J+12LP8pJ4xYdWh+iUWfZzVYvcQ5QZUhVOsFGoECgYA9llKmc/cFaXrCr97g7ls8
ZWW1kQKLawAv6+90dwECJl+zpyQN/TURyEfz6oFJDbNEL0xAAcm9thDah177Tq95
pcHbVn/pAI4h4CLzM2ExSe8Ybvy6iPZaBiCVXq3ms1PK0EDyYLH1p3FZqm+UStZh
/6fYspyivrQEivRK3jGuWA==
-----END PRIVATE KEY-----

View File

@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDFN6QgNHj/lUdg
gPqd6dAYCsN98QikJkMedo56jW78FuDTDA3N5kumvsG+kAer9FixjCNYNtk7ST2r
ce3XMuDwkjglfcSdmH6uR5UIAx2KMhPo7suP6RpTI6OObYSdMiPMzks2s9nrKLD9
tl5HxcI+6FTF1zyGOlKL3AJRlPgxbcFazWu+v3rFMCbPuzN7Qbm45KXGpdMF0fki
F935sOb9GMylfKixjBjr1+vjsi0AseK/YAdHfljq0+hH6z3wM6NOzSo81hvWlpda
5EPodIt/IZHtjExcSHe82uey/jZV0Yy6NxEmwRdIY2Krk0Dht48f1L1rPiNTRH7H
br4/G3ojAgMBAAECggEAATiJ23ZhS1++squ87jsgAfR+TYP8Kpv408u/43Ig5KgC
ZnwPUWqvP2e0TLwyhMKwXxHMt2nITxQlSnyCXU/5nk07BYxkqhiwEni4Xo86YK+H
rQXEnKFaSHdF6dNNIo+VZiark4adS9XgJp0fsn0LnON7GhBt72MvPW6auxH1HJIC
rcjnzJu/KzTVrId9QNsEDl9cNRlHXuPSfohdq2o39PBKGDeDEDTvP9wHtasEF6oe
uj1OH5fAxiXptT0Ln0Y8QeFe8R8Odul5mUXCgMOkKmHZPDxTq9ldCuPjrz15JfnS
T3MecaWdvChpIP2WybjLstJy9qXTl0fhRkpJWpaVbQKBgQDu9puq64vZn0jak7ns
bFbKPemFw7tiiwwnqyzqCTRddw1vETJN/MgpRWvc7d1Cvzs2+Z2kn0Cgpdw/30Ej
VRHiE/d1rj+u33F6vGBUBccRue1t0UdFMFF/YnlNDRRnljQgx4N3mezzNE2RikR8
jVp+jvWTSTgf3y4Hip1ffCSNtQKBgQDTRx1huSzUOed/36YLdnxP+AlV3L+c/EKU
GItKZk2bqRlqgs6wcsNlVjveb9o9j6M5pg/lh5HaqJyhj8DYVnEdmXfcQJiUfIPh
5802asIXQVBmL9rTMR34wsr/lzzK3k0KORqFcUdf2fDI/UQNpVUJvWQQY8fl2sVo
zmp88JAPdwKBgQCLC6fsvn5ztMF5nffTX/7oUzosgYXpgyshcfMCgzSbJgkFFaaF
xo7ZpPFsbmQO0KMuC/T0s02xrJEKAWgvnPJ48FFPgoK/yHiJiE8s1OfOordK7Tlh
QwpI6w3WDcRPuhC++hi/YSuFIGv6QdA0ATQk7B5tA2/K69wmuztzMhM6+QKBgH5E
LwQbRfZj0L20bKjHHA4y32loLz/j5upZLM2/DDyuN9lW6a28OJiUi90pHdXSxSsL
2s5DUmDKiiloH0lrh9i3wlFobYe4Tp0xCoyuCucZCrK3gODcptvnlqhfu15GsuYc
MIR1qcFYH7YO3qAFIiha/rVo3Ku7LmWvjyayInaLAoGAZ/dELEAcyhImf1HvtmBT
qNg4uI6t/2fvHdoAeQkkGjEDWFTGEaMp0cJEbETPD68TwNh5dL/xUJibTwMbK+m5
rdIA2oTZMkFtWubIvMCrD/J6E+8Pzl+eK+0C0axO/I29S4veORNGWvCtqdFICDgZ
CluJ0NZFeMH8g8tgfI9HnHc=
-----END PRIVATE KEY-----

37
docker-compose.root.yml Normal file
View File

@@ -0,0 +1,37 @@
version: '3.8'
services:
tunnel-please:
image: git.fossy.my.id/bagas/tunnel-please:latest
container_name: tunnel-please-root
user: root
network_mode: host
restart: unless-stopped
volumes:
- certs:/app/certs
environment:
DOMAIN: example.com
PORT: 2200
HTTP_PORT: 8080
HTTPS_PORT: 8443
TLS_ENABLED: "true"
TLS_REDIRECT: "true"
ACME_EMAIL: admin@example.com
CF_API_TOKEN: your_cloudflare_api_token_here
ACME_STAGING: "false"
CORS_LIST: http://localhost:3000,https://example.com
ALLOWED_PORTS: 40000-41000
BUFFER_SIZE: 32768
PPROF_ENABLED: "false"
PPROF_PORT: 6060
healthcheck:
test: ["CMD", "/bin/sh", "-c", "netstat -tln | grep -q :2200"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
certs:
driver: local

View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
tunnel-please:
image: git.fossy.my.id/bagas/tunnel-please:latest
container_name: tunnel-please-standard
restart: unless-stopped
ports:
- "2200:2200"
- "80:8080"
- "443:8443"
volumes:
- certs:/app/certs
environment:
DOMAIN: example.com
PORT: 2200
HTTP_PORT: 8080
HTTPS_PORT: 8443
TLS_ENABLED: "true"
TLS_REDIRECT: "true"
ACME_EMAIL: admin@example.com
CF_API_TOKEN: your_cloudflare_api_token_here
ACME_STAGING: "false"
CORS_LIST: http://localhost:3000,https://example.com
ALLOWED_PORTS: none
BUFFER_SIZE: 32768
PPROF_ENABLED: "false"
PPROF_PORT: 6060
healthcheck:
test: ["CMD", "/bin/sh", "-c", "netstat -tln | grep -q :2200"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
certs:
driver: local

40
docker-compose.tcp.yml Normal file
View File

@@ -0,0 +1,40 @@
version: '3.8'
services:
tunnel-please:
image: git.fossy.my.id/bagas/tunnel-please:latest
container_name: tunnel-please-tcp
restart: unless-stopped
ports:
- "2200:2200"
- "80:8080"
- "443:8443"
- "30000-31000:30000-31000"
volumes:
- certs:/app/certs
environment:
DOMAIN: example.com
PORT: 2200
HTTP_PORT: 8080
HTTPS_PORT: 8443
TLS_ENABLED: "true"
TLS_REDIRECT: "true"
ACME_EMAIL: admin@example.com
CF_API_TOKEN: your_cloudflare_api_token_here
ACME_STAGING: "false"
CORS_LIST: http://localhost:3000,https://example.com
ALLOWED_PORTS: 30000-31000
BUFFER_SIZE: 32768
PPROF_ENABLED: "false"
PPROF_PORT: 6060
healthcheck:
test: ["CMD", "/bin/sh", "-c", "netstat -tln | grep -q :2200"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
certs:
driver: local

51
go.mod
View File

@@ -1,10 +1,55 @@
module tunnel_pls
go 1.24.4
go 1.25.5
require (
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
golang.org/x/crypto v0.45.0
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 golang.org/x/sys v0.38.0 // indirect
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.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.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.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/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.1 // indirect
go.uber.org/zap/exp v0.3.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.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

159
go.sum
View File

@@ -1,13 +1,150 @@
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=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
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.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.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.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/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=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
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.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.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=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
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.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=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
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.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-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=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.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=

35
internal/config/config.go Normal file
View File

@@ -0,0 +1,35 @@
package config
import (
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
func init() {
if _, err := os.Stat(".env"); err == nil {
if err := godotenv.Load(".env"); err != nil {
log.Printf("Warning: Failed to load .env file: %s", err)
}
}
}
func Getenv(key, defaultValue string) string {
val := os.Getenv(key)
if val == "" {
val = defaultValue
}
return val
}
func GetBufferSize() int {
sizeStr := Getenv("BUFFER_SIZE", "32768")
size, err := strconv.Atoi(sizeStr)
if err != nil || size < 4096 || size > 1048576 {
return 32768
}
return size
}

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.GetInteraction().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.GetLifecycle().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
}
}

67
internal/key/key.go Normal file
View File

@@ -0,0 +1,67 @@
package key
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"log"
"os"
"path/filepath"
"golang.org/x/crypto/ssh"
)
func GenerateSSHKeyIfNotExist(keyPath string) error {
if _, err := os.Stat(keyPath); err == nil {
log.Printf("SSH key already exists at %s", keyPath)
return nil
}
log.Printf("SSH key not found at %s, generating new key pair...", keyPath)
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return err
}
privateKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
dir := filepath.Dir(keyPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
privateKeyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer privateKeyFile.Close()
if err := pem.Encode(privateKeyFile, privateKeyPEM); err != nil {
return err
}
publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey)
if err != nil {
return err
}
pubKeyPath := keyPath + ".pub"
pubKeyFile, err := os.OpenFile(pubKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer pubKeyFile.Close()
_, err = pubKeyFile.Write(ssh.MarshalAuthorizedKey(publicKey))
if err != nil {
return err
}
log.Printf("SSH key pair generated successfully at %s and %s", keyPath, pubKeyPath)
return nil
}

View File

@@ -6,39 +6,50 @@ import (
"strconv"
"strings"
"sync"
"tunnel_pls/utils"
"tunnel_pls/internal/config"
)
type PortManager struct {
type Manager interface {
AddPortRange(startPort, endPort uint16) error
GetUnassignedPort() (uint16, bool)
SetPortStatus(port uint16, assigned bool) error
GetPortStatus(port uint16) (bool, bool)
}
type manager struct {
mu sync.RWMutex
ports map[uint16]bool
sortedPorts []uint16
}
var Manager = PortManager{
var Default Manager = &manager{
ports: make(map[uint16]bool),
sortedPorts: []uint16{},
}
func init() {
rawRange := utils.Getenv("ALLOWED_PORTS")
rawRange := config.Getenv("ALLOWED_PORTS", "")
if rawRange == "" {
return
}
splitRange := strings.Split(rawRange, "-")
if len(splitRange) != 2 {
Manager.AddPortRange(30000, 31000)
} else {
start, err := strconv.ParseUint(splitRange[0], 10, 16)
if err != nil {
start = 30000
}
end, err := strconv.ParseUint(splitRange[1], 10, 16)
if err != nil {
end = 31000
}
Manager.AddPortRange(uint16(start), uint16(end))
return
}
start, err := strconv.ParseUint(splitRange[0], 10, 16)
if err != nil {
return
}
end, err := strconv.ParseUint(splitRange[1], 10, 16)
if err != nil {
return
}
_ = Default.AddPortRange(uint16(start), uint16(end))
}
func (pm *PortManager) AddPortRange(startPort, endPort uint16) error {
func (pm *manager) AddPortRange(startPort, endPort uint16) error {
pm.mu.Lock()
defer pm.mu.Unlock()
@@ -57,7 +68,7 @@ func (pm *PortManager) AddPortRange(startPort, endPort uint16) error {
return nil
}
func (pm *PortManager) GetUnassignedPort() (uint16, bool) {
func (pm *manager) GetUnassignedPort() (uint16, bool) {
pm.mu.Lock()
defer pm.mu.Unlock()
@@ -70,7 +81,7 @@ func (pm *PortManager) GetUnassignedPort() (uint16, bool) {
return 0, false
}
func (pm *PortManager) SetPortStatus(port uint16, assigned bool) error {
func (pm *manager) SetPortStatus(port uint16, assigned bool) error {
pm.mu.Lock()
defer pm.mu.Unlock()
@@ -78,7 +89,7 @@ func (pm *PortManager) SetPortStatus(port uint16, assigned bool) error {
return nil
}
func (pm *PortManager) GetPortStatus(port uint16) (bool, bool) {
func (pm *manager) GetPortStatus(port uint16) (bool, bool) {
pm.mu.RLock()
defer pm.mu.RUnlock()

18
internal/random/random.go Normal file
View File

@@ -0,0 +1,18 @@
package random
import (
mathrand "math/rand"
"strings"
"time"
)
func GenerateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
seededRand := mathrand.New(mathrand.NewSource(time.Now().UnixNano() + int64(mathrand.Intn(9999))))
var result strings.Builder
for i := 0; i < length; i++ {
randomIndex := seededRand.Intn(len(charset))
result.WriteString(string(charset[randomIndex]))
}
return result.String()
}

BIN
main

Binary file not shown.

128
main.go
View File

@@ -1,37 +1,141 @@
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/utils"
"tunnel_pls/session"
"tunnel_pls/version"
"golang.org/x/crypto/ssh"
)
func main() {
sshConfig := &ssh.ServerConfig{
NoClientAuth: true,
ServerVersion: "SSH-2.0-TunnlPls-1.0",
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
return nil, nil
},
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
fmt.Println(version.GetVersion())
os.Exit(0)
}
log.SetOutput(os.Stdout)
log.SetFlags(log.LstdFlags | log.Lshortfile)
privateBytes, err := os.ReadFile(utils.Getenv("ssh_private_key"))
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")
go func() {
pprofAddr := fmt.Sprintf("localhost:%s", pprofPort)
log.Printf("Starting pprof server on http://%s/debug/pprof/", pprofAddr)
if err := http.ListenAndServe(pprofAddr, nil); err != nil {
log.Printf("pprof server error: %v", err)
}
}()
}
sshConfig := &ssh.ServerConfig{
NoClientAuth: true,
ServerVersion: fmt.Sprintf("SSH-2.0-TunnlPls-%s", version.GetShortVersion()),
}
sshKeyPath := "certs/ssh/id_rsa"
if err := key.GenerateSSHKeyIfNotExist(sshKeyPath); err != nil {
log.Fatalf("Failed to generate SSH key: %s", err)
}
privateBytes, err := os.ReadFile(sshKeyPath)
if err != nil {
log.Fatalf("Failed to load private key : %s", err.Error())
log.Fatalf("Failed to load private key: %s", err)
}
private, err := ssh.ParsePrivateKey(privateBytes)
if err != nil {
log.Fatal("Failed to parse private key")
log.Fatalf("Failed to parse private key: %s", err)
}
sshConfig.AddHostKey(private)
app := server.NewServer(*sshConfig)
app.Start()
sessionRegistry := session.NewRegistry()
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)
}
}
}

19
renovate.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": true,
"baseBranchPatterns": [
"staging"
]
}
]
}

View File

@@ -1,28 +0,0 @@
package server
import (
"log"
"net"
"tunnel_pls/session"
"golang.org/x/crypto/ssh"
)
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()
if err != nil {
log.Printf("failed to close SSH connection: %v", err)
return
}
return
}
log.Println("SSH connection established:", sshConn.User())
session.New(sshConn, forwardingReqs, chans)
return
}

View File

@@ -4,8 +4,6 @@ import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
type HeaderManager interface {
@@ -15,64 +13,169 @@ type HeaderManager interface {
Finalize() []byte
}
type ResponseHeaderFactory struct {
type ResponseHeaderManager interface {
Get(key string) string
Set(key string, value string)
Remove(key string)
Finalize() []byte
}
type RequestHeaderManager interface {
Get(key string) string
Set(key string, value string)
Remove(key string)
Finalize() []byte
GetMethod() string
GetPath() string
GetVersion() string
}
type responseHeaderFactory struct {
startLine []byte
headers map[string]string
}
type RequestHeaderFactory struct {
Method string
Path string
Version string
type requestHeaderFactory struct {
method string
path string
version string
startLine []byte
headers map[string]string
}
func NewRequestHeaderFactory(r io.Reader) (*RequestHeaderFactory, error) {
br := bufio.NewReader(r)
header := &RequestHeaderFactory{
headers: make(map[string]string),
func NewRequestHeaderFactory(r interface{}) (RequestHeaderManager, error) {
switch v := r.(type) {
case []byte:
return parseHeadersFromBytes(v)
case *bufio.Reader:
return parseHeadersFromReader(v)
default:
return nil, fmt.Errorf("unsupported type: %T", r)
}
}
func parseHeadersFromBytes(headerData []byte) (RequestHeaderManager, error) {
header := &requestHeaderFactory{
headers: make(map[string]string, 16),
}
startLine, err := br.ReadString('\n')
if err != nil {
return nil, err
lineEnd := bytes.IndexByte(headerData, '\n')
if lineEnd == -1 {
return nil, fmt.Errorf("invalid request: no newline found")
}
startLine = strings.TrimRight(startLine, "\r\n")
header.startLine = []byte(startLine)
parts := strings.Split(startLine, " ")
startLine := bytes.TrimRight(headerData[:lineEnd], "\r\n")
header.startLine = make([]byte, len(startLine))
copy(header.startLine, startLine)
parts := bytes.Split(startLine, []byte{' '})
if len(parts) < 3 {
return nil, fmt.Errorf("invalid request line")
}
header.Method = parts[0]
header.Path = parts[1]
header.Version = parts[2]
header.method = string(parts[0])
header.path = string(parts[1])
header.version = string(parts[2])
for {
line, err := br.ReadString('\n')
if err != nil {
return nil, err
remaining := headerData[lineEnd+1:]
for len(remaining) > 0 {
lineEnd = bytes.IndexByte(remaining, '\n')
if lineEnd == -1 {
lineEnd = len(remaining)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
line := bytes.TrimRight(remaining[:lineEnd], "\r\n")
if len(line) == 0 {
break
}
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 {
continue
colonIdx := bytes.IndexByte(line, ':')
if colonIdx != -1 {
key := bytes.TrimSpace(line[:colonIdx])
value := bytes.TrimSpace(line[colonIdx+1:])
header.headers[string(key)] = string(value)
}
header.headers[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
if lineEnd == len(remaining) {
break
}
remaining = remaining[lineEnd+1:]
}
return header, nil
}
func NewResponseHeaderFactory(startLine []byte) *ResponseHeaderFactory {
header := &ResponseHeaderFactory{
func parseHeadersFromReader(br *bufio.Reader) (RequestHeaderManager, error) {
header := &requestHeaderFactory{
headers: make(map[string]string, 16),
}
startLineBytes, err := br.ReadSlice('\n')
if err != nil {
if err == bufio.ErrBufferFull {
var startLine string
startLine, err = br.ReadString('\n')
if err != nil {
return nil, err
}
startLineBytes = []byte(startLine)
} else {
return nil, err
}
}
startLineBytes = bytes.TrimRight(startLineBytes, "\r\n")
header.startLine = make([]byte, len(startLineBytes))
copy(header.startLine, startLineBytes)
parts := bytes.Split(startLineBytes, []byte{' '})
if len(parts) < 3 {
return nil, fmt.Errorf("invalid request line")
}
header.method = string(parts[0])
header.path = string(parts[1])
header.version = string(parts[2])
for {
lineBytes, err := br.ReadSlice('\n')
if err != nil {
if err == bufio.ErrBufferFull {
var line string
line, err = br.ReadString('\n')
if err != nil {
return nil, err
}
lineBytes = []byte(line)
} else {
return nil, err
}
}
lineBytes = bytes.TrimRight(lineBytes, "\r\n")
if len(lineBytes) == 0 {
break
}
colonIdx := bytes.IndexByte(lineBytes, ':')
if colonIdx == -1 {
continue
}
key := bytes.TrimSpace(lineBytes[:colonIdx])
value := bytes.TrimSpace(lineBytes[colonIdx+1:])
header.headers[string(key)] = string(value)
}
return header, nil
}
func NewResponseHeaderFactory(startLine []byte) ResponseHeaderManager {
header := &responseHeaderFactory{
startLine: nil,
headers: make(map[string]string),
}
@@ -98,19 +201,19 @@ func NewResponseHeaderFactory(startLine []byte) *ResponseHeaderFactory {
return header
}
func (resp *ResponseHeaderFactory) Get(key string) string {
func (resp *responseHeaderFactory) Get(key string) string {
return resp.headers[key]
}
func (resp *ResponseHeaderFactory) Set(key string, value string) {
func (resp *responseHeaderFactory) Set(key string, value string) {
resp.headers[key] = value
}
func (resp *ResponseHeaderFactory) Remove(key string) {
func (resp *responseHeaderFactory) Remove(key string) {
delete(resp.headers, key)
}
func (resp *ResponseHeaderFactory) Finalize() []byte {
func (resp *responseHeaderFactory) Finalize() []byte {
var buf bytes.Buffer
buf.Write(resp.startLine)
@@ -127,7 +230,7 @@ func (resp *ResponseHeaderFactory) Finalize() []byte {
return buf.Bytes()
}
func (req *RequestHeaderFactory) Get(key string) string {
func (req *requestHeaderFactory) Get(key string) string {
val, ok := req.headers[key]
if !ok {
return ""
@@ -135,22 +238,32 @@ func (req *RequestHeaderFactory) Get(key string) string {
return val
}
func (req *RequestHeaderFactory) Set(key string, value string) {
func (req *requestHeaderFactory) Set(key string, value string) {
req.headers[key] = value
}
func (req *RequestHeaderFactory) Remove(key string) {
func (req *requestHeaderFactory) Remove(key string) {
delete(req.headers, key)
}
func (req *RequestHeaderFactory) Finalize() []byte {
func (req *requestHeaderFactory) GetMethod() string {
return req.method
}
func (req *requestHeaderFactory) GetPath() string {
return req.path
}
func (req *requestHeaderFactory) GetVersion() string {
return req.version
}
func (req *requestHeaderFactory) Finalize() []byte {
var buf bytes.Buffer
buf.Write(req.startLine)
buf.WriteString("\r\n")
req.headers["X-HF"] = "modified"
for key, val := range req.headers {
buf.WriteString(key)
buf.WriteString(": ")

View File

@@ -10,36 +10,66 @@ import (
"net"
"regexp"
"strings"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/session"
"tunnel_pls/types"
"tunnel_pls/utils"
"golang.org/x/crypto/ssh"
)
type Interaction interface {
SendMessage(message string)
}
type CustomWriter struct {
RemoteAddr net.Addr
writer io.Writer
reader io.Reader
headerBuf []byte
buf []byte
respHeader *ResponseHeaderFactory
reqHeader *RequestHeaderFactory
interaction Interaction
respMW []ResponseMiddleware
reqStartMW []RequestMiddleware
reqEndMW []RequestMiddleware
type HTTPWriter interface {
io.Reader
io.Writer
GetRemoteAddr() net.Addr
GetWriter() io.Writer
AddResponseMiddleware(mw ResponseMiddleware)
AddRequestStartMiddleware(mw RequestMiddleware)
SetRequestHeader(header RequestHeaderManager)
GetRequestStartMiddleware() []RequestMiddleware
}
func (cw *CustomWriter) SetInteraction(interaction Interaction) {
cw.interaction = interaction
type customWriter struct {
remoteAddr net.Addr
writer io.Writer
reader io.Reader
headerBuf []byte
buf []byte
respHeader ResponseHeaderManager
reqHeader RequestHeaderManager
respMW []ResponseMiddleware
reqStartMW []RequestMiddleware
reqEndMW []RequestMiddleware
}
func (cw *CustomWriter) Read(p []byte) (int, error) {
func (cw *customWriter) GetRemoteAddr() net.Addr {
return cw.remoteAddr
}
func (cw *customWriter) GetWriter() io.Writer {
return cw.writer
}
func (cw *customWriter) AddResponseMiddleware(mw ResponseMiddleware) {
cw.respMW = append(cw.respMW, mw)
}
func (cw *customWriter) AddRequestStartMiddleware(mw RequestMiddleware) {
cw.reqStartMW = append(cw.reqStartMW, mw)
}
func (cw *customWriter) SetRequestHeader(header RequestHeaderManager) {
cw.reqHeader = header
}
func (cw *customWriter) GetRequestStartMiddleware() []RequestMiddleware {
return cw.reqStartMW
}
func (cw *customWriter) Read(p []byte) (int, error) {
tmp := make([]byte, len(p))
read, err := cw.reader.Read(tmp)
if err != nil {
if read == 0 && err != nil {
return 0, err
}
@@ -48,6 +78,9 @@ func (cw *CustomWriter) Read(p []byte) (int, error) {
idx := bytes.Index(tmp, DELIMITER)
if idx == -1 {
copy(p, tmp)
if err != nil {
return read, err
}
return read, nil
}
@@ -60,55 +93,51 @@ func (cw *CustomWriter) Read(p []byte) (int, error) {
}
for _, m := range cw.reqEndMW {
err := m.HandleRequest(cw.reqHeader)
err = m.HandleRequest(cw.reqHeader)
if err != nil {
log.Printf("Error when applying request middleware: %v", err)
return 0, err
}
}
headerReader := bufio.NewReader(bytes.NewReader(header))
reqhf, err := NewRequestHeaderFactory(headerReader)
reqhf, err := NewRequestHeaderFactory(header)
if err != nil {
return 0, err
}
for _, m := range cw.reqStartMW {
err := m.HandleRequest(reqhf)
if err != nil {
log.Printf("Error when applying request middleware: %v", err)
return 0, err
if mwErr := m.HandleRequest(reqhf); mwErr != nil {
log.Printf("Error when applying request middleware: %v", mwErr)
return 0, mwErr
}
}
cw.reqHeader = reqhf
finalHeader := reqhf.Finalize()
n := copy(p, finalHeader)
n += copy(p[n:], body)
combined := append(finalHeader, body...)
n := copy(p, combined)
return n, nil
}
func NewCustomWriter(writer io.Writer, reader io.Reader, remoteAddr net.Addr) *CustomWriter {
return &CustomWriter{
RemoteAddr: remoteAddr,
writer: writer,
reader: reader,
buf: make([]byte, 0, 4096),
interaction: nil,
func NewCustomWriter(writer io.Writer, reader io.Reader, remoteAddr net.Addr) HTTPWriter {
return &customWriter{
remoteAddr: remoteAddr,
writer: writer,
reader: reader,
buf: make([]byte, 0, 4096),
}
}
var DELIMITER = []byte{0x0D, 0x0A, 0x0D, 0x0A} // HTTP HEADER DELIMITER `\r\n\r\n`
var DELIMITER = []byte{0x0D, 0x0A, 0x0D, 0x0A}
var requestLine = regexp.MustCompile(`^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|TRACE|CONNECT) \S+ HTTP/\d\.\d$`)
var responseLine = regexp.MustCompile(`^HTTP/\d\.\d \d{3} .+`)
func isHTTPHeader(buf []byte) bool {
lines := bytes.Split(buf, []byte("\r\n"))
if len(lines) < 1 {
return false
}
startLine := string(lines[0])
if !requestLine.MatchString(startLine) && !responseLine.MatchString(startLine) {
return false
@@ -118,83 +147,100 @@ func isHTTPHeader(buf []byte) bool {
if len(line) == 0 {
break
}
if !bytes.Contains(line, []byte(":")) {
colonIdx := bytes.IndexByte(line, ':')
if colonIdx <= 0 {
return false
}
}
return true
}
func (cw *CustomWriter) Write(p []byte) (int, error) {
if len(p) == len(types.BadGatewayResponse) && bytes.Equal(p, types.BadGatewayResponse) {
return cw.writer.Write(p)
func (cw *customWriter) Write(p []byte) (int, error) {
if cw.respHeader != nil && len(cw.buf) == 0 && len(p) >= 5 && string(p[0:5]) == "HTTP/" {
cw.respHeader = nil
}
if cw.respHeader != nil {
n, err := cw.writer.Write(p)
if err != nil {
return n, err
}
return n, nil
}
cw.buf = append(cw.buf, p...)
// TODO: implement middleware buat cache system dll
if idx := bytes.Index(cw.buf, DELIMITER); idx != -1 {
header := cw.buf[:idx+len(DELIMITER)]
body := cw.buf[idx+len(DELIMITER):]
if isHTTPHeader(header) {
resphf := NewResponseHeaderFactory(header)
for _, m := range cw.respMW {
err := m.HandleResponse(resphf, body)
if err != nil {
log.Printf("Cannot apply middleware: %s\n", err)
return 0, err
}
}
header = resphf.Finalize()
cw.respHeader = resphf
_, err := cw.writer.Write(header)
if err != nil {
return 0, err
}
idx := bytes.Index(cw.buf, DELIMITER)
if idx == -1 {
return len(p), nil
}
if len(body) > 0 {
_, err := cw.writer.Write(body)
if err != nil {
return 0, err
}
}
cw.buf = nil
return len(p), nil
header := cw.buf[:idx+len(DELIMITER)]
body := cw.buf[idx+len(DELIMITER):]
if !isHTTPHeader(header) {
_, err := cw.writer.Write(cw.buf)
cw.buf = nil
if err != nil {
return 0, err
}
return len(p), nil
}
cw.buf = nil
n, err := cw.writer.Write(p)
if err != nil {
return n, err
}
resphf := NewResponseHeaderFactory(header)
for _, m := range cw.respMW {
err := m.HandleResponse(cw.respHeader, p)
err := m.HandleResponse(resphf, body)
if err != nil {
log.Printf("Cannot apply middleware: %s\n", err)
return 0, err
}
}
return n, nil
}
header = resphf.Finalize()
cw.respHeader = resphf
func (cw *CustomWriter) AddInteraction(interaction Interaction) {
cw.interaction = interaction
_, err := cw.writer.Write(header)
if err != nil {
return 0, err
}
if len(body) > 0 {
_, err = cw.writer.Write(body)
if err != nil {
return 0, err
}
}
cw.buf = nil
return len(p), nil
}
var redirectTLS = false
func NewHTTPServer() error {
listener, err := net.Listen("tcp", ":80")
type HTTPServer interface {
ListenAndServe() error
ListenAndServeTLS() error
handler(conn net.Conn)
handlerTLS(conn net.Conn)
}
type httpServer struct {
sessionRegistry session.Registry
}
func NewHTTPServer(sessionRegistry session.Registry) HTTPServer {
return &httpServer{sessionRegistry: sessionRegistry}
}
func (hs *httpServer) ListenAndServe() error {
httpPort := config.Getenv("HTTP_PORT", "8080")
listener, err := net.Listen("tcp", ":"+httpPort)
if err != nil {
return errors.New("Error listening: " + err.Error())
}
if utils.Getenv("tls_enabled") == "true" && utils.Getenv("tls_redirect") == "true" {
if config.Getenv("TLS_ENABLED", "false") == "true" && config.Getenv("TLS_REDIRECT", "false") == "true" {
redirectTLS = true
}
go func() {
for {
conn, err := listener.Accept()
var conn net.Conn
conn, err = listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
@@ -203,13 +249,13 @@ func NewHTTPServer() error {
continue
}
go Handler(conn)
go hs.handler(conn)
}
}()
return nil
}
func Handler(conn net.Conn) {
func (hs *httpServer) handler(conn net.Conn) {
defer func() {
err := conn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
@@ -239,8 +285,8 @@ func Handler(conn net.Conn) {
slug := host[0]
if redirectTLS {
_, err := conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
fmt.Sprintf("Location: https://%s.%s/\r\n", slug, utils.Getenv("domain")) +
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
fmt.Sprintf("Location: https://%s.%s/\r\n", slug, config.Getenv("DOMAIN", "localhost")) +
"Content-Length: 0\r\n" +
"Connection: close\r\n" +
"\r\n"))
@@ -252,8 +298,7 @@ func Handler(conn net.Conn) {
}
if slug == "ping" {
// TODO: implement cors
_, err := conn.Write([]byte(
_, err = conn.Write([]byte(
"HTTP/1.1 200 OK\r\n" +
"Content-Length: 0\r\n" +
"Connection: close\r\n" +
@@ -269,9 +314,12 @@ func Handler(conn net.Conn) {
return
}
sshSession, ok := session.Clients[slug]
if !ok {
_, err := conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
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" +
"Connection: close\r\n" +
@@ -280,59 +328,68 @@ func Handler(conn net.Conn) {
log.Println("Failed to write 301 Moved Permanently:", err)
return
}
err = conn.Close()
if err != nil {
log.Println("Failed to close connection:", err)
return
}
return
}
cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr())
cw.SetInteraction(sshSession.Interaction)
forwardRequest(cw, reqhf, sshSession)
return
}
func forwardRequest(cw *CustomWriter, initialRequest *RequestHeaderFactory, sshSession *session.SSHSession) {
payload := sshSession.Forwarder.CreateForwardedTCPIPPayload(cw.RemoteAddr)
channel, reqs, err := sshSession.Lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload)
if err != nil {
log.Printf("Failed to open forwarded-tcpip channel: %v", err)
return
func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSession *session.SSHSession) {
payload := sshSession.GetForwarder().CreateForwardedTCPIPPayload(cw.GetRemoteAddr())
type channelResult struct {
channel ssh.Channel
reqs <-chan *ssh.Request
err error
}
resultChan := make(chan channelResult, 1)
go func() {
for req := range reqs {
err := req.Reply(false, nil)
if err != nil {
log.Printf("Failed to reply to request: %v", err)
return
}
}
channel, reqs, err := sshSession.GetLifecycle().GetConnection().OpenChannel("forwarded-tcpip", payload)
resultChan <- channelResult{channel, reqs, err}
}()
_, err = channel.Write(initialRequest.Finalize())
if err != nil {
log.Printf("Failed to forward request: %v", err)
var channel ssh.Channel
var reqs <-chan *ssh.Request
select {
case result := <-resultChan:
if result.err != nil {
log.Printf("Failed to open forwarded-tcpip channel: %v", result.err)
sshSession.GetForwarder().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())
return
}
//TODO: Implement wrapper func buat add/remove middleware
go ssh.DiscardRequests(reqs)
fingerprintMiddleware := NewTunnelFingerprint()
loggerMiddleware := NewRequestLogger(cw.interaction, cw.RemoteAddr)
cw.respMW = append(cw.respMW, fingerprintMiddleware)
cw.reqStartMW = append(cw.reqStartMW, loggerMiddleware)
forwardedForMiddleware := NewForwardedFor(cw.GetRemoteAddr())
//TODO: Tambah req Middleware
cw.reqEndMW = nil
cw.reqHeader = initialRequest
cw.AddResponseMiddleware(fingerprintMiddleware)
cw.AddRequestStartMiddleware(forwardedForMiddleware)
cw.SetRequestHeader(initialRequest)
for _, m := range cw.reqStartMW {
err := m.HandleRequest(cw.reqHeader)
if err != nil {
for _, m := range cw.GetRequestStartMiddleware() {
if err := m.HandleRequest(initialRequest); err != nil {
log.Printf("Error handling request: %v", err)
return
}
}
sshSession.Forwarder.HandleConnection(cw, channel, cw.RemoteAddr)
_, err := channel.Write(initialRequest.Finalize())
if err != nil {
log.Printf("Failed to forward request: %v", err)
return
}
sshSession.GetForwarder().HandleConnection(cw, channel, cw.GetRemoteAddr())
return
}

View File

@@ -8,25 +8,28 @@ import (
"log"
"net"
"strings"
"tunnel_pls/session"
"tunnel_pls/utils"
"tunnel_pls/internal/config"
"tunnel_pls/types"
)
func NewHTTPSServer() error {
cert, err := tls.LoadX509KeyPair(utils.Getenv("cert_loc"), utils.Getenv("key_loc"))
func (hs *httpServer) ListenAndServeTLS() error {
domain := config.Getenv("DOMAIN", "localhost")
httpsPort := config.Getenv("HTTPS_PORT", "8443")
tlsConfig, err := NewTLSConfig(domain)
if err != nil {
return err
return fmt.Errorf("failed to initialize TLS config: %w", err)
}
config := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, err := tls.Listen("tcp", ":443", config)
ln, err := tls.Listen("tcp", ":"+httpsPort, tlsConfig)
if err != nil {
return err
}
go func() {
for {
conn, err := ln.Accept()
var conn net.Conn
conn, err = ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
log.Println("https server closed")
@@ -35,13 +38,13 @@ func NewHTTPSServer() error {
continue
}
go HandlerTLS(conn)
go hs.handlerTLS(conn)
}
}()
return nil
}
func HandlerTLS(conn net.Conn) {
func (hs *httpServer) handlerTLS(conn net.Conn) {
defer func() {
err := conn.Close()
if err != nil {
@@ -60,24 +63,18 @@ func HandlerTLS(conn net.Conn) {
host := strings.Split(reqhf.Get("Host"), ".")
if len(host) < 1 {
_, err := conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
_, err = conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
if err != nil {
log.Println("Failed to write 400 Bad Request:", err)
return
}
err = conn.Close()
if err != nil {
log.Println("Failed to close connection:", err)
return
}
return
}
slug := host[0]
if slug == "ping" {
// TODO: implement cors
_, err := conn.Write([]byte(
_, err = conn.Write([]byte(
"HTTP/1.1 200 OK\r\n" +
"Content-Length: 0\r\n" +
"Connection: close\r\n" +
@@ -93,9 +90,12 @@ func HandlerTLS(conn net.Conn) {
return
}
sshSession, ok := session.Clients[slug]
if !ok {
_, err := conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
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" +
"Connection: close\r\n" +
@@ -104,15 +104,9 @@ func HandlerTLS(conn net.Conn) {
log.Println("Failed to write 301 Moved Permanently:", err)
return
}
err = conn.Close()
if err != nil {
log.Println("Failed to close connection:", err)
return
}
return
}
cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr())
cw.SetInteraction(sshSession.Interaction)
forwardRequest(cw, reqhf, sshSession)
return
}

View File

@@ -1,17 +1,15 @@
package server
import (
"fmt"
"net"
"time"
)
type RequestMiddleware interface {
HandleRequest(header *RequestHeaderFactory) error
HandleRequest(header RequestHeaderManager) error
}
type ResponseMiddleware interface {
HandleResponse(header *ResponseHeaderFactory, body []byte) error
HandleResponse(header ResponseHeaderManager, body []byte) error
}
type TunnelFingerprint struct{}
@@ -19,145 +17,25 @@ type TunnelFingerprint struct{}
func NewTunnelFingerprint() *TunnelFingerprint {
return &TunnelFingerprint{}
}
func (h *TunnelFingerprint) HandleRequest(header *RequestHeaderFactory) error {
return nil
}
func (h *TunnelFingerprint) HandleResponse(header *ResponseHeaderFactory, body []byte) error {
func (h *TunnelFingerprint) HandleResponse(header ResponseHeaderManager, body []byte) error {
header.Set("Server", "Tunnel Please")
return nil
}
type RequestLogger struct {
interaction Interaction
remoteAddr net.Addr
type ForwardedFor struct {
addr net.Addr
}
func NewRequestLogger(interaction Interaction, remoteAddr net.Addr) *RequestLogger {
return &RequestLogger{
interaction: interaction,
remoteAddr: remoteAddr,
func NewForwardedFor(addr net.Addr) *ForwardedFor {
return &ForwardedFor{addr: addr}
}
func (ff *ForwardedFor) HandleRequest(header RequestHeaderManager) error {
host, _, err := net.SplitHostPort(ff.addr.String())
if err != nil {
return err
}
}
func (rl *RequestLogger) HandleRequest(header *RequestHeaderFactory) error {
rl.interaction.SendMessage(fmt.Sprintf("\033[32m%s %s -> %s %s \033[0m\r\n", time.Now().UTC().Format(time.RFC3339), rl.remoteAddr.String(), header.Method, header.Path))
header.Set("X-Forwarded-For", host)
return nil
}
func (rl *RequestLogger) HandleResponse(header *ResponseHeaderFactory, body []byte) error { return nil }
//TODO: Implement caching atau enggak
//const maxCacheSize = 50 * 1024 * 1024
//
//type DiskCacheMiddleware struct {
// dir string
// mu sync.Mutex
// file *os.File
// path string
// cacheable bool
//}
//
//func NewDiskCacheMiddleware() *DiskCacheMiddleware {
// return &DiskCacheMiddleware{dir: "cache"}
//}
//
//func (c *DiskCacheMiddleware) ensureDir() error {
// return os.MkdirAll(c.dir, 0755)
//}
//
//func (c *DiskCacheMiddleware) cacheKey(method, path string) string {
// return fmt.Sprintf("%s_%s.cache", method, base64.URLEncoding.EncodeToString([]byte(path)))
//}
//
//func (c *DiskCacheMiddleware) filePath(method, path string) string {
// return filepath.Join(c.dir, c.cacheKey(method, path))
//}
//
//func fileExists(path string) bool {
// _, err := os.Stat(path)
// if err == nil {
// return true
// }
// if os.IsNotExist(err) {
// return false
// }
// return false
//}
//
//func canCacheRequest(header *RequestHeaderFactory) bool {
// if header.Method != "GET" {
// return false
// }
//
// if cacheControl := header.Get("Cache-Control"); cacheControl != "" {
// if strings.Contains(cacheControl, "no-store") || strings.Contains(cacheControl, "private") || strings.Contains(cacheControl, "no-cache") || strings.Contains(cacheControl, "max-age=0") {
// return false
// }
// }
//
// if header.Get("Authorization") != "" {
// return false
// }
//
// if header.Get("Cookie") != "" {
// return false
// }
//
// return true
//}
//
//func (c *DiskCacheMiddleware) HandleRequest(header *RequestHeaderFactory) error {
// if !canCacheRequest(header) {
// c.cacheable = false
// return nil
// }
//
// c.cacheable = true
// _ = c.ensureDir()
// path := c.filePath(header.Method, header.Path)
//
// if fileExists(path + ".finish") {
// c.file = nil
// return nil
// }
//
// if c.file != nil {
// err := c.file.Close()
// if err != nil {
// return err
// }
// err = os.Rename(c.path, c.path+".finish")
// if err != nil {
// return err
// }
// }
//
// c.path = path
// f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
// if err != nil {
// return err
// }
//
// c.file = f
//
// return nil
//}
//
//func (c *DiskCacheMiddleware) HandleResponse(header *ResponseHeaderFactory, body []byte) error {
// if !c.cacheable {
// return nil
// }
//
// if c.file == nil {
// header.Set("X-Cache", "HIT")
// return nil
// }
//
// _, err := c.file.Write(body)
// if err != nil {
// return err
// }
//
// header.Set("X-Cache", "MISS")
// return nil
//}

View File

@@ -1,52 +1,60 @@
package server
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"tunnel_pls/utils"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/internal/grpc/client"
"tunnel_pls/session"
"golang.org/x/crypto/ssh"
)
type Server struct {
Conn *net.Listener
Config *ssh.ServerConfig
HttpServer *http.Server
conn *net.Listener
config *ssh.ServerConfig
sessionRegistry session.Registry
grpcClient *client.Client
}
func NewServer(config ssh.ServerConfig) *Server {
listener, err := net.Listen("tcp", fmt.Sprintf(":%s", utils.Getenv("port")))
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)
return nil
return nil, err
}
if utils.Getenv("tls_enabled") == "true" {
go func() {
err := NewHTTPSServer()
if err != nil {
log.Fatalf("failed to start https server: %v", err)
}
return
}()
HttpServer := NewHTTPServer(sessionRegistry)
err = HttpServer.ListenAndServe()
if err != nil {
log.Fatalf("failed to start http server: %v", err)
return nil, err
}
go func() {
err := NewHTTPServer()
if config.Getenv("TLS_ENABLED", "false") == "true" {
err = HttpServer.ListenAndServeTLS()
if err != nil {
log.Fatalf("failed to start http server: %v", err)
log.Fatalf("failed to start https server: %v", err)
return nil, err
}
}()
return &Server{
Conn: &listener,
Config: &config,
}
return &Server{
conn: &listener,
config: sshConfig,
sessionRegistry: sessionRegistry,
grpcClient: grpcClient,
}, nil
}
func (s *Server) Start() {
log.Println("SSH server is starting on port 2200...")
for {
conn, err := (*s.Conn).Accept()
conn, err := (*s.conn).Accept()
if err != nil {
log.Printf("failed to accept connection: %v", err)
continue
@@ -55,3 +63,40 @@ func (s *Server) Start() {
go s.handleConnection(conn)
}
}
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()
if err != nil {
log.Printf("failed to close SSH connection: %v", err)
return
}
return
}
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)
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)
return
}
return
}

336
server/tls.go Normal file
View File

@@ -0,0 +1,336 @@
package server
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
"sync"
"time"
"tunnel_pls/internal/config"
"github.com/caddyserver/certmagic"
"github.com/libdns/cloudflare"
)
type TLSManager interface {
userCertsExistAndValid() bool
loadUserCerts() error
startCertWatcher()
initCertMagic() error
getTLSConfig() *tls.Config
getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
}
type tlsManager struct {
domain string
certPath string
keyPath string
storagePath string
userCert *tls.Certificate
userCertMu sync.RWMutex
magic *certmagic.Config
useCertMagic bool
}
var globalTLSManager TLSManager
var tlsManagerOnce sync.Once
func NewTLSConfig(domain string) (*tls.Config, error) {
var initErr error
tlsManagerOnce.Do(func() {
certPath := "certs/tls/cert.pem"
keyPath := "certs/tls/privkey.pem"
storagePath := "certs/tls/certmagic"
tm := &tlsManager{
domain: domain,
certPath: certPath,
keyPath: keyPath,
storagePath: storagePath,
}
if tm.userCertsExistAndValid() {
log.Printf("Using user-provided certificates from %s and %s", certPath, keyPath)
if err := tm.loadUserCerts(); err != nil {
initErr = fmt.Errorf("failed to load user certificates: %w", err)
return
}
tm.useCertMagic = false
tm.startCertWatcher()
} else {
if !isACMEConfigComplete() {
log.Printf("User certificates missing or invalid, and ACME configuration is incomplete")
log.Printf("To enable automatic certificate generation, set CF_API_TOKEN environment variable")
initErr = fmt.Errorf("no valid certificates found and ACME configuration is incomplete (CF_API_TOKEN is required)")
return
}
log.Printf("User certificates missing or don't cover %s and *.%s, using CertMagic", domain, domain)
if err := tm.initCertMagic(); err != nil {
initErr = fmt.Errorf("failed to initialize CertMagic: %w", err)
return
}
tm.useCertMagic = true
}
globalTLSManager = tm
})
if initErr != nil {
return nil, initErr
}
return globalTLSManager.getTLSConfig(), nil
}
func isACMEConfigComplete() bool {
cfAPIToken := config.Getenv("CF_API_TOKEN", "")
return cfAPIToken != ""
}
func (tm *tlsManager) userCertsExistAndValid() bool {
if _, err := os.Stat(tm.certPath); os.IsNotExist(err) {
log.Printf("Certificate file not found: %s", tm.certPath)
return false
}
if _, err := os.Stat(tm.keyPath); os.IsNotExist(err) {
log.Printf("Key file not found: %s", tm.keyPath)
return false
}
return ValidateCertDomains(tm.certPath, tm.domain)
}
func ValidateCertDomains(certPath, domain string) bool {
certPEM, err := os.ReadFile(certPath)
if err != nil {
log.Printf("Failed to read certificate: %v", err)
return false
}
block, _ := pem.Decode(certPEM)
if block == nil {
log.Printf("Failed to decode PEM block from certificate")
return false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
log.Printf("Failed to parse certificate: %v", err)
return false
}
if time.Now().After(cert.NotAfter) {
log.Printf("Certificate has expired (NotAfter: %v)", cert.NotAfter)
return false
}
if time.Now().Add(30 * 24 * time.Hour).After(cert.NotAfter) {
log.Printf("Certificate expiring soon (NotAfter: %v), will use CertMagic for renewal", cert.NotAfter)
return false
}
var certDomains []string
if cert.Subject.CommonName != "" {
certDomains = append(certDomains, cert.Subject.CommonName)
}
certDomains = append(certDomains, cert.DNSNames...)
hasBase := false
hasWildcard := false
wildcardDomain := "*." + domain
for _, d := range certDomains {
if d == domain {
hasBase = true
}
if d == wildcardDomain {
hasWildcard = true
}
}
if !hasBase {
log.Printf("Certificate does not cover base domain: %s", domain)
}
if !hasWildcard {
log.Printf("Certificate does not cover wildcard domain: %s", wildcardDomain)
}
return hasBase && hasWildcard
}
func (tm *tlsManager) loadUserCerts() error {
cert, err := tls.LoadX509KeyPair(tm.certPath, tm.keyPath)
if err != nil {
return err
}
tm.userCertMu.Lock()
tm.userCert = &cert
tm.userCertMu.Unlock()
log.Printf("Loaded user certificates successfully")
return nil
}
func (tm *tlsManager) startCertWatcher() {
go func() {
var lastCertMod, lastKeyMod time.Time
if info, err := os.Stat(tm.certPath); err == nil {
lastCertMod = info.ModTime()
}
if info, err := os.Stat(tm.keyPath); err == nil {
lastKeyMod = info.ModTime()
}
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
certInfo, certErr := os.Stat(tm.certPath)
keyInfo, keyErr := os.Stat(tm.keyPath)
if certErr != nil || keyErr != nil {
continue
}
if certInfo.ModTime().After(lastCertMod) || keyInfo.ModTime().After(lastKeyMod) {
log.Printf("Certificate files changed, reloading...")
if !ValidateCertDomains(tm.certPath, tm.domain) {
log.Printf("New certificates don't cover required domains")
if !isACMEConfigComplete() {
log.Printf("Cannot switch to CertMagic: ACME configuration is incomplete (CF_API_TOKEN is required)")
continue
}
log.Printf("Switching to CertMagic for automatic certificate management")
if err := tm.initCertMagic(); err != nil {
log.Printf("Failed to initialize CertMagic: %v", err)
continue
}
tm.useCertMagic = true
return
}
if err := tm.loadUserCerts(); err != nil {
log.Printf("Failed to reload certificates: %v", err)
continue
}
lastCertMod = certInfo.ModTime()
lastKeyMod = keyInfo.ModTime()
log.Printf("Certificates reloaded successfully")
}
}
}()
}
func (tm *tlsManager) initCertMagic() error {
if err := os.MkdirAll(tm.storagePath, 0700); err != nil {
return fmt.Errorf("failed to create cert storage directory: %w", err)
}
acmeEmail := config.Getenv("ACME_EMAIL", "admin@"+tm.domain)
cfAPIToken := config.Getenv("CF_API_TOKEN", "")
acmeStaging := config.Getenv("ACME_STAGING", "false") == "true"
if cfAPIToken == "" {
return fmt.Errorf("CF_API_TOKEN environment variable is required for automatic certificate generation")
}
cfProvider := &cloudflare.Provider{
APIToken: cfAPIToken,
}
storage := &certmagic.FileStorage{Path: tm.storagePath}
cache := certmagic.NewCache(certmagic.CacheOptions{
GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) {
return tm.magic, nil
},
})
magic := certmagic.New(cache, certmagic.Config{
Storage: storage,
})
acmeIssuer := certmagic.NewACMEIssuer(magic, certmagic.ACMEIssuer{
Email: acmeEmail,
Agreed: true,
DNS01Solver: &certmagic.DNS01Solver{
DNSManager: certmagic.DNSManager{
DNSProvider: cfProvider,
},
},
})
if acmeStaging {
acmeIssuer.CA = certmagic.LetsEncryptStagingCA
log.Printf("Using Let's Encrypt staging server")
} else {
acmeIssuer.CA = certmagic.LetsEncryptProductionCA
log.Printf("Using Let's Encrypt production server")
}
magic.Issuers = []certmagic.Issuer{acmeIssuer}
tm.magic = magic
domains := []string{tm.domain, "*." + tm.domain}
log.Printf("Requesting certificates for: %v", domains)
ctx := context.Background()
if err := magic.ManageSync(ctx, domains); err != nil {
return fmt.Errorf("failed to obtain certificates: %w", err)
}
log.Printf("Certificates obtained successfully for %v", domains)
return nil
}
func (tm *tlsManager) getTLSConfig() *tls.Config {
return &tls.Config{
GetCertificate: tm.getCertificate,
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
SessionTicketsDisabled: false,
CipherSuites: []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_CHACHA20_POLY1305_SHA256,
},
CurvePreferences: []tls.CurveID{
tls.X25519,
},
ClientAuth: tls.NoClientCert,
NextProtos: nil,
}
}
func (tm *tlsManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
if tm.useCertMagic {
return tm.magic.GetCertificate(hello)
}
tm.userCertMu.RLock()
defer tm.userCertMu.RUnlock()
if tm.userCert == nil {
return nil, fmt.Errorf("no certificate available")
}
return tm.userCert, nil
}

View File

@@ -8,18 +8,44 @@ import (
"log"
"net"
"strconv"
"sync"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/session/slug"
"tunnel_pls/types"
"golang.org/x/crypto/ssh"
)
var bufferPool = sync.Pool{
New: func() interface{} {
bufSize := config.GetBufferSize()
return make([]byte, bufSize)
},
}
func copyWithBuffer(dst io.Writer, src io.Reader) (written int64, err error) {
buf := bufferPool.Get().([]byte)
defer bufferPool.Put(buf)
return io.CopyBuffer(dst, src, buf)
}
type Forwarder struct {
Listener net.Listener
TunnelType types.TunnelType
ForwardedPort uint16
SlugManager slug.Manager
Lifecycle Lifecycle
listener net.Listener
tunnelType types.TunnelType
forwardedPort uint16
slugManager slug.Manager
lifecycle Lifecycle
}
func NewForwarder(slugManager slug.Manager) *Forwarder {
return &Forwarder{
listener: nil,
tunnelType: "",
forwardedPort: 0,
slugManager: slugManager,
lifecycle: nil,
}
}
type Lifecycle interface {
@@ -42,7 +68,7 @@ type ForwardingController interface {
}
func (f *Forwarder) SetLifecycle(lifecycle Lifecycle) {
f.Lifecycle = lifecycle
f.lifecycle = lifecycle
}
func (f *Forwarder) AcceptTCPConnections() {
@@ -55,28 +81,57 @@ func (f *Forwarder) AcceptTCPConnections() {
log.Printf("Error accepting connection: %v", err)
continue
}
payload := f.CreateForwardedTCPIPPayload(conn.RemoteAddr())
channel, reqs, err := f.Lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload)
if err != nil {
log.Printf("Failed to open forwarded-tcpip channel: %v", err)
return
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
log.Printf("Failed to set connection deadline: %v", err)
if closeErr := conn.Close(); closeErr != nil {
log.Printf("Failed to close connection: %v", closeErr)
}
continue
}
payload := f.CreateForwardedTCPIPPayload(conn.RemoteAddr())
type channelResult struct {
channel ssh.Channel
reqs <-chan *ssh.Request
err error
}
resultChan := make(chan channelResult, 1)
go func() {
for req := range reqs {
err := req.Reply(false, nil)
if err != nil {
log.Printf("Failed to reply to request: %v", err)
return
}
}
channel, reqs, err := f.lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload)
resultChan <- channelResult{channel, reqs, err}
}()
go f.HandleConnection(conn, channel, conn.RemoteAddr())
select {
case result := <-resultChan:
if result.err != nil {
log.Printf("Failed to open forwarded-tcpip channel: %v", result.err)
if closeErr := conn.Close(); closeErr != nil {
log.Printf("Failed to close connection: %v", closeErr)
}
continue
}
if err := conn.SetDeadline(time.Time{}); err != nil {
log.Printf("Failed to clear connection deadline: %v", err)
}
go ssh.DiscardRequests(result.reqs)
go f.HandleConnection(conn, result.channel, conn.RemoteAddr())
case <-time.After(5 * time.Second):
log.Printf("Timeout opening forwarded-tcpip channel")
if closeErr := conn.Close(); closeErr != nil {
log.Printf("Failed to close connection: %v", closeErr)
}
}
}
}
func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteAddr net.Addr) {
defer func(src ssh.Channel) {
defer func() {
_, err := io.Copy(io.Discard, src)
if err != nil {
log.Printf("Failed to discard connection: %v", err)
@@ -84,48 +139,63 @@ func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteA
err = src.Close()
if err != nil && !errors.Is(err, io.EOF) {
log.Printf("Error closing connection: %v", err)
log.Printf("Error closing source channel: %v", err)
}
}(src)
log.Printf("Handling new forwarded connection from %s", remoteAddr)
go func() {
_, err := io.Copy(src, dst)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
log.Printf("Error copying from conn.Reader to channel: %v", err)
if closer, ok := dst.(io.Closer); ok {
err = closer.Close()
if err != nil && !errors.Is(err, io.EOF) {
log.Printf("Error closing destination connection: %v", err)
}
}
}()
_, err := io.Copy(dst, src)
log.Printf("Handling new forwarded connection from %s", remoteAddr)
if err != nil && !errors.Is(err, io.EOF) {
log.Printf("Error copying from channel to conn.Writer: %v", err)
}
return
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, err := copyWithBuffer(dst, src)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
log.Printf("Error copying src→dst: %v", err)
}
}()
go func() {
defer wg.Done()
_, err := copyWithBuffer(src, dst)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
log.Printf("Error copying dst→src: %v", err)
}
}()
wg.Wait()
}
func (f *Forwarder) SetType(tunnelType types.TunnelType) {
f.TunnelType = tunnelType
f.tunnelType = tunnelType
}
func (f *Forwarder) GetTunnelType() types.TunnelType {
return f.TunnelType
return f.tunnelType
}
func (f *Forwarder) GetForwardedPort() uint16 {
return f.ForwardedPort
return f.forwardedPort
}
func (f *Forwarder) SetForwardedPort(port uint16) {
f.ForwardedPort = port
f.forwardedPort = port
}
func (f *Forwarder) SetListener(listener net.Listener) {
f.Listener = listener
f.listener = listener
}
func (f *Forwarder) GetListener() net.Listener {
return f.Listener
return f.listener
}
func (f *Forwarder) WriteBadGatewayResponse(dst io.Writer) {
@@ -137,8 +207,8 @@ func (f *Forwarder) WriteBadGatewayResponse(dst io.Writer) {
}
func (f *Forwarder) Close() error {
if f.GetTunnelType() != types.HTTP {
return f.Listener.Close()
if f.GetListener() != nil {
return f.listener.Close()
}
return nil
}

View File

@@ -7,10 +7,9 @@ import (
"log"
"net"
portUtil "tunnel_pls/internal/port"
"tunnel_pls/internal/random"
"tunnel_pls/types"
"tunnel_pls/utils"
"golang.org/x/crypto/ssh"
)
@@ -19,10 +18,28 @@ var blockedReservedPorts = []uint16{1080, 1433, 1521, 1900, 2049, 3306, 3389, 54
func (s *SSHSession) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
for req := range GlobalRequest {
switch req.Type {
case "tcpip-forward":
s.HandleTCPIPForward(req)
return
case "shell", "pty-req", "window-change":
case "shell", "pty-req":
err := req.Reply(true, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
case "window-change":
p := req.Payload
if len(p) < 16 {
log.Println("invalid window-change payload")
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
return
}
cols := binary.BigEndian.Uint32(p[0:4])
rows := binary.BigEndian.Uint32(p[4:8])
s.interaction.SetWH(int(cols), int(rows))
err := req.Reply(true, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
@@ -52,7 +69,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
log.Println("Failed to reply to request:", err)
return
}
err = s.Lifecycle.Close()
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
@@ -62,13 +79,12 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
var rawPortToBind uint32
if err := binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil {
log.Println("Failed to read port from payload:", err)
s.Interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port. (02) \r\n", rawPortToBind))
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.Lifecycle.Close()
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
@@ -76,13 +92,13 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
}
if rawPortToBind > 65535 {
s.Interaction.SendMessage(fmt.Sprintf("Port %d is larger then allowed port of 65535. (02)\r\n", rawPortToBind))
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()
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
@@ -90,163 +106,183 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
}
portToBind := uint16(rawPortToBind)
if isBlockedPort(portToBind) {
s.Interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port. (02)\r\n", 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()
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
s.Interaction.SendMessage("\033[H\033[2J")
s.Lifecycle.SetStatus(types.RUNNING)
go s.Interaction.HandleUserInput()
if portToBind == 80 || portToBind == 443 {
s.HandleHTTPForward(req, portToBind)
return
} else {
if portToBind == 0 {
unassign, success := portUtil.Manager.GetUnassignedPort()
portToBind = unassign
if !success {
s.Interaction.SendMessage("No available port\r\n")
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.Manager.GetPortStatus(portToBind); isExist && isUse {
s.Interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port. (03)\r\n", 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()
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
err := portUtil.Manager.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) {
s.Forwarder.SetType(types.HTTP)
s.Forwarder.SetForwardedPort(portToBind)
slug := generateUniqueSlug()
if slug == "" {
} 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
}
return
}
s.SlugManager.Set(slug)
registerClient(slug, s)
buf := new(bytes.Buffer)
err := binary.Write(buf, binary.BigEndian, uint32(80))
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
log.Printf("HTTP forwarding approved on port: %d", 80)
domain := utils.Getenv("domain")
protocol := "http"
if utils.Getenv("tls_enabled") == "true" {
protocol = "https"
}
s.Interaction.ShowWelcomeMessage()
s.Interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to %s://%s.%s\r\n", protocol, slug, domain))
err = req.Reply(true, buf.Bytes())
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
}
func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) {
s.Forwarder.SetType(types.TCP)
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 {
s.Interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port.\r\n", portToBind))
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.Lifecycle.Close()
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
s.Forwarder.SetListener(listener)
s.Forwarder.SetForwardedPort(portToBind)
s.Interaction.ShowWelcomeMessage()
s.Interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to tcp://%s:%d \r\n", utils.Getenv("domain"), s.Forwarder.GetForwardedPort()))
err = portUtil.Default.SetPortStatus(portToBind, true)
if err != nil {
log.Println("Failed to set port status:", err)
return
}
go s.Forwarder.AcceptTCPConnections()
s.HandleTCPForward(req, addr, portToBind)
}
func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
slug := random.GenerateRandomString(20)
key := types.SessionKey{Id: slug, Type: types.HTTP}
if !s.registry.Register(key, s) {
log.Printf("Failed to register client with slug: %s", slug)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
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(key)
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
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(key)
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
return
}
s.forwarder.SetType(types.HTTP)
s.forwarder.SetForwardedPort(portToBind)
s.slugManager.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 {
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
}
key := types.SessionKey{Id: fmt.Sprintf("%d", portToBind), Type: types.TCP}
if !s.registry.Register(key, s) {
log.Printf("Failed to register TCP client with id: %s", key.Id)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
if closeErr := listener.Close(); closeErr != nil {
log.Printf("Failed to close listener: %s", closeErr)
}
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
_ = s.lifecycle.Close()
return
}
buf := new(bytes.Buffer)
err = binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
log.Println("Failed to reply to request:", err)
log.Println("Failed to write port to buffer:", err)
s.registry.Remove(key)
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
}
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)
s.registry.Remove(key)
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
}
return
}
}
func generateUniqueSlug() string {
maxAttempts := 5
for i := 0; i < maxAttempts; i++ {
slug := utils.GenerateRandomString(20)
clientsMutex.RLock()
_, exists := Clients[slug]
clientsMutex.RUnlock()
if !exists {
return slug
}
}
log.Println("Failed to generate unique slug after multiple attempts")
return ""
s.forwarder.SetType(types.TCP)
s.forwarder.SetListener(listener)
s.forwarder.SetForwardedPort(portToBind)
s.slugManager.Set(key.Id)
s.lifecycle.SetStatus(types.RUNNING)
go s.forwarder.AcceptTCPConnections()
s.interaction.Start()
}
func readSSHString(reader *bytes.Reader) (string, error) {

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,10 @@ package lifecycle
import (
"errors"
"fmt"
"io"
"log"
"net"
"time"
portUtil "tunnel_pls/internal/port"
"tunnel_pls/session/slug"
"tunnel_pls/types"
@@ -14,109 +13,105 @@ import (
"golang.org/x/crypto/ssh"
)
type Interaction interface {
SendMessage(string)
}
type Forwarder interface {
Close() error
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
}
type Lifecycle struct {
Status types.Status
Conn ssh.Conn
Channel ssh.Channel
Interaction Interaction
Forwarder Forwarder
SlugManager slug.Manager
unregisterClient func(slug string)
type SessionRegistry interface {
Remove(key types.SessionKey)
}
func (l *Lifecycle) SetUnregisterClient(unregisterClient func(slug string)) {
l.unregisterClient = unregisterClient
type Lifecycle struct {
status types.Status
conn ssh.Conn
channel ssh.Channel
forwarder Forwarder
sessionRegistry SessionRegistry
slugManager slug.Manager
startedAt time.Time
user string
}
func NewLifecycle(conn ssh.Conn, forwarder Forwarder, slugManager slug.Manager, user string) *Lifecycle {
return &Lifecycle{
status: types.INITIALIZING,
conn: conn,
channel: nil,
forwarder: forwarder,
slugManager: slugManager,
sessionRegistry: nil,
startedAt: time.Now(),
user: user,
}
}
func (l *Lifecycle) SetSessionRegistry(registry SessionRegistry) {
l.sessionRegistry = registry
}
type SessionLifecycle interface {
Close() error
WaitForRunningStatus()
SetStatus(status types.Status)
GetConnection() ssh.Conn
GetChannel() ssh.Channel
GetUser() string
SetChannel(channel ssh.Channel)
SetUnregisterClient(unregisterClient func(slug string))
SetSessionRegistry(registry SessionRegistry)
IsActive() bool
StartedAt() time.Time
}
func (l *Lifecycle) GetUser() string {
return l.user
}
func (l *Lifecycle) GetChannel() ssh.Channel {
return l.Channel
return l.channel
}
func (l *Lifecycle) SetChannel(channel ssh.Channel) {
l.Channel = channel
l.channel = channel
}
func (l *Lifecycle) GetConnection() ssh.Conn {
return l.Conn
return l.conn
}
func (l *Lifecycle) SetStatus(status types.Status) {
l.Status = status
}
func (l *Lifecycle) WaitForRunningStatus() {
timeout := time.After(3 * time.Second)
ticker := time.NewTicker(150 * time.Millisecond)
defer ticker.Stop()
frames := []string{"-", "\\", "|", "/"}
i := 0
for {
select {
case <-ticker.C:
l.Interaction.SendMessage(fmt.Sprintf("\rLoading %s", frames[i]))
i = (i + 1) % len(frames)
if l.Status == types.RUNNING {
l.Interaction.SendMessage("\r\033[K")
return
}
case <-timeout:
l.Interaction.SendMessage("\r\033[K")
l.Interaction.SendMessage("TCP/IP request not received in time.\r\nCheck your internet connection and confirm the server responds within 3000ms.\r\nEnsure you ran the correct command. For more details, visit https://tunnl.live.\r\n\r\n")
err := l.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
log.Println("Timeout waiting for session to start running")
return
}
l.status = status
if status == types.RUNNING && l.startedAt.IsZero() {
l.startedAt = time.Now()
}
}
func (l *Lifecycle) Close() error {
err := l.Forwarder.Close()
err := l.forwarder.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
}
if l.Channel != nil {
err := l.Channel.Close()
if l.channel != nil {
err := l.channel.Close()
if err != nil && !errors.Is(err, io.EOF) {
return err
}
}
if l.Conn != nil {
err := l.Conn.Close()
if l.conn != nil {
err := l.conn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
}
}
clientSlug := l.SlugManager.Get()
if clientSlug != "" {
l.unregisterClient(clientSlug)
clientSlug := l.slugManager.Get()
if clientSlug != "" && l.sessionRegistry.Remove != nil {
key := types.SessionKey{Id: clientSlug, Type: l.forwarder.GetTunnelType()}
l.sessionRegistry.Remove(key)
}
if l.Forwarder.GetTunnelType() == types.TCP {
err := portUtil.Manager.SetPortStatus(l.Forwarder.GetForwardedPort(), false)
if l.forwarder.GetTunnelType() == types.TCP {
err = portUtil.Default.SetPortStatus(l.forwarder.GetForwardedPort(), false)
if err != nil {
return err
}
@@ -124,3 +119,11 @@ func (l *Lifecycle) Close() error {
return nil
}
func (l *Lifecycle) IsActive() bool {
return l.status == types.RUNNING
}
func (l *Lifecycle) StartedAt() time.Time {
return l.startedAt
}

309
session/registry.go Normal file
View File

@@ -0,0 +1,309 @@
package session
import (
"fmt"
"sync"
"tunnel_pls/types"
)
type Key = types.SessionKey
type Registry interface {
Get(key Key) (session *SSHSession, err error)
GetWithUser(user string, key Key) (session *SSHSession, err error)
Update(user string, oldKey, newKey Key) error
Register(key Key, session *SSHSession) (success bool)
Remove(key Key)
GetAllSessionFromUser(user string) []*SSHSession
}
type registry struct {
mu sync.RWMutex
byUser map[string]map[Key]*SSHSession
slugIndex map[Key]string
}
func NewRegistry() Registry {
return &registry{
byUser: make(map[string]map[Key]*SSHSession),
slugIndex: make(map[Key]string),
}
}
func (r *registry) Get(key Key) (session *SSHSession, err error) {
r.mu.RLock()
defer r.mu.RUnlock()
userID, ok := r.slugIndex[key]
if !ok {
return nil, fmt.Errorf("session not found")
}
client, ok := r.byUser[userID][key]
if !ok {
return nil, fmt.Errorf("session not found")
}
return client, nil
}
func (r *registry) GetWithUser(user string, key Key) (session *SSHSession, 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.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")
}
delete(r.byUser[user], oldKey)
delete(r.slugIndex, oldKey)
client.slugManager.Set(newKey.Id)
r.slugIndex[newKey] = user
if r.byUser[user] == nil {
r.byUser[user] = make(map[Key]*SSHSession)
}
r.byUser[user][newKey] = client
return nil
}
func (r *registry) Register(key Key, session *SSHSession) (success bool) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.slugIndex[key]; exists {
return false
}
userID := session.lifecycle.GetUser()
if r.byUser[userID] == nil {
r.byUser[userID] = make(map[Key]*SSHSession)
}
r.byUser[userID][key] = session
r.slugIndex[key] = userID
return true
}
func (r *registry) GetAllSessionFromUser(user string) []*SSHSession {
r.mu.RLock()
defer r.mu.RUnlock()
m := r.byUser[user]
if len(m) == 0 {
return []*SSHSession{}
}
sessions := make([]*SSHSession, 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
)

View File

@@ -1,23 +1,18 @@
package session
import (
"bytes"
"fmt"
"log"
"sync"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/session/forwarder"
"tunnel_pls/session/interaction"
"tunnel_pls/session/lifecycle"
"tunnel_pls/session/slug"
"tunnel_pls/types"
"golang.org/x/crypto/ssh"
)
var (
clientsMutex sync.RWMutex
Clients = make(map[string]*SSHSession)
)
type Session interface {
HandleGlobalRequest(ch <-chan *ssh.Request)
HandleTCPIPForward(req *ssh.Request)
@@ -26,104 +21,121 @@ type Session interface {
}
type SSHSession struct {
Lifecycle lifecycle.SessionLifecycle
Interaction interaction.Controller
Forwarder forwarder.ForwardingController
SlugManager slug.Manager
initialReq <-chan *ssh.Request
sshReqChannel <-chan ssh.NewChannel
lifecycle lifecycle.SessionLifecycle
interaction interaction.Controller
forwarder forwarder.ForwardingController
slugManager slug.Manager
registry Registry
}
func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel) {
func (s *SSHSession) GetLifecycle() lifecycle.SessionLifecycle {
return s.lifecycle
}
func (s *SSHSession) GetInteraction() interaction.Controller {
return s.interaction
}
func (s *SSHSession) GetForwarder() forwarder.ForwardingController {
return s.forwarder
}
func (s *SSHSession) GetSlugManager() slug.Manager {
return s.slugManager
}
func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel, sessionRegistry Registry, user string) *SSHSession {
slugManager := slug.NewManager()
forwarderManager := &forwarder.Forwarder{
Listener: nil,
TunnelType: "",
ForwardedPort: 0,
SlugManager: slugManager,
}
interactionManager := &interaction.Interaction{
CommandBuffer: bytes.NewBuffer(make([]byte, 0, 20)),
EditMode: false,
EditSlug: "",
SlugManager: slugManager,
Forwarder: forwarderManager,
Lifecycle: nil,
}
lifecycleManager := &lifecycle.Lifecycle{
Status: "",
Conn: conn,
Channel: nil,
Interaction: interactionManager,
Forwarder: forwarderManager,
SlugManager: slugManager,
}
forwarderManager := forwarder.NewForwarder(slugManager)
interactionManager := interaction.NewInteraction(slugManager, forwarderManager)
lifecycleManager := lifecycle.NewLifecycle(conn, forwarderManager, slugManager, user)
interactionManager.SetLifecycle(lifecycleManager)
interactionManager.SetSlugModificator(updateClientSlug)
forwarderManager.SetLifecycle(lifecycleManager)
lifecycleManager.SetUnregisterClient(unregisterClient)
session := &SSHSession{
Lifecycle: lifecycleManager,
Interaction: interactionManager,
Forwarder: forwarderManager,
SlugManager: slugManager,
interactionManager.SetSessionRegistry(sessionRegistry)
lifecycleManager.SetSessionRegistry(sessionRegistry)
return &SSHSession{
initialReq: forwardingReq,
sshReqChannel: sshChan,
lifecycle: lifecycleManager,
interaction: interactionManager,
forwarder: forwarderManager,
slugManager: slugManager,
registry: sessionRegistry,
}
}
go func() {
go session.Lifecycle.WaitForRunningStatus()
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"`
}
for channel := range sshChan {
ch, reqs, _ := channel.Accept()
if session.Lifecycle.GetChannel() == nil {
session.Lifecycle.SetChannel(ch)
session.Interaction.SetChannel(ch)
session.Lifecycle.SetStatus(types.SETUP)
go session.HandleGlobalRequest(forwardingReq)
}
go session.HandleGlobalRequest(reqs)
}
err := session.Lifecycle.Close()
func (s *SSHSession) Detail() Detail {
return Detail{
ForwardingType: string(s.forwarder.GetTunnelType()),
Slug: s.slugManager.Get(),
UserID: s.lifecycle.GetUser(),
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
}
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"))))
if err != nil {
return err
}
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
return
}()
}
func updateClientSlug(oldSlug, newSlug string) bool {
clientsMutex.Lock()
defer clientsMutex.Unlock()
if _, exists := Clients[newSlug]; exists && newSlug != oldSlug {
return false
return fmt.Errorf("no forwarding Request")
}
client, ok := Clients[oldSlug]
if !ok {
return false
s.lifecycle.SetChannel(ch)
s.interaction.SetChannel(ch)
s.HandleTCPIPForward(tcpipReq)
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
return err
}
delete(Clients, oldSlug)
client.SlugManager.Set(newSlug)
Clients[newSlug] = client
return true
return nil
}
func registerClient(slug string, session *SSHSession) bool {
clientsMutex.Lock()
defer clientsMutex.Unlock()
if _, exists := Clients[slug]; exists {
return false
func (s *SSHSession) waitForTCPIPForward() *ssh.Request {
select {
case req, ok := <-s.initialReq:
if !ok {
log.Println("Forwarding request channel closed")
return nil
}
if req.Type == "tcpip-forward" {
return req
}
if err := req.Reply(false, nil); err != nil {
log.Printf("Failed to reply to request: %v", err)
}
log.Printf("Expected tcpip-forward request, got: %s", req.Type)
return nil
case <-time.After(500 * time.Millisecond):
log.Println("No forwarding request received")
return nil
}
Clients[slug] = session
return true
}
func unregisterClient(slug string) {
clientsMutex.Lock()
defer clientsMutex.Unlock()
delete(Clients, slug)
}

View File

@@ -1,32 +1,24 @@
package slug
import "sync"
type Manager interface {
Get() string
Set(slug string)
}
type manager struct {
slug string
slugMu sync.RWMutex
slug string
}
func NewManager() Manager {
return &manager{
slug: "",
slugMu: sync.RWMutex{},
slug: "",
}
}
func (s *manager) Get() string {
s.slugMu.RLock()
defer s.slugMu.RUnlock()
return s.slug
}
func (s *manager) Set(slug string) {
s.slugMu.Lock()
s.slug = slug
s.slugMu.Unlock()
}

View File

@@ -11,10 +11,16 @@ const (
type TunnelType string
const (
HTTP TunnelType = "HTTP"
TCP TunnelType = "TCP"
UNKNOWN TunnelType = "UNKNOWN"
HTTP TunnelType = "HTTP"
TCP TunnelType = "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" +

View File

@@ -1,58 +0,0 @@
package utils
import (
"log"
"math/rand"
"os"
"strings"
"sync"
"time"
"github.com/joho/godotenv"
)
type Env struct {
value map[string]string
mu sync.Mutex
}
var env *Env
func init() {
env = &Env{value: map[string]string{}}
}
func GenerateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
seededRand := rand.New(rand.NewSource(time.Now().UnixNano() + int64(rand.Intn(9999))))
var result strings.Builder
for i := 0; i < length; i++ {
randomIndex := seededRand.Intn(len(charset))
result.WriteString(string(charset[randomIndex]))
}
return result.String()
}
func Getenv(key string) string {
env.mu.Lock()
defer env.mu.Unlock()
if val, ok := env.value[key]; ok {
return val
}
if os.Getenv("HOSTNAME") == "" {
err := godotenv.Load(".env")
if err != nil {
log.Fatalf("Error loading .env file: %s", err)
}
}
val := os.Getenv(key)
env.value[key] = val
if val == "" {
panic("Asking for env: " + key + " but got nothing, please set your environment first")
}
return val
}

17
version/version.go Normal file
View File

@@ -0,0 +1,17 @@
package version
import "fmt"
var (
Version = "dev"
BuildDate = "unknown"
Commit = "unknown"
)
func GetVersion() string {
return fmt.Sprintf("tunnel_pls %s (commit: %s, built: %s)", Version, Commit, BuildDate)
}
func GetShortVersion() string {
return Version
}