11 Commits

Author SHA1 Message Date
765d74d585 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1 2026-01-12 08:00:49 +00:00
2e8767f17a chore: upgrade TLS configuration to TLS 1.3
All checks were successful
renovate / renovate (push) Successful in 1m34s
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 2m49s
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
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
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
23 changed files with 774 additions and 349 deletions

View File

@@ -5,6 +5,8 @@ on:
branches: branches:
- main - main
- staging - staging
tags:
- 'v*'
paths: paths:
- '**.go' - '**.go'
- 'go.mod' - 'go.mod'
@@ -15,8 +17,9 @@ on:
- '.gitea/workflows/build.yml' - '.gitea/workflows/build.yml'
jobs: jobs:
build-and-push: build-and-push-branches:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref_type == 'branch'
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -32,6 +35,17 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} 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 - name: Build and push Docker image for main
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@@ -40,6 +54,10 @@ jobs:
tags: | tags: |
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:latest git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:latest
platforms: linux/amd64,linux/arm64 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' if: github.ref == 'refs/heads/main'
- name: Build and push Docker image for staging - name: Build and push Docker image for staging
@@ -50,4 +68,85 @@ jobs:
tags: | tags: |
git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:staging git.fossy.my.id/${{ secrets.DOCKER_USERNAME }}/tunnel-please:staging
platforms: linux/amd64,linux/arm64 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/staging' 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'

View File

@@ -1,5 +1,9 @@
FROM golang:1.25.5-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 && \ RUN apk update && apk upgrade && \
apk add --no-cache ca-certificates tzdata git && \ apk add --no-cache ca-certificates tzdata git && \
update-ca-certificates update-ca-certificates
@@ -18,7 +22,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux \ CGO_ENABLED=0 GOOS=linux \
go build -trimpath \ go build -trimpath \
-ldflags="-w -s" \ -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 \ -o /app/tunnel_pls \
. .
@@ -28,6 +32,10 @@ RUN adduser -D -u 10001 -g '' appuser && \
FROM scratch FROM scratch
ARG VERSION=dev
ARG BUILD_DATE=unknown
ARG COMMIT=unknown
COPY --from=go_builder /usr/share/zoneinfo /usr/share/zoneinfo 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 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=go_builder /etc/passwd /etc/passwd COPY --from=go_builder /etc/passwd /etc/passwd
@@ -43,6 +51,9 @@ ENV TZ=Asia/Jakarta
EXPOSE 2200 8080 8443 EXPOSE 2200 8080 8443
LABEL org.opencontainers.image.title="Tunnel Please" \ LABEL org.opencontainers.image.title="Tunnel Please" \
org.opencontainers.image.description="SSH-based tunnel server" 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"] ENTRYPOINT ["/app/tunnel_pls"]

16
go.mod
View File

@@ -3,20 +3,21 @@ module tunnel_pls
go 1.24.4 go 1.24.4
require ( require (
github.com/caddyserver/certmagic v0.25.0 github.com/caddyserver/certmagic v0.25.1
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/libdns/cloudflare v0.2.2 github.com/libdns/cloudflare v0.2.2
github.com/muesli/termenv v0.16.0
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect github.com/caddyserver/zerossl v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
@@ -27,20 +28,19 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mholt/acmez/v3 v3.1.3 // indirect github.com/mholt/acmez/v3 v3.1.4 // indirect
github.com/miekg/dns v1.1.68 // indirect github.com/miekg/dns v1.1.69 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/blake3 v0.2.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/mod v0.30.0 // indirect golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect

12
go.sum
View File

@@ -6,8 +6,12 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/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 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic=
github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= 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 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= 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 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -50,8 +54,12 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc= github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc=
github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
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.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -81,6 +89,8 @@ 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/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= 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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
@@ -91,6 +101,8 @@ golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

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

@@ -1,4 +1,4 @@
package utils package key
import ( import (
"crypto/rand" "crypto/rand"
@@ -6,54 +6,12 @@ import (
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"log" "log"
mathrand "math/rand"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
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 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()
}
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
}
func GenerateSSHKeyIfNotExist(keyPath string) error { func GenerateSSHKeyIfNotExist(keyPath string) error {
if _, err := os.Stat(keyPath); err == nil { if _, err := os.Stat(keyPath); err == nil {
log.Printf("SSH key already exists at %s", keyPath) log.Printf("SSH key already exists at %s", keyPath)

View File

@@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"tunnel_pls/utils" "tunnel_pls/internal/config"
) )
type Manager interface { type Manager interface {
@@ -28,7 +28,7 @@ var Default Manager = &manager{
} }
func init() { func init() {
rawRange := utils.Getenv("ALLOWED_PORTS", "") rawRange := config.Getenv("ALLOWED_PORTS", "")
if rawRange == "" { if rawRange == "" {
return return
} }

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()
}

27
main.go
View File

@@ -6,19 +6,29 @@ import (
"net/http" "net/http"
_ "net/http/pprof" _ "net/http/pprof"
"os" "os"
"tunnel_pls/internal/config"
"tunnel_pls/internal/key"
"tunnel_pls/server" "tunnel_pls/server"
"tunnel_pls/utils" "tunnel_pls/session"
"tunnel_pls/version"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
func main() { func main() {
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.SetOutput(os.Stdout)
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
pprofEnabled := utils.Getenv("PPROF_ENABLED", "false") log.Printf("Starting %s", version.GetVersion())
pprofEnabled := config.Getenv("PPROF_ENABLED", "false")
if pprofEnabled == "true" { if pprofEnabled == "true" {
pprofPort := utils.Getenv("PPROF_PORT", "6060") pprofPort := config.Getenv("PPROF_PORT", "6060")
go func() { go func() {
pprofAddr := fmt.Sprintf("localhost:%s", pprofPort) pprofAddr := fmt.Sprintf("localhost:%s", pprofPort)
log.Printf("Starting pprof server on http://%s/debug/pprof/", pprofAddr) log.Printf("Starting pprof server on http://%s/debug/pprof/", pprofAddr)
@@ -30,11 +40,11 @@ func main() {
sshConfig := &ssh.ServerConfig{ sshConfig := &ssh.ServerConfig{
NoClientAuth: true, NoClientAuth: true,
ServerVersion: "SSH-2.0-TunnlPls-1.0", ServerVersion: fmt.Sprintf("SSH-2.0-TunnlPls-%s", version.GetShortVersion()),
} }
sshKeyPath := "certs/ssh/id_rsa" sshKeyPath := "certs/ssh/id_rsa"
if err := utils.GenerateSSHKeyIfNotExist(sshKeyPath); err != nil { if err := key.GenerateSSHKeyIfNotExist(sshKeyPath); err != nil {
log.Fatalf("Failed to generate SSH key: %s", err) log.Fatalf("Failed to generate SSH key: %s", err)
} }
@@ -49,6 +59,11 @@ func main() {
} }
sshConfig.AddHostKey(private) sshConfig.AddHostKey(private)
app := server.NewServer(sshConfig) sessionRegistry := session.NewRegistry()
app, err := server.NewServer(sshConfig, sessionRegistry)
if err != nil {
log.Fatalf("Failed to start server: %s", err)
}
app.Start() app.Start()
} }

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,7 +4,6 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"fmt" "fmt"
"strings"
) )
type HeaderManager interface { type HeaderManager interface {
@@ -44,43 +43,132 @@ type requestHeaderFactory struct {
headers map[string]string headers map[string]string
} }
func NewRequestHeaderFactory(br *bufio.Reader) (RequestHeaderManager, error) { 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{ header := &requestHeaderFactory{
headers: make(map[string]string), headers: make(map[string]string, 16),
} }
startLine, err := br.ReadString('\n') lineEnd := bytes.IndexByte(headerData, '\n')
if err != nil { if lineEnd == -1 {
return nil, err 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 { if len(parts) < 3 {
return nil, fmt.Errorf("invalid request line") return nil, fmt.Errorf("invalid request line")
} }
header.method = parts[0] header.method = string(parts[0])
header.path = parts[1] header.path = string(parts[1])
header.version = parts[2] header.version = string(parts[2])
for { remaining := headerData[lineEnd+1:]
line, err := br.ReadString('\n')
if err != nil { for len(remaining) > 0 {
return nil, err 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 break
} }
kv := strings.SplitN(line, ":", 2) colonIdx := bytes.IndexByte(line, ':')
if len(kv) != 2 { if colonIdx != -1 {
key := bytes.TrimSpace(line[:colonIdx])
value := bytes.TrimSpace(line[colonIdx+1:])
header.headers[string(key)] = string(value)
}
if lineEnd == len(remaining) {
break
}
remaining = remaining[lineEnd+1:]
}
return header, nil
}
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 continue
} }
header.headers[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
key := bytes.TrimSpace(lineBytes[:colonIdx])
value := bytes.TrimSpace(lineBytes[colonIdx+1:])
header.headers[string(key)] = string(value)
} }
return header, nil return header, nil

View File

@@ -11,21 +11,15 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"tunnel_pls/internal/config"
"tunnel_pls/session" "tunnel_pls/session"
"tunnel_pls/utils"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type Interaction interface {
SendMessage(message string)
}
type HTTPWriter interface { type HTTPWriter interface {
io.Reader io.Reader
io.Writer io.Writer
SetInteraction(interaction Interaction)
AddInteraction(interaction Interaction)
GetRemoteAddr() net.Addr GetRemoteAddr() net.Addr
GetWriter() io.Writer GetWriter() io.Writer
AddResponseMiddleware(mw ResponseMiddleware) AddResponseMiddleware(mw ResponseMiddleware)
@@ -35,21 +29,16 @@ type HTTPWriter interface {
} }
type customWriter struct { type customWriter struct {
remoteAddr net.Addr remoteAddr net.Addr
writer io.Writer writer io.Writer
reader io.Reader reader io.Reader
headerBuf []byte headerBuf []byte
buf []byte buf []byte
respHeader ResponseHeaderManager respHeader ResponseHeaderManager
reqHeader RequestHeaderManager reqHeader RequestHeaderManager
interaction Interaction respMW []ResponseMiddleware
respMW []ResponseMiddleware reqStartMW []RequestMiddleware
reqStartMW []RequestMiddleware reqEndMW []RequestMiddleware
reqEndMW []RequestMiddleware
}
func (cw *customWriter) SetInteraction(interaction Interaction) {
cw.interaction = interaction
} }
func (cw *customWriter) GetRemoteAddr() net.Addr { func (cw *customWriter) GetRemoteAddr() net.Addr {
@@ -110,8 +99,7 @@ func (cw *customWriter) Read(p []byte) (int, error) {
} }
} }
headerReader := bufio.NewReader(bytes.NewReader(header)) reqhf, err := NewRequestHeaderFactory(header)
reqhf, err := NewRequestHeaderFactory(headerReader)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -135,11 +123,10 @@ func (cw *customWriter) Read(p []byte) (int, error) {
func NewCustomWriter(writer io.Writer, reader io.Reader, remoteAddr net.Addr) HTTPWriter { func NewCustomWriter(writer io.Writer, reader io.Reader, remoteAddr net.Addr) HTTPWriter {
return &customWriter{ return &customWriter{
remoteAddr: remoteAddr, remoteAddr: remoteAddr,
writer: writer, writer: writer,
reader: reader, reader: reader,
buf: make([]byte, 0, 4096), buf: make([]byte, 0, 4096),
interaction: nil,
} }
} }
@@ -224,19 +211,29 @@ func (cw *customWriter) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
func (cw *customWriter) AddInteraction(interaction Interaction) {
cw.interaction = interaction
}
var redirectTLS = false var redirectTLS = false
func NewHTTPServer() error { type HTTPServer interface {
httpPort := utils.Getenv("HTTP_PORT", "8080") 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) listener, err := net.Listen("tcp", ":"+httpPort)
if err != nil { if err != nil {
return errors.New("Error listening: " + err.Error()) return errors.New("Error listening: " + err.Error())
} }
if utils.Getenv("TLS_ENABLED", "false") == "true" && utils.Getenv("TLS_REDIRECT", "false") == "true" { if config.Getenv("TLS_ENABLED", "false") == "true" && config.Getenv("TLS_REDIRECT", "false") == "true" {
redirectTLS = true redirectTLS = true
} }
go func() { go func() {
@@ -251,13 +248,13 @@ func NewHTTPServer() error {
continue continue
} }
go Handler(conn) go hs.handler(conn)
} }
}() }()
return nil return nil
} }
func Handler(conn net.Conn) { func (hs *httpServer) handler(conn net.Conn) {
defer func() { defer func() {
err := conn.Close() err := conn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) { if err != nil && !errors.Is(err, net.ErrClosed) {
@@ -288,7 +285,7 @@ func Handler(conn net.Conn) {
if redirectTLS { if redirectTLS {
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" + _, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
fmt.Sprintf("Location: https://%s.%s/\r\n", slug, utils.Getenv("DOMAIN", "localhost")) + fmt.Sprintf("Location: https://%s.%s/\r\n", slug, config.Getenv("DOMAIN", "localhost")) +
"Content-Length: 0\r\n" + "Content-Length: 0\r\n" +
"Connection: close\r\n" + "Connection: close\r\n" +
"\r\n")) "\r\n"))
@@ -316,8 +313,8 @@ func Handler(conn net.Conn) {
return return
} }
sshSession, ok := session.Clients[slug] sshSession, exist := hs.sessionRegistry.Get(slug)
if !ok { if !exist {
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" + _, 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) + fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) +
"Content-Length: 0\r\n" + "Content-Length: 0\r\n" +

View File

@@ -8,13 +8,12 @@ import (
"log" "log"
"net" "net"
"strings" "strings"
"tunnel_pls/session" "tunnel_pls/internal/config"
"tunnel_pls/utils"
) )
func NewHTTPSServer() error { func (hs *httpServer) ListenAndServeTLS() error {
domain := utils.Getenv("DOMAIN", "localhost") domain := config.Getenv("DOMAIN", "localhost")
httpsPort := utils.Getenv("HTTPS_PORT", "8443") httpsPort := config.Getenv("HTTPS_PORT", "8443")
tlsConfig, err := NewTLSConfig(domain) tlsConfig, err := NewTLSConfig(domain)
if err != nil { if err != nil {
@@ -38,13 +37,13 @@ func NewHTTPSServer() error {
continue continue
} }
go HandlerTLS(conn) go hs.handlerTLS(conn)
} }
}() }()
return nil return nil
} }
func HandlerTLS(conn net.Conn) { func (hs *httpServer) handlerTLS(conn net.Conn) {
defer func() { defer func() {
err := conn.Close() err := conn.Close()
if err != nil { if err != nil {
@@ -90,8 +89,8 @@ func HandlerTLS(conn net.Conn) {
return return
} }
sshSession, ok := session.Clients[slug] sshSession, exist := hs.sessionRegistry.Get(slug)
if !ok { if !exist {
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" + _, 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) + fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) +
"Content-Length: 0\r\n" + "Content-Length: 0\r\n" +

View File

@@ -4,50 +4,45 @@ import (
"fmt" "fmt"
"log" "log"
"net" "net"
"net/http" "tunnel_pls/internal/config"
"tunnel_pls/utils" "tunnel_pls/session"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type Server struct { type Server struct {
conn *net.Listener conn *net.Listener
config *ssh.ServerConfig config *ssh.ServerConfig
httpServer *http.Server sessionRegistry session.Registry
} }
func (s *Server) GetConn() *net.Listener { func NewServer(sshConfig *ssh.ServerConfig, sessionRegistry session.Registry) (*Server, error) {
return s.conn listener, err := net.Listen("tcp", fmt.Sprintf(":%s", config.Getenv("PORT", "2200")))
}
func (s *Server) GetConfig() *ssh.ServerConfig {
return s.config
}
func (s *Server) GetHttpServer() *http.Server {
return s.httpServer
}
func NewServer(config *ssh.ServerConfig) *Server {
listener, err := net.Listen("tcp", fmt.Sprintf(":%s", utils.Getenv("PORT", "2200")))
if err != nil { if err != nil {
log.Fatalf("failed to listen on port 2200: %v", err) log.Fatalf("failed to listen on port 2200: %v", err)
return nil return nil, err
} }
if utils.Getenv("TLS_ENABLED", "false") == "true" {
err = NewHTTPSServer() HttpServer := NewHTTPServer(sessionRegistry)
if err != nil { err = HttpServer.ListenAndServe()
log.Fatalf("failed to start https server: %v", err)
}
}
err = NewHTTPServer()
if err != nil { if err != nil {
log.Fatalf("failed to start http server: %v", err) log.Fatalf("failed to start http server: %v", err)
return nil, err
} }
if config.Getenv("TLS_ENABLED", "false") == "true" {
err = HttpServer.ListenAndServeTLS()
if err != nil {
log.Fatalf("failed to start https server: %v", err)
return nil, err
}
}
return &Server{ return &Server{
conn: &listener, conn: &listener,
config: config, config: sshConfig,
} sessionRegistry: sessionRegistry,
}, nil
} }
func (s *Server) Start() { func (s *Server) Start() {
@@ -62,3 +57,26 @@ func (s *Server) Start() {
go s.handleConnection(conn) 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
}
log.Println("SSH connection established:", sshConn.User())
sshSession := session.New(sshConn, forwardingReqs, chans, s.sessionRegistry)
err = sshSession.Start()
if err != nil {
log.Printf("SSH session ended with error: %v", err)
return
}
return
}

View File

@@ -10,7 +10,7 @@ import (
"os" "os"
"sync" "sync"
"time" "time"
"tunnel_pls/utils" "tunnel_pls/internal/config"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/libdns/cloudflare" "github.com/libdns/cloudflare"
@@ -92,7 +92,7 @@ func NewTLSConfig(domain string) (*tls.Config, error) {
} }
func isACMEConfigComplete() bool { func isACMEConfigComplete() bool {
cfAPIToken := utils.Getenv("CF_API_TOKEN", "") cfAPIToken := config.Getenv("CF_API_TOKEN", "")
return cfAPIToken != "" return cfAPIToken != ""
} }
@@ -241,9 +241,9 @@ func (tm *tlsManager) initCertMagic() error {
return fmt.Errorf("failed to create cert storage directory: %w", err) return fmt.Errorf("failed to create cert storage directory: %w", err)
} }
acmeEmail := utils.Getenv("ACME_EMAIL", "admin@"+tm.domain) acmeEmail := config.Getenv("ACME_EMAIL", "admin@"+tm.domain)
cfAPIToken := utils.Getenv("CF_API_TOKEN", "") cfAPIToken := config.Getenv("CF_API_TOKEN", "")
acmeStaging := utils.Getenv("ACME_STAGING", "false") == "true" acmeStaging := config.Getenv("ACME_STAGING", "false") == "true"
if cfAPIToken == "" { if cfAPIToken == "" {
return fmt.Errorf("CF_API_TOKEN environment variable is required for automatic certificate generation") return fmt.Errorf("CF_API_TOKEN environment variable is required for automatic certificate generation")
@@ -301,7 +301,22 @@ func (tm *tlsManager) initCertMagic() error {
func (tm *tlsManager) getTLSConfig() *tls.Config { func (tm *tlsManager) getTLSConfig() *tls.Config {
return &tls.Config{ return &tls.Config{
GetCertificate: tm.getCertificate, GetCertificate: tm.getCertificate,
MinVersion: tls.VersionTLS12, 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,
} }
} }

View File

@@ -10,16 +10,16 @@ import (
"strconv" "strconv"
"sync" "sync"
"time" "time"
"tunnel_pls/internal/config"
"tunnel_pls/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/types" "tunnel_pls/types"
"tunnel_pls/utils"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
var bufferPool = sync.Pool{ var bufferPool = sync.Pool{
New: func() interface{} { New: func() interface{} {
bufSize := utils.GetBufferSize() bufSize := config.GetBufferSize()
return make([]byte, bufSize) return make([]byte, bufSize)
}, },
} }
@@ -152,25 +152,26 @@ func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteA
log.Printf("Handling new forwarded connection from %s", remoteAddr) log.Printf("Handling new forwarded connection from %s", remoteAddr)
done := make(chan struct{}, 2) var wg sync.WaitGroup
wg.Add(2)
go func() {
_, err := copyWithBuffer(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)
}
done <- struct{}{}
}()
go func() { go func() {
defer wg.Done()
_, err := copyWithBuffer(dst, src) _, err := copyWithBuffer(dst, src)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
log.Printf("Error copying from channel to conn.Writer: %v", err) log.Printf("Error copying src→dst: %v", err)
} }
done <- struct{}{}
}() }()
<-done 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) { func (f *Forwarder) SetType(tunnelType types.TunnelType) {

View File

@@ -7,10 +7,9 @@ import (
"log" "log"
"net" "net"
portUtil "tunnel_pls/internal/port" portUtil "tunnel_pls/internal/port"
"tunnel_pls/internal/random"
"tunnel_pls/types" "tunnel_pls/types"
"tunnel_pls/utils"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -107,7 +106,6 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
} }
portToBind := uint16(rawPortToBind) portToBind := uint16(rawPortToBind)
if isBlockedPort(portToBind) { if isBlockedPort(portToBind) {
log.Printf("Port %d is blocked or restricted", portToBind) log.Printf("Port %d is blocked or restricted", portToBind)
err := req.Reply(false, nil) err := req.Reply(false, nil)
@@ -165,16 +163,9 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
} }
func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) { func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
slug := generateUniqueSlug() slug := random.GenerateRandomString(20)
if slug == "" {
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
return
}
if !registerClient(slug, s) { if !s.registry.Register(slug, s) {
log.Printf("Failed to register client with slug: %s", slug) log.Printf("Failed to register client with slug: %s", slug)
err := req.Reply(false, nil) err := req.Reply(false, nil)
if err != nil { if err != nil {
@@ -187,7 +178,7 @@ func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
err := binary.Write(buf, binary.BigEndian, uint32(portToBind)) err := binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil { if err != nil {
log.Println("Failed to write port to buffer:", err) log.Println("Failed to write port to buffer:", err)
unregisterClient(slug) s.registry.Remove(slug)
err = req.Reply(false, nil) err = req.Reply(false, nil)
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) log.Println("Failed to reply to request:", err)
@@ -199,7 +190,7 @@ func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
err = req.Reply(true, buf.Bytes()) err = req.Reply(true, buf.Bytes())
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) log.Println("Failed to reply to request:", err)
unregisterClient(slug) s.registry.Remove(slug)
err = req.Reply(false, nil) err = req.Reply(false, nil)
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) log.Println("Failed to reply to request:", err)
@@ -272,25 +263,6 @@ func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind
s.interaction.Start() s.interaction.Start()
} }
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 ""
}
func readSSHString(reader *bytes.Reader) (string, error) { func readSSHString(reader *bytes.Reader) (string, error) {
var length uint32 var length uint32
if err := binary.Read(reader, binary.BigEndian, &length); err != nil { if err := binary.Read(reader, binary.BigEndian, &length); err != nil {

View File

@@ -22,6 +22,131 @@ const (
paddingRight = 4 paddingRight = 4
) )
var forbiddenSlugs = []string{ var forbiddenSlugs = map[string]struct{}{
"ping", "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": {},
} }

View File

@@ -6,9 +6,10 @@ import (
"log" "log"
"strings" "strings"
"time" "time"
"tunnel_pls/internal/config"
"tunnel_pls/internal/random"
"tunnel_pls/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/types" "tunnel_pls/types"
"tunnel_pls/utils"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@@ -113,7 +114,7 @@ func (i *Interaction) SetChannel(channel ssh.Channel) {
i.channel = channel i.channel = channel
} }
func (i *Interaction) SetSlugModificator(modificator func(oldSlug, newSlug string) bool) { func (i *Interaction) SetSlugModificator(modificator func(oldSlug, newSlug string) (success bool)) {
i.updateClientSlug = modificator i.updateClientSlug = modificator
} }
@@ -198,6 +199,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if m.editingSlug { if m.editingSlug {
if m.tunnelType != types.HTTP {
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
switch msg.String() { switch msg.String() {
case "esc": case "esc":
m.editingSlug = false m.editingSlug = false
@@ -205,7 +211,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "enter": case "enter":
inputValue := m.slugInput.Value() inputValue := m.slugInput.Value()
m.interaction.updateClientSlug(m.interaction.slugManager.Get(), inputValue)
if isForbiddenSlug(inputValue) {
m.slugError = "This subdomain is reserved. Please choose a different one."
return m, nil
} else if !isValidSlug(inputValue) {
m.slugError = "Invalid subdomain. Follow the rules."
return m, nil
}
if !m.interaction.updateClientSlug(m.interaction.slugManager.Get(), inputValue) {
m.slugError = "Someone already uses this subdomain."
return m, nil
}
m.tunnelURL = buildURL(m.protocol, inputValue, m.domain) m.tunnelURL = buildURL(m.protocol, inputValue, m.domain)
m.editingSlug = false m.editingSlug = false
m.slugError = "" m.slugError = ""
@@ -709,9 +728,9 @@ func (m model) View() string {
func (i *Interaction) Start() { func (i *Interaction) Start() {
lipgloss.SetColorProfile(termenv.TrueColor) lipgloss.SetColorProfile(termenv.TrueColor)
domain := utils.Getenv("DOMAIN", "localhost") domain := config.Getenv("DOMAIN", "localhost")
protocol := "http" protocol := "http"
if utils.Getenv("TLS_ENABLED", "false") == "true" { if config.Getenv("TLS_ENABLED", "false") == "true" {
protocol = "https" protocol = "https"
} }
@@ -798,5 +817,32 @@ func buildURL(protocol, subdomain, domain string) string {
} }
func generateRandomSubdomain() string { func generateRandomSubdomain() string {
return utils.GenerateRandomString(20) return random.GenerateRandomString(20)
}
func isValidSlug(slug string) bool {
if len(slug) < minSlugLength || len(slug) > maxSlugLength {
return false
}
if slug[0] == '-' || slug[len(slug)-1] == '-' {
return false
}
for _, c := range slug {
if !isValidSlugChar(byte(c)) {
return false
}
}
return true
}
func isValidSlugChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
}
func isForbiddenSlug(slug string) bool {
_, ok := forbiddenSlugs[slug]
return ok
} }

66
session/registry.go Normal file
View File

@@ -0,0 +1,66 @@
package session
import "sync"
type Registry interface {
Get(slug string) (session *SSHSession, exist bool)
Update(oldSlug, newSlug string) (success bool)
Register(slug string, session *SSHSession) (success bool)
Remove(slug string)
}
type registry struct {
mu sync.RWMutex
clients map[string]*SSHSession
}
func NewRegistry() Registry {
return &registry{
clients: make(map[string]*SSHSession),
}
}
func (r *registry) Get(slug string) (session *SSHSession, exist bool) {
r.mu.RLock()
defer r.mu.RUnlock()
session, exist = r.clients[slug]
return
}
func (r *registry) Update(oldSlug, newSlug string) (success bool) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.clients[newSlug]; exists && newSlug != oldSlug {
return false
}
client, ok := r.clients[oldSlug]
if !ok {
return false
}
delete(r.clients, oldSlug)
client.slugManager.Set(newSlug)
r.clients[newSlug] = client
return true
}
func (r *registry) Register(slug string, session *SSHSession) (success bool) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.clients[slug]; exists {
return false
}
r.clients[slug] = session
return true
}
func (r *registry) Remove(slug string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.clients, slug)
}

View File

@@ -1,23 +1,18 @@
package session package session
import ( import (
"fmt"
"log" "log"
"sync"
"time" "time"
"tunnel_pls/internal/config"
"tunnel_pls/session/forwarder" "tunnel_pls/session/forwarder"
"tunnel_pls/session/interaction" "tunnel_pls/session/interaction"
"tunnel_pls/session/lifecycle" "tunnel_pls/session/lifecycle"
"tunnel_pls/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/utils"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
var (
clientsMutex sync.RWMutex
Clients = make(map[string]*SSHSession)
)
type Session interface { type Session interface {
HandleGlobalRequest(ch <-chan *ssh.Request) HandleGlobalRequest(ch <-chan *ssh.Request)
HandleTCPIPForward(req *ssh.Request) HandleTCPIPForward(req *ssh.Request)
@@ -26,10 +21,13 @@ type Session interface {
} }
type SSHSession struct { type SSHSession struct {
lifecycle lifecycle.SessionLifecycle initialReq <-chan *ssh.Request
interaction interaction.Controller sshReqChannel <-chan ssh.NewChannel
forwarder forwarder.ForwardingController lifecycle lifecycle.SessionLifecycle
slugManager slug.Manager interaction interaction.Controller
forwarder forwarder.ForwardingController
slugManager slug.Manager
registry Registry
} }
func (s *SSHSession) GetLifecycle() lifecycle.SessionLifecycle { func (s *SSHSession) GetLifecycle() lifecycle.SessionLifecycle {
@@ -48,55 +46,64 @@ func (s *SSHSession) GetSlugManager() slug.Manager {
return s.slugManager return s.slugManager
} }
func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel) { func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel, sessionRegistry Registry) *SSHSession {
slugManager := slug.NewManager() slugManager := slug.NewManager()
forwarderManager := forwarder.NewForwarder(slugManager) forwarderManager := forwarder.NewForwarder(slugManager)
interactionManager := interaction.NewInteraction(slugManager, forwarderManager) interactionManager := interaction.NewInteraction(slugManager, forwarderManager)
lifecycleManager := lifecycle.NewLifecycle(conn, forwarderManager, slugManager) lifecycleManager := lifecycle.NewLifecycle(conn, forwarderManager, slugManager)
interactionManager.SetLifecycle(lifecycleManager) interactionManager.SetLifecycle(lifecycleManager)
interactionManager.SetSlugModificator(updateClientSlug) interactionManager.SetSlugModificator(sessionRegistry.Update)
forwarderManager.SetLifecycle(lifecycleManager) forwarderManager.SetLifecycle(lifecycleManager)
lifecycleManager.SetUnregisterClient(unregisterClient) lifecycleManager.SetUnregisterClient(sessionRegistry.Remove)
session := &SSHSession{ return &SSHSession{
lifecycle: lifecycleManager, initialReq: forwardingReq,
interaction: interactionManager, sshReqChannel: sshChan,
forwarder: forwarderManager, lifecycle: lifecycleManager,
slugManager: slugManager, interaction: interactionManager,
} forwarder: forwarderManager,
slugManager: slugManager,
var once sync.Once registry: sessionRegistry,
for channel := range sshChan {
ch, reqs, err := channel.Accept()
if err != nil {
log.Printf("failed to accept channel: %v", err)
continue
}
once.Do(func() {
session.lifecycle.SetChannel(ch)
session.interaction.SetChannel(ch)
tcpipReq := session.waitForTCPIPForward(forwardingReq)
if tcpipReq == nil {
log.Printf("Port forwarding request not received. Ensure you ran the correct command with -R flag. Example: ssh %s -p %s -R 80:localhost:3000", utils.Getenv("DOMAIN", "localhost"), utils.Getenv("PORT", "2200"))
if err := session.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
go session.HandleTCPIPForward(tcpipReq)
})
session.HandleGlobalRequest(reqs)
}
if err := session.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
} }
} }
func (s *SSHSession) waitForTCPIPForward(forwardingReq <-chan *ssh.Request) *ssh.Request { 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 fmt.Errorf("No forwarding Request")
}
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
}
return nil
}
func (s *SSHSession) waitForTCPIPForward() *ssh.Request {
select { select {
case req, ok := <-forwardingReq: case req, ok := <-s.initialReq:
if !ok { if !ok {
log.Println("Forwarding request channel closed") log.Println("Forwarding request channel closed")
return nil return nil
@@ -114,41 +121,3 @@ func (s *SSHSession) waitForTCPIPForward(forwardingReq <-chan *ssh.Request) *ssh
return nil return nil
} }
} }
func updateClientSlug(oldSlug, newSlug string) bool {
clientsMutex.Lock()
defer clientsMutex.Unlock()
if _, exists := Clients[newSlug]; exists && newSlug != oldSlug {
return false
}
client, ok := Clients[oldSlug]
if !ok {
return false
}
delete(Clients, oldSlug)
client.slugManager.Set(newSlug)
Clients[newSlug] = client
return true
}
func registerClient(slug string, session *SSHSession) bool {
clientsMutex.Lock()
defer clientsMutex.Unlock()
if _, exists := Clients[slug]; exists {
return false
}
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 package slug
import "sync"
type Manager interface { type Manager interface {
Get() string Get() string
Set(slug string) Set(slug string)
} }
type manager struct { type manager struct {
slug string slug string
slugMu sync.RWMutex
} }
func NewManager() Manager { func NewManager() Manager {
return &manager{ return &manager{
slug: "", slug: "",
slugMu: sync.RWMutex{},
} }
} }
func (s *manager) Get() string { func (s *manager) Get() string {
s.slugMu.RLock()
defer s.slugMu.RUnlock()
return s.slug return s.slug
} }
func (s *manager) Set(slug string) { func (s *manager) Set(slug string) {
s.slugMu.Lock()
s.slug = slug s.slug = slug
s.slugMu.Unlock()
} }

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
}