├── .air.toml ├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── pages.yaml │ ├── release-dev.yaml │ ├── release-v2.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser-dev.yaml ├── .goreleaser-nolatest.yaml ├── .goreleaser.yaml ├── Dockerfile ├── Dockerfile.goreleaser ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── healthcheck │ └── main.go └── server │ └── main.go ├── dev ├── Dockerfile.dev ├── docker-compose-dev.yaml ├── docker-compose-local.yaml ├── docker-compose.yaml ├── file1.yaml ├── tsdproxy-local.yaml └── tsdproxy.yaml ├── docker-compose.yaml ├── docs ├── content │ ├── _index.md │ ├── about.md │ └── docs │ │ ├── _index.md │ │ ├── advanced │ │ ├── _index.md │ │ ├── dashboard.md │ │ ├── docker-secrets.md │ │ ├── headscale.md │ │ ├── host-mode.md │ │ ├── icons.md │ │ └── tailscale.md │ │ ├── changelog.md │ │ ├── docker.md │ │ ├── getting-started.md │ │ ├── list.md │ │ ├── scenarios │ │ ├── 1i-1docker-1tailscale-1servarr.excalidraw │ │ ├── 1i-1docker-1tailscale-1servarr.md │ │ ├── 1i-1docker-1tailscale-1servarr.svg │ │ ├── 1i-2docker-1tailscale.excalidraw │ │ ├── 1i-2docker-1tailscale.md │ │ ├── 1i-2docker-1tailscale.svg │ │ ├── 1i-2docker-3tailscale.excalidraw │ │ ├── 1i-2docker-3tailscale.md │ │ ├── 1i-2docker-3tailscale.svg │ │ ├── 2i-2docker-1tailscale.excalidraw │ │ ├── 2i-2docker-1tailscale.md │ │ ├── 2i-2docker-1tailscale.svg │ │ ├── 2i-2docker-3tailscale.excalidraw │ │ ├── 2i-2docker-3tailscale.md │ │ ├── 2i-2docker-3tailscale.svg │ │ └── _index.md │ │ ├── serverconfig.md │ │ ├── troubleshooting.md │ │ └── v2 │ │ ├── _index.md │ │ ├── advanced │ │ ├── _index.md │ │ ├── docker-secrets.md │ │ ├── icons.md │ │ └── tailscale.md │ │ ├── getting-started.md │ │ ├── providers │ │ ├── _index.md │ │ ├── docker.md │ │ └── lists.md │ │ └── serverconfig.md ├── go.mod ├── go.sum ├── hugo.yaml ├── i18n │ └── en.yaml └── static │ ├── images │ ├── tsdproxy-compare.svg │ └── tsdproxy.svg │ ├── tsdproxy compare dark.svg │ └── tsdproxy compare.excalidraw ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ ├── configfile.go │ ├── generateproviders.go │ └── validator.go ├── consts │ ├── files.go │ └── proxymanager.go ├── core │ ├── const.go │ ├── healthcheck.go │ ├── http.go │ ├── log.go │ ├── pprof.go │ ├── sessions.go │ └── version.go ├── dashboard │ ├── dash.go │ └── stream.go ├── model │ ├── contextkey.go │ ├── default.go │ ├── port.go │ ├── proxyconfig.go │ ├── status.go │ └── whois.go ├── proxymanager │ ├── port.go │ ├── proxy.go │ └── proxymanager.go ├── proxyproviders │ ├── proxyproviders.go │ └── tailscale │ │ ├── provider.go │ │ └── proxy.go ├── targetproviders │ ├── docker │ │ ├── autodetect.go │ │ ├── consts.go │ │ ├── container.go │ │ ├── docker.go │ │ ├── errors.go │ │ ├── legacy.go │ │ └── utils.go │ ├── list │ │ └── list.go │ └── targetproviders.go └── ui │ ├── components │ └── components.go │ ├── layouts │ └── layouts.go │ ├── pages │ ├── pages.go │ └── proxylist.templ │ ├── static │ └── .keep │ └── ui.go └── web ├── bun.lock ├── index.html ├── package.json ├── public ├── icons │ ├── icon-192x192.png │ ├── icon-512x512.png │ └── tsdproxy.svg └── tsdproxy.svg ├── pwa-assets.config.js ├── scripts.js ├── styles.css ├── tsdproxy-dark.css ├── tsdproxy-light.css ├── vite.config.js └── web.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | exclude_dir = [ 7 | "assets", 8 | "tmp", 9 | "vendor", 10 | "testdata", 11 | "web/public", 12 | "web/node_modules", 13 | "web/dist", 14 | "public", 15 | "docs", 16 | "dev", 17 | "dist", 18 | ] 19 | args_bin = [] 20 | bin = "./tmp/main -config dev/tsdproxy-local.yaml" 21 | cmd = "templ generate --notify-proxy & go build -o ./tmp/main cmd/server/main.go" 22 | delay = 1000 23 | exclude_file = [] 24 | exclude_regex = ["_test.go"] 25 | exclude_unchanged = false 26 | follow_symlink = false 27 | full_bin = "" 28 | include_dir = [] 29 | include_ext = ["go", "tpl", "tmpl"] 30 | include_file = [] 31 | kill_delay = "1s" 32 | log = "build-errors.log" 33 | poll = false 34 | poll_interval = 0 35 | post_cmd = [] 36 | pre_cmd = [] 37 | rerun = false 38 | rerun_delay = 500 39 | send_interrupt = true 40 | stop_on_error = true 41 | 42 | [color] 43 | app = "" 44 | build = "yellow" 45 | main = "magenta" 46 | runner = "green" 47 | watcher = "cyan" 48 | 49 | [log] 50 | main_only = false 51 | silent = false 52 | time = false 53 | 54 | [misc] 55 | clean_on_exit = false 56 | 57 | [proxy] 58 | app_port = 0 59 | enabled = false 60 | proxy_port = 0 61 | 62 | [screen] 63 | clear_on_rebuild = false 64 | keep_scroll = true 65 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | dev/ 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: almeidapaulopt # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: almeidapaulopt # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: almeidapaulopt 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Before file a bug** 11 | verify if you case is already known in https://almeidapaulopt.github.io/tsdproxy/docs/troubleshooting/ 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Send config** 20 | Send the config tsdproxy.yaml file. 21 | 22 | **Logs** 23 | Always send logs 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: build 5 | 6 | on: 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v4 17 | with: 18 | go-version: "1.23" 19 | - uses: oven-sh/setup-bun@v1 20 | with: 21 | bun-version: latest 22 | - name: Install bun dependencies 23 | run: bun i --cwd ./web 24 | 25 | - name: Install dependencies 26 | run: go install github.com/a-h/templ/cmd/templ@latest 27 | - name: Generate 28 | run: go generate ./... 29 | - name: Build 30 | run: go build -v ./... 31 | -------------------------------------------------------------------------------- /.github/workflows/pages.yaml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Hugo site to GitHub Pages 2 | name: Deploy Hugo site to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | paths: 8 | - docs/** 9 | branches: 10 | - main 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 16 | permissions: 17 | contents: read 18 | pages: write 19 | id-token: write 20 | 21 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 22 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 23 | concurrency: 24 | group: "pages" 25 | cancel-in-progress: false 26 | 27 | # Default to bash 28 | defaults: 29 | run: 30 | shell: bash 31 | 32 | jobs: 33 | # Build job 34 | build: 35 | runs-on: ubuntu-latest 36 | env: 37 | HUGO_VERSION: 0.140.1 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 # fetch all history for .GitInfo and .Lastmod 43 | submodules: recursive 44 | - name: Setup Go 45 | uses: actions/setup-go@v5 46 | with: 47 | go-version-file: "go.mod" 48 | - name: Setup Pages 49 | id: pages 50 | uses: actions/configure-pages@v4 51 | - name: Setup Hugo 52 | run: | 53 | wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \ 54 | && sudo dpkg -i ${{ runner.temp }}/hugo.deb 55 | - name: Build with Hugo 56 | env: 57 | # For maximum backward compatibility with Hugo modules 58 | HUGO_ENVIRONMENT: production 59 | HUGO_ENV: production 60 | run: | 61 | hugo \ 62 | --gc --minify \ 63 | --source=docs \ 64 | --baseURL "${{ steps.pages.outputs.base_url }}/" 65 | - name: Upload artifact 66 | uses: actions/upload-pages-artifact@v3 67 | with: 68 | path: ./docs/public 69 | 70 | # Deployment job 71 | deploy: 72 | environment: 73 | name: github-pages 74 | url: ${{ steps.deployment.outputs.page_url }} 75 | runs-on: ubuntu-latest 76 | needs: build 77 | steps: 78 | - name: Deploy to GitHub Pages 79 | id: deployment 80 | uses: actions/deploy-pages@v4 81 | -------------------------------------------------------------------------------- /.github/workflows/release-dev.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser devel 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - docs/** 10 | - dev/** 11 | 12 | permissions: 13 | contents: write 14 | packages: write 15 | issues: write 16 | id-token: write 17 | 18 | jobs: 19 | goreleaser: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - uses: docker/setup-qemu-action@v3 28 | - uses: docker/setup-buildx-action@v3 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version-file: "go.mod" 33 | - uses: oven-sh/setup-bun@v1 34 | with: 35 | bun-version: latest 36 | 37 | - uses: sigstore/cosign-installer@v3.7.0 38 | 39 | - name: dockerhub-login 40 | uses: docker/login-action@v3 41 | with: 42 | username: ${{ secrets.DOCKER_USERNAME }} 43 | password: ${{ secrets.DOCKER_PASSWORD }} 44 | - name: ghcr-login 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.repository_owner }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Get Tailscale version 52 | run: echo "TAILSCALE_VERSION=$(go list -m tailscale.com | awk '{print $2}')" >> $GITHUB_ENV 53 | 54 | - name: Run GoReleaser 55 | uses: goreleaser/goreleaser-action@v6 56 | with: 57 | distribution: goreleaser 58 | version: "~> v2" 59 | args: release -f .goreleaser-dev.yaml --skip validate --clean 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | DOCKER_PASSWORD: $${{ secrets.DOCKER_PASSWORD }} 63 | DOCKER_USERNAME: $${{ secrets.DOCKER_USERNAME }} 64 | TAILSCALE_VERSION: ${{ env.TAILSCALE_VERSION }} 65 | -------------------------------------------------------------------------------- /.github/workflows/release-v2.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | # run only against v2 tags 7 | tags: 8 | - v2.* 9 | 10 | permissions: 11 | contents: write 12 | packages: write 13 | issues: write 14 | id-token: write 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - uses: docker/setup-qemu-action@v3 26 | - uses: docker/setup-buildx-action@v3 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version-file: "go.mod" 31 | - uses: oven-sh/setup-bun@v1 32 | with: 33 | bun-version: latest 34 | - uses: sigstore/cosign-installer@v3.7.0 35 | - name: dockerhub-login 36 | if: startsWith(github.ref, 'refs/tags/v') 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_USERNAME }} 40 | password: ${{ secrets.DOCKER_PASSWORD }} 41 | - name: ghcr-login 42 | if: startsWith(github.ref, 'refs/tags/v') 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.repository_owner }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Get Tailscale version 50 | run: echo "TAILSCALE_VERSION=$(go list -m tailscale.com | awk '{print $2}')" >> $GITHUB_ENV 51 | 52 | - name: Run GoReleaser 53 | uses: goreleaser/goreleaser-action@v6 54 | with: 55 | distribution: goreleaser 56 | version: "~> v2" 57 | args: release -f .goreleaser-nolatest.yaml --skip validate --clean 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | DOCKER_PASSWORD: $${{ secrets.DOCKER_PASSWORD }} 61 | DOCKER_USERNAME: $${{ secrets.DOCKER_USERNAME }} 62 | TAILSCALE_VERSION: ${{ env.TAILSCALE_VERSION }} 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | # run only against v1 tags 7 | tags: 8 | - "v1.*" 9 | 10 | permissions: 11 | contents: write 12 | packages: write 13 | issues: write 14 | id-token: write 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - uses: docker/setup-qemu-action@v3 26 | - uses: docker/setup-buildx-action@v3 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version-file: "go.mod" 31 | - uses: oven-sh/setup-bun@v1 32 | with: 33 | bun-version: latest 34 | - uses: sigstore/cosign-installer@v3.7.0 35 | - name: dockerhub-login 36 | if: startsWith(github.ref, 'refs/tags/v') 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_USERNAME }} 40 | password: ${{ secrets.DOCKER_PASSWORD }} 41 | - name: ghcr-login 42 | if: startsWith(github.ref, 'refs/tags/v') 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.repository_owner }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Get Tailscale version 50 | run: echo "TAILSCALE_VERSION=$(go list -m tailscale.com | awk '{print $2}')" >> $GITHUB_ENV 51 | 52 | - name: Run GoReleaser 53 | uses: goreleaser/goreleaser-action@v6 54 | with: 55 | distribution: goreleaser 56 | version: "~> v2" 57 | args: release --clean 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | DOCKER_PASSWORD: $${{ secrets.DOCKER_PASSWORD }} 61 | DOCKER_USERNAME: $${{ secrets.DOCKER_USERNAME }} 62 | TAILSCALE_VERSION: ${{ env.TAILSCALE_VERSION }} 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | 18 | # Go workspace file 19 | go.work 20 | go.work.sum 21 | 22 | # env file 23 | .env 24 | 25 | dist/ 26 | dev/data/ 27 | dev/KEY_FILE 28 | dev/tsauthkey.env 29 | dev/notes 30 | 31 | # Hugo output 32 | docs/public/ 33 | docs/resources/ 34 | docs/.hugo_build.lock 35 | 36 | # templ generated files 37 | *_templ.go 38 | *_templ.txt 39 | 40 | # static files 41 | internal/ui/static/* 42 | 43 | # Node files 44 | # Dependency directories 45 | node_modules/ 46 | web/dist 47 | web/public/icons/sh 48 | web/public/icons/si 49 | web/public/icons/mdi 50 | 51 | # macOS 52 | .DS_Store 53 | 54 | # other 55 | tmp 56 | web/main.zip 57 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | modules-download-mode: readonly 4 | linters: 5 | default: none 6 | enable: 7 | - bodyclose 8 | - copyloopvar 9 | - cyclop 10 | - dogsled 11 | - dupl 12 | - durationcheck 13 | - errcheck 14 | - errname 15 | - errorlint 16 | - goconst 17 | - gocyclo 18 | - goheader 19 | - gosec 20 | - govet 21 | - grouper 22 | - ineffassign 23 | - lll 24 | - makezero 25 | - misspell 26 | - mnd 27 | - nestif 28 | - nilnil 29 | - perfsprint 30 | - prealloc 31 | - predeclared 32 | - promlinter 33 | - revive 34 | - spancheck 35 | - staticcheck 36 | - tagliatelle 37 | - unconvert 38 | - unused 39 | - wastedassign 40 | - whitespace 41 | - zerologlint 42 | settings: 43 | gocyclo: 44 | min-complexity: 50 45 | goheader: 46 | values: 47 | const: 48 | AUTHOR: Paulo Almeida 49 | template: |- 50 | SPDX-FileCopyrightText: {{ YEAR }} {{ AUTHOR }} 51 | SPDX-License-Identifier: MIT 52 | govet: 53 | enable-all: true 54 | lll: 55 | line-length: 160 56 | misspell: 57 | locale: US 58 | whitespace: 59 | multi-func: true 60 | exclusions: 61 | generated: lax 62 | presets: 63 | - comments 64 | - common-false-positives 65 | - legacy 66 | - std-error-handling 67 | rules: 68 | - linters: 69 | - goconst 70 | path: (.+)_test\.go 71 | paths: 72 | - third_party$ 73 | - builtin$ 74 | - examples$ 75 | formatters: 76 | enable: 77 | - gofmt 78 | - gofumpt 79 | - goimports 80 | settings: 81 | goimports: 82 | local-prefixes: 83 | - github.com/almeidapaulopt/tsdproxy 84 | exclusions: 85 | generated: lax 86 | paths: 87 | - third_party$ 88 | - builtin$ 89 | - examples$ 90 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # Usa uma imagem oficial do Go como base para a compilação 3 | FROM golang:1.24 AS builder 4 | RUN apk add --no-cache ca-certificates && update-ca-certificates 2>/dev/null || true 5 | 6 | # Define o diretório de trabalho 7 | WORKDIR /app 8 | 9 | # Copia o código fonte para o container 10 | COPY . . 11 | 12 | # Compila a aplicação Go 13 | RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /tsdproxyd ./cmd/server/main.go 14 | RUN CGO_ENABLED=0 GOOS=linux go build -o /healthcheck ./cmd/healthcheck/main.go 15 | 16 | 17 | FROM scratch 18 | 19 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 20 | 21 | COPY --from=builder /tsdproxyd /tsdproxyd 22 | COPY --from=builder /healthcheck /healthcheck 23 | 24 | ENTRYPOINT ["/tsdproxyd"] 25 | 26 | EXPOSE 8080 27 | HEALTHCHECK CMD [ "/healthcheck" ] 28 | -------------------------------------------------------------------------------- /Dockerfile.goreleaser: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS certs 2 | RUN apk add --no-cache ca-certificates && update-ca-certificates 2>/dev/null || true 3 | 4 | FROM scratch 5 | 6 | COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 7 | 8 | COPY tsdproxyd / 9 | COPY healthcheck / 10 | 11 | ENTRYPOINT ["/tsdproxyd"] 12 | EXPOSE 8080 13 | HEALTHCHECK --interval=1m --timeout=2s CMD [ "/healthcheck" ] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 almeidapaulopt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: dev 2 | 3 | # Change these variables as necessary. 4 | MAIN_PACKAGE_PATH := "cmd/server/main.go" 5 | BINARY_NAME := tsdproxy 6 | PACKAGE := github.com/almeidapaulopt/tsdproxy 7 | 8 | 9 | 10 | BUILD_DATE=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ') 11 | GIT_COMMIT=$(shell git rev-parse HEAD) 12 | GIT_TAG=$(shell if [ -z "`git status --porcelain`" ]; then git describe --exact-match --tags HEAD 2>/dev/null; fi) 13 | GIT_TREE_STATE=$(shell if [ -z "`git status --porcelain`" ]; then echo "clean" ; else echo "dirty"; fi) 14 | GIT_REMOTE_REPO=upstream 15 | VERSION=$(shell if [ ! -z "${GIT_TAG}" ] ; then echo "${GIT_TAG}" | sed -e "s/^v//" ; else cat internal/core/version.txt ; fi) 16 | GO_VERSION=$(shell go version | cut -d " " -f3) 17 | 18 | 19 | 20 | # docker image publishing options 21 | DOCKER_PUSH=false 22 | IMAGE_TAG=latest 23 | 24 | override LDFLAGS += \ 25 | -X ${PACKAGE}/internal/core.AppVersion=${VERSION} \ 26 | -X ${PACKAGE}/internal/core.BuildDate=${BUILD_DATE} \ 27 | -X ${PACKAGE}/internal/core.GitCommit=${GIT_COMMIT} \ 28 | -X ${PACKAGE}/internal/core.GitTreeState=${GIT_TREE_STATE} \ 29 | -X ${PACKAGE}/internal/core.GoVersion=${GO_VERSION} 30 | 31 | 32 | ifneq (${GIT_TAG},) 33 | IMAGE_TAG=${GIT_TAG} 34 | override LDFLAGS += -X ${PACKAGE}/internal/core.GitTag=${GIT_TAG} 35 | endif 36 | 37 | 38 | 39 | 40 | # ==================================================================================== # 41 | # HELPERS 42 | # ==================================================================================== # 43 | 44 | ## help: print this help message 45 | .PHONY: help 46 | help: 47 | @echo 'Usage:' 48 | @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' 49 | 50 | .PHONY: confirm 51 | confirm: 52 | @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] 53 | 54 | .PHONY: no-dirty 55 | no-dirty: 56 | git diff --exit-code 57 | 58 | 59 | # ==================================================================================== # 60 | # DEVELOPMENT 61 | # ==================================================================================== # 62 | 63 | ## test: run all tests 64 | .PHONY: test 65 | test: 66 | go test -v -race -buildvcs ./... 67 | 68 | ## test/cover: run all tests and display coverage 69 | .PHONY: test/cover 70 | test/cover: 71 | go test -v -race -buildvcs -coverprofile=./tmp/coverage.out ./... 72 | go tool cover -html=./tmp/coverage.out 73 | 74 | ## build: build the application 75 | .PHONY: build 76 | build: 77 | @echo "GIT_TAG: ${GIT_TAG}" 78 | go build -ldflags '$(LDFLAGS)' -o=./tmp/${BINARY_NAME} ${MAIN_PACKAGE_PATH} 79 | 80 | ## run: run the application 81 | .PHONY: run 82 | run: build/static build 83 | ./tmp/${BINARY_NAME} 84 | 85 | 86 | ## dev: start dev server 87 | .PHONY: dev 88 | dev: docker_start 89 | make -j2 assets server_start 90 | 91 | ## server_start: start the server 92 | .PHONY: server_start 93 | server_start: 94 | templ generate --proxy="http://localhost:5173" --watch --cmd="echo RELOAD" & 95 | air 96 | 97 | .PHONY: assets 98 | assets: 99 | bun run --cwd web dev 100 | 101 | ## docker_start: start the docker containers 102 | .PHONY: docker_start 103 | docker_start: 104 | cd dev && docker compose -f docker-compose-local.yaml up -d 105 | 106 | ## dev_docker: start the dev docker containers 107 | .PHONY: dev_docker 108 | dev_docker: 109 | CURRENT_UID=$(shell id -u):$(shell id -g) docker compose -f dev/docker-compose-dev.yaml up 110 | 111 | ## dev_docker_stop: stop the dev docker containers 112 | .PHONY: dev_docker_stop 113 | dev_docker_stop: 114 | CURRENT_UID=$(shell id -u):$(shell id -g) docker compose -f dev/docker-compose-dev.yaml down 115 | 116 | 117 | ## dev_image: generate docker development image 118 | .PHONY: dev_image 119 | dev_image: 120 | docker build --build-arg UID=$(shell id -u) --build-arg GID=$(shell id -g) -f dev/Dockerfile.dev -t devimage . 121 | 122 | ## docker_stop: stop the docker containers 123 | .PHONY: docker_stop 124 | docker_stop: 125 | -cd dev && docker compose -f docker-compose-local.yaml down 126 | 127 | 128 | ## stop: stop the dev server 129 | .PHONY: stop 130 | stop: docker_stop 131 | 132 | 133 | ## docker_image: Create docker image 134 | .PHONY: docker_image 135 | docker_image: 136 | docker buildx build -t "tsdproxy:latest" . 137 | 138 | 139 | ## docs local server 140 | .PHONY: docs 141 | docs: 142 | cd docs && hugo server --disableFastRender 143 | 144 | 145 | .PHONY: run_in_docker 146 | run_in_docker: 147 | templ generate --proxy="http://localhost:5173" --watch --cmd="echo RELOAD" & 148 | air 149 | 150 | ## audit: run quality control checks 151 | .PHONY: audit 152 | audit: 153 | go mod verify 154 | golangci-lint run 155 | go run honnef.co/go/tools/cmd/staticcheck@latest -checks=all,-ST1000,-U1000 ./... 156 | go vet ./... 157 | deadcode ./... 158 | go run golang.org/x/vuln/cmd/govulncheck@latest ./... 159 | go test -race -buildvcs -vet=off ./... 160 | gosec -exclude-generated ./... 161 | 162 | 163 | # ==================================================================================== # 164 | # OPERATIONS 165 | # ==================================================================================== # 166 | 167 | ## push: push changes to the remote Git repository 168 | .PHONY: push 169 | push: tidy audit no-dirty 170 | git push 171 | git push --tags 172 | 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TsDProxy - Tailscale Proxy 2 | 3 | TsDProxy simplifies the process of securely exposing services and Docker containers 4 | to your Tailscale network by automatically creating Tailscale machines for each 5 | tagged container. This allows services to be accessible via unique, secure URLs 6 | without the need for complex configurations or additional Tailscale containers. 7 | 8 | ## Version 2 9 | 10 | Version 2 is already in beta. Please try and open issues if bugs detected. 11 | Some configurations of Version 1.x are deprecated or changed please verify it in 12 | [Documentation changelog](https://almeidapaulopt.github.io/tsdproxy/docs/changelog/). 13 | 14 | Because of some breaking changes, final version 2 arrive will not set as latest 15 | Docker image. We will wait some weeks to give you time to update. 16 | 17 | Version 1 will not get new features. 18 | 19 | ## Full Documentation 20 | 21 | - [Official Documentation](https://almeidapaulopt.github.io/tsdproxy/) 22 | 23 | ## Breaking Changes 24 | 25 | Please read the [Documentation changelog](https://almeidapaulopt.github.io/tsdproxy/docs/changelog/) 26 | for details. 27 | 28 | ## Help needed 29 | 30 | Please help with documentation, tests development, new features, bug fixes. 31 | If you don't feel comfortable to this kind of tasks, [sponsor](https://github.com/sponsors/almeidapaulopt) 32 | the project. 33 | 34 | ## Docker Images 35 | 36 | 1. almeidapaulopt/tsdproxy:vx.x.x - Version x.x.x 37 | 2. almeidapaulopt/tsdproxy:1 - Latest release of version 1.x.x 38 | 3. almeidapaulopt/tsdproxy:2 - Latest release of version 2.x.x (beta) 39 | 4. almeidapaulopt/tsdproxy:latest - Latest stable 40 | 5. almeidapaulopt/tsdproxy:dev - Latest Development Build 41 | 42 | ## Core Functionality 43 | 44 | - **Automatic Tailscale Machine Creation**: For each Docker container tagged 45 | with the appropriate labels, TsDProxy creates a new Tailscale machine. 46 | - **Default Serving**: By default, each service is accessible via 47 | `https://{machine-name}.funny-name.ts.net`, where `{machine-name}` is derived 48 | from your container name or custom label. 49 | 50 | ## Key Features 51 | 52 | - **Simplified Networking**: Eliminates the need for a separate Tailscale 53 | container for each service. 54 | - **Label-Based Configuration**: Easy setup using Docker container labels. 55 | - **Automatic HTTPS**: Leverages Tailscale's built-in LetsEncrypt certificate support. 56 | - **Flexible Protocol Support**: Handles HTTP and HTTPS traffic (defaulting to HTTPS). 57 | - **Lightweight Architecture**: Efficient, Docker-based design for minimal overhead. 58 | 59 | ## How It Works 60 | 61 | TsDProxy operates by creating a seamless integration between your Docker 62 | containers and Tailscale network: 63 | 64 | 1. **Container Scanning**: TsDProxy continuously monitors your Docker 65 | environment for containers with the `tsdproxy.enable=true` label. 66 | 2. **Tailscale Machine Creation**: When a tagged container is detected, TsDProxy 67 | automatically creates a new Tailscale machine for that container. 68 | 3. **Hostname Assignment**: The Tailscale machine is assigned a hostname based 69 | on the `tsdproxy.name` label or the container's name. 70 | 4. **Port Mapping**: TsDProxy maps the container's internal port to the Tailscale 71 | machine. 72 | 5. **Traffic Routing**: Incoming requests to the Tailscale machine are routed to 73 | the appropriate Docker container and port. 74 | 6. **Dynamic Management**: As containers start and stop, TsDProxy automatically 75 | creates and removes the corresponding Tailscale machines and routing configurations. 76 | 77 | ## Requirements 78 | 79 | Before using this application, make sure you have: 80 | 81 | - [Tailscale](https://tailscale.com/) installed and configured on your host machine. 82 | - [Docker](https://www.docker.com/) installed and running. 83 | 84 | ## License 85 | 86 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file 87 | for details. 88 | 89 | ## Contributing 90 | 91 | Contributions are welcome! Feel free to open issues or submit pull requests to help improve the app. 92 | -------------------------------------------------------------------------------- /cmd/healthcheck/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package main 5 | 6 | import ( 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | h, err := http.Get("http://127.0.0.1:8080/health/ready/") 13 | if err != nil { 14 | os.Exit(1) 15 | } 16 | h.Body.Close() 17 | } 18 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package main 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | 14 | "github.com/docker/docker/client" 15 | "github.com/rs/zerolog" 16 | 17 | "github.com/almeidapaulopt/tsdproxy/internal/config" 18 | "github.com/almeidapaulopt/tsdproxy/internal/core" 19 | "github.com/almeidapaulopt/tsdproxy/internal/dashboard" 20 | pm "github.com/almeidapaulopt/tsdproxy/internal/proxymanager" 21 | ) 22 | 23 | type WebApp struct { 24 | Log zerolog.Logger 25 | HTTP *core.HTTPServer 26 | Health *core.Health 27 | Docker *client.Client 28 | ProxyManager *pm.ProxyManager 29 | Dashboard *dashboard.Dashboard 30 | } 31 | 32 | func InitializeApp() (*WebApp, error) { 33 | err := config.InitializeConfig() 34 | if err != nil { 35 | return nil, err 36 | } 37 | logger := core.NewLog() 38 | 39 | httpServer := core.NewHTTPServer(logger) 40 | httpServer.Use(core.SessionMiddleware) 41 | 42 | health := core.NewHealthHandler(httpServer, logger) 43 | 44 | // Start ProxyManager 45 | // 46 | proxymanager := pm.NewProxyManager(logger) 47 | 48 | // init Dashboard 49 | // 50 | dash := dashboard.NewDashboard(httpServer, logger, proxymanager) 51 | 52 | webApp := &WebApp{ 53 | Log: logger, 54 | HTTP: httpServer, 55 | Health: health, 56 | ProxyManager: proxymanager, 57 | Dashboard: dash, 58 | } 59 | return webApp, nil 60 | } 61 | 62 | func main() { 63 | println("Initializing server") 64 | println("Version", core.GetVersion()) 65 | 66 | app, err := InitializeApp() 67 | if err != nil { 68 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 69 | os.Exit(1) 70 | } 71 | 72 | app.Start() 73 | defer app.Stop() 74 | 75 | // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds. 76 | // 77 | quit := make(chan os.Signal, 1) 78 | signal.Notify(quit, os.Interrupt, syscall.SIGTERM) 79 | <-quit 80 | } 81 | 82 | func (app *WebApp) Start() { 83 | app.Log.Info(). 84 | Str("Version", core.GetVersion()).Msg("Starting server") 85 | 86 | // Start the webserver 87 | // 88 | go func() { 89 | app.Log.Info().Msg("Initializing WebServer") 90 | 91 | // Start the webserver 92 | // 93 | srv := http.Server{ 94 | Addr: fmt.Sprintf("%s:%d", config.Config.HTTP.Hostname, config.Config.HTTP.Port), 95 | ReadHeaderTimeout: core.ReadHeaderTimeout, 96 | } 97 | 98 | app.Health.SetReady() 99 | 100 | if err := app.HTTP.StartServer(&srv); errors.Is(err, http.ErrServerClosed) { 101 | app.Log.Fatal().Err(err).Msg("shutting down the server") 102 | } 103 | }() 104 | 105 | // Setup proxy for existing containers 106 | // 107 | app.Log.Info().Msg("Setting up proxy proxies") 108 | 109 | app.ProxyManager.Start() 110 | 111 | // Start watching docker events 112 | // 113 | app.ProxyManager.WatchEvents() 114 | 115 | // Add Routes 116 | // 117 | app.Dashboard.AddRoutes() 118 | core.PprofAddRoutes(app.HTTP) 119 | } 120 | 121 | func (app *WebApp) Stop() { 122 | app.Log.Info().Msg("Shutdown server") 123 | 124 | app.Health.SetNotReady() 125 | 126 | // Shutdown things here 127 | // 128 | app.ProxyManager.StopAllProxies() 129 | 130 | app.Log.Info().Msg("Server was shutdown successfully") 131 | } 132 | -------------------------------------------------------------------------------- /dev/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Dockerfile.dev 2 | # 3 | FROM golang:1.24-alpine 4 | 5 | WORKDIR /app 6 | 7 | # ARG UID=1000 8 | # ARG GID=1000 9 | # 10 | # RUN groupadd -g $GID appgroup && useradd -l -m -u $UID -g appgroup appuser 11 | # 12 | 13 | #USER appuser 14 | 15 | RUN go install github.com/air-verse/air@latest && go install github.com/a-h/templ/cmd/templ@latest 16 | 17 | COPY go.mod go.sum ./ 18 | 19 | RUN go mod download 20 | 21 | EXPOSE 8080 22 | EXPOSE 7331 23 | 24 | # CMD ["make", "server_start"] 25 | 26 | CMD ["air", "-c", ".air.toml"] 27 | -------------------------------------------------------------------------------- /dev/docker-compose-dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | dev: 3 | build: 4 | context: .. 5 | dockerfile: dev/Dockerfile.dev 6 | ports: 7 | - "8080:8080" 8 | - "7331:7331" 9 | volumes: 10 | - ..:/app 11 | - .:/config 12 | - /var/run/docker.sock:/var/run/docker.sock 13 | - data:/data 14 | labels: 15 | - tsdproxy.enable=true 16 | - tsdproxy.name=dash-dev 17 | - tsdproxy.dash.visible=false 18 | secrets: 19 | - authkey 20 | extra_hosts: 21 | - "host.docker.internal:host-gateway" 22 | 23 | secrets: 24 | authkey: 25 | file: ./KEY_FILE 26 | 27 | volumes: 28 | tmp: 29 | data: 30 | -------------------------------------------------------------------------------- /dev/docker-compose-local.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | c1: 3 | image: nginx:latest 4 | ports: 5 | - 91:80 6 | - 92:8000 7 | - 93:8080 8 | labels: 9 | - tsdproxy.enable=true 10 | - tsdproxy.name=testeff 11 | - tsdproxy.ephemeral=true 12 | - tsdproxy.funnel=true 13 | - tsdproxy.container_port=80 14 | - tsdproxy.dash.visible=true 15 | # - tsdproxy.port.1=443/https:80/http, no_autodetect 16 | # networks: 17 | # - c1 18 | 19 | c2: 20 | image: nginx:1.27 21 | ports: 22 | - 82:80 23 | labels: 24 | - tsdproxy.enable=true 25 | # - tsdproxy.port.1=443/https:80/http 26 | networks: 27 | - c2 28 | 29 | c3: 30 | image: nginxinc/nginx-unprivileged:latest 31 | ports: 32 | - 83:8080 33 | labels: 34 | - tsdproxy.enable=true 35 | - tsdproxy.autodetect=true 36 | # - tsdproxy.port.1=443/https:8080/http 37 | # networks: 38 | # - c2 39 | 40 | s1: 41 | image: nginx 42 | ports: 43 | - 101:80 44 | - 102:8000 45 | - 103:8080 46 | deploy: 47 | labels: 48 | - tsdproxy.enable=true 49 | - tsdproxy.name=testeff 50 | - tsdproxy.ephemeral=true 51 | - tsdproxy.funnel=true 52 | - tsdproxy.container_port=80 53 | - tsdproxy.dash.visible=true 54 | networks: 55 | - c1 56 | 57 | s2: 58 | image: nginx 59 | ports: 60 | - 112:80 61 | deploy: 62 | labels: 63 | - tsdproxy.enable=true 64 | networks: 65 | - c2 66 | 67 | s3: 68 | image: nginx 69 | network_mode: host 70 | ports: 71 | - 113:80 72 | deploy: 73 | labels: 74 | - tsdproxy.enable=true 75 | 76 | volumes: 77 | tsdata: 78 | tmp: 79 | 80 | networks: 81 | c1: 82 | c2: 83 | -------------------------------------------------------------------------------- /dev/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | tailscale-docker-proxy: 3 | image: tsdproxy:latest 4 | container_name: tailscale-docker-proxy 5 | # network_mode: host 6 | user: root 7 | ports: 8 | - "8080:8080" 9 | volumes: 10 | - tmp:/tmp 11 | - .:/config 12 | - data:/data 13 | - /var/run/docker.sock:/var/run/docker.sock 14 | # - /run/user/1000/docker.sock:/var/run/docker.sock 15 | restart: unless-stopped 16 | # environment: 17 | # - TSDPROXY_AUTHKEYFILE=/run/secrets/authkey 18 | # - DOCKER_HOST=unix:///run/user/1000/docker.sock 19 | # - TSDPROXY_LOG_LEVEL=trace 20 | secrets: 21 | - authkey 22 | labels: 23 | - tsdproxy.enable=true 24 | - tsdproxy.name=dash-dev 25 | networks: 26 | c1: 27 | 28 | c1: 29 | image: nginx 30 | ports: 31 | - 81:80 32 | labels: 33 | - tsdproxy.enable=true 34 | - tsdproxy.name=teste 35 | networks: 36 | c1: 37 | 38 | c2: 39 | image: nginx 40 | ports: 41 | - 82:80 42 | networks: 43 | c2: 44 | labels: 45 | - tsdproxy.enable=true 46 | 47 | c3: 48 | image: nginx 49 | ports: 50 | - 83:80 51 | networks: 52 | c3: 53 | labels: 54 | - tsdproxy.enable=true 55 | 56 | volumes: 57 | tsdata: 58 | tmp: 59 | data: 60 | 61 | secrets: 62 | authkey: 63 | file: ./KEY_FILE 64 | 65 | networks: 66 | c1: 67 | name: c1 68 | c2: 69 | name: c2 70 | c3: 71 | name: c3 72 | -------------------------------------------------------------------------------- /dev/file1.yaml: -------------------------------------------------------------------------------- 1 | dash-dev: 2 | ports: 3 | "443/https": 4 | targets: 5 | - "http://localhost:8080" 6 | # nas12: 7 | # ports: 8 | # "443/https": 9 | # targets: 10 | # - "http://google.com" 11 | # "80/http": 12 | # isRedirect: true 13 | # tlsValidate: false 14 | # targets: 15 | # - "https://google.com" 16 | # nas13: 17 | # ports: 18 | # "443/https": 19 | # targets: 20 | # - "http://google.com" 21 | # "80/http": 22 | # isRedirect: true 23 | # tlsValidate: false 24 | # targets: 25 | # - "https://google.com" 26 | # nas14: 27 | # ports: 28 | # "443/https": 29 | # targets: 30 | # - "http://google.com" 31 | # "80/http": 32 | # isRedirect: true 33 | # tlsValidate: false 34 | # targets: 35 | # - "https://google.com" 36 | # nas15: 37 | # ports: 38 | # "443/https": 39 | # targets: 40 | # - "http://google.com" 41 | # "80/http": 42 | # isRedirect: true 43 | # tlsValidate: false 44 | # targets: 45 | # - "https://google.com" 46 | # nas16: 47 | # ports: 48 | # "443/https": 49 | # targets: 50 | # - "http://google.com" 51 | # "80/http": 52 | # isRedirect: true 53 | # tlsValidate: false 54 | # targets: 55 | # - "https://google.com" 56 | # nas2: 57 | # ports: 58 | # "443/https": 59 | # targets: 60 | # - "http://google.com" 61 | # "80/http": 62 | # isRedirect: true 63 | # tlsValidate: false 64 | # targets: 65 | # - "https://google.com" 66 | # nas3: 67 | # ports: 68 | # "443/https": 69 | # targets: 70 | # - "http://google.com" 71 | # "80/http": 72 | # isRedirect: true 73 | # tlsValidate: false 74 | # targets: 75 | # - "https://google.com" 76 | # nas4: 77 | # ports: 78 | # "443/https": 79 | # targets: 80 | # - "http://google.com" 81 | # "80/http": 82 | # isRedirect: true 83 | # tlsValidate: false 84 | # targets: 85 | # - "https://google.com" 86 | # nas5: 87 | # ports: 88 | # "443/https": 89 | # targets: 90 | # - "http://google.com" 91 | # "80/http": 92 | # isRedirect: true 93 | # tlsValidate: false 94 | # targets: 95 | # - "https://google.com" 96 | # nas6: 97 | # ports: 98 | # "443/https": 99 | # targets: 100 | # - "http://google.com" 101 | # "80/http": 102 | # isRedirect: true 103 | # tlsValidate: false 104 | # targets: 105 | # - "https://google.com" 106 | -------------------------------------------------------------------------------- /dev/tsdproxy-local.yaml: -------------------------------------------------------------------------------- 1 | defaultProxyProvider: default 2 | docker: 3 | local: 4 | host: unix:///var/run/docker.sock 5 | targetHostname: 172.31.0.1 6 | # server: 7 | # host: tcp://192.168.1.110:2375 8 | # TargetHostname: 192.168.1.110 9 | 10 | lists: 11 | file1: 12 | filename: dev/file1.yaml 13 | 14 | tailscale: 15 | providers: 16 | default: 17 | controlUrl: https://controlplane.tailscale.com 18 | # authKeyFile: dev/KEY_FILE 19 | dataDir: dev/data/ 20 | http: 21 | hostname: 0.0.0.0 22 | port: 8080 23 | log: 24 | level: debug 25 | json: false 26 | proxyAccessLog: true 27 | -------------------------------------------------------------------------------- /dev/tsdproxy.yaml: -------------------------------------------------------------------------------- 1 | defaultproxyprovider: default 2 | docker: 3 | local: # name of the docker provider 4 | host: unix:///var/run/docker.sock # host of the docker socket or daemon 5 | targethostname: 172.31.0.1 # hostname or IP of docker server 6 | defaultproxyprovider: default # name of which proxy provider to use 7 | tailscale: 8 | providers: 9 | default: # name of the provider 10 | authkey: your-authkey # define authkey here 11 | authkeyfile: "/run/secrets/authkey" # use this to load authkey from file. If this is defined, Authkey is ignored 12 | controlurl: https://controlplane.tailscale.com # use this to override the default control URL 13 | datadir: /data/ 14 | http: 15 | hostname: 0.0.0.0 16 | port: 8080 17 | log: 18 | level: debug # set logging level info, error or trace 19 | json: false # set to true to enable json logging 20 | proxyaccesslog: true # set to true to enable container access log 21 | files: 22 | file1: 23 | filename: /config/file1.yaml 24 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | tsdproxy: 3 | image: almeidapaulopt/tsdproxy:latest 4 | volumes: 5 | - /var/run/docker.sock:/var/run/docker.sock 6 | - datadir:/data 7 | - :/config 8 | restart: unless-stopped 9 | 10 | volumes: 11 | datadir: 12 | -------------------------------------------------------------------------------- /docs/content/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: TSDPRoxy 3 | layout: hextra-home 4 | --- 5 | {{< hextra/hero-container image="/images/tsdproxy.svg" >}} 6 | {{< hextra/hero-badge >}} 7 |
8 | Free, open source 9 | {{< icon name="arrow-circle-right" attributes="height=14" >}} 10 | {{< /hextra/hero-badge >}} 11 | 12 |
13 | {{< hextra/hero-headline >}} 14 | Very simple proxy 
with Tailscale 15 | {{< /hextra/hero-headline >}} 16 |
17 | 18 |
19 | {{< hextra/hero-subtitle >}} 20 | Fast, simple and easy 
for virtual services in Tailscale 21 | {{< /hextra/hero-subtitle >}} 22 |
23 | 24 |
25 | {{< hextra/hero-button text="Get Started" link="docs" >}} 26 |
27 | 28 | {{< /hextra/hero-container >}} 29 | 30 |
31 | 32 | {{< hextra/feature-grid >}} 33 | {{< hextra/feature-card 34 | title="Easy to Use" 35 | subtitle="Proxies traffic to virtual Tailscale addresses using Docker container labels" 36 | >}} 37 | {{< hextra/feature-card 38 | title="Lightweight as a Feather" 39 | subtitle="No need to spin up a dedicated Tailscale container for every service." 40 | >}} 41 | {{< hextra/feature-card 42 | title="Work saver" 43 | subtitle="No need to configure virtual hosts in Tailscale network." 44 | >}} 45 | {{< hextra/feature-card 46 | title="Automatically supports TLS" 47 | subtitle="Automatically supports Tailscale/LetsEncrypt certificates with MagicDNS." 48 | >}} 49 | {{< hextra/feature-card 50 | title="Free" 51 | subtitle="TSDPRoxy is free, open source and always will be." 52 | >}} 53 | {{< hextra/feature-card 54 | title="And More..." 55 | icon="sparkles" 56 | subtitle="More features are coming." 57 | >}} 58 | {{< /hextra/feature-grid >}} 59 | -------------------------------------------------------------------------------- /docs/content/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About 3 | type: about 4 | --- 5 | 6 | TSDProxy is a Tailscale + Docker application that automatically creates a proxy to virtual addresses in your Tailscale network based on Docker container labels. It simplifies traffic redirection to services running inside Docker containers, without the need for a separate Tailscale container for each service. 7 | -------------------------------------------------------------------------------- /docs/content/docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | linkTitle: "Documentation" 3 | title: Introduction 4 | weight: 1 5 | --- 6 | 7 | 👋 Welcome to the TSDProxy documentation! 8 | 9 | ## What is TSDProxy? 10 | 11 | TSDProxy is an application that automatically creates a proxy to 12 | virtual addresses in your Tailscale network. 13 | Easy to configure and deploy, based on Docker container labels or a simple proxy 14 | list file. 15 | It simplifies traffic redirection to services running inside Docker containers, 16 | without the need for a separate Tailscale container for each service. 17 | 18 | > [!NOTE] 19 | > TSDProxy just needs a label in your new docker service or a proxy list file and 20 | > it will be automatically created in your Tailscale network and the proxy will be 21 | > ready to be used. 22 | 23 | ## Why another proxy? 24 | 25 | TSDProxy was created to address the need for a proxy that can handle multiple services 26 | without the need for a dedicated Tailscale container for each service and without configuring 27 | virtual hosts in Tailscale network. 28 | 29 | ![how tsdproxy works](/images/tsdproxy.svg) 30 | 31 | ## What's different with TSDProxy? 32 | 33 | TSDProxy differs from other Tailscale proxies in that it does not require a separate Tailscale container per service. 34 | 35 | ![how tsdproxy works](/images/tsdproxy-compare.svg) 36 | 37 | ## Features 38 | 39 | - **Easy to Use** - Creates virtual Tailscale addresses using Docker container labels. 40 | - **Really Easy to Use** - Creates virtual Tailscale addresses using a proxy list. 41 | - **Lightweight** - No need to spin up a dedicated Tailscale container for every service. 42 | - **Quick deploy** - No need to configure virtual hosts in Tailscale network. 43 | - **Automatically supports TLS** - Automatically supports Tailscale/LetsEncrypt certificates 44 | with MagicDNS. 45 | 46 | ## Questions or Feedback? 47 | 48 | > [!IMPORTANT] 49 | TSDProxy is still in active development. 50 | Have a question or feedback? Feel free to [open an issue](https://github.com/almeidapaulopt/tsdproxy/issues)! 51 | 52 | ## Next 53 | 54 | Dive right into the following section to get started: 55 | 56 | {{< cards >}} 57 | {{< card link="getting-started" title="Getting Started" icon="document-text" 58 | subtitle="Learn how to get started with TSDProxy" 59 | >}} 60 | {{< /cards >}} 61 | -------------------------------------------------------------------------------- /docs/content/docs/advanced/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | linkTitle: Advanced 3 | title: Advanced Topics 4 | prev: /docs/scenarios 5 | next: /docs/advanced/dashboard 6 | weight: 5 7 | --- 8 | {{< cards >}} 9 | {{< card link="dashboard" title="Dashboard" icon="view-boards" >}} 10 | {{< card link="docker-secrets" title="Docker secrets" icon="key" >}} 11 | 12 | {{< card link="host-mode" title="Service with Host Network Mode" icon="view-boards" >}} 13 | {{< card link="icons" title="Dashboard icons" icon="view-boards" >}} 14 | {{< card link="tailscale" title="Tailscale" icon="key" >}} 15 | {{< /cards >}} 16 | -------------------------------------------------------------------------------- /docs/content/docs/advanced/dashboard.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dashboard 3 | prev: /docs/advanced 4 | --- 5 | 6 | {{% steps %}} 7 | 8 | ### Dashboard in docker 9 | 10 | #### TSDProxy docker compose 11 | 12 | Update docker-compose.yml with the following: 13 | 14 | ```yaml {filename="/config/tsdproxy.yaml"} 15 | labels: 16 | - tsdproxy.enable=true 17 | - tsdproxy.name=dash 18 | ``` 19 | 20 | #### Restart TSDProxy 21 | 22 | ```bash 23 | docker compose restart 24 | ``` 25 | 26 | ### Standalone 27 | 28 | #### Configure with a Proxy List provider 29 | 30 | Configure a new files provider or configure it in any existing files provider. 31 | 32 | ```yaml {filename="/config/tsdproxy.yaml"} 33 | files: 34 | proxies: 35 | filename: /config/proxies.yaml 36 | ``` 37 | 38 | #### Add Dashboard entry on your Proxy List file 39 | 40 | ```yaml {filename="/config/proxies.yaml"} 41 | dash: 42 | url: http://127.0.0.1:8080 43 | ``` 44 | 45 | ### Test Dashboard access 46 | 47 | ```bash 48 | curl https://dash.FUNNY-NAME.ts.net 49 | ``` 50 | 51 | > [!NOTE] 52 | > Note that you need to replace `FUNNY-NAME` with the name of your network. 53 | 54 | {{% /steps %}} 55 | -------------------------------------------------------------------------------- /docs/content/docs/advanced/docker-secrets.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Docker secrets 3 | --- 4 | 5 | If you want to use Docker secrets to store your Tailscale authkey, you can use 6 | the following example: 7 | 8 | {{% steps %}} 9 | 10 | ### Requirements 11 | 12 | Make sure you have Docker Swarm enabled on your server. 13 | 14 | 15 | 16 | "Docker secrets are only available to swarm services, not to standalone 17 | containers. To use this feature, consider adapting your container to run as a service." 18 | 19 | ### Add a docker secret 20 | 21 | We need to create a docker secret, which we can name `authkey` and store the Tailscale 22 | authkey in it. We can do that using the following command: 23 | 24 | ```bash 25 | printf "Your Tailscale AuthKey" | docker secret create authkey - 26 | ``` 27 | 28 | ### TsDProxy Docker compose 29 | 30 | ```yaml docker-compose.yml 31 | services: 32 | tsdproxy: 33 | image: almeidapaulopt/tsdproxy:latest 34 | volumes: 35 | - /var/run/docker.sock:/var/run/docker.sock 36 | - datadir:/data 37 | - :/config 38 | secrets: 39 | - authkey 40 | 41 | volumes: 42 | datadir: 43 | 44 | secrets: 45 | authkey: 46 | external: true 47 | ``` 48 | 49 | ### TsDProxy configuration 50 | 51 | ```yaml /config/tsdproxy.yaml 52 | tailscale: 53 | providers: 54 | default: # name of the provider 55 | authkeyfile: "/run/secrets/authkey" 56 | ``` 57 | 58 | ### Restart tsdproxy 59 | 60 | ``` bash 61 | docker compose restart 62 | ``` 63 | 64 | {{% /steps %}} 65 | -------------------------------------------------------------------------------- /docs/content/docs/advanced/headscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Headscale 3 | draft: true 4 | --- 5 | 6 | In case you want to use the Headscale service, please read the following: 7 | 8 | {{% steps %}} 9 | 10 | ### In your TSDProxy docker-compose.yaml 11 | 12 | Add the following to the `environment` section: 13 | 14 | ```yaml docker-compose.yml 15 | environment: 16 | - TSDPROXY_CONTROLURL="url of you headscale server" 17 | ``` 18 | 19 | ### Restart TSDProxy 20 | 21 | ```bash 22 | docker compose restart 23 | ``` 24 | 25 | {{% /steps %}} 26 | -------------------------------------------------------------------------------- /docs/content/docs/advanced/host-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Service with host network_mode 3 | --- 4 | 5 | If you want to run a service in `network_mode: host`, TSDProxy tries to detect how 6 | to proxy the container. In case of not autodetection work for your case, you need 7 | to specify a port in the `tsdproxy.container_port` option. 8 | 9 | {{% steps %}} 10 | 11 | ### Add a label to your service 12 | 13 | In your service, add the following label, with the port you want to use in the container: 14 | 15 | ```yaml 16 | labels: 17 | tsdproxy.container_port: 8080 18 | ``` 19 | 20 | ### Restart your service 21 | 22 | After you restart your service, you should be able to access it using the port 23 | you specified in the label. 24 | 25 | {{% /steps %}} 26 | -------------------------------------------------------------------------------- /docs/content/docs/advanced/icons.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dashboard icons 3 | --- 4 | 5 | TSDProxy supports three comprehensive icon libraries: 6 | 7 | 1. **Material Design Icons** [pictogrammers.com/library/mdi](https://pictogrammers.com/library/mdi/), 8 | offering a vast collection of intuitive and versatile icons. Use "mdi/" as the prefix. 9 | 2. **Simple Icons** [simpleicons.org](https://simpleicons.org), which includes 10 | icons for popular brands and services. Use "si/" as prefix. 11 | 3. **Selfh.st Icons** [selfh.st/icons](https://selfh.st/icons/), 12 | collection of icons and logos for self-hosted dashboards. Use "sh/" as prefix. 13 | 14 | >[!NOTE] 15 | > Only SVG icons are available. 16 | 17 | ## How it works 18 | 19 | 1. Select the icon in icon libraries websites. 20 | 2. Add the definition to your proxy "tsdproxy.dash.icon" in [docker provider](/docs/docker/#tsdproxydashicon) 21 | or "icon" in dashboard section for [Proxy List](/docs/list/#proxy-list-file-options) 22 | 3. Set the icon definition to "library/icon" 23 | (don't add extension, TSDProxy will add .svg) 24 | 25 | ## Examples: 26 | 27 | "si/tailscale" [simpleicons.org/?q=tailscale](https://simpleicons.org/?q=tailscale). 28 | 29 | "mdi/music-box" [pictogrammers.com/library/mdi/icon/music-box](https://pictogrammers.com/library/mdi/icon/music-box/). 30 | 31 | "sh/adguard-home" [selfh.st/icon/](https://selfh.st/icons/). With the mouse 32 | hover on the "svg" icon, you can see the name of the icon. 33 | -------------------------------------------------------------------------------- /docs/content/docs/advanced/tailscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tailscale 3 | next: /docs/scenarios 4 | --- 5 | 6 | ## OAuth 7 | 8 | {{% steps %}} 9 | 10 | ### Disable AuthKey 11 | 12 | OAuth authentication mode is enable if no AuthKey is set in the configuration 13 | for Tailscale provider. 14 | 15 | Like: 16 | 17 | ```yaml {filename="/config/tsdproxy.yaml"} 18 | tailscale: 19 | providers: 20 | default: 21 | authKey: "" 22 | authKeyFile: "" 23 | ``` 24 | 25 | When the proxy starts, it will wait to be authenticated with the Tailscale. 26 | 27 | ### Go Dashboard 28 | 29 | Go to TSDProxy Dashboard (example: ). 30 | 31 | ### Authenticate 32 | 33 | Click on the Proxy that should show "Authentication" status. 34 | 35 | >[!TIP] 36 | > Set "Ephemeral" to false in the Tailscale provider to avoid the need of 37 | authentication next time. See [docker Ephemeral label](../../docker/#tsdproxyephemeral) 38 | or [Proxy List configuration](../../list/#proxy-list-file-options) 39 | 40 | {{% /steps %}} 41 | 42 | ## AuthKey 43 | 44 | {{% steps %}} 45 | 46 | ### Generate Authkey 47 | 48 | 1. Go to [https://login.tailscale.com/admin/settings/keys](https://login.tailscale.com/admin/settings/keys) 49 | 2. Click in "Generate auth key" 50 | 3. Add a Description 51 | 4. Enable Reusable 52 | 5. Enable Ephemeral 53 | 6. Add Tags if you need 54 | 7. Click in "Generate key" 55 | 56 | >[!WARNING] 57 | > If tags were added to the key, all proxies initialized with the same authkey 58 | > will get the same tags. 59 | > Add a new Tailscale provider to the configuration if 60 | > you need to use different) 61 | 62 | ### Add to configuration 63 | 64 | Add you key to the configuration as follow: 65 | 66 | ```yaml {filename="/config/tsdproxy.yaml"} 67 | tailscale: 68 | providers: 69 | default: 70 | authKey: "GENERATED KEY HERE" 71 | authKeyFile: "" 72 | ``` 73 | 74 | ### Restart 75 | 76 | Restart TSDProxy 77 | 78 | {{% /steps %}} 79 | 80 | ## Funnel 81 | 82 | Beside adding the TSDProxy configuration to activate Funnel to a proxy, you also 83 | should give permissions on Tailscale ACL. See [here](.././troubleshooting/#funnel-doesnt-work) to more detail. 84 | -------------------------------------------------------------------------------- /docs/content/docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | weight: 500 4 | --- 5 | 6 | {{% steps %}} 7 | 8 | ### 2.0.0-beta4 9 | 10 | #### New features 11 | 12 | - Multiple ports in each tailscale hosts 13 | - Enable and activate multiple redirects 14 | - Proxies can use http and https 15 | - OAuth autentication without using the dashboard 16 | - Assign Tags on Tailscale hosts 17 | - Dashboard gets updated in real-time 18 | - Search in the dashboard 19 | - Dashboard proxies are sorted in alphabetically order 20 | - Add support to swam stacks 21 | - Tailscale user profile in top-right of Dashboard 22 | - Pass Tailscale identity headers to destination service 23 | 24 | #### Breaking changes 25 | 26 | - Files provider is now Lists ( key in /config/tsdproxy.yaml changed to 27 | **lists:** instead of files:) 28 | - Lists are now a different yaml file to support multiple ports and redirects, 29 | please [Lists](../v2/providers/lists) 30 | 31 | #### Deprecated Docker labels 32 | 33 | - tsdproxy.autodetect 34 | - tsdproxy.container_port 35 | - tsdproxy.funnel 36 | - tsdproxy.scheme 37 | - tsdproxy.tlsvalidate 38 | 39 | ### 1.4.0 40 | 41 | #### New features 42 | 43 | - OAuth authentication using the Dashboard. 44 | - Dashboard has now proxy status. 45 | - Icons and Labels can be used to customize the Dashboard. 46 | 47 | #### Fixes 48 | 49 | - Error on port when autodetect is disabled. 50 | 51 | ### 1.3.0 52 | 53 | #### Braking changes 54 | 55 | Configuration files are now validated and doesn't allow invalid configuration keys 56 | [Verify valid configuration keys](../serverconfig/#sample-configuration-file). 57 | 58 | #### New features 59 | 60 | - Generate TLS certificates for containers when starting proxies. 61 | - Configuration files are now validated. 62 | 63 | ### 1.2.0 64 | 65 | #### New features 66 | 67 | Dashboard finally arrived. 68 | 69 | ### 1.1.2 70 | 71 | #### Fixes 72 | 73 | Reload Proxy List Files when changes. 74 | 75 | #### New features 76 | 77 | - Quicker start with different approach to start proxies in docker 78 | - Add support for targets with self-signed certificates. 79 | 80 | ### 1.1.1 81 | 82 | #### New Docker container labels 83 | 84 | ##### tsdproxy.autodetect 85 | 86 | If TSDProxy, for any reason, can't detect the container's network you can 87 | disable it. 88 | 89 | ##### tsdproxy.scheme 90 | 91 | If a container uses https, use tsdproxy.scheme=https label. 92 | 93 | ### 1.1.0 94 | 95 | #### New File Provider 96 | 97 | TSDProxy now supports a new file provider. It's useful if you want to proxy URL 98 | without Docker. 99 | Now you can use TSDProxy even without Docker. 100 | 101 | ### 1.0.0 102 | 103 | #### New Autodetection function for containers network 104 | 105 | TSDProxy now tries to connect to the container using docker internal 106 | ip addresses and ports. It's more reliable and faster, even in container without 107 | exposed ports. 108 | 109 | #### New configuration method 110 | 111 | TSDProxy still supports the Environment variable method. But there's much more 112 | power with the new configuration yaml file. 113 | 114 | #### Multiple Tailscale servers 115 | 116 | TSDProxy now supports multiple Tailscale servers. This option is useful if you 117 | have multiple Tailscale accounts, if you want to group containers with the same 118 | AUTHKEY or if you want to use different servers for different containers. 119 | 120 | #### Multiple Docker servers 121 | 122 | TSDProxy now supports multiple Docker servers. This option is useful if you have 123 | multiple Docker instances and don't want to deploy and manage TSDProxy on each one. 124 | 125 | #### New installation scenarios documentation 126 | 127 | Now there is a new [scenarios](/docs/scenarios) section. 128 | 129 | #### New logs 130 | 131 | Now logs are more readable and easier to read and with context. 132 | 133 | #### New Docker container labels 134 | 135 | **tsdproxy.proxyprovider** is the label that defines the Tailscale proxy 136 | provider. It's optional. 137 | 138 | #### TSDProxy can now run standalone 139 | 140 | With the new configuration file, TSDProxy can be run standalone. 141 | Just run tsdproxyd --config ./config . 142 | 143 | #### New flag --config 144 | 145 | This new flag allows you to specify a configuration file. It's useful if you 146 | want to use as a command line tool instead of a container. 147 | 148 | ```bash 149 | tsdproxyd --config ./config/tsdproxy.yaml 150 | ``` 151 | 152 | {{% /steps %}} 153 | -------------------------------------------------------------------------------- /docs/content/docs/docker.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Docker Services 3 | weight: 3 4 | --- 5 | 6 | To add a service to your TSDProxy instance, you need to add a label to your 7 | service container. 8 | 9 | {{% steps %}} 10 | 11 | ### tsdproxy.enable 12 | 13 | Just add the label `tsdproxy.enable` to true and restart you service. The 14 | container will be started and TSDProxy will be enabled. 15 | 16 | ```yaml 17 | labels: 18 | tsdproxy.enable: "true" 19 | ``` 20 | 21 | TSProxy will use container name as Tailscale server, and will use one exposed 22 | port to proxy traffic. 23 | 24 | ### tsdproxy.name 25 | 26 | If you define a name different from the container name, you can define it with 27 | the label `tsdproxy.name` and it will be used as the Tailscale server name. 28 | 29 | ```yaml 30 | labels: 31 | tsdproxy.enable: "true" 32 | tsdproxy.name: "myserver" 33 | ``` 34 | 35 | ### tsdproxy.container_port 36 | 37 | If you want to define a different port than the default one, you can define it 38 | with the label `tsdproxy.container_port`. 39 | This is useful if the container has multiple exposed ports or if the container 40 | is running in network_mode=host. 41 | 42 | ```yaml 43 | ports: 44 | - 8081:8080 45 | - 8000:8000 46 | labels: 47 | tsdproxy.enable: "true" 48 | tsdproxy.name: "myserver" 49 | tsdproxy.container_port: 8080 50 | ``` 51 | 52 | > [!NOTE] 53 | Note that the port used in the `tsdproxy.container_port` label is the port used 54 | internal in the container and not the exposed port. 55 | 56 | ### tsdproxy.ephemeral 57 | 58 | If you want to use an ephemeral container, you can define it with the label `tsdproxy.ephemeral`. 59 | 60 | ```yaml 61 | labels: 62 | tsdproxy.enable: "true" 63 | tsdproxy.name: "myserver" 64 | tsdproxy.ephemeral: "true" 65 | ``` 66 | 67 | ### tsdproxy.webclient 68 | 69 | If you want to enable the Tailscale webclient (port 5252), you can define it 70 | with the label `tsdproxy.webclient`. 71 | 72 | ```yaml 73 | labels: 74 | tsdproxy.enable: "true" 75 | tsdproxy.name: "myserver" 76 | tsdproxy.webclient: "true" 77 | ``` 78 | 79 | ### tsdproxy.tsnet_verbose 80 | 81 | If you want to enable Tailscale's verbose mode, you can define it with the label 82 | `tsdproxy.tsnet_verbose`. 83 | 84 | ```yaml 85 | labels: 86 | tsdproxy.enable: "true" 87 | tsdproxy.name: "myserver" 88 | tsdproxy.tsnet_verbose: "true" 89 | ``` 90 | 91 | ### tsdproxy.funnel 92 | 93 | To enable funnel mode, you can define it with the label `tsdproxy.funnel`. 94 | 95 | ```yaml 96 | labels: 97 | tsdproxy.enable: "true" 98 | tsdproxy.name: "myserver" 99 | tsdproxy.funnel: "true" 100 | ``` 101 | 102 | ### tsdproxy.authkey 103 | 104 | Enable TSDProxy authentication with a different Authkey. 105 | This give the possibility to add tags on your containers if were defined when 106 | created the Authkey. 107 | 108 | ```yaml 109 | labels: 110 | tsdproxy.enable: "true" 111 | tsdproxy.authkey: "YOUR_AUTHKEY_HERE" 112 | ``` 113 | 114 | ### tsdproxy.authkeyfile 115 | 116 | Authkeyfile is the path to your Authkey. This is useful if you want to use 117 | docker secrets. 118 | 119 | ```yaml 120 | labels: 121 | tsdproxy.enable: "true" 122 | tsdproxy.authkey: "/run/secrets/authkey" 123 | ``` 124 | 125 | ### tsdproxy.proxyprovider 126 | 127 | If you want to use a proxy provider other than the default one, you can define 128 | it with the label `tsdproxy.proxyprovider`. 129 | 130 | ```yaml 131 | labels: 132 | tsdproxy.enable: "true" 133 | tsdproxy.proxyprovider: "providername" 134 | ``` 135 | 136 | ### tsdproxy.autodetect 137 | 138 | Defaults to true, if your having problem with the internal network interfaces 139 | autodetection, set to false. 140 | 141 | ```yaml 142 | labels: 143 | tsdproxy.enable: "true" 144 | tsdproxy.autodetect: "false" 145 | ``` 146 | 147 | ### tsdproxy.scheme 148 | 149 | Defaults to "http", set to https to enable "https" if the container is running 150 | with TLS. 151 | 152 | ```yaml 153 | labels: 154 | tsdproxy.enable: "true" 155 | tsdproxy.scheme: "https" 156 | ``` 157 | 158 | ### tsdproxy.tlsvalidate 159 | 160 | Defaults to true, set to false to disable TLS validation. 161 | 162 | ```yaml 163 | labels: 164 | tsdproxy.enable: "true" 165 | tsdproxy.scheme: "https" 166 | tsdproxy.tlsvalidate: "false" 167 | ``` 168 | 169 | ### tsdproxy.dash.visible 170 | 171 | Defaults to true, set to false to hide on Dashboard. 172 | 173 | ```yaml 174 | labels: 175 | tsdproxy.enable: "true" 176 | tsdproxy.dash.visible: "false" 177 | ``` 178 | 179 | ### tsdproxy.dash.label 180 | 181 | Sets the proxy label on dashboard. Defaults to tsdproxy.name. 182 | 183 | ```yaml 184 | labels: 185 | tsdproxy.enable: "true" 186 | tsdproxy.name: "nas" 187 | tsdproxy.dash.label: "Files" 188 | ``` 189 | 190 | ### tsdproxy.dash.icon 191 | 192 | Sets the proxy icon on dashboard. If not defined, TSDProxy will try to find a 193 | icon based on the image name. See available icons in [icons](/docs/advanced/icons). 194 | 195 | ```yaml 196 | labels: 197 | tsdproxy.enable: "true" 198 | tsdproxy.dash.icon: "si/portainer" 199 | ``` 200 | 201 | {{% /steps %}} 202 | -------------------------------------------------------------------------------- /docs/content/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | weight: 1 4 | prev: /docs 5 | --- 6 | 7 | ## Quick Start 8 | 9 | Using Docker Compose, you can easily configure the proxy to your Tailscale 10 | containers. Here’s an example of how you can configure your services using 11 | Docker Compose: 12 | 13 | {{% steps %}} 14 | 15 | ### Create a TSDProxy docker-compose.yaml 16 | 17 | ```yaml docker-compose.yml 18 | services: 19 | tsdproxy: 20 | image: almeidapaulopt/tsdproxy:1 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock 23 | - datadir:/data 24 | - :/config 25 | restart: unless-stopped 26 | ports: 27 | - "8080:8080" 28 | 29 | volumes: 30 | datadir: 31 | ``` 32 | 33 | ### Start the TSDProxy container 34 | 35 | ```bash 36 | docker compose up -d 37 | ``` 38 | 39 | ### Configure TSDProxy 40 | 41 | After the TSDProxy container is started, a configuration file 42 | `/config/tsdproxy.yaml` is created and populated with the following: 43 | 44 | ```yaml {filename="/config/tsdproxy.yaml"} 45 | defaultProxyProvider: default 46 | docker: 47 | local: # name of the docker target provider 48 | host: unix:///var/run/docker.sock # host of the docker socket or daemon 49 | targetHostname: 172.31.0.1 # hostname or IP of docker server 50 | defaultProxyProvider: default # name of which proxy provider to use 51 | files: {} 52 | tailscale: 53 | providers: 54 | default: # name of the provider 55 | authKey: "" # optional, define authkey here 56 | authKeyFile: "" # optional, use this to load authkey from file. If this is defined, Authkey is ignored 57 | controlUrl: https://controlplane.tailscale.com # use this to override the default control URL 58 | dataDir: /data/ 59 | http: 60 | hostname: 0.0.0.0 61 | port: 8080 62 | log: 63 | level: info # set logging level info, error or trace 64 | json: false # set to true to enable json logging 65 | proxyAccessLog: true # set to true to enable container access log 66 | ``` 67 | 68 | #### Edit the configuration file 69 | 70 | 1. Change your docker host if you are not using the socket. 71 | 2. Restart the service if you changed the configuration. 72 | 73 | ```bash 74 | docker compose restart 75 | ``` 76 | 77 | ### Run a sample service 78 | 79 | Here we’ll use the nginx image to serve a sample service. 80 | The container name is `sample-nginx`, expose port 8111, and add the 81 | `tsdproxy.enable` label. 82 | 83 | ```bash 84 | docker run -d --name sample-nginx -p 8111:80 --label "tsdproxy.enable=true" nginx:latest 85 | ``` 86 | 87 | ### Open Dashboard 88 | 89 | 1. Visit the dashboard at http://:8080. 90 | 2. Sample-nginx should appear in the dashboard. Click the button and 91 | authenticate with Tailscale. 92 | 3. After authentication, the proxy will be enabled. 93 | 94 | > [!IMPORTANT] 95 | > The first time you run the proxy, it will take a few seconds to start, because 96 | > it needs to connect to the Tailscale network, generate the certificates, and start 97 | > the proxy. 98 | 99 | {{% /steps %}} 100 | -------------------------------------------------------------------------------- /docs/content/docs/list.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Proxy List 3 | next: /docs/advanced 4 | weight: 4 5 | --- 6 | 7 | TSDProxy can be configured to proxy using a YAML configuration file. 8 | Multiple files can be used, and they are referred to as target providers. 9 | Each target provider could be used to group the way you decide better to help 10 | you manage your proxies. Or can use a single file to proxy all your targets. 11 | 12 | > [!CAUTION] 13 | > Configuration files are case sensitive 14 | 15 | {{% steps %}} 16 | 17 | ### How to enable? 18 | 19 | In your /config/tsdproxy.yaml, specify the files you want to use, just 20 | like this example where the `critical` and `media` providers are defined. 21 | 22 | ```yaml {filename="/config/tsdproxy.yaml"} 23 | files: 24 | critical: 25 | filename: /config/critical.yaml 26 | defaultProxyProvider: tailscale1 27 | defaultProxyAccessLog: true 28 | media: 29 | filename: /config/media.yaml 30 | defaultProxyProvider: default 31 | defaultProxyAccessLog: false 32 | ``` 33 | 34 | ```yaml {filename="/config/critical.yaml"} 35 | nas1: 36 | url: https://192.168.1.2:5001 37 | tlsValidate: false 38 | nas2: 39 | url: https://192.168.1.3:5001 40 | tlsValidate: false 41 | ``` 42 | 43 | ```yaml {filename="/config/media.yaml"} 44 | music: 45 | url: http://192.168.1.10:3789 46 | video: 47 | url: http://192.168.1.10:3800 48 | photos: 49 | url: http://192.168.1.10:3801 50 | ``` 51 | 52 | This configuration will create two groups of proxies: 53 | 54 | - nas1.funny-name.ts.net and nas2.funny-name.ts.net 55 | - Self-signed tls certificates 56 | - Both use 'tailscale1' Tailscale provider 57 | - All access logs are enabled 58 | - music.ts.net, video.ts.net and photos.ts.net. 59 | - On the same host with different ports 60 | - Use 'default' Tailscale provider 61 | - Don't enable access logs 62 | 63 | ### Provider Configuration options 64 | 65 | ```yaml {filename="/config/tsdproxy.yaml"} 66 | files: 67 | critical: # Name the target provider 68 | filename: /config/critical.yaml # file with the proxy list 69 | defaultProxyProvider: tailscale1 # (optional) default proxy provider 70 | defaultProxyAccessLog: true # (optional) Enable access logs 71 | ``` 72 | 73 | ### Proxy list file options 74 | 75 | ```yaml {filename="/config/filename.yaml"} 76 | music: # Name of the proxy 77 | url: http://192.168.1.10:3789 # url of service to proxy 78 | proxyProvider: default # (optional) name of the proxy provider 79 | tlsValidate: false # (optional, default true) disable TLS validation 80 | tailscale: # (optional) Tailscale configuration for this proxy 81 | authKey: asdasdas # (optional) Tailscale authkey 82 | ephemeral: false # (optional) disable ephemeral mode 83 | runWebClient: false # (optional) Run web client 84 | verbose: false # (optional) Run in verbose mode 85 | funnel: false # (optional) Run in funnel mode 86 | dashboard: 87 | visible: false # (optional) doesn't show proxy in dashboard 88 | label: "" # optional, label to be shown in dashboard 89 | icon: "" # optional, icon to be shown in dashboard 90 | ``` 91 | 92 | > [!TIP] 93 | > TSDProxy will reload the proxy list when it is updated. 94 | > You only need to restart TSDProxy if your changes are in /config/tsdproxy.yaml 95 | 96 | > [!NOTE] 97 | > See available icons in [icons](/docs/advanced/icons). 98 | 99 | {{% /steps %}} 100 | -------------------------------------------------------------------------------- /docs/content/docs/scenarios/1i-1docker-1tailscale-1servarr.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: One TSDProxy instance, one Docker server, one Tailscale provider and a Servarr stack using network_mode service:vpn 3 | prev: /docs/scenarios/ 4 | --- 5 | ## Description 6 | 7 | In this scenario, we will have: 8 | 9 | 1. one TSDProxy instance. 10 | 2. one Docker server (with a servarr stack). 11 | 3. one Tailscale configuration. 12 | 13 | ## Scenario 14 | 15 | ![tsdproxy with servarr](1i-1docker-1tailscale-1servarr.svg) 16 | 17 | ### Server 18 | 19 | ```yaml {filename="docker-compose.yaml"} 20 | services: 21 | tsdproxy: 22 | image: tsdproxy:latest 23 | user: root 24 | ports: 25 | - "8080:8080" 26 | volumes: 27 | - :/config 28 | - data:/data 29 | - /var/run/docker.sock:/var/run/docker.sock 30 | restart: unless-stopped 31 | 32 | prowlarr: 33 | container_name: prowlarr 34 | image: lscr.io/linuxserver/prowlarr:latest 35 | network_mode: "service:vpn" 36 | 37 | vpn: 38 | image: qmcgaw/gluetun 39 | networks: 40 | tailscale: 41 | aliases: 42 | - prowlarr 43 | 44 | prowlarr: 45 | network_mode: "service:vpn" 46 | ``` 47 | 48 | ```yaml {filename="/config/tsdproxy.yaml"} 49 | files: 50 | media: 51 | filename: /config/media.yaml 52 | defaultProxyProvider: default 53 | defaultProxyAccessLog: false 54 | ``` 55 | 56 | ```yaml {filename="/config/media.yaml"} 57 | prowlarr: 58 | url: http://prowlarr:9696 59 | ``` -------------------------------------------------------------------------------- /docs/content/docs/scenarios/1i-2docker-1tailscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: One TSDProxy instance, two Docker servers and one Tailscale provider 3 | prev: /docs/scenarios/ 4 | --- 5 | ## Description 6 | 7 | In this scenario, we will have: 8 | 9 | 1. one TSDProxy instance. 10 | 2. two Docker servers. 11 | 3. one Tailscale configuration. 12 | 13 | ## Scenario 14 | 15 | ![multiple docker server with a single TSDProxy instance](1i-2docker-1tailscale.svg) 16 | 17 | ### Server 1 18 | 19 | ```yaml {filename="docker-compose.yaml"} 20 | services: 21 | tsdproxy: 22 | image: tsdproxy:latest 23 | user: root 24 | ports: 25 | - "8080:8080" 26 | volumes: 27 | - :/config 28 | - data:/data 29 | - /var/run/docker.sock:/var/run/docker.sock 30 | restart: unless-stopped 31 | 32 | webserver1: 33 | image: nginx 34 | ports: 35 | - 81:80 36 | labels: 37 | tsdproxy.enable: true 38 | tsdproxy.name: webserver1 39 | 40 | portainer: 41 | image: portainer/portainer-ee:2.21.4 42 | ports: 43 | - "9443:9443" 44 | - "9000:9000" 45 | - "8000:8000" 46 | volumes: 47 | - portainer_data:/data 48 | - /var/run/docker.sock:/var/run/docker.sock 49 | labels: 50 | tsdproxy.enable: true 51 | tsdproxy.name: portainer 52 | tsdproxy.container_port: 9000 53 | 54 | volumes: 55 | data: 56 | portainer_data: 57 | ``` 58 | 59 | ### Server 2 60 | 61 | ```yaml {filename="docker-compose.yaml"} 62 | services: 63 | webserver2: 64 | image: nginx 65 | ports: 66 | - 81:80 67 | labels: 68 | tsdproxy.enable: true 69 | tsdproxy.name: webserver2 70 | 71 | memos: 72 | image: neosmemo/memos:stable 73 | container_name: memos 74 | volumes: 75 | - memos:/var/opt/memos 76 | ports: 77 | - 5230:5230 78 | labels: 79 | tsdproxy.enable: true 80 | tsdproxy.name: memos 81 | tsdproxy.container_port: 5230 82 | 83 | volumes: 84 | memos: 85 | ``` 86 | 87 | ## TSDProxy Configuration 88 | 89 | ```yaml {filename="/config/tsdproxy.yaml"} 90 | defaultProxyProvider: default 91 | docker: 92 | srv1: 93 | host: unix:///var/run/docker.sock 94 | defaultProxyProvider: default 95 | srv2: 96 | host: tcp://174.17.0.1:2376 97 | targetHostname: 174.17.0.1 98 | defaultProxyProvider: default 99 | tailscale: 100 | providers: 101 | default: 102 | authKey: "sdfsdgsdfgdfg" 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/content/docs/scenarios/1i-2docker-3tailscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: One TSDProxy instance, two Docker servers and three Tailscale providers 3 | --- 4 | ## Description 5 | 6 | In this scenario, we will have : 7 | 8 | 1. two Docker servers. 9 | 2. only one TSDProxy instance. 10 | 3. three Tailscale providers. 11 | 4. Containers in SRV1 will use the 'default' provider. 12 | 5. Containers in SRV2 will use the 'account2' provider. 13 | 6. webserver1 is running in SRV1 but will use the 'withtags' provider. 14 | 7. memos is running in SRV2 but will use the 'withtags' provider. 15 | 16 | ## Scenario 17 | 18 | ![1 TSDProxy instance, 2 Docker servers and 3 Tailscale providers](1i-2docker-3tailscale.svg) 19 | 20 | ### Server 1 21 | 22 | ```yaml {filename="docker-compose.yaml"} 23 | services: 24 | tsdproxy: 25 | image: tsdproxy:latest 26 | user: root 27 | ports: 28 | - "8080:8080" 29 | volumes: 30 | - :/config 31 | - data:/data 32 | - /var/run/docker.sock:/var/run/docker.sock 33 | restart: unless-stopped 34 | 35 | webserver1: 36 | image: nginx 37 | ports: 38 | - 81:80 39 | labels: 40 | tsdproxy.enable: true 41 | tsdproxy.name: webserver1 42 | tsdproxy.provider: withtags 43 | 44 | 45 | portainer: 46 | image: portainer/portainer-ee:2.21.4 47 | ports: 48 | - "9443:9443" 49 | - "9000:9000" 50 | - "8000:8000" 51 | volumes: 52 | - portainer_data:/data 53 | - /var/run/docker.sock:/var/run/docker.sock 54 | labels: 55 | tsdproxy.enable: true 56 | tsdproxy.name: portainer 57 | tsdproxy.container_port: 9000 58 | 59 | volumes: 60 | data: 61 | portainer_data: 62 | ``` 63 | 64 | ### Server 2 65 | 66 | ```yaml {filename="docker-compose.yaml"} 67 | services: 68 | webserver2: 69 | image: nginx 70 | ports: 71 | - 81:80 72 | labels: 73 | - tsdproxy.enable=true 74 | - tsdproxy.name=webserver2 75 | 76 | memos: 77 | image: neosmemo/memos:stable 78 | container_name: memos 79 | volumes: 80 | - memos:/var/opt/memos 81 | ports: 82 | - 5230:5230 83 | labels: 84 | tsdproxy.enable: true 85 | tsdproxy.name: memos 86 | tsdproxy.container_port: 5230 87 | tsdproxy.provider: withtags 88 | 89 | volumes: 90 | memos: 91 | ``` 92 | 93 | ## TSDProxy Configuration 94 | 95 | ```yaml {filename="/config/tsdproxy.yaml"} 96 | defaultProxyProvider: default 97 | docker: 98 | srv1: 99 | host: unix:///var/run/docker.sock 100 | defaultProxyProvider: default 101 | srv2: 102 | host: tcp://174.17.0.1:2376 103 | targetHostname: 174.17.0.1 104 | defaultProxyProvider: account2 105 | tailscale: 106 | providers: 107 | default: 108 | authKey: "sdfsdgsdfgdfg" 109 | withtags: 110 | authKey: "jujgnndvds" 111 | account2: 112 | authKey: "nnnnnnndndnddd" 113 | ``` 114 | -------------------------------------------------------------------------------- /docs/content/docs/scenarios/2i-2docker-1tailscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Two TSDProxy instances, two Docker servers and one Tailscale provider 3 | --- 4 | ## Description 5 | 6 | In this scenario, we will have: 7 | 8 | 1. two TSDProxy instances 9 | 2. two Docker servers running 10 | 3. one Tailscale provider. 11 | 12 | ## Scenario 13 | 14 | ![2 tsdproxy instances, 2 docker servers, 1 tailscale provider](2i-2docker-1tailscale.svg) 15 | 16 | ### Server 1 17 | 18 | ```yaml {filename="docker-compose.yaml"} 19 | services: 20 | tsdproxy: 21 | image: tsdproxy:latest 22 | user: root 23 | ports: 24 | - "8080:8080" 25 | volumes: 26 | - :/config 27 | - data:/data 28 | - /var/run/docker.sock:/var/run/docker.sock 29 | restart: unless-stopped 30 | 31 | webserver1: 32 | image: nginx 33 | ports: 34 | - 81:80 35 | labels: 36 | - tsdproxy.enable=true 37 | - tsdproxy.name=webserver1 38 | 39 | portainer: 40 | image: portainer/portainer-ee:2.21.4 41 | ports: 42 | - "9443:9443" 43 | - "9000:9000" 44 | - "8000:8000" 45 | volumes: 46 | - portainer_data:/data 47 | - /var/run/docker.sock:/var/run/docker.sock 48 | labels: 49 | tsdproxy.enable: "true" 50 | tsdproxy.name: "portainer" 51 | tsdproxy.container_port: 9000 52 | 53 | volumes: 54 | data: 55 | postainer_data: 56 | ``` 57 | 58 | ### Server 2 59 | 60 | ```yaml {filename="docker-compose.yaml"} 61 | services: 62 | tsdproxy: 63 | image: tsdproxy:latest 64 | user: root 65 | ports: 66 | - "8080:8080" 67 | volumes: 68 | - :/config 69 | - data:/data 70 | - /var/run/docker.sock:/var/run/docker.sock 71 | restart: unless-stopped 72 | 73 | webserver2: 74 | image: nginx 75 | ports: 76 | - 81:80 77 | labels: 78 | - tsdproxy.enable=true 79 | - tsdproxy.name=webserver2 80 | 81 | memos: 82 | image: neosmemo/memos:stable 83 | container_name: memos 84 | volumes: 85 | - memos:/var/opt/memos 86 | ports: 87 | - 5230:5230 88 | labels: 89 | tsdproxy.enable: "true" 90 | tsdproxy.name: "memos" 91 | tsdproxy.container_port: 5230 92 | 93 | volumes: 94 | memos: 95 | ``` 96 | 97 | ## TSDProxy Configuration of SRV1 98 | 99 | ```yaml {filename="/config/tsdproxy.yaml"} 100 | defaultProxyProvider: default 101 | docker: 102 | srv1: 103 | host: unix:///var/run/docker.sock 104 | defaultProxyProvider: default 105 | tailscale: 106 | providers: 107 | default: 108 | authKey: "SAMEKEY" 109 | ``` 110 | 111 | ## TSDProxy Configuration of SRV2 112 | 113 | ```yaml {filename="/config/tsdproxy.yaml"} 114 | defaultProxyProvider: default 115 | docker: 116 | srv2: 117 | host: unix:///var/run/docker.sock 118 | defaultProxyProvider: default 119 | tailscale: 120 | providers: 121 | default: 122 | authKey: "SAMEKEY" 123 | ``` 124 | -------------------------------------------------------------------------------- /docs/content/docs/scenarios/2i-2docker-3tailscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Two TSDProxy instances, two Docker servers and three Tailscale providers 3 | next: changelog 4 | --- 5 | ## Description 6 | 7 | In this scenario, we will have : 8 | 9 | 1. two Docker servers. 10 | 2. one TSDProxy instance in each docker server. 11 | 3. three Tailscale providers. 12 | 4. Containers in SRV1 will use the 'default' provider. 13 | 5. Containers in SRV2 will use the 'account2' provider. 14 | 6. webserver1 is running in SRV1 but will use the 'withtags' provider. 15 | 7. memos is running in SRV2 but will use the 'withtags' provider. 16 | 17 | ## Scenario 18 | 19 | ![2 tsdproxy instances, 2 Docker servers and 3 Tailscale providers](2i-2docker-3tailscale.svg) 20 | 21 | ### Server 1 22 | 23 | ```yaml {filename="docker-compose.yaml"} 24 | services: 25 | tsdproxy: 26 | image: tsdproxy:latest 27 | user: root 28 | ports: 29 | - "8080:8080" 30 | volumes: 31 | - :/config 32 | - data:/data 33 | - /var/run/docker.sock:/var/run/docker.sock 34 | restart: unless-stopped 35 | 36 | webserver1: 37 | image: nginx 38 | ports: 39 | - 81:80 40 | labels: 41 | tsdproxy.enable: true 42 | tsdproxy.name: webserver1 43 | tsdproxy.provider: withtags 44 | 45 | 46 | portainer: 47 | image: portainer/portainer-ee:2.21.4 48 | ports: 49 | - "9443:9443" 50 | - "9000:9000" 51 | - "8000:8000" 52 | volumes: 53 | - portainer_data:/data 54 | - /var/run/docker.sock:/var/run/docker.sock 55 | labels: 56 | tsdproxy.enable: true 57 | tsdproxy.name: portainer 58 | tsdproxy.container_port: 9000 59 | 60 | volumes: 61 | data: 62 | portainer_data: 63 | ``` 64 | 65 | ### Server 2 66 | 67 | ```yaml {filename="docker-compose.yaml"} 68 | services: 69 | webserver2: 70 | image: nginx 71 | ports: 72 | - 81:80 73 | labels: 74 | - tsdproxy.enable=true 75 | - tsdproxy.name=webserver2 76 | 77 | memos: 78 | image: neosmemo/memos:stable 79 | container_name: memos 80 | volumes: 81 | - memos:/var/opt/memos 82 | ports: 83 | - 5230:5230 84 | labels: 85 | tsdproxy.enable: true 86 | tsdproxy.name: memos 87 | tsdproxy.container_port: 5230 88 | tsdproxy.provider: withtags 89 | 90 | volumes: 91 | memos: 92 | ``` 93 | 94 | ## TSDProxy Configuration of SRV1 95 | 96 | ```yaml {filename="/config/tsdproxy.yaml"} 97 | defaultProxyProvider: default 98 | docker: 99 | srv1: 100 | host: unix:///var/run/docker.sock 101 | defaultProxyProvider: default 102 | tailscale: 103 | providers: 104 | default: 105 | authKey: "sdfsdgsdfgdfg" 106 | withtags: 107 | authKey: "jujgnndvds" 108 | account2: 109 | authKey: "nnnnnnndndnddd" 110 | ``` 111 | 112 | ## TSDProxy Configuration of SRV2 113 | 114 | ```yaml {filename="/config/tsdproxy.yaml"} 115 | defaultProxyProvider: default 116 | docker: 117 | srv2: 118 | host: unix:///var/run/docker.sock 119 | defaultProxyProvider: account2 120 | tailscale: 121 | providers: 122 | default: 123 | authKey: "sdfsdgsdfgdfg" 124 | withtags: 125 | authKey: "jujgnndvds" 126 | account2: 127 | authKey: "nnnnnnndndnddd" 128 | ``` 129 | -------------------------------------------------------------------------------- /docs/content/docs/scenarios/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | linkTitle: Scenarios 3 | title: Configuration scenarios 4 | prev: /docs/services 5 | next: 1i-2docker-1tailscale 6 | weight: 10 7 | --- 8 | 9 | 1. [One TSDProxy instance, two Docker servers, one Tailscale provider](1i-2docker-1tailscale) 10 | 2. [One TSDProxy instance, two Docker servers, three Tailscale providers](1i-2docker-3tailscale) 11 | 3. [Two TSDProxy instances, two Docker servers, one Tailscale provider](2i-2docker-1tailscale) 12 | 4. [Two TSDProxy instances,two Docker servers, three Tailscale providers](2i-2docker-3tailscale) 13 | 5. [One TSDProxy instance, one Docker server, one Tailscale provider and a Servarr stack using `network_mode: "service:vpn"`](1i-1docker-1tailscale-1servarr) 14 | -------------------------------------------------------------------------------- /docs/content/docs/serverconfig.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Server configuration 3 | weight: 2 4 | --- 5 | 6 | 7 | TSDProxy uses the configuration file `/config/tsdproxy.yaml` 8 | 9 | > [!IMPORTANT] 10 | > The environment variables configuration used until v0.6.0 is deprecated and 11 | > will be removed in the future. 12 | 13 | {{% steps %}} 14 | 15 | ### Sample configuration File 16 | 17 | > [!WARNING] 18 | > Configuration files are case sensitive 19 | 20 | ```yaml {filename="/config/tsdproxy.yaml"} 21 | defaultProxyProvider: default 22 | docker: 23 | local: # name of the docker target provider 24 | host: unix:///var/run/docker.sock # host of the docker socket or daemon 25 | targetHostname: 172.31.0.1 # hostname or IP of docker server 26 | defaultProxyProvider: default # name of which proxy provider to use 27 | files: 28 | critical: # Name the target provider 29 | filename: /config/critical.yaml # file with the proxy list 30 | defaultProxyProvider: tailscale1 # (optional) default proxy provider 31 | defaultProxyAccessLog: true # (optional) Enable access logs 32 | tailscale: 33 | providers: 34 | default: # name of the provider 35 | authKey: "" # define authkey here 36 | authKeyFile: "" # use this to load authkey from file. If this is defined, Authkey is ignored 37 | controlUrl: https://controlplane.tailscale.com # use this to override the default control URL 38 | dataDir: /data/ 39 | http: 40 | hostname: 0.0.0.0 41 | port: 8080 42 | log: 43 | level: info # set logging level info, error or trace 44 | json: false # set to true to enable json logging 45 | proxyAccessLog: true # set to true to enable container access log 46 | ``` 47 | 48 | ### log section 49 | 50 | #### level 51 | 52 | Define the logging level. The default is info. 53 | 54 | #### json 55 | 56 | Set to true if what logging in json format. 57 | 58 | ### tailscale section 59 | 60 | You can use the following options to configure Tailscale: 61 | 62 | #### dataDir 63 | 64 | Define the data directory used by Tailscale. The default is `/data/`. 65 | 66 | #### providers 67 | 68 | Here you can define multiple Tailscale providers. Each provider is configured 69 | with the following options: 70 | 71 | ```yaml {filename="/config/tsdproxy.yaml"} 72 | default: # name of the provider 73 | authKey: your-authkey # define authkey here 74 | authKeyFile: "" # use this to load authkey from file. 75 | controlUrl: https://controlplane.tailscale.com 76 | ``` 77 | 78 | Look at next example with multiple providers. 79 | 80 | ```yaml {filename="/config/tsdproxy.yaml"} 81 | tailscale: 82 | providers: 83 | default: 84 | authKey: your-authkey 85 | authKeyFile: "" 86 | controlUrl: https://controlplane.tailscale.com 87 | 88 | server1: 89 | authKey: authkey-server1 90 | authKeyFile: "" 91 | controlUrl: http://server1 92 | 93 | differentkey: 94 | authKey: authkey-with-diferent-tags 95 | authKeyFile: "" 96 | controlUrl: https://controlplane.tailscale.com 97 | ``` 98 | 99 | TSDProxy is configured with 3 tailscale providers. Provider 'default' with tailscale 100 | servers, Provider 'server1' with a different tailscale server and provider 'differentkey' 101 | using the default tailscale server with a different authkey where you can add any 102 | tags. 103 | 104 | > [!TIP] 105 | > Visit [Tailscale page](../advanced/tailscale/) for more details. 106 | 107 | ### docker section 108 | 109 | TSDProxy can use multiple docker servers. Each docker server can be configured 110 | like this: 111 | 112 | ```yaml {filename="/config/tsdproxy.yaml"} 113 | local: # name of the docker provider 114 | host: unix:///var/run/docker.sock # host of the docker socket or daemon 115 | targetHostname: 172.31.0.1 # hostname or IP of docker server 116 | defaultProxyProvider: default # name of which proxy provider to use 117 | ``` 118 | 119 | Look at next example of using a multiple docker servers configuration. 120 | 121 | ```yaml {filename="/config/tsdproxy.yaml"} 122 | docker: 123 | local: 124 | host: unix:///var/run/docker.sock 125 | defaultProxyProvider: default 126 | srv1: 127 | host: tcp://174.17.0.1:2376 128 | targetHostname: 174.17.0.1 129 | defaultProxyProvider: server1 130 | ``` 131 | 132 | TSDProxy is configured with a local server and a server remote 'srv1' 133 | 134 | #### host 135 | 136 | host is the address of the docker socket or daemon. The default is `unix:///var/run/docker.sock` 137 | 138 | #### targetHostname 139 | 140 | Is the ip address or dns name of docker server. TSDProxy has a autodetect system 141 | to connect with containers, but there's some cases where it's necessary to use 142 | the other interfaces besides the docker internals. 143 | 144 | #### defaultProxyProvider 145 | 146 | defaultProxyProvider is the name of the proxy provider to use. (defined in tailscale 147 | providers section). Any container defined to be proxied will use this provider 148 | unless it has a specific provider defined label. 149 | 150 | {{% /steps %}} 151 | -------------------------------------------------------------------------------- /docs/content/docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Troubleshooting 3 | prev: /docs/advanced 4 | weight: 300 5 | --- 6 | 7 | ## How to troubleshoot TSDProxy 8 | 9 | ### Global 10 | 11 | 1. Verify your tsdproxy.yaml file. Configuration files are Case sensitive. 12 | [Verify your files](../serverconfig/#sample-configuration-file) 13 | 14 | ### Docker provider 15 | 16 | 1. Verify if you added the label with tsdproxy.enable=true 17 | 2. Force use of the port adding tsdproxy.container_port=xxx to the container 18 | 3. If your container is using https add tsdproxy.scheme="https" to your container 19 | 4. If case of self certificates also add tsdproxy.tlsvalidate=false 20 | 5. Check if your firewall isn't blocking the traffic 21 | 6. Add your container to the same TSDProxy docker network 22 | 7. Disable autodetection with tsdproxy.autodetect="false" in your container 23 | 8. Verify if your case isn't in the next [Common errors](#common-errors) 24 | 9. Still having problems? Send a [Bug report](https://github.com/almeidapaulopt/tsdproxy/issues/new/choose) 25 | 26 | ### Proxies List provider 27 | 28 | 1. Configuration files are case sensitive. [Verify your files](../list/#proxy-list-file-options) 29 | 30 | ## Common Errors 31 | 32 | {{% steps %}} 33 | 34 | ### http: proxy error: tls: failed to verify certificate: x509: certificate 35 | 36 | The actual error is a TLS error. The most common cause is that the target has a 37 | self-signed certificate. 38 | 39 | ```yaml 40 | tsdproxy.enable: true 41 | tsdproxy.scheme: https 42 | tsdproxy.tlsvalidate: false 43 | ``` 44 | 45 | ### http: proxy error: dial tcp 172.18.0.1:8001: i/o timeout 46 | 47 | This error is caused by the target not being reachable. It's a network error. 48 | 49 | #### Cause: Firewall 50 | 51 | Most likely the firewall is blocking the traffic. If using UFW, execute this command: 52 | 53 | ```bash 54 | sudo ufw allow in from 172.17.0.0/16 55 | ``` 56 | 57 | #### Cause: Failed docker autodetection 58 | 59 | Try to disable autodetection and define the port: 60 | 61 | ```yaml 62 | labels: 63 | tsdproxy.enable: "true" 64 | tsdproxy.autodetect: "false" 65 | tsdproxy.container_port: 8001 66 | ``` 67 | 68 | ### Funnel doesn't work 69 | 70 | #### Cause: Funnel not enabled 71 | 72 | Visit to enable Funnel in ACL 73 | 74 | #### Cause: Using tags with Funnel 75 | 76 | If using tags, edit the attribute to include your tag(s), e.g.: 77 | 78 | ```json 79 | "nodeAttrs": [ 80 | { 81 | "target": ["autogroup:member", "tag:server"], 82 | "attr": ["funnel"], 83 | }, 84 | ], 85 | ``` 86 | 87 | {{%/ steps %}} 88 | -------------------------------------------------------------------------------- /docs/content/docs/v2/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | linkTitle: "Documentation v2" 3 | title: Introduction 4 | weight: 400 5 | --- 6 | 7 | 👋 Welcome to the TSDProxy documentation! 8 | 9 | > [!CAUTION] 10 | > Version 2 still in beta, but it's already available for testing. 11 | > 12 | > As a beta version, it may have some bugs, missing features, documentation errors, 13 | > or other issues. 14 | 15 | ## What is TSDProxy? 16 | 17 | TSDProxy is an application that automatically creates a proxy to 18 | virtual addresses in your Tailscale network. 19 | Easy to configure and deploy, based on Docker container labels or a simple proxy 20 | list file. 21 | It simplifies traffic redirection to services running inside Docker containers, 22 | without the need for a separate Tailscale container for each service. 23 | 24 | > [!NOTE] 25 | > TSDProxy just needs a label in your new docker service or a proxy list file and 26 | > it will be automatically created in your Tailscale network and the proxy will be 27 | > ready to be used. 28 | 29 | ## Why another proxy? 30 | 31 | TSDProxy was created to address the need for a proxy that can handle multiple services 32 | without the need for a dedicated Tailscale container for each service and without configuring 33 | virtual hosts in Tailscale network. 34 | 35 | ![how tsdproxy works](/images/tsdproxy.svg) 36 | 37 | ## What's different with TSDProxy? 38 | 39 | TSDProxy differs from other Tailscale proxies in that it does not require a separate Tailscale. 40 | 41 | ![how tsdproxy works](/images/tsdproxy-compare.svg) 42 | 43 | ## Features 44 | 45 | - **Easy to Use** - creates virtual Tailscale addresses using Docker container labels 46 | - **Really Easy to Use** - creates virtual Tailscale addresses using a proxy list 47 | - **Lightweight** -No need to spin up a dedicated Tailscale container for every service. 48 | - **Quick deploy** - No need to configure virtual hosts in Tailscale network. 49 | - **Automatically supports TLS** - Automatically supports Tailscale/LetsEncrypt certificates 50 | with MagicDNS. 51 | 52 | ## Questions or Feedback? 53 | 54 | > [!IMPORTANT] 55 | TSDProxy is still in active development. 56 | Have a question or feedback? Feel free to [open an issue](https://github.com/almeidapaulopt/tsdproxy/issues)! 57 | 58 | ## Next 59 | 60 | Dive right into the following section to get started: 61 | 62 | {{< cards >}} 63 | {{< card link="getting-started" title="Getting Started" icon="document-text" 64 | subtitle="Learn how to get started with TSDProxy" 65 | >}} 66 | {{< /cards >}} 67 | -------------------------------------------------------------------------------- /docs/content/docs/v2/advanced/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | linkTitle: Advanced 3 | title: Advanced Topics 4 | prev: /docs/scenarios 5 | next: /docs/advanced/dashboard 6 | weight: 5 7 | --- 8 | {{< cards >}} 9 | {{< card link="dashboard" title="Dashboard" icon="view-boards" >}} 10 | {{< card link="docker-secrets" title="Docker secrets" icon="key" >}} 11 | 12 | {{< card link="host-mode" title="Service with Host Network Mode" icon="view-boards" >}} 13 | {{< card link="icons" title="Dashboard icons" icon="view-boards" >}} 14 | {{< card link="tailscale" title="Tailscale" icon="key" >}} 15 | {{< /cards >}} 16 | -------------------------------------------------------------------------------- /docs/content/docs/v2/advanced/docker-secrets.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Docker secrets 3 | --- 4 | 5 | If you want to use Docker secrets to store your Tailscale authkey, you can use 6 | the following example: 7 | 8 | {{% steps %}} 9 | 10 | ### Requirements 11 | 12 | Make sure you have Docker Swarm enabled on your server. 13 | 14 | 15 | 16 | "Docker secrets are only available to swarm services, not to standalone 17 | containers. To use this feature, consider adapting your container to run as a service." 18 | 19 | ### Add a docker secret 20 | 21 | We need to create a docker secret, which we can name `authkey` and store the Tailscale 22 | authkey in it. We can do that using the following command: 23 | 24 | ```bash 25 | printf "Your Tailscale AuthKey" | docker secret create authkey - 26 | ``` 27 | 28 | ### TsDProxy Docker compose 29 | 30 | ```yaml docker-compose.yml 31 | services: 32 | tsdproxy: 33 | image: almeidapaulopt/tsdproxy:latest 34 | volumes: 35 | - /var/run/docker.sock:/var/run/docker.sock 36 | - datadir:/data 37 | - :/config 38 | secrets: 39 | - authkey 40 | 41 | volumes: 42 | datadir: 43 | 44 | secrets: 45 | authkey: 46 | external: true 47 | ``` 48 | 49 | ### TsDProxy configuration 50 | 51 | ```yaml /config/tsdproxy.yaml 52 | tailscale: 53 | providers: 54 | default: # name of the provider 55 | authkeyfile: "/run/secrets/authkey" 56 | ``` 57 | 58 | ### Restart tsdproxy 59 | 60 | ``` bash 61 | docker compose restart 62 | ``` 63 | 64 | {{% /steps %}} 65 | -------------------------------------------------------------------------------- /docs/content/docs/v2/advanced/icons.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dashboard icons 3 | --- 4 | 5 | TSDProxy supports three comprehensive icon libraries: 6 | 7 | 1. **Material Design Icons** [pictogrammers.com/library/mdi](https://pictogrammers.com/library/mdi/), 8 | offering a vast collection of intuitive and versatile icons. Use "mdi/" as the prefix. 9 | 2. **Simple Icons** [simpleicons.org](https://simpleicons.org), which includes 10 | icons for popular brands and services. Use "si/" as prefix. 11 | 3. **Selfh.st Icons** [selfh.st/icons](https://selfh.st/icons/), 12 | collection of icons and logos for self-hosted dashboards. Use "sh/" as prefix. 13 | 14 | >[!NOTE] 15 | > Only SVG icons are available. 16 | 17 | ## How it works 18 | 19 | 1. Select the icon in icon libraries websites. 20 | 2. Add the definition to your proxy "tsdproxy.dash.icon" in [docker provider](/docs/docker/#tsdproxydashicon) 21 | or "icon" in dashboard section for [Proxy List](/docs/list/#proxy-list-file-options) 22 | 3. Set the icon definition to "library/icon" 23 | (don't add extension, TSDProxy will add .svg) 24 | 25 | ## Examples: 26 | 27 | "si/tailscale" [simpleicons.org/?q=tailscale](https://simpleicons.org/?q=tailscale). 28 | 29 | "mdi/music-box" [pictogrammers.com/library/mdi/icon/music-box](https://pictogrammers.com/library/mdi/icon/music-box/). 30 | 31 | "sh/adguard-home" [selfh.st/icon/](https://selfh.st/icons/). With the mouse 32 | hover on the "svg" icon, you can see the name of the icon. 33 | -------------------------------------------------------------------------------- /docs/content/docs/v2/advanced/tailscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tailscale 3 | next: /docs/scenarios 4 | --- 5 | 6 | 7 | This document guides you through the different authentication and configuration 8 | options for Tailscale with TSDProxy. 9 | 10 | ## Authentication Methods 11 | 12 | TSDProxy supports three authentication methods with Tailscale: OAuth, 13 | OAuth (manual), and AuthKey. 14 | 15 | ### OAuth 16 | 17 | {{% steps %}} 18 | 19 | #### Prerequisites 20 | 21 | 1. Generate an OAuth client at [https://login.tailscale.com/admin/settings/oauth](https://login.tailscale.com/admin/settings/oauth). 22 | 2. Define tags for services. Tags can be defined in the provider, applying to 23 | all services. 24 | 25 | > [!Important] 26 | > All auth keys created from an OAuth client require **tags**. This is a Tailscale requirement. 27 | 28 | #### Configuration 29 | 30 | Add the OAuth client credentials to the TSDProxy configuration: 31 | 32 | ```yaml {filename="/config/tsdproxy.yaml"} 33 | tailscale: 34 | providers: 35 | default: 36 | clientId: "your_client_id" 37 | clientSecret: "your_client_secret" 38 | tags: "tag:example" # Optional if tags are defined in each proxy 39 | ``` 40 | 41 | #### Restart 42 | 43 | Restart TSDProxy to apply the changes. 44 | 45 | > [!Tip] 46 | > If the proxy fails to authenticate after restarting, check the error logs. 47 | > Ensure the tags are correct and the OAuth client is enabled. 48 | 49 | {{% /steps %}} 50 | 51 | ### OAuth (Manual) 52 | 53 | {{% steps %}} 54 | 55 | #### Disable AuthKey 56 | 57 | OAuth authentication mode is enabled when no AuthKey is set in the Tailscale 58 | provider configuration: 59 | 60 | ```yaml {filename="/config/tsdproxy.yaml"} 61 | tailscale: 62 | providers: 63 | default: 64 | authKey: "" 65 | authKeyFile: "" 66 | ``` 67 | 68 | The proxy will wait for authentication with Tailscale during startup. 69 | 70 | #### Dashboard 71 | 72 | Access the TSDProxy dashboard (e.g., `http://192.168.1.1:8080`). 73 | 74 | #### Authentication 75 | 76 | Click on the proxy with "Authentication" status. 77 | 78 | > [!Tip] 79 | > If "Ephemeral" is set to `true`, authentication is required at each TSDProxy restart. 80 | 81 | {{% /steps %}} 82 | 83 | ### AuthKey 84 | 85 | {{% steps %}} 86 | 87 | #### Generate AuthKey 88 | 89 | 1. Go to [https://login.tailscale.com/admin/settings/keys](https://login.tailscale.com/admin/settings/keys). 90 | 2. Click "Generate auth key". 91 | 3. Add a description. 92 | 4. Enable "Reusable". 93 | 5. Add tags if needed. 94 | 6. Click "Generate key". 95 | 96 | > [!Warning] 97 | > If tags are added to the key, all proxies initialized with the same AuthKey will receive the same tags. To use different tags, add a new Tailscale provider to the configuration. 98 | 99 | #### Configuration 100 | 101 | Add the AuthKey to the TSDProxy configuration: 102 | 103 | ```yaml {filename="/config/tsdproxy.yaml"} 104 | tailscale: 105 | providers: 106 | default: 107 | authKey: "YOUR_GENERATED_KEY_HERE" 108 | authKeyFile: "" 109 | ``` 110 | 111 | #### Restart 112 | 113 | Restart TSDProxy to apply the changes. 114 | 115 | {{% /steps %}} 116 | 117 | ## Funnel 118 | 119 | In addition to configuring TSDProxy to enable Funnel, you need to grant 120 | permissions in the Tailscale ACL. See [Troubleshooting](.././troubleshooting/#funnel-doesnt-work) 121 | for more details. Also read Tailscale's [Funnel documentation](https://tailscale.com/kb/1223/funnel#requirements-and-limitations) 122 | for requirements and limitations. 123 | 124 | ## Tags 125 | 126 | - Tags are required for OAuth authentication. 127 | - Tags only work with OAuth authentication. 128 | - Tags can be configured in the provider or service. 129 | - If tags are defined in the provider, they apply to all services. 130 | - If tags are defined in the service, provider tags are ignored. 131 | -------------------------------------------------------------------------------- /docs/content/docs/v2/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | weight: 1 4 | prev: /docs 5 | --- 6 | 7 | ## Quick Start 8 | 9 | Using Docker Compose, you can easily configure the proxy to your Tailscale 10 | containers. Here’s an example of how you can configure your services using 11 | Docker Compose: 12 | 13 | {{% steps %}} 14 | 15 | ### Create a TSDProxy docker-compose.yaml 16 | 17 | ```yaml docker-compose.yml 18 | services: 19 | tsdproxy: 20 | image: almeidapaulopt/tsdproxy:2 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock 23 | - datadir:/data 24 | - :/config 25 | restart: unless-stopped 26 | ports: 27 | - "8080:8080" 28 | extra_hosts: 29 | - "host.docker.internal:host-gateway" 30 | volumes: 31 | datadir: 32 | ``` 33 | 34 | ### Start the TSDProxy container 35 | 36 | ```bash 37 | docker compose up -d 38 | ``` 39 | 40 | ### Configure TSDProxy 41 | 42 | After the TSDProxy container is started, a configuration file 43 | `/config/tsdproxy.yaml` is created and populated with the following: 44 | 45 | ```yaml {filename="/config/tsdproxy.yaml"} 46 | defaultProxyProvider: default 47 | docker: 48 | local: # name of the docker target provider 49 | host: unix:///var/run/docker.sock # host of the docker socket or daemon 50 | targetHostname: host.docker.internal # hostname or IP of docker server (ex: host.docker.internal or 172.31.0.1) 51 | defaultProxyProvider: default # name of which proxy provider to use 52 | lists: {} 53 | tailscale: 54 | providers: 55 | default: # name of the provider 56 | authKey: "" # optional, define authkey here 57 | authKeyFile: "" # optional, use this to load authkey from file. If this is defined, Authkey is ignored 58 | controlUrl: https://controlplane.tailscale.com # use this to override the default control URL 59 | dataDir: /data/ 60 | http: 61 | hostname: 0.0.0.0 62 | port: 8080 63 | log: 64 | level: info # set logging level info, error or trace 65 | json: false # set to true to enable json logging 66 | proxyAccessLog: true # set to true to enable container access log 67 | ``` 68 | 69 | #### Edit the configuration file 70 | 71 | 1. Change your docker host if you are not using the socket. 72 | 2. Restart the service if you changed the configuration. 73 | 74 | ```bash 75 | docker compose restart 76 | ``` 77 | 78 | ### Run a sample service 79 | 80 | Here we’ll use the nginx image to serve a sample service. 81 | The container name is `sample-nginx`, expose port 8111, and add the 82 | `tsdproxy.enable` label. 83 | 84 | ```bash 85 | docker run -d --name sample-nginx -p 8111:80 --label "tsdproxy.enable=true" nginx:latest 86 | ``` 87 | 88 | ### Open Dashboard 89 | 90 | 1. Visit the dashboard at http://:8080. 91 | 2. Sample-nginx should appear in the dashboard. Click the button and 92 | authenticate with Tailscale. 93 | 3. After authentication, the proxy will be enabled. 94 | 95 | > [!IMPORTANT] 96 | > The first time you run the proxy, it will take a few seconds to start, because 97 | > it needs to connect to the Tailscale network, generate the certificates, and start 98 | > the proxy. 99 | 100 | {{% /steps %}} 101 | -------------------------------------------------------------------------------- /docs/content/docs/v2/providers/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Providers 3 | prev: /docs/serverconfig 4 | weight: 3 5 | --- 6 | 7 | {{< cards >}} 8 | {{< card link="docker" title="Docker" icon="view-boards" >}} 9 | {{< card link="lists" title="Lists" icon="server" >}} 10 | {{< /cards >}} 11 | -------------------------------------------------------------------------------- /docs/content/docs/v2/providers/lists.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Lists 3 | next: /docs/advanced 4 | weight: 4 5 | --- 6 | 7 | TSDProxy can be configured to proxy using a YAML configuration file. 8 | Multiple lists can be used, and they are referred to as target providers. 9 | Each target provider could be used to group the way you decide better to help 10 | you manage your proxies. Or can use a single file to proxy all your targets. 11 | 12 | > [!CAUTION] 13 | > Configuration files are case sensitive 14 | 15 | {{% steps %}} 16 | 17 | ### How to enable? 18 | 19 | In your /config/tsdproxy.yaml, specify the lists you want to use, just 20 | like this example where the `critical` and `media` providers are defined. 21 | 22 | ```yaml {filename="/config/tsdproxy.yaml"} 23 | lists: 24 | critical: 25 | filename: /config/critical.yaml 26 | defaultProxyProvider: tailscale1 27 | defaultProxyAccessLog: true 28 | media: 29 | filename: /config/media.yaml 30 | defaultProxyProvider: default 31 | defaultProxyAccessLog: false 32 | ``` 33 | 34 | ```yaml {filename="/config/critical.yaml"} 35 | nas1: 36 | ports: 37 | 443/https: 38 | targets: 39 | - http://nas1.local:5001 40 | 80/http: 41 | targets: 42 | - nas1.funny-name.ts.net 43 | isRedirect: true 44 | nas2: 45 | ports: 46 | 443/https: 47 | targets: 48 | - https://nas2.local:5001 49 | ``` 50 | 51 | ```yaml {filename="/config/media.yaml"} 52 | music: 53 | ports: 54 | 443/https: 55 | targets: 56 | - http://192.168.1.10:3789 57 | video: 58 | ports: 59 | 443/https: 60 | targets: 61 | - http://192.168.1.10:8080 62 | photos: 63 | ports: 64 | 443/https: 65 | targets: 66 | - http://192.168.1.10:8181 67 | ``` 68 | 69 | This configuration will create two groups of proxies: 70 | 71 | - nas1.funny-name.ts.net and nas2.funny-name.ts.net 72 | - Self-signed tls certificates 73 | - Both use 'tailscale1' Tailscale provider 74 | - All access logs are enabled 75 | - music.ts.net, video.ts.net and photos.ts.net. 76 | - On the same host with different ports 77 | - Use 'default' Tailscale provider 78 | - Don't enable access logs 79 | 80 | ### Provider Configuration options 81 | 82 | ```yaml {filename="/config/tsdproxy.yaml"} 83 | lists: 84 | critical: # Name the target provider 85 | filename: /config/critical.yaml # file with the proxy list 86 | defaultProxyProvider: tailscale1 # (optional) default proxy provider 87 | defaultProxyAccessLog: true # (optional) Enable access logs 88 | ``` 89 | 90 | ### Proxy list file options 91 | 92 | ```yaml {filename="/config/filename.yaml"} 93 | proxyname: # Name of the proxy 94 | proxyProvider: default # (optional) name of the proxy provider 95 | 96 | tailscale: # (optional) Tailscale configuration for this proxy 97 | authKey: asdasdas # (optional) Tailscale authkey 98 | ephemeral: false # (optional) (defaults to false) Enable ephemeral mode 99 | runWebClient: false # (optional) (defaults to false) Run web client 100 | verbose: false # (optional) (defaults to false) Run in verbose mode 101 | tags: "tag:example,tag:server" # (optional) tags to apply 102 | # (will override the default provider tags) 103 | 104 | ports: 105 | port/protocol: #example 443/https, 80/http 106 | targets: # list of targets (in this version only the first will be used) 107 | - http://sub.domain.com:8111 # change to your target 108 | tailscale: # (optional) 109 | funnel: true # (optional) (defaults to false), enable funnel mode 110 | isRedirect: true # (optional) (defaults to false), redirect to the target 111 | tlsValidate: false # (optional) /defaults to true), disable targets TLS validation 112 | 113 | dashboard: 114 | visible: false # (optional) (defaults to true) doesn't show proxy in dashboard 115 | label: "" # (optional), label to be shown in dashboard 116 | icon: "" # (optional), icon to be shown in dashboard 117 | ``` 118 | 119 | > [!TIP] 120 | > TSDProxy will reload the proxy list when it is updated. 121 | > You only need to restart TSDProxy if your changes are in /config/tsdproxy.yaml 122 | 123 | > [!NOTE] 124 | > See available icons in [icons](../../advanced/icons). 125 | 126 | {{% /steps %}} 127 | -------------------------------------------------------------------------------- /docs/content/docs/v2/serverconfig.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Server configuration 3 | weight: 2 4 | next: /providers 5 | --- 6 | 7 | TSDProxy utilizes the configuration file `/config/tsdproxy.yaml` for its settings. 8 | 9 | > [!IMPORTANT] 10 | > Environment variable configurations used in versions prior to v0.6.0 are 11 | > deprecated and will be removed in future releases. 12 | 13 | {{% steps %}} 14 | 15 | ### Sample Configuration File 16 | 17 | > [!Warning] 18 | > Configuration files are case-sensitive. 19 | 20 | ```yaml {filename="/config/tsdproxy.yaml"} 21 | defaultProxyProvider: default 22 | docker: 23 | local: # Name of the Docker target provider 24 | host: unix:///var/run/docker.sock # Docker socket or daemon address 25 | targetHostname: host.docker.internal # hostname or IP of docker server (ex: host.docker.internal or 172.31.0.1) 26 | defaultProxyProvider: default # Default proxy provider for this Docker server 27 | lists: 28 | critical: # Name of the target list provider 29 | filename: /config/critical.yaml # Path to the proxy list file 30 | defaultProxyProvider: tailscale1 # (Optional) Default proxy provider for this list 31 | defaultProxyAccessLog: true # (Optional) Enable access logs for this list 32 | tailscale: 33 | providers: 34 | default: # Name of the Tailscale provider 35 | clientId: "your_client_id" # OAuth client ID (generated by Tailscale) 36 | clientSecret: "your_client_secret" # OAuth client secret (generated by Tailscale) 37 | # If clientId and clientSecret are defined, authKey 38 | # and authKeyFile are ignored 39 | authKey: "" # Tailscale auth key (alternative to OAuth) 40 | authKeyFile: "" # Path to a file containing the auth key (ignores authKey if defined) 41 | tags: "tag:example,tag:server" # Default tags for all containers using this provider 42 | # Container-specific tags override these default tags 43 | controlUrl: https://controlplane.tailscale.com # Override the default Tailscale control URL 44 | dataDir: /data/ # Tailscale data directory 45 | http: 46 | hostname: 0.0.0.0 # HTTP server hostname 47 | port: 8080 # HTTP server port 48 | log: 49 | level: info # Logging level (info, error, debug or trace) 50 | json: false # Enable JSON logging (true/false) 51 | proxyAccessLog: true # Enable container access logs (true/false) 52 | ``` 53 | 54 | ### Configuration Sections 55 | 56 | #### log Section 57 | 58 | ##### level 59 | 60 | Defines the logging level. Options are `info`, `error`, `debug` or `trace`. 61 | The default is `info`. 62 | 63 | ##### json 64 | 65 | Enables JSON-formatted logging when set to `true`. Defaults to `false`. 66 | 67 | #### tailscale Section 68 | 69 | Configures Tailscale integration. 70 | 71 | ##### dataDir 72 | 73 | Specifies the data directory used by Tailscale. Defaults to `/data/`. 74 | 75 | ##### providers 76 | 77 | Defines multiple Tailscale providers. Each provider has the following options: 78 | 79 | ```yaml {filename="/config/tsdproxy.yaml"} 80 | default: # Provider name 81 | authKey: your-authkey # Tailscale auth key 82 | authKeyFile: "" # Path to auth key file 83 | controlUrl: https://controlplane.tailscale.com # Tailscale control URL 84 | ``` 85 | 86 | Example with multiple providers: 87 | 88 | ```yaml {filename="/config/tsdproxy.yaml"} 89 | tailscale: 90 | providers: 91 | default: 92 | authKey: your-authkey 93 | authKeyFile: "" 94 | controlUrl: https://controlplane.tailscale.com 95 | 96 | server1: 97 | authKey: authkey-server1 98 | authKeyFile: "" 99 | controlUrl: http://server1 100 | 101 | differentkey: 102 | authKey: authkey-with-different-tags 103 | authKeyFile: "" 104 | controlUrl: https://controlplane.tailscale.com 105 | ``` 106 | 107 | This example configures three Tailscale providers: `default` (default server), 108 | `server1` (different Tailscale server), and `differentkey` (default server with 109 | a different auth key for specific tags). 110 | 111 | > [!Tip] 112 | > For more details, see the [Tailscale page](../advanced/tailscale/). 113 | 114 | #### docker Section 115 | 116 | Configures Docker server connections. Multiple Docker servers can be defined: 117 | 118 | ```yaml {filename="/config/tsdproxy.yaml"} 119 | local: # Docker provider name 120 | host: unix:///var/run/docker.sock # Docker socket or daemon address 121 | targetHostname: 172.31.0.1 # Docker server hostname or IP 122 | defaultProxyProvider: default # Default proxy provider for this Docker server 123 | ``` 124 | 125 | Example with multiple Docker servers: 126 | 127 | ```yaml {filename="/config/tsdproxy.yaml"} 128 | docker: 129 | local: 130 | host: unix:///var/run/docker.sock 131 | defaultProxyProvider: default 132 | srv1: 133 | host: tcp://174.17.0.1:2376 134 | targetHostname: 174.17.0.1 135 | defaultProxyProvider: server1 136 | ``` 137 | 138 | This example configures a `local` Docker server and a remote `srv1` server. 139 | 140 | ##### host 141 | 142 | Specifies the Docker socket or daemon address. Defaults to `unix:///var/run/docker.sock`. 143 | 144 | ##### targetHostname 145 | 146 | Specifies the IP address or DNS name of the Docker server. Used for connecting to 147 | containers in specific cases. 148 | 149 | ##### defaultProxyProvider 150 | 151 | Specifies the default Tailscale provider (defined in the `tailscale.providers` 152 | section) to use for containers on this Docker server. Container-specific labels 153 | override this setting. 154 | 155 | {{% /steps %}} 156 | -------------------------------------------------------------------------------- /docs/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/imfing/hextra-starter-template 2 | 3 | go 1.23.4 4 | 5 | require github.com/imfing/hextra v0.9.5 // indirect 6 | -------------------------------------------------------------------------------- /docs/go.sum: -------------------------------------------------------------------------------- 1 | github.com/imfing/hextra v0.9.0 h1:1UyLZgS1eayce2ETCOjAQssXpkRz3HDrIs/fljH0lkU= 2 | github.com/imfing/hextra v0.9.0/go.mod h1:cEfel3lU/bSx7lTE/+uuR4GJaphyOyiwNR3PTqFTXpI= 3 | github.com/imfing/hextra v0.9.3 h1:p4vDm2TSgt3RpJdJm2mqkpoJCH2S08wzySyyYodtgCc= 4 | github.com/imfing/hextra v0.9.3/go.mod h1:cEfel3lU/bSx7lTE/+uuR4GJaphyOyiwNR3PTqFTXpI= 5 | github.com/imfing/hextra v0.9.4 h1:k1KEC2mtYbMVBMOYUYK5Yy8pNPpIH3u6pBTRyBSN6No= 6 | github.com/imfing/hextra v0.9.4/go.mod h1:cEfel3lU/bSx7lTE/+uuR4GJaphyOyiwNR3PTqFTXpI= 7 | github.com/imfing/hextra v0.9.5 h1:lG6wnklT9PNLepq69Gj3GZyHRUkBWwv6bkPVL9yAcIQ= 8 | github.com/imfing/hextra v0.9.5/go.mod h1:cEfel3lU/bSx7lTE/+uuR4GJaphyOyiwNR3PTqFTXpI= 9 | -------------------------------------------------------------------------------- /docs/hugo.yaml: -------------------------------------------------------------------------------- 1 | # Hugo configuration file 2 | title: TSDProxy 3 | 4 | enableRobotsTXT: true 5 | enableGitInfo: true 6 | enableEmoji: true 7 | 8 | baseURL: https://almeidapaulopt.github.io/tsdproxy/ 9 | 10 | defaultContentLanguage: en 11 | languages: 12 | en: 13 | languageName: English 14 | weight: 1 15 | 16 | # import hextra as module 17 | module: 18 | imports: 19 | - path: github.com/imfing/hextra 20 | 21 | markup: 22 | # allow raw html 23 | goldmark: 24 | renderer: 25 | unsafe: true 26 | 27 | # enable hextra syntax highlight 28 | highlight: 29 | noClasses: false 30 | 31 | enableInlineShortcodes: true 32 | 33 | menu: 34 | main: 35 | - name: Documentation 36 | pageRef: /docs 37 | weight: 1 38 | - name: About 39 | pageRef: /about 40 | weight: 2 41 | - name: Try v2 42 | pageRef: /docs/v2 43 | weight: 3 44 | - name: Search 45 | weight: 4 46 | params: 47 | type: search 48 | - name: GitHub 49 | weight: 5 50 | url: "https://github.com/almeidapaulopt/tsdproxy" 51 | params: 52 | icon: github 53 | - name: Twitter 54 | weight: 6 55 | url: "https://twitter.com/almeidapaulopt" 56 | params: 57 | icon: x-twitter 58 | 59 | sidebar: 60 | - params: 61 | type: separator 62 | weight: 1 63 | - name: "About" 64 | pageRef: "/about" 65 | weight: 2 66 | 67 | params: 68 | description: TSDProxy is a proxy for tailscale 69 | navbar: 70 | displayTitle: true 71 | displayLogo: false 72 | width: wide 73 | 74 | page: 75 | # full (100%), wide (90rem), normal (1280px) 76 | width: full 77 | 78 | footer: 79 | enable: true 80 | displayCopyright: true 81 | displayPoweredBy: false 82 | width: normal 83 | 84 | displayUpdatedDate: true 85 | dateFormat: "January 2, 2006" 86 | 87 | editURL: 88 | enable: true 89 | base: "https://github.com/almeidapaulopt/tsdproxy/edit/main/docs/content" 90 | 91 | # Search 92 | search: 93 | enable: true 94 | type: flexsearch 95 | 96 | flexsearch: 97 | # index page by: content | summary | heading | title 98 | index: content 99 | 100 | tokenize: full 101 | 102 | highlight: 103 | copy: 104 | enable: true 105 | # hover | always 106 | display: hover 107 | 108 | article: 109 | displayPagination: true 110 | 111 | taxonomies: 112 | version: version 113 | 114 | services: 115 | googleAnalytics: 116 | ID: G-PLF0YCK3J2 117 | -------------------------------------------------------------------------------- /docs/i18n/en.yaml: -------------------------------------------------------------------------------- 1 | backToTop: "Scroll to top" 2 | changeLanguage: "Change language" 3 | changeTheme: "Change theme" 4 | copyCode: "Copy code" 5 | copyright: "© 2024 almeidapaulopt" 6 | dark: "Dark" 7 | editThisPage: "Edit this page on GitHub →" 8 | lastUpdated: "Last updated on" 9 | light: "Light" 10 | noResultsFound: "No results found." 11 | onThisPage: "On this page" 12 | readMore: "Read more →" 13 | searchPlaceholder: "Search..." 14 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package config 5 | 6 | import ( 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "io/fs" 11 | "os" 12 | 13 | "github.com/creasty/defaults" 14 | "github.com/rs/zerolog/log" 15 | ) 16 | 17 | type ( 18 | // config stores complete configuration. 19 | // 20 | config struct { 21 | DefaultProxyProvider string `validate:"required" default:"default" yaml:"defaultProxyProvider"` 22 | 23 | Docker map[string]*DockerTargetProviderConfig `validate:"dive,required" yaml:"docker"` 24 | Lists map[string]*ListTargetProviderConfig `validate:"dive,required" yaml:"lists"` 25 | Tailscale TailscaleProxyProviderConfig `yaml:"tailscale"` 26 | 27 | HTTP HTTPConfig `yaml:"http"` 28 | Log LogConfig `yaml:"log"` 29 | 30 | ProxyAccessLog bool `validate:"boolean" default:"true" yaml:"proxyAccessLog"` 31 | } 32 | 33 | // LogConfig stores logging configuration. 34 | LogConfig struct { 35 | Level string `validate:"required,oneof=debug info warn error fatal panic trace" default:"info" yaml:"level"` 36 | JSON bool `validate:"boolean" default:"false" yaml:"json"` 37 | } 38 | 39 | // HTTPConfig stores HTTP configuration. 40 | HTTPConfig struct { 41 | Hostname string `validate:"ip|hostname,required" default:"0.0.0.0" yaml:"hostname"` 42 | Port uint16 `validate:"numeric,min=1,max=65535,required" default:"8080" yaml:"port"` 43 | } 44 | 45 | // DockerTargetProviderConfig struct stores Docker target provider configuration. 46 | DockerTargetProviderConfig struct { 47 | Host string `validate:"required,uri" default:"unix:///var/run/docker.sock" yaml:"host"` 48 | TargetHostname string `validate:"ip|hostname" default:"172.31.0.1" yaml:"targetHostname"` 49 | DefaultProxyProvider string `validate:"omitempty" yaml:"defaultProxyProvider,omitempty"` 50 | TryDockerInternalNetwork bool `validate:"boolean" default:"false" yaml:"tryDockerInternalNetwork"` 51 | } 52 | 53 | // TailscaleProxyProviderConfig struct stores Tailscale ProxyProvider configuration 54 | TailscaleProxyProviderConfig struct { 55 | Providers map[string]*TailscaleServerConfig `validate:"dive,required" yaml:"providers"` 56 | DataDir string `validate:"dir" default:"/data/" yaml:"dataDir"` 57 | } 58 | 59 | // TailscaleServerConfig struct stores Tailscale Server configuration 60 | TailscaleServerConfig struct { 61 | AuthKey string `default:"" validate:"omitempty" yaml:"authKey,omitempty"` 62 | AuthKeyFile string `default:"" validate:"omitempty" yaml:"authKeyFile,omitempty"` 63 | ClientID string `default:"" validate:"omitempty" yaml:"clientId,omitempty"` 64 | ClientSecret string `default:"" validate:"omitempty" yaml:"clientSecret,omitempty"` 65 | Tags string `default:"" validate:"omitempty" yaml:"tags,omitempty"` 66 | ControlURL string `default:"https://controlplane.tailscale.com" validate:"uri" yaml:"controlUrl"` 67 | } 68 | 69 | // ListTargetProviderConfig struct stores a proxy list target provider configuration. 70 | ListTargetProviderConfig struct { 71 | Filename string `validate:"required,file" yaml:"filename"` 72 | DefaultProxyProvider string `validate:"omitempty" yaml:"defaultProxyProvider,omitempty"` 73 | DefaultProxyAccessLog bool `default:"true" validate:"boolean" yaml:"defaultProxyAccessLog"` 74 | } 75 | ) 76 | 77 | // Config is a global variable to store configuration. 78 | var Config *config 79 | 80 | // GetConfig loads, validates and returns configuration. 81 | func InitializeConfig() error { 82 | Config = &config{} 83 | Config.Tailscale.Providers = make(map[string]*TailscaleServerConfig) 84 | Config.Docker = make(map[string]*DockerTargetProviderConfig) 85 | Config.Lists = make(map[string]*ListTargetProviderConfig) 86 | 87 | file := flag.String("config", "/config/tsdproxy.yaml", "loag configuration from file") 88 | flag.Parse() 89 | 90 | fileConfig := NewConfigFile(log.Logger, *file, Config) 91 | 92 | println("loading configuration from:", *file) 93 | 94 | if err := fileConfig.Load(); err != nil { 95 | if !errors.Is(err, fs.ErrNotExist) { 96 | return err 97 | } 98 | println("Generating default configuration to:", *file) 99 | 100 | if err := defaults.Set(Config); err != nil { 101 | fmt.Printf("Error loading defaults: %v", err) 102 | } 103 | 104 | Config.generateDefaultProviders() 105 | if err := fileConfig.Save(); err != nil { 106 | return err 107 | } 108 | } 109 | 110 | // Load default values. 111 | // Make sure to set default values after loading from file 112 | // unless defaults of map type are not loaded. 113 | if err := defaults.Set(Config); err != nil { 114 | fmt.Printf("Error loading defaults: %v", err) 115 | } 116 | 117 | // load auth keys from files 118 | for _, d := range Config.Tailscale.Providers { 119 | if d != nil && d.ClientSecret != "" && d.ClientID != "" { 120 | continue 121 | } 122 | 123 | if d != nil && d.AuthKeyFile != "" { 124 | authkey, err := Config.getAuthKeyFromFile(d.AuthKeyFile) 125 | if err != nil { 126 | return err 127 | } 128 | d.AuthKey = authkey 129 | } 130 | } 131 | 132 | // validate config 133 | if err := Config.validate(); err != nil { 134 | return err 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func (c *config) getAuthKeyFromFile(authKeyFile string) (string, error) { 141 | authkey, err := os.ReadFile(authKeyFile) 142 | if err != nil { 143 | println("Error reading auth key file:", err) 144 | return "", err 145 | } 146 | return string(authkey), nil 147 | } 148 | -------------------------------------------------------------------------------- /internal/config/configfile.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package config 5 | 6 | import ( 7 | "bytes" 8 | "errors" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "sync" 13 | 14 | "github.com/almeidapaulopt/tsdproxy/internal/consts" 15 | 16 | "github.com/fsnotify/fsnotify" 17 | "github.com/rs/zerolog" 18 | "gopkg.in/yaml.v3" 19 | ) 20 | 21 | type ConfigFile struct { 22 | data any 23 | log zerolog.Logger 24 | 25 | onChange func(fsnotify.Event) 26 | 27 | filename string 28 | 29 | mtx sync.Mutex 30 | } 31 | 32 | func NewConfigFile(log zerolog.Logger, filename string, data any) *ConfigFile { 33 | return &ConfigFile{ 34 | filename: filename, 35 | data: data, 36 | log: log.With().Str("module", "file").Str("files", filename).Logger(), 37 | } 38 | } 39 | 40 | func (f *ConfigFile) Load() error { 41 | data, err := os.ReadFile(f.filename) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | err = unmarshalStrict(data, f.data) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (f *ConfigFile) Save() error { 55 | // create config directory 56 | dir, _ := filepath.Split(f.filename) 57 | if _, err := os.Stat(dir); os.IsNotExist(err) { 58 | if err1 := os.MkdirAll(dir, consts.PermOwnerAll); err1 != nil { 59 | return err1 60 | } 61 | } 62 | 63 | yaml, err := yaml.Marshal(f.data) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | err = os.WriteFile(f.filename, yaml, consts.PermAllRead+consts.PermOwnerWrite) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | return nil 74 | } 75 | 76 | // OnConfigChange sets the event handler that is called when a config file changes. 77 | func (f *ConfigFile) OnChange(run func(in fsnotify.Event)) { 78 | f.mtx.Lock() 79 | defer f.mtx.Unlock() 80 | 81 | f.onChange = run 82 | } 83 | 84 | // WatchConfig starts watching a config file for changes. 85 | func (f *ConfigFile) Watch() { 86 | f.log.Debug().Str("file", f.filename).Msg("Start watching file") 87 | 88 | initWG := sync.WaitGroup{} 89 | initWG.Add(1) 90 | 91 | go func() { 92 | watcher, err := fsnotify.NewWatcher() 93 | if err != nil { 94 | f.log.Fatal().Err(err).Msg("failed to create a new watcher") 95 | } 96 | defer watcher.Close() 97 | 98 | file := filepath.Clean(f.filename) 99 | dir, _ := filepath.Split(file) 100 | 101 | eventsWG := sync.WaitGroup{} 102 | eventsWG.Add(1) 103 | 104 | // Start listening for events. 105 | go func() { 106 | defer eventsWG.Done() 107 | f.watchEvents(watcher, file, &eventsWG) 108 | }() 109 | 110 | err = watcher.Add(dir) 111 | if err != nil { 112 | f.log.Fatal().Err(err).Str("filename", f.filename).Msg("failed to watch config file") 113 | } 114 | 115 | initWG.Done() 116 | eventsWG.Wait() 117 | }() 118 | initWG.Wait() 119 | } 120 | 121 | func (f *ConfigFile) watchEvents(watcher *fsnotify.Watcher, file string, eventsWG *sync.WaitGroup) { 122 | realFile, _ := filepath.EvalSymlinks(f.filename) 123 | for { 124 | select { 125 | case event, ok := <-watcher.Events: 126 | if !ok { 127 | return 128 | } 129 | f.handleEvent(event, file, &realFile, eventsWG) 130 | case err, ok := <-watcher.Errors: 131 | if ok { 132 | f.log.Error().Err(err).Msg("watching config file error") 133 | } 134 | return 135 | } 136 | } 137 | } 138 | 139 | func (f *ConfigFile) handleEvent(event fsnotify.Event, file string, realFile *string, eventsWG *sync.WaitGroup) { 140 | currentFile, _ := filepath.EvalSymlinks(f.filename) 141 | if (filepath.Clean(event.Name) == file && 142 | (event.Has(fsnotify.Write) || event.Has(fsnotify.Create))) || 143 | (currentFile != "" && currentFile != *realFile) { 144 | *realFile = currentFile 145 | 146 | if f.onChange != nil { 147 | f.onChange(event) 148 | } 149 | } else if filepath.Clean(event.Name) == file && event.Has(fsnotify.Remove) { 150 | eventsWG.Done() 151 | } 152 | } 153 | 154 | func unmarshalStrict(data []byte, out any) error { 155 | dec := yaml.NewDecoder(bytes.NewReader(data)) 156 | dec.KnownFields(true) 157 | 158 | if err := dec.Decode(out); err != nil && !errors.Is(err, io.EOF) { 159 | return err 160 | } 161 | 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /internal/config/generateproviders.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package config 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | "os" 10 | 11 | "github.com/creasty/defaults" 12 | ) 13 | 14 | const ( 15 | DockerDefaultName = "local" 16 | TailscaleDefaultProviderName = "default" 17 | ) 18 | 19 | // generateDefaultProviders method Generate the config from environment variables 20 | // used in 0.x.x versions 21 | func (c *config) generateDefaultProviders() { 22 | // Legacy Hostname from DOCKER_HOST from environment 23 | // 24 | c.generateDockerConfig() 25 | 26 | c.generateTailscaleConfig() 27 | } 28 | 29 | // generateDockerConfig method generate the Docker Config provider from environment variables 30 | func (c *config) generateDockerConfig() { 31 | // Legacy Hostname from DOCKER_HOST from environment 32 | // 33 | docker := new(DockerTargetProviderConfig) 34 | // set DockerConfig defaults 35 | if err := defaults.Set(docker); err != nil { 36 | fmt.Printf("Error loading defaults: %v", err) 37 | } 38 | if os.Getenv("DOCKER_HOST") != "" { 39 | docker.Host = os.Getenv("DOCKER_HOST") 40 | } 41 | 42 | if os.Getenv("TSDPROXY_HOSTNAME") != "" { 43 | docker.TargetHostname = os.Getenv("TSDPROXY_HOSTNAME") 44 | } 45 | 46 | // Check whether the hostname host.docker.internal can be resolved. This allows avoiding updates to the TargetHostname field in the configuration file. 47 | ip, err := net.LookupIP("host.docker.internal") 48 | if err == nil || len(ip) > 0 { 49 | docker.TargetHostname = "host.docker.internal" 50 | } 51 | 52 | c.Docker[DockerDefaultName] = docker 53 | } 54 | 55 | // generateTailscaleConfig method generate the Tailscale Config provider from environment variables 56 | func (c *config) generateTailscaleConfig() { 57 | ts := new(TailscaleServerConfig) 58 | // set TailscaleConfig defaults 59 | if err := defaults.Set(ts); err != nil { 60 | fmt.Printf("Error loading defaults: %v", err) 61 | } 62 | 63 | authKeyFile := os.Getenv("TSDPROXY_AUTHKEYFILE") 64 | authKey := os.Getenv("TSDPROXY_AUTHKEY") 65 | controlURL := os.Getenv("TSDPROXY_CONTROLURL") 66 | dataDir := os.Getenv("TSDPROXY_DATADIR") 67 | 68 | if authKeyFile != "" { 69 | var err error 70 | authKey, err = c.getAuthKeyFromFile(authKeyFile) 71 | if err != nil { 72 | fmt.Printf("Error loading auth key from file: %v", err) 73 | } 74 | } 75 | 76 | if authKey != "" { 77 | ts.AuthKey = authKey 78 | } 79 | if authKeyFile != "" { 80 | ts.AuthKeyFile = authKeyFile 81 | } 82 | 83 | if controlURL != "" { 84 | ts.ControlURL = controlURL 85 | } 86 | if dataDir != "" { 87 | c.Tailscale.DataDir = dataDir 88 | } 89 | 90 | c.Tailscale.Providers[TailscaleDefaultProviderName] = ts 91 | 92 | if c.DefaultProxyProvider == "" { 93 | c.DefaultProxyProvider = TailscaleDefaultProviderName 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/config/validator.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package config 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "strings" 10 | 11 | "github.com/go-playground/validator/v10" 12 | ) 13 | 14 | type DefaultProxyProviderNotFoundError struct { 15 | ProviderName string 16 | } 17 | 18 | func (e *DefaultProxyProviderNotFoundError) Error() string { 19 | return "Default proxy provider " + e.ProviderName + " not found" 20 | } 21 | 22 | var ErrNoDefaultProxyProvider = errors.New("no default proxy provider") 23 | 24 | // validate method Validate configurations. 25 | func (c *config) validate() error { 26 | println("Validating configuration...") 27 | validate := validator.New() 28 | 29 | if err := validate.Struct(Config); err != nil { 30 | // validationErrors := err.(validator.ValidationErrors) 31 | var validationErrors validator.ValidationErrors 32 | if errors.As(err, &validationErrors) { 33 | for _, e := range validationErrors { 34 | fmt.Println(e) 35 | } 36 | return err 37 | } 38 | } 39 | 40 | // TODO: add validation for each provider 41 | // TODO: add default proxy provider to each proxy if not defined 42 | // 43 | 44 | // Set default Proxy Provider if not set. 45 | // 46 | if c.DefaultProxyProvider != "" { 47 | if !c.hasProxyProvider(c.DefaultProxyProvider) { 48 | return &DefaultProxyProviderNotFoundError{ProviderName: c.DefaultProxyProvider} 49 | } 50 | } else { 51 | var temp string 52 | var err error 53 | if temp, err = c.getDefaultProxyProvider(); err != nil { 54 | return err 55 | } 56 | c.DefaultProxyProvider = strings.ToLower(temp) 57 | } 58 | 59 | // add default proxy provider to docker providers 60 | // 61 | err := c.addDefaultProxyProviderToDockerProviders() 62 | if err != nil { 63 | return err 64 | } 65 | return nil 66 | } 67 | 68 | func (c *config) addDefaultProxyProviderToDockerProviders() error { 69 | for _, p := range c.Docker { 70 | if p.DefaultProxyProvider == "" { 71 | p.DefaultProxyProvider = c.DefaultProxyProvider 72 | } else { 73 | if !c.hasProxyProvider(p.DefaultProxyProvider) { 74 | return &DefaultProxyProviderNotFoundError{ProviderName: p.DefaultProxyProvider} 75 | } 76 | } 77 | } 78 | return nil 79 | } 80 | 81 | func (c *config) getDefaultProxyProvider() (string, error) { 82 | for name := range c.Tailscale.Providers { 83 | return strings.ToLower(name), nil 84 | } 85 | return "", ErrNoDefaultProxyProvider 86 | } 87 | 88 | func (c *config) hasProxyProvider(name string) bool { 89 | for n := range c.Tailscale.Providers { 90 | if strings.EqualFold(n, name) { 91 | return true 92 | } 93 | } 94 | return false 95 | } 96 | -------------------------------------------------------------------------------- /internal/consts/files.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package consts 5 | 6 | const ( 7 | PermNone = 0o000 // No permissions 8 | PermOwnerRead = 0o400 // Owner: Read 9 | PermOwnerWrite = 0o200 // Owner: Write 10 | PermOwnerExecute = 0o100 // Owner: Execute 11 | PermOwnerAll = 0o700 // Owner: Read, Write, Execute 12 | 13 | PermGroupRead = 0o040 // Group: Read 14 | PermGroupWrite = 0o020 // Group: Write 15 | PermGroupExecute = 0o010 // Group: Execute 16 | PermGroupAll = 0o070 // Group: Read, Write, Execute 17 | 18 | PermOthersRead = 0o004 // Others: Read 19 | PermOthersWrite = 0o002 // Others: Write 20 | PermOthersExecute = 0o001 // Others: Execute 21 | PermOthersAll = 0o007 // Others: Read, Write, Execute 22 | 23 | PermOwnerGroupRead = 0o440 // Owner and Group: Read 24 | PermOwnerGroupAll = 0o770 // Owner and Group: All 25 | 26 | PermAllRead = 0o444 // Everyone: Read 27 | PermAllWrite = 0o222 // Everyone: Write 28 | PermAllExecute = 0o111 // Everyone: Execute 29 | PermAll = 0o777 // Everyone: Read, Write, Execute 30 | ) 31 | -------------------------------------------------------------------------------- /internal/consts/proxymanager.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package consts 5 | 6 | const ( 7 | HeaderUsername = "X-tsdproxy-username" 8 | HeaderDisplayName = "x-tsdproxy-displayName" 9 | HeaderProfilePicURL = "x-tsdproxy-profilePicUrl" 10 | ) 11 | -------------------------------------------------------------------------------- /internal/core/const.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package core 5 | 6 | import "time" 7 | 8 | const ( 9 | // G112 (CWE-400): Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server (Confidence: LOW, Severity: MEDIUM. 10 | ReadHeaderTimeout = 5 * time.Second 11 | ) 12 | -------------------------------------------------------------------------------- /internal/core/healthcheck.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package core 5 | 6 | import ( 7 | "net/http" 8 | "sync/atomic" 9 | 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | const ( 14 | Ready = 1 15 | NotReady = 0 16 | ) 17 | 18 | type Health struct { 19 | HTTP *HTTPServer 20 | Log zerolog.Logger 21 | ready int32 22 | } 23 | 24 | func NewHealthHandler(http *HTTPServer, log zerolog.Logger) *Health { 25 | h := &Health{ 26 | HTTP: http, 27 | Log: log, 28 | } 29 | 30 | atomic.StoreInt32(&h.ready, NotReady) 31 | 32 | h.AddRoutes() 33 | 34 | return h 35 | } 36 | 37 | func (h *Health) AddRoutes() { 38 | h.HTTP.Handle("GET /health/ready/", h.Ready()) 39 | } 40 | 41 | func (h *Health) Ready() http.HandlerFunc { 42 | return func(w http.ResponseWriter, r *http.Request) { 43 | if atomic.LoadInt32(&h.ready) == Ready { 44 | h.HTTP.JSONResponseCode(w, r, map[string]string{"status": "OK"}, http.StatusOK) 45 | return 46 | } 47 | 48 | h.HTTP.JSONResponseCode(w, r, map[string]string{"status": "NOK"}, http.StatusServiceUnavailable) 49 | } 50 | } 51 | 52 | func (h *Health) SetReady() { 53 | atomic.StoreInt32(&h.ready, Ready) 54 | h.Log.Info().Msgf("Health check set to ready") 55 | } 56 | 57 | func (h *Health) SetNotReady() { 58 | atomic.StoreInt32(&h.ready, NotReady) 59 | h.Log.Info().Msgf("Health check set to not ready") 60 | } 61 | -------------------------------------------------------------------------------- /internal/core/http.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package core 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "net/http" 10 | 11 | "github.com/rs/zerolog" 12 | "go.opentelemetry.io/otel/codes" 13 | "go.opentelemetry.io/otel/trace" 14 | ) 15 | 16 | // Middleware type as before. 17 | type Middleware func(http.Handler) http.Handler 18 | 19 | // HTTPServer struct to hold our routes and middleware. 20 | type HTTPServer struct { 21 | Log zerolog.Logger 22 | Mux *http.ServeMux 23 | middlewares []Middleware 24 | } 25 | 26 | // NewHTTPServer creates and returns a new App with an initialized ServeMux and middleware slice. 27 | func NewHTTPServer(log zerolog.Logger) *HTTPServer { 28 | return &HTTPServer{ 29 | Mux: http.NewServeMux(), 30 | middlewares: []Middleware{}, 31 | Log: log, 32 | } 33 | } 34 | 35 | // Use adds middleware to the chain. 36 | func (a *HTTPServer) Use(mw Middleware) { 37 | a.middlewares = append(a.middlewares, mw) 38 | } 39 | 40 | // Handle registers a handler for a specific route, applying all middleware. 41 | func (a *HTTPServer) Handle(pattern string, handler http.Handler) { 42 | finalHandler := handler 43 | for i := len(a.middlewares) - 1; i >= 0; i-- { 44 | finalHandler = a.middlewares[i](finalHandler) 45 | } 46 | 47 | a.Mux.Handle(pattern, finalHandler) 48 | } 49 | 50 | // Get method add a GET handler 51 | func (a *HTTPServer) Get(pattern string, handler http.Handler) { 52 | a.Handle("GET "+pattern, handler) 53 | } 54 | 55 | // Post method add a POST handler 56 | func (a *HTTPServer) Post(pattern string, handler http.Handler) { 57 | a.Handle("POST "+pattern, handler) 58 | } 59 | 60 | // StartServer starts a custom http server. 61 | func (a *HTTPServer) StartServer(s *http.Server) error { 62 | // set Logger the first middlewares 63 | s.Handler = LoggerMiddleware(a.Log, a.Mux) 64 | 65 | if s.TLSConfig != nil { 66 | // add logger middleware 67 | return s.ListenAndServeTLS("", "") 68 | } 69 | 70 | return s.ListenAndServe() 71 | } 72 | 73 | func (a *HTTPServer) JSONResponse(w http.ResponseWriter, _ *http.Request, result interface{}) { 74 | body, err := json.Marshal(result) 75 | if err != nil { 76 | w.WriteHeader(http.StatusInternalServerError) 77 | a.Log.Error().Err(err).Msg("JSON marshal failed in JSONResponse") 78 | return 79 | } 80 | 81 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 82 | w.Header().Set("X-Content-Type-Options", "nosniff") 83 | w.WriteHeader(http.StatusOK) 84 | _, err = w.Write(a.prettyJSON(body)) 85 | if err != nil { 86 | a.Log.Error().Err(err).Msg("Write failed in JSONResponse") 87 | } 88 | } 89 | 90 | func (a *HTTPServer) JSONResponseCode(w http.ResponseWriter, _ *http.Request, result interface{}, responseCode int) { 91 | body, err := json.Marshal(result) 92 | if err != nil { 93 | w.WriteHeader(http.StatusInternalServerError) 94 | a.Log.Error().Err(err).Msg("JSON marshal failed") 95 | return 96 | } 97 | 98 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 99 | w.Header().Set("X-Content-Type-Options", "nosniff") 100 | w.WriteHeader(responseCode) 101 | _, err = w.Write(a.prettyJSON(body)) 102 | if err != nil { 103 | a.Log.Error().Err(err).Msg("Write failed in JSONResponseCode") 104 | } 105 | } 106 | 107 | func (a *HTTPServer) ErrorResponse(w http.ResponseWriter, _ *http.Request, span trace.Span, returnError string, code int) { 108 | data := struct { 109 | Message string `json:"message"` 110 | Code int `json:"code"` 111 | }{ 112 | Code: code, 113 | Message: returnError, 114 | } 115 | 116 | span.SetStatus(codes.Error, returnError) 117 | 118 | body, err := json.Marshal(data) 119 | if err != nil { 120 | w.WriteHeader(http.StatusInternalServerError) 121 | a.Log.Error().Err(err).Msg("JSON marshal failed") 122 | return 123 | } 124 | 125 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 126 | w.Header().Set("X-Content-Type-Options", "nosniff") 127 | w.WriteHeader(http.StatusOK) 128 | _, err = w.Write(a.prettyJSON(body)) 129 | if err != nil { 130 | a.Log.Error().Err(err).Msg("Write failed in ErrorResponse") 131 | } 132 | } 133 | 134 | func (a *HTTPServer) prettyJSON(b []byte) []byte { 135 | var out bytes.Buffer 136 | if err := json.Indent(&out, b, "", " "); err != nil { 137 | a.Log.Err(err).Msg("prettyJSON failed") 138 | } 139 | return out.Bytes() 140 | } 141 | -------------------------------------------------------------------------------- /internal/core/log.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package core 5 | 6 | import ( 7 | "bufio" 8 | "errors" 9 | "net" 10 | "net/http" 11 | "os" 12 | 13 | "github.com/rs/zerolog" 14 | "github.com/rs/zerolog/log" 15 | 16 | "github.com/almeidapaulopt/tsdproxy/internal/config" 17 | ) 18 | 19 | var ErrHijackNotSupported = errors.New("hijack not supported") 20 | 21 | func NewLog() zerolog.Logger { 22 | println("Setting up logger") 23 | 24 | var logger zerolog.Logger 25 | 26 | if config.Config.Log.JSON { 27 | logger = zerolog.New(os.Stderr).With().Timestamp().Logger() 28 | } else { 29 | logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() 30 | } 31 | 32 | log.Logger = logger 33 | logLevel, err := zerolog.ParseLevel(config.Config.Log.Level) 34 | if err != nil { 35 | logger.Fatal().Err(err).Msg("Could not parse log level") 36 | } 37 | 38 | if logLevel == zerolog.DebugLevel || logLevel == zerolog.TraceLevel { 39 | logger = logger.With().Caller().Logger() 40 | } 41 | 42 | zerolog.SetGlobalLevel(logLevel) 43 | logger.Info().Str("Log level", config.Config.Log.Level).Msg("Log Settings") 44 | 45 | return logger 46 | } 47 | 48 | // LogRecord warps a http.ResponseWriter and records the status. 49 | type LogRecord struct { 50 | err error 51 | http.ResponseWriter 52 | status int 53 | } 54 | 55 | // WriteHeader overrides ResponseWriter.WriteHeader to keep track of the response code. 56 | func (r *LogRecord) WriteHeader(status int) { 57 | r.status = status 58 | r.ResponseWriter.WriteHeader(status) 59 | } 60 | 61 | func (r *LogRecord) Write(data []byte) (int, error) { 62 | n, err := r.ResponseWriter.Write(data) 63 | if err != nil { 64 | r.err = err 65 | } 66 | 67 | return n, err 68 | } 69 | 70 | func (r *LogRecord) Hijack() (net.Conn, *bufio.ReadWriter, error) { 71 | h, ok := r.ResponseWriter.(http.Hijacker) 72 | if !ok { 73 | return nil, nil, ErrHijackNotSupported 74 | } 75 | return h.Hijack() 76 | } 77 | 78 | func (r *LogRecord) Flush() { 79 | r.ResponseWriter.(http.Flusher).Flush() 80 | } 81 | 82 | // LoggerMiddleware is a middleware function that logs incoming HTTP requests. 83 | func LoggerMiddleware(l zerolog.Logger, next http.Handler) http.Handler { 84 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 85 | lw := &LogRecord{ 86 | ResponseWriter: w, 87 | status: http.StatusOK, 88 | } 89 | 90 | // Call the next handler in the chain 91 | // lw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK} 92 | next.ServeHTTP(lw, r) 93 | // Log the request method and URL 94 | if lw.status >= http.StatusBadRequest { 95 | l.Error(). 96 | Err(lw.err). 97 | Int("status", lw.status). 98 | Str("method", r.Method). 99 | Str("host", r.Host). 100 | Str("client", r.RemoteAddr). 101 | Str("url", r.URL.String()). 102 | Msg("error") 103 | } else { 104 | l.Info(). 105 | Int("status", lw.status). 106 | Str("method", r.Method). 107 | Str("host", r.Host). 108 | Str("client", r.RemoteAddr). 109 | Str("url", r.URL.String()). 110 | Msg("request") 111 | } 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /internal/core/pprof.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package core 5 | 6 | import ( 7 | "net/http" 8 | "net/http/pprof" 9 | ) 10 | 11 | func PprofAddRoutes(http *HTTPServer) { 12 | http.Get("/debug/pprof/", pprofIndex()) 13 | http.Get("/debug/pprof/cmdline", pprofCmdline()) 14 | http.Get("/debug/pprof/profile", pprofProfile()) 15 | http.Get("/debug/pprof/symbol", pprofSymbol()) 16 | http.Get("/debug/pprof/trace", pprofTrace()) 17 | } 18 | 19 | func pprofIndex() http.HandlerFunc { 20 | return pprof.Index 21 | } 22 | 23 | func pprofCmdline() http.HandlerFunc { 24 | return pprof.Cmdline 25 | } 26 | 27 | func pprofProfile() http.HandlerFunc { 28 | return pprof.Profile 29 | } 30 | 31 | func pprofSymbol() http.HandlerFunc { 32 | return pprof.Symbol 33 | } 34 | 35 | func pprofTrace() http.HandlerFunc { 36 | return pprof.Trace 37 | } 38 | -------------------------------------------------------------------------------- /internal/core/sessions.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package core 5 | 6 | import ( 7 | "errors" 8 | "net/http" 9 | "sync" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | // Session store (maps sessionID -> data) 15 | var ( 16 | sessions = make(map[string]map[string]string) 17 | mtx sync.Mutex 18 | ) 19 | 20 | // Middleware to manage sessions 21 | func SessionMiddleware(next http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | // Check for existing session cookie 24 | cookie, err := r.Cookie("session_id") 25 | var sessionID string 26 | 27 | if errors.Is(err, http.ErrNoCookie) { 28 | // No session, create a new one 29 | http.SetCookie(w, &http.Cookie{ 30 | Name: "session_id", 31 | Value: uuid.New().String(), 32 | Path: "/", 33 | HttpOnly: true, 34 | Secure: true, 35 | }) 36 | 37 | mtx.Lock() 38 | sessions[sessionID] = make(map[string]string) 39 | mtx.Unlock() 40 | } else { 41 | sessionID = cookie.Value 42 | } 43 | 44 | r.Header.Set("X-Session-ID", sessionID) 45 | next.ServeHTTP(w, r) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /internal/core/version.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package core 5 | 6 | import ( 7 | "runtime/debug" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | version string 13 | realVersion *string 14 | isDirty *bool 15 | AppNameVersion = AppName + "-" + GetVersion() 16 | ) 17 | 18 | const ( 19 | AppName = "TSDProxy" 20 | AppAuthor = "Paulo Almeida " 21 | ) 22 | 23 | func GetVersion() string { 24 | if realVersion == nil { 25 | tempVersion := strings.TrimSpace(version) 26 | if getIsDirty() { 27 | tempVersion += "-dirty" 28 | } 29 | realVersion = &tempVersion 30 | } 31 | return *realVersion 32 | } 33 | 34 | func getIsDirty() bool { 35 | if isDirty != nil { 36 | return *isDirty 37 | } 38 | 39 | bi, ok := debug.ReadBuildInfo() 40 | if ok { 41 | modified := false 42 | 43 | for _, v := range bi.Settings { 44 | if v.Key == "vcs.modified" { 45 | if v.Value == "true" { 46 | modified = true 47 | } 48 | } 49 | } 50 | isDirty = &modified 51 | } 52 | return *isDirty 53 | } 54 | -------------------------------------------------------------------------------- /internal/dashboard/dash.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package dashboard 5 | 6 | import ( 7 | "sync" 8 | 9 | "github.com/almeidapaulopt/tsdproxy/internal/core" 10 | "github.com/almeidapaulopt/tsdproxy/internal/model" 11 | "github.com/almeidapaulopt/tsdproxy/internal/proxymanager" 12 | "github.com/almeidapaulopt/tsdproxy/internal/ui/pages" 13 | "github.com/almeidapaulopt/tsdproxy/web" 14 | 15 | "github.com/rs/zerolog" 16 | ) 17 | 18 | type Dashboard struct { 19 | Log zerolog.Logger 20 | HTTP *core.HTTPServer 21 | pm *proxymanager.ProxyManager 22 | sseClients map[string]*sseClient 23 | mtx sync.RWMutex 24 | } 25 | 26 | func NewDashboard(http *core.HTTPServer, log zerolog.Logger, pm *proxymanager.ProxyManager) *Dashboard { 27 | dash := &Dashboard{ 28 | Log: log.With().Str("module", "dashboard").Logger(), 29 | HTTP: http, 30 | pm: pm, 31 | sseClients: make(map[string]*sseClient), 32 | } 33 | 34 | go dash.streamProxyUpdates() 35 | 36 | return dash 37 | } 38 | 39 | // AddRoutes method add dashboard related routes to the http server 40 | func (dash *Dashboard) AddRoutes() { 41 | dash.HTTP.Get("/stream", dash.streamHandler()) 42 | dash.HTTP.Get("/", web.Static) 43 | } 44 | 45 | // index is the HandlerFunc to index page of dashboard 46 | func (dash *Dashboard) renderList(ch chan SSEMessage) { 47 | dash.mtx.RLock() 48 | defer dash.mtx.RUnlock() 49 | 50 | // force remove elements of proxy-list inn case of client reconnect 51 | ch <- SSEMessage{ 52 | Type: EventRemoveMessage, 53 | Message: "#proxy-list>*", 54 | } 55 | 56 | proxies := dash.pm.GetProxies() 57 | _ = proxies 58 | for name, p := range dash.pm.Proxies { 59 | if p.Config.Dashboard.Visible { 60 | dash.renderProxy(ch, name, EventAppend) 61 | } 62 | } 63 | 64 | dash.streamSortList(ch) 65 | } 66 | 67 | func (dash *Dashboard) renderProxy(ch chan SSEMessage, name string, ev EventType) { 68 | p, ok := dash.pm.GetProxy(name) 69 | if !ok { 70 | return 71 | } 72 | 73 | status := p.GetStatus() 74 | 75 | url := p.GetURL() 76 | if status == model.ProxyStatusAuthenticating { 77 | url = p.GetAuthURL() 78 | } 79 | 80 | icon := p.Config.Dashboard.Icon 81 | if icon == "" { 82 | icon = model.DefaultDashboardIcon 83 | } 84 | 85 | label := p.Config.Dashboard.Label 86 | if label == "" { 87 | label = name 88 | } 89 | 90 | ports := make([]model.PortConfig, len(p.Config.Ports)) 91 | i := 0 92 | for _, target := range p.Config.Ports { 93 | ports[i] = target 94 | i++ 95 | } 96 | 97 | enabled := status == model.ProxyStatusAuthenticating || status == model.ProxyStatusRunning 98 | 99 | a := pages.ProxyData{ 100 | Enabled: enabled, 101 | Name: name, 102 | URL: url, 103 | ProxyStatus: status, 104 | Icon: icon, 105 | Label: label, 106 | Ports: ports, 107 | } 108 | 109 | ch <- SSEMessage{ 110 | Type: ev, 111 | Comp: pages.Proxy(a), 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/dashboard/stream.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package dashboard 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/almeidapaulopt/tsdproxy/internal/consts" 10 | "github.com/almeidapaulopt/tsdproxy/internal/model" 11 | 12 | "github.com/a-h/templ" 13 | datastar "github.com/starfederation/datastar/sdk/go" 14 | ) 15 | 16 | const ( 17 | chanSizeSSEQueue = 0 18 | 19 | EventAppend EventType = iota 20 | EventMerge 21 | EventMergeMessage 22 | EventRemoveMessage 23 | EventScript 24 | EventUpdateSignals 25 | ) 26 | 27 | // sseClient represents an SSE connection 28 | type ( 29 | EventType int 30 | sseClient struct { 31 | channel chan SSEMessage 32 | } 33 | 34 | SSEMessage struct { 35 | Comp templ.Component 36 | Message string 37 | Type EventType 38 | } 39 | ) 40 | 41 | // Handler for the `/stream` endpoint 42 | func (dash *Dashboard) streamHandler() http.HandlerFunc { 43 | return func(w http.ResponseWriter, r *http.Request) { 44 | sessionID := r.Header.Get("X-Session-ID") 45 | 46 | sse := datastar.NewSSE(w, r) 47 | 48 | // Create a new client 49 | client := &sseClient{ 50 | channel: make(chan SSEMessage, chanSizeSSEQueue), 51 | } 52 | 53 | // Register client 54 | dash.mtx.Lock() 55 | dash.sseClients[sessionID] = client 56 | dash.mtx.Unlock() 57 | 58 | dash.Log.Info().Msg("New Client connected") 59 | // Ensure client is removed when disconnected 60 | defer dash.removeSSEClient(sessionID) 61 | 62 | go func() { 63 | dash.renderList(client.channel) 64 | dash.updateUser(r, client.channel) 65 | }() 66 | 67 | var err error 68 | 69 | // Send messages to the client 70 | LOOP: 71 | for { 72 | select { 73 | case <-r.Context().Done(): 74 | break LOOP 75 | case message := <-client.channel: 76 | switch message.Type { 77 | case EventAppend: 78 | err = sse.MergeFragmentTempl( 79 | message.Comp, 80 | datastar.WithMergeMode(datastar.FragmentMergeModeAppend), 81 | datastar.WithSelector("#proxy-list"), 82 | ) 83 | 84 | case EventMerge: 85 | err = sse.MergeFragmentTempl( 86 | message.Comp, 87 | ) 88 | 89 | case EventMergeMessage: 90 | err = sse.MergeFragments(message.Message) 91 | 92 | case EventRemoveMessage: 93 | err = sse.RemoveFragments(message.Message) 94 | 95 | case EventScript: 96 | err = sse.ExecuteScript(message.Message) 97 | 98 | case EventUpdateSignals: 99 | err = sse.MergeSignals([]byte(message.Message)) 100 | } 101 | } 102 | 103 | if err != nil { 104 | dash.Log.Error().Err(err).Msg("Error sending message to client") 105 | break LOOP 106 | } 107 | } 108 | } 109 | } 110 | 111 | func (dash *Dashboard) updateUser(r *http.Request, ch chan SSEMessage) { 112 | username := r.Header.Get(consts.HeaderUsername) 113 | displayName := r.Header.Get(consts.HeaderDisplayName) 114 | profilePicURL := r.Header.Get(consts.HeaderProfilePicURL) 115 | 116 | signals := `{user_username: '` + username + 117 | `', user_displayName: '` + displayName + 118 | `', user_profilePicUrl: '` + profilePicURL + `'}` 119 | 120 | ch <- SSEMessage{ 121 | Type: EventUpdateSignals, 122 | Message: signals, 123 | } 124 | } 125 | 126 | func (dash *Dashboard) removeSSEClient(name string) { 127 | dash.mtx.Lock() 128 | 129 | if client, ok := dash.sseClients[name]; ok { 130 | delete(dash.sseClients, name) 131 | close(client.channel) 132 | } 133 | dash.mtx.Unlock() 134 | 135 | dash.Log.Info().Msg("Client disconnected") 136 | } 137 | 138 | func (dash *Dashboard) streamProxyUpdates() { 139 | for event := range dash.pm.SubscribeStatusEvents() { 140 | dash.mtx.RLock() 141 | for _, sseClient := range dash.sseClients { 142 | switch event.Status { 143 | case model.ProxyStatusInitializing: 144 | dash.renderProxy(sseClient.channel, event.ID, EventAppend) 145 | dash.streamSortList(sseClient.channel) 146 | 147 | case model.ProxyStatusStopped: 148 | sseClient.channel <- SSEMessage{ 149 | Type: EventRemoveMessage, 150 | Message: "#" + event.ID, 151 | } 152 | 153 | default: 154 | dash.renderProxy(sseClient.channel, event.ID, EventMerge) 155 | } 156 | } 157 | dash.mtx.RUnlock() 158 | } 159 | } 160 | 161 | func (dash *Dashboard) streamSortList(channel chan SSEMessage) { 162 | channel <- SSEMessage{ 163 | Type: EventScript, 164 | Message: "sortList()", 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /internal/model/contextkey.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package model 5 | 6 | const ( 7 | ContextKeyWhois ContextKey = "contextkey.whois" 8 | ) 9 | 10 | type ( 11 | ContextKey string 12 | ) 13 | -------------------------------------------------------------------------------- /internal/model/default.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package model 5 | 6 | const ( 7 | // Default values to proxyconfig 8 | // 9 | DefaultProxyAccessLog = true 10 | DefaultProxyProvider = "" 11 | DefaultTLSValidate = true 12 | 13 | // tailscale defaults 14 | DefaultTailscaleEphemeral = false 15 | DefaultTailscaleRunWebClient = false 16 | DefaultTailscaleVerbose = false 17 | DefaultTailscaleFunnel = false 18 | DefaultTailscaleControlURL = "" 19 | 20 | // Dashboard defauts 21 | DefaultDashboardVisible = true 22 | DefaultDashboardIcon = "tsdproxy" 23 | ) 24 | -------------------------------------------------------------------------------- /internal/model/port.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package model 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | type ( 15 | PortConfig struct { 16 | name string `validate:"string" yaml:"name"` 17 | ProxyProtocol string `validate:"string" yaml:"proxyProtocol"` 18 | targets []*url.URL 19 | ProxyPort int `validate:"hostname_port" yaml:"proxyPort"` 20 | TLSValidate bool `validate:"boolean" yaml:"tlsValidate"` 21 | IsRedirect bool `validate:"boolean" yaml:"isRedirect"` 22 | Tailscale TailscalePort `validate:"dive" yaml:"tailscale"` 23 | } 24 | 25 | TailscalePort struct { 26 | Funnel bool `validate:"boolean" yaml:"funnel"` 27 | } 28 | ) 29 | 30 | const ( 31 | redirectSeparator = "->" 32 | proxySeparator = ":" 33 | protocolSeparator = "/" 34 | ) 35 | 36 | var ( 37 | ErrInvalidPortFormat = errors.New("invalid format, missing '" + protocolSeparator + "' or '" + redirectSeparator + "'") 38 | ErrInvalidProxyConfig = errors.New("invalid proxy configuration") 39 | ErrInvalidTargetConfig = errors.New("invalid target configuration") 40 | ) 41 | 42 | // NewPortLongLabel parses a port configuration string and returns a PortConfig struct. 43 | // 44 | // The input string `s` must follow one of these formats: 45 | // 1. "/:/" 46 | // - Example: "443/https:80/http" 47 | // 48 | // 2. ":" 49 | // - Example: "443:80" 50 | // - Defaults: "https" for `proxy protocol` and "http" for `target protocol`. 51 | // 52 | // 3. "/->" 53 | // - Example: "443/https->https://example.com" 54 | // - This format indicates a redirect, setting `IsRedirect` to true and TargetURL. 55 | // 56 | // Returns: 57 | // - PortConfig: A struct containing parsed proxy and target configurations. 58 | // - error: An error if the input string is invalid. 59 | // 60 | // Examples: 61 | // 1. "443/https:80/http" -> ProxyPort=443, ProxyProtocol="https", TargetPort=80, TargetProtocol="http" 62 | // 2. "443:80" -> ProxyPort=443, ProxyProtocol="https", TargetPort=80, TargetProtocol="http" 63 | // 3. "443/https->https://example.com" -> ProxyPort=443, ProxyProtocol="https", IsRedirect=true, TargetURL=https://example.com 64 | 65 | func NewPortLongLabel(s string) (PortConfig, error) { 66 | config := defaultPortConfig(s) 67 | 68 | separator := detectSeparator(s) 69 | 70 | parts := strings.Split(s, separator) 71 | if len(parts) != 2 { //nolint:mnd 72 | return config, ErrInvalidProxyConfig 73 | } 74 | 75 | err := parseProxySegment(parts[0], &config) 76 | if err != nil { 77 | return config, err 78 | } 79 | 80 | if separator == redirectSeparator { 81 | config.IsRedirect = true 82 | err = parseRedirectTarget(parts[1], &config) 83 | } else { 84 | err = parseTargetSegment(parts[1], &config) 85 | } 86 | 87 | return config, err 88 | } 89 | 90 | // NewPortShortLabel parses a port configuration string and returns a PortConfig struct. 91 | // 92 | // The input string `s` must follow one of these formats: 93 | // 1. "/" 94 | // - Example: "443/https" 95 | func NewPortShortLabel(s string) (PortConfig, error) { 96 | config := defaultPortConfig(s) 97 | 98 | err := parseProxySegment(s, &config) 99 | if err != nil { 100 | return config, err 101 | } 102 | 103 | return config, nil 104 | } 105 | 106 | func (p *PortConfig) String() string { 107 | return p.name 108 | } 109 | 110 | // defaultPortConfig initializes a PortConfig with default values. 111 | func defaultPortConfig(name string) PortConfig { 112 | return PortConfig{ 113 | name: name, 114 | ProxyProtocol: "https", 115 | ProxyPort: 443, //nolint:mnd 116 | IsRedirect: false, 117 | } 118 | } 119 | 120 | // detectSeparator determines the separator used in the configuration string and whether it's a redirect. 121 | func detectSeparator(s string) string { 122 | if strings.Contains(s, redirectSeparator) { 123 | return redirectSeparator 124 | } 125 | return proxySeparator 126 | } 127 | 128 | // parseProxySegment parses the proxy segment of the configuration string. 129 | func parseProxySegment(segment string, config *PortConfig) error { 130 | proxyParts := strings.Split(segment, protocolSeparator) 131 | if len(proxyParts) > 2 { //nolint:mnd 132 | return ErrInvalidProxyConfig 133 | } 134 | 135 | proxyPort, err := strconv.Atoi(proxyParts[0]) 136 | if err != nil { 137 | return fmt.Errorf("invalid proxy port: %w", err) 138 | } 139 | config.ProxyPort = proxyPort 140 | 141 | if len(proxyParts) == 2 { //nolint:mnd 142 | config.ProxyProtocol = proxyParts[1] 143 | } 144 | 145 | return nil 146 | } 147 | 148 | func parseTargetSegment(segment string, config *PortConfig) error { 149 | targetParts := strings.Split(segment, protocolSeparator) 150 | if len(targetParts) > 2 { //nolint:mnd 151 | return ErrInvalidTargetConfig 152 | } 153 | 154 | _, err := strconv.Atoi(targetParts[0]) 155 | if err != nil { 156 | return fmt.Errorf("invalid target port: %w", err) 157 | } 158 | 159 | targetProtocol := "http" 160 | 161 | if len(targetParts) == 2 { //nolint:mnd 162 | targetProtocol = targetParts[1] 163 | } 164 | 165 | urlParsed, err := url.Parse(targetProtocol + "://0.0.0.0:" + targetParts[0]) 166 | if err != nil { 167 | return fmt.Errorf("error to parse url: %w", err) 168 | } 169 | 170 | config.targets = []*url.URL{urlParsed} 171 | 172 | return nil 173 | } 174 | 175 | func parseRedirectTarget(segment string, config *PortConfig) error { 176 | targetURL, err := url.Parse(segment) 177 | if err != nil || targetURL.Scheme == "" || targetURL.Host == "" { 178 | return fmt.Errorf("invalid target URL: %v", segment) 179 | } 180 | 181 | config.AddTarget(targetURL) 182 | 183 | return nil 184 | } 185 | 186 | func (p *PortConfig) GetTargets() []*url.URL { 187 | return p.targets 188 | } 189 | 190 | func (p *PortConfig) GetFirstTarget() *url.URL { 191 | if len(p.GetTargets()) > 0 { 192 | return p.GetTargets()[0] 193 | } 194 | return &url.URL{} 195 | } 196 | 197 | func (p *PortConfig) AddTarget(target *url.URL) { 198 | p.targets = append(p.targets, target) 199 | } 200 | 201 | // ReplaceTarget replaces a target URL with a new one. 202 | // used mainly for updating the target URL when the container IP changes like docker provider. 203 | func (p *PortConfig) ReplaceTarget(origin, target *url.URL) { 204 | for k, v := range p.targets { 205 | if v.String() == origin.String() { 206 | p.targets[k] = target 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /internal/model/proxyconfig.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | package model 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/creasty/defaults" 9 | ) 10 | 11 | type ( 12 | 13 | // Config struct stores all the configuration for the proxy 14 | Config struct { 15 | Ports PortConfigList `validate:"dive"` 16 | TargetProvider string 17 | TargetID string 18 | ProxyProvider string 19 | Hostname string 20 | Dashboard Dashboard `validate:"dive"` 21 | Tailscale Tailscale `validate:"dive"` 22 | ProxyAccessLog bool `default:"true" validate:"boolean"` 23 | } 24 | 25 | // Tailscale struct stores the configuration for tailscale ProxyProvider 26 | Tailscale struct { 27 | Tags string `yaml:"tags"` 28 | AuthKey string `yaml:"authKey"` 29 | Ephemeral bool `default:"false" validate:"boolean" yaml:"ephemeral"` 30 | RunWebClient bool `default:"false" validate:"boolean" yaml:"runWebClient"` 31 | Verbose bool `default:"false" validate:"boolean" yaml:"verbose"` 32 | } 33 | 34 | Dashboard struct { 35 | Label string `validate:"string" yaml:"label"` 36 | Icon string `default:"tsdproxy" validate:"string" yaml:"icon"` 37 | Visible bool `default:"true" validate:"boolean" yaml:"visible"` 38 | } 39 | 40 | PortConfigList map[string]PortConfig 41 | ) 42 | 43 | func NewConfig() (*Config, error) { 44 | config := new(Config) 45 | 46 | err := defaults.Set(config) 47 | if err != nil { 48 | return nil, fmt.Errorf("error loading defaults: %w", err) 49 | } 50 | 51 | return config, nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/model/status.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | package model 4 | 5 | type ( 6 | ProxyStatus int 7 | 8 | ProxyEvent struct { 9 | ID string 10 | Port string 11 | AuthURL string 12 | Status ProxyStatus 13 | } 14 | ) 15 | 16 | const ( 17 | ProxyStatusInitializing ProxyStatus = iota 18 | ProxyStatusStarting 19 | ProxyStatusAuthenticating 20 | ProxyStatusRunning 21 | ProxyStatusStopping 22 | ProxyStatusStopped 23 | ProxyStatusError 24 | ) 25 | 26 | var proxyStatusStrings = []string{ 27 | "Initializing", 28 | "Starting", 29 | "Authenticating", 30 | "Running", 31 | "Stopping", 32 | "Stopped", 33 | "Error", 34 | } 35 | 36 | func (s *ProxyStatus) String() string { 37 | return proxyStatusStrings[int(*s)] 38 | } 39 | -------------------------------------------------------------------------------- /internal/model/whois.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package model 5 | 6 | import "context" 7 | 8 | type ( 9 | Whois struct { 10 | ID string 11 | DisplayName string 12 | Username string 13 | ProfilePicURL string 14 | } 15 | ) 16 | 17 | func (w *Whois) GetID() string { 18 | return w.ID 19 | } 20 | 21 | func (w *Whois) GetDisplayName() string { 22 | return w.DisplayName 23 | } 24 | 25 | func (w *Whois) GetUsername() string { 26 | return w.Username 27 | } 28 | 29 | func (w *Whois) GetProfilePicURL() string { 30 | return w.ProfilePicURL 31 | } 32 | 33 | func WhoisFromContext(ctx context.Context) (Whois, bool) { 34 | who, ok := ctx.Value(ContextKeyWhois).(Whois) 35 | 36 | return who, ok 37 | } 38 | 39 | func WhoisNewContext(ctx context.Context, who Whois) context.Context { 40 | return context.WithValue(ctx, ContextKeyWhois, who) 41 | } 42 | -------------------------------------------------------------------------------- /internal/proxymanager/port.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package proxymanager 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | "errors" 10 | "fmt" 11 | "net" 12 | "net/http" 13 | "net/http/httputil" 14 | "sync" 15 | 16 | "github.com/almeidapaulopt/tsdproxy/internal/consts" 17 | "github.com/almeidapaulopt/tsdproxy/internal/core" 18 | "github.com/almeidapaulopt/tsdproxy/internal/model" 19 | 20 | "github.com/rs/zerolog" 21 | ) 22 | 23 | type port struct { 24 | log zerolog.Logger 25 | ctx context.Context 26 | listener net.Listener 27 | cancel context.CancelFunc 28 | httpServer *http.Server 29 | mtx sync.Mutex 30 | } 31 | 32 | func newPortProxy( 33 | ctx context.Context, 34 | pconfig model.PortConfig, 35 | log zerolog.Logger, 36 | accessLog bool, 37 | whoisFunc func(next http.Handler) http.Handler, 38 | ) *port { 39 | // 40 | log = log.With().Str("port", pconfig.String()).Logger() 41 | 42 | ctxPort, cancel := context.WithCancel(ctx) 43 | 44 | // Create the reverse proxy 45 | // 46 | tr := &http.Transport{ 47 | TLSClientConfig: &tls.Config{InsecureSkipVerify: !pconfig.TLSValidate}, //nolint 48 | } 49 | reverseProxy := &httputil.ReverseProxy{ 50 | Transport: tr, 51 | Rewrite: func(r *httputil.ProxyRequest) { 52 | r.SetURL(pconfig.GetFirstTarget()) 53 | r.Out.Host = r.In.Host 54 | r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] 55 | 56 | if user, ok := model.WhoisFromContext(r.In.Context()); ok { 57 | r.Out.Header.Set(consts.HeaderUsername, user.Username) 58 | r.Out.Header.Set(consts.HeaderDisplayName, user.DisplayName) 59 | r.Out.Header.Set(consts.HeaderProfilePicURL, user.ProfilePicURL) 60 | } 61 | 62 | r.SetXForwarded() 63 | }, 64 | } 65 | 66 | handler := whoisFunc(reverseProxy) 67 | // add logger to proxy 68 | if accessLog { 69 | handler = core.LoggerMiddleware(log, handler) 70 | } 71 | 72 | // main http Server 73 | httpServer := &http.Server{ 74 | Handler: handler, 75 | ReadHeaderTimeout: core.ReadHeaderTimeout, 76 | BaseContext: func(net.Listener) context.Context { return ctxPort }, 77 | } 78 | 79 | return &port{ 80 | log: log, 81 | ctx: ctxPort, 82 | cancel: cancel, 83 | httpServer: httpServer, 84 | } 85 | } 86 | 87 | func newPortRedirect(ctx context.Context, pconfig model.PortConfig, log zerolog.Logger) *port { 88 | log = log.With().Str("port", pconfig.String()).Logger() 89 | 90 | ctxPort, cancel := context.WithCancel(ctx) 91 | 92 | redirectHTTPServer := &http.Server{ 93 | ReadHeaderTimeout: core.ReadHeaderTimeout, 94 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 | http.Redirect(w, r, pconfig.GetFirstTarget().String(), http.StatusMovedPermanently) 96 | }), 97 | } 98 | 99 | return &port{ 100 | log: log, 101 | ctx: ctxPort, 102 | cancel: cancel, 103 | httpServer: redirectHTTPServer, 104 | } 105 | } 106 | 107 | func (p *port) startWithListener(l net.Listener) error { 108 | p.mtx.Lock() 109 | p.listener = l 110 | p.mtx.Unlock() 111 | 112 | err := p.httpServer.Serve(l) 113 | defer p.log.Info().Msg("Terminating server") 114 | 115 | if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) { 116 | return fmt.Errorf("error starting port %w", err) 117 | } 118 | return nil 119 | } 120 | 121 | func (p *port) close() error { 122 | var errs error 123 | 124 | if p.httpServer != nil { 125 | errs = errors.Join(errs, p.httpServer.Shutdown(p.ctx)) 126 | } 127 | 128 | if p.listener != nil { 129 | errs = errors.Join(errs, p.listener.Close()) 130 | } 131 | 132 | p.cancel() 133 | 134 | return errs 135 | } 136 | -------------------------------------------------------------------------------- /internal/proxymanager/proxy.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package proxymanager 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "sync" 14 | 15 | "github.com/almeidapaulopt/tsdproxy/internal/model" 16 | "github.com/almeidapaulopt/tsdproxy/internal/proxyproviders" 17 | 18 | "github.com/rs/zerolog" 19 | ) 20 | 21 | type ( 22 | // Proxy struct is a struct that contains all the information needed to run a proxy. 23 | Proxy struct { 24 | onUpdate func(event model.ProxyEvent) 25 | 26 | log zerolog.Logger 27 | ctx context.Context 28 | providerProxy proxyproviders.ProxyInterface 29 | Config *model.Config 30 | URL *url.URL 31 | cancel context.CancelFunc 32 | ports map[string]*port 33 | mtx sync.RWMutex 34 | status model.ProxyStatus 35 | } 36 | ) 37 | 38 | // NewProxy function is a function that creates a new proxy. 39 | func NewProxy(log zerolog.Logger, 40 | pcfg *model.Config, 41 | proxyProvider proxyproviders.Provider, 42 | ) (*Proxy, error) { 43 | // 44 | var err error 45 | 46 | log = log.With().Str("proxyname", pcfg.Hostname).Logger() 47 | log.Info().Str("hostname", pcfg.Hostname).Msg("setting up proxy") 48 | 49 | log.Debug().Str("hostname", pcfg.Hostname). 50 | Msg("initializing proxy") 51 | 52 | // Create the proxyProvider proxy 53 | // 54 | pProvider, err := proxyProvider.NewProxy(pcfg) 55 | if err != nil { 56 | return nil, fmt.Errorf("error initializing proxy on proxyProvider: %w", err) 57 | } 58 | 59 | log.Debug(). 60 | Str("hostname", pcfg.Hostname). 61 | Msg("Proxy server created successfully") 62 | 63 | ctx, cancel := context.WithCancel(context.Background()) 64 | 65 | p := &Proxy{ 66 | log: log, 67 | Config: pcfg, 68 | ctx: ctx, 69 | cancel: cancel, 70 | providerProxy: pProvider, 71 | ports: make(map[string]*port), 72 | } 73 | 74 | p.initPorts() 75 | 76 | return p, nil 77 | } 78 | 79 | func (proxy *Proxy) Start() { 80 | go func() { 81 | go proxy.start() 82 | for event := range proxy.providerProxy.WatchEvents() { 83 | proxy.setStatus(event.Status) 84 | } 85 | }() 86 | } 87 | 88 | // Close method is a method that initiate proxy close procedure. 89 | func (proxy *Proxy) Close() { 90 | proxy.setStatus(model.ProxyStatusStopping) 91 | 92 | // cancel context 93 | proxy.cancel() 94 | 95 | // make sure all listeners are closed 96 | proxy.close() 97 | 98 | proxy.setStatus(model.ProxyStatusStopped) 99 | } 100 | 101 | func (proxy *Proxy) GetStatus() model.ProxyStatus { 102 | proxy.mtx.RLock() 103 | defer proxy.mtx.RUnlock() 104 | 105 | return proxy.status 106 | } 107 | 108 | func (proxy *Proxy) GetURL() string { 109 | return proxy.providerProxy.GetURL() 110 | } 111 | 112 | func (proxy *Proxy) GetAuthURL() string { 113 | return proxy.providerProxy.GetAuthURL() 114 | } 115 | 116 | func (proxy *Proxy) ProviderUserMiddleware(next http.Handler) http.Handler { 117 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 | who := proxy.providerProxy.Whois(r) 119 | 120 | ctx := model.WhoisNewContext(r.Context(), who) 121 | 122 | next.ServeHTTP(w, r.WithContext(ctx)) 123 | }) 124 | } 125 | 126 | func (proxy *Proxy) initPorts() { 127 | var newPort *port 128 | for k, v := range proxy.Config.Ports { 129 | log := proxy.log.With().Str("port", k).Logger() 130 | if v.IsRedirect { 131 | newPort = newPortRedirect(proxy.ctx, v, log) 132 | } else { 133 | newPort = newPortProxy(proxy.ctx, v, log, proxy.Config.ProxyAccessLog, proxy.ProviderUserMiddleware) 134 | } 135 | 136 | proxy.log.Debug().Any("port", newPort).Msg("newport") 137 | 138 | proxy.mtx.Lock() 139 | proxy.ports[k] = newPort 140 | proxy.mtx.Unlock() 141 | } 142 | } 143 | 144 | // Start method is a method that starts the proxy. 145 | func (proxy *Proxy) start() { 146 | proxy.log.Info().Msg("starting proxy") 147 | 148 | proxy.mtx.RLock() 149 | portsConfig := proxy.Config.Ports 150 | portsCount := len(proxy.ports) 151 | proxy.mtx.RUnlock() 152 | 153 | if portsCount == 0 { 154 | proxy.log.Warn().Msg("No ports configured") 155 | proxy.setStatus(model.ProxyStatusError) 156 | 157 | return 158 | } 159 | 160 | if err := proxy.providerProxy.Start(proxy.ctx); err != nil { 161 | proxy.log.Error().Err(err).Msg("Error starting with proxy provider") 162 | proxy.Close() 163 | return 164 | } 165 | 166 | var l net.Listener 167 | var err error 168 | 169 | for k := range portsConfig { 170 | proxy.log.Debug().Str("port", k).Msg("Starting proxy port") 171 | 172 | l, err = proxy.providerProxy.GetListener(k) 173 | if err != nil { 174 | proxy.log.Error().Err(err).Str("port", k).Msg("Error adding listener") 175 | continue 176 | } 177 | 178 | proxy.startPort(k, l) 179 | } 180 | } 181 | 182 | func (proxy *Proxy) startPort(name string, l net.Listener) { 183 | proxy.mtx.RLock() 184 | defer proxy.mtx.RUnlock() 185 | 186 | // make sure port exists 187 | if p, ok := proxy.ports[name]; ok { 188 | go func() { 189 | if err := p.startWithListener(l); err != nil { 190 | proxy.log.Error().Err(err).Msg("error starting port") 191 | proxy.setStatus(model.ProxyStatusError) 192 | } 193 | }() 194 | } 195 | } 196 | 197 | // close method is a method that closes all listeners ans httpServer. 198 | func (proxy *Proxy) close() { 199 | var errs error 200 | proxy.log.Info().Str("name", proxy.Config.Hostname).Msg("stopping proxy") 201 | 202 | for _, p := range proxy.ports { 203 | errs = errors.Join(errs, p.close()) 204 | } 205 | if proxy.providerProxy != nil { 206 | errs = errors.Join(proxy.providerProxy.Close()) 207 | } 208 | 209 | if errs != nil { 210 | proxy.log.Error().Err(errs).Msg("Error stopping proxy") 211 | } 212 | 213 | proxy.log.Info().Str("name", proxy.Config.Hostname).Msg("proxy stopped") 214 | } 215 | 216 | func (proxy *Proxy) setStatus(status model.ProxyStatus) { 217 | proxy.mtx.Lock() 218 | 219 | if proxy.status == status { 220 | proxy.mtx.Unlock() 221 | return 222 | } 223 | 224 | proxy.status = status 225 | proxy.mtx.Unlock() 226 | 227 | if proxy.onUpdate != nil { 228 | proxy.onUpdate(model.ProxyEvent{ 229 | ID: proxy.Config.Hostname, 230 | Status: status, 231 | }) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /internal/proxyproviders/proxyproviders.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package proxyproviders 5 | 6 | import ( 7 | "context" 8 | "net" 9 | "net/http" 10 | 11 | "github.com/almeidapaulopt/tsdproxy/internal/model" 12 | ) 13 | 14 | type ( 15 | // Proxy interface for each proxy provider 16 | Provider interface { 17 | NewProxy(cfg *model.Config) (ProxyInterface, error) 18 | } 19 | 20 | // ProxyInterface interface for each proxy 21 | ProxyInterface interface { 22 | Start(context.Context) error 23 | Close() error 24 | GetListener(port string) (net.Listener, error) 25 | GetURL() string 26 | GetAuthURL() string 27 | WatchEvents() chan model.ProxyEvent 28 | Whois(r *http.Request) model.Whois 29 | } 30 | ) 31 | -------------------------------------------------------------------------------- /internal/proxyproviders/tailscale/provider.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/almeidapaulopt/tsdproxy/internal/config" 13 | "github.com/almeidapaulopt/tsdproxy/internal/model" 14 | "github.com/almeidapaulopt/tsdproxy/internal/proxyproviders" 15 | 16 | "github.com/rs/zerolog" 17 | "tailscale.com/client/tailscale/v2" 18 | "tailscale.com/tsnet" 19 | ) 20 | 21 | type ( 22 | // Client struct implements proxyprovider for tailscale 23 | Client struct { 24 | log zerolog.Logger 25 | 26 | Hostname string 27 | AuthKey string 28 | clientID string 29 | clientSecret string 30 | controlURL string 31 | datadir string 32 | tags string 33 | } 34 | 35 | oauth struct { 36 | Authkey string `yaml:"authkey"` 37 | } 38 | ) 39 | 40 | var _ proxyproviders.Provider = (*Client)(nil) 41 | 42 | func New(log zerolog.Logger, name string, provider *config.TailscaleServerConfig) (*Client, error) { 43 | datadir := filepath.Join(config.Config.Tailscale.DataDir, name) 44 | 45 | return &Client{ 46 | log: log.With().Str("tailscale", name).Logger(), 47 | Hostname: name, 48 | AuthKey: strings.TrimSpace(provider.AuthKey), 49 | clientID: strings.TrimSpace(provider.ClientID), 50 | clientSecret: strings.TrimSpace(provider.ClientSecret), 51 | tags: strings.TrimSpace(provider.Tags), 52 | datadir: datadir, 53 | controlURL: provider.ControlURL, 54 | }, nil 55 | } 56 | 57 | // NewProxy method implements proxyprovider NewProxy method 58 | func (c *Client) NewProxy(config *model.Config) (proxyproviders.ProxyInterface, error) { 59 | c.log.Debug(). 60 | Str("hostname", config.Hostname). 61 | Msg("Setting up tailscale server") 62 | 63 | log := c.log.With().Str("Hostname", config.Hostname).Logger() 64 | 65 | datadir := path.Join(c.datadir, config.Hostname) 66 | authKey := c.getAuthkey(config, datadir) 67 | 68 | tserver := &tsnet.Server{ 69 | Hostname: config.Hostname, 70 | AuthKey: authKey, 71 | Dir: datadir, 72 | Ephemeral: config.Tailscale.Ephemeral, 73 | RunWebClient: config.Tailscale.RunWebClient, 74 | UserLogf: func(format string, args ...any) { 75 | log.Info().Msgf(format, args...) 76 | }, 77 | Logf: func(format string, args ...any) { 78 | log.Trace().Msgf(format, args...) 79 | }, 80 | 81 | ControlURL: c.getControlURL(), 82 | } 83 | 84 | // if verbose is set, use the info log level 85 | if config.Tailscale.Verbose { 86 | tserver.Logf = func(format string, args ...any) { 87 | log.Info().Msgf(format, args...) 88 | } 89 | } 90 | 91 | return &Proxy{ 92 | log: log, 93 | config: config, 94 | tsServer: tserver, 95 | events: make(chan model.ProxyEvent), 96 | }, nil 97 | } 98 | 99 | // getControlURL method returns the control URL 100 | func (c *Client) getControlURL() string { 101 | if c.controlURL == "" { 102 | return model.DefaultTailscaleControlURL 103 | } 104 | return c.controlURL 105 | } 106 | 107 | func (c *Client) getAuthkey(config *model.Config, path string) string { 108 | authKey := config.Tailscale.AuthKey 109 | 110 | if c.clientID != "" && c.clientSecret != "" { 111 | authKey = c.getOAuth(config, path) 112 | } 113 | 114 | if authKey == "" { 115 | authKey = c.AuthKey 116 | } 117 | return authKey 118 | } 119 | 120 | func (c *Client) getOAuth(cfg *model.Config, dir string) string { 121 | data := new(oauth) 122 | 123 | file := config.NewConfigFile(c.log, path.Join(dir, "tsdproxy.yaml"), data) 124 | if err := file.Load(); err == nil { 125 | if data.Authkey != "" { 126 | return data.Authkey 127 | } 128 | } 129 | 130 | ctx := context.Background() 131 | 132 | tsclient := &tailscale.Client{ 133 | Tailnet: "-", 134 | UserAgent: "tsdproxy", 135 | HTTP: tailscale.OAuthConfig{ 136 | ClientID: c.clientID, 137 | ClientSecret: c.clientSecret, 138 | Scopes: []string{"all:write"}, 139 | }.HTTPClient(), 140 | } 141 | 142 | temptags := strings.Trim(strings.TrimSpace(cfg.Tailscale.Tags), "\"") 143 | if temptags == "" { 144 | temptags = strings.Trim(strings.TrimSpace(c.tags), "\"") 145 | } 146 | 147 | if temptags == "" { 148 | c.log.Error().Msg("must define tags to use OAuth") 149 | return "" 150 | } 151 | 152 | capabilities := tailscale.KeyCapabilities{} 153 | capabilities.Devices.Create.Ephemeral = cfg.Tailscale.Ephemeral 154 | capabilities.Devices.Create.Reusable = false 155 | capabilities.Devices.Create.Preauthorized = true 156 | capabilities.Devices.Create.Tags = strings.Split(temptags, ",") 157 | 158 | ckr := tailscale.CreateKeyRequest{ 159 | Capabilities: capabilities, 160 | Description: "tsdproxy", 161 | } 162 | 163 | authkey, err := tsclient.Keys().Create(ctx, ckr) 164 | if err != nil { 165 | c.log.Error().Err(err).Msg("unable to get Oauth token") 166 | return "" 167 | } 168 | 169 | data.Authkey = authkey.Key 170 | if err := file.Save(); err != nil { 171 | c.log.Error().Err(err).Msg("unable to save oauth file") 172 | } 173 | 174 | return authkey.Key 175 | } 176 | -------------------------------------------------------------------------------- /internal/proxyproviders/tailscale/proxy.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "net" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | 15 | "github.com/almeidapaulopt/tsdproxy/internal/model" 16 | "github.com/almeidapaulopt/tsdproxy/internal/proxyproviders" 17 | 18 | "github.com/rs/zerolog" 19 | "tailscale.com/client/local" 20 | "tailscale.com/ipn" 21 | "tailscale.com/tsnet" 22 | ) 23 | 24 | // Proxy struct implements proxyconfig.Proxy. 25 | type Proxy struct { 26 | log zerolog.Logger 27 | config *model.Config 28 | tsServer *tsnet.Server 29 | lc *local.Client 30 | ctx context.Context 31 | 32 | events chan model.ProxyEvent 33 | 34 | authURL string 35 | url string 36 | status model.ProxyStatus 37 | 38 | mtx sync.Mutex 39 | } 40 | 41 | var ( 42 | _ proxyproviders.ProxyInterface = (*Proxy)(nil) 43 | 44 | ErrProxyPortNotFound = errors.New("proxy port not found") 45 | ) 46 | 47 | // Start method implements proxyconfig.Proxy Start method. 48 | func (p *Proxy) Start(ctx context.Context) error { 49 | var ( 50 | err error 51 | lc *local.Client 52 | ) 53 | 54 | if err = p.tsServer.Start(); err != nil { 55 | return err 56 | } 57 | 58 | if lc, err = p.tsServer.LocalClient(); err != nil { 59 | return err 60 | } 61 | 62 | p.mtx.Lock() 63 | p.ctx = ctx 64 | p.lc = lc 65 | p.mtx.Unlock() 66 | 67 | go p.watchStatus() 68 | 69 | return nil 70 | } 71 | 72 | func (p *Proxy) GetURL() string { 73 | // TODO: should be configurable and not force to https 74 | return "https://" + p.url 75 | } 76 | 77 | // Close method implements proxyconfig.Proxy Close method. 78 | func (p *Proxy) Close() error { 79 | if p.tsServer != nil { 80 | return p.tsServer.Close() 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func (p *Proxy) GetListener(port string) (net.Listener, error) { 87 | portCfg, ok := p.config.Ports[port] 88 | if !ok { 89 | return nil, ErrProxyPortNotFound 90 | } 91 | 92 | network := portCfg.ProxyProtocol 93 | if portCfg.ProxyProtocol == "http" || portCfg.ProxyProtocol == "https" { 94 | network = "tcp" 95 | } 96 | addr := ":" + strconv.Itoa(portCfg.ProxyPort) 97 | 98 | if portCfg.Tailscale.Funnel { 99 | return p.tsServer.ListenFunnel(network, addr) 100 | } 101 | if portCfg.ProxyProtocol == "https" { 102 | return p.tsServer.ListenTLS(network, addr) 103 | } 104 | return p.tsServer.Listen(network, addr) 105 | } 106 | 107 | func (p *Proxy) WatchEvents() chan model.ProxyEvent { 108 | return p.events 109 | } 110 | 111 | func (p *Proxy) GetAuthURL() string { 112 | return p.authURL 113 | } 114 | 115 | func (p *Proxy) Whois(r *http.Request) model.Whois { 116 | who, err := p.lc.WhoIs(r.Context(), r.RemoteAddr) 117 | if err != nil { 118 | return model.Whois{} 119 | } 120 | 121 | return model.Whois{ 122 | DisplayName: who.UserProfile.DisplayName, 123 | Username: who.UserProfile.LoginName, 124 | ID: who.UserProfile.ID.String(), 125 | ProfilePicURL: who.UserProfile.ProfilePicURL, 126 | } 127 | } 128 | 129 | func (p *Proxy) watchStatus() { 130 | watcher, err := p.lc.WatchIPNBus(p.ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys|ipn.NotifyInitialHealthState) 131 | if err != nil { 132 | p.log.Error().Err(err).Msg("tailscale.watchStatus") 133 | return 134 | } 135 | defer watcher.Close() 136 | 137 | for { 138 | n, err := watcher.Next() 139 | if err != nil { 140 | if !errors.Is(err, context.Canceled) { 141 | p.log.Error().Err(err).Msg("tailscale.watchStatus: Next") 142 | } 143 | return 144 | } 145 | 146 | if n.ErrMessage != nil { 147 | p.log.Error().Str("error", *n.ErrMessage).Msg("tailscale.watchStatus: backend") 148 | return 149 | } 150 | 151 | status, err := p.lc.Status(p.ctx) 152 | if err != nil && !errors.Is(err, net.ErrClosed) { 153 | p.log.Error().Err(err).Msg("tailscale.watchStatus: status") 154 | return 155 | } 156 | 157 | switch status.BackendState { 158 | case "NeedsLogin": 159 | if status.AuthURL != "" { 160 | p.setStatus(model.ProxyStatusAuthenticating, "", status.AuthURL) 161 | } 162 | case "Starting": 163 | p.setStatus(model.ProxyStatusStarting, "", "") 164 | case "Running": 165 | p.setStatus(model.ProxyStatusRunning, strings.TrimRight(status.Self.DNSName, "."), "") 166 | if p.status != model.ProxyStatusRunning { 167 | p.getTLSCertificates() 168 | } 169 | } 170 | } 171 | } 172 | 173 | func (p *Proxy) setStatus(status model.ProxyStatus, url string, authURL string) { 174 | if p.status == status && p.url == url && p.authURL == authURL { 175 | return 176 | } 177 | 178 | p.log.Debug().Str("authURL", url).Str("status", status.String()).Msg("tailscale status") 179 | 180 | p.mtx.Lock() 181 | p.status = status 182 | if url != "" { 183 | p.url = url 184 | } 185 | if authURL != "" { 186 | p.authURL = authURL 187 | } 188 | p.mtx.Unlock() 189 | 190 | p.events <- model.ProxyEvent{ 191 | Status: status, 192 | } 193 | } 194 | 195 | func (p *Proxy) getTLSCertificates() { 196 | p.log.Info().Msg("Generating TLS certificate") 197 | certDomains := p.tsServer.CertDomains() 198 | if _, _, err := p.lc.CertPair(p.ctx, certDomains[0]); err != nil { 199 | p.log.Error().Err(err).Msg("error to get TLS certificates") 200 | return 201 | } 202 | p.log.Info().Msg("TLS certificate generated") 203 | } 204 | -------------------------------------------------------------------------------- /internal/targetproviders/docker/autodetect.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package docker 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | "net/url" 10 | ) 11 | 12 | // tryConnectContainer method tries to connect to the container 13 | func (c *container) tryConnectContainer(scheme, internalPort, publishedPort string) (*url.URL, error) { 14 | hostname := c.hostname 15 | 16 | if internalPort != "" { 17 | // test connection with the container using docker networking 18 | // try connecting to internal ip and internal port 19 | port, err := c.tryInternalPort(scheme, hostname, internalPort) 20 | if err == nil { 21 | return port, nil 22 | } 23 | c.log.Debug().Err(err).Msg("Error connecting to internal port") 24 | } 25 | 26 | // try connecting to internal gateway and published port 27 | if publishedPort != "" { 28 | port, err := c.tryPublishedPort(scheme, publishedPort) 29 | if err == nil { 30 | return port, nil 31 | } 32 | c.log.Debug().Err(err).Msg("Error connecting to published port") 33 | } 34 | 35 | return nil, &NoValidTargetFoundError{containerName: c.name} 36 | } 37 | 38 | // tryInternalPort method tries to connect to the container internal ip and internal port 39 | func (c *container) tryInternalPort(scheme, hostname, port string) (*url.URL, error) { 40 | c.log.Debug().Str("hostname", hostname).Str("port", port).Msg("trying to connect to internal port") 41 | 42 | // if the container is running in host mode, 43 | // try connecting to defaultBridgeAddress of the host and internal port. 44 | if c.networkMode == "host" && c.defaultBridgeAddress != "" { 45 | if err := c.dial(c.defaultBridgeAddress, port); err == nil { 46 | c.log.Info().Str("address", c.defaultBridgeAddress).Str("port", port).Msg("Successfully connected using defaultBridgeAddress and internal port") 47 | return url.Parse(scheme + "://" + c.defaultBridgeAddress + ":" + port) 48 | } 49 | 50 | c.log.Debug().Str("address", c.defaultBridgeAddress).Str("port", port).Msg("Failed to connect") 51 | } 52 | 53 | for _, ipAddress := range c.ipAddress { 54 | // try connecting to container IP and internal port 55 | if err := c.dial(ipAddress, port); err == nil { 56 | c.log.Info().Str("address", ipAddress). 57 | Str("port", port).Msg("Successfully connected using internal ip and internal port") 58 | return url.Parse(scheme + "://" + ipAddress + ":" + port) 59 | } 60 | c.log.Debug().Str("address", ipAddress). 61 | Str("port", port).Msg("Failed to connect") 62 | } 63 | 64 | return nil, ErrNoValidTargetFoundForInternalPorts 65 | } 66 | 67 | // tryPublishedPort method tries to connect to the container internal ip and published port 68 | func (c *container) tryPublishedPort(scheme, port string) (*url.URL, error) { 69 | for _, gateway := range c.gateways { 70 | if err := c.dial(gateway, port); err == nil { 71 | c.log.Info().Str("address", gateway).Str("port", port).Msg("Successfully connected using docker network gateway and published port") 72 | return url.Parse(scheme + "://" + gateway + ":" + port) 73 | } 74 | 75 | c.log.Debug().Str("address", gateway).Str("port", port).Msg("Failed to connect using docker network gateway and published port") 76 | } 77 | 78 | return nil, ErrNoValidTargetFoundForPublishedPorts 79 | } 80 | 81 | // dial method tries to connect to a host and port 82 | func (c *container) dial(host, port string) error { 83 | address := net.JoinHostPort(host, port) 84 | conn, err := net.DialTimeout("tcp", address, dialTimeout) 85 | if err != nil { 86 | return fmt.Errorf("error dialing %s: %w", address, err) 87 | } 88 | conn.Close() 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /internal/targetproviders/docker/consts.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package docker 5 | 6 | import ( 7 | "time" 8 | ) 9 | 10 | const ( 11 | // Constants to be used in container labels 12 | LabelPrefix = "tsdproxy." 13 | LabelIsEnabled = LabelEnable + "=true" 14 | 15 | // Container config labels. 16 | LabelEnable = LabelPrefix + "enable" 17 | LabelName = LabelPrefix + "name" 18 | LabelContainerAccessLog = LabelPrefix + "containeraccesslog" 19 | LabelProxyProvider = LabelPrefix + "proxyprovider" 20 | LabelPort = LabelPrefix + "port." 21 | // Tailscale 22 | LabelEphemeral = LabelPrefix + "ephemeral" 23 | LabelRunWebClient = LabelPrefix + "runwebclient" 24 | LabelTsnetVerbose = LabelPrefix + "tsnet_verbose" 25 | LabelAuthKey = LabelPrefix + "authkey" 26 | LabelAuthKeyFile = LabelPrefix + "authkeyfile" 27 | LabelAutoDetect = LabelPrefix + "autodetect" 28 | LabelTags = LabelPrefix + "tags" 29 | // Legacy 30 | LabelContainerPort = LabelPrefix + "container_port" 31 | LabelScheme = LabelPrefix + "scheme" 32 | LabelTLSValidate = LabelPrefix + "tlsvalidate" 33 | // Legacy Tailscale 34 | LabelFunnel = LabelPrefix + "funnel" 35 | // Dashboard config labels 36 | LabelDashboardPrefix = LabelPrefix + "dash." 37 | LabelDashboardVisible = LabelDashboardPrefix + "visible" 38 | LabelDashboardLabel = LabelDashboardPrefix + "label" 39 | LabelDashboardIcon = LabelDashboardPrefix + "icon" 40 | 41 | // docker only defaults 42 | DefaultTargetScheme = "http" 43 | 44 | // auto detect 45 | dialTimeout = 2 * time.Second 46 | autoDetectTries = 5 47 | autoDetectSleep = 5 * time.Second 48 | 49 | // Port options 50 | PortOptionNoTLSValidate = "no_tlsvalidate" 51 | PortOptionTailscaleFunnel = "tailscale_funnel" 52 | ) 53 | -------------------------------------------------------------------------------- /internal/targetproviders/docker/errors.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package docker 5 | 6 | import ( 7 | "errors" 8 | ) 9 | 10 | type NoValidTargetFoundError struct { 11 | containerName string 12 | } 13 | 14 | func (n *NoValidTargetFoundError) Error() string { 15 | return "no valid target found for " + n.containerName 16 | } 17 | 18 | var ( 19 | ErrNoPortFoundInContainer = errors.New("no port found in container") 20 | ErrNoValidTargetFoundForInternalPorts = errors.New("no valid target found for internal ports") 21 | ErrNoValidTargetFoundForPublishedPorts = errors.New("no valid target found for exposed ports") 22 | ) 23 | -------------------------------------------------------------------------------- /internal/targetproviders/docker/legacy.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package docker 5 | 6 | import "github.com/almeidapaulopt/tsdproxy/internal/model" 7 | 8 | func (c *container) getLegacyPort() (model.PortConfig, error) { 9 | c.log.Trace().Msg("getLegacyPort") 10 | defer c.log.Trace().Msg("end getLegacyPort") 11 | 12 | cPort := c.getIntenalPortLegacy() 13 | 14 | cProtocol, hasProtocol := c.labels[LabelScheme] 15 | if !hasProtocol { 16 | cProtocol = "http" 17 | } 18 | 19 | port, err := model.NewPortLongLabel("443/https:" + cPort + "/" + cProtocol) 20 | if err != nil { 21 | return port, err 22 | } 23 | port.TLSValidate = c.getLabelBool(LabelTLSValidate, model.DefaultTLSValidate) 24 | port.Tailscale.Funnel = c.getLabelBool(LabelFunnel, model.DefaultTailscaleFunnel) 25 | 26 | port, err = c.generateTargetFromFirstTarget(port) 27 | if err != nil { 28 | return port, err 29 | } 30 | 31 | return port, nil 32 | } 33 | 34 | // getIntenalPortLegacy method returns the container internal port 35 | func (c *container) getIntenalPortLegacy() string { 36 | c.log.Trace().Msg("getIntenalPortLegacy") 37 | defer c.log.Trace().Msg("end getIntenalPortLegacy") 38 | 39 | // If Label is defined, get the container port 40 | if customContainerPort, ok := c.labels[LabelContainerPort]; ok { 41 | return customContainerPort 42 | } 43 | 44 | for p := range c.ports { 45 | return p 46 | } 47 | 48 | return "" 49 | } 50 | -------------------------------------------------------------------------------- /internal/targetproviders/docker/utils.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package docker 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // getLabelBool method returns a bool from a container label. 14 | func (c *container) getLabelBool(label string, defaultValue bool) bool { 15 | // Set default value 16 | value := defaultValue 17 | if valueString, ok := c.labels[label]; ok { 18 | valueBool, err := strconv.ParseBool(valueString) 19 | // set value only if no error 20 | // if error, keep default 21 | // 22 | if err == nil { 23 | value = valueBool 24 | } 25 | } 26 | return value 27 | } 28 | 29 | // getLabelString method returns a string from a container label. 30 | func (c *container) getLabelString(label string, defaultValue string) string { 31 | // Set default value 32 | value := defaultValue 33 | if valueString, ok := c.labels[label]; ok { 34 | value = valueString 35 | } 36 | 37 | return value 38 | } 39 | 40 | // getAuthKeyFromAuthFile method returns a auth key from a file. 41 | func (c *container) getAuthKeyFromAuthFile(authKey string) (string, error) { 42 | authKeyFile, ok := c.labels[LabelAuthKeyFile] 43 | if !ok || authKeyFile == "" { 44 | return authKey, nil 45 | } 46 | temp, err := os.ReadFile(authKeyFile) 47 | if err != nil { 48 | return "", fmt.Errorf("read auth key from file: %w", err) 49 | } 50 | return strings.TrimSpace(string(temp)), nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/targetproviders/targetproviders.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package targetproviders 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/almeidapaulopt/tsdproxy/internal/model" 10 | ) 11 | 12 | type ( 13 | // TargetProvider interface to be implemented by all target providers 14 | TargetProvider interface { 15 | WatchEvents(ctx context.Context, eventsChan chan TargetEvent, errChan chan error) 16 | GetDefaultProxyProviderName() string 17 | Close() 18 | AddTarget(id string) (*model.Config, error) 19 | DeleteProxy(id string) error 20 | } 21 | ) 22 | 23 | const ( 24 | ActionStartProxy ActionType = iota + 1 25 | ActionStopProxy 26 | ActionRestartProxy 27 | ActionStartProt 28 | ActionStopPrort 29 | ActionRestartPort 30 | ) 31 | 32 | type ( 33 | ActionType int 34 | 35 | TargetEvent struct { 36 | TargetProvider TargetProvider 37 | ID string 38 | Action ActionType 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /internal/ui/components/components.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package components 5 | 6 | func IconURL(name string) string { 7 | if name == "" { 8 | name = "tsdproxy" 9 | } 10 | return "/icons/" + name + ".svg" 11 | } 12 | -------------------------------------------------------------------------------- /internal/ui/layouts/layouts.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package layouts 5 | -------------------------------------------------------------------------------- /internal/ui/pages/pages.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package pages 5 | -------------------------------------------------------------------------------- /internal/ui/pages/proxylist.templ: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "github.com/almeidapaulopt/tsdproxy/internal/model" 5 | "github.com/almeidapaulopt/tsdproxy/internal/ui/components" 6 | "strings" 7 | ) 8 | 9 | type ProxyData struct { 10 | Enabled bool 11 | Name string 12 | Icon string 13 | URL string 14 | Label string 15 | ProxyStatus model.ProxyStatus 16 | Ports []model.PortConfig 17 | } 18 | 19 | type Port struct { 20 | ID string 21 | } 22 | 23 | templ Proxy(item ProxyData) { 24 |
-1" } 29 | > 30 |
31 | { 32 |
33 |
34 |

35 | 36 | 39 |

40 |
{ item.ProxyStatus.String() }
41 | 55 |
56 | 57 | 69 | 72 | 73 |
74 | } 75 | 76 | func modalname(name string) string { 77 | // javascript does not allow "-" in variable names 78 | temp := strings.ReplaceAll(name, "-", "_") 79 | return temp + "_modal" 80 | } 81 | -------------------------------------------------------------------------------- /internal/ui/static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/almeidapaulopt/tsdproxy/f4cfbb95440a55e6fad5621827010ce35c281d26/internal/ui/static/.keep -------------------------------------------------------------------------------- /internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package ui 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | 12 | "github.com/a-h/templ" 13 | datastar "github.com/starfederation/datastar/sdk/go" 14 | ) 15 | 16 | //go:generate templ generate 17 | 18 | func RenderTempl(w http.ResponseWriter, r *http.Request, cmp templ.Component) error { 19 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 20 | w.WriteHeader(http.StatusOK) 21 | 22 | err := cmp.Render(r.Context(), w) 23 | if err != nil { 24 | return fmt.Errorf("failed to render template: %w", err) 25 | } 26 | 27 | return err 28 | } 29 | 30 | func RenderNewSSE(w http.ResponseWriter, r *http.Request, cmp templ.Component) error { 31 | sse := datastar.NewSSE(w, r) 32 | return sse.MergeFragmentTempl(cmp) 33 | } 34 | 35 | func RenderSSE(_ http.ResponseWriter, r *http.Request, cmp templ.Component) { 36 | var buf bytes.Buffer 37 | 38 | writer := io.Writer(&buf) 39 | _ = cmp.Render(r.Context(), writer) 40 | } 41 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@tailwindcss/vite": "^4.1.7", 13 | "@vite-pwa/assets-generator": "^0.2.6", 14 | "daisyui": "^5.0.37", 15 | "tailwindcss": "^4.1.7", 16 | "vite": "^6.3.5", 17 | "vite-plugin-compression2": "^1.4.0", 18 | "vite-plugin-pwa": "^0.21.2" 19 | }, 20 | "dependencies": { 21 | "@mdi/svg": "^7.4.47", 22 | "@starfederation/datastar": "^1.0.0-beta.11", 23 | "simple-icons": "^14.14.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/almeidapaulopt/tsdproxy/f4cfbb95440a55e6fad5621827010ce35c281d26/web/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /web/public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/almeidapaulopt/tsdproxy/f4cfbb95440a55e6fad5621827010ce35c281d26/web/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /web/public/icons/tsdproxy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /web/public/tsdproxy.svg: -------------------------------------------------------------------------------- 1 | icons/tsdproxy.svg -------------------------------------------------------------------------------- /web/pwa-assets.config.js: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | minimal2023Preset as preset, 4 | } from '@vite-pwa/assets-generator/config' 5 | 6 | export default defineConfig({ 7 | headLinkOptions: { 8 | preset: 'all', 9 | }, 10 | preset, 11 | images: ['public/tsdproxy.svg'], 12 | }) 13 | -------------------------------------------------------------------------------- /web/scripts.js: -------------------------------------------------------------------------------- 1 | import { load } from "./node_modules/@starfederation/datastar/dist/bundles/datastar.js"; 2 | load(); 3 | 4 | 5 | window.sortList = function() { 6 | const list = document.getElementById("proxy-list"); 7 | if (!list) return; 8 | 9 | const items = [...list.children].sort((a, b) => { 10 | return a.id.localeCompare(b.id); 11 | }); 12 | 13 | items.forEach(item => list.appendChild(item)); 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /web/styles.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @source "../internal/**/*.templ"; 3 | @source "./index.html"; 4 | 5 | @variant dark (&:where(.tsdproxy-dark, .tsdproxy-dark *, [data-theme=tsdproxy-dark], [data-theme=tsdproxy-dark] *)); 6 | 7 | 8 | @plugin "daisyui" { 9 | themes: tsdproxy-light --default, tsdproxy-dark; 10 | include: reset, properties, scrollbar, rootscrolllock, rootscrollgutter, rootcolor, 11 | link, button, toggle, tooltip, card, card-body, badge, label, navbar, footer, menu, 12 | dropdown, checkbox, radius, modal, kbd, input; 13 | } 14 | 15 | @import "./tsdproxy-light.css"; 16 | @import "./tsdproxy-dark.css"; 17 | 18 | @layer base { 19 | 20 | html, 21 | body { 22 | @apply h-full; 23 | } 24 | } 25 | 26 | @layer components { 27 | #proxy-list { 28 | @apply flex flex-wrap gap-4 px-4 mt-8 sm:px-7; 29 | 30 | .proxy { 31 | 32 | @apply card card-side card-xs shadow-md bg-base-300 dark:bg-base-200 basis-2xs grow; 33 | 34 | figure { 35 | @apply size-20 p-4; 36 | } 37 | 38 | .card-title button { 39 | @apply m-2 p-2 btn badge badge-xs badge-info absolute right-0 top-0; 40 | 41 | img { 42 | @apply size-[1em]; 43 | } 44 | } 45 | 46 | .status { 47 | @apply badge badge-warning badge-xs; 48 | 49 | &.Authenticating { 50 | @apply badge-info; 51 | } 52 | 53 | &.Running { 54 | @apply badge-success; 55 | } 56 | 57 | &.Error, 58 | &.Stopping, 59 | &.Stopped { 60 | @apply badge-error; 61 | } 62 | } 63 | 64 | .openbtn { 65 | @apply card-actions justify-end absolute right-2 bottom-2; 66 | 67 | a { 68 | @apply btn btn-primary btn-sm; 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /web/tsdproxy-dark.css: -------------------------------------------------------------------------------- 1 | @plugin "daisyui/theme" { 2 | name: "tsdproxy-dark"; 3 | default: false; 4 | prefersdark: false; 5 | color-scheme: "dark"; 6 | --color-base-100: oklch(27% 0.033 256.848); 7 | --color-base-200: oklch(37% 0.034 259.733); 8 | --color-base-300: oklch(21.15% 0.012 254.09); 9 | --color-base-content: oklch(97.807% 0.029 256.847); 10 | --color-primary: oklch(58% 0.233 277.117); 11 | --color-primary-content: oklch(96% 0.018 272.314); 12 | --color-secondary: oklch(52% 0.223 3.958); 13 | --color-secondary-content: oklch(94% 0.028 342.258); 14 | --color-accent: oklch(77% 0.152 181.912); 15 | --color-accent-content: oklch(38% 0.063 188.416); 16 | --color-neutral: oklch(14% 0.005 285.823); 17 | --color-neutral-content: oklch(92% 0.004 286.32); 18 | --color-info: oklch(74% 0.16 232.661); 19 | --color-info-content: oklch(29% 0.066 243.157); 20 | --color-success: oklch(76% 0.177 163.223); 21 | --color-success-content: oklch(37% 0.077 168.94); 22 | --color-warning: oklch(82% 0.189 84.429); 23 | --color-warning-content: oklch(41% 0.112 45.904); 24 | --color-error: oklch(71% 0.194 13.428); 25 | --color-error-content: oklch(27% 0.105 12.094); 26 | --radius-selector: 0.5rem; 27 | --radius-field: 0.25rem; 28 | --radius-box: 0.5rem; 29 | --size-selector: 0.25rem; 30 | --size-field: 0.25rem; 31 | --border: 0.5px; 32 | --depth: 0; 33 | --noise: 0; 34 | } 35 | -------------------------------------------------------------------------------- /web/tsdproxy-light.css: -------------------------------------------------------------------------------- 1 | @plugin "daisyui/theme" { 2 | name: "tsdproxy-light"; 3 | default: true; 4 | prefersdark: false; 5 | color-scheme: "light"; 6 | --color-base-100: oklch(100% 0 0); 7 | --color-base-200: oklch(92% 0.004 286.32); 8 | --color-base-300: oklch(86% 0 0); 9 | --color-base-content: oklch(35.519% 0.032 262.988); 10 | --color-primary: oklch(61.302% 0.202 261.294); 11 | --color-primary-content: oklch(100% 0 0); 12 | --color-secondary: oklch(76.662% 0.135 153.45); 13 | --color-secondary-content: oklch(33.387% 0.04 162.24); 14 | --color-accent: oklch(72.772% 0.149 33.2); 15 | --color-accent-content: oklch(0% 0 0); 16 | --color-neutral: oklch(35.519% 0.032 262.988); 17 | --color-neutral-content: oklch(98.462% 0.001 247.838); 18 | --color-info: oklch(72.06% 0.191 231.6); 19 | --color-info-content: oklch(0% 0 0); 20 | --color-success: oklch(64.8% 0.15 160); 21 | --color-success-content: oklch(0% 0 0); 22 | --color-warning: oklch(84.71% 0.199 83.87); 23 | --color-warning-content: oklch(0% 0 0); 24 | --color-error: oklch(71.76% 0.221 22.18); 25 | --color-error-content: oklch(0% 0 0); 26 | --radius-selector: 0.5rem; 27 | --radius-field: 0.25rem; 28 | --radius-box: 0.5rem; 29 | --size-selector: 0.25rem; 30 | --size-field: 0.25rem; 31 | --border: 1px; 32 | --depth: 0; 33 | --noise: 0; 34 | } 35 | -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from `vite`; 2 | import { cpSync, mkdirSync, existsSync } from 'fs'; 3 | import { resolve } from 'path'; 4 | import tailwindcss from '@tailwindcss/vite'; 5 | import { compression } from 'vite-plugin-compression2'; 6 | import { VitePWA } from 'vite-plugin-pwa' 7 | 8 | 9 | function copyIconsToPublic() { 10 | let isBuild = false; 11 | 12 | return { 13 | name: 'copy-icons-to-public', 14 | 15 | config(config, { command }) { 16 | isBuild = command === 'build'; 17 | }, 18 | 19 | buildStart() { 20 | if (!isBuild) return; 21 | 22 | const targets = [ 23 | { 24 | src: resolve(__dirname, 'node_modules/simple-icons/icons'), 25 | dest: resolve(__dirname, 'public/icons/si'), 26 | }, 27 | { 28 | src: resolve(__dirname, 'node_modules/@mdi/svg/svg'), 29 | dest: resolve(__dirname, 'public/icons/mdi'), 30 | }, 31 | ]; 32 | 33 | for (const { src, dest } of targets) { 34 | if (!existsSync(dest)) { 35 | mkdirSync(dest, { recursive: true }); 36 | } 37 | cpSync(src, dest, { recursive: true }); 38 | } 39 | } 40 | }; 41 | } 42 | 43 | 44 | export default defineConfig({ 45 | 46 | server: { 47 | proxy: { 48 | '/list': 'http://localhost:8080', 49 | '/stream': 'http://localhost:8080' 50 | } 51 | }, 52 | plugins: [ 53 | copyIconsToPublic(), 54 | 55 | compression({ 56 | filter: /\.(js|css|html|svg|ico|json|txt|woff2?|ttf)$/, 57 | }), 58 | compression({ 59 | algorithm: 'brotliCompress', 60 | filter: /\.(js|css|html|svg|ico|json|txt|woff2?|ttf)$/, 61 | }), 62 | 63 | tailwindcss(), 64 | 65 | VitePWA({ 66 | registerType: 'autoUpdate', 67 | injectRegister: false, 68 | 69 | pwaAssets: { 70 | disabled: false, 71 | config: true, 72 | }, 73 | 74 | manifest: { 75 | name: 'TSDProxy', 76 | short_name: 'TSDProxy', 77 | description: 'TSDProxy', 78 | theme_color: '#ffffff', 79 | }, 80 | 81 | workbox: { 82 | globPatterns: ['**/*.{js,css,html,ico}'], 83 | cleanupOutdatedCaches: true, 84 | clientsClaim: true, 85 | }, 86 | 87 | devOptions: { 88 | enabled: false, 89 | navigateFallback: 'index.html', 90 | suppressWarnings: true, 91 | type: 'module', 92 | }, 93 | }), 94 | ], 95 | 96 | build: { 97 | rollupOptions: { 98 | output: { 99 | entryFileNames: `[name]-[hash].js`, 100 | chunkFileNames: `[name]-[hash].js`, 101 | assetFileNames: `[name]-[hash].[ext]` 102 | } 103 | } 104 | } 105 | }); 106 | -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Paulo Almeida 2 | // SPDX-License-Identifier: MIT 3 | 4 | package web 5 | 6 | import ( 7 | "embed" 8 | "io/fs" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/rs/zerolog/log" 13 | "github.com/vearutop/statigz" 14 | "github.com/vearutop/statigz/brotli" 15 | ) 16 | 17 | //go:generate wget -nc https://github.com/selfhst/icons/archive/refs/heads/main.zip 18 | //go:generate unzip -jo main.zip icons-main/svg/* -d public/icons/sh 19 | //go:generate bun run build 20 | 21 | //go:embed dist 22 | var dist embed.FS 23 | 24 | var Static http.Handler 25 | 26 | const DefaultIcon = "tsdproxy" 27 | 28 | func init() { 29 | staticFS, err := fs.Sub(dist, "dist") 30 | if err != nil { 31 | log.Fatal().Err(err).Msg("Failed to open dist directory") 32 | } 33 | 34 | Static = statigz.FileServer(staticFS.(fs.ReadDirFS), brotli.AddEncoding) 35 | } 36 | 37 | func GuessIcon(name string) string { 38 | nameParts := strings.Split(name, "/") 39 | lastPart := nameParts[len(nameParts)-1] 40 | baseName := strings.SplitN(lastPart, ":", 2)[0] //nolint 41 | baseName = strings.SplitN(baseName, "@", 2)[0] //nolint 42 | 43 | var foundFile string 44 | err := fs.WalkDir(dist, ".", func(path string, d fs.DirEntry, err error) error { 45 | if err != nil { 46 | return err 47 | } 48 | if !d.IsDir() && strings.HasSuffix(d.Name(), ".svg") { 49 | if strings.TrimSuffix(d.Name(), ".svg") == baseName { 50 | foundFile = path 51 | return fs.SkipDir 52 | } 53 | } 54 | return nil 55 | }) 56 | if err != nil || foundFile == "" { 57 | return DefaultIcon 58 | } 59 | icon := strings.TrimPrefix(foundFile, "dist/icons/") 60 | return strings.TrimSuffix(icon, ".svg") 61 | } 62 | --------------------------------------------------------------------------------