[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: owenthereal\nopen_collective: upterm\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/workflows/build-and-release.yaml",
    "content": "name: Build and Release\n\non:\n  workflow_call:\n    inputs:\n      snapshot:\n        description: 'Build snapshot (no publishing)'\n        required: false\n        default: true\n        type: boolean\n      docker_repo:\n        description: 'Docker repository'\n        required: false\n        default: 'ghcr.io/owenthereal/upterm/uptermd'\n        type: string\n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Set up Docker QEMU\n        uses: docker/setup-qemu-action@v4\n        with:\n          platforms: 'amd64,arm64,ppc64le,s390x'\n\n      - name: Login to ghcr.io\n        if: ${{ !inputs.snapshot }}\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GH_TOKEN }}\n\n      - name: Run GoReleaser (Snapshot)\n        if: ${{ inputs.snapshot }}\n        uses: goreleaser/goreleaser-action@v7\n        with:\n          distribution: goreleaser\n          version: '~> v2'\n          args: release --clean --snapshot --skip=publish\n        env:\n          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}\n          DOCKER_REPO: ${{ inputs.docker_repo }}\n\n      - name: Run GoReleaser (Release)\n        if: ${{ !inputs.snapshot }}\n        uses: goreleaser/goreleaser-action@v7\n        with:\n          distribution: goreleaser\n          version: '~> v2'\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}\n          DOCKER_REPO: ${{ inputs.docker_repo }}\n"
  },
  {
    "path": ".github/workflows/build.yaml",
    "content": "name: Build\non:\n  push:\n    branches:\n      - master\n  pull_request:\npermissions:\n  contents: write\n  packages: write\njobs:\n  build:\n    name: Compile\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [macos-latest, ubuntu-latest, windows-latest]\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n      - name: Compile\n        run: make install\n  test-macos:\n    name: Test (macOS)\n    runs-on: macos-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n      - name: Test\n        run: make test\n        env:\n          BASH_SILENCE_DEPRECATION_WARNING: 1\n          MUTE_FLAKY_TESTS: 1\n\n  test-ubuntu:\n    name: Test (Ubuntu + Consul)\n    runs-on: ubuntu-latest\n    services:\n      consul:\n        image: consul:1.15\n        ports:\n          - 8500:8500\n        options: >-\n          --health-cmd \"consul members\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n      - name: Test\n        run: make test\n        env:\n          BASH_SILENCE_DEPRECATION_WARNING: 1\n          MUTE_FLAKY_TESTS: 1\n\n  test-windows:\n    name: Test (Windows)\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n      - name: Test\n        run: make test\n        env:\n          MUTE_FLAKY_TESTS: 1\n  vet:\n    name: Vet\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n      - name: Vet\n        run: make vet\n\n  test-e2e:\n    name: E2E Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n\n      - name: Install tmux\n        run: sudo apt-get update && sudo apt-get install -y tmux\n\n      - name: Build upterm\n        run: make install\n\n      - name: Generate SSH host keys\n        run: |\n          mkdir -p /tmp/uptermd\n          ssh-keygen -t ed25519 -f /tmp/uptermd/id_ed25519 -N \"\"\n\n      - name: Build and start uptermd\n        run: |\n          go build -o /tmp/uptermd/uptermd ./cmd/uptermd\n          /tmp/uptermd/uptermd --ssh-addr 127.0.0.1:2222 --private-key /tmp/uptermd/id_ed25519 > /tmp/uptermd/uptermd.log 2>&1 &\n          echo \"Waiting for uptermd to start...\"\n          for i in $(seq 1 30); do\n            if nc -z 127.0.0.1 2222 2>/dev/null; then\n              echo \"uptermd is ready\"\n              break\n            fi\n            if [ $i -eq 30 ]; then\n              echo \"uptermd failed to start\"\n              cat /tmp/uptermd/uptermd.log\n              exit 1\n            fi\n            sleep 1\n          done\n\n      - name: Run E2E Tests\n        run: make test-e2e\n        env:\n          UPTERM_E2E_SERVER: ssh://127.0.0.1:2222\n\n      - name: Cleanup uptermd\n        if: always()\n        run: pkill uptermd || true\n\n  build-and-release:\n    name: Build and Release (Snapshot)\n    uses: ./.github/workflows/build-and-release.yaml\n    with:\n      snapshot: true\n      docker_repo: ghcr.io/owenthereal/upterm/uptermd\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ master ]\n  schedule:\n    - cron: '26 15 * * 1'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'go' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]\n        # Learn more about CodeQL language support at https://git.io/codeql-language-support\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v6\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v4\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        # queries: ./path/to/local/query, your-org/your-repo/queries@main\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v4\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 https://git.io/JvXDl\n\n    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines\n    #    and modify them (or add more) to build your code if your project\n    #    uses a compiled language\n\n    #- run: |\n    #   make bootstrap\n    #   make release\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v4\n"
  },
  {
    "path": ".github/workflows/e2e.yaml",
    "content": "name: E2E Tests\n\non:\n  workflow_dispatch:\n    inputs:\n      uptermd_url:\n        description: 'Uptermd server URL (e.g., ssh://uptermd.upterm.dev:22)'\n        required: true\n        default: 'ssh://uptermd.upterm.dev:22'\n\njobs:\n  e2e:\n    name: E2E Tests\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: go.mod\n          check-latest: true\n\n      - name: Install tmux\n        run: sudo apt-get update && sudo apt-get install -y tmux\n\n      - name: Install upterm (latest release)\n        run: |\n          mkdir -p /tmp/upterm-release\n          curl -sL https://github.com/owenthereal/upterm/releases/latest/download/upterm_linux_amd64.tar.gz | tar xz -C /tmp/upterm-release\n          sudo mv /tmp/upterm-release/upterm /usr/local/bin/\n\n      - name: Run E2E Tests\n        run: make test-e2e\n        env:\n          UPTERM_E2E_SERVER: ${{ inputs.uptermd_url }}\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: Release\non:\n  push:\n    tags:\n      - 'v*'\npermissions:\n  contents: write\n  packages: write\njobs:\n  build-and-release:\n    name: Release binaries and Docker images\n    uses: ./.github/workflows/build-and-release.yaml\n    with:\n      snapshot: false\n      docker_repo: ghcr.io/owenthereal/upterm/uptermd\n    secrets: inherit\n  deploy:\n    name: Deploy app\n    runs-on: ubuntu-latest\n    needs: [build-and-release]\n    steps:\n      - uses: actions/checkout@v6\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Get version from tag\n        id: version\n        run: |\n          VERSION=${GITHUB_REF#refs/tags/v}\n          echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n          echo \"git_commit=$GITHUB_SHA\" >> $GITHUB_OUTPUT\n          echo \"build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')\" >> $GITHUB_OUTPUT\n      - name: Deploy to Fly.io\n        run: |\n          flyctl deploy --remote-only \\\n            --build-arg VERSION=${{ steps.version.outputs.version }} \\\n            --build-arg GIT_COMMIT=${{ steps.version.outputs.git_commit }} \\\n            --build-arg BUILD_DATE=${{ steps.version.outputs.build_date }}\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "build\nc.out\nrelease\n.terraform\n*.tfstate\n*.tfstate.backup\ndist\nbin\nCLAUDE.md\n.claude/\n*.exe\n*.exe~\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "version: 2\nbuilds:\n  - id: upterm\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - darwin\n      - windows\n    goarch:\n      - \"386\"\n      - \"amd64\"\n      - \"arm\"\n      - \"arm64\"\n      - \"ppc64le\"\n      - \"s390x\"\n    main: ./cmd/upterm\n    ldflags:\n      - -s -w -X github.com/owenthereal/upterm/internal/version.Version={{.Version}} -X github.com/owenthereal/upterm/internal/version.GitCommit={{.Commit}} -X github.com/owenthereal/upterm/internal/version.Date={{.CommitDate}}\n  - id: uptermd\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n    goarch:\n      - \"amd64\"\n      - \"arm64\"\n      - \"ppc64le\"\n      - \"s390x\"\n    main: ./cmd/uptermd\n    binary: uptermd\n    ldflags:\n      - -s -w -X github.com/owenthereal/upterm/internal/version.Version={{.Version}} -X github.com/owenthereal/upterm/internal/version.GitCommit={{.Commit}} -X github.com/owenthereal/upterm/internal/version.Date={{.CommitDate}}\narchives:\n  - id: upterm\n    name_template: '{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}'\n    wrap_in_directory: false\n    ids:\n      - upterm\n    files:\n      - LICENSE*\n      - README*\n      - etc/*\n      - docs/*\n  - id: uptermd\n    name_template: '{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}'\n    wrap_in_directory: false\n    ids:\n      - uptermd\n    files:\n      - LICENSE*\n      - README*\nhomebrew_casks:\n  - repository:\n      owner: owenthereal\n      name: homebrew-upterm\n    commit_author:\n      name: Owen Ou\n      email: o@owenou.com\n    homepage: https://upterm.dev\n    description: Instant Terminal Sharing\n    directory: Casks\n    ids:\n      - upterm\n    license: \"Apache 2.0\"\n    manpages:\n      - \"etc/man/man1/upterm.1\"\n      - \"etc/man/man1/upterm-host.1\"\n      - \"etc/man/man1/upterm-proxy.1\"\n      - \"etc/man/man1/upterm-session.1\"\n      - \"etc/man/man1/upterm-session-current.1\"\n      - \"etc/man/man1/upterm-session-info.1\"\n      - \"etc/man/man1/upterm-session-list.1\"\n      - \"etc/man/man1/upterm-upgrade.1\"\n      - \"etc/man/man1/upterm-version.1\"\n    completions:\n      bash: \"etc/completion/upterm.bash_completion.sh\"\n      zsh: \"etc/completion/upterm.zsh_completion\"\n    hooks:\n      post:\n        install: |\n          if OS.mac?\n            system_command \"/usr/bin/xattr\", args: [\"-dr\", \"com.apple.quarantine\", \"#{staged_path}/upterm\"]\n          end\nscoops:\n  - repository:\n      owner: owenthereal\n      name: scoop-upterm\n    commit_author:\n      name: Owen Ou\n      email: o@owenou.com\n    homepage: https://upterm.dev\n    description: Instant Terminal Sharing\n    license: Apache-2.0\n    ids:\n      - upterm\ndockers_v2:\n  - dockerfile: Dockerfile.uptermd\n    ids:\n      - uptermd\n    images:\n      - \"{{ .Env.DOCKER_REPO }}\"\n    tags:\n      - \"{{ .Tag }}\"\n      - latest\n    platforms:\n      - linux/amd64\n      - linux/arm64\n      - linux/ppc64le\n      - linux/s390x\n    flags:\n      - \"--target=pre-built-binary\"\n    labels:\n      \"org.opencontainers.image.title\": \"{{ .ProjectName }}\"\n      \"org.opencontainers.image.description\": \"Upterm server daemon\"\n      \"org.opencontainers.image.url\": \"https://github.com/owenthereal/upterm\"\n      \"org.opencontainers.image.source\": \"https://github.com/owenthereal/upterm\"\n      \"org.opencontainers.image.version\": \"{{ .Version }}\"\n      \"org.opencontainers.image.created\": '{{ time \"2006-01-02T15:04:05Z07:00\" }}'\n      \"org.opencontainers.image.revision\": \"{{ .FullCommit }}\"\n      \"org.opencontainers.image.licenses\": \"Apache-2.0\"\n    extra_files:\n      - go.mod\n      - go.sum\nchecksum:\n  name_template: \"checksums.txt\"\nsnapshot:\n  version_template: \"{{ incpatch .Version }}-snapshot\"\nrelease:\n  prerelease: auto\n  name_template: \"Upterm {{.Version}}\"\n  mode: append\nchangelog:\n  sort: asc\n  use: github\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^script:\"\n      - \"^go.mod:\"\n      - \"^.github:\"\n      - Merge branch\nnfpms: #build:linux\n  - license: Apache-2.0\n    maintainer: Owen Ou <o@owenou.com>\n    ids:\n      - upterm\n    homepage: https://github.com/owenthereal/upterm\n    bindir: /usr/bin\n    description: Instant Terminal Sharing\n    file_name_template: '{{ .PackageName }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 \"v1\") }}{{ .Amd64 }}{{ end }}'\n    formats:\n      - deb\n      - rpm\n    contents:\n      - src: \"./etc/man/man1/upterm*.1\"\n        dst: \"/usr/share/man/man1\"\n      - src: \"./etc/completion/upterm.bash_completion.sh\"\n        dst: \"/usr/share/bash-completion/completions/upterm\"\n      - src: \"./etc/completion/upterm.zsh_completion\"\n        dst: \"/usr/share/zsh/site-functions/_upterm\"\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nWhen contributing to this repository, please first discuss the change you wish to make via issue,\nemail, or any other method with the owners of this repository before making a change. \n\nPlease note we have a code of conduct, please follow it in all your interactions with the project.\n\n## Pull Request Process\n\n1. Ensure any install or build dependencies are removed before the end of the layer when doing a \n   build.\n2. Update the README.md with details of changes to the interface, this includes new environment \n   variables, exposed ports, useful file locations and container parameters.\n3. Increase the version numbers in any examples files and the README.md to the new version that this\n   Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).\n4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you \n   do not have permission to do that, you may request the second reviewer to merge it for you.\n\n## Code of Conduct\n\n### Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, gender identity and expression, level of experience,\nnationality, personal appearance, race, religion, or sexual identity and\norientation.\n\n### Our Standards\n\nExamples of behavior that contributes to creating a positive environment\ninclude:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Focusing on what is best for the community\n* Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or\nadvances\n* Trolling, insulting/derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n### Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehavior and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviors that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n### Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n### Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported by contacting the project team at [INSERT EMAIL ADDRESS]. All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n### Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at [http://contributor-covenant.org/version/1/4][version]\n\n[homepage]: http://contributor-covenant.org\n[version]: http://contributor-covenant.org/version/1/4/\n"
  },
  {
    "path": "Dockerfile.uptermd",
    "content": "# syntax=docker/dockerfile:1\n\n# Build stage - builds from source (used by Fly deployment)\nFROM golang:latest AS builder\n\nARG TARGETOS\nARG TARGETARCH\nARG VERSION=0.0.0+dev\nARG GIT_COMMIT=unknown\nARG BUILD_DATE=unknown\n\nWORKDIR /src\nENV CGO_ENABLED=0\nRUN --mount=target=. \\\n  --mount=type=cache,target=/root/.cache/go-build \\\n  --mount=type=cache,target=/go/pkg \\\n  GOOS=$TARGETOS GOARCH=$TARGETARCH go install \\\n  -ldflags=\"-s -w -X github.com/owenthereal/upterm/internal/version.Version=${VERSION} -X github.com/owenthereal/upterm/internal/version.GitCommit=${GIT_COMMIT} -X github.com/owenthereal/upterm/internal/version.Date=${BUILD_DATE}\" \\\n  ./cmd/...\n\n# Base runtime stage\nFROM gcr.io/distroless/static:nonroot AS base\n\nWORKDIR /app\nENV PATH=\"/app:${PATH}\"\n\n# sshd ws & prometheus\nEXPOSE 2222 8080 9090\n\n# Fly deployment stage (builds from source)\nFROM base AS uptermd-fly\nCOPY --from=builder /go/bin/uptermd /go/bin/uptermd-fly /app/\nENTRYPOINT [\"uptermd-fly\"]\n\n# Pre-built binary stage (used by GoReleaser)\nFROM base AS pre-built-binary\nARG TARGETPLATFORM\nCOPY ${TARGETPLATFORM}/uptermd /app/\nENTRYPOINT [\"uptermd\"]\n\n# Default stage\nFROM base AS uptermd\nCOPY --from=builder /go/bin/uptermd /app/\nENTRYPOINT [\"uptermd\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "Makefile",
    "content": "SHELL=/bin/bash -o pipefail\n\nBIN_DIR ?= $(CURDIR)/bin\nexport PATH := $(BIN_DIR):$(PATH)\n\n.PHONY: tools\ntools:\n\trm -rf $(BIN_DIR) && mkdir -p $(BIN_DIR)\n\t# goreleaser\n\tGOBIN=$(BIN_DIR) go install github.com/goreleaser/goreleaser@latest\n\n.PHONY: generate\ngenerate: proto\n\n.PHONY: docs\ndocs:\n\trm -rf docs && mkdir docs\n\trm -rf etc && mkdir -p etc/man/man1 && mkdir -p etc/completion\n\tXDG_STATE_HOME=/home/user/.local/state XDG_CONFIG_HOME=/home/user/.config XDG_RUNTIME_DIR=/run/user/1000 go run cmd/gendoc/main.go\n\n.PHONY: proto\nproto:\n\tdocker run -v $(CURDIR)/server:/defs namely/protoc-all -f server.proto -l go --go-source-relative -o .\n\tdocker run -v $(CURDIR)/host/api:/defs namely/protoc-all -f api.proto -l go --go-source-relative -o .\n\n.PHONY: build\nbuild:\n\tgo build -o $(BIN_DIR)/upterm ./cmd/upterm\n\tgo build -o $(BIN_DIR)/uptermd ./cmd/uptermd\n\tgo build -o $(BIN_DIR)/uptermd-fly ./cmd/uptermd-fly\n\n.PHONY: install\ninstall:\n\tgo install ./cmd/...\n\nTAG ?= latest\nREPO ?= ghcr.io/owenthereal/upterm/uptermd\nDOCKER_BUILD_FLAGS ?= --load\n.PHONY: docker_build\ndocker_build:\n\tdocker buildx build -t $(REPO):$(TAG) -f Dockerfile.uptermd $(DOCKER_BUILD_FLAGS) .\n\nGO_TEST_FLAGS ?= \"\"\n.PHONY: test\ntest:\n\tgo test $$(go list ./... | grep -v /e2e) -timeout=180s -coverprofile=c.out -covermode=atomic -count=1 -race -v $(GO_TEST_FLAGS)\n\n# E2E tests require tmux and UPTERM_E2E_SERVER env var\n# Example: UPTERM_E2E_SERVER=ssh://uptermd.upterm.dev:22 make test-e2e\n.PHONY: test-e2e\ntest-e2e:\n\tgo test ./internal/e2e/... -timeout=180s -count=1 -v $(GO_TEST_FLAGS)\n\n.PHONY: vet\nvet:\n\tdocker run --rm -v $(CURDIR):/app:z -w /app golangci/golangci-lint:latest golangci-lint run -v --timeout 15m --fix\n\nDOCKER_REPO ?= ghcr.io/owenthereal/upterm/uptermd\n.PHONY: goreleaser\ngoreleaser:\n\tDOCKER_REPO=$(DOCKER_REPO) goreleaser release --clean --snapshot --skip=publish\n"
  },
  {
    "path": "Procfile",
    "content": "web: bin/uptermd --ssh-addr 0.0.0.0:2222 --ws-addr 0.0.0.0:$PORT --node-addr ${HEROKU_PRIVATE_IP:-0.0.0.0}:2222 --network mem\n"
  },
  {
    "path": "README.md",
    "content": "# Upterm\n\n[Upterm](https://github.com/owenthereal/upterm) is an open-source tool enabling developers to share terminal sessions securely over the web. It’s perfect for remote pair programming, accessing computers behind NATs/firewalls, remote debugging, and more.\n\nThis is a [blog post](https://owenou.com/upterm) to describe Upterm in depth.\n\n## :movie_camera: Quick Demo\n\n[![demo](https://raw.githubusercontent.com/owenthereal/upterm/gh-pages/demo.gif)](https://asciinema.org/a/efeKPxxzKi3pkyu9LWs1yqdbB)\n\n## :rocket: Getting Started\n\n## Installation\n\n### Mac\n\n```console\nbrew install --cask owenthereal/upterm/upterm\n```\n\n#### Migrating from Formula to Cask\n\nIf you previously installed upterm using the Homebrew formula (without `--cask`), you'll need to migrate to the Cask version:\n\n```console\n# Uninstall the old formula version\nbrew uninstall upterm\n\n# Install the new Cask version\nbrew install --cask owenthereal/upterm/upterm\n```\n\n**Note:** Running `brew upgrade` with the old formula installed will fail with an error. Follow the migration steps above to resolve this.\n\n### Windows\n\n```powershell\nscoop bucket add upterm https://github.com/owenthereal/scoop-upterm\nscoop install upterm\n```\n\n### Standalone\n\n`upterm` can be easily installed as an executable. Download the latest [compiled binaries](https://github.com/owenthereal/upterm/releases) and put it in your executable path.\n\n### From source\n\n```console\ngit clone https://github.com/owenthereal/upterm.git\ncd upterm\ngo install ./cmd/upterm/...\n```\n\n## :wrench: Basic Usage\n\n1. Host starts a terminal session:\n\n   ```console\n   upterm host\n   ```\n\n1. Host retrieves and shares the SSH connection string:\n\n   ```console\n   upterm session current\n   ```\n\n1. Client connects using the shared string:\n\n   ```console\n   ssh TOKEN@uptermd.upterm.dev\n   ```\n\n## :blue_book: Quick Reference\n\nDive into more commands and advanced usage in the [documentation](docs/upterm.md).\nBelow are some notable highlights:\n\n### Command Execution\n\nHost a session with any desired command:\n\n```console\nupterm host -- docker run --rm -ti ubuntu bash\n```\n\n### Access Control\n\nHost a session with specified client public key(s) authorized to connect:\n\n```console\nupterm host --authorized-keys PATH_TO_PUBLIC_KEY\n```\n\nAuthorize specified GitHub, GitLab, SourceHut, Codeberg users with their corresponding public keys:\n\n```console\nupterm host --github-user username\nupterm host --gitlab-user username\nupterm host --srht-user username\nupterm host --codeberg-user username\n```\n\n### Force command\n\nHost a session initiating `tmux new -t pair-programming`, while ensuring clients join with `tmux attach -t pair-programming`.\nThis mirrors functionality provided by tmate:\n\n```console\nupterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming\n```\n\n### File Transfer (SFTP/SCP)\n\nClients can transfer files using standard `scp` or `sftp` commands. The connection details are shown when running `upterm session current`:\n\n```console\n# Download a file from host\nscp -P PORT USER@HOST:/path/to/file.txt ./local/\n\n# Upload a file to host\nscp -P PORT ./local/file.txt USER@HOST:/path/to/destination/\n```\n\n**Security model:**\n\n- File transfers have the same access as the terminal session (clients can already access any file via the shell)\n- Without `--accept`, each file operation prompts the host for approval via a dialog\n- Use `--read-only` to restrict SFTP to downloads only (no uploads, deletes, or modifications)\n- Use `--no-sftp` to disable file transfers entirely\n\n### Local TCP Forwarding\n\nClients can use standard SSH local forwarding through a hosted session when the host opts in:\n\n```console\nupterm host --allow-local-tcp-forwarding\nssh -L 5555:127.0.0.1:8080 SESSION_SSH_USER@uptermd.upterm.dev\n```\n\n### WebSocket Connection\n\nIn scenarios where your host restricts ssh transport, establish a connection to `uptermd.upterm.dev` (or your self-hosted server) via WebSocket:\n\n```console\nupterm host --server wss://uptermd.upterm.dev -- bash\n```\n\nClients can connect to the host session via WebSocket as well:\n\n```console\nssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN@uptermd.upterm.dev:443\n```\n\n### Debug GitHub Actions\n\n`upterm` can be integrated with GitHub Actions to enable real-time SSH debugging, allowing you to interact directly with the runner system during workflow execution. This is achieved through [action-upterm](https://github.com/owenthereal/action-upterm), which sets up an `upterm` session within your CI pipeline.\n\nTo get started, include `action-upterm` in your GitHub Actions workflow as follows:\n\n```yaml\nname: CI\non: [push]\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v2\n    - name: Setup upterm session\n      uses: owenthereal/action-upterm@v1\n```\n\nThis setup allows you to SSH into the workflow runner whenever you need to troubleshoot or inspect the execution environment. Find the SSH connection string in the `Checks` tab of your Pull Request or in the workflow logs.\n\nFor comprehensive details on configuring and using this integration, visit the [action-upterm GitHub repo](https://github.com/owenthereal/action-upterm).\n\n## :bulb: Tips\n\n### Resolving Tmux Session Display Issue\n\n**Issue**: The command `upterm session current` does not display the current session when used within Tmux.\n\n**Cause**: This occurs because `upterm session current` requires the `UPTERM_ADMIN_SOCKET` environment variable, which is set in the specified command. Tmux, however, does not carry over environment variables not on its default list to any Tmux session unless instructed to do so ([Reference](http://man.openbsd.org/i386/tmux.1#GLOBAL_AND_SESSION_ENVIRONMENT)).\n\n**Solution**: To rectify this, add the following line to your `~/.tmux.conf`:\n\n```conf\nset-option -ga update-environment \" UPTERM_ADMIN_SOCKET\"\n```\n\n### Identifying Upterm Session\n\n**Issue**: It might be unclear whether your shell command is running in an upterm session, especially with common shell commands like `bash` or `zsh`.\n\n**Solution**: Use `upterm session current -o go-template` to customize your shell prompt with session info. Add to your `~/.bashrc` or `~/.zshrc`:\n\n```bash\n# Show 🆙 emoji and connected client count when in upterm session\nexport PS1='$(upterm session current -o go-template=\"🆙 {{.ClientCount}} \" 2>/dev/null)'\"$PS1\"\n```\n\n**Template variables available** (Go templates use PascalCase field names):\n\n- `{{.SessionID}}` - Session ID\n- `{{.ClientCount}}` - Number of connected clients\n- `{{.Host}}` - Server host\n- `{{.Command}}` - Command being shared\n- `{{.ForceCommand}}` - Force command (if set)\n\n> **Note**: JSON output (`-o json`) uses camelCase keys (e.g., `sessionId`, `clientCount`).\n>\n> **Tip**: The same template mechanism can be used for terminal titles or other integrations.\n\n**Alternative** (simpler, without client count):\n\n```bash\nexport PS1=\"$([[ ! -z \"${UPTERM_ADMIN_SOCKET}\"  ]] && echo -e '\\xF0\\x9F\\x86\\x99 ')$PS1\"\n```\n\n## :gear: How it works\n\nUpterm starts an SSH server (a.k.a. `sshd`) in the host machine and sets up a reverse SSH tunnel to a [Upterm server](https://github.com/owenthereal/upterm/tree/master/cmd/uptermd) (a.k.a. `uptermd`).\nClients connect to a terminal session over the public internet via `uptermd` using `ssh` or `ssh` over WebSocket.\n\n![upterm flowchart](https://raw.githubusercontent.com/owenthereal/upterm/gh-pages/upterm-flowchart.svg?sanitize=true)\n\n## :hammer_and_wrench: Deployment\n\n### Kubernetes\n\nYou can deploy uptermd to a Kubernetes cluster. Install it with [helm](https://helm.sh):\n\n```console\nhelm repo add upterm https://upterm.dev\nhelm repo update\nhelm install uptermd upterm/uptermd\n```\n\n### Fly.io\n\nThe cheapest way to deploy a worry-free [Upterm server](https://github.com/owenthereal/upterm/tree/master/cmd/uptermd) (a.k.a. `uptermd`) is to use [Fly.io](https://fly.io).\nFly offers a generous free tier and excellent global performance. The official uptermd community server is hosted on Fly.\n\n1. Install the Fly CLI and authenticate:\n\n   ```console\n   curl -L https://fly.io/install.sh | sh\n   flyctl auth login\n   ```\n\n1. Copy and customize the [`fly.example.toml`](./fly.example.toml) file to `fly.toml` for your deployment configuration.\n1. Deploy your uptermd server:\n\n  ```console\n  flyctl deploy\n  ```\n\nYour uptermd server will be available at `your-app-name.fly.dev`. You can connect using either SSH or WebSocket protocols.\n\n### Heroku\n\nYou can deploy an [Upterm server](https://github.com/owenthereal/upterm/tree/master/cmd/uptermd) (a.k.a. `uptermd`) to [Heroku](https://heroku.com).\nNote that Heroku discontinued their free tier in November 2022, so this option now requires paid plans.\n\nYou can deploy with one click of the following button:\n\n[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)\n\nYou can also automate the deployment with [Heroku Terraform](https://devcenter.heroku.com/articles/using-terraform-with-heroku).\nThe Heroku Terraform scripts are in the [terraform/heroku folder](./terraform/heroku).\nA [util script](./bin/heroku-install) is provided for your convenience to automate everything:\n\n```console\ngit clone https://github.com/owenthereal/upterm\ncd upterm\n```\n\nProvision uptermd in Heroku Common Runtime. Follow instructions.\n\n```console\nbin/heroku-install\n```\n\nProvision uptermd in Heroku Private Spaces. Follow instructions.\n\n```console\nTF_VAR_heroku_region=REGION TF_VAR_heroku_space=SPACE_NAME TF_VAR_heroku_team=TEAM_NAME bin/heroku-install\n```\n\nYou **must** use WebSocket as the protocol for a Heroku-deployed Uptermd server because the platform only support HTTP/HTTPS routing.\nThis is how you host a session and join a session:\n\nUse the Heroku-deployed Uptermd server via WebSocket\n\n```console\nupterm host --server wss://YOUR_HEROKU_APP_URL -- YOUR_COMMAND\n```\n\nA client connects to the host session via WebSocket\n\n```console\nssh -o ProxyCommand='upterm proxy wss://TOKEN@YOUR_HEROKU_APP_URL' TOKEN@YOUR_HEROKU_APP_URL:443\n```\n\n### Digital Ocean\n\nThere is an util script that makes provisioning [Digital Ocean Kubernetes](https://www.digitalocean.com/products/kubernetes) and an Upterm server easier:\n\n```bash\nTF_VAR_do_token=$DO_PAT \\\nTF_VAR_uptermd_host=uptermd.upterm.dev \\\nTF_VAR_uptermd_acme_email=YOUR_EMAIL \\\nTF_VAR_uptermd_helm_repo=http://localhost:8080 \\\nTF_VAR_uptermd_host_keys_dir=PATH_TO_HOST_KEYS \\\nbin/do-install\n```\n\n### Systemd\n\nA hardened systemd service is provided in `systemd/uptermd.service`. You can use it to easily run a\nsecured `uptermd` on your machine:\n\n```console\ncp systemd/uptermd.service /etc/systemd/system/uptermd.service\nsystemctl daemon-reload\nsystemctl start uptermd\n```\n\n### Traefik\n\nBelow is an example `docker-compose` configuration for deploying `uptermd` behind [Traefik](https://doc.traefik.io/traefik/), including support for both SSH and WebSocket connections:\n\n```yaml\nservices:\n  upterm:\n    build: \n        context: https://github.com/owenthereal/upterm.git\n        dockerfile: Dockerfile.uptermd\n    labels:\n      - \"traefik.enable=true\"\n      - \"traefik.docker.network=web\"\n      # SSH over TCP (port 2222)\n      - \"traefik.tcp.services.uptermd.loadbalancer.server.port=2222\"\n      - \"traefik.tcp.services.uptermd.loadbalancer.proxyProtocol.version=2\" # required for real IP forwarding over TCP\n      - \"traefik.tcp.routers.uptermd.service=uptermd\"\n      - \"traefik.tcp.routers.uptermd.rule=HostSNI(`*`)\"\n      - \"traefik.tcp.routers.uptermd.entrypoints=uptermd\"\n      # WebSocket over HTTPS (port 8443)\n      - \"traefik.http.services.uptermd-wss.loadbalancer.server.port=8443\"\n      - \"traefik.http.routers.uptermd-wss.service=uptermd-wss\"\n      - \"traefik.http.routers.uptermd-wss.rule=Host(`upterm.example.com`)\" # edit as needed\n      - \"traefik.http.routers.uptermd-wss.entrypoints=websecure\"\n      - \"traefik.http.routers.uptermd-wss.tls.certresolver=<your cert resolver here>\"\n\n    command:\n      - --ssh-addr=0.0.0.0:2222\n      - --ws-addr=0.0.0.0:8443\n      - --ssh-proxy-protocol\n\n    networks:\n      - web\n\nnetworks:\n  web:\n    external: true\n```\n\n**Important notes:**\n\n- **Proxy Protocol:**\n  The `--ssh-proxy-protocol` flag (or `UPTERMD_SSH_PROXY_PROTOCOL=true` environment variable) tells `uptermd` to expect the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) header on incoming SSH connections. This is essential when using Traefik (or other TCP proxies like HAProxy or AWS ELB) to preserve the real client IP address.\n  **If you enable `--ssh-proxy-protocol`, all incoming SSH connections must come through a proxy that supports and is configured to use the PROXY protocol. Direct SSH connections will fail, as `uptermd` will expect the protocol header.**\n\n- **Entrypoints:**\n  Make sure to configure the appropriate [Traefik entrypoints](https://doc.traefik.io/traefik/routing/entrypoints/). This example uses two: one for SSH (`uptermd` on port `2222`) and one for WebSocket/HTTPS (`websecure` on port `443`).\n\n- **WebSocket:**\n  The WebSocket service allows clients to connect to `uptermd` over HTTPS, which is useful in restrictive network environments.\n\n- **Certificates:**\n  Replace `<your cert resolver here>` with your actual Traefik certificate resolver for TLS.\n\nFor more details on Traefik TCP and HTTP routing, see the [Traefik documentation](https://doc.traefik.io/traefik/routing/overview/).\n\n### Restricting Host Registration\n\nBy default, any SSH client that can reach `uptermd` can register a session as a host.\nFor private or invite-only deployments, the `--authorized-keys` flag (or `UPTERMD_AUTHORIZED_KEYS` environment variable) restricts host registration to a specific set of public keys.\nThis mirrors OpenSSH's [`AuthorizedKeysFile`](https://man.openbsd.org/sshd_config#AuthorizedKeysFile) directive.\n\n```console\nuptermd --authorized-keys /etc/uptermd/authorized_keys\n```\n\nThe flag accepts standard `authorized_keys`-formatted files (one key per line, comments allowed) and may be repeated to compose keys from multiple sources:\n\n```console\nuptermd --authorized-keys /etc/uptermd/team.keys --authorized-keys /etc/uptermd/ops.keys\n```\n\nFiles are read once at startup; restart `uptermd` to pick up edits. Joiners (clients connecting to a session) are unaffected — they continue to be authorized by the host's own `authorized_keys`.\n\nFor the Helm chart, populate the `authorized_keys` value:\n\n```yaml\nauthorized_keys:\n  - \"ssh-ed25519 AAAA... alice@laptop\"\n  - \"ssh-ed25519 BBBB... bob@desktop\"\n```\n\n## :chart_with_upwards_trend: Monitoring\n\n`uptermd` exposes Prometheus metrics at the `/metrics` endpoint when configured with `--metric-addr` (or `UPTERMD_METRIC_ADDR` environment variable).\n\nAvailable metrics:\n\n- `routing_connections_count` (Counter) - Total number of SSH connections accepted\n- `routing_active_connections_count` (Gauge) - Current number of active SSH connections\n- `routing_connection_duration_seconds` (Histogram) - Connection duration in seconds\n- `routing_errors_count` (Counter) - Total number of connection errors\n- `routing_connection_timeout_count` (Counter) - Number of connections that timed out during establishment\n\n## :balance_scale: Comparison with Prior Arts\n\nUpterm stands as a modern alternative to [Tmate](https://github.com/tmate-io/tmate).\n\nTmate originates as a fork from an older iteration of Tmux, extending terminal sharing capabilities atop Tmux 2.x. However, Tmate has no plans to align with the latest Tmux updates, compelling Tmate & Tmux users to manage two separate configurations. For instance, the necessity to [bind identical keys twice, conditionally](https://github.com/tmate-io/tmate/issues/108).\n\nOn the flip side, Upterm is architected from the ground up to be an independent solution, not a fork. It embodies the idea of connecting the input & output of any shell command between a host and its clients, transcending beyond merely `tmux`. This paves the way for securely sharing terminal sessions utilizing containers.\n\nWritten in Go, Upterm is more hack-friendly compared to Tmate, which is crafted in C, akin to Tmux. The seamless compilation of Upterm CLI and server (`uptermd`) into a single binary facilitates swift [deployment of your pairing server](#hammer_and_wrench-deployment) across any cloud environment, devoid of dependencies.\n\n## License\n\n[Apache 2.0](https://github.com/owenthereal/upterm/blob/master/LICENSE)\n"
  },
  {
    "path": "app.json",
    "content": "{\n    \"name\": \"Upterm\",\n    \"keywords\": [\n        \"golang\",\n        \"terminal\",\n        \"upterm\",\n        \"uptermd\"\n    ],\n    \"website\": \"https://upterm.dev\",\n    \"success_url\": \"/getting-started\",\n    \"description\": \"Secure Terminal Sharing\",\n    \"repository\": \"https://github.com/owenthereal/upterm\",\n    \"buildpacks\": [\n        {\n            \"url\": \"heroku/go\"\n        }\n    ]\n}"
  },
  {
    "path": "charts/uptermd/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n"
  },
  {
    "path": "charts/uptermd/Chart.yaml",
    "content": "apiVersion: v2\nname: uptermd\ndescription: Secure Terminal Sharing\ntype: application\nversion: 0.2.0\nappVersion: 0.14.3\nhome: https://upterm.dev\nsources:\n  - https://github.com/owenthereal/upterm\ndependencies:\nmaintainers:\n  - name: Owen Ou\n    email: o@owenou.com\n    url: https://github.com/owenthereal\n"
  },
  {
    "path": "charts/uptermd/templates/NOTES.txt",
    "content": "Host a terminal session by running these commands:\n{{- if contains \"NodePort\" .Values.service.type }}\n  export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath=\"{.items[0].status.addresses[0].address}\")\n  upterm host --server ssh://$NODE_IP:22 -- bash\n{{- else if contains \"LoadBalancer\" .Values.service.type }}\n  NOTE: It may take a few minutes for the LoadBalancer IP to be available.\n  You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include \"upterm.fullname\" . }}'\n\n  export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include \"upterm.fullname\" . }} --template \"{{\"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}\"}}\")\n  upterm host --server ssh://$SERVICE_IP:22 -- bash\n{{- else if contains \"ClusterIP\" .Values.service.type }}\n  export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l \"app.kubernetes.io/name={{ include \"upterm.name\" . }},app.kubernetes.io/instance={{ .Release.Name }}\" -o jsonpath=\"{.items[0].metadata.name}\")\n  kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 2222:22\n  upterm host --server ssh://localhost:2222 -- bash\n{{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/_helpers.tpl",
    "content": "{{/* vim: set filetype=mustache: */}}\n{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"upterm.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCreate a default fully qualified app name.\nWe truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).\nIf release name contains chart name it will be used as a full name.\n*/}}\n{{- define \"upterm.fullname\" -}}\n{{- if .Values.fullnameOverride }}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- $name := default .Chart.Name .Values.nameOverride }}\n{{- if contains $name .Release.Name }}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n{{- end }}\n{{- end }}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"upterm.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"upterm.labels\" -}}\nhelm.sh/chart: {{ include \"upterm.chart\" . }}\n{{ include \"upterm.selectorLabels\" . }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"upterm.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"upterm.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{/*\nCreate the name of the service account to use\n*/}}\n{{- define \"upterm.serviceAccountName\" -}}\n{{- if .Values.serviceAccount.create }}\n{{- default (include \"upterm.fullname\" .) .Values.serviceAccount.name }}\n{{- else }}\n{{- default \"default\" .Values.serviceAccount.name }}\n{{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/configmap.yaml",
    "content": "{{- if gt (len .Values.authorized_keys) 0 }}\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: {{ include \"upterm.fullname\" . }}\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\ndata:\n  authorized_keys: |-\n    {{- range .Values.authorized_keys }}\n    {{- . | nindent 4 }}\n    {{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"upterm.fullname\" . }}\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\nspec:\n{{- if not .Values.autoscaling.enabled }}\n  replicas: {{ .Values.replicaCount }}\n{{- end }}\n  selector:\n    matchLabels:\n      {{- include \"upterm.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n    {{- with .Values.podAnnotations }}\n      annotations:\n        {{- toYaml . | nindent 8 }}\n    {{- end }}\n      labels:\n        {{- include \"upterm.selectorLabels\" . | nindent 8 }}\n    spec:\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      serviceAccountName: {{ include \"upterm.serviceAccountName\" . }}\n      securityContext:\n        {{- toYaml .Values.podSecurityContext | nindent 8 }}\n      containers:\n        - name: {{ .Chart.Name }}\n          securityContext:\n            {{- toYaml .Values.securityContext | nindent 12 }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          args:\n            - --ssh-addr\n            - $(POD_IP):22\n            {{- if .Values.websocket.enabled }}\n            - --ws-addr\n            - $(POD_IP):80\n            {{- end }}\n            {{- if gt (len .Values.authorized_keys) 0 }}\n            - --authorized-keys\n            - /etc/uptermd/authorized_keys\n            {{- end }}\n            - --node-addr\n            - $(POD_IP):22\n            - --hostname\n            - {{ .Values.hostname }}\n            {{- range $key, $val := .Values.host_keys }}\n            {{ if hasSuffix \".pub\" $key }}\n            {{ else }}\n            - --private-key\n            - /host-keys/{{ $key }}\n            {{- end }}\n            {{- end }}\n            - --network\n            - mem\n            - --metric-addr\n            - $(POD_IP):9090\n            {{- if .Values.debug }}\n            - --debug\n            {{- end }}\n          env:\n            - name: POD_IP\n              valueFrom:\n                fieldRef:\n                  fieldPath: status.podIP\n          ports:\n            - containerPort: 22\n              name: sshd\n            {{- if .Values.websocket.enabled }}\n            - containerPort: 80\n              name: ws\n            {{- end }}\n            - containerPort: 9090\n              name: exporter\n          readinessProbe:\n            tcpSocket:\n              port: 22\n            periodSeconds: 10\n          livenessProbe:\n            tcpSocket:\n              port: 22\n            periodSeconds: 20\n          resources:\n            {{- toYaml .Values.resources | nindent 12 }}\n          volumeMounts:\n            - mountPath: /host-keys\n              name: host-keys\n            {{- if gt (len .Values.authorized_keys) 0 }}\n            - mountPath: /etc/uptermd\n              name: authorized-keys\n            {{- end }}\n      volumes:\n        - name: host-keys\n          secret:\n            secretName: {{ include \"upterm.fullname\" . }}\n            defaultMode: 0600\n        {{- if gt (len .Values.authorized_keys) 0 }}\n        - name: authorized-keys\n          configMap:\n            name: {{ include \"upterm.fullname\" . }}\n        {{- end }}\n      {{- with .Values.nodeSelector }}\n      nodeSelector:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/hpa.yaml",
    "content": "{{- if .Values.autoscaling.enabled }}\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: {{ include \"upterm.fullname\" . }}\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: {{ include \"upterm.fullname\" . }}\n  minReplicas: {{ .Values.autoscaling.minReplicas }}\n  maxReplicas: {{ .Values.autoscaling.maxReplicas }}\n  metrics:\n  {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}\n    - type: Resource\n      resource:\n        name: cpu\n        target:\n          type: Utilization\n          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}\n  {{- end }}\n  {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}\n    - type: Resource\n      resource:\n        name: memory\n        target:\n          type: Utilization\n          averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}\n  {{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/ingress.yaml",
    "content": "{{- if .Values.websocket.enabled }}\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: {{ include \"upterm.fullname\" . }}\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\n  annotations:\n    kubernetes.io/ingress.class: {{ .Values.websocket.ingress_nginx_ingress_class }}\n    nginx.ingress.kubernetes.io/proxy-send-timeout: \"3600\"\n    nginx.ingress.kubernetes.io/proxy-read-timeout: \"3600\"\n    nginx.ingress.kubernetes.io/limit-connections: \"4\"\n    nginx.ingress.kubernetes.io/limit-rps: \"5\"\n    cert-manager.io/issuer: {{ include \"upterm.fullname\" . }}-letsencrypt\n    {{- with .Values.websocket.ingress.annotations }}\n    {{- toYaml . | nindent 4 }}\n    {{- end }}\nspec:\n    tls:\n      - hosts:\n        - {{ .Values.hostname }}\n        secretName: {{ .Values.hostname | replace \".\" \"-\" }}-tls\n    rules:\n      - host: {{ .Values.hostname }}\n        http:\n          paths:\n          - path: /\n            pathType: Prefix\n            backend:\n              service:\n                name: {{ include \"upterm.fullname\" . }}\n                port:\n                  number: 80\n{{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/issuer.yaml",
    "content": "{{- if .Values.websocket.enabled }}\napiVersion: cert-manager.io/v1\nkind: Issuer\nmetadata:\n  name: {{ include \"upterm.fullname\" . }}-letsencrypt\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\nspec:\n acme:\n   # The ACME server URL\n   server: https://acme-v02.api.letsencrypt.org/directory\n   # Email address used for ACME registration\n   email: {{ .Values.websocket.cert_manager_acme_email }}\n   # Name of a secret used to store the ACME account private key\n   privateKeySecretRef:\n     name: {{ include \"upterm.fullname\" . }}-letsencrypt\n   # Enable the HTTP-01 challenge provider\n   solvers:\n   - http01:\n       ingress:\n         class: {{ .Values.websocket.ingress_nginx_ingress_class }}\n{{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/secret.yaml",
    "content": "apiVersion: v1\nkind: Secret\nmetadata:\n  name: {{ include \"upterm.fullname\" . }}\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\ntype: Opaque\ndata:\n  {{- range $key, $val := .Values.host_keys }}\n  {{ $key }}: {{ $val }}\n  {{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"upterm.fullname\" . }}\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\n  {{- with .Values.service.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n  type: {{ .Values.service.type }}\n  ports:\n    - port: 22\n      protocol: TCP\n      targetPort: 22\n      name: sshd\n    {{- if .Values.websocket.enabled }}\n    - port: 80\n      protocol: TCP\n      targetPort: 80\n      name: ws\n    {{- end }}\n  selector:\n    {{- include \"upterm.selectorLabels\" . | nindent 4 }}\n"
  },
  {
    "path": "charts/uptermd/templates/serviceaccount.yaml",
    "content": "{{- if .Values.serviceAccount.create -}}\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"upterm.serviceAccountName\" . }}\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\n  {{- with .Values.serviceAccount.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n{{- end }}\n"
  },
  {
    "path": "charts/uptermd/templates/tests/test-connection.yaml",
    "content": "apiVersion: v1\nkind: Pod\nmetadata:\n  name: \"{{ include \"upterm.fullname\" . }}-test-connection\"\n  labels:\n    {{- include \"upterm.labels\" . | nindent 4 }}\n  annotations:\n    \"helm.sh/hook\": test-success\nspec:\n  containers:\n    - name: wget\n      image: busybox\n      command: ['wget']\n      args: ['{{ include \"upterm.fullname\" . }}:{{ .Values.service.port }}']\n  restartPolicy: Never\n"
  },
  {
    "path": "charts/uptermd/values.yaml",
    "content": "# Default values for upterm.\n# This is a YAML-formatted file.\n# Declare variables to be passed into your templates.\n\nreplicaCount: 1\n\nimage:\n  repository: ghcr.io/owenthereal/upterm/uptermd\n  pullPolicy: Always\n  # Overrides the image tag whose default is the chart appVersion.\n  tag: latest\n\nimagePullSecrets: []\nnameOverride: \"\"\nfullnameOverride: \"\"\n\ndebug: false\n\nserviceAccount:\n  # Specifies whether a service account should be created\n  create: true\n  # Annotations to add to the service account\n  annotations: {}\n  # The name of the service account to use.\n  # If not set and create is true, a name is generated using the fullname template\n  name: \"\"\n\npodAnnotations: {}\n\npodSecurityContext: {}\n  # fsGroup: 2000\n\nsecurityContext: {}\n  # capabilities:\n  #   drop:\n  #   - ALL\n  # readOnlyRootFilesystem: true\n  # runAsNonRoot: true\n  # runAsUser: 1000\n\nservice:\n  type: ClusterIP\n  # Set to LoadBalancer to accept traffic from outside the cluster\n  # type: LoadBalancer\n  annotations: {}\n\nresources: \n  limits:\n    cpu: 100m\n    memory: 512Mi\n  requests:\n    cpu: 100m\n    memory: 512Mi\n\nautoscaling:\n  enabled: false\n  minReplicas: 1\n  maxReplicas: 10\n  targetCPUUtilizationPercentage: 80\n  targetMemoryUtilizationPercentage: 80\n\nnodeSelector: {}\n\ntolerations: []\n\naffinity: {}\n\nhost_keys: {}\n\n# SSH public keys (in `authorized_keys` line format, one entry per list item)\n# permitted to register as hosts on this uptermd. When empty, any client may\n# register. Edits require restarting uptermd to take effect.\n# Example:\n#   authorized_keys:\n#     - \"ssh-ed25519 AAAA... alice@laptop\"\n#     - \"ssh-ed25519 BBBB... bob@desktop\"\nauthorized_keys: []\n\nhostname: my-upterm-host\n\n# Require ingress-nginx & cert-manager\nwebsocket:\n  enabled: false\n  cert_manager_acme_email: your_email\n  ingress_nginx_ingress_class: nginx\n  ingress:\n    annotations: {}\n"
  },
  {
    "path": "cmd/gendoc/main.go",
    "content": "package main\n\nimport (\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/upterm/command\"\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/spf13/cobra/doc\"\n)\n\nfunc main() {\n\tlogger := logging.Must(logging.Console()).With(\"component\", \"gendoc\")\n\tdefer func() {\n\t\t_ = logger.Close()\n\t}()\n\n\t// Note: XDG environment variables should be set externally before running this command\n\t// to generate docs with generic paths instead of machine-specific paths.\n\t// See Makefile 'docs' target for proper environment variable setup.\n\trootCmd := command.Root()\n\n\tif err := doc.GenMarkdownTree(rootCmd, \"./docs\"); err != nil {\n\t\tlogger.Error(\"failed generating markdown docs\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n\n\theader := &doc.GenManHeader{\n\t\tTitle:   \"UPTERM\",\n\t\tSection: \"1\",\n\t\tSource:  \"Upterm \" + version.String(),\n\t\tManual:  \"Upterm Manual\",\n\t}\n\tif err := doc.GenManTree(rootCmd, header, \"./etc/man/man1\"); err != nil {\n\t\tlogger.Error(\"failed generating man pages\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif err := rootCmd.GenBashCompletionFile(\"./etc/completion/upterm.bash_completion.sh\"); err != nil {\n\t\tlogger.Error(\"failed generating bash completion\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n\tif err := rootCmd.GenZshCompletionFile(\"./etc/completion/upterm.zsh_completion\"); err != nil {\n\t\tlogger.Error(\"failed generating zsh completion\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/upterm/command/config.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/viper\"\n)\n\nfunc configCmd() *cobra.Command {\n\tconfigPath := utils.UptermConfigFilePath()\n\tcmd := &cobra.Command{\n\t\tUse:   \"config\",\n\t\tShort: \"Manage upterm configuration\",\n\t\tLong: fmt.Sprintf(`Manage upterm configuration file.\n\nConfig file: %s\n\nThis follows the XDG Base Directory Specification.\n\nConfiguration priority (highest to lowest):\n  1. Command-line flags\n  2. Environment variables (UPTERM_ prefix)\n  3. Config file\n  4. Default values`, configPath),\n\t}\n\n\tcmd.AddCommand(configPathCmd())\n\tcmd.AddCommand(configViewCmd())\n\tcmd.AddCommand(configEditCmd())\n\n\treturn cmd\n}\n\nfunc configPathCmd() *cobra.Command {\n\tconfigPath := utils.UptermConfigFilePath()\n\tcmd := &cobra.Command{\n\t\tUse:   \"path\",\n\t\tShort: \"Show the path to the config file\",\n\t\tLong: fmt.Sprintf(`Show the path to the config file.\n\nConfig file: %s\n\nThe config file is optional and created manually by users.`, configPath),\n\t\tExample: `  # Show config file path:\n  upterm config path\n\n  # Create config file directory:\n  mkdir -p \"$(dirname \"$(upterm config path)\")\"`,\n\t\tRunE: configPathRunE,\n\t}\n\n\treturn cmd\n}\n\nfunc configViewCmd() *cobra.Command {\n\tconfigPath := utils.UptermConfigFilePath()\n\tcmd := &cobra.Command{\n\t\tUse:   \"view\",\n\t\tShort: \"View the config file contents\",\n\t\tLong: fmt.Sprintf(`View the config file contents.\n\nConfig file: %s\n\nIf the config file exists, this command displays its contents. If it doesn't\nexist, this command shows an example config file that you can use as a template.`, configPath),\n\t\tExample: `  # View current config:\n  upterm config view\n\n  # View and save as new config:\n  upterm config view > \"$(upterm config path)\"`,\n\t\tRunE: configViewRunE,\n\t}\n\n\treturn cmd\n}\n\nfunc configEditCmd() *cobra.Command {\n\tconfigPath := utils.UptermConfigFilePath()\n\tcmd := &cobra.Command{\n\t\tUse:   \"edit\",\n\t\tShort: \"Edit the config file\",\n\t\tLong: fmt.Sprintf(`Edit the config file in your default editor.\n\nConfig file: %s\n\nThis command opens the config file in your editor (determined by $VISUAL, $EDITOR,\nor a sensible default). If the config file doesn't exist, it creates a template\nwith example settings and comments.\n\nThe config directory is created automatically if it doesn't exist.`, configPath),\n\t\tExample: `  # Edit config file:\n  upterm config edit\n\n  # Use a specific editor:\n  EDITOR=nano upterm config edit`,\n\t\tRunE: configEditRunE,\n\t}\n\n\treturn cmd\n}\n\nfunc configPathRunE(c *cobra.Command, args []string) error {\n\tconfigPath := utils.UptermConfigFilePath()\n\tfmt.Println(configPath)\n\treturn nil\n}\n\nfunc configViewRunE(c *cobra.Command, args []string) error {\n\tconfigPath := utils.UptermConfigFilePath()\n\n\t// Check if file exists\n\tif _, err := os.Stat(configPath); os.IsNotExist(err) {\n\t\t// Show example config\n\t\tfmt.Println(\"# Config file does not exist. Example config:\")\n\t\tfmt.Println()\n\t\tfmt.Print(exampleConfig())\n\t\treturn nil\n\t}\n\n\t// Read and display file\n\tcontent, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read config file: %w\", err)\n\t}\n\n\tfmt.Print(string(content))\n\treturn nil\n}\n\nfunc configEditRunE(c *cobra.Command, args []string) error {\n\tconfigPath := utils.UptermConfigFilePath()\n\tconfigDir := utils.UptermConfigDir()\n\n\t// Create config directory if it doesn't exist\n\tif err := os.MkdirAll(configDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create config directory: %w\", err)\n\t}\n\n\t// Create example config if file doesn't exist\n\tif _, err := os.Stat(configPath); os.IsNotExist(err) {\n\t\tif err := os.WriteFile(configPath, []byte(exampleConfig()), 0600); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create config file: %w\", err)\n\t\t}\n\t}\n\n\t// Determine editor to use\n\teditor := getEditor()\n\n\t// Open editor\n\tcmd := exec.Command(editor, configPath)\n\tcmd.Stdin = os.Stdin\n\tcmd.Stdout = os.Stdout\n\tcmd.Stderr = os.Stderr\n\n\tif err := cmd.Run(); err != nil {\n\t\treturn fmt.Errorf(\"failed to open editor: %w\", err)\n\t}\n\n\t// Validate config after editing\n\tif err := validateConfig(configPath); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Warning: config file has syntax errors: %v\\n\", err)\n\t\tfmt.Fprintf(os.Stderr, \"Edit again with 'upterm config edit' or view with 'upterm config view'.\\n\")\n\t}\n\n\treturn nil\n}\n\n// getEditor returns the editor to use, checking $VISUAL, $EDITOR, then defaults.\nfunc getEditor() string {\n\t// Check $VISUAL first (for full-screen editors)\n\tif editor := os.Getenv(\"VISUAL\"); editor != \"\" {\n\t\treturn editor\n\t}\n\n\t// Check $EDITOR (for line editors)\n\tif editor := os.Getenv(\"EDITOR\"); editor != \"\" {\n\t\treturn editor\n\t}\n\n\t// Platform-specific defaults\n\tswitch runtime.GOOS {\n\tcase \"windows\":\n\t\treturn \"notepad\"\n\tdefault:\n\t\t// Unix-like systems: prefer nano for better UX, fall back to vi\n\t\tif _, err := exec.LookPath(\"nano\"); err == nil {\n\t\t\treturn \"nano\"\n\t\t}\n\t\treturn \"vi\"\n\t}\n}\n\n// validateConfig validates the config file by attempting to parse it.\nfunc validateConfig(path string) error {\n\tv := viper.New()\n\tv.SetConfigFile(path)\n\treturn v.ReadInConfig()\n}\n\n// exampleConfig returns an example config file with comments.\nfunc exampleConfig() string {\n\treturn `# Upterm Configuration File\n#\n# This file follows the XDG Base Directory Specification.\n# Settings here are overridden by environment variables (UPTERM_*) and command-line flags.\n#\n# Configuration priority (highest to lowest):\n#   1. Command-line flags\n#   2. Environment variables (UPTERM_* prefix)\n#   3. This config file\n#   4. Default values\n\n# Debug logging (default: false)\n# When enabled, writes debug-level logs to the log file.\n# debug: true\n\n# Default server address for hosting sessions (default: ssh://uptermd.upterm.dev:22)\n# Supported protocols: ssh, ws, wss\n# server: ssh://uptermd.upterm.dev:22\n\n# Force a specific command for clients (default: none)\n# When set, clients cannot run arbitrary commands.\n# Use YAML array syntax: [\"command\", \"arg1\", \"arg2\"]\n# force-command: [\"/bin/bash\", \"-l\"]\n\n# Path to authorized_keys file for client authentication (default: none)\n# authorized-keys: /path/to/authorized_keys\n\n# Paths to private key files (default: generates ephemeral key)\n# private-key:\n#   - /path/to/private/key1\n#   - /path/to/private/key2\n\n# Read-only mode (default: false)\n# When enabled, clients can view but not interact with the session.\n# read-only: false\n\n# Allow clients to use SSH local TCP forwarding (ssh -L) (default: false)\n# When enabled, clients can reach TCP destinations visible to the host.\n# Cannot be combined with read-only.\n# allow-local-tcp-forwarding: false\n\n# Auto-accept clients without confirmation (default: false)\n# WARNING: Only use this in trusted environments.\n# accept: false\n\n# Hide client IP addresses from logs and display (default: false)\n# hide-client-ip: false\n`\n}\n"
  },
  {
    "path": "cmd/upterm/command/host.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/gen2brain/beeep\"\n\t\"github.com/google/shlex\"\n\t\"github.com/hashicorp/go-multierror\"\n\t\"github.com/owenthereal/upterm/cmd/upterm/command/internal/tui\"\n\t\"github.com/owenthereal/upterm/host\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/host/sftp\"\n\t\"github.com/owenthereal/upterm/icon\"\n\tuptermctx \"github.com/owenthereal/upterm/internal/context\"\n\t\"github.com/spf13/cobra\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// UserDiscardedError represents a user's intentional choice to discard the session\ntype UserDiscardedError struct{}\n\nfunc (e UserDiscardedError) Error() string {\n\treturn \"session discarded by user\"\n}\n\n// UserInterruptedError represents a user's Ctrl+C interruption\ntype UserInterruptedError struct{}\n\nfunc (e UserInterruptedError) Error() string {\n\treturn \"interrupted by user\"\n}\n\n// SilentError wraps an error that has already been displayed to the user.\n// main.go checks for this type to avoid duplicate logging.\ntype SilentError struct {\n\tErr error\n}\n\nfunc (e SilentError) Error() string {\n\treturn e.Err.Error()\n}\n\nfunc (e SilentError) Unwrap() error {\n\treturn e.Err\n}\n\nvar (\n\tflagServer                  string\n\tflagForceCommand            string\n\tflagPrivateKeys             []string\n\tflagKnownHostsFilename      string\n\tflagAuthorizedKeys          string\n\tflagCodebergUsers           []string\n\tflagGitHubUsers             []string\n\tflagGitLabUsers             []string\n\tflagSourceHutUsers          []string\n\tflagReadOnly                bool\n\tflagAccept                  bool\n\tflagSkipHostKeyCheck        bool\n\tflagNoSFTP                  bool\n\tflagAllowLocalTCPForwarding bool\n)\n\nfunc hostCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"host\",\n\t\tShort: \"Host a terminal session\",\n\t\tLong: `Host a terminal session via a reverse SSH tunnel to the Upterm server.\n\nThe session links the host and client IO to a command's IO. Authentication with the\nUpterm server uses private keys in this order:\n  1. Private key files: ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa\n  2. SSH Agent keys\n  3. Auto-generated ephemeral key (if no keys found)\n\nTo authorize client connections, use --authorized-keys to specify an authorized_keys file\ncontaining client public keys.`,\n\t\tExample: `  # Host a terminal session running $SHELL, attaching client's IO to the host's:\n  upterm host\n\n  # Accept client connections automatically without prompts:\n  upterm host --accept\n\n  # Host a terminal session allowing only specified public key(s) to connect:\n  upterm host --authorized-keys PATH_TO_AUTHORIZED_KEY_FILE\n\n  # Host a session executing a custom command:\n  upterm host -- docker run --rm -ti ubuntu bash\n\n  # Host a 'tmux new -t pair-programming' session, forcing clients to join with 'tmux attach -t pair-programming':\n  upterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming\n\n  # Allow clients to use local TCP forwarding (ssh -L) through the hosted session:\n  upterm host --allow-local-tcp-forwarding\n\n  # Use a different Uptermd server, hosting a session via WebSocket:\n  upterm host --server wss://YOUR_UPTERMD_SERVER -- YOUR_COMMAND`,\n\t\tPreRunE: validateShareRequiredFlags,\n\t\tRunE:    shareRunE,\n\t}\n\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\tslog.Error(\"error getting user home directory\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n\n\tcmd.PersistentFlags().StringVarP(&flagServer, \"server\", \"\", \"ssh://uptermd.upterm.dev:22\", \"Specify the upterm server address (required). Supported protocols: ssh, ws, wss.\")\n\tcmd.PersistentFlags().StringVarP(&flagForceCommand, \"force-command\", \"f\", \"\", \"Enforce a specified command for clients to join, and link the command's input/output to the client's terminal.\")\n\tcmd.PersistentFlags().StringSliceVarP(&flagPrivateKeys, \"private-key\", \"i\", defaultPrivateKeys(homeDir), \"Specify private key files for public key authentication with the upterm server (required).\")\n\tcmd.PersistentFlags().StringVarP(&flagKnownHostsFilename, \"known-hosts\", \"\", defaultKnownHost(homeDir), \"Specify a file containing known keys for remote hosts (required).\")\n\tcmd.PersistentFlags().StringVar(&flagAuthorizedKeys, \"authorized-keys\", \"\", \"Specify a authorize_keys file listing authorized public keys for connection.\")\n\tcmd.PersistentFlags().StringSliceVar(&flagCodebergUsers, \"codeberg-user\", nil, \"Authorize specified Codeberg users by allowing their public keys to connect.\")\n\tcmd.PersistentFlags().StringSliceVar(&flagGitHubUsers, \"github-user\", nil, \"Authorize specified GitHub users by allowing their public keys to connect. Configure GitHub CLI environment variables as needed; see https://cli.github.com/manual/gh_help_environment for details.\")\n\tcmd.PersistentFlags().StringSliceVar(&flagGitLabUsers, \"gitlab-user\", nil, \"Authorize specified GitLab users by allowing their public keys to connect.\")\n\tcmd.PersistentFlags().StringSliceVar(&flagSourceHutUsers, \"srht-user\", nil, \"Authorize specified SourceHut users by allowing their public keys to connect.\")\n\tcmd.PersistentFlags().BoolVar(&flagAccept, \"accept\", false, \"Automatically accept client connections without prompts.\")\n\tcmd.PersistentFlags().BoolVarP(&flagReadOnly, \"read-only\", \"r\", false, \"Host a read-only session, preventing client interaction. Also restricts SFTP to download-only.\")\n\tcmd.PersistentFlags().BoolVar(&flagHideClientIP, \"hide-client-ip\", false, \"Hide client IP addresses from output (auto-enabled in CI environments).\")\n\tcmd.PersistentFlags().BoolVar(&flagSkipHostKeyCheck, \"skip-host-key-check\", false, \"Automatically accept unknown server host keys and add them to known_hosts (similar to SSH's StrictHostKeyChecking=accept-new). This bypasses host key verification for new connections.\")\n\tcmd.PersistentFlags().BoolVar(&flagNoSFTP, \"no-sftp\", false, \"Disable file transfer via SFTP/SCP. By default, clients can transfer files with the same access as the terminal session.\")\n\tcmd.PersistentFlags().BoolVar(&flagAllowLocalTCPForwarding, \"allow-local-tcp-forwarding\", false, \"Allow clients to use SSH local TCP forwarding (ssh -L) through the hosted session, reaching TCP destinations visible to the host.\")\n\n\treturn cmd\n}\n\nfunc validateShareRequiredFlags(c *cobra.Command, args []string) error {\n\tvar result error\n\n\tif flagReadOnly && flagAllowLocalTCPForwarding {\n\t\tresult = multierror.Append(result, fmt.Errorf(\"--read-only and --allow-local-tcp-forwarding cannot be used together: a read-only session must not permit network pivoting through the host\"))\n\t}\n\n\tif flagServer == \"\" {\n\t\tresult = multierror.Append(result, fmt.Errorf(\"missing flag --server\"))\n\t} else {\n\t\tu, err := url.Parse(flagServer)\n\t\tif err != nil {\n\t\t\tresult = multierror.Append(result, fmt.Errorf(\"error parsing server URL: %w\", err))\n\t\t}\n\n\t\tif u != nil {\n\t\t\tif u.Scheme != \"ssh\" && u.Scheme != \"ws\" && u.Scheme != \"wss\" {\n\t\t\t\tresult = multierror.Append(result, fmt.Errorf(\"unsupported server protocol %s\", u.Scheme))\n\t\t\t}\n\n\t\t\tif u.Scheme == \"ssh\" {\n\t\t\t\t_, _, err := net.SplitHostPort(u.Host)\n\t\t\t\tif err != nil {\n\t\t\t\t\tresult = multierror.Append(result, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// set default ports for ws or wss\n\t\t\tif u.Scheme == \"ws\" && u.Port() == \"\" {\n\t\t\t\tu.Host = u.Host + \":80\"\n\t\t\t\tflagServer = u.String()\n\t\t\t}\n\t\t\tif u.Scheme == \"wss\" && u.Port() == \"\" {\n\t\t\t\tu.Host = u.Host + \":443\"\n\t\t\t\tflagServer = u.String()\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result\n}\n\nfunc shareRunE(c *cobra.Command, args []string) error {\n\t// Early TTY check: if interactive confirmation is needed but no TTY is available, fail fast\n\t// before making any network connections. This provides clear feedback and avoids orphan sessions.\n\tif !flagAccept && !tui.IsTTY() {\n\t\tc.SilenceUsage = true\n\t\tc.SilenceErrors = true\n\t\tfmt.Fprintln(os.Stderr, \"Error: interactive confirmation requires a terminal (TTY)\")\n\t\tfmt.Fprintln(os.Stderr)\n\t\tfmt.Fprintln(os.Stderr, \"To run in non-interactive environments (CI, scripts, etc.), use --accept:\")\n\t\tfmt.Fprintln(os.Stderr, \"  upterm host --accept [command]\")\n\t\treturn SilentError{Err: errors.New(\"no TTY available\")}\n\t}\n\n\tvar err error\n\tif len(args) == 0 {\n\t\tshellCmd := getDefaultShell()\n\t\targs, err = shlex.Split(shellCmd)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(args) == 0 {\n\t\t\treturn fmt.Errorf(\"no command is specified\")\n\t\t}\n\t}\n\n\tvar forceCommand []string\n\tif flagForceCommand != \"\" {\n\t\tforceCommand, err = shlex.Split(flagForceCommand)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error parsing command %s: %w\", flagForceCommand, err)\n\t\t}\n\t}\n\n\tlogger := uptermctx.Logger(c.Context())\n\tif logger == nil {\n\t\treturn fmt.Errorf(\"logger not available\")\n\t}\n\n\tvar authorizedKeys []*host.AuthorizedKey\n\tif flagAuthorizedKeys != \"\" {\n\t\taks, err := host.AuthorizedKeysFromFile(flagAuthorizedKeys)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error reading authorized keys: %w\", err)\n\t\t}\n\t\tauthorizedKeys = append(authorizedKeys, aks)\n\t}\n\tif flagCodebergUsers != nil {\n\t\tcodebergUserKeys, err := host.CodebergUserAuthorizedKeys(flagCodebergUsers)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error reading Codeberg user keys: %w\", err)\n\t\t}\n\t\tauthorizedKeys = append(authorizedKeys, codebergUserKeys...)\n\t}\n\tif flagGitHubUsers != nil {\n\t\tgitHubUserKeys, err := host.GitHubUserAuthorizedKeys(flagGitHubUsers, logger.Logger)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error reading GitHub user keys: %w\", err)\n\t\t}\n\t\tauthorizedKeys = append(authorizedKeys, gitHubUserKeys...)\n\t}\n\tif flagGitLabUsers != nil {\n\t\tgitLabUserKeys, err := host.GitLabUserAuthorizedKeys(flagGitLabUsers)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error reading GitLab user keys: %w\", err)\n\t\t}\n\t\tauthorizedKeys = append(authorizedKeys, gitLabUserKeys...)\n\t}\n\tif flagSourceHutUsers != nil {\n\t\tsourceHutUserKeys, err := host.SourceHutUserAuthorizedKeys(flagSourceHutUsers)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error reading SourceHut user keys: %w\", err)\n\t\t}\n\t\tauthorizedKeys = append(authorizedKeys, sourceHutUserKeys...)\n\t}\n\n\tsigners, cleanup, err := host.Signers(flagPrivateKeys)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error reading private keys: %w\", err)\n\t}\n\tif cleanup != nil {\n\t\tdefer cleanup()\n\t}\n\n\tvar hkcb ssh.HostKeyCallback\n\tif flagSkipHostKeyCheck {\n\t\thkcb, err = host.NewAutoAcceptingHostKeyCallback(os.Stdout, flagKnownHostsFilename)\n\t} else {\n\t\thkcb, err = host.NewPromptingHostKeyCallback(os.Stdin, os.Stdout, flagKnownHostsFilename)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Set up SFTP permission checker based on --accept flag\n\tvar sftpPermissionChecker sftp.PermissionChecker\n\tif flagAccept {\n\t\tsftpPermissionChecker = &AutoAllowPermissionChecker{}\n\t} else {\n\t\tsftpPermissionChecker = &DialogPermissionChecker{}\n\t}\n\n\th := &host.Host{\n\t\tHost:                    flagServer,\n\t\tCommand:                 args,\n\t\tForceCommand:            forceCommand,\n\t\tSigners:                 signers,\n\t\tHostKeyCallback:         hkcb,\n\t\tAuthorizedKeys:          authorizedKeys,\n\t\tKeepAliveDuration:       50 * time.Second, // nlb is 350 sec & heroku router is 55 sec\n\t\tSessionCreatedCallback:  displaySessionCallback,\n\t\tClientJoinedCallback:    clientJoinedCallback,\n\t\tClientLeftCallback:      clientLeftCallback,\n\t\tStdin:                   os.Stdin,\n\t\tStdout:                  os.Stdout,\n\t\tLogger:                  logger.Logger,\n\t\tReadOnly:                flagReadOnly,\n\t\tAllowLocalTCPForwarding: flagAllowLocalTCPForwarding,\n\t\tSFTPDisabled:            flagNoSFTP,\n\t\tSFTPPermissionChecker:   sftpPermissionChecker,\n\t}\n\n\terr = h.Run(c.Context())\n\n\t// Handle user actions specially - no help menu\n\tvar userDiscardedErr UserDiscardedError\n\tif errors.As(err, &userDiscardedErr) {\n\t\treturn nil // Clean exit for user discard (exit code 0)\n\t}\n\n\tvar userInterruptedErr UserInterruptedError\n\tif errors.As(err, &userInterruptedErr) {\n\t\t// Set both flags to prevent help menu and error display\n\t\tc.SilenceUsage = true\n\t\tc.SilenceErrors = true\n\t\treturn userInterruptedErr\n\t}\n\n\treturn err\n}\n\nfunc clientJoinedCallback(c *api.Client) {\n\t_ = beeep.Notify(\"Upterm Client Joined\", notifyBody(c), icon.Upterm)\n}\n\nfunc clientLeftCallback(c *api.Client) {\n\t_ = beeep.Notify(\"Upterm Client Left\", notifyBody(c), icon.Upterm)\n}\n\nfunc notifyBody(c *api.Client) string {\n\treturn clientDesc(c.Addr, c.Version, c.PublicKeyFingerprint)\n}\n\nfunc displaySessionCallback(ctx context.Context, session *api.GetSessionResponse) error {\n\t// Build session detail (includes SCP commands if SFTP is enabled)\n\tdetail, err := buildSessionDetail(session)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to build session detail: %w\", err)\n\t}\n\n\t// With --accept, just print session info and continue (no interactive confirmation needed)\n\tif flagAccept {\n\t\ttui.PrintSessionDetail(detail)\n\t\treturn nil\n\t}\n\n\t// Run interactive TUI for confirmation (TTY is guaranteed by early check in shareRunE)\n\tmodel := tui.NewHostSessionModel(detail, false)\n\tp := tea.NewProgram(model, tea.WithContext(ctx))\n\n\tfinalModel, err := p.Run()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"session confirmation failed: %w\", err)\n\t}\n\n\t// Extract result from the model\n\tsessionModel, ok := finalModel.(tui.HostSessionModel)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unexpected model type: got %T, want tui.HostSessionModel\", finalModel)\n\t}\n\n\t// Handle the result\n\tswitch sessionModel.Result() {\n\tcase tui.HostSessionConfirmAccepted:\n\t\treturn nil\n\tcase tui.HostSessionConfirmRejected:\n\t\treturn UserDiscardedError{}\n\tcase tui.HostSessionConfirmInterrupted:\n\t\treturn UserInterruptedError{}\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown confirmation result: %d\", sessionModel.Result())\n\t}\n}\n\nfunc defaultPrivateKeys(homeDir string) []string {\n\tvar pks []string\n\tfor _, f := range []string{\n\t\t\"id_ed25519\",\n\t\t\"id_ed25519_sk\",\n\t\t\"id_ecdsa\",\n\t\t\"id_ecdsa_sk\",\n\t\t\"id_dsa\",\n\t\t\"id_rsa\",\n\t} {\n\t\tpk := filepath.Join(homeDir, \".ssh\", f)\n\t\tif _, err := os.Stat(pk); os.IsNotExist(err) {\n\t\t\tcontinue\n\t\t}\n\n\t\tpks = append(pks, pk)\n\t}\n\n\treturn pks\n}\n\nfunc defaultKnownHost(homeDir string) string {\n\treturn filepath.Join(homeDir, \".ssh\", \"known_hosts\")\n}\n"
  },
  {
    "path": "cmd/upterm/command/host_test.go",
    "content": "package command\n\nimport (\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_validateShareRequiredFlags_readOnlyAndLocalTCPForwarding(t *testing.T) {\n\torigServer := flagServer\n\torigReadOnly := flagReadOnly\n\torigAllowLocalTCPForwarding := flagAllowLocalTCPForwarding\n\tt.Cleanup(func() {\n\t\tflagServer = origServer\n\t\tflagReadOnly = origReadOnly\n\t\tflagAllowLocalTCPForwarding = origAllowLocalTCPForwarding\n\t})\n\n\tflagServer = \"ssh://uptermd.upterm.dev:22\"\n\n\tcases := []struct {\n\t\tname                    string\n\t\treadOnly                bool\n\t\tallowLocalTCPForwarding bool\n\t\twantErrSubstr           string\n\t}{\n\t\t{name: \"neither\", readOnly: false, allowLocalTCPForwarding: false},\n\t\t{name: \"read-only only\", readOnly: true, allowLocalTCPForwarding: false},\n\t\t{name: \"forwarding only\", readOnly: false, allowLocalTCPForwarding: true},\n\t\t{\n\t\t\tname:                    \"both rejected\",\n\t\t\treadOnly:                true,\n\t\t\tallowLocalTCPForwarding: true,\n\t\t\twantErrSubstr:           \"--read-only and --allow-local-tcp-forwarding cannot be used together\",\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tcc := c\n\t\tt.Run(cc.name, func(t *testing.T) {\n\t\t\tflagReadOnly = cc.readOnly\n\t\t\tflagAllowLocalTCPForwarding = cc.allowLocalTCPForwarding\n\n\t\t\terr := validateShareRequiredFlags(nil, nil)\n\t\t\tif cc.wantErrSubstr == \"\" {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.ErrorContains(t, err, cc.wantErrSubstr)\n\t\t})\n\t}\n}\n\nfunc Test_parseURL(t *testing.T) {\n\tcases := []struct {\n\t\tname       string\n\t\turl        string\n\t\twantScheme string\n\t\twantHost   string\n\t\twantPort   string\n\t}{\n\t\t{\n\t\t\tname:       \"port 443\",\n\t\t\turl:        \"wss://foo.com:443\",\n\t\t\twantScheme: \"wss\",\n\t\t\twantHost:   \"foo.com\",\n\t\t\twantPort:   \"443\",\n\t\t},\n\t\t{\n\t\t\tname:       \"port 80\",\n\t\t\turl:        \"http://foo.com:80\",\n\t\t\twantScheme: \"http\",\n\t\t\twantHost:   \"foo.com\",\n\t\t\twantPort:   \"80\",\n\t\t},\n\t\t{\n\t\t\tname:       \"port 22\",\n\t\t\turl:        \"ssh://foo.com:22\",\n\t\t\twantScheme: \"ssh\",\n\t\t\twantHost:   \"foo.com\",\n\t\t\twantPort:   \"22\",\n\t\t},\n\t\t{\n\t\t\tname:       \"no port\",\n\t\t\turl:        \"wss://foo.com\",\n\t\t\twantScheme: \"wss\",\n\t\t\twantHost:   \"foo.com\",\n\t\t\twantPort:   \"443\",\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tcc := c\n\t\tt.Run(cc.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t_, scheme, host, port, err := parseURL(cc.url)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tif diff := cmp.Diff(cc.wantScheme, scheme); diff != \"\" {\n\t\t\t\tt.Fatal(diff)\n\t\t\t}\n\n\t\t\tif diff := cmp.Diff(cc.wantHost, host); diff != \"\" {\n\t\t\t\tt.Fatal(diff)\n\t\t\t}\n\n\t\t\tif diff := cmp.Diff(cc.wantPort, port); diff != \"\" {\n\t\t\t\tt.Fatal(diff)\n\t\t\t}\n\t\t})\n\t}\n\n}\n"
  },
  {
    "path": "cmd/upterm/command/host_unix.go",
    "content": "//go:build !windows\n\npackage command\n\nimport (\n\t\"os\"\n)\n\n// getDefaultShell returns the default shell on Unix systems\nfunc getDefaultShell() string {\n\tshell := os.Getenv(\"SHELL\")\n\tif shell == \"\" {\n\t\tshell = \"/bin/sh\"\n\t}\n\treturn shell\n}\n"
  },
  {
    "path": "cmd/upterm/command/host_windows.go",
    "content": "//go:build windows\n\npackage command\n\nimport (\n\t\"os/exec\"\n)\n\n// getDefaultShell returns the default shell on Windows\n// Prefers PowerShell Core (pwsh) if available, otherwise falls back to cmd.exe\nfunc getDefaultShell() string {\n\t// Check for PowerShell Core first\n\tif _, err := exec.LookPath(\"pwsh\"); err == nil {\n\t\t// -NoLogo suppresses the copyright banner\n\t\treturn \"pwsh -NoLogo\"\n\t}\n\n\t// Check for PowerShell\n\tif _, err := exec.LookPath(\"powershell\"); err == nil {\n\t\t// -NoLogo suppresses the copyright banner\n\t\treturn \"powershell -NoLogo\"\n\t}\n\n\t// Fallback to cmd.exe (always available on Windows)\n\treturn \"cmd.exe\"\n}\n"
  },
  {
    "path": "cmd/upterm/command/internal/tui/host_session.go",
    "content": "package tui\n\nimport (\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\n// HostSessionConfirmResult represents the outcome of a confirmation prompt\ntype HostSessionConfirmResult int\n\nconst (\n\t// HostSessionConfirmAccepted indicates the user accepted (pressed 'y')\n\tHostSessionConfirmAccepted HostSessionConfirmResult = iota\n\t// HostSessionConfirmRejected indicates the user rejected (pressed 'n')\n\tHostSessionConfirmRejected\n\t// HostSessionConfirmInterrupted indicates the user interrupted (pressed Ctrl+C)\n\tHostSessionConfirmInterrupted\n)\n\n// HostSessionModel handles both session display and confirmation for the host command.\n// It renders the session information and waits for user confirmation (y/n/Ctrl+C)\n// unless auto-accept is enabled.\ntype HostSessionModel struct {\n\tdetail     SessionDetail\n\tautoAccept bool\n\tstate      sessionState\n\tresult     HostSessionConfirmResult\n\twidth      int\n}\n\n// sessionState represents the current state of the host session prompt\ntype sessionState int\n\nconst (\n\t// stateWaitingForConfirm indicates we're displaying the prompt and waiting for user input\n\tstateWaitingForConfirm sessionState = iota\n\t// stateDone indicates a decision has been made and we're ready to quit\n\tstateDone\n)\n\n// NewHostSessionModel creates a model for displaying session and getting confirmation\nfunc NewHostSessionModel(detail SessionDetail, autoAccept bool) HostSessionModel {\n\tinitialState := stateWaitingForConfirm\n\tif autoAccept {\n\t\tinitialState = stateDone\n\t}\n\n\treturn HostSessionModel{\n\t\tdetail:     detail,\n\t\tautoAccept: autoAccept,\n\t\tstate:      initialState,\n\t\tresult:     HostSessionConfirmAccepted, // default for auto-accept\n\t\twidth:      getTermWidth(),\n\t}\n}\n\nfunc (m HostSessionModel) Init() tea.Cmd {\n\t// Auto-quit immediately if auto-accept is enabled\n\tif m.autoAccept {\n\t\treturn tea.Quit\n\t}\n\treturn nil\n}\n\nfunc (m HostSessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tm.width = msg.Width\n\n\tcase tea.KeyMsg:\n\t\t// Only handle input when waiting for confirmation\n\t\tif m.state != stateWaitingForConfirm {\n\t\t\treturn m, nil\n\t\t}\n\n\t\tswitch msg.String() {\n\t\tcase \"y\", \"Y\":\n\t\t\tm.result = HostSessionConfirmAccepted\n\t\t\tm.state = stateDone\n\t\t\treturn m, tea.Quit\n\t\tcase \"n\", \"N\":\n\t\t\tm.result = HostSessionConfirmRejected\n\t\t\tm.state = stateDone\n\t\t\treturn m, tea.Quit\n\t\tcase \"ctrl+c\":\n\t\t\tm.result = HostSessionConfirmInterrupted\n\t\t\tm.state = stateDone\n\t\t\treturn m, tea.Quit\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\nfunc (m HostSessionModel) View() string {\n\tvar b strings.Builder\n\n\t// Session info\n\tb.WriteString(renderSessionDetail(m.detail, m.width))\n\n\tif !IsTTY() {\n\t\treturn b.String()\n\t}\n\n\tswitch m.state {\n\tcase stateWaitingForConfirm:\n\t\tb.WriteString(\"\\n\")\n\t\tb.WriteString(FooterStyle.Render(\"Accept connections? [y/n] (or <ctrl-c> to force exit)\"))\n\t\tb.WriteString(\"\\n\")\n\n\tcase stateDone:\n\t\tb.WriteString(\"\\n\")\n\t\tswitch m.result {\n\t\tcase HostSessionConfirmAccepted:\n\t\t\tb.WriteString(CommandStyle.Render(\"Starting to accept connections...\"))\n\t\t\tb.WriteString(\"\\n\\n\")\n\t\t\tb.WriteString(FooterStyle.Render(\"💡 Run 'upterm session current' to display session info\"))\n\t\tcase HostSessionConfirmRejected:\n\t\t\tb.WriteString(FooterStyle.Render(\"Session discarded.\"))\n\t\tcase HostSessionConfirmInterrupted:\n\t\t\tb.WriteString(FooterStyle.Render(\"Cancelled by user.\"))\n\t\t}\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\treturn b.String()\n}\n\n// Result returns the confirmation result\nfunc (m HostSessionModel) Result() HostSessionConfirmResult {\n\treturn m.result\n}\n"
  },
  {
    "path": "cmd/upterm/command/internal/tui/session_detail.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/muesli/reflow/wrap\"\n\t\"golang.org/x/term\"\n)\n\n// IsTTY returns whether stdout is a terminal\nfunc IsTTY() bool {\n\treturn term.IsTerminal(int(os.Stdout.Fd()))\n}\n\n// getTermWidth returns the terminal width, defaulting to 80 if unavailable\nfunc getTermWidth() int {\n\twidth, _, err := term.GetSize(int(os.Stdout.Fd()))\n\tif err != nil {\n\t\treturn 80\n\t}\n\treturn width\n}\n\n// RunModel runs a bubbletea model with automatic TTY detection.\n// For non-TTY environments, just prints View() once and returns.\nfunc RunModel(model tea.Model) (tea.Model, error) {\n\tif !IsTTY() {\n\t\t// Non-TTY: print View() once (lipgloss auto-strips colors)\n\t\tfmt.Print(model.View())\n\t\treturn model, nil\n\t}\n\tp := tea.NewProgram(model, tea.WithAltScreen())\n\treturn p.Run()\n}\n\n// SessionDetail holds session information for display\ntype SessionDetail struct {\n\tIsCurrent        bool\n\tAdminSocket      string\n\tSessionID        string\n\tCommand          string\n\tForceCommand     string\n\tHost             string\n\tSSHCommand       string\n\tSFTPEnabled      bool   // Whether SFTP/SCP is enabled\n\tSFTPCommand      string // SFTP command\n\tSCPUpload        string // SCP upload example\n\tSCPDownload      string // SCP download example\n\tAuthorizedKeys   string\n\tConnectedClients []string\n}\n\n// FormatSessionDetail renders a SessionDetail to a string using terminal width\nfunc FormatSessionDetail(detail SessionDetail) string {\n\treturn renderSessionDetail(detail, getTermWidth())\n}\n\n// PrintSessionDetail prints session detail to stdout\nfunc PrintSessionDetail(detail SessionDetail) {\n\tfmt.Print(FormatSessionDetail(detail))\n}\n\n// wrapLines wraps text to width and returns lines.\n// For non-TTY output, skips wrapping since output may be piped to other tools,\n// but still respects embedded newlines for proper layout.\nfunc wrapLines(text string, width int) []string {\n\tif text == \"\" {\n\t\treturn []string{}\n\t}\n\tif !IsTTY() {\n\t\treturn strings.Split(text, \"\\n\") // No wrapping, but respect newlines\n\t}\n\twrapped := wrap.String(text, max(width, 10))\n\treturn strings.Split(wrapped, \"\\n\")\n}\n\n// renderWrappedRow renders a label: value row with wrapping, continuation lines indented\nfunc renderWrappedRow(b *strings.Builder, label string, value string, labelWidth int, valueWidth int, style lipgloss.Style) {\n\tl := LabelStyle.Width(labelWidth).Render(label)\n\tlines := wrapLines(value, valueWidth)\n\tif len(lines) == 0 {\n\t\tb.WriteString(l + \"\\n\")\n\t\treturn\n\t}\n\tfor i, line := range lines {\n\t\tif i == 0 {\n\t\t\tb.WriteString(l + style.Render(line) + \"\\n\")\n\t\t} else {\n\t\t\tb.WriteString(strings.Repeat(\" \", labelWidth) + style.Render(line) + \"\\n\")\n\t\t}\n\t}\n}\n\n// renderSessionDetail generates the session detail content for the given width\nfunc renderSessionDetail(detail SessionDetail, width int) string {\n\tvar b strings.Builder\n\n\t// Title\n\tb.WriteString(TitleStyle.Render(fmt.Sprintf(\"Session: %s\", detail.SessionID)))\n\tb.WriteString(\"\\n\\n\")\n\n\t// Layout constants\n\tlabelWidth := 18\n\tvalueWidth := max(width-labelWidth-2, 20)\n\n\t// Basic fields (skip empty fields to reduce noise)\n\trenderWrappedRow(&b, \"Command:\", detail.Command, labelWidth, valueWidth, ValueStyle)\n\tif detail.ForceCommand != \"\" {\n\t\trenderWrappedRow(&b, \"Force Command:\", detail.ForceCommand, labelWidth, valueWidth, ValueStyle)\n\t}\n\trenderWrappedRow(&b, \"Host:\", detail.Host, labelWidth, valueWidth, ValueStyle)\n\tif detail.AuthorizedKeys != \"\" {\n\t\trenderWrappedRow(&b, \"Authorized Keys:\", detail.AuthorizedKeys, labelWidth, valueWidth, ValueStyle)\n\t}\n\n\t// Commands section - each command on its own line for readability\n\t// Use wrapping to prevent truncation on narrow terminals\n\tcmdIndent := 4\n\tcmdWidth := max(width-cmdIndent-2, 20)\n\n\tb.WriteString(\"\\n\")\n\tb.WriteString(LabelStyle.Render(\"➤ SSH:\") + \"\\n\")\n\tfor _, line := range wrapLines(detail.SSHCommand, cmdWidth) {\n\t\tb.WriteString(strings.Repeat(\" \", cmdIndent) + CommandStyle.Render(line) + \"\\n\")\n\t}\n\n\t// SFTP and SCP commands (only shown if SFTP is enabled)\n\tif detail.SFTPEnabled {\n\t\tb.WriteString(LabelStyle.Render(\"➤ SFTP:\") + \"\\n\")\n\t\tfor _, line := range wrapLines(detail.SFTPCommand, cmdWidth) {\n\t\t\tb.WriteString(strings.Repeat(\" \", cmdIndent) + CommandStyle.Render(line) + \"\\n\")\n\t\t}\n\t\tb.WriteString(LabelStyle.Render(\"➤ SCP:\") + \"\\n\")\n\t\tfor _, line := range wrapLines(detail.SCPUpload, cmdWidth) {\n\t\t\tb.WriteString(strings.Repeat(\" \", cmdIndent) + CommandStyle.Render(line) + \"\\n\")\n\t\t}\n\t\tfor _, line := range wrapLines(detail.SCPDownload, cmdWidth) {\n\t\t\tb.WriteString(strings.Repeat(\" \", cmdIndent) + CommandStyle.Render(line) + \"\\n\")\n\t\t}\n\t}\n\n\t// Connected clients\n\tif len(detail.ConnectedClients) > 0 {\n\t\tb.WriteString(\"\\n\")\n\t\tb.WriteString(LabelStyle.Render(\"Connected Clients:\") + \"\\n\")\n\t\tfor _, client := range detail.ConnectedClients {\n\t\t\tfor i, line := range wrapLines(client, width-4) {\n\t\t\t\tindent := 2\n\t\t\t\tif i > 0 {\n\t\t\t\t\tindent = 4\n\t\t\t\t}\n\t\t\t\tb.WriteString(strings.Repeat(\" \", indent) + ValueStyle.Render(line) + \"\\n\")\n\t\t\t}\n\t\t}\n\t}\n\n\treturn b.String()\n}\n"
  },
  {
    "path": "cmd/upterm/command/internal/tui/session_detail_test.go",
    "content": "package tui\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc Test_wrapLines(t *testing.T) {\n\tcases := []struct {\n\t\tname  string\n\t\ttext  string\n\t\twidth int\n\t\twant  []string\n\t}{\n\t\t{\n\t\t\tname:  \"empty string\",\n\t\t\ttext:  \"\",\n\t\t\twidth: 80,\n\t\t\twant:  []string{},\n\t\t},\n\t\t{\n\t\t\tname:  \"single line\",\n\t\t\ttext:  \"hello world\",\n\t\t\twidth: 80,\n\t\t\twant:  []string{\"hello world\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"multi-line with embedded newlines\",\n\t\t\ttext:  \"owenthereal:\\n- SHA256:abc123\\n- SHA256:def456\",\n\t\t\twidth: 80,\n\t\t\twant:  []string{\"owenthereal:\", \"- SHA256:abc123\", \"- SHA256:def456\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"trailing newline\",\n\t\t\ttext:  \"line1\\nline2\\n\",\n\t\t\twidth: 80,\n\t\t\twant:  []string{\"line1\", \"line2\", \"\"},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\t// wrapLines behavior depends on IsTTY(), but in test environment\n\t\t\t// it should be non-TTY, so we test the non-TTY path\n\t\t\tgot := wrapLines(c.text, c.width)\n\t\t\tassert.Equal(t, c.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_renderWrappedRow_multiline(t *testing.T) {\n\t// Test that multi-line values have continuation lines properly indented\n\tvar b strings.Builder\n\tlabelWidth := 18\n\tvalueWidth := 60\n\tvalue := \"owenthereal:\\n- SHA256:abc123\\n- SHA256:def456\"\n\n\trenderWrappedRow(&b, \"Authorized Keys:\", value, labelWidth, valueWidth, ValueStyle)\n\tgot := b.String()\n\n\t// Check that continuation lines are indented\n\tlines := strings.Split(got, \"\\n\")\n\trequire.GreaterOrEqual(t, len(lines), 3, \"expected at least 3 lines, got: %q\", got)\n\n\t// First line should have the label\n\tassert.True(t, strings.HasPrefix(lines[0], \"Authorized Keys:\"), \"first line should start with label, got: %q\", lines[0])\n\n\t// Continuation lines should be indented (start with spaces)\n\tindent := strings.Repeat(\" \", labelWidth)\n\tfor i := 1; i < len(lines)-1; i++ { // -1 to skip trailing empty line\n\t\tassert.True(t, strings.HasPrefix(lines[i], indent), \"line %d should be indented with %d spaces, got: %q\", i, labelWidth, lines[i])\n\t}\n}\n\nfunc Test_FormatSessionDetail_authorizedKeys(t *testing.T) {\n\tdetail := SessionDetail{\n\t\tSessionID:      \"test123\",\n\t\tCommand:        \"bash\",\n\t\tHost:           \"ssh://example.com:22\",\n\t\tSSHCommand:     \"ssh test123@example.com\",\n\t\tAuthorizedKeys: \"user1:\\n- SHA256:key1\\nuser2:\\n- SHA256:key2\",\n\t}\n\n\toutput := FormatSessionDetail(detail)\n\n\t// Verify the output contains properly formatted authorized keys\n\tassert.Contains(t, output, \"Authorized Keys:\")\n\n\t// Check that key fingerprints are indented (appear after spaces)\n\tlines := strings.Split(output, \"\\n\")\n\tfoundIndentedKey := false\n\tfor _, line := range lines {\n\t\t// Look for lines that start with spaces followed by \"- SHA256:\"\n\t\ttrimmed := strings.TrimLeft(line, \" \")\n\t\tif strings.HasPrefix(trimmed, \"- SHA256:\") && strings.HasPrefix(line, \"  \") {\n\t\t\tfoundIndentedKey = true\n\t\t\tbreak\n\t\t}\n\t}\n\tassert.True(t, foundIndentedKey, \"key fingerprints should be indented in output\")\n}\n"
  },
  {
    "path": "cmd/upterm/command/internal/tui/session_list.go",
    "content": "package tui\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/bubbles/table\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// SessionListModel provides an interactive session list using bubbles/table\ntype SessionListModel struct {\n\ttable      table.Model\n\tsessions   []SessionDetail\n\tdetailView *SessionDetail // nil when showing list, non-nil when showing detail\n\tquitting   bool\n\twidth      int\n}\n\n// List-specific styles (extend base styles)\nvar (\n\tlistHeaderStyle = TitleStyle.MarginBottom(1)\n\tlistFooterStyle = FooterStyle.MarginTop(1)\n)\n\n// calculateColumns returns table columns sized for the given terminal width\nfunc calculateColumns(width int) []table.Column {\n\t// Fixed column\n\tconst markerWidth = 2\n\t// Table adds ~3 chars padding per column (borders + spacing)\n\tconst columnPadding = 12 // 4 columns * 3\n\n\tavailable := width - markerWidth - columnPadding\n\tif available <= 0 {\n\t\tavailable = 40 // fallback minimum\n\t}\n\n\t// Proportional distribution: sessionID 35%, command 25%, host 40%\n\tsessionIDWidth := max(available*35/100, 10)\n\tcommandWidth := max(available*25/100, 8)\n\thostWidth := max(available-sessionIDWidth-commandWidth, 15)\n\n\treturn []table.Column{\n\t\t{Title: \"\", Width: markerWidth},\n\t\t{Title: \"SESSION ID\", Width: sessionIDWidth},\n\t\t{Title: \"COMMAND\", Width: commandWidth},\n\t\t{Title: \"HOST\", Width: hostWidth},\n\t}\n}\n\n// NewSessionListModel creates a new interactive session list\nfunc NewSessionListModel(sessions []SessionDetail) SessionListModel {\n\twidth := getTermWidth()\n\n\tcolumns := calculateColumns(width)\n\n\trows := make([]table.Row, len(sessions))\n\tcursorIdx := 0\n\tfor i, s := range sessions {\n\t\tmarker := \"\"\n\t\tif s.IsCurrent {\n\t\t\tmarker = \"*\"\n\t\t\tcursorIdx = i\n\t\t}\n\t\trows[i] = table.Row{marker, s.SessionID, s.Command, s.Host}\n\t}\n\n\tt := table.New(\n\t\ttable.WithColumns(columns),\n\t\ttable.WithRows(rows),\n\t\ttable.WithFocused(true),\n\t\ttable.WithHeight(min(len(sessions)+1, 10)),\n\t)\n\n\ts := table.DefaultStyles()\n\ts.Header = s.Header.\n\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\tBorderForeground(lipgloss.Color(\"240\")).\n\t\tBorderBottom(true).\n\t\tBold(false)\n\ts.Selected = s.Selected.\n\t\tForeground(lipgloss.Color(\"229\")).\n\t\tBackground(lipgloss.Color(\"236\")).\n\t\tBold(true)\n\tt.SetStyles(s)\n\n\t// Set cursor to current session\n\tt.SetCursor(cursorIdx)\n\n\treturn SessionListModel{\n\t\ttable:    t,\n\t\tsessions: sessions,\n\t\twidth:    width,\n\t}\n}\n\nfunc (m SessionListModel) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (m SessionListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\t// If showing detail view, delegate to it\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tm.width = msg.Width\n\t\tif m.detailView == nil {\n\t\t\tm.table.SetColumns(calculateColumns(msg.Width))\n\t\t}\n\n\tcase tea.KeyMsg:\n\t\t// Handle detail view keys\n\t\tif m.detailView != nil {\n\t\t\tswitch msg.String() {\n\t\t\tcase \"q\", \"esc\", \"enter\", \" \":\n\t\t\t\tm.detailView = nil\n\t\t\t\treturn m, tea.ClearScreen\n\t\t\tcase \"ctrl+c\":\n\t\t\t\tm.quitting = true\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\t\t\treturn m, nil\n\t\t}\n\n\t\t// Handle list view keys\n\t\tswitch msg.String() {\n\t\tcase \"q\", \"ctrl+c\", \"esc\":\n\t\t\tm.quitting = true\n\t\t\treturn m, tea.Quit\n\n\t\tcase \"enter\":\n\t\t\tcursor := m.table.Cursor()\n\t\t\tif cursor >= 0 && cursor < len(m.sessions) {\n\t\t\t\tselected := m.sessions[cursor]\n\t\t\t\tm.detailView = &selected\n\t\t\t\treturn m, tea.ClearScreen\n\t\t\t}\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tm.table, cmd = m.table.Update(msg)\n\treturn m, cmd\n}\n\nfunc (m SessionListModel) View() string {\n\tif m.quitting {\n\t\treturn \"\"\n\t}\n\n\t// Show detail view if active\n\tif m.detailView != nil {\n\t\tcontent := renderSessionDetail(*m.detailView, m.width)\n\t\tfooter := FooterStyle.Render(\"Press q or enter to go back\")\n\t\treturn content + \"\\n\" + footer\n\t}\n\n\tif len(m.sessions) == 0 {\n\t\theader := listHeaderStyle.Render(\"Active Sessions (0)\")\n\t\tempty := EmptyStyle.Render(\"  No active sessions found\")\n\t\tif !IsTTY() {\n\t\t\treturn fmt.Sprintf(\"%s\\n%s\\n\", header, empty)\n\t\t}\n\t\thint := listFooterStyle.Render(\"  Run 'upterm host' to share your terminal\")\n\t\treturn fmt.Sprintf(\"%s\\n%s\\n\\n%s\\n\", header, empty, hint)\n\t}\n\n\theader := listHeaderStyle.Render(fmt.Sprintf(\"Active Sessions (%d)\", len(m.sessions)))\n\tif !IsTTY() {\n\t\treturn fmt.Sprintf(\"%s\\n%s\\n\", header, m.table.View())\n\t}\n\n\tfooter := listFooterStyle.Render(\"↑/↓: navigate • enter: view details • q: quit\")\n\treturn fmt.Sprintf(\"%s\\n%s\\n%s\\n\", header, m.table.View(), footer)\n}\n"
  },
  {
    "path": "cmd/upterm/command/internal/tui/styles.go",
    "content": "package tui\n\nimport (\n\t\"os\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// renderer is bound to stdout for consistent style rendering\nvar renderer = lipgloss.NewRenderer(os.Stdout)\n\n// Common styles used across TUI components\n// Using basic ANSI colors (0-15) which adapt to terminal themes,\n// ensuring readability on both light and dark backgrounds.\nvar (\n\t// Title/Header style - bright cyan, bold\n\tTitleStyle = renderer.NewStyle().\n\t\t\tBold(true).\n\t\t\tForeground(lipgloss.Color(\"14\"))\n\n\t// Label style - white (terminal's default light color)\n\tLabelStyle = renderer.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"7\"))\n\n\t// Value style - bright white\n\tValueStyle = renderer.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"15\"))\n\n\t// Command style - bright green, bold (for SSH commands)\n\tCommandStyle = renderer.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"10\")).\n\t\t\tBold(true)\n\n\t// Footer style - dark gray\n\tFooterStyle = renderer.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"8\"))\n\n\t// Empty/placeholder style - dark gray, italic\n\tEmptyStyle = renderer.NewStyle().\n\t\t\tForeground(lipgloss.Color(\"8\")).\n\t\t\tItalic(true)\n)\n"
  },
  {
    "path": "cmd/upterm/command/privacy.go",
    "content": "package command\n\nimport \"os\"\n\nvar (\n\tflagHideClientIP bool\n)\n\n// shouldHideClientIP determines if client IP addresses should be hidden from display.\n//\n// This function checks conditions in this priority order:\n//  1. Explicit --hide-client-ip flag (overrides everything)\n//  2. UPTERM_HIDE_CLIENT_IP environment variable (automatically bound by viper)\n//  3. Auto-detect CI environment (if neither flag nor env var set)\n//\n// This is particularly useful for CI/CD pipelines where session output is logged\n// and potentially publicly visible. By default, IPs are automatically hidden in\n// detected CI environments to prevent accidental exposure in build logs.\n//\n// Usage:\n//   upterm host --hide-client-ip              # Explicit flag\n//   UPTERM_HIDE_CLIENT_IP=true upterm host    # Environment variable (auto-bound)\n//   upterm host                               # Auto-detects CI (GitHub Actions, etc.)\nfunc shouldHideClientIP() bool {\n\t// If flag is set (either via CLI flag or via UPTERM_HIDE_CLIENT_IP env var bound by viper)\n\tif flagHideClientIP {\n\t\treturn true\n\t}\n\n\t// Auto-detect CI environments as fallback\n\treturn isCI()\n}\n\n// isCI detects if the current process is running in a CI/CD environment\n// by checking for common CI environment variables.\nfunc isCI() bool {\n\tciEnvVars := []string{\n\t\t\"CI\",             // Generic CI indicator (GitHub Actions, GitLab CI, etc.)\n\t\t\"GITHUB_ACTIONS\", // GitHub Actions\n\t\t\"GITLAB_CI\",      // GitLab CI\n\t\t\"CIRCLECI\",       // CircleCI\n\t\t\"TRAVIS\",         // Travis CI\n\t\t\"JENKINS_URL\",    // Jenkins\n\t\t\"BUILDKITE\",      // Buildkite\n\t\t\"TF_BUILD\",       // Azure Pipelines\n\t\t\"TEAMCITY_VERSION\", // TeamCity\n\t\t\"BITBUCKET_BUILD_NUMBER\", // Bitbucket Pipelines\n\t}\n\n\tfor _, envVar := range ciEnvVars {\n\t\tif os.Getenv(envVar) != \"\" {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "cmd/upterm/command/proxy.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/url\"\n\t\"os\"\n\n\t\"github.com/oklog/run\"\n\tuio \"github.com/owenthereal/upterm/io\"\n\t\"github.com/owenthereal/upterm/ws\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc proxyCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"proxy\",\n\t\tShort: \"Proxy a terminal session via WebSocket\",\n\t\tLong:  \"Proxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand.\",\n\t\tExample: `  # Host shares a session running $SHELL over WebSocket:\n  upterm host --server wss://uptermd.upterm.dev -- YOUR_COMMAND\n\n  # Client connects to the host session via WebSocket:\n  ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN:uptermd.uptermd.dev:443`,\n\t\tRunE: proxyRunE,\n\t}\n\n\treturn cmd\n}\n\nfunc proxyRunE(c *cobra.Command, args []string) error {\n\tif len(args) == 0 {\n\t\treturn fmt.Errorf(\"missing WebSocket url\")\n\t}\n\n\tu, err := url.Parse(args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tconn, err := ws.NewWSConn(u, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tvar g run.Group\n\t{\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(conn, uio.NewContextReader(ctx, os.Stdin))\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\t_ = conn.Close()\n\t\t\tcancel()\n\t\t})\n\t}\n\t{\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(os.Stdout, uio.NewContextReader(ctx, conn))\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\t_ = conn.Close()\n\t\t\tcancel()\n\t\t})\n\t}\n\n\treturn g.Run()\n}\n"
  },
  {
    "path": "cmd/upterm/command/root.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\tuptermctx \"github.com/owenthereal/upterm/internal/context\"\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n\t\"github.com/spf13/viper\"\n)\n\nfunc Root() *cobra.Command {\n\trootCmd := &cobra.Command{\n\t\tUse:   \"upterm\",\n\t\tShort: \"Instant Terminal Sharing\",\n\t\tLong: `Upterm is an open-source solution for sharing terminal sessions instantly over secure SSH tunnels to the public internet.\n\nConfiguration Priority (highest to lowest):\n  1. Command-line flags\n  2. Environment variables (UPTERM_ prefix)\n  3. Config file (see below)\n  4. Default values\n\nConfig File:\n  ~/.config/upterm/config.yaml (Linux)\n  ~/Library/Application Support/upterm/config.yaml (macOS)\n  %LOCALAPPDATA%\\upterm\\config.yaml (Windows)\n\n  Run 'upterm config path' to see your config file location.\n  Run 'upterm config edit' to create and edit the config file.\n\nEnvironment Variables:\n  All flags can be set via environment variables with the UPTERM_ prefix.\n  Flag names are converted by replacing hyphens (-) with underscores (_).\n\n  Examples:\n    --hide-client-ip  → UPTERM_HIDE_CLIENT_IP=true\n    --read-only       → UPTERM_READ_ONLY=true\n    --accept          → UPTERM_ACCEPT=true`,\n\t\tExample: `  # Host a terminal session running $SHELL, attaching client's IO to the host's:\n  $ upterm host\n\n  # Display the SSH connection string for sharing with client(s):\n  $ upterm session current\n  === SESSION_ID\n  Command:                /bin/bash\n  Force Command:          n/a\n  Host:                   ssh://uptermd.upterm.dev:22\n  SSH Session:            ssh TOKEN@uptermd.upterm.dev\n\n  # A client connects to the host session via SSH:\n  $ ssh TOKEN@uptermd.upterm.dev\n\n  # Set flags via environment variables:\n  $ UPTERM_HIDE_CLIENT_IP=true upterm host`,\n\t\tPersistentPreRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\t// Bind all flags to environment variables with UPTERM_ prefix\n\t\t\tif err := bindFlagsToEnv(cmd); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tdebug, _ := cmd.Flags().GetBool(\"debug\")\n\n\t\t\tlogOptions := []logging.Option{logging.File(utils.UptermLogFilePath())}\n\t\t\tif debug {\n\t\t\t\tlogOptions = append(logOptions, logging.Debug())\n\t\t\t}\n\n\t\t\tlogger, err := logging.New(logOptions...)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcmd.SetContext(uptermctx.WithLogger(cmd.Context(), logger))\n\n\t\t\treturn nil\n\t\t},\n\t\tPersistentPostRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif logger := uptermctx.Logger(cmd.Context()); logger != nil {\n\t\t\t\treturn logger.Close()\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tlogPath := utils.UptermLogFilePath()\n\trootCmd.PersistentFlags().Bool(\"debug\", os.Getenv(\"DEBUG\") != \"\",\n\t\tfmt.Sprintf(\"enable debug level logging (log file: %s).\", logPath))\n\n\trootCmd.AddCommand(configCmd())\n\trootCmd.AddCommand(hostCmd())\n\trootCmd.AddCommand(proxyCmd())\n\trootCmd.AddCommand(sessionCmd())\n\trootCmd.AddCommand(upgradeCmd())\n\trootCmd.AddCommand(versionCmd())\n\n\treturn rootCmd\n}\n\n// bindFlagsToEnv binds all command flags to config file and environment variables.\n// Configuration priority (highest to lowest):\n//  1. Command-line flags\n//  2. Environment variables with UPTERM_ prefix\n//  3. Config file (XDG_CONFIG_HOME/upterm/config.yaml)\n//  4. Default values\n//\n// Examples:\n//\n//\t--hide-client-ip flag -> UPTERM_HIDE_CLIENT_IP env var -> hide-client-ip in config.yaml\n//\t--read-only flag -> UPTERM_READ_ONLY env var -> read-only in config.yaml\nfunc bindFlagsToEnv(cmd *cobra.Command) error {\n\tv := viper.New()\n\n\t// Configure config file\n\tconfigPath := utils.UptermConfigFilePath()\n\tv.SetConfigFile(configPath)\n\n\t// Try to read config file (silent fail if not exists, but warn on parse errors)\n\tif err := v.ReadInConfig(); err != nil {\n\t\t// Only warn if the file exists but can't be parsed\n\t\tif _, statErr := os.Stat(configPath); statErr == nil {\n\t\t\t// File exists but couldn't be read - log warning if we have logger\n\t\t\tif logger := uptermctx.Logger(cmd.Context()); logger != nil {\n\t\t\t\tlogger.Warn(\"Failed to read config file\", \"path\", configPath, \"error\", err)\n\t\t\t}\n\t\t}\n\t\t// Otherwise silently continue - config file is optional\n\t}\n\n\t// Visit all flags and bind them to viper\n\tcmd.Flags().VisitAll(func(flag *pflag.Flag) {\n\t\tif flag.Name != \"help\" {\n\t\t\t// Ignore binding errors - not all flags support environment variable binding\n\t\t\t_ = v.BindPFlag(flag.Name, flag)\n\t\t}\n\t})\n\n\t// Enable automatic environment variable reading\n\tv.AutomaticEnv()\n\t// Replace hyphens with underscores for env var names (--hide-client-ip -> HIDE_CLIENT_IP)\n\tv.SetEnvKeyReplacer(strings.NewReplacer(\"-\", \"_\"))\n\t// Set prefix so all env vars start with UPTERM_ (UPTERM_HIDE_CLIENT_IP)\n\tv.SetEnvPrefix(\"UPTERM\")\n\n\t// Sync viper values back to flags\n\t// Priority: flags (if changed) > env vars > config file > defaults\n\tcmd.Flags().VisitAll(func(flag *pflag.Flag) {\n\t\tif flag.Name != \"help\" && !flag.Changed && v.IsSet(flag.Name) {\n\t\t\tval := v.Get(flag.Name)\n\t\t\t// Ignore setting errors - not all flag types can be set from strings\n\t\t\t_ = cmd.Flags().Set(flag.Name, toString(val))\n\t\t}\n\t})\n\n\treturn nil\n}\n\n// toString converts a value to string for flag setting.\n// Handles bool and string slice types specially, uses fmt.Sprintf for others.\nfunc toString(val any) string {\n\tswitch v := val.(type) {\n\tcase bool:\n\t\tif v {\n\t\t\treturn \"true\"\n\t\t}\n\t\treturn \"false\"\n\tcase string:\n\t\treturn v\n\tcase []string:\n\t\t// For string slice flags (e.g., --private-key), join with commas\n\t\treturn strings.Join(v, \",\")\n\tdefault:\n\t\t// For all other types (int, float, etc.), use fmt.Sprintf\n\t\treturn fmt.Sprintf(\"%v\", v)\n\t}\n}\n"
  },
  {
    "path": "cmd/upterm/command/session.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"text/template\"\n\n\t\"github.com/owenthereal/upterm/cmd/upterm/command/internal/tui\"\n\t\"github.com/owenthereal/upterm/host\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tflagAdminSocket string\n\tflagOutput      string\n)\n\n// sessionTemplateData holds data for template output\ntype sessionTemplateData struct {\n\tSessionID    string `json:\"sessionId\"`\n\tClientCount  int    `json:\"clientCount\"`\n\tHost         string `json:\"host\"`\n\tCommand      string `json:\"command\"`\n\tForceCommand string `json:\"forceCommand\"`\n}\n\nfunc sessionCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"session\",\n\t\tAliases: []string{\"se\"},\n\t\tShort:   \"Display and manage terminal sessions\",\n\t}\n\tcmd.AddCommand(current())\n\tcmd.AddCommand(list())\n\tcmd.AddCommand(show())\n\n\treturn cmd\n}\n\nfunc list() *cobra.Command {\n\truntimeDir := utils.UptermRuntimeDir()\n\tcmd := &cobra.Command{\n\t\tUse:     \"list\",\n\t\tAliases: []string{\"ls\", \"l\"},\n\t\tShort:   \"List shared sessions\",\n\t\tLong: fmt.Sprintf(`List shared sessions.\n\nSockets are stored in: %s\n\nFollows the XDG Base Directory Specification with fallback to $HOME/.upterm\nin constrained environments where XDG directories are unavailable.`, runtimeDir),\n\t\tExample: `  # List shared sessions:\n  upterm session list`,\n\t\tRunE: listRunE,\n\t}\n\n\treturn cmd\n}\n\nfunc show() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"info\",\n\t\tAliases: []string{\"i\"},\n\t\tShort:   \"Display terminal session by name\",\n\t\tLong:    `Display terminal session by name.`,\n\t\tExample: `  # Display session by name:\n  upterm session info NAME`,\n\t\tRunE: infoRunE,\n\t}\n\n\tcmd.Flags().BoolVar(&flagHideClientIP, \"hide-client-ip\", false, \"Hide client IP addresses from output (auto-enabled in CI environments).\")\n\n\treturn cmd\n}\n\nfunc current() *cobra.Command {\n\truntimeDir := utils.UptermRuntimeDir()\n\tcmd := &cobra.Command{\n\t\tUse:     \"current\",\n\t\tAliases: []string{\"c\"},\n\t\tShort:   \"Display the current terminal session\",\n\t\tLong: fmt.Sprintf(`Display the current terminal session.\n\nBy default, reads the admin socket path from $UPTERM_ADMIN_SOCKET (automatically set\nwhen you run 'upterm host').\n\nSockets are stored in: %s\n\nFollows the XDG Base Directory Specification with fallback to $HOME/.upterm\nin constrained environments where XDG directories are unavailable.\n\nOutput formats:\n  -o json                           JSON output\n  -o go-template='{{.ClientCount}}' Custom Go template\n\nTemplate variables: SessionID, ClientCount, Host, Command, ForceCommand`, runtimeDir),\n\t\tExample: `  # Display the active session as defined in $UPTERM_ADMIN_SOCKET:\n  upterm session current\n\n  # Output as JSON:\n  upterm session current -o json\n\n  # Custom format for shell prompt (outputs nothing if not in session):\n  upterm session current -o go-template='🆙 {{.ClientCount}} '\n\n  # For terminal title:\n  upterm session current -o go-template='upterm: {{.ClientCount}} clients | {{.SessionID}}'`,\n\t\tPreRunE: validateCurrentRequiredFlags,\n\t\tRunE:    currentRunE,\n\t}\n\n\tcmd.PersistentFlags().StringVarP(&flagAdminSocket, \"admin-socket\", \"\", currentAdminSocketFile(), \"Admin socket path (required).\")\n\tcmd.Flags().StringVarP(&flagOutput, \"output\", \"o\", \"\", \"Output format: json or go-template='...'\")\n\tcmd.Flags().BoolVar(&flagHideClientIP, \"hide-client-ip\", false, \"Hide client IP addresses from output (auto-enabled in CI environments).\")\n\n\treturn cmd\n}\n\nfunc listRunE(c *cobra.Command, args []string) error {\n\tsessions, err := listSessions(c.Context(), utils.UptermRuntimeDir())\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tmodel := tui.NewSessionListModel(sessions)\n\t_, err = tui.RunModel(model)\n\treturn err\n}\n\n// fetchSessionDetail returns session details for an admin socket\nfunc fetchSessionDetail(ctx context.Context, adminSocket string) (tui.SessionDetail, error) {\n\tsess, err := session(ctx, adminSocket)\n\tif err != nil {\n\t\treturn tui.SessionDetail{}, err\n\t}\n\treturn buildSessionDetail(sess)\n}\n\nfunc infoRunE(c *cobra.Command, args []string) error {\n\tif len(args) == 0 {\n\t\treturn fmt.Errorf(\"missing session name\")\n\t}\n\n\tadminSocket := filepath.Join(utils.UptermRuntimeDir(), host.AdminSocketFile(args[0]))\n\tdetail, err := fetchSessionDetail(c.Context(), adminSocket)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttui.PrintSessionDetail(detail)\n\treturn nil\n}\n\nfunc currentRunE(c *cobra.Command, args []string) error {\n\t// If output format specified, use special handling (non-interactive)\n\tif flagOutput != \"\" {\n\t\treturn outputSession(c.Context(), flagAdminSocket, flagOutput)\n\t}\n\n\tdetail, err := fetchSessionDetail(c.Context(), flagAdminSocket)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttui.PrintSessionDetail(detail)\n\treturn nil\n}\n\n// outputSession handles -o/--output flag for session current\nfunc outputSession(ctx context.Context, adminSocket, format string) error {\n\t// Error if not in upterm session (no admin socket)\n\tif adminSocket == \"\" {\n\t\treturn fmt.Errorf(\"not in upterm session (UPTERM_ADMIN_SOCKET not set)\")\n\t}\n\n\t// Validate format\n\tif format != \"json\" && !strings.HasPrefix(format, \"go-template=\") {\n\t\treturn fmt.Errorf(\"invalid output format %q: must be 'json' or 'go-template=<template>'\", format)\n\t}\n\n\t// Try to get session\n\tsess, err := session(ctx, adminSocket)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get session: %w\", err)\n\t}\n\n\t// Build template data\n\tdata := sessionTemplateData{\n\t\tSessionID:    sess.SessionId,\n\t\tClientCount:  len(sess.ConnectedClients),\n\t\tHost:         sess.Host,\n\t\tCommand:      strings.Join(sess.Command, \" \"),\n\t\tForceCommand: strings.Join(sess.ForceCommand, \" \"),\n\t}\n\n\t// Handle json output\n\tif format == \"json\" {\n\t\tenc := json.NewEncoder(os.Stdout)\n\t\tenc.SetIndent(\"\", \"  \")\n\t\treturn enc.Encode(data)\n\t}\n\n\t// Handle go-template output\n\ttmplStr := strings.TrimPrefix(format, \"go-template=\")\n\t// Remove surrounding quotes if present\n\ttmplStr = strings.Trim(tmplStr, \"'\\\"\")\n\n\ttmpl, err := template.New(\"session\").Parse(tmplStr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid template: %w\", err)\n\t}\n\n\treturn tmpl.Execute(os.Stdout, data)\n}\n\nfunc listSessions(ctx context.Context, dir string) ([]tui.SessionDetail, error) {\n\tvar result []tui.SessionDetail\n\n\tfiles, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcurrentAdminSocket := currentAdminSocketFile()\n\tfor _, file := range files {\n\t\t// continue if the file is not SESSION.sock\n\t\tif filepath.Ext(file.Name()) != host.AdminSockExt {\n\t\t\tcontinue\n\t\t}\n\n\t\tadminSocket := filepath.Join(dir, file.Name())\n\t\tsess, err := session(ctx, adminSocket)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tdetail, err := buildSessionDetail(sess)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tdetail.IsCurrent = adminSocket == currentAdminSocket\n\t\tdetail.AdminSocket = adminSocket\n\t\tresult = append(result, detail)\n\t}\n\n\treturn result, nil\n}\n\nfunc parseURL(str string) (u *url.URL, scheme string, host string, port string, err error) {\n\tu, err = url.Parse(str)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tscheme = u.Scheme\n\thost, port, err = net.SplitHostPort(u.Host)\n\tif err != nil {\n\t\tif !strings.Contains(err.Error(), \"missing port in address\") {\n\t\t\treturn\n\t\t}\n\n\t\terr = nil\n\t\thost = u.Host\n\t\tswitch u.Scheme {\n\t\tcase \"ssh\":\n\t\t\tport = \"22\"\n\t\tcase \"ws\":\n\t\t\tport = \"80\"\n\t\tcase \"wss\":\n\t\t\tport = \"443\"\n\t\t}\n\t}\n\n\treturn\n}\n\n// buildSessionDetail returns session detail for TUI display\nfunc buildSessionDetail(sess *api.GetSessionResponse) (tui.SessionDetail, error) {\n\tuser := sess.SshUser\n\tif user == \"\" {\n\t\t// Fallback to encoding for backward compatibility with older servers\n\t\tuser = routing.NewEncodeDecoder(routing.ModeEmbedded).Encode(sess.SessionId, sess.NodeAddr)\n\t}\n\n\tu, scheme, host, port, err := parseURL(sess.Host)\n\tif err != nil {\n\t\treturn tui.SessionDetail{}, err\n\t}\n\n\tvar hostPort string\n\tif port == \"\" || port == \"80\" || port == \"443\" {\n\t\thostPort = host\n\t} else {\n\t\thostPort = host + \":\" + port\n\t}\n\n\tvar sshCmd string\n\tif scheme == \"ssh\" {\n\t\tsshCmd = fmt.Sprintf(\"ssh %s@%s\", user, host)\n\t\tif port != \"22\" {\n\t\t\tsshCmd = fmt.Sprintf(\"%s -p %s\", sshCmd, port)\n\t\t}\n\t} else {\n\t\tsshCmd = fmt.Sprintf(\"ssh -o ProxyCommand='upterm proxy %s://%s@%s' %s@%s\", scheme, user, hostPort, user, host+\":\"+port)\n\t}\n\n\tvar clients []string\n\tfor _, c := range sess.ConnectedClients {\n\t\tclients = append(clients, clientDesc(c.Addr, c.Version, c.PublicKeyFingerprint))\n\t}\n\n\t// Build SFTP/SCP commands if enabled and using direct SSH\n\tvar sftpCmd, scpUpload, scpDownload string\n\tsftpEnabled := !sess.SftpDisabled && scheme == \"ssh\"\n\tif sftpEnabled {\n\t\t// SFTP command (similar to SSH)\n\t\tif port != \"\" && port != \"22\" {\n\t\t\tsftpCmd = fmt.Sprintf(\"sftp -P %s %s@%s\", port, user, host)\n\t\t\tscpUpload = fmt.Sprintf(\"scp -P %s <local> %s@%s:<remote>\", port, user, host)\n\t\t\tscpDownload = fmt.Sprintf(\"scp -P %s %s@%s:<remote> <local>\", port, user, host)\n\t\t} else {\n\t\t\tsftpCmd = fmt.Sprintf(\"sftp %s@%s\", user, host)\n\t\t\tscpUpload = fmt.Sprintf(\"scp <local> %s@%s:<remote>\", user, host)\n\t\t\tscpDownload = fmt.Sprintf(\"scp %s@%s:<remote> <local>\", user, host)\n\t\t}\n\t}\n\n\treturn tui.SessionDetail{\n\t\tSessionID:        sess.SessionId,\n\t\tCommand:          strings.Join(sess.Command, \" \"),\n\t\tForceCommand:     strings.Join(sess.ForceCommand, \" \"),\n\t\tHost:             u.Scheme + \"://\" + hostPort,\n\t\tSSHCommand:       sshCmd,\n\t\tSFTPEnabled:      sftpEnabled,\n\t\tSFTPCommand:      sftpCmd,\n\t\tSCPUpload:        scpUpload,\n\t\tSCPDownload:      scpDownload,\n\t\tAuthorizedKeys:   displayAuthorizedKeys(sess.AuthorizedKeys),\n\t\tConnectedClients: clients,\n\t}, nil\n}\n\nfunc clientDesc(addr, clientVer, fingerprint string) string {\n\tif shouldHideClientIP() {\n\t\taddr = \"[redacted]\"\n\t}\n\treturn fmt.Sprintf(\"%s %s %s\", addr, clientVer, fingerprint)\n}\n\nfunc currentAdminSocketFile() string {\n\treturn os.Getenv(upterm.HostAdminSocketEnvVar)\n}\n\nfunc session(ctx context.Context, adminSocket string) (*api.GetSessionResponse, error) {\n\tc, err := host.AdminClient(adminSocket)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn c.GetSession(ctx, &api.GetSessionRequest{})\n}\n\nfunc validateCurrentRequiredFlags(c *cobra.Command, args []string) error {\n\tmissingFlagNames := []string{}\n\tif flagAdminSocket == \"\" {\n\t\tmissingFlagNames = append(missingFlagNames, \"admin-socket\")\n\t}\n\n\tif len(missingFlagNames) > 0 {\n\t\treturn fmt.Errorf(`required flag(s) \"%s\" not set`, strings.Join(missingFlagNames, \", \"))\n\t}\n\n\treturn nil\n}\n\nfunc displayAuthorizedKeys(keys []*api.AuthorizedKey) string {\n\tvar aks []string\n\tfor _, ak := range keys {\n\t\tif len(ak.PublicKeyFingerprints) == 0 {\n\t\t\taks = append(aks, fmt.Sprintf(\"[!] %s (no SSH keys configured)\", ak.Comment))\n\t\t} else {\n\t\t\tvar fps []string\n\t\t\tfor _, fp := range ak.PublicKeyFingerprints {\n\t\t\t\tfps = append(fps, fmt.Sprintf(\"- %s\", fp))\n\t\t\t}\n\t\t\taks = append(aks, fmt.Sprintf(\"%s:\\n%s\", ak.Comment, strings.Join(fps, \"\\n\")))\n\t\t}\n\t}\n\n\treturn strings.Join(aks, \"\\n\")\n}\n"
  },
  {
    "path": "cmd/upterm/command/sftp_permission.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/ncruces/zenity\"\n\t\"github.com/owenthereal/upterm/host/sftp\"\n\t\"github.com/owenthereal/upterm/utils\"\n)\n\n// DialogPermissionChecker shows GUI dialogs for permission prompts.\ntype DialogPermissionChecker struct {\n\t// allowedSessions tracks SSH sessions where user clicked \"Allow All\".\n\t// All operations in these sessions are auto-allowed.\n\tallowedSessions sync.Map // map[sessionID]struct{}\n\n\t// allowedFiles tracks files where user clicked \"Allow\" (per session).\n\t// All operations on these files are auto-allowed for that session.\n\t// Key format: \"sessionID:path\"\n\tallowedFiles sync.Map // map[string]struct{}\n}\n\n// CheckPermission shows a dialog for the operation.\n// For two-path operations (rename, symlink, link), both source and target paths are passed.\nfunc (d *DialogPermissionChecker) CheckPermission(op sftp.Operation, client sftp.ClientInfo, paths ...string) (sftp.PermissionResult, error) {\n\tif len(paths) == 0 {\n\t\treturn sftp.PermissionDenied, fmt.Errorf(\"no path provided\")\n\t}\n\n\t// Auto-allow if user clicked \"Allow All\" for this session\n\tif d.isSessionAllowed(client.SessionID) {\n\t\treturn sftp.PermissionAllowed, nil\n\t}\n\n\t// Auto-allow if user clicked \"Allow\" for all involved paths in this session\n\tallPathsAllowed := true\n\tfor _, p := range paths {\n\t\tif !d.isFileAllowed(client.SessionID, p) {\n\t\t\tallPathsAllowed = false\n\t\t\tbreak\n\t\t}\n\t}\n\tif allPathsAllowed {\n\t\treturn sftp.PermissionAllowed, nil\n\t}\n\n\tresult, err := d.showDialog(op, client, paths)\n\n\t// Track based on user's choice\n\tswitch result {\n\tcase sftp.PermissionAlwaysAllow:\n\t\t// \"Allow All\" - allow all operations in this session\n\t\td.allowSession(client.SessionID)\n\tcase sftp.PermissionAllowed:\n\t\t// \"Allow\" - allow all operations on these paths in this session\n\t\tfor _, p := range paths {\n\t\t\td.allowFile(client.SessionID, p)\n\t\t}\n\t}\n\n\treturn result, err\n}\n\nfunc (d *DialogPermissionChecker) allowSession(sessionID string) {\n\td.allowedSessions.Store(sessionID, struct{}{})\n}\n\nfunc (d *DialogPermissionChecker) isSessionAllowed(sessionID string) bool {\n\t_, ok := d.allowedSessions.Load(sessionID)\n\treturn ok\n}\n\nfunc (d *DialogPermissionChecker) allowFile(sessionID, path string) {\n\tkey := sessionID + \":\" + path\n\td.allowedFiles.Store(key, struct{}{})\n}\n\nfunc (d *DialogPermissionChecker) isFileAllowed(sessionID, path string) bool {\n\tkey := sessionID + \":\" + path\n\t_, ok := d.allowedFiles.Load(key)\n\treturn ok\n}\n\n// ClearSession removes cached permissions for the given session.\nfunc (d *DialogPermissionChecker) ClearSession(sessionID string) {\n\t// Remove session-level permission\n\td.allowedSessions.Delete(sessionID)\n\n\t// Remove all file-level permissions for this session\n\tprefix := sessionID + \":\"\n\td.allowedFiles.Range(func(key, _ any) bool {\n\t\tif k, ok := key.(string); ok && strings.HasPrefix(k, prefix) {\n\t\t\td.allowedFiles.Delete(key)\n\t\t}\n\t\treturn true\n\t})\n}\n\nfunc (d *DialogPermissionChecker) showDialog(op sftp.Operation, client sftp.ClientInfo, paths []string) (sftp.PermissionResult, error) {\n\ttitle := \"Upterm File Transfer\"\n\n\t// Format client identifier\n\tclientID := \"unknown\"\n\tif client.Fingerprint != \"\" {\n\t\tclientID = client.Fingerprint\n\t}\n\n\t// Use shortened paths for user-friendly display (e.g., ~/foo instead of /Users/name/foo)\n\tdisplayPath := utils.ShortenHomePath(paths[0])\n\tvar displayTarget string\n\tif len(paths) > 1 {\n\t\tdisplayTarget = utils.ShortenHomePath(paths[1])\n\t}\n\n\tvar msg string\n\tswitch op {\n\tcase sftp.OpDownload:\n\t\tmsg = fmt.Sprintf(\"Client [%s] wants to download:\\n%s\", clientID, displayPath)\n\tcase sftp.OpUpload:\n\t\tmsg = fmt.Sprintf(\"Client [%s] wants to upload:\\n%s\", clientID, displayPath)\n\tcase sftp.OpDelete, sftp.OpRmdir:\n\t\tmsg = fmt.Sprintf(\"Client [%s] wants to delete:\\n%s\", clientID, displayPath)\n\tcase sftp.OpMkdir:\n\t\tmsg = fmt.Sprintf(\"Client [%s] wants to create directory:\\n%s\", clientID, displayPath)\n\tcase sftp.OpRename:\n\t\tif displayTarget != \"\" {\n\t\t\tmsg = fmt.Sprintf(\"Client [%s] wants to rename:\\n%s → %s\", clientID, displayPath, displayTarget)\n\t\t} else {\n\t\t\tmsg = fmt.Sprintf(\"Client [%s] wants to rename:\\n%s\", clientID, displayPath)\n\t\t}\n\tcase sftp.OpSymlink:\n\t\tif displayTarget != \"\" {\n\t\t\tmsg = fmt.Sprintf(\"Client [%s] wants to create symlink:\\n%s → %s\", clientID, displayPath, displayTarget)\n\t\t} else {\n\t\t\tmsg = fmt.Sprintf(\"Client [%s] wants to create symlink:\\n%s\", clientID, displayPath)\n\t\t}\n\tcase sftp.OpLink:\n\t\tif displayTarget != \"\" {\n\t\t\tmsg = fmt.Sprintf(\"Client [%s] wants to create hard link:\\n%s → %s\", clientID, displayPath, displayTarget)\n\t\t} else {\n\t\t\tmsg = fmt.Sprintf(\"Client [%s] wants to create hard link:\\n%s\", clientID, displayPath)\n\t\t}\n\tcase sftp.OpSetstat:\n\t\tmsg = fmt.Sprintf(\"Client [%s] wants to modify file attributes:\\n%s\", clientID, displayPath)\n\tdefault:\n\t\tmsg = fmt.Sprintf(\"Client [%s] wants to %s:\\n%s\", clientID, op.String(), displayPath)\n\t}\n\n\t// Show question dialog with Allow/Allow All/Deny buttons\n\terr := zenity.Question(msg,\n\t\tzenity.Title(title),\n\t\tzenity.OKLabel(\"Allow\"),\n\t\tzenity.CancelLabel(\"Deny\"),\n\t\tzenity.ExtraButton(\"Allow All\"),\n\t)\n\n\tif err == nil {\n\t\treturn sftp.PermissionAllowed, nil\n\t}\n\tif err == zenity.ErrExtraButton {\n\t\treturn sftp.PermissionAlwaysAllow, nil\n\t}\n\tif err == zenity.ErrCanceled {\n\t\treturn sftp.PermissionDenied, nil\n\t}\n\n\t// Other error (e.g., zenity not installed, no display)\n\treturn sftp.PermissionDenied, err\n}\n\n// AutoAllowPermissionChecker always allows operations (for --accept mode or testing).\ntype AutoAllowPermissionChecker struct{}\n\n// CheckPermission always returns PermissionAllowed.\nfunc (a *AutoAllowPermissionChecker) CheckPermission(op sftp.Operation, client sftp.ClientInfo, paths ...string) (sftp.PermissionResult, error) {\n\treturn sftp.PermissionAllowed, nil\n}\n\n// ClearSession is a no-op since AutoAllowPermissionChecker doesn't track sessions.\nfunc (a *AutoAllowPermissionChecker) ClearSession(sessionID string) {}\n"
  },
  {
    "path": "cmd/upterm/command/upgrade.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\tggh \"github.com/google/go-github/v48/github\"\n\tuptermctx \"github.com/owenthereal/upterm/internal/context\"\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/tj/go-update\"\n\t\"github.com/tj/go-update/progress\"\n\t\"github.com/tj/go/term\"\n)\n\nfunc upgradeCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"upgrade\",\n\t\tShort: \"Upgrade the CLI\",\n\t\tExample: `  # Upgrade to the latest version:\n  upterm upgrade\n\n  # Upgrade to a specific version:\n  upterm upgrade 0.2.0`,\n\t\tRunE: upgradeRunE,\n\t}\n\n\treturn cmd\n}\n\nfunc upgradeRunE(c *cobra.Command, args []string) error {\n\tlogger := uptermctx.Logger(c.Context())\n\tif logger == nil {\n\t\treturn fmt.Errorf(\"logger not available\")\n\t}\n\n\tterm.HideCursor()\n\tdefer term.ShowCursor()\n\n\tm := &update.Manager{\n\t\tCommand: \"upterm\",\n\t\tStore: &store{\n\t\t\tOwner:   \"owenthereal\",\n\t\t\tRepo:    \"upterm\",\n\t\t\tVersion: version.String(),\n\t\t},\n\t}\n\n\tvar r release\n\tif len(args) > 0 {\n\t\trr, err := m.GetRelease(trimVPrefix(args[0]))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error fetching release: %s\", err)\n\t\t}\n\n\t\tr = release{rr}\n\t} else {\n\t\t// fetch the new releases\n\t\treleases, err := m.LatestReleases()\n\t\tif err != nil {\n\t\t\tlogger.Error(\"error fetching releases\", \"error\", err)\n\t\t\treturn fmt.Errorf(\"error fetching releases: %w\", err)\n\t\t}\n\n\t\t// no updates\n\t\tif len(releases) == 0 {\n\t\t\treturn fmt.Errorf(\"no updates\")\n\t\t}\n\n\t\t// latest release\n\t\tr = release{releases[0]}\n\t}\n\n\tif version.String() == trimVPrefix(r.Version) {\n\t\tfmt.Println(\"Upterm is up-to-date\")\n\t\treturn nil\n\t}\n\n\t// find the tarball for this system\n\ta := r.FindTarballWithVersion(runtime.GOOS, runtime.GOARCH)\n\tif a == nil {\n\t\treturn fmt.Errorf(\"no binary for your system\")\n\t}\n\n\t// download tarball to a tmp dir\n\ttarball, err := a.DownloadProxy(progress.Reader)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error downloading: %s\", err)\n\t}\n\n\t// install it\n\tif err := m.Install(tarball); err != nil {\n\t\treturn fmt.Errorf(\"error installing: %s\", err)\n\t}\n\n\tfmt.Printf(\"Upgraded upterm %s to %s\\n\", version.String(), trimVPrefix(r.Version))\n\treturn nil\n}\n\nfunc trimVPrefix(s string) string {\n\treturn strings.TrimPrefix(s, \"v\")\n}\n\ntype release struct {\n\t*update.Release\n}\n\nfunc (r *release) FindTarballWithVersion(os, arch string) *update.Asset {\n\ts := fmt.Sprintf(\"%s_%s\", os, arch)\n\tfor _, a := range r.Assets {\n\t\text := filepath.Ext(a.Name)\n\t\tif strings.Contains(a.Name, s) && ext == \".gz\" {\n\t\t\treturn a\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype store struct {\n\tOwner   string\n\tRepo    string\n\tVersion string\n}\n\nfunc (s *store) GetRelease(version string) (*update.Release, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tgh := ggh.NewClient(nil)\n\n\tr, res, err := gh.Repositories.GetReleaseByTag(ctx, s.Owner, s.Repo, \"v\"+version)\n\n\tif res.StatusCode == 404 {\n\t\treturn nil, update.ErrNotFound\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn githubRelease(r), nil\n}\n\nfunc (s *store) LatestReleases() ([]*update.Release, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tgh := ggh.NewClient(nil)\n\n\tr, _, err := gh.Repositories.GetLatestRelease(ctx, s.Owner, s.Repo)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn []*update.Release{\n\t\tgithubRelease(r),\n\t}, nil\n}\n\nfunc githubRelease(r *ggh.RepositoryRelease) *update.Release {\n\tout := &update.Release{\n\t\tVersion:     r.GetTagName(),\n\t\tNotes:       r.GetBody(),\n\t\tPublishedAt: r.GetPublishedAt().Time,\n\t\tURL:         r.GetURL(),\n\t}\n\n\tfor _, a := range r.Assets {\n\t\tout.Assets = append(out.Assets, &update.Asset{\n\t\t\tName:      a.GetName(),\n\t\t\tSize:      a.GetSize(),\n\t\t\tURL:       a.GetBrowserDownloadURL(),\n\t\t\tDownloads: a.GetDownloadCount(),\n\t\t})\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "cmd/upterm/command/version.go",
    "content": "package command\n\nimport (\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc versionCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"version\",\n\t\tShort: \"Show version\",\n\t\tRunE: func(c *cobra.Command, args []string) error {\n\t\t\tversion.PrintVersion(\"Upterm\")\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/upterm/main.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"log/slog\"\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/upterm/command\"\n)\n\nfunc main() {\n\tif err := command.Root().Execute(); err != nil {\n\t\t// Don't log errors that have already been displayed to the user\n\t\tvar silentErr command.SilentError\n\t\tif !errors.As(err, &silentErr) {\n\t\t\tslog.Error(\"Error executing command\", \"error\", err)\n\t\t}\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/uptermd/command/root.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\tuptermctx \"github.com/owenthereal/upterm/internal/context\"\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/server\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n\t\"github.com/spf13/viper\"\n)\n\nfunc Root() *cobra.Command {\n\trootCmd := &rootCmd{}\n\tcmd := &cobra.Command{\n\t\tUse:   \"uptermd\",\n\t\tShort: \"Upterm Daemon\",\n\t\tRunE:  rootCmd.RunE,\n\t}\n\n\tcmd.PersistentFlags().String(\"config\", \"\", \"server config\")\n\n\tcmd.PersistentFlags().StringP(\"ssh-addr\", \"\", utils.DefaultLocalhost(\"2222\"), \"ssh server address\")\n\tcmd.PersistentFlags().StringP(\"ws-addr\", \"\", \"\", \"websocket server address\")\n\tcmd.PersistentFlags().StringP(\"node-addr\", \"\", \"\", \"node address\")\n\tcmd.PersistentFlags().StringSliceP(\"authorized-keys\", \"\", nil, \"authorized_keys file(s) controlling which public keys may register as hosts; may be repeated, mirroring OpenSSH's AuthorizedKeysFile directive\")\n\tcmd.PersistentFlags().StringSliceP(\"private-key\", \"\", nil, \"server private key\")\n\tcmd.PersistentFlags().StringSliceP(\"hostname\", \"\", nil, \"server hostname for public-key authentication certificate principals. If empty, public-key authentication is used instead.\")\n\tcmd.PersistentFlags().BoolP(\"ssh-proxy-protocol\", \"\", false, \"enable PROXY protocol support for the SSH listener (for use behind TCP proxies like Traefik, HAProxy, or AWS ELB)\")\n\n\tcmd.PersistentFlags().StringP(\"network\", \"\", \"mem\", \"network provider\")\n\tcmd.PersistentFlags().StringSliceP(\"network-opt\", \"\", nil, \"network provider option\")\n\n\tcmd.PersistentFlags().StringP(\"metric-addr\", \"\", \"\", \"metric server address\")\n\tcmd.PersistentFlags().BoolP(\"debug\", \"\", os.Getenv(\"DEBUG\") != \"\", \"debug\")\n\n\tcmd.PersistentFlags().String(\"routing\", string(routing.ModeEmbedded), \"session routing mode\")\n\tcmd.PersistentFlags().String(\"consul-url\", \"\", \"consul URL for routing mode 'consul'\")\n\tcmd.PersistentFlags().String(\"consul-session-ttl\", server.DefaultSessionTTL.String(), \"consul session TTL for routing mode 'consul'\")\n\n\tcmd.PersistentFlags().String(\"sentry-dsn\", \"\", \"Sentry DSN for error tracking\")\n\n\tcmd.AddCommand(versionCmd())\n\n\treturn cmd\n}\n\ntype rootCmd struct {\n}\n\nfunc (cmd *rootCmd) RunE(c *cobra.Command, args []string) error {\n\tvar opt server.Opt\n\tif err := unmarshalFlags(c, &opt); err != nil {\n\t\treturn err\n\t}\n\n\tlogOptions := []logging.Option{logging.Console()}\n\tif opt.Debug {\n\t\tlogOptions = append(logOptions, logging.Debug())\n\t}\n\tif opt.SentryDSN != \"\" {\n\t\tlogOptions = append(logOptions, logging.Sentry(opt.SentryDSN))\n\t}\n\n\tlogger, err := logging.New(logOptions...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\t_ = logger.Close()\n\t}()\n\n\tc.SetContext(uptermctx.WithLogger(c.Context(), logger))\n\n\tif err := server.Start(c.Context(), opt, logger.Logger); err != nil {\n\t\tlogger.Error(\"failed to start uptermd\", \"error\", err)\n\t\treturn fmt.Errorf(\"failed to start uptermd: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc unmarshalFlags(cmd *cobra.Command, opts interface{}) error {\n\tv := viper.New()\n\n\tcmd.Flags().VisitAll(func(flag *pflag.Flag) {\n\t\tflagName := flag.Name\n\t\tif flagName != \"config\" && flagName != \"help\" {\n\t\t\tif err := v.BindPFlag(flagName, flag); err != nil {\n\t\t\t\tpanic(fmt.Errorf(\"error binding flag '%s': %w\", flagName, err).Error())\n\t\t\t}\n\t\t}\n\t})\n\n\tv.AutomaticEnv()\n\tv.SetEnvKeyReplacer(strings.NewReplacer(\"-\", \"_\"))\n\tv.SetEnvPrefix(\"UPTERMD\")\n\n\t// Bind SENTRY_DSN directly (standard convention), with UPTERMD_SENTRY_DSN as fallback\n\t_ = v.BindEnv(\"sentry-dsn\", \"SENTRY_DSN\", \"UPTERMD_SENTRY_DSN\")\n\n\tcfgFile, err := cmd.Flags().GetString(\"config\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif _, err := os.Stat(cfgFile); err == nil {\n\t\tv.SetConfigFile(cfgFile)\n\t}\n\n\tif err := v.ReadInConfig(); err != nil {\n\t\tif _, ok := err.(viper.ConfigFileNotFoundError); !ok {\n\t\t\treturn fmt.Errorf(\"error loading config file %s: %w\", cfgFile, err)\n\t\t}\n\t}\n\n\treturn v.Unmarshal(opts)\n}\n"
  },
  {
    "path": "cmd/uptermd/command/version.go",
    "content": "package command\n\nimport (\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc versionCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"version\",\n\t\tShort: \"Show version\",\n\t\tRunE: func(c *cobra.Command, args []string) error {\n\t\t\tversion.PrintVersion(\"Uptermd\")\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "cmd/uptermd/main.go",
    "content": "package main\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/uptermd/command\"\n)\n\nfunc main() {\n\tif err := command.Root().Execute(); err != nil {\n\t\tslog.Error(\"command execution failed\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/uptermd-fly/main.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/uptermd/command\"\n)\n\nfunc main() {\n\tflyAppName := os.Getenv(\"FLY_APP_NAME\")\n\tif flyAppName == \"\" {\n\t\tslog.Error(\"FLY_APP_NAME is not set\")\n\t\tos.Exit(1)\n\t}\n\n\tflyMachineID := os.Getenv(\"FLY_MACHINE_ID\")\n\tif flyMachineID == \"\" {\n\t\tslog.Error(\"FLY_MACHINE_ID is not set\")\n\t\tos.Exit(1)\n\t}\n\n\tconfig := map[string]any{\n\t\t\"UPTERMD_SSH_ADDR\":           \"[::]:2222\",\n\t\t\"UPTERMD_WS_ADDR\":            \"[::]:8080\",\n\t\t\"UPTERMD_NODE_ADDR\":          fmt.Sprintf(\"%s.vm.%s.internal:2222\", flyMachineID, flyAppName),\n\t\t\"UPTERMD_SSH_PROXY_PROTOCOL\": \"true\",\n\t\t\"UPTERMD_METRIC_ADDR\":        \"[::]:9091\",\n\t}\n\n\tflyConsulURL := os.Getenv(\"FLY_CONSUL_URL\")\n\tif flyConsulURL != \"\" {\n\t\tconfig[\"UPTERMD_ROUTING\"] = \"consul\"\n\t\tconfig[\"UPTERMD_CONSUL_URL\"] = flyConsulURL\n\t\tconfig[\"UPTERMD_CONSUL_SESSION_TTL\"] = \"1h\"\n\t\tslog.Info(\"Using Consul routing for multi-machine deployment\")\n\t} else {\n\t\tconfig[\"UPTERMD_ROUTING\"] = \"embedded\"\n\t\tslog.Info(\"Using embedded routing for single-machine deployment\")\n\t}\n\n\tfor key, value := range config {\n\t\tif err := os.Setenv(key, fmt.Sprintf(\"%v\", value)); err != nil {\n\t\t\tslog.Error(\"failed to set environment variable\", \"key\", key, \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tslog.Info(\"Starting uptermd on Fly.io\", \"config\", config)\n\tif err := command.Root().Execute(); err != nil {\n\t\tslog.Error(\"command execution failed\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "docs/upterm.md",
    "content": "## upterm\n\nInstant Terminal Sharing\n\n### Synopsis\n\nUpterm is an open-source solution for sharing terminal sessions instantly over secure SSH tunnels to the public internet.\n\nConfiguration Priority (highest to lowest):\n  1. Command-line flags\n  2. Environment variables (UPTERM_ prefix)\n  3. Config file (see below)\n  4. Default values\n\nConfig File:\n  ~/.config/upterm/config.yaml (Linux)\n  ~/Library/Application Support/upterm/config.yaml (macOS)\n  %LOCALAPPDATA%\\upterm\\config.yaml (Windows)\n\n  Run 'upterm config path' to see your config file location.\n  Run 'upterm config edit' to create and edit the config file.\n\nEnvironment Variables:\n  All flags can be set via environment variables with the UPTERM_ prefix.\n  Flag names are converted by replacing hyphens (-) with underscores (_).\n\n  Examples:\n    --hide-client-ip  → UPTERM_HIDE_CLIENT_IP=true\n    --read-only       → UPTERM_READ_ONLY=true\n    --accept          → UPTERM_ACCEPT=true\n\n### Examples\n\n```\n  # Host a terminal session running $SHELL, attaching client's IO to the host's:\n  $ upterm host\n\n  # Display the SSH connection string for sharing with client(s):\n  $ upterm session current\n  === SESSION_ID\n  Command:                /bin/bash\n  Force Command:          n/a\n  Host:                   ssh://uptermd.upterm.dev:22\n  SSH Session:            ssh TOKEN@uptermd.upterm.dev\n\n  # A client connects to the host session via SSH:\n  $ ssh TOKEN@uptermd.upterm.dev\n\n  # Set flags via environment variables:\n  $ UPTERM_HIDE_CLIENT_IP=true upterm host\n```\n\n### Options\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n  -h, --help    help for upterm\n```\n\n### SEE ALSO\n\n* [upterm config](upterm_config.md)\t - Manage upterm configuration\n* [upterm host](upterm_host.md)\t - Host a terminal session\n* [upterm proxy](upterm_proxy.md)\t - Proxy a terminal session via WebSocket\n* [upterm session](upterm_session.md)\t - Display and manage terminal sessions\n* [upterm upgrade](upterm_upgrade.md)\t - Upgrade the CLI\n* [upterm version](upterm_version.md)\t - Show version\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_config.md",
    "content": "## upterm config\n\nManage upterm configuration\n\n### Synopsis\n\nManage upterm configuration file.\n\nConfig file: /home/user/.config/upterm/config.yaml\n\nThis follows the XDG Base Directory Specification.\n\nConfiguration priority (highest to lowest):\n  1. Command-line flags\n  2. Environment variables (UPTERM_ prefix)\n  3. Config file\n  4. Default values\n\n### Options\n\n```\n  -h, --help   help for config\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm](upterm.md)\t - Instant Terminal Sharing\n* [upterm config edit](upterm_config_edit.md)\t - Edit the config file\n* [upterm config path](upterm_config_path.md)\t - Show the path to the config file\n* [upterm config view](upterm_config_view.md)\t - View the config file contents\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_config_edit.md",
    "content": "## upterm config edit\n\nEdit the config file\n\n### Synopsis\n\nEdit the config file in your default editor.\n\nConfig file: /home/user/.config/upterm/config.yaml\n\nThis command opens the config file in your editor (determined by $VISUAL, $EDITOR,\nor a sensible default). If the config file doesn't exist, it creates a template\nwith example settings and comments.\n\nThe config directory is created automatically if it doesn't exist.\n\n```\nupterm config edit [flags]\n```\n\n### Examples\n\n```\n  # Edit config file:\n  upterm config edit\n\n  # Use a specific editor:\n  EDITOR=nano upterm config edit\n```\n\n### Options\n\n```\n  -h, --help   help for edit\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm config](upterm_config.md)\t - Manage upterm configuration\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_config_path.md",
    "content": "## upterm config path\n\nShow the path to the config file\n\n### Synopsis\n\nShow the path to the config file.\n\nConfig file: /home/user/.config/upterm/config.yaml\n\nThe config file is optional and created manually by users.\n\n```\nupterm config path [flags]\n```\n\n### Examples\n\n```\n  # Show config file path:\n  upterm config path\n\n  # Create config file directory:\n  mkdir -p \"$(dirname \"$(upterm config path)\")\"\n```\n\n### Options\n\n```\n  -h, --help   help for path\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm config](upterm_config.md)\t - Manage upterm configuration\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_config_view.md",
    "content": "## upterm config view\n\nView the config file contents\n\n### Synopsis\n\nView the config file contents.\n\nConfig file: /home/user/.config/upterm/config.yaml\n\nIf the config file exists, this command displays its contents. If it doesn't\nexist, this command shows an example config file that you can use as a template.\n\n```\nupterm config view [flags]\n```\n\n### Examples\n\n```\n  # View current config:\n  upterm config view\n\n  # View and save as new config:\n  upterm config view > \"$(upterm config path)\"\n```\n\n### Options\n\n```\n  -h, --help   help for view\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm config](upterm_config.md)\t - Manage upterm configuration\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_host.md",
    "content": "## upterm host\n\nHost a terminal session\n\n### Synopsis\n\nHost a terminal session via a reverse SSH tunnel to the Upterm server.\n\nThe session links the host and client IO to a command's IO. Authentication with the\nUpterm server uses private keys in this order:\n  1. Private key files: ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa\n  2. SSH Agent keys\n  3. Auto-generated ephemeral key (if no keys found)\n\nTo authorize client connections, use --authorized-keys to specify an authorized_keys file\ncontaining client public keys.\n\n```\nupterm host [flags]\n```\n\n### Examples\n\n```\n  # Host a terminal session running $SHELL, attaching client's IO to the host's:\n  upterm host\n\n  # Accept client connections automatically without prompts:\n  upterm host --accept\n\n  # Host a terminal session allowing only specified public key(s) to connect:\n  upterm host --authorized-keys PATH_TO_AUTHORIZED_KEY_FILE\n\n  # Host a session executing a custom command:\n  upterm host -- docker run --rm -ti ubuntu bash\n\n  # Host a 'tmux new -t pair-programming' session, forcing clients to join with 'tmux attach -t pair-programming':\n  upterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming\n\n  # Allow clients to use local TCP forwarding (ssh -L) through the hosted session:\n  upterm host --allow-local-tcp-forwarding\n\n  # Use a different Uptermd server, hosting a session via WebSocket:\n  upterm host --server wss://YOUR_UPTERMD_SERVER -- YOUR_COMMAND\n```\n\n### Options\n\n```\n      --accept                       Automatically accept client connections without prompts.\n      --allow-local-tcp-forwarding   Allow clients to use SSH local TCP forwarding (ssh -L) through the hosted session, reaching TCP destinations visible to the host.\n      --authorized-keys string       Specify a authorize_keys file listing authorized public keys for connection.\n      --codeberg-user strings        Authorize specified Codeberg users by allowing their public keys to connect.\n  -f, --force-command string         Enforce a specified command for clients to join, and link the command's input/output to the client's terminal.\n      --github-user strings          Authorize specified GitHub users by allowing their public keys to connect. Configure GitHub CLI environment variables as needed; see https://cli.github.com/manual/gh_help_environment for details.\n      --gitlab-user strings          Authorize specified GitLab users by allowing their public keys to connect.\n  -h, --help                         help for host\n      --hide-client-ip               Hide client IP addresses from output (auto-enabled in CI environments).\n      --known-hosts string           Specify a file containing known keys for remote hosts (required). (default \"/Users/owen/.ssh/known_hosts\")\n      --no-sftp                      Disable file transfer via SFTP/SCP. By default, clients can transfer files with the same access as the terminal session.\n  -i, --private-key strings          Specify private key files for public key authentication with the upterm server (required). (default [/Users/owen/.ssh/id_ed25519])\n  -r, --read-only                    Host a read-only session, preventing client interaction. Also restricts SFTP to download-only.\n      --server string                Specify the upterm server address (required). Supported protocols: ssh, ws, wss. (default \"ssh://uptermd.upterm.dev:22\")\n      --skip-host-key-check          Automatically accept unknown server host keys and add them to known_hosts (similar to SSH's StrictHostKeyChecking=accept-new). This bypasses host key verification for new connections.\n      --srht-user strings            Authorize specified SourceHut users by allowing their public keys to connect.\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm](upterm.md)\t - Instant Terminal Sharing\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_proxy.md",
    "content": "## upterm proxy\n\nProxy a terminal session via WebSocket\n\n### Synopsis\n\nProxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand.\n\n```\nupterm proxy [flags]\n```\n\n### Examples\n\n```\n  # Host shares a session running $SHELL over WebSocket:\n  upterm host --server wss://uptermd.upterm.dev -- YOUR_COMMAND\n\n  # Client connects to the host session via WebSocket:\n  ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN:uptermd.uptermd.dev:443\n```\n\n### Options\n\n```\n  -h, --help   help for proxy\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm](upterm.md)\t - Instant Terminal Sharing\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_session.md",
    "content": "## upterm session\n\nDisplay and manage terminal sessions\n\n### Options\n\n```\n  -h, --help   help for session\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm](upterm.md)\t - Instant Terminal Sharing\n* [upterm session current](upterm_session_current.md)\t - Display the current terminal session\n* [upterm session info](upterm_session_info.md)\t - Display terminal session by name\n* [upterm session list](upterm_session_list.md)\t - List shared sessions\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_session_current.md",
    "content": "## upterm session current\n\nDisplay the current terminal session\n\n### Synopsis\n\nDisplay the current terminal session.\n\nBy default, reads the admin socket path from $UPTERM_ADMIN_SOCKET (automatically set\nwhen you run 'upterm host').\n\nSockets are stored in: /run/user/1000/upterm\n\nFollows the XDG Base Directory Specification with fallback to $HOME/.upterm\nin constrained environments where XDG directories are unavailable.\n\nOutput formats:\n  -o json                           JSON output\n  -o go-template='{{.ClientCount}}' Custom Go template\n\nTemplate variables: SessionID, ClientCount, Host, Command, ForceCommand\n\n```\nupterm session current [flags]\n```\n\n### Examples\n\n```\n  # Display the active session as defined in $UPTERM_ADMIN_SOCKET:\n  upterm session current\n\n  # Output as JSON:\n  upterm session current -o json\n\n  # Custom format for shell prompt (outputs nothing if not in session):\n  upterm session current -o go-template='🆙 {{.ClientCount}} '\n\n  # For terminal title:\n  upterm session current -o go-template='upterm: {{.ClientCount}} clients | {{.SessionID}}'\n```\n\n### Options\n\n```\n      --admin-socket string   Admin socket path (required).\n  -h, --help                  help for current\n      --hide-client-ip        Hide client IP addresses from output (auto-enabled in CI environments).\n  -o, --output string         Output format: json or go-template='...'\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm session](upterm_session.md)\t - Display and manage terminal sessions\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_session_info.md",
    "content": "## upterm session info\n\nDisplay terminal session by name\n\n### Synopsis\n\nDisplay terminal session by name.\n\n```\nupterm session info [flags]\n```\n\n### Examples\n\n```\n  # Display session by name:\n  upterm session info NAME\n```\n\n### Options\n\n```\n  -h, --help             help for info\n      --hide-client-ip   Hide client IP addresses from output (auto-enabled in CI environments).\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm session](upterm_session.md)\t - Display and manage terminal sessions\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_session_list.md",
    "content": "## upterm session list\n\nList shared sessions\n\n### Synopsis\n\nList shared sessions.\n\nSockets are stored in: /run/user/1000/upterm\n\nFollows the XDG Base Directory Specification with fallback to $HOME/.upterm\nin constrained environments where XDG directories are unavailable.\n\n```\nupterm session list [flags]\n```\n\n### Examples\n\n```\n  # List shared sessions:\n  upterm session list\n```\n\n### Options\n\n```\n  -h, --help   help for list\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm session](upterm_session.md)\t - Display and manage terminal sessions\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_upgrade.md",
    "content": "## upterm upgrade\n\nUpgrade the CLI\n\n```\nupterm upgrade [flags]\n```\n\n### Examples\n\n```\n  # Upgrade to the latest version:\n  upterm upgrade\n\n  # Upgrade to a specific version:\n  upterm upgrade 0.2.0\n```\n\n### Options\n\n```\n  -h, --help   help for upgrade\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm](upterm.md)\t - Instant Terminal Sharing\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "docs/upterm_version.md",
    "content": "## upterm version\n\nShow version\n\n```\nupterm version [flags]\n```\n\n### Options\n\n```\n  -h, --help   help for version\n```\n\n### Options inherited from parent commands\n\n```\n      --debug   enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n```\n\n### SEE ALSO\n\n* [upterm](upterm.md)\t - Instant Terminal Sharing\n\n###### Auto generated by spf13/cobra on 3-May-2026\n"
  },
  {
    "path": "etc/completion/upterm.bash_completion.sh",
    "content": "# bash completion for upterm                               -*- shell-script -*-\n\n__upterm_debug()\n{\n    if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then\n        echo \"$*\" >> \"${BASH_COMP_DEBUG_FILE}\"\n    fi\n}\n\n# Homebrew on Macs have version 1.3 of bash-completion which doesn't include\n# _init_completion. This is a very minimal version of that function.\n__upterm_init_completion()\n{\n    COMPREPLY=()\n    _get_comp_words_by_ref \"$@\" cur prev words cword\n}\n\n__upterm_index_of_word()\n{\n    local w word=$1\n    shift\n    index=0\n    for w in \"$@\"; do\n        [[ $w = \"$word\" ]] && return\n        index=$((index+1))\n    done\n    index=-1\n}\n\n__upterm_contains_word()\n{\n    local w word=$1; shift\n    for w in \"$@\"; do\n        [[ $w = \"$word\" ]] && return\n    done\n    return 1\n}\n\n__upterm_handle_go_custom_completion()\n{\n    __upterm_debug \"${FUNCNAME[0]}: cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}\"\n\n    local shellCompDirectiveError=1\n    local shellCompDirectiveNoSpace=2\n    local shellCompDirectiveNoFileComp=4\n    local shellCompDirectiveFilterFileExt=8\n    local shellCompDirectiveFilterDirs=16\n\n    local out requestComp lastParam lastChar comp directive args\n\n    # Prepare the command to request completions for the program.\n    # Calling ${words[0]} instead of directly upterm allows handling aliases\n    args=(\"${words[@]:1}\")\n    # Disable ActiveHelp which is not supported for bash completion v1\n    requestComp=\"UPTERM_ACTIVE_HELP=0 ${words[0]} __completeNoDesc ${args[*]}\"\n\n    lastParam=${words[$((${#words[@]}-1))]}\n    lastChar=${lastParam:$((${#lastParam}-1)):1}\n    __upterm_debug \"${FUNCNAME[0]}: lastParam ${lastParam}, lastChar ${lastChar}\"\n\n    if [ -z \"${cur}\" ] && [ \"${lastChar}\" != \"=\" ]; then\n        # If the last parameter is complete (there is a space following it)\n        # We add an extra empty parameter so we can indicate this to the go method.\n        __upterm_debug \"${FUNCNAME[0]}: Adding extra empty parameter\"\n        requestComp=\"${requestComp} \\\"\\\"\"\n    fi\n\n    __upterm_debug \"${FUNCNAME[0]}: calling ${requestComp}\"\n    # Use eval to handle any environment variables and such\n    out=$(eval \"${requestComp}\" 2>/dev/null)\n\n    # Extract the directive integer at the very end of the output following a colon (:)\n    directive=${out##*:}\n    # Remove the directive\n    out=${out%:*}\n    if [ \"${directive}\" = \"${out}\" ]; then\n        # There is not directive specified\n        directive=0\n    fi\n    __upterm_debug \"${FUNCNAME[0]}: the completion directive is: ${directive}\"\n    __upterm_debug \"${FUNCNAME[0]}: the completions are: ${out}\"\n\n    if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then\n        # Error code.  No completion.\n        __upterm_debug \"${FUNCNAME[0]}: received error from custom completion go code\"\n        return\n    else\n        if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then\n            if [[ $(type -t compopt) = \"builtin\" ]]; then\n                __upterm_debug \"${FUNCNAME[0]}: activating no space\"\n                compopt -o nospace\n            fi\n        fi\n        if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then\n            if [[ $(type -t compopt) = \"builtin\" ]]; then\n                __upterm_debug \"${FUNCNAME[0]}: activating no file completion\"\n                compopt +o default\n            fi\n        fi\n    fi\n\n    if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then\n        # File extension filtering\n        local fullFilter filter filteringCmd\n        # Do not use quotes around the $out variable or else newline\n        # characters will be kept.\n        for filter in ${out}; do\n            fullFilter+=\"$filter|\"\n        done\n\n        filteringCmd=\"_filedir $fullFilter\"\n        __upterm_debug \"File filtering command: $filteringCmd\"\n        $filteringCmd\n    elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then\n        # File completion for directories only\n        local subdir\n        # Use printf to strip any trailing newline\n        subdir=$(printf \"%s\" \"${out}\")\n        if [ -n \"$subdir\" ]; then\n            __upterm_debug \"Listing directories in $subdir\"\n            __upterm_handle_subdirs_in_dir_flag \"$subdir\"\n        else\n            __upterm_debug \"Listing directories in .\"\n            _filedir -d\n        fi\n    else\n        while IFS='' read -r comp; do\n            COMPREPLY+=(\"$comp\")\n        done < <(compgen -W \"${out}\" -- \"$cur\")\n    fi\n}\n\n__upterm_handle_reply()\n{\n    __upterm_debug \"${FUNCNAME[0]}\"\n    local comp\n    case $cur in\n        -*)\n            if [[ $(type -t compopt) = \"builtin\" ]]; then\n                compopt -o nospace\n            fi\n            local allflags\n            if [ ${#must_have_one_flag[@]} -ne 0 ]; then\n                allflags=(\"${must_have_one_flag[@]}\")\n            else\n                allflags=(\"${flags[*]} ${two_word_flags[*]}\")\n            fi\n            while IFS='' read -r comp; do\n                COMPREPLY+=(\"$comp\")\n            done < <(compgen -W \"${allflags[*]}\" -- \"$cur\")\n            if [[ $(type -t compopt) = \"builtin\" ]]; then\n                [[ \"${COMPREPLY[0]}\" == *= ]] || compopt +o nospace\n            fi\n\n            # complete after --flag=abc\n            if [[ $cur == *=* ]]; then\n                if [[ $(type -t compopt) = \"builtin\" ]]; then\n                    compopt +o nospace\n                fi\n\n                local index flag\n                flag=\"${cur%=*}\"\n                __upterm_index_of_word \"${flag}\" \"${flags_with_completion[@]}\"\n                COMPREPLY=()\n                if [[ ${index} -ge 0 ]]; then\n                    PREFIX=\"\"\n                    cur=\"${cur#*=}\"\n                    ${flags_completion[${index}]}\n                    if [ -n \"${ZSH_VERSION:-}\" ]; then\n                        # zsh completion needs --flag= prefix\n                        eval \"COMPREPLY=( \\\"\\${COMPREPLY[@]/#/${flag}=}\\\" )\"\n                    fi\n                fi\n            fi\n\n            if [[ -z \"${flag_parsing_disabled}\" ]]; then\n                # If flag parsing is enabled, we have completed the flags and can return.\n                # If flag parsing is disabled, we may not know all (or any) of the flags, so we fallthrough\n                # to possibly call handle_go_custom_completion.\n                return 0;\n            fi\n            ;;\n    esac\n\n    # check if we are handling a flag with special work handling\n    local index\n    __upterm_index_of_word \"${prev}\" \"${flags_with_completion[@]}\"\n    if [[ ${index} -ge 0 ]]; then\n        ${flags_completion[${index}]}\n        return\n    fi\n\n    # we are parsing a flag and don't have a special handler, no completion\n    if [[ ${cur} != \"${words[cword]}\" ]]; then\n        return\n    fi\n\n    local completions\n    completions=(\"${commands[@]}\")\n    if [[ ${#must_have_one_noun[@]} -ne 0 ]]; then\n        completions+=(\"${must_have_one_noun[@]}\")\n    elif [[ -n \"${has_completion_function}\" ]]; then\n        # if a go completion function is provided, defer to that function\n        __upterm_handle_go_custom_completion\n    fi\n    if [[ ${#must_have_one_flag[@]} -ne 0 ]]; then\n        completions+=(\"${must_have_one_flag[@]}\")\n    fi\n    while IFS='' read -r comp; do\n        COMPREPLY+=(\"$comp\")\n    done < <(compgen -W \"${completions[*]}\" -- \"$cur\")\n\n    if [[ ${#COMPREPLY[@]} -eq 0 && ${#noun_aliases[@]} -gt 0 && ${#must_have_one_noun[@]} -ne 0 ]]; then\n        while IFS='' read -r comp; do\n            COMPREPLY+=(\"$comp\")\n        done < <(compgen -W \"${noun_aliases[*]}\" -- \"$cur\")\n    fi\n\n    if [[ ${#COMPREPLY[@]} -eq 0 ]]; then\n        if declare -F __upterm_custom_func >/dev/null; then\n            # try command name qualified custom func\n            __upterm_custom_func\n        else\n            # otherwise fall back to unqualified for compatibility\n            declare -F __custom_func >/dev/null && __custom_func\n        fi\n    fi\n\n    # available in bash-completion >= 2, not always present on macOS\n    if declare -F __ltrim_colon_completions >/dev/null; then\n        __ltrim_colon_completions \"$cur\"\n    fi\n\n    # If there is only 1 completion and it is a flag with an = it will be completed\n    # but we don't want a space after the =\n    if [[ \"${#COMPREPLY[@]}\" -eq \"1\" ]] && [[ $(type -t compopt) = \"builtin\" ]] && [[ \"${COMPREPLY[0]}\" == --*= ]]; then\n       compopt -o nospace\n    fi\n}\n\n# The arguments should be in the form \"ext1|ext2|extn\"\n__upterm_handle_filename_extension_flag()\n{\n    local ext=\"$1\"\n    _filedir \"@(${ext})\"\n}\n\n__upterm_handle_subdirs_in_dir_flag()\n{\n    local dir=\"$1\"\n    pushd \"${dir}\" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return\n}\n\n__upterm_handle_flag()\n{\n    __upterm_debug \"${FUNCNAME[0]}: c is $c words[c] is ${words[c]}\"\n\n    # if a command required a flag, and we found it, unset must_have_one_flag()\n    local flagname=${words[c]}\n    local flagvalue=\"\"\n    # if the word contained an =\n    if [[ ${words[c]} == *\"=\"* ]]; then\n        flagvalue=${flagname#*=} # take in as flagvalue after the =\n        flagname=${flagname%=*} # strip everything after the =\n        flagname=\"${flagname}=\" # but put the = back\n    fi\n    __upterm_debug \"${FUNCNAME[0]}: looking for ${flagname}\"\n    if __upterm_contains_word \"${flagname}\" \"${must_have_one_flag[@]}\"; then\n        must_have_one_flag=()\n    fi\n\n    # if you set a flag which only applies to this command, don't show subcommands\n    if __upterm_contains_word \"${flagname}\" \"${local_nonpersistent_flags[@]}\"; then\n      commands=()\n    fi\n\n    # keep flag value with flagname as flaghash\n    # flaghash variable is an associative array which is only supported in bash > 3.\n    if [[ -z \"${BASH_VERSION:-}\" || \"${BASH_VERSINFO[0]:-}\" -gt 3 ]]; then\n        if [ -n \"${flagvalue}\" ] ; then\n            flaghash[${flagname}]=${flagvalue}\n        elif [ -n \"${words[ $((c+1)) ]}\" ] ; then\n            flaghash[${flagname}]=${words[ $((c+1)) ]}\n        else\n            flaghash[${flagname}]=\"true\" # pad \"true\" for bool flag\n        fi\n    fi\n\n    # skip the argument to a two word flag\n    if [[ ${words[c]} != *\"=\"* ]] && __upterm_contains_word \"${words[c]}\" \"${two_word_flags[@]}\"; then\n        __upterm_debug \"${FUNCNAME[0]}: found a flag ${words[c]}, skip the next argument\"\n        c=$((c+1))\n        # if we are looking for a flags value, don't show commands\n        if [[ $c -eq $cword ]]; then\n            commands=()\n        fi\n    fi\n\n    c=$((c+1))\n\n}\n\n__upterm_handle_noun()\n{\n    __upterm_debug \"${FUNCNAME[0]}: c is $c words[c] is ${words[c]}\"\n\n    if __upterm_contains_word \"${words[c]}\" \"${must_have_one_noun[@]}\"; then\n        must_have_one_noun=()\n    elif __upterm_contains_word \"${words[c]}\" \"${noun_aliases[@]}\"; then\n        must_have_one_noun=()\n    fi\n\n    nouns+=(\"${words[c]}\")\n    c=$((c+1))\n}\n\n__upterm_handle_command()\n{\n    __upterm_debug \"${FUNCNAME[0]}: c is $c words[c] is ${words[c]}\"\n\n    local next_command\n    if [[ -n ${last_command} ]]; then\n        next_command=\"_${last_command}_${words[c]//:/__}\"\n    else\n        if [[ $c -eq 0 ]]; then\n            next_command=\"_upterm_root_command\"\n        else\n            next_command=\"_${words[c]//:/__}\"\n        fi\n    fi\n    c=$((c+1))\n    __upterm_debug \"${FUNCNAME[0]}: looking for ${next_command}\"\n    declare -F \"$next_command\" >/dev/null && $next_command\n}\n\n__upterm_handle_word()\n{\n    if [[ $c -ge $cword ]]; then\n        __upterm_handle_reply\n        return\n    fi\n    __upterm_debug \"${FUNCNAME[0]}: c is $c words[c] is ${words[c]}\"\n    if [[ \"${words[c]}\" == -* ]]; then\n        __upterm_handle_flag\n    elif __upterm_contains_word \"${words[c]}\" \"${commands[@]}\"; then\n        __upterm_handle_command\n    elif [[ $c -eq 0 ]]; then\n        __upterm_handle_command\n    elif __upterm_contains_word \"${words[c]}\" \"${command_aliases[@]}\"; then\n        # aliashash variable is an associative array which is only supported in bash > 3.\n        if [[ -z \"${BASH_VERSION:-}\" || \"${BASH_VERSINFO[0]:-}\" -gt 3 ]]; then\n            words[c]=${aliashash[${words[c]}]}\n            __upterm_handle_command\n        else\n            __upterm_handle_noun\n        fi\n    else\n        __upterm_handle_noun\n    fi\n    __upterm_handle_word\n}\n\n_upterm_config_edit()\n{\n    last_command=\"upterm_config_edit\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_config_help()\n{\n    last_command=\"upterm_config_help\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    has_completion_function=1\n    noun_aliases=()\n}\n\n_upterm_config_path()\n{\n    last_command=\"upterm_config_path\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_config_view()\n{\n    last_command=\"upterm_config_view\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_config()\n{\n    last_command=\"upterm_config\"\n\n    command_aliases=()\n\n    commands=()\n    commands+=(\"edit\")\n    commands+=(\"help\")\n    commands+=(\"path\")\n    commands+=(\"view\")\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_help()\n{\n    last_command=\"upterm_help\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    has_completion_function=1\n    noun_aliases=()\n}\n\n_upterm_host()\n{\n    last_command=\"upterm_host\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--accept\")\n    flags+=(\"--allow-local-tcp-forwarding\")\n    flags+=(\"--authorized-keys=\")\n    two_word_flags+=(\"--authorized-keys\")\n    flags+=(\"--codeberg-user=\")\n    two_word_flags+=(\"--codeberg-user\")\n    flags+=(\"--force-command=\")\n    two_word_flags+=(\"--force-command\")\n    two_word_flags+=(\"-f\")\n    flags+=(\"--github-user=\")\n    two_word_flags+=(\"--github-user\")\n    flags+=(\"--gitlab-user=\")\n    two_word_flags+=(\"--gitlab-user\")\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--hide-client-ip\")\n    flags+=(\"--known-hosts=\")\n    two_word_flags+=(\"--known-hosts\")\n    flags+=(\"--no-sftp\")\n    flags+=(\"--private-key=\")\n    two_word_flags+=(\"--private-key\")\n    two_word_flags+=(\"-i\")\n    flags+=(\"--read-only\")\n    flags+=(\"-r\")\n    flags+=(\"--server=\")\n    two_word_flags+=(\"--server\")\n    flags+=(\"--skip-host-key-check\")\n    flags+=(\"--srht-user=\")\n    two_word_flags+=(\"--srht-user\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_proxy()\n{\n    last_command=\"upterm_proxy\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_session_current()\n{\n    last_command=\"upterm_session_current\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--admin-socket=\")\n    two_word_flags+=(\"--admin-socket\")\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--hide-client-ip\")\n    local_nonpersistent_flags+=(\"--hide-client-ip\")\n    flags+=(\"--output=\")\n    two_word_flags+=(\"--output\")\n    two_word_flags+=(\"-o\")\n    local_nonpersistent_flags+=(\"--output\")\n    local_nonpersistent_flags+=(\"--output=\")\n    local_nonpersistent_flags+=(\"-o\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_session_help()\n{\n    last_command=\"upterm_session_help\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    has_completion_function=1\n    noun_aliases=()\n}\n\n_upterm_session_info()\n{\n    last_command=\"upterm_session_info\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--hide-client-ip\")\n    local_nonpersistent_flags+=(\"--hide-client-ip\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_session_list()\n{\n    last_command=\"upterm_session_list\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_session()\n{\n    last_command=\"upterm_session\"\n\n    command_aliases=()\n\n    commands=()\n    commands+=(\"current\")\n    if [[ -z \"${BASH_VERSION:-}\" || \"${BASH_VERSINFO[0]:-}\" -gt 3 ]]; then\n        command_aliases+=(\"c\")\n        aliashash[\"c\"]=\"current\"\n    fi\n    commands+=(\"help\")\n    commands+=(\"info\")\n    if [[ -z \"${BASH_VERSION:-}\" || \"${BASH_VERSINFO[0]:-}\" -gt 3 ]]; then\n        command_aliases+=(\"i\")\n        aliashash[\"i\"]=\"info\"\n    fi\n    commands+=(\"list\")\n    if [[ -z \"${BASH_VERSION:-}\" || \"${BASH_VERSINFO[0]:-}\" -gt 3 ]]; then\n        command_aliases+=(\"l\")\n        aliashash[\"l\"]=\"list\"\n        command_aliases+=(\"ls\")\n        aliashash[\"ls\"]=\"list\"\n    fi\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_upgrade()\n{\n    last_command=\"upterm_upgrade\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_version()\n{\n    last_command=\"upterm_version\"\n\n    command_aliases=()\n\n    commands=()\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n    flags+=(\"--debug\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n_upterm_root_command()\n{\n    last_command=\"upterm\"\n\n    command_aliases=()\n\n    commands=()\n    commands+=(\"config\")\n    commands+=(\"help\")\n    commands+=(\"host\")\n    commands+=(\"proxy\")\n    commands+=(\"session\")\n    if [[ -z \"${BASH_VERSION:-}\" || \"${BASH_VERSINFO[0]:-}\" -gt 3 ]]; then\n        command_aliases+=(\"se\")\n        aliashash[\"se\"]=\"session\"\n    fi\n    commands+=(\"upgrade\")\n    commands+=(\"version\")\n\n    flags=()\n    two_word_flags=()\n    local_nonpersistent_flags=()\n    flags_with_completion=()\n    flags_completion=()\n\n    flags+=(\"--debug\")\n    flags+=(\"--help\")\n    flags+=(\"-h\")\n    local_nonpersistent_flags+=(\"--help\")\n    local_nonpersistent_flags+=(\"-h\")\n\n    must_have_one_flag=()\n    must_have_one_noun=()\n    noun_aliases=()\n}\n\n__start_upterm()\n{\n    local cur prev words cword split\n    declare -A flaghash 2>/dev/null || :\n    declare -A aliashash 2>/dev/null || :\n    if declare -F _init_completion >/dev/null 2>&1; then\n        _init_completion -s || return\n    else\n        __upterm_init_completion -n \"=\" || return\n    fi\n\n    local c=0\n    local flag_parsing_disabled=\n    local flags=()\n    local two_word_flags=()\n    local local_nonpersistent_flags=()\n    local flags_with_completion=()\n    local flags_completion=()\n    local commands=(\"upterm\")\n    local command_aliases=()\n    local must_have_one_flag=()\n    local must_have_one_noun=()\n    local has_completion_function=\"\"\n    local last_command=\"\"\n    local nouns=()\n    local noun_aliases=()\n\n    __upterm_handle_word\n}\n\nif [[ $(type -t compopt) = \"builtin\" ]]; then\n    complete -o default -F __start_upterm upterm\nelse\n    complete -o default -o nospace -F __start_upterm upterm\nfi\n\n# ex: ts=4 sw=4 et filetype=sh\n"
  },
  {
    "path": "etc/completion/upterm.zsh_completion",
    "content": "#compdef upterm\ncompdef _upterm upterm\n\n# zsh completion for upterm                               -*- shell-script -*-\n\n__upterm_debug()\n{\n    local file=\"$BASH_COMP_DEBUG_FILE\"\n    if [[ -n ${file} ]]; then\n        echo \"$*\" >> \"${file}\"\n    fi\n}\n\n_upterm()\n{\n    local shellCompDirectiveError=1\n    local shellCompDirectiveNoSpace=2\n    local shellCompDirectiveNoFileComp=4\n    local shellCompDirectiveFilterFileExt=8\n    local shellCompDirectiveFilterDirs=16\n    local shellCompDirectiveKeepOrder=32\n\n    local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder\n    local -a completions\n\n    __upterm_debug \"\\n========= starting completion logic ==========\"\n    __upterm_debug \"CURRENT: ${CURRENT}, words[*]: ${words[*]}\"\n\n    # The user could have moved the cursor backwards on the command-line.\n    # We need to trigger completion from the $CURRENT location, so we need\n    # to truncate the command-line ($words) up to the $CURRENT location.\n    # (We cannot use $CURSOR as its value does not work when a command is an alias.)\n    words=(\"${=words[1,CURRENT]}\")\n    __upterm_debug \"Truncated words[*]: ${words[*]},\"\n\n    lastParam=${words[-1]}\n    lastChar=${lastParam[-1]}\n    __upterm_debug \"lastParam: ${lastParam}, lastChar: ${lastChar}\"\n\n    # For zsh, when completing a flag with an = (e.g., upterm -n=<TAB>)\n    # completions must be prefixed with the flag\n    setopt local_options BASH_REMATCH\n    if [[ \"${lastParam}\" =~ '-.*=' ]]; then\n        # We are dealing with a flag with an =\n        flagPrefix=\"-P ${BASH_REMATCH}\"\n    fi\n\n    # Prepare the command to obtain completions\n    requestComp=\"${words[1]} __complete ${words[2,-1]}\"\n    if [ \"${lastChar}\" = \"\" ]; then\n        # If the last parameter is complete (there is a space following it)\n        # We add an extra empty parameter so we can indicate this to the go completion code.\n        __upterm_debug \"Adding extra empty parameter\"\n        requestComp=\"${requestComp} \\\"\\\"\"\n    fi\n\n    __upterm_debug \"About to call: eval ${requestComp}\"\n\n    # Use eval to handle any environment variables and such\n    out=$(eval ${requestComp} 2>/dev/null)\n    __upterm_debug \"completion output: ${out}\"\n\n    # Extract the directive integer following a : from the last line\n    local lastLine\n    while IFS='\\n' read -r line; do\n        lastLine=${line}\n    done < <(printf \"%s\\n\" \"${out[@]}\")\n    __upterm_debug \"last line: ${lastLine}\"\n\n    if [ \"${lastLine[1]}\" = : ]; then\n        directive=${lastLine[2,-1]}\n        # Remove the directive including the : and the newline\n        local suffix\n        (( suffix=${#lastLine}+2))\n        out=${out[1,-$suffix]}\n    else\n        # There is no directive specified.  Leave $out as is.\n        __upterm_debug \"No directive found.  Setting do default\"\n        directive=0\n    fi\n\n    __upterm_debug \"directive: ${directive}\"\n    __upterm_debug \"completions: ${out}\"\n    __upterm_debug \"flagPrefix: ${flagPrefix}\"\n\n    if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then\n        __upterm_debug \"Completion received error. Ignoring completions.\"\n        return\n    fi\n\n    local activeHelpMarker=\"_activeHelp_ \"\n    local endIndex=${#activeHelpMarker}\n    local startIndex=$((${#activeHelpMarker}+1))\n    local hasActiveHelp=0\n    while IFS='\\n' read -r comp; do\n        # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker)\n        if [ \"${comp[1,$endIndex]}\" = \"$activeHelpMarker\" ];then\n            __upterm_debug \"ActiveHelp found: $comp\"\n            comp=\"${comp[$startIndex,-1]}\"\n            if [ -n \"$comp\" ]; then\n                compadd -x \"${comp}\"\n                __upterm_debug \"ActiveHelp will need delimiter\"\n                hasActiveHelp=1\n            fi\n\n            continue\n        fi\n\n        if [ -n \"$comp\" ]; then\n            # If requested, completions are returned with a description.\n            # The description is preceded by a TAB character.\n            # For zsh's _describe, we need to use a : instead of a TAB.\n            # We first need to escape any : as part of the completion itself.\n            comp=${comp//:/\\\\:}\n\n            local tab=\"$(printf '\\t')\"\n            comp=${comp//$tab/:}\n\n            __upterm_debug \"Adding completion: ${comp}\"\n            completions+=${comp}\n            lastComp=$comp\n        fi\n    done < <(printf \"%s\\n\" \"${out[@]}\")\n\n    # Add a delimiter after the activeHelp statements, but only if:\n    # - there are completions following the activeHelp statements, or\n    # - file completion will be performed (so there will be choices after the activeHelp)\n    if [ $hasActiveHelp -eq 1 ]; then\n        if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then\n            __upterm_debug \"Adding activeHelp delimiter\"\n            compadd -x \"--\"\n            hasActiveHelp=0\n        fi\n    fi\n\n    if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then\n        __upterm_debug \"Activating nospace.\"\n        noSpace=\"-S ''\"\n    fi\n\n    if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then\n        __upterm_debug \"Activating keep order.\"\n        keepOrder=\"-V\"\n    fi\n\n    if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then\n        # File extension filtering\n        local filteringCmd\n        filteringCmd='_files'\n        for filter in ${completions[@]}; do\n            if [ ${filter[1]} != '*' ]; then\n                # zsh requires a glob pattern to do file filtering\n                filter=\"\\*.$filter\"\n            fi\n            filteringCmd+=\" -g $filter\"\n        done\n        filteringCmd+=\" ${flagPrefix}\"\n\n        __upterm_debug \"File filtering command: $filteringCmd\"\n        _arguments '*:filename:'\"$filteringCmd\"\n    elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then\n        # File completion for directories only\n        local subdir\n        subdir=\"${completions[1]}\"\n        if [ -n \"$subdir\" ]; then\n            __upterm_debug \"Listing directories in $subdir\"\n            pushd \"${subdir}\" >/dev/null 2>&1\n        else\n            __upterm_debug \"Listing directories in .\"\n        fi\n\n        local result\n        _arguments '*:dirname:_files -/'\" ${flagPrefix}\"\n        result=$?\n        if [ -n \"$subdir\" ]; then\n            popd >/dev/null 2>&1\n        fi\n        return $result\n    else\n        __upterm_debug \"Calling _describe\"\n        if eval _describe $keepOrder \"completions\" completions $flagPrefix $noSpace; then\n            __upterm_debug \"_describe found some completions\"\n\n            # Return the success of having called _describe\n            return 0\n        else\n            __upterm_debug \"_describe did not find completions.\"\n            __upterm_debug \"Checking if we should do file completion.\"\n            if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then\n                __upterm_debug \"deactivating file completion\"\n\n                # We must return an error code here to let zsh know that there were no\n                # completions found by _describe; this is what will trigger other\n                # matching algorithms to attempt to find completions.\n                # For example zsh can match letters in the middle of words.\n                return 1\n            else\n                # Perform file completion\n                __upterm_debug \"Activating file completion\"\n\n                # We must return the result of this command, so it must be the\n                # last command, or else we must store its result to return it.\n                _arguments '*:filename:_files'\" ${flagPrefix}\"\n            fi\n        fi\n    fi\n}\n\n# don't run the completion function when being source-ed or eval-ed\nif [ \"$funcstack[1]\" = \"_upterm\" ]; then\n    _upterm\nfi\n"
  },
  {
    "path": "etc/man/man1/upterm-config-edit.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config-edit - Edit the config file\n\n\n.SH SYNOPSIS\n\\fBupterm config edit [flags]\\fP\n\n\n.SH DESCRIPTION\nEdit the config file in your default editor.\n\n.PP\nConfig file: /home/user/.config/upterm/config.yaml\n\n.PP\nThis command opens the config file in your editor (determined by $VISUAL, $EDITOR,\nor a sensible default). If the config file doesn't exist, it creates a template\nwith example settings and comments.\n\n.PP\nThe config directory is created automatically if it doesn't exist.\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for edit\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # Edit config file:\n  upterm config edit\n\n  # Use a specific editor:\n  EDITOR=nano upterm config edit\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm-config(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-config-path.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config-path - Show the path to the config file\n\n\n.SH SYNOPSIS\n\\fBupterm config path [flags]\\fP\n\n\n.SH DESCRIPTION\nShow the path to the config file.\n\n.PP\nConfig file: /home/user/.config/upterm/config.yaml\n\n.PP\nThe config file is optional and created manually by users.\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for path\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # Show config file path:\n  upterm config path\n\n  # Create config file directory:\n  mkdir -p \"$(dirname \"$(upterm config path)\")\"\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm-config(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-config-view.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config-view - View the config file contents\n\n\n.SH SYNOPSIS\n\\fBupterm config view [flags]\\fP\n\n\n.SH DESCRIPTION\nView the config file contents.\n\n.PP\nConfig file: /home/user/.config/upterm/config.yaml\n\n.PP\nIf the config file exists, this command displays its contents. If it doesn't\nexist, this command shows an example config file that you can use as a template.\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for view\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # View current config:\n  upterm config view\n\n  # View and save as new config:\n  upterm config view > \"$(upterm config path)\"\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm-config(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-config.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config - Manage upterm configuration\n\n\n.SH SYNOPSIS\n\\fBupterm config [flags]\\fP\n\n\n.SH DESCRIPTION\nManage upterm configuration file.\n\n.PP\nConfig file: /home/user/.config/upterm/config.yaml\n\n.PP\nThis follows the XDG Base Directory Specification.\n\n.PP\nConfiguration priority (highest to lowest):\n  1. Command-line flags\n  2. Environment variables (UPTERM_ prefix)\n  3. Config file\n  4. Default values\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for config\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH SEE ALSO\n\\fBupterm(1)\\fP, \\fBupterm-config-edit(1)\\fP, \\fBupterm-config-path(1)\\fP, \\fBupterm-config-view(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-host.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-host - Host a terminal session\n\n\n.SH SYNOPSIS\n\\fBupterm host [flags]\\fP\n\n\n.SH DESCRIPTION\nHost a terminal session via a reverse SSH tunnel to the Upterm server.\n\n.PP\nThe session links the host and client IO to a command's IO. Authentication with the\nUpterm server uses private keys in this order:\n  1. Private key files: ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa\n  2. SSH Agent keys\n  3. Auto-generated ephemeral key (if no keys found)\n\n.PP\nTo authorize client connections, use --authorized-keys to specify an authorized_keys file\ncontaining client public keys.\n\n\n.SH OPTIONS\n\\fB--accept\\fP[=false]\n\tAutomatically accept client connections without prompts.\n\n.PP\n\\fB--allow-local-tcp-forwarding\\fP[=false]\n\tAllow clients to use SSH local TCP forwarding (ssh -L) through the hosted session, reaching TCP destinations visible to the host.\n\n.PP\n\\fB--authorized-keys\\fP=\"\"\n\tSpecify a authorize_keys file listing authorized public keys for connection.\n\n.PP\n\\fB--codeberg-user\\fP=[]\n\tAuthorize specified Codeberg users by allowing their public keys to connect.\n\n.PP\n\\fB-f\\fP, \\fB--force-command\\fP=\"\"\n\tEnforce a specified command for clients to join, and link the command's input/output to the client's terminal.\n\n.PP\n\\fB--github-user\\fP=[]\n\tAuthorize specified GitHub users by allowing their public keys to connect. Configure GitHub CLI environment variables as needed; see https://cli.github.com/manual/gh_help_environment for details.\n\n.PP\n\\fB--gitlab-user\\fP=[]\n\tAuthorize specified GitLab users by allowing their public keys to connect.\n\n.PP\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for host\n\n.PP\n\\fB--hide-client-ip\\fP[=false]\n\tHide client IP addresses from output (auto-enabled in CI environments).\n\n.PP\n\\fB--known-hosts\\fP=\"/Users/owen/.ssh/known_hosts\"\n\tSpecify a file containing known keys for remote hosts (required).\n\n.PP\n\\fB--no-sftp\\fP[=false]\n\tDisable file transfer via SFTP/SCP. By default, clients can transfer files with the same access as the terminal session.\n\n.PP\n\\fB-i\\fP, \\fB--private-key\\fP=[/Users/owen/.ssh/id_ed25519]\n\tSpecify private key files for public key authentication with the upterm server (required).\n\n.PP\n\\fB-r\\fP, \\fB--read-only\\fP[=false]\n\tHost a read-only session, preventing client interaction. Also restricts SFTP to download-only.\n\n.PP\n\\fB--server\\fP=\"ssh://uptermd.upterm.dev:22\"\n\tSpecify the upterm server address (required). Supported protocols: ssh, ws, wss.\n\n.PP\n\\fB--skip-host-key-check\\fP[=false]\n\tAutomatically accept unknown server host keys and add them to known_hosts (similar to SSH's StrictHostKeyChecking=accept-new). This bypasses host key verification for new connections.\n\n.PP\n\\fB--srht-user\\fP=[]\n\tAuthorize specified SourceHut users by allowing their public keys to connect.\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # Host a terminal session running $SHELL, attaching client's IO to the host's:\n  upterm host\n\n  # Accept client connections automatically without prompts:\n  upterm host --accept\n\n  # Host a terminal session allowing only specified public key(s) to connect:\n  upterm host --authorized-keys PATH_TO_AUTHORIZED_KEY_FILE\n\n  # Host a session executing a custom command:\n  upterm host -- docker run --rm -ti ubuntu bash\n\n  # Host a 'tmux new -t pair-programming' session, forcing clients to join with 'tmux attach -t pair-programming':\n  upterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming\n\n  # Allow clients to use local TCP forwarding (ssh -L) through the hosted session:\n  upterm host --allow-local-tcp-forwarding\n\n  # Use a different Uptermd server, hosting a session via WebSocket:\n  upterm host --server wss://YOUR_UPTERMD_SERVER -- YOUR_COMMAND\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-proxy.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-proxy - Proxy a terminal session via WebSocket\n\n\n.SH SYNOPSIS\n\\fBupterm proxy [flags]\\fP\n\n\n.SH DESCRIPTION\nProxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand.\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for proxy\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # Host shares a session running $SHELL over WebSocket:\n  upterm host --server wss://uptermd.upterm.dev -- YOUR_COMMAND\n\n  # Client connects to the host session via WebSocket:\n  ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN:uptermd.uptermd.dev:443\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-session-current.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session-current - Display the current terminal session\n\n\n.SH SYNOPSIS\n\\fBupterm session current [flags]\\fP\n\n\n.SH DESCRIPTION\nDisplay the current terminal session.\n\n.PP\nBy default, reads the admin socket path from $UPTERM_ADMIN_SOCKET (automatically set\nwhen you run 'upterm host').\n\n.PP\nSockets are stored in: /run/user/1000/upterm\n\n.PP\nFollows the XDG Base Directory Specification with fallback to $HOME/.upterm\nin constrained environments where XDG directories are unavailable.\n\n.PP\nOutput formats:\n  -o json                           JSON output\n  -o go-template='{{.ClientCount}}' Custom Go template\n\n.PP\nTemplate variables: SessionID, ClientCount, Host, Command, ForceCommand\n\n\n.SH OPTIONS\n\\fB--admin-socket\\fP=\"\"\n\tAdmin socket path (required).\n\n.PP\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for current\n\n.PP\n\\fB--hide-client-ip\\fP[=false]\n\tHide client IP addresses from output (auto-enabled in CI environments).\n\n.PP\n\\fB-o\\fP, \\fB--output\\fP=\"\"\n\tOutput format: json or go-template='...'\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # Display the active session as defined in $UPTERM_ADMIN_SOCKET:\n  upterm session current\n\n  # Output as JSON:\n  upterm session current -o json\n\n  # Custom format for shell prompt (outputs nothing if not in session):\n  upterm session current -o go-template='🆙 {{.ClientCount}} '\n\n  # For terminal title:\n  upterm session current -o go-template='upterm: {{.ClientCount}} clients | {{.SessionID}}'\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm-session(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-session-info.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session-info - Display terminal session by name\n\n\n.SH SYNOPSIS\n\\fBupterm session info [flags]\\fP\n\n\n.SH DESCRIPTION\nDisplay terminal session by name.\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for info\n\n.PP\n\\fB--hide-client-ip\\fP[=false]\n\tHide client IP addresses from output (auto-enabled in CI environments).\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # Display session by name:\n  upterm session info NAME\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm-session(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-session-list.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session-list - List shared sessions\n\n\n.SH SYNOPSIS\n\\fBupterm session list [flags]\\fP\n\n\n.SH DESCRIPTION\nList shared sessions.\n\n.PP\nSockets are stored in: /run/user/1000/upterm\n\n.PP\nFollows the XDG Base Directory Specification with fallback to $HOME/.upterm\nin constrained environments where XDG directories are unavailable.\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for list\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # List shared sessions:\n  upterm session list\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm-session(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-session.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session - Display and manage terminal sessions\n\n\n.SH SYNOPSIS\n\\fBupterm session [flags]\\fP\n\n\n.SH DESCRIPTION\nDisplay and manage terminal sessions\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for session\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH SEE ALSO\n\\fBupterm(1)\\fP, \\fBupterm-session-current(1)\\fP, \\fBupterm-session-info(1)\\fP, \\fBupterm-session-list(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-upgrade.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-upgrade - Upgrade the CLI\n\n\n.SH SYNOPSIS\n\\fBupterm upgrade [flags]\\fP\n\n\n.SH DESCRIPTION\nUpgrade the CLI\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for upgrade\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH EXAMPLE\n.EX\n  # Upgrade to the latest version:\n  upterm upgrade\n\n  # Upgrade to a specific version:\n  upterm upgrade 0.2.0\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm-version.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-version - Show version\n\n\n.SH SYNOPSIS\n\\fBupterm version [flags]\\fP\n\n\n.SH DESCRIPTION\nShow version\n\n\n.SH OPTIONS\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for version\n\n\n.SH OPTIONS INHERITED FROM PARENT COMMANDS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n\n.SH SEE ALSO\n\\fBupterm(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "etc/man/man1/upterm.1",
    "content": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm - Instant Terminal Sharing\n\n\n.SH SYNOPSIS\n\\fBupterm [flags]\\fP\n\n\n.SH DESCRIPTION\nUpterm is an open-source solution for sharing terminal sessions instantly over secure SSH tunnels to the public internet.\n\n.PP\nConfiguration Priority (highest to lowest):\n  1. Command-line flags\n  2. Environment variables (UPTERM_ prefix)\n  3. Config file (see below)\n  4. Default values\n\n.PP\nConfig File:\n  ~/.config/upterm/config.yaml (Linux)\n  ~/Library/Application Support/upterm/config.yaml (macOS)\n  %LOCALAPPDATA%\\\\upterm\\\\config.yaml (Windows)\n\n.PP\nRun 'upterm config path' to see your config file location.\n  Run 'upterm config edit' to create and edit the config file.\n\n.PP\nEnvironment Variables:\n  All flags can be set via environment variables with the UPTERM_ prefix.\n  Flag names are converted by replacing hyphens (-) with underscores (_).\n\n.PP\nExamples:\n    --hide-client-ip  → UPTERM_HIDE_CLIENT_IP=true\n    --read-only       → UPTERM_READ_ONLY=true\n    --accept          → UPTERM_ACCEPT=true\n\n\n.SH OPTIONS\n\\fB--debug\\fP[=false]\n\tenable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).\n\n.PP\n\\fB-h\\fP, \\fB--help\\fP[=false]\n\thelp for upterm\n\n\n.SH EXAMPLE\n.EX\n  # Host a terminal session running $SHELL, attaching client's IO to the host's:\n  $ upterm host\n\n  # Display the SSH connection string for sharing with client(s):\n  $ upterm session current\n  === SESSION_ID\n  Command:                /bin/bash\n  Force Command:          n/a\n  Host:                   ssh://uptermd.upterm.dev:22\n  SSH Session:            ssh TOKEN@uptermd.upterm.dev\n\n  # A client connects to the host session via SSH:\n  $ ssh TOKEN@uptermd.upterm.dev\n\n  # Set flags via environment variables:\n  $ UPTERM_HIDE_CLIENT_IP=true upterm host\n.EE\n\n\n.SH SEE ALSO\n\\fBupterm-config(1)\\fP, \\fBupterm-host(1)\\fP, \\fBupterm-proxy(1)\\fP, \\fBupterm-session(1)\\fP, \\fBupterm-upgrade(1)\\fP, \\fBupterm-version(1)\\fP\n\n\n.SH HISTORY\n3-May-2026 Auto generated by spf13/cobra\n"
  },
  {
    "path": "fly.example.toml",
    "content": "# Example Fly.io configuration for deploying your own uptermd server\n# Copy this file to fly.toml and customize the app name and other settings\n\napp = \"my-uptermd-server\"  # Change this to your desired app name\nprimary_region = \"iad\"     # Change to your preferred region (iad, fra, nrt, etc.)\nkill_signal = \"SIGINT\"\nkill_timeout = \"5s\"\n\n[build]\ndockerfile = \"Dockerfile.uptermd\"\nbuild-target = \"uptermd-fly\"\n\n[experimental]\nentrypoint = [\"uptermd-fly\"]\n\n# Routing Configuration:\n# - If FLY_CONSUL_URL environment variable is set: uses Consul routing for multi-machine deployments\n# - If FLY_CONSUL_URL is not set: uses embedded routing for single-machine deployments (simpler setup)\n# For personal use, you don't need to set FLY_CONSUL_URL - embedded mode will be used automatically\n\n# Resource allocation - adjust based on your needs\n[vm]\ncpu_kind = \"shared\"\ncpus = 1\nmemory_mb = 256  # Increase if you expect high usage\n\n# SSH service (port 22)\n[[services]]\nprotocol = \"tcp\"\ninternal_port = 2222\nauto_stop_machines = false\nauto_start_machines = true\nmin_machines_running = 1  # Start with 1 for cost efficiency\n\n[[services.ports]]\nport = 22\nhandlers = [\"proxy_proto\"]\nproxy_proto_options = { version = \"v2\" }\n\n[services.concurrency]\ntype = \"connections\"\nhard_limit = 500   # Reduced for personal use\nsoft_limit = 400\n\n[[services.tcp_checks]]\ninterval = \"15s\"\ntimeout = \"2s\"\ngrace_period = \"5s\"\nrestart_limit = 3\n\n# WebSocket service (ports 80/443)  \n[[services]]\nprotocol = \"tcp\"\ninternal_port = 8080\nauto_stop_machines = false\nauto_start_machines = true\nmin_machines_running = 1  # Start with 1 for cost efficiency\n\n[[services.ports]]\nport = 80\nhandlers = [\"http\"]\nforce_https = true\n\n[[services.ports]]\nport = 443\nhandlers = [\"tls\", \"http\"]\n\n[services.concurrency]\ntype = \"connections\"\nhard_limit = 500   # Reduced for personal use\nsoft_limit = 400\n\n[[services.http_checks]]\ninterval = \"30s\"\ntimeout = \"5s\"\ngrace_period = \"10s\"\nrestart_limit = 3\npath = \"/health\"\nprotocol = \"http\""
  },
  {
    "path": "fly.toml",
    "content": "app = \"upterm\"\nkill_signal = \"SIGINT\"\nkill_timeout = \"5s\"\n\n[build]\ndockerfile = \"Dockerfile.uptermd\"\nbuild-target = \"uptermd-fly\"\n\n[metrics]\nport = 9091\npath = \"/metrics\"\n\n[experimental]\nentrypoint = [\"uptermd-fly\"]\n\n[vm]\ncpu_kind = \"shared\"\ncpus = 1\nmemory_mb = 256\n\n[[services]]\nprotocol = \"tcp\"\ninternal_port = 2222\nauto_stop_machines = false\nauto_start_machines = true\nmin_machines_running = 3\nprocesses = [\"app\"]\n\n[[services.ports]]\nport = 22\nhandlers = [\"proxy_proto\"]\nproxy_proto_options = { version = \"v2\" }\n\n[services.concurrency]\ntype = \"connections\"\nhard_limit = 2500\nsoft_limit = 2000\n\n[[services.tcp_checks]]\ninterval = \"15s\"\ntimeout = \"2s\"\ngrace_period = \"5s\"\nrestart_limit = 3\n\n[[services]]\nprotocol = \"tcp\"\ninternal_port = 8080\nauto_stop_machines = false\nauto_start_machines = true\nmin_machines_running = 3\nprocesses = [\"app\"]\n\n[[services.ports]]\nport = 80\nhandlers = [\"http\"]\nforce_https = true\n\n[[services.ports]]\nport = 443\nhandlers = [\"tls\", \"http\"]\n[services.concurrency]\ntype = \"connections\"\nhard_limit = 2500\nsoft_limit = 2000\n\n[[services.http_checks]]\ninterval = \"30s\"\ntimeout = \"5s\"\ngrace_period = \"10s\"\nrestart_limit = 3\npath = \"/health\"\nprotocol = \"http\"\n"
  },
  {
    "path": "ftests/client_test.go",
    "content": "package ftests\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/host\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc testHostNoAuthorizedKeyAnyClientJoin(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:         getTestShell(),\n\t\tPrivateKeys:     []string{HostPrivateKey},\n\t\tAdminSocketFile: adminSocketFile,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server - require session exists to continue\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\tc := &Client{\n\t\tPrivateKeys: []string{HostPrivateKey}, // use the wrong key\n\t}\n\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n}\n\nfunc testClientAuthorizedKeyNotMatching(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server - require session exists to continue\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\tc := &Client{\n\t\tPrivateKeys: []string{HostPrivateKey}, // use the wrong key\n\t}\n\n\terr = c.Join(session, clientJoinURL)\n\n\t// Test authorization failure - use assert for expected error validation\n\trequire.Error(err, \"connection should be rejected with wrong key\")\n\tassert.ErrorContains(err, \"ssh: handshake failed\", \"should fail with SSH handshake error\")\n}\n\nfunc testClientNonExistingSession(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\n\tdefer h.Close()\n\n\t// verify admin server\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// verify input/output\n\thostInputCh, hostOutputCh := h.InputOutput()\n\thostScanner := scanner(hostOutputCh)\n\n\thostInputCh <- `echo \"hello\"`\n\trequire.Equal(`echo \"hello\"`, scan(hostScanner))\n\trequire.Equal(\"hello\", scan(hostScanner))\n\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\tsession.SshUser = \"non-existing-user\" // set SSH user to non-existing\n\terr = c.Join(session, clientJoinURL)\n\n\t// Unfortunately there is no explicit error to the client.\n\t// But ssh handshake fails with the connection closed\n\trequire.ErrorContains(err, \"ssh: handshake failed\")\n}\n\nfunc testClientAttachHostWithSameCommand(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Setup - use require for critical setup steps\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// verify admin server\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// verify input/output\n\thostInputCh, hostOutputCh := h.InputOutput()\n\thostScanner := scanner(hostOutputCh)\n\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\n\tremoteInputCh, remoteOutputCh := c.InputOutput()\n\tremoteScanner := scanner(remoteOutputCh)\n\n\t// host input\n\thostInputCh <- `echo \"hello\"`\n\tassert.Equal(`echo \"hello\"`, scan(hostScanner), \"host should echo command\")\n\tassert.Equal(\"hello\", scan(hostScanner), \"host should show command output\")\n\n\t// client output\n\tassert.Equal(`echo \"hello\"`, scan(remoteScanner), \"client should see host command\")\n\tassert.Equal(\"hello\", scan(remoteScanner), \"client should see host output\")\n\n\t// client input\n\tremoteInputCh <- `echo \"hello again\"`\n\tassert.Equal(`echo \"hello again\"`, scan(remoteScanner), \"client should echo its own command\")\n\tassert.Equal(\"hello again\", scan(remoteScanner), \"client should see its own output\")\n\n\t// host output\n\t// host should link to remote with the same input/output\n\tassert.Equal(`echo \"hello again\"`, scan(hostScanner), \"host should see client command\")\n\tassert.Equal(\"hello again\", scan(hostScanner), \"host should see client output\")\n}\n\nfunc testClientAttachHostWithDifferentCommand(t *testing.T, hostShareURL string, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Setup - use require for critical setup steps\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tForceCommand:             getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// verify admin server\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// verify input/output\n\thostInputCh, hostOutputCh := h.InputOutput()\n\thostScanner := scanner(hostOutputCh)\n\n\thostInputCh <- `echo \"hello\"`\n\n\tassert.Equal(`echo \"hello\"`, scan(hostScanner), \"host should echo initial command\")\n\n\tassert.Equal(\"hello\", scan(hostScanner), \"host should show initial output\")\n\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\n\tremoteInputCh, remoteOutputCh := c.InputOutput()\n\tremoteScanner := scanner(remoteOutputCh)\n\n\t// Wait for ssh stdin/stdout to fully attach - critical for force command isolation\n\ttime.Sleep(time.Second)\n\n\tremoteInputCh <- `echo \"hello again\"`\n\n\tassert.Equal(`echo \"hello again\"`, scan(remoteScanner), \"client should echo its command\")\n\tassert.Equal(\"hello again\", scan(remoteScanner), \"client should see output\")\n\n\t// host shouldn't be linked to remote\n\thostInputCh <- `echo \"hello\"`\n\n\tassert.Equal(`echo \"hello\"`, scan(hostScanner), \"host should echo second command independently\")\n\tassert.Equal(\"hello\", scan(hostScanner), \"host should show second output independently\")\n}\n\nfunc testClientAttachReadOnly(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Setup - use require for critical setup steps\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t\tReadOnly:                 true,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// verify admin server\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// verify input/output\n\thostInputCh, hostOutputCh := h.InputOutput()\n\thostScanner := scanner(hostOutputCh)\n\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\n\tremoteInputCh, remoteOutputCh := c.InputOutput()\n\tremoteScanner := scanner(remoteOutputCh)\n\n\t// client output\n\t// client should get \"welcome message\"\n\t// \\n\n\t// === Attached to read-only session ===\n\t// \\n\n\tassert.Equal(\"=== Attached to read-only session ===\", scan(remoteScanner), \"client should see read-only welcome message\")\n\n\t// host input should still work\n\thostInputCh <- `echo \"hello\"`\n\n\tassert.Equal(`echo \"hello\"`, scan(hostScanner), \"host should echo command in read-only mode\")\n\tassert.Equal(\"hello\", scan(hostScanner), \"host should show output in read-only mode\")\n\n\t// Drain any buffered output (e.g., PowerShell prompts) before testing client input blocking\n\t// This prevents flaky failures where trailing shell output is mistaken for client input\n\tdrainTimeout := 100 * time.Millisecond\n\tdrained := false\n\tfor !drained {\n\t\tselect {\n\t\tcase str := <-hostOutputCh:\n\t\t\ttestLogger.Debug(\"drained buffered host output\", \"output\", str)\n\t\tcase <-time.After(drainTimeout):\n\t\t\tdrained = true\n\t\t}\n\t}\n\n\t// client input should be disabled\n\tremoteInputCh <- `echo \"hello again\"`\n\n\t// client should read what was sent by hostInputCh and not what was sent on remoteInputCh\n\tassert.Equal(`echo \"hello\"`, scan(remoteScanner), \"client should see host output, not its own input\")\n\n\tselect {\n\t// host shouldn't receive anything from client and because client input is disabled\n\tcase str := <-hostOutputCh:\n\t\tt.Fatalf(\"host shouldn't receive client input: receive=%s\", str)\n\tcase <-time.After(sshAttachTimeout):\n\t\ttestLogger.Debug(\"Read-only timeout confirmed - client input properly blocked\")\n\t\treturn\n\t}\n\n}\n\nfunc testClientLocalPortForward(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t\tAllowLocalTCPForwarding:  true,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\ttargetLn, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\trequire.NoError(err)\n\tdefer func() {\n\t\t_ = targetLn.Close()\n\t}()\n\n\ttargetErrCh := make(chan error, 1)\n\tgo func() {\n\t\tconn, err := targetLn.Accept()\n\t\tif err != nil {\n\t\t\ttargetErrCh <- err\n\t\t\treturn\n\t\t}\n\t\tdefer func() {\n\t\t\t_ = conn.Close()\n\t\t}()\n\n\t\tif err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {\n\t\t\ttargetErrCh <- err\n\t\t\treturn\n\t\t}\n\n\t\tbuf := make([]byte, 4)\n\t\tif _, err := io.ReadFull(conn, buf); err != nil {\n\t\t\ttargetErrCh <- err\n\t\t\treturn\n\t\t}\n\n\t\tif string(buf) != \"ping\" {\n\t\t\ttargetErrCh <- fmt.Errorf(\"unexpected forwarded payload: %q\", string(buf))\n\t\t\treturn\n\t\t}\n\n\t\t_, err = io.WriteString(conn, \"pong\")\n\t\ttargetErrCh <- err\n\t}()\n\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\tforwardedConn, err := c.sshClient.Dial(\"tcp\", targetLn.Addr().String())\n\trequire.NoError(err)\n\tdefer func() {\n\t\t_ = forwardedConn.Close()\n\t}()\n\n\tforwardErrCh := make(chan error, 1)\n\tgo func() {\n\t\tif _, err := io.WriteString(forwardedConn, \"ping\"); err != nil {\n\t\t\tforwardErrCh <- err\n\t\t\treturn\n\t\t}\n\n\t\treply := make([]byte, 4)\n\t\tif _, err := io.ReadFull(forwardedConn, reply); err != nil {\n\t\t\tforwardErrCh <- err\n\t\t\treturn\n\t\t}\n\n\t\tif string(reply) != \"pong\" {\n\t\t\tforwardErrCh <- fmt.Errorf(\"unexpected forwarded reply: %q\", string(reply))\n\t\t\treturn\n\t\t}\n\n\t\tforwardErrCh <- nil\n\t}()\n\n\tselect {\n\tcase err := <-forwardErrCh:\n\t\trequire.NoError(err)\n\tcase <-time.After(5 * time.Second):\n\t\tt.Fatal(\"timeout waiting for forwarded TCP round trip\")\n\t}\n\n\tselect {\n\tcase err := <-targetErrCh:\n\t\trequire.NoError(err)\n\tcase <-time.After(5 * time.Second):\n\t\tt.Fatal(\"timeout waiting for forwarded TCP target\")\n\t}\n}\n\nfunc testClientLocalPortForwardDisabled(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\tforwardedConn, err := c.sshClient.Dial(\"tcp\", \"127.0.0.1:1\")\n\tif forwardedConn != nil {\n\t\t_ = forwardedConn.Close()\n\t}\n\n\trequire.Error(err)\n\tassert.ErrorContains(err, \"port forwarding is disabled\")\n}\n\nfunc getAndVerifySession(t *testing.T, adminSocketFile string, wantHostURL, wantNodeURL string) *api.GetSessionResponse {\n\trequire := require.New(t)\n\n\tadminClient, err := host.AdminClient(adminSocketFile)\n\trequire.NoError(err)\n\n\tsess, err := adminClient.GetSession(context.Background(), &api.GetSessionRequest{})\n\trequire.NoError(err)\n\n\tcheckSessionPayload(t, sess, wantHostURL, wantNodeURL)\n\n\treturn sess\n}\n\nfunc checkSessionPayload(t *testing.T, sess *api.GetSessionResponse, wantHostURL, wantNodeURL string) {\n\trequire := require.New(t)\n\trequire.NotEmpty(sess.SessionId, \"session ID should not be empty\")\n\trequire.Equal(wantHostURL, sess.Host, \"host URL mismatch\")\n\trequire.Equal(wantNodeURL, sess.NodeAddr, \"node URL mismatch\")\n\trequire.NotEmpty(sess.SshUser, \"SSH user should not be empty\")\n}\n\n// testOldClientToNewConsulServer tests backward compatibility scenario where\n// an old upterm client (using embedded format) connects to a new uptermd server running in Consul mode\nfunc testOldClientToNewConsulServer(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:         getTestShell(),\n\t\tPrivateKeys:     []string{HostPrivateKey},\n\t\tAdminSocketFile: adminSocketFile,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Get session info from host (this is in the new format for Consul mode)\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// Create an embedded format SSH user (what old clients would send)\n\tembeddedEncoder := routing.NewEncodeDecoder(routing.ModeEmbedded)\n\toldClientSSHUser := embeddedEncoder.Encode(session.SessionId, session.NodeAddr)\n\n\tt.Logf(\"Testing backward compatibility:\")\n\tt.Logf(\"  Session ID: %s\", session.SessionId)\n\tt.Logf(\"  Node Address: %s\", session.NodeAddr)\n\tt.Logf(\"  New client SSH user (Consul format): %s\", session.SshUser)\n\tt.Logf(\"  Old client SSH user (embedded format): %s\", oldClientSSHUser)\n\n\t// Create a regular client but override the SSH username to simulate old client behavior\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\n\t// Create a modified session response with the old format SSH user\n\toldFormatSession := &api.GetSessionResponse{\n\t\tSessionId: session.SessionId,\n\t\tNodeAddr:  session.NodeAddr,\n\t\tHost:      session.Host,\n\t\tSshUser:   oldClientSSHUser, // Use old embedded format instead of Consul format\n\t}\n\n\t// This should work thanks to our backward compatibility fix\n\terr = c.Join(oldFormatSession, clientJoinURL)\n\trequire.NoError(err, \"Old client with embedded format should be able to connect to Consul server\")\n\tdefer c.Close()\n\n\tt.Log(\"Backward compatibility test passed: old client successfully connected to new Consul server\")\n}\n\n// setupAdminSocket creates a temporary admin socket and returns the socket file path\nfunc setupAdminSocket(t *testing.T) string {\n\trequire := require.New(t)\n\n\t// Use a shorter temp dir to avoid Unix socket path length limits\n\tadminSockDir, err := os.MkdirTemp(\"\", \"up\")\n\trequire.NoError(err)\n\n\tt.Cleanup(func() {\n\t\t_ = os.RemoveAll(adminSockDir)\n\t})\n\treturn filepath.Join(adminSockDir, \"u.sock\")\n}\n"
  },
  {
    "path": "ftests/ftests_test.go",
    "content": "package ftests\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"log/slog\"\n\n\t\"github.com/go-kit/kit/metrics/provider\"\n\t\"github.com/oklog/run\"\n\t\"github.com/owenthereal/upterm/host\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/internal/testhelpers\"\n\tuio \"github.com/owenthereal/upterm/io\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/server\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/owenthereal/upterm/ws\"\n\t\"github.com/pborman/ansi\"\n\t\"github.com/pkg/sftp\"\n\t\"github.com/stretchr/testify/suite\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nvar (\n\t// Shared debug logger for all tests\n\ttestLogger = logging.Must(logging.Console(), logging.Debug()).Logger\n)\n\n// getTestShell returns platform-appropriate shell command for tests\nfunc getTestShell() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\t// Prefer PowerShell Core (pwsh) if available, otherwise use Windows PowerShell (powershell)\n\t\t// -NonInteractive disables PSReadLine (no syntax highlighting/line editing)\n\t\t// -NoProfile/-NoLogo reduce startup noise\n\t\t// Tests must drain the initial \"PS >\" prompt\n\t\tshell := \"powershell\" // Default to Windows PowerShell (always available)\n\t\tif _, err := exec.LookPath(\"pwsh\"); err == nil {\n\t\t\tshell = \"pwsh\" // Use PowerShell Core if available\n\t\t}\n\t\treturn []string{shell, \"-NoProfile\", \"-NoLogo\", \"-NonInteractive\"}\n\t}\n\treturn []string{\"bash\", \"-c\", \"PS1='' BASH_SILENCE_DEPRECATION_WARNING=1 bash --norc\"}\n}\n\nconst (\n\tserverStartupTimeout  = 3 * time.Second\n\tunixSocketWaitTimeout = 3 * time.Second\n\tkeepAliveDuration     = 2 * time.Second\n\tsshAttachTimeout      = 500 * time.Millisecond\n\n\t// Test key material\n\tServerPublicKeyContent  = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA7wM3URdkoip/GKliykxrkz5k5U9OeX3y/bE0Nz/Pl6`\n\tServerPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACAO8DN1EXZKIqfxipYspMa5M+ZOVPTnl98v2xNDc/z5egAAAIj7+f6n+/n+\npwAAAAtzc2gtZWQyNTUxOQAAACAO8DN1EXZKIqfxipYspMa5M+ZOVPTnl98v2xNDc/z5eg\nAAAECJxt3qnAWGGklvhi4HTwyzY3EdjOAKpgXvcYTX6mDa+g7wM3URdkoip/GKliykxrkz\n5k5U9OeX3y/bE0Nz/Pl6AAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----`\n\tHostPublicKeyContent  = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOA+rMcwWFPJVE2g6EwRPkYmNJfaS/+gkyZ99aR/65uz`\n\tHostPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDgPqzHMFhTyVRNoOhMET5GJjSX2kv/oJMmffWkf+ubswAAAIiu5GOBruRj\ngQAAAAtzc2gtZWQyNTUxOQAAACDgPqzHMFhTyVRNoOhMET5GJjSX2kv/oJMmffWkf+ubsw\nAAAEDBHlsR95C/pGVHtQGpgrUi+Qwgkfnp9QlRKdEhhx4rxOA+rMcwWFPJVE2g6EwRPkYm\nNJfaS/+gkyZ99aR/65uzAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----`\n\tClientPublicKeyContent  = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdcHcuMfI8bGAyHPcGsAc/vd/gl5673pRkRBGY`\n\tClientPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDdBFq43XB3LjHyPGxgMhz3BrAHP73f4Jeeu96UZEQRmAAAAIiRPFazkTxW\nswAAAAtzc2gtZWQyNTUxOQAAACDdBFq43XB3LjHyPGxgMhz3BrAHP73f4Jeeu96UZEQRmA\nAAAEDmpjZHP/SIyBTp6YBFPzUi18iDo2QHolxGRDpx+m7let0EWrjdcHcuMfI8bGAyHPcG\nsAc/vd/gl5673pRkRBGYAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----`\n)\n\nvar (\n\tHostPrivateKey   string\n\tClientPrivateKey string\n)\n\n// FtestCase represents a functional test case\ntype FtestCase func(t *testing.T, hostURL, hostNodeAddr, clientJoinURL string)\n\n// AuthTestCases contains all authentication-related test functions\nvar AuthTestCases = []FtestCase{\n\ttestHostNoAuthorizedKeyAnyClientJoin,\n\ttestClientAuthorizedKeyNotMatching,\n\ttestHostFailToShareWithoutPrivateKey,\n}\n\n// SessionTestCases contains all session management test functions\nvar SessionTestCases = []FtestCase{\n\ttestClientNonExistingSession,\n}\n\n// ConnectionTestCases contains all connection-related test functions\nvar ConnectionTestCases = []FtestCase{\n\ttestClientAttachHostWithSameCommand,\n\ttestClientAttachHostWithDifferentCommand,\n\ttestClientAttachReadOnly,\n\ttestClientLocalPortForwardDisabled,\n\ttestClientLocalPortForward,\n}\n\n// CallbackTestCases contains all callback/event-related test functions\nvar CallbackTestCases = []FtestCase{\n\ttestHostClientCallback,\n\ttestHostSessionCreatedCallback,\n}\n\n// BackwardCompatibilityTestCases contains tests for backward compatibility scenarios\nvar BackwardCompatibilityTestCases = []FtestCase{\n\ttestOldClientToNewConsulServer,\n}\n\n// FtestSuite runs functional tests with different session routing modes\ntype FtestSuite struct {\n\tsuite.Suite\n\tmode routing.Mode\n\tts1  TestServer\n\tts2  TestServer\n}\n\nfunc (suite *FtestSuite) SetupSuite() {\n\t// Setup key pairs\n\tremove, err := setupKeyPairs()\n\tsuite.Require().NoError(err)\n\tsuite.T().Cleanup(remove)\n\n\t// Create test servers with the specified routing mode\n\tsuite.ts1, err = NewServerWithMode(ServerPrivateKeyContent, suite.mode)\n\tsuite.Require().NoError(err)\n\n\tsuite.ts2, err = NewServerWithMode(ServerPrivateKeyContent, suite.mode)\n\tsuite.Require().NoError(err)\n}\n\nfunc (suite *FtestSuite) TearDownSuite() {\n\tif suite.ts1 != nil {\n\t\t_ = suite.ts1.Shutdown()\n\t}\n\tif suite.ts2 != nil {\n\t\t_ = suite.ts2.Shutdown()\n\t}\n}\n\nfunc (suite *FtestSuite) TestAuth() {\n\tsuite.runTestCategory(AuthTestCases)\n}\n\nfunc (suite *FtestSuite) TestSession() {\n\tsuite.runTestCategory(SessionTestCases)\n}\n\nfunc (suite *FtestSuite) TestConnection() {\n\tsuite.runTestCategory(ConnectionTestCases)\n}\n\nfunc (suite *FtestSuite) TestCallbacks() {\n\tsuite.runTestCategory(CallbackTestCases)\n}\n\nfunc (suite *FtestSuite) TestBackwardCompatibility() {\n\t// Only run backward compatibility tests in Consul mode\n\t// (since embedded mode doesn't need backward compatibility)\n\tif suite.mode != routing.ModeConsul {\n\t\tsuite.T().Skip(\"Backward compatibility tests only run in Consul mode\")\n\t\treturn\n\t}\n\tsuite.runTestCategory(BackwardCompatibilityTestCases)\n}\n\nfunc (suite *FtestSuite) runTestCategory(testCases []FtestCase) {\n\tprotocols := []string{\"ssh\", \"ws\"}\n\n\tfor _, protocol := range protocols {\n\t\tsuite.T().Run(protocol, func(t *testing.T) {\n\t\t\tsuite.runTestsForProtocol(protocol, testCases)\n\t\t})\n\t}\n}\n\nfunc (suite *FtestSuite) runTestsForProtocol(protocol string, testCases []FtestCase) {\n\ttopologies := []struct {\n\t\tname      string\n\t\thostURL   string\n\t\tclientURL string\n\t}{\n\t\t{\n\t\t\tname:      \"singleNode\",\n\t\t\thostURL:   protocol + \"://\" + suite.getServerAddr(protocol, suite.ts1),\n\t\t\tclientURL: protocol + \"://\" + suite.getServerAddr(protocol, suite.ts1),\n\t\t},\n\t\t{\n\t\t\tname:      \"multiNodes\",\n\t\t\thostURL:   protocol + \"://\" + suite.getServerAddr(protocol, suite.ts1),\n\t\t\tclientURL: protocol + \"://\" + suite.getServerAddr(protocol, suite.ts2),\n\t\t},\n\t}\n\n\tfor _, topo := range topologies {\n\t\tsuite.T().Run(topo.name, func(t *testing.T) {\n\t\t\tfor _, testFunc := range testCases {\n\t\t\t\ttestName := funcName(testFunc)\n\t\t\t\tt.Run(testName, func(t *testing.T) {\n\t\t\t\t\tt.Parallel()\n\t\t\t\t\ttestFunc(t, topo.hostURL, suite.ts1.NodeAddr(), topo.clientURL)\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc (suite *FtestSuite) getServerAddr(protocol string, server TestServer) string {\n\tif protocol == \"ssh\" {\n\t\treturn server.SSHAddr()\n\t}\n\treturn server.WSAddr()\n}\n\n// Test runners for different modes\nfunc TestEmbedded(t *testing.T) {\n\tsuite.Run(t, &FtestSuite{mode: routing.ModeEmbedded})\n}\n\nfunc TestConsul(t *testing.T) {\n\t// Skip if Consul is not available\n\tif !testhelpers.IsConsulAvailable() {\n\t\tt.Skip(\"Consul not available - set CONSUL_URL or ensure Consul is running on localhost:8500\")\n\t}\n\tsuite.Run(t, &FtestSuite{mode: routing.ModeConsul})\n}\n\nfunc mustParseURL(urlStr string) *url.URL {\n\tu, err := url.Parse(urlStr)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"failed to parse URL %s: %v\", urlStr, err))\n\t}\n\treturn u\n}\n\nfunc funcName(i interface{}) string {\n\tname := runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()\n\tsplit := strings.Split(name, \".\")\n\n\treturn split[len(split)-1]\n}\n\ntype TestServer interface {\n\tSSHAddr() string\n\tWSAddr() string\n\tNodeAddr() string\n\tShutdown() error\n}\n\nfunc NewServerWithMode(hostKey string, mode routing.Mode) (TestServer, error) {\n\tsshln, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create SSH listener: %w\", err)\n\t}\n\n\twsln, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\t_ = sshln.Close()\n\t\treturn nil, fmt.Errorf(\"failed to create WebSocket listener: %w\", err)\n\t}\n\n\ts := &Server{\n\t\thostKeyContent: hostKey,\n\t\tsshln:          sshln,\n\t\twsln:           wsln,\n\t\tmode:           mode,\n\t}\n\n\t// Start server in background\n\tstartErrCh := make(chan error, 1)\n\tgo func() {\n\t\tif err := s.start(); err != nil {\n\t\t\ttestLogger.Error(\"error starting test server\", \"error\", err, \"mode\", mode)\n\t\t\tstartErrCh <- err\n\t\t}\n\t}()\n\n\t// Wait for server to start with timeout\n\tctx, cancel := context.WithTimeout(context.Background(), serverStartupTimeout)\n\tdefer cancel()\n\n\t// Wait for SSH server\n\tif err := utils.WaitForServer(ctx, s.SSHAddr()); err != nil {\n\t\t_ = s.Shutdown()\n\t\treturn nil, fmt.Errorf(\"SSH server failed to start: %w\", err)\n\t}\n\n\t// Wait for WebSocket server\n\tif err := utils.WaitForServer(ctx, s.WSAddr()); err != nil {\n\t\t_ = s.Shutdown()\n\t\treturn nil, fmt.Errorf(\"WebSocket server failed to start: %w\", err)\n\t}\n\n\t// Check for startup errors\n\tselect {\n\tcase err := <-startErrCh:\n\t\t_ = s.Shutdown()\n\t\treturn nil, fmt.Errorf(\"server startup failed: %w\", err)\n\tdefault:\n\t}\n\n\treturn s, nil\n}\n\ntype Server struct {\n\tServer *server.Server\n\n\tsshln          net.Listener\n\twsln           net.Listener\n\thostKeyContent string\n\tmode           routing.Mode\n\tlogger         *slog.Logger\n\n\tshutdownOnce sync.Once\n\tmu           sync.RWMutex\n}\n\nfunc (s *Server) start() error {\n\tsigners, err := utils.CreateSigners([][]byte{[]byte(s.hostKeyContent)})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create signers: %w\", err)\n\t}\n\n\tvar hostSigners []ssh.Signer\n\tfor _, signer := range signers {\n\t\tcs := server.HostCertSigner{\n\t\t\tHostnames: []string{\"127.0.0.1\"},\n\t\t}\n\t\thostSigner, err := cs.SignCert(signer)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to sign host certificate: %w\", err)\n\t\t}\n\n\t\thostSigners = append(hostSigners, hostSigner)\n\t}\n\n\tnetwork := &server.MemoryProvider{}\n\tif err := network.SetOpts(nil); err != nil {\n\t\treturn fmt.Errorf(\"failed to set network provider options: %w\", err)\n\t}\n\n\tlogger := testLogger.With(\n\t\t\"mode\", s.mode,\n\t\t\"ssh\", s.SSHAddr(),\n\t\t\"ws\", s.WSAddr(),\n\t)\n\ts.logger = logger\n\n\t// Create session manager based on the mode\n\tvar sm *server.SessionManager\n\tswitch s.mode {\n\tcase routing.ModeEmbedded:\n\t\tsm, err = server.NewSessionManager(\n\t\t\trouting.ModeEmbedded,\n\t\t\tserver.WithSessionManagerLogger(logger),\n\t\t)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create embedded session manager: %w\", err)\n\t\t}\n\tcase routing.ModeConsul:\n\t\tsm, err = server.NewSessionManager(\n\t\t\trouting.ModeConsul,\n\t\t\tserver.WithSessionManagerLogger(logger),\n\t\t\tserver.WithSessionManagerConsulURL(mustParseURL(testhelpers.ConsulURL())),\n\t\t)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create consul session manager: %w\", err)\n\t\t}\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported routing mode: %s\", s.mode)\n\t}\n\n\ts.mu.Lock()\n\ts.Server = &server.Server{\n\t\tNodeAddr:        s.SSHAddr(), // node addr is hard coded to ssh addr\n\t\tHostSigners:     hostSigners,\n\t\tSigners:         signers,\n\t\tNetworkProvider: network,\n\t\tMetricsProvider: provider.NewDiscardProvider(),\n\t\tSessionManager:  sm,\n\t\tLogger:          logger,\n\t}\n\ts.mu.Unlock()\n\n\treturn s.Server.ServeWithContext(context.Background(), s.sshln, s.wsln)\n}\n\nfunc (s *Server) SSHAddr() string {\n\treturn s.sshln.Addr().String()\n}\n\nfunc (s *Server) WSAddr() string {\n\treturn s.wsln.Addr().String()\n}\n\nfunc (s *Server) NodeAddr() string {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\tif s.Server == nil {\n\t\treturn \"\"\n\t}\n\treturn s.Server.NodeAddr\n}\n\nfunc (s *Server) Shutdown() error {\n\tvar err error\n\ts.shutdownOnce.Do(func() {\n\t\tif s.logger != nil {\n\t\t\ts.logger.Info(\"shutting down test server\")\n\t\t}\n\n\t\tif s.Server != nil {\n\t\t\terr = s.Server.Shutdown()\n\t\t}\n\t})\n\treturn err\n}\n\ntype Host struct {\n\t*host.Host\n\n\tCommand                  []string\n\tForceCommand             []string\n\tPrivateKeys              []string\n\tAdminSocketFile          string\n\tSessionCreatedCallback   func(context.Context, *api.GetSessionResponse) error\n\tClientJoinedCallback     func(*api.Client)\n\tClientLeftCallback       func(*api.Client)\n\tPermittedClientPublicKey string\n\tAllowLocalTCPForwarding  bool\n\tReadOnly                 bool\n\tSFTPDisabled             bool // Disable SFTP subsystem\n\tinputCh                  chan string\n\toutputCh                 chan string\n\tctx                      context.Context\n\tcancel                   func()\n\twg                       sync.WaitGroup\n}\n\nfunc (c *Host) Close() {\n\t// Cancel context to signal goroutines to stop\n\tc.cancel()\n\n\t// Close input channel to unblock the input goroutine\n\tif c.inputCh != nil {\n\t\tclose(c.inputCh)\n\t}\n\n\t// Wait for all goroutines to finish with a timeout\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tc.wg.Wait()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\t\t// Clean shutdown completed\n\tcase <-time.After(2 * time.Second):\n\t\t// Timeout - goroutines didn't finish in time, but that's okay for tests\n\t\ttestLogger.Warn(\"timeout waiting for host goroutines to finish\")\n\t}\n}\n\nfunc (c *Host) init() {\n\tc.ctx, c.cancel = context.WithCancel(context.Background())\n\tc.inputCh = make(chan string)\n\tc.outputCh = make(chan string)\n}\n\nfunc (c *Host) Share(url string) error {\n\tc.init()\n\n\tstdinr, stdinw, err := os.Pipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tstdoutr, stdoutw, err := os.Pipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsigners, err := host.SignersFromFiles(c.PrivateKeys)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// permit client public key\n\tvar authorizedKeys []*host.AuthorizedKey\n\tif c.PermittedClientPublicKey != \"\" {\n\t\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(c.PermittedClientPublicKey))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tauthorizedKeys = append(authorizedKeys, &host.AuthorizedKey{PublicKeys: []ssh.PublicKey{pk}})\n\t}\n\n\tif c.AdminSocketFile == \"\" {\n\t\treturn fmt.Errorf(\"AdminSocketFile is required but not set\")\n\t}\n\n\tlogger := testLogger\n\n\tc.Host = &host.Host{\n\t\tHost:                           url,\n\t\tCommand:                        c.Command,\n\t\tForceCommand:                   c.ForceCommand,\n\t\tSigners:                        signers,\n\t\tAuthorizedKeys:                 authorizedKeys,\n\t\tAdminSocketFile:                c.AdminSocketFile,\n\t\tSessionCreatedCallback:         c.SessionCreatedCallback,\n\t\tClientJoinedCallback:           c.ClientJoinedCallback,\n\t\tClientLeftCallback:             c.ClientLeftCallback,\n\t\tKeepAliveDuration:              keepAliveDuration,\n\t\tLogger:                         logger,\n\t\tHostKeyCallback:                ssh.InsecureIgnoreHostKey(),\n\t\tStdin:                          stdinr,\n\t\tStdout:                         stdoutw,\n\t\tAllowLocalTCPForwarding:        c.AllowLocalTCPForwarding,\n\t\tReadOnly:                       c.ReadOnly,\n\t\tSFTPDisabled:                   c.SFTPDisabled,\n\t\tForceForwardingInputForTesting: true,\n\t}\n\n\terrCh := make(chan error)\n\tgo func() {\n\t\tif err := c.Run(c.ctx); err != nil {\n\t\t\ttestLogger.Error(\"error running host\", \"error\", err)\n\t\t\terrCh <- err\n\t\t}\n\t}()\n\n\tif err := waitForUnixSocket(c.AdminSocketFile, errCh); err != nil {\n\t\treturn err\n\t}\n\n\t// Start I/O goroutines with proper synchronization\n\tc.wg.Add(2)\n\n\t// output - reads from stdout and forwards to output channel\n\tgo func() {\n\t\tdefer c.wg.Done()\n\t\tw := writeFunc(func(p []byte) (int, error) {\n\t\t\tb, err := ansi.Strip(p)\n\t\t\tif err != nil {\n\t\t\t\t// Ignore ANSI parsing errors (e.g., malformed OSC sequences)\n\t\t\t\t// and use the original bytes instead\n\t\t\t\ttestLogger.Warn(\"failed to strip ANSI codes\", \"p\", p, \"error\", err)\n\t\t\t\tb = p\n\t\t\t}\n\t\t\t// Use select to respect context cancellation when sending to channel\n\t\t\tselect {\n\t\t\tcase c.outputCh <- string(b):\n\t\t\tcase <-c.ctx.Done():\n\t\t\t\treturn 0, c.ctx.Err()\n\t\t\t}\n\t\t\treturn len(p), nil\n\t\t})\n\t\t_, _ = io.Copy(w, stdoutr)\n\t}()\n\n\t// input - reads from input channel and forwards to stdin\n\tgo func() {\n\t\tdefer c.wg.Done()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase str, ok := <-c.inputCh:\n\t\t\t\tif !ok {\n\t\t\t\t\t// Channel closed, exit goroutine\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// On Windows, cmd.exe needs \\r\\n to execute commands\n\t\t\t\tlineEnding := \"\\n\"\n\t\t\t\tif runtime.GOOS == \"windows\" {\n\t\t\t\t\tlineEnding = \"\\r\\n\"\n\t\t\t\t}\n\t\t\t\tif _, err := io.Copy(stdinw, bytes.NewBufferString(str+lineEnding)); err != nil {\n\t\t\t\t\ttestLogger.Error(\"error copying to stdin\", \"error\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\tcase <-c.ctx.Done():\n\t\t\t\t// Context cancelled, exit goroutine\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (c *Host) InputOutput() (chan string, chan string) {\n\treturn c.inputCh, c.outputCh\n}\n\ntype Client struct {\n\tPrivateKeys []string\n\tsshClient   *ssh.Client\n\tsession     *ssh.Session\n\tsshStdin    io.WriteCloser\n\tsshStdout   io.Reader\n\tinputCh     chan string\n\toutputCh    chan string\n\tcancel      func()\n\twg          sync.WaitGroup\n}\n\nfunc (c *Client) init() {\n\tc.inputCh = make(chan string)\n\tc.outputCh = make(chan string)\n}\n\nfunc (c *Client) InputOutput() (chan string, chan string) {\n\treturn c.inputCh, c.outputCh\n}\n\n// SFTP returns an SFTP client using the existing SSH connection.\n// The caller is responsible for closing the returned SFTP client.\nfunc (c *Client) SFTP() (*sftp.Client, error) {\n\tif c.sshClient == nil {\n\t\treturn nil, fmt.Errorf(\"SSH client not connected\")\n\t}\n\treturn sftp.NewClient(c.sshClient)\n}\n\nfunc (c *Client) Close() {\n\t// Cancel context to signal goroutines to stop\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\n\t// Close input channel to unblock input goroutine\n\tif c.inputCh != nil {\n\t\tclose(c.inputCh)\n\t}\n\n\t// Wait for goroutines to finish with a timeout\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tc.wg.Wait()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\t\t// Clean shutdown completed\n\tcase <-time.After(2 * time.Second):\n\t\t// Timeout - goroutines didn't finish in time\n\t\ttestLogger.Warn(\"timeout waiting for client goroutines to finish\")\n\t}\n\n\t// Now close the session and client\n\tif c.session != nil {\n\t\t_ = c.session.Close()\n\t}\n\tif c.sshClient != nil {\n\t\t_ = c.sshClient.Close()\n\t}\n}\n\nfunc (c *Client) JoinWithContext(ctx context.Context, session *api.GetSessionResponse, clientJoinURL string) error {\n\tc.init()\n\n\tauths, err := authMethodsFromFiles(c.PrivateKeys)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tconfig := &ssh.ClientConfig{\n\t\tUser:            session.SshUser,\n\t\tAuth:            auths,\n\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(),\n\t}\n\n\tu, err := url.Parse(clientJoinURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif u.Scheme == \"ws\" || u.Scheme == \"wss\" {\n\t\tencodedNodeAddr := base64.URLEncoding.EncodeToString([]byte(session.NodeAddr))\n\t\tu, _ = url.Parse(u.String())\n\t\tu.User = url.UserPassword(session.SessionId, encodedNodeAddr)\n\t\tc.sshClient, err = ws.NewSSHClient(u, config, true)\n\t} else {\n\t\tc.sshClient, err = ssh.Dial(\"tcp\", u.Host, config)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.session, err = c.sshClient.NewSession()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err = c.session.RequestPty(\"xterm\", 40, 80, ssh.TerminalModes{}); err != nil {\n\t\treturn err\n\t}\n\n\tc.sshStdin, err = c.session.StdinPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.sshStdout, err = c.session.StdoutPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err = c.session.Shell(); err != nil {\n\t\treturn err\n\t}\n\n\tvar g run.Group\n\tctx, cancel := context.WithCancel(ctx)\n\tc.cancel = cancel // Store cancel function for cleanup\n\t{\n\t\t// output\n\t\tg.Add(func() error {\n\t\t\tw := writeFunc(func(pp []byte) (int, error) {\n\t\t\t\tb, err := ansi.Strip(pp)\n\t\t\t\tif err != nil {\n\t\t\t\t\t// Ignore ANSI parsing errors (e.g., malformed OSC sequences)\n\t\t\t\t\t// and use the original bytes instead\n\t\t\t\t\ttestLogger.Warn(\"failed to strip ANSI codes\", \"pp\", pp, \"error\", err)\n\t\t\t\t\tb = pp\n\t\t\t\t}\n\t\t\t\t// Use select to respect context cancellation\n\t\t\t\tselect {\n\t\t\t\tcase c.outputCh <- string(b):\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn 0, ctx.Err()\n\t\t\t\t}\n\t\t\t\treturn len(pp), nil\n\t\t\t})\n\t\t\t_, err := io.Copy(w, uio.NewContextReader(ctx, c.sshStdout))\n\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\tcancel()\n\t\t})\n\n\t}\n\t{\n\t\t// input\n\t\tg.Add(func() error {\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase s, ok := <-c.inputCh:\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\t// Channel closed, exit goroutine\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\t// On Windows, cmd.exe needs \\r\\n to execute commands\n\t\t\t\t\tlineEnding := \"\\n\"\n\t\t\t\t\tif runtime.GOOS == \"windows\" {\n\t\t\t\t\t\tlineEnding = \"\\r\\n\"\n\t\t\t\t\t}\n\t\t\t\t\tif _, err := io.Copy(c.sshStdin, bytes.NewBufferString(s+lineEnding)); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn ctx.Err()\n\t\t\t\t}\n\t\t\t}\n\t\t}, func(err error) {\n\t\t\tcancel()\n\t\t})\n\t}\n\n\t// Track the goroutine running the group\n\tc.wg.Add(1)\n\tgo func() {\n\t\tdefer c.wg.Done()\n\t\tif err := g.Run(); err != nil {\n\t\t\ttestLogger.Error(\"error in client run group\", \"error\", err)\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (c *Client) Join(session *api.GetSessionResponse, clientJoinURL string) error {\n\treturn c.JoinWithContext(context.Background(), session, clientJoinURL)\n}\n\nfunc scanner(ch chan string) *bufio.Scanner {\n\tr, w := io.Pipe()\n\ts := bufio.NewScanner(r)\n\n\tgo func() {\n\t\tfor str := range ch {\n\t\t\t_, _ = w.Write([]byte(str))\n\t\t}\n\n\t}()\n\n\treturn s\n}\n\n// stripShellPrompt removes PowerShell prompt prefix and ANSI codes on Windows\n// PowerShell outputs: \"\\x1b[...ANSI codes...PS C:\\path> command\" instead of just \"command\"\nfunc stripShellPrompt(s string) string {\n\tif runtime.GOOS != \"windows\" {\n\t\treturn s\n\t}\n\n\t// First, remove all ANSI escape sequences\n\t// CSI sequences: ESC [ ... final byte (0x40-0x7E per ECMA-48)\n\t// OSC sequences: ESC ] ... BEL (0x07)\n\tansiRe := regexp.MustCompile(`\\x1b\\[[^\\x40-\\x7e]*[\\x40-\\x7e]|\\x1b\\][^\\x07]*\\x07`)\n\ts = ansiRe.ReplaceAllString(s, \"\")\n\n\t// Then remove \"PS <path>>\" (can appear multiple times due to screen redraws)\n\t// Don't use ^ anchor so we match all occurrences, not just start of line\n\tpromptRe := regexp.MustCompile(`PS [^>]+>\\s*`)\n\treturn promptRe.ReplaceAllString(s, \"\")\n}\n\nfunc scan(s *bufio.Scanner) string {\n\tfor s.Scan() {\n\t\ttext := stripShellPrompt(strings.TrimSpace(s.Text()))\n\t\tif text == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\treturn text\n\t}\n\n\treturn s.Err().Error()\n}\n\nfunc waitForUnixSocket(socket string, errCh chan error) error {\n\tctx, cancel := context.WithTimeout(context.Background(), unixSocketWaitTimeout)\n\tdefer cancel()\n\n\tticker := time.NewTicker(time.Second)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase err := <-errCh:\n\t\t\treturn err\n\t\tcase <-ctx.Done():\n\t\t\treturn fmt.Errorf(\"timeout waiting for unix socket %s: %w\", socket, ctx.Err())\n\t\tcase <-ticker.C:\n\t\t\ttestLogger.Debug(\"waiting for unix socket\", \"socket\", socket)\n\t\t\tif _, err := os.Stat(socket); err == nil {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype writeFunc func(p []byte) (n int, err error)\n\nfunc (rf writeFunc) Write(p []byte) (n int, err error) { return rf(p) }\n\nfunc authMethodsFromFiles(privateKeys []string) ([]ssh.AuthMethod, error) {\n\tsigners, err := host.SignersFromFiles(privateKeys)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar auths []ssh.AuthMethod\n\tfor _, signer := range signers {\n\t\tauths = append(auths, ssh.PublicKeys(signer))\n\t}\n\n\treturn auths, nil\n}\n\nfunc setupKeyPairs() (func(), error) {\n\tvar err error\n\n\tHostPrivateKey, err = writeTempFile(\"id_ed25519\", HostPrivateKeyContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tClientPrivateKey, err = writeTempFile(\"id_ed25519\", ClientPrivateKeyContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tremove := func() {\n\t\t_ = os.Remove(HostPrivateKey)\n\t\t_ = os.Remove(ClientPrivateKey)\n\t}\n\n\treturn remove, nil\n}\n\nfunc writeTempFile(name, content string) (string, error) {\n\tfile, err := os.CreateTemp(\"\", name)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer func() {\n\t\t_ = file.Close()\n\t}()\n\n\tif _, err := file.Write([]byte(content)); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn file.Name(), nil\n}\n"
  },
  {
    "path": "ftests/host_test.go",
    "content": "package ftests\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nfunc testHostClientCallback(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\tjch := make(chan *api.Client)\n\tlch := make(chan *api.Client)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t\tClientJoinedCallback: func(c *api.Client) {\n\t\t\tjch <- c\n\t\t},\n\t\tClientLeftCallback: func(c *api.Client) {\n\t\t\tlch <- c\n\t\t},\n\t}\n\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// verify admin server\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.JoinWithContext(ctx, session, clientJoinURL)\n\trequire.NoError(err)\n\n\tvar clientID string\n\tselect {\n\tcase cc := <-jch:\n\t\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(ClientPublicKeyContent))\n\t\trequire.NoError(err)\n\n\t\tassert.NotEmpty(cc.Id, \"client id can't be empty\")\n\t\tclientID = cc.Id\n\n\t\tassert.Equal(utils.FingerprintSHA256(pk), cc.PublicKeyFingerprint, \"public key fingerprint should match\")\n\t\tassert.Equal(\"SSH-2.0-Go\", cc.Version, \"client version should match\")\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"client joined callback is not called\")\n\t}\n\n\t// client leaves\n\tcancel()\n\tc.Close()\n\n\tselect {\n\tcase cc := <-lch:\n\t\tassert.NotEmpty(cc.Id, \"client id can't be empty\")\n\n\t\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(ClientPublicKeyContent))\n\t\trequire.NoError(err)\n\n\t\tassert.Equal(clientID, cc.Id, \"client ID should match on leave\")\n\t\tassert.Equal(utils.FingerprintSHA256(pk), cc.PublicKeyFingerprint, \"public key fingerprint should match on leave\")\n\t\tassert.Equal(\"SSH-2.0-Go\", cc.Version, \"client version should match on leave\")\n\tcase <-time.After(2 * time.Second):\n\t\tif os.Getenv(\"MUTE_FLAKY_TESTS\") != \"\" {\n\t\t\ttestLogger.Error(\"FLAKY_TEST: client left callback is not called\")\n\t\t} else {\n\t\t\tt.Fatal(\"client left callback is not called\")\n\t\t}\n\t}\n}\n\nfunc testHostSessionCreatedCallback(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:         getTestShell(),\n\t\tForceCommand:    []string{\"vim\"},\n\t\tPrivateKeys:     []string{HostPrivateKey},\n\t\tAdminSocketFile: adminSocketFile,\n\t\tSessionCreatedCallback: func(_ context.Context, session *api.GetSessionResponse) error {\n\t\t\tassert.Equal(getTestShell(), session.Command, \"command should match\")\n\t\t\tassert.Equal([]string{\"vim\"}, session.ForceCommand, \"force command should match\")\n\n\t\t\tcheckSessionPayload(t, session, hostShareURL, hostNodeAddr)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n}\n\nfunc testHostFailToShareWithoutPrivateKey(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:         getTestShell(),\n\t\tAdminSocketFile: adminSocketFile,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.Error(err, \"should fail without private key\")\n\trequire.ErrorContains(err, \"Permission denied (publickey)\", \"should fail with permission denied error\")\n}\n"
  },
  {
    "path": "ftests/sftp_test.go",
    "content": "package ftests\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// SFTPTestCases contains all SFTP-related test functions\nvar SFTPTestCases = []FtestCase{\n\ttestSFTPDownload,\n\ttestSFTPUpload,\n\ttestSFTPReadOnly,\n\ttestSFTPDisabled,\n\ttestSFTPDirectoryListing,\n\ttestSFTPSetstat,\n}\n\n// TestSFTP runs SFTP tests using the FtestSuite framework\nfunc (suite *FtestSuite) TestSFTP() {\n\tsuite.runTestCategory(SFTPTestCases)\n}\n\n// testSFTPDownload tests downloading a file via SFTP\n// This test is critical for verifying cluster mode: client connects to one server (ts2)\n// while host is on another (ts1), SFTP requests must be properly routed.\nfunc testSFTPDownload(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create temp directory for test files\n\ttestDir := t.TempDir()\n\n\t// Create a test file to download\n\ttestContent := \"Hello from SFTP download test!\\n\"\n\ttestFilePath := filepath.Join(testDir, \"download-test.txt\")\n\terr := os.WriteFile(testFilePath, []byte(testContent), 0644)\n\trequire.NoError(err)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr = h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server and get session\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// Connect client\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\t// Open SFTP client\n\tsftpClient, err := c.SFTP()\n\trequire.NoError(err, \"should be able to open SFTP connection\")\n\tdefer func() { _ = sftpClient.Close() }()\n\n\t// Download the file using absolute path (OpenSSH semantics)\n\tf, err := sftpClient.Open(testFilePath)\n\trequire.NoError(err, \"should be able to open file via SFTP\")\n\tdefer func() { _ = f.Close() }()\n\n\tdownloadedContent, err := io.ReadAll(f)\n\trequire.NoError(err, \"should be able to read file via SFTP\")\n\n\tassert.Equal(testContent, string(downloadedContent), \"downloaded content should match\")\n}\n\n// testSFTPUpload tests uploading a file via SFTP\n// Tests cluster mode routing for write operations.\nfunc testSFTPUpload(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create temp directory for test files\n\ttestDir := t.TempDir()\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server and get session\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// Connect client\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\t// Open SFTP client\n\tsftpClient, err := c.SFTP()\n\trequire.NoError(err, \"should be able to open SFTP connection\")\n\tdefer func() { _ = sftpClient.Close() }()\n\n\t// Upload a new file using absolute path (OpenSSH semantics)\n\tuploadFilePath := filepath.Join(testDir, \"upload-test.txt\")\n\tuploadContent := \"Hello from SFTP upload test!\\n\"\n\tf, err := sftpClient.Create(uploadFilePath)\n\trequire.NoError(err, \"should be able to create file via SFTP\")\n\n\t_, err = f.Write([]byte(uploadContent))\n\trequire.NoError(err, \"should be able to write to file via SFTP\")\n\terr = f.Close()\n\trequire.NoError(err, \"should be able to close file via SFTP\")\n\n\t// Verify file exists on host\n\tcontent, err := os.ReadFile(uploadFilePath)\n\trequire.NoError(err, \"uploaded file should exist on host\")\n\tassert.Equal(uploadContent, string(content), \"uploaded content should match\")\n}\n\n// testSFTPReadOnly tests that uploads are blocked in read-only mode\nfunc testSFTPReadOnly(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create temp directory for test files\n\ttestDir := t.TempDir()\n\n\t// Create a test file to download (should still work in read-only mode)\n\ttestContent := \"Hello from read-only test!\\n\"\n\ttestFilePath := filepath.Join(testDir, \"readonly-test.txt\")\n\terr := os.WriteFile(testFilePath, []byte(testContent), 0644)\n\trequire.NoError(err)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t\tReadOnly:                 true, // Enable read-only mode\n\t}\n\terr = h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server and get session\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// Connect client\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\t// Open SFTP client\n\tsftpClient, err := c.SFTP()\n\trequire.NoError(err, \"should be able to open SFTP connection\")\n\tdefer func() { _ = sftpClient.Close() }()\n\n\t// Download should still work in read-only mode (using absolute path)\n\tf, err := sftpClient.Open(testFilePath)\n\trequire.NoError(err, \"download should work in read-only mode\")\n\tdownloadedContent, err := io.ReadAll(f)\n\trequire.NoError(err)\n\t_ = f.Close()\n\tassert.Equal(testContent, string(downloadedContent), \"downloaded content should match\")\n\n\t// Upload should fail in read-only mode\n\tuploadFilePath := filepath.Join(testDir, \"upload-should-fail.txt\")\n\t_, err = sftpClient.Create(uploadFilePath)\n\tassert.Error(err, \"upload should fail in read-only mode\")\n}\n\n// testSFTPDisabled tests that SFTP subsystem is disabled when configured\nfunc testSFTPDisabled(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t\tSFTPDisabled:             true, // Disable SFTP\n\t}\n\terr := h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server and get session\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// Connect client\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\t// Trying to open SFTP client should fail when SFTP is disabled\n\t_, err = c.SFTP()\n\tassert.Error(err, \"SFTP connection should fail when SFTP is disabled\")\n}\n\n// testSFTPDirectoryListing tests listing directories via SFTP\nfunc testSFTPDirectoryListing(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create temp directory for test files\n\ttestDir := t.TempDir()\n\n\t// Create some test files and directories\n\terr := os.WriteFile(filepath.Join(testDir, \"file1.txt\"), []byte(\"content1\"), 0644)\n\trequire.NoError(err)\n\terr = os.WriteFile(filepath.Join(testDir, \"file2.txt\"), []byte(\"content2\"), 0644)\n\trequire.NoError(err)\n\terr = os.Mkdir(filepath.Join(testDir, \"subdir\"), 0755)\n\trequire.NoError(err)\n\terr = os.WriteFile(filepath.Join(testDir, \"subdir\", \"file3.txt\"), []byte(\"content3\"), 0644)\n\trequire.NoError(err)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr = h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server and get session\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// Connect client\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\t// Open SFTP client\n\tsftpClient, err := c.SFTP()\n\trequire.NoError(err, \"should be able to open SFTP connection\")\n\tdefer func() { _ = sftpClient.Close() }()\n\n\t// List test directory using absolute path (OpenSSH semantics)\n\tentries, err := sftpClient.ReadDir(testDir)\n\trequire.NoError(err, \"should be able to list test directory\")\n\n\t// Verify we see the expected entries\n\tnames := make(map[string]bool)\n\tfor _, entry := range entries {\n\t\tnames[entry.Name()] = true\n\t}\n\n\tassert.True(names[\"file1.txt\"], \"should see file1.txt\")\n\tassert.True(names[\"file2.txt\"], \"should see file2.txt\")\n\tassert.True(names[\"subdir\"], \"should see subdir\")\n\n\t// List subdirectory using absolute path\n\tsubDirPath := filepath.Join(testDir, \"subdir\")\n\tsubEntries, err := sftpClient.ReadDir(subDirPath)\n\trequire.NoError(err, \"should be able to list subdirectory\")\n\trequire.Len(subEntries, 1, \"subdir should have one file\")\n\tassert.Equal(\"file3.txt\", subEntries[0].Name(), \"should see file3.txt in subdir\")\n}\n\n// testSFTPSetstat tests file attribute modifications (chmod, truncate, chtimes)\nfunc testSFTPSetstat(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create temp directory for test files\n\ttestDir := t.TempDir()\n\n\t// Create a test file\n\ttestFilePath := filepath.Join(testDir, \"setstat-test.txt\")\n\ttestContent := \"Hello from SFTP setstat test!\\n\"\n\terr := os.WriteFile(testFilePath, []byte(testContent), 0644)\n\trequire.NoError(err)\n\n\t// Setup admin socket\n\tadminSocketFile := setupAdminSocket(t)\n\n\th := &Host{\n\t\tCommand:                  getTestShell(),\n\t\tPrivateKeys:              []string{HostPrivateKey},\n\t\tAdminSocketFile:          adminSocketFile,\n\t\tPermittedClientPublicKey: ClientPublicKeyContent,\n\t}\n\terr = h.Share(hostShareURL)\n\trequire.NoError(err)\n\tdefer h.Close()\n\n\t// Verify admin server and get session\n\tsession := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr)\n\n\t// Connect client\n\tc := &Client{\n\t\tPrivateKeys: []string{ClientPrivateKey},\n\t}\n\terr = c.Join(session, clientJoinURL)\n\trequire.NoError(err)\n\tdefer c.Close()\n\n\t// Open SFTP client\n\tsftpClient, err := c.SFTP()\n\trequire.NoError(err, \"should be able to open SFTP connection\")\n\tdefer func() { _ = sftpClient.Close() }()\n\n\t// Test 1: Chmod - change file permissions\n\terr = sftpClient.Chmod(testFilePath, 0600)\n\trequire.NoError(err, \"should be able to chmod file\")\n\n\tinfo, err := os.Stat(testFilePath)\n\trequire.NoError(err)\n\t// Check file is writable (0200 bit) - works cross-platform\n\t// (Windows uses ACLs, not Unix permission bits, so exact mode differs)\n\tassert.NotZero(info.Mode().Perm()&0200, \"file should be writable\")\n\n\t// Test 2: Truncate - change file size\n\terr = sftpClient.Truncate(testFilePath, 5)\n\trequire.NoError(err, \"should be able to truncate file\")\n\n\tinfo, err = os.Stat(testFilePath)\n\trequire.NoError(err)\n\tassert.Equal(int64(5), info.Size(), \"file size should be 5 bytes\")\n\n\t// Verify content is truncated\n\tcontent, err := os.ReadFile(testFilePath)\n\trequire.NoError(err)\n\tassert.Equal(\"Hello\", string(content), \"content should be truncated to 'Hello'\")\n\n\t// Test 3: Truncate to 0 - verify we can truncate to zero bytes\n\terr = sftpClient.Truncate(testFilePath, 0)\n\trequire.NoError(err, \"should be able to truncate file to 0 bytes\")\n\n\tinfo, err = os.Stat(testFilePath)\n\trequire.NoError(err)\n\tassert.Equal(int64(0), info.Size(), \"file size should be 0 bytes\")\n\n\t// Test 4: Chtimes - change file timestamps\n\tatime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)\n\tmtime := time.Date(2021, 6, 15, 12, 30, 0, 0, time.UTC)\n\terr = sftpClient.Chtimes(testFilePath, atime, mtime)\n\trequire.NoError(err, \"should be able to change file times\")\n\n\tinfo, err = os.Stat(testFilePath)\n\trequire.NoError(err)\n\t// Note: atime may not be preserved on all systems, but mtime should be\n\tassert.Equal(mtime.Unix(), info.ModTime().Unix(), \"mtime should be updated\")\n}\n"
  },
  {
    "path": "go.mod",
    "content": "// +heroku goVersion 1.25.4\n// +heroku install ./cmd/uptermd/...\n\nmodule github.com/owenthereal/upterm\n\ngo 1.26\n\nrequire (\n\tgithub.com/VividCortex/gohistogram v1.0.0 // indirect\n\tgithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect\n\tgithub.com/apex/log v1.9.0 // indirect\n\tgithub.com/buger/goterm v1.0.4 // indirect; idirect\n\tgithub.com/c4milo/unpackit v0.0.0-20170704181138-4ed373e9ef1c // indirect\n\tgithub.com/creack/pty v1.1.24\n\tgithub.com/dchest/uniuri v1.2.0\n\tgithub.com/dsnet/compress v0.0.1 // indirect\n\tgithub.com/gen2brain/beeep v0.11.2\n\tgithub.com/getsentry/sentry-go v0.46.2\n\tgithub.com/getsentry/sentry-go/slog v0.46.2\n\tgithub.com/go-kit/kit v0.13.0\n\tgithub.com/google/go-cmp v0.7.0\n\tgithub.com/google/go-querystring v1.1.0 // indirect\n\tgithub.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/gosuri/uilive v0.0.4 // indirect\n\tgithub.com/gosuri/uiprogress v0.0.1 // indirect\n\tgithub.com/hashicorp/consul/api v1.34.2\n\tgithub.com/hashicorp/go-multierror v1.1.1\n\tgithub.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c // indirect\n\tgithub.com/jpillora/chisel v1.11.6\n\tgithub.com/klauspost/pgzip v1.2.6 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/oklog/run v1.2.0\n\tgithub.com/olebedev/emitter v0.0.0-20230411050614-349169dec2ba\n\tgithub.com/pborman/ansi v1.1.0\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/rs/xid v1.6.0\n\tgithub.com/spf13/cobra v1.10.2\n\tgithub.com/tj/go v1.8.7\n\tgithub.com/ulikunitz/xz v0.5.15 // indirect\n\tgolang.org/x/crypto v0.49.0\n\tgoogle.golang.org/grpc v1.81.0\n\tgoogle.golang.org/protobuf v1.36.11\n)\n\nrequire (\n\tgithub.com/adrg/xdg v0.5.3\n\tgithub.com/avast/retry-go/v4 v4.7.0\n\tgithub.com/charmbracelet/bubbles v1.0.0\n\tgithub.com/charmbracelet/bubbletea v1.3.10\n\tgithub.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc\n\tgithub.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309\n\tgithub.com/charmbracelet/x/conpty v0.2.0\n\tgithub.com/cli/go-gh/v2 v2.13.0\n\tgithub.com/google/go-github/v48 v48.2.0\n\tgithub.com/hashicorp/go-version v1.8.0\n\tgithub.com/muesli/reflow v0.3.0\n\tgithub.com/ncruces/zenity v0.10.14\n\tgithub.com/owenthereal/tmux v0.0.0-20260110065009-80f16deab60d\n\tgithub.com/pires/go-proxyproto v0.11.0\n\tgithub.com/pkg/sftp v1.13.10\n\tgithub.com/samber/slog-multi v1.7.1\n\tgithub.com/spf13/pflag v1.0.10\n\tgithub.com/spf13/viper v1.21.0\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/tj/go-update v2.2.4+incompatible\n\tgolang.org/x/sys v0.43.0\n\tgolang.org/x/term v0.42.0\n)\n\nrequire (\n\tgit.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect\n\tgithub.com/akavel/rsrc v0.10.2 // indirect\n\tgithub.com/armon/go-metrics v0.4.1 // indirect\n\tgithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.4.1 // indirect\n\tgithub.com/charmbracelet/x/ansi v0.11.6 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.15 // indirect\n\tgithub.com/charmbracelet/x/term v0.2.2 // indirect\n\tgithub.com/charmbracelet/x/termios v0.1.1 // indirect\n\tgithub.com/cli/safeexec v1.0.1 // indirect\n\tgithub.com/cli/shurcooL-graphql v0.0.4 // indirect\n\tgithub.com/clipperhouse/displaywidth v0.9.0 // indirect\n\tgithub.com/clipperhouse/stringish v0.1.1 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.5.0 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/esiqveland/notify v0.13.3 // indirect\n\tgithub.com/fatih/color v1.16.0 // indirect\n\tgithub.com/fsnotify/fsnotify v1.9.0 // indirect\n\tgithub.com/go-kit/log v0.2.1 // indirect\n\tgithub.com/go-logfmt/logfmt v0.6.0 // indirect\n\tgithub.com/go-ole/go-ole v1.3.0 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/godbus/dbus/v5 v5.1.0 // indirect\n\tgithub.com/hashicorp/errwrap v1.1.0 // indirect\n\tgithub.com/hashicorp/go-cleanhttp v0.5.2 // indirect\n\tgithub.com/hashicorp/go-hclog v1.5.0 // indirect\n\tgithub.com/hashicorp/go-immutable-radix v1.3.1 // indirect\n\tgithub.com/hashicorp/go-rootcerts v1.0.2 // indirect\n\tgithub.com/hashicorp/golang-lru v0.5.4 // indirect\n\tgithub.com/hashicorp/serf v0.10.1 // indirect\n\tgithub.com/henvic/httpretty v0.1.4 // indirect\n\tgithub.com/hooklift/assert v0.1.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/jackmordaunt/icns/v3 v3.0.1 // indirect\n\tgithub.com/josephspurrier/goversioninfo v1.4.1 // indirect\n\tgithub.com/jpillora/sizestr v1.0.0 // indirect\n\tgithub.com/klauspost/compress v1.18.0 // indirect\n\tgithub.com/kr/fs v0.1.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.3.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.13 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.19 // indirect\n\tgithub.com/mitchellh/go-homedir v1.1.0 // indirect\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/termenv v0.16.0 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.66.1 // indirect\n\tgithub.com/prometheus/procfs v0.16.1 // indirect\n\tgithub.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/sagikazarmark/locafero v0.11.0 // indirect\n\tgithub.com/samber/lo v1.52.0 // indirect\n\tgithub.com/samber/slog-common v0.20.0 // indirect\n\tgithub.com/sergeymakinen/go-bmp v1.0.0 // indirect\n\tgithub.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect\n\tgithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect\n\tgithub.com/thlib/go-timezone-local v0.0.6 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgo.yaml.in/yaml/v2 v2.4.2 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect\n\tgolang.org/x/image v0.38.0 // indirect\n\tgolang.org/x/net v0.52.0 // indirect\n\tgolang.org/x/sync v0.20.0 // indirect\n\tgolang.org/x/text v0.36.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n\nreplace golang.org/x/crypto => github.com/tg123/sshpiper.crypto v0.50.0-sshpiper-20260423\n\nreplace github.com/pkg/sftp => github.com/owenthereal/sftp v0.0.0-20260113082633-ef3e1c92482e\n"
  },
  {
    "path": "go.sum",
    "content": "git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=\ngit.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=\ngithub.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=\ngithub.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE=\ngithub.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=\ngithub.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=\ngithub.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=\ngithub.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw=\ngithub.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=\ngithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=\ngithub.com/apex/log v1.3.0/go.mod h1:jd8Vpsr46WAe3EZSQ/IUMs2qQD/GOycT5rPWCO1yGcs=\ngithub.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0=\ngithub.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA=\ngithub.com/apex/logs v0.0.4/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=\ngithub.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=\ngithub.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE=\ngithub.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys=\ngithub.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=\ngithub.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=\ngithub.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=\ngithub.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=\ngithub.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=\ngithub.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=\ngithub.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=\ngithub.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=\ngithub.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=\ngithub.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=\ngithub.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=\ngithub.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=\ngithub.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=\ngithub.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=\ngithub.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=\ngithub.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=\ngithub.com/buger/goterm v0.0.0-20181115115552-c206103e1f37/go.mod h1:u9UyCz2eTrSGy6fbupqJ54eY5c4IC8gREQ1053dK12U=\ngithub.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=\ngithub.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=\ngithub.com/c4milo/unpackit v0.0.0-20170704181138-4ed373e9ef1c h1:aprLqMn7gSPT+vdDSl+/E6NLEuArwD/J7IWd8bJt5lQ=\ngithub.com/c4milo/unpackit v0.0.0-20170704181138-4ed373e9ef1c/go.mod h1:Ie6SubJv/NTO9Q0UBH0QCl3Ve50lu9hjbi5YJUw03TE=\ngithub.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=\ngithub.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=\ngithub.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=\ngithub.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=\ngithub.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=\ngithub.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=\ngithub.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=\ngithub.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=\ngithub.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=\ngithub.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=\ngithub.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=\ngithub.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=\ngithub.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=\ngithub.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=\ngithub.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=\ngithub.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=\ngithub.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=\ngithub.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=\ngithub.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=\ngithub.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=\ngithub.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys=\ngithub.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM=\ngithub.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=\ngithub.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=\ngithub.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY=\ngithub.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk=\ngithub.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=\ngithub.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=\ngithub.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=\ngithub.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=\ngithub.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=\ngithub.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=\ngithub.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA=\ngithub.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw=\ngithub.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=\ngithub.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=\ngithub.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=\ngithub.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=\ngithub.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=\ngithub.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=\ngithub.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=\ngithub.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=\ngithub.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=\ngithub.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=\ngithub.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=\ngithub.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=\ngithub.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=\ngithub.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=\ngithub.com/getsentry/sentry-go/slog v0.46.2 h1:LvlIgQtGPWrzXSwnuyid4lCcRPJA+32CdHz6E2Zy4iE=\ngithub.com/getsentry/sentry-go/slog v0.46.2/go.mod h1:ij4MuNplQBVR6+uXExJqEH440WiKljKd2Ey3UmFjyeY=\ngithub.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=\ngithub.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=\ngithub.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU=\ngithub.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=\ngithub.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=\ngithub.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=\ngithub.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=\ngithub.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=\ngithub.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=\ngithub.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=\ngithub.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=\ngithub.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=\ngithub.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=\ngithub.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=\ngithub.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=\ngithub.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=\ngithub.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=\ngithub.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=\ngithub.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=\ngithub.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY=\ngithub.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI=\ngithub.com/gosuri/uiprogress v0.0.1 h1:0kpv/XY/qTmFWl/SkaJykZXrBBzwwadmW8fRb7RJSxw=\ngithub.com/gosuri/uiprogress v0.0.1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0=\ngithub.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=\ngithub.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=\ngithub.com/hashicorp/consul/api v1.34.2 h1:B5jqSSKwWyY8U8WiGS5vmPEPkkF0bAvrECykdZkDR80=\ngithub.com/hashicorp/consul/api v1.34.2/go.mod h1:+gAdHQa2zvgYX3ZfcgITtnYCSj6AgS/cgotvCKaE+b8=\ngithub.com/hashicorp/consul/sdk v0.18.1 h1:RDTeBvAeOveI2xI86sV+8WkaN7OkP4zz+cG3fOobDCM=\ngithub.com/hashicorp/consul/sdk v0.18.1/go.mod h1:XdP2tEJmAvlK4jgoKTTtohGkRJlS4mU44mv9/sjU21s=\ngithub.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=\ngithub.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=\ngithub.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=\ngithub.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=\ngithub.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=\ngithub.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=\ngithub.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=\ngithub.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=\ngithub.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=\ngithub.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=\ngithub.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=\ngithub.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=\ngithub.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=\ngithub.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=\ngithub.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=\ngithub.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=\ngithub.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=\ngithub.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=\ngithub.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=\ngithub.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=\ngithub.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=\ngithub.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=\ngithub.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=\ngithub.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=\ngithub.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=\ngithub.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=\ngithub.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=\ngithub.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=\ngithub.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=\ngithub.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=\ngithub.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM=\ngithub.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0=\ngithub.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=\ngithub.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=\ngithub.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU=\ngithub.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM=\ngithub.com/hooklift/assert v0.1.0 h1:UZzFxx5dSb9aBtvMHTtnPuvFnBvcEhHTPb9+0+jpEjs=\ngithub.com/hooklift/assert v0.1.0/go.mod h1:pfexfvIHnKCdjh6CkkIZv5ic6dQ6aU2jhKghBlXuwwY=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs=\ngithub.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=\ngithub.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=\ngithub.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=\ngithub.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=\ngithub.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=\ngithub.com/josephspurrier/goversioninfo v1.4.1 h1:5LvrkP+n0tg91J9yTkoVnt/QgNnrI1t4uSsWjIonrqY=\ngithub.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY=\ngithub.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=\ngithub.com/jpillora/chisel v1.11.6 h1:wqlbuatAk8pst2GNY7d2F4AnHxMaLVOA2S/SkC7OlEg=\ngithub.com/jpillora/chisel v1.11.6/go.mod h1:gAFVTwDLIMM/dH0w1twfSXz0NQUnBVWrlimi707vk7k=\ngithub.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw=\ngithub.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mzD+MZ33f0=\ngithub.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=\ngithub.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=\ngithub.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=\ngithub.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=\ngithub.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=\ngithub.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=\ngithub.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=\ngithub.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=\ngithub.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=\ngithub.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=\ngithub.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=\ngithub.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=\ngithub.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=\ngithub.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=\ngithub.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=\ngithub.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=\ngithub.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=\ngithub.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=\ngithub.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=\ngithub.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=\ngithub.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=\ngithub.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg=\ngithub.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=\ngithub.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=\ngithub.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=\ngithub.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=\ngithub.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=\ngithub.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI=\ngithub.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4=\ngithub.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=\ngithub.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=\ngithub.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=\ngithub.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=\ngithub.com/olebedev/emitter v0.0.0-20230411050614-349169dec2ba h1:/Q5vvLs180BFH7u+Nakdrr1B9O9RAxVaIurFQy0c8QQ=\ngithub.com/olebedev/emitter v0.0.0-20230411050614-349169dec2ba/go.mod h1:eT2/Pcsim3XBjbvldGiJBvvgiqZkAFyiOJJsDKXs/ts=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=\ngithub.com/owenthereal/sftp v0.0.0-20260113082633-ef3e1c92482e h1:aTNsPifEBOqyFBeJ66kg55fGiLMM9D6AH0pzzAlqnEY=\ngithub.com/owenthereal/sftp v0.0.0-20260113082633-ef3e1c92482e/go.mod h1:ZJjziXQfMZHQhZLwCXYsNgAWo7JCwe+ZPgUAuNeb+ck=\ngithub.com/owenthereal/tmux v0.0.0-20260110065009-80f16deab60d h1:M4eHUCs7KYTLz8jK6sOa+bCXiU2BV8xj9f2QFJsYLeA=\ngithub.com/owenthereal/tmux v0.0.0-20260110065009-80f16deab60d/go.mod h1:LmUG3DzUnCf1F03dVT+dyJADTiFN+dK+ZQHCqfkmvrQ=\ngithub.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=\ngithub.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=\ngithub.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=\ngithub.com/pborman/ansi v1.1.0 h1:ga494FEIR0L6it/U7G5S4WeK0WkdsbRDJJdLi5DVuq8=\ngithub.com/pborman/ansi v1.1.0/go.mod h1:SgWzwMAx1X/Ez7i90VqF8LRiQtx52pWDiQP+x3iGnzw=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=\ngithub.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=\ngithub.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=\ngithub.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=\ngithub.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=\ngithub.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=\ngithub.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=\ngithub.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=\ngithub.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=\ngithub.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=\ngithub.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=\ngithub.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=\ngithub.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=\ngithub.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=\ngithub.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=\ngithub.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=\ngithub.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc=\ngithub.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=\ngithub.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=\ngithub.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=\ngithub.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=\ngithub.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=\ngithub.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=\ngithub.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=\ngithub.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=\ngithub.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=\ngithub.com/samber/slog-common v0.20.0 h1:WaLnm/aCvBJSk5nR5aXZTFBaV0B47A+AEaEOiZDeUnc=\ngithub.com/samber/slog-common v0.20.0/go.mod h1:+Ozat1jgnnE59UAlmNX1IF3IByHsODnnwf9jUcBZ+m8=\ngithub.com/samber/slog-multi v1.7.1 h1:aCLXHRxgU+2v0PVlEOh7phynzM7CRo89ZgFtOwaqVEE=\ngithub.com/samber/slog-multi v1.7.1/go.mod h1:A4KQC99deqfkCDJcL/cO3kX6McX7FffQAx/8QHink+c=\ngithub.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=\ngithub.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=\ngithub.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=\ngithub.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=\ngithub.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=\ngithub.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=\ngithub.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=\ngithub.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=\ngithub.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=\ngithub.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs=\ngithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=\ngithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=\ngithub.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=\ngithub.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/stripe/stripe-go v70.15.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=\ngithub.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=\ngithub.com/tg123/sshpiper.crypto v0.50.0-sshpiper-20260423 h1:44vKXnloFVn783FySXE1+fPOC6YDai2a8naegP3A4v4=\ngithub.com/tg123/sshpiper.crypto v0.50.0-sshpiper-20260423/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=\ngithub.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU=\ngithub.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=\ngithub.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=\ngithub.com/tj/assert v0.0.1/go.mod h1:lsg+GHQ0XplTcWKGxFLf/XPcPxWO8x2ut5jminoR2rA=\ngithub.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=\ngithub.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=\ngithub.com/tj/go v1.8.7 h1:a7M1Xo+QKmlUHEzZj2LX0LHqkh7/LpOa6Or8luBvY/c=\ngithub.com/tj/go v1.8.7/go.mod h1:88DQADQo0c0fHmWNcr88pIGUHlV5du8aGtON+S1jr5A=\ngithub.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc=\ngithub.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=\ngithub.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=\ngithub.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=\ngithub.com/tj/go-update v2.2.4+incompatible h1:7Rkw5ZyRSFb3QyEWM7sHCy9rCy1/r66elkOyGlfnZFc=\ngithub.com/tj/go-update v2.2.4+incompatible/go.mod h1:waFwwyiAhGey2e+dNoYQ/iLhIcFqhCW7zL/+vDU1WLo=\ngithub.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=\ngithub.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=\ngithub.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=\ngithub.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=\ngo.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=\ngo.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=\ngo.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=\ngo.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=\ngo.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=\ngo.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=\ngo.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=\ngo.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=\ngo.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=\ngolang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=\ngolang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=\ngolang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=\ngolang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=\ngolang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=\ngolang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=\ngolang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=\ngolang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=\ngolang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=\ngolang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=\ngolang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=\ngolang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=\ngolang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=\ngolang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=\ngolang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=\ngolang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=\ngolang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=\ngolang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=\ngolang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=\ngolang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=\ngolang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=\ngolang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=\ngolang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=\ngolang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=\ngolang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw=\ngolang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE=\ngolang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=\ngolang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=\ngolang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=\ngolang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=\ngolang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=\ngolang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=\ngolang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=\ngolang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=\ngolang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=\ngolang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=\ngolang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=\ngolang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=\ngolang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=\ngolang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=\ngolang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=\ngolang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=\ngolang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=\ngolang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=\ngolang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=\ngolang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=\ngolang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=\ngolang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=\ngolang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=\ngolang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=\ngolang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=\ngolang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=\ngolang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=\ngolang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=\ngolang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=\ngolang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=\ngolang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=\ngolang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=\ngolang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=\ngolang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=\ngolang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=\ngolang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=\ngonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=\ngoogle.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=\ngoogle.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=\ngopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20200602174320-3e3e88ca92fa/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "host/adminclient.go",
    "content": "package host\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/credentials/insecure\"\n)\n\nconst (\n\tAdminSockExt = \".sock\"\n)\n\nfunc AdminSocketFile(sessionID string) string {\n\treturn fmt.Sprintf(\"%s%s\", sessionID, AdminSockExt)\n}\n\nfunc AdminClient(socket string) (api.AdminServiceClient, error) {\n\t// Use mtls\n\t// Workaround for gRPC Unix socket support on Windows: https://github.com/grpc/grpc-go/issues/8675\n\tconn, err := grpc.NewClient(\n\t\t\"passthrough:///unix\",\n\t\tgrpc.WithTransportCredentials(insecure.NewCredentials()),\n\t\tgrpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {\n\t\t\treturn (&net.Dialer{}).DialContext(ctx, \"unix\", socket)\n\t\t}),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn api.NewAdminServiceClient(conn), nil\n}\n"
  },
  {
    "path": "host/api/api.pb.go",
    "content": "// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n// \tprotoc-gen-go v1.28.1\n// \tprotoc        v3.21.6\n// source: api.proto\n\npackage api\n\nimport (\n\tprotoreflect \"google.golang.org/protobuf/reflect/protoreflect\"\n\tprotoimpl \"google.golang.org/protobuf/runtime/protoimpl\"\n\treflect \"reflect\"\n\tsync \"sync\"\n)\n\nconst (\n\t// Verify that this generated code is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)\n\t// Verify that runtime/protoimpl is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)\n)\n\ntype Identifier_Type int32\n\nconst (\n\tIdentifier_HOST   Identifier_Type = 0\n\tIdentifier_CLIENT Identifier_Type = 1\n)\n\n// Enum value maps for Identifier_Type.\nvar (\n\tIdentifier_Type_name = map[int32]string{\n\t\t0: \"HOST\",\n\t\t1: \"CLIENT\",\n\t}\n\tIdentifier_Type_value = map[string]int32{\n\t\t\"HOST\":   0,\n\t\t\"CLIENT\": 1,\n\t}\n)\n\nfunc (x Identifier_Type) Enum() *Identifier_Type {\n\tp := new(Identifier_Type)\n\t*p = x\n\treturn p\n}\n\nfunc (x Identifier_Type) String() string {\n\treturn protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))\n}\n\nfunc (Identifier_Type) Descriptor() protoreflect.EnumDescriptor {\n\treturn file_api_proto_enumTypes[0].Descriptor()\n}\n\nfunc (Identifier_Type) Type() protoreflect.EnumType {\n\treturn &file_api_proto_enumTypes[0]\n}\n\nfunc (x Identifier_Type) Number() protoreflect.EnumNumber {\n\treturn protoreflect.EnumNumber(x)\n}\n\n// Deprecated: Use Identifier_Type.Descriptor instead.\nfunc (Identifier_Type) EnumDescriptor() ([]byte, []int) {\n\treturn file_api_proto_rawDescGZIP(), []int{4, 0}\n}\n\ntype GetSessionRequest struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n}\n\nfunc (x *GetSessionRequest) Reset() {\n\t*x = GetSessionRequest{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_api_proto_msgTypes[0]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *GetSessionRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*GetSessionRequest) ProtoMessage() {}\n\nfunc (x *GetSessionRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_api_proto_msgTypes[0]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead.\nfunc (*GetSessionRequest) Descriptor() ([]byte, []int) {\n\treturn file_api_proto_rawDescGZIP(), []int{0}\n}\n\ntype GetSessionResponse struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tSessionId        string           `protobuf:\"bytes,1,opt,name=session_id,json=sessionId,proto3\" json:\"session_id,omitempty\"`\n\tCommand          []string         `protobuf:\"bytes,2,rep,name=command,proto3\" json:\"command,omitempty\"`\n\tForceCommand     []string         `protobuf:\"bytes,3,rep,name=force_command,json=forceCommand,proto3\" json:\"force_command,omitempty\"`\n\tHost             string           `protobuf:\"bytes,4,opt,name=host,proto3\" json:\"host,omitempty\"`\n\tNodeAddr         string           `protobuf:\"bytes,5,opt,name=node_addr,json=nodeAddr,proto3\" json:\"node_addr,omitempty\"`\n\tConnectedClients []*Client        `protobuf:\"bytes,6,rep,name=connected_clients,json=connectedClients,proto3\" json:\"connected_clients,omitempty\"`\n\tAuthorizedKeys   []*AuthorizedKey `protobuf:\"bytes,7,rep,name=authorized_keys,json=authorizedKeys,proto3\" json:\"authorized_keys,omitempty\"`\n\tSshUser          string           `protobuf:\"bytes,8,opt,name=ssh_user,json=sshUser,proto3\" json:\"ssh_user,omitempty\"`                 // SSH username for client connections\n\tSftpDisabled     bool             `protobuf:\"varint,9,opt,name=sftp_disabled,json=sftpDisabled,proto3\" json:\"sftp_disabled,omitempty\"` // true if SFTP is disabled (--no-sftp)\n}\n\nfunc (x *GetSessionResponse) Reset() {\n\t*x = GetSessionResponse{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_api_proto_msgTypes[1]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *GetSessionResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*GetSessionResponse) ProtoMessage() {}\n\nfunc (x *GetSessionResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_api_proto_msgTypes[1]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead.\nfunc (*GetSessionResponse) Descriptor() ([]byte, []int) {\n\treturn file_api_proto_rawDescGZIP(), []int{1}\n}\n\nfunc (x *GetSessionResponse) GetSessionId() string {\n\tif x != nil {\n\t\treturn x.SessionId\n\t}\n\treturn \"\"\n}\n\nfunc (x *GetSessionResponse) GetCommand() []string {\n\tif x != nil {\n\t\treturn x.Command\n\t}\n\treturn nil\n}\n\nfunc (x *GetSessionResponse) GetForceCommand() []string {\n\tif x != nil {\n\t\treturn x.ForceCommand\n\t}\n\treturn nil\n}\n\nfunc (x *GetSessionResponse) GetHost() string {\n\tif x != nil {\n\t\treturn x.Host\n\t}\n\treturn \"\"\n}\n\nfunc (x *GetSessionResponse) GetNodeAddr() string {\n\tif x != nil {\n\t\treturn x.NodeAddr\n\t}\n\treturn \"\"\n}\n\nfunc (x *GetSessionResponse) GetConnectedClients() []*Client {\n\tif x != nil {\n\t\treturn x.ConnectedClients\n\t}\n\treturn nil\n}\n\nfunc (x *GetSessionResponse) GetAuthorizedKeys() []*AuthorizedKey {\n\tif x != nil {\n\t\treturn x.AuthorizedKeys\n\t}\n\treturn nil\n}\n\nfunc (x *GetSessionResponse) GetSshUser() string {\n\tif x != nil {\n\t\treturn x.SshUser\n\t}\n\treturn \"\"\n}\n\nfunc (x *GetSessionResponse) GetSftpDisabled() bool {\n\tif x != nil {\n\t\treturn x.SftpDisabled\n\t}\n\treturn false\n}\n\ntype AuthorizedKey struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tPublicKeyFingerprints []string `protobuf:\"bytes,1,rep,name=public_key_fingerprints,json=publicKeyFingerprints,proto3\" json:\"public_key_fingerprints,omitempty\"`\n\tComment               string   `protobuf:\"bytes,2,opt,name=comment,proto3\" json:\"comment,omitempty\"`\n}\n\nfunc (x *AuthorizedKey) Reset() {\n\t*x = AuthorizedKey{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_api_proto_msgTypes[2]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *AuthorizedKey) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*AuthorizedKey) ProtoMessage() {}\n\nfunc (x *AuthorizedKey) ProtoReflect() protoreflect.Message {\n\tmi := &file_api_proto_msgTypes[2]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use AuthorizedKey.ProtoReflect.Descriptor instead.\nfunc (*AuthorizedKey) Descriptor() ([]byte, []int) {\n\treturn file_api_proto_rawDescGZIP(), []int{2}\n}\n\nfunc (x *AuthorizedKey) GetPublicKeyFingerprints() []string {\n\tif x != nil {\n\t\treturn x.PublicKeyFingerprints\n\t}\n\treturn nil\n}\n\nfunc (x *AuthorizedKey) GetComment() string {\n\tif x != nil {\n\t\treturn x.Comment\n\t}\n\treturn \"\"\n}\n\ntype Client struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId                   string `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tVersion              string `protobuf:\"bytes,2,opt,name=version,proto3\" json:\"version,omitempty\"`\n\tAddr                 string `protobuf:\"bytes,3,opt,name=addr,proto3\" json:\"addr,omitempty\"`\n\tPublicKeyFingerprint string `protobuf:\"bytes,4,opt,name=public_key_fingerprint,json=publicKeyFingerprint,proto3\" json:\"public_key_fingerprint,omitempty\"`\n}\n\nfunc (x *Client) Reset() {\n\t*x = Client{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_api_proto_msgTypes[3]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *Client) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Client) ProtoMessage() {}\n\nfunc (x *Client) ProtoReflect() protoreflect.Message {\n\tmi := &file_api_proto_msgTypes[3]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Client.ProtoReflect.Descriptor instead.\nfunc (*Client) Descriptor() ([]byte, []int) {\n\treturn file_api_proto_rawDescGZIP(), []int{3}\n}\n\nfunc (x *Client) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *Client) GetVersion() string {\n\tif x != nil {\n\t\treturn x.Version\n\t}\n\treturn \"\"\n}\n\nfunc (x *Client) GetAddr() string {\n\tif x != nil {\n\t\treturn x.Addr\n\t}\n\treturn \"\"\n}\n\nfunc (x *Client) GetPublicKeyFingerprint() string {\n\tif x != nil {\n\t\treturn x.PublicKeyFingerprint\n\t}\n\treturn \"\"\n}\n\ntype Identifier struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tId       string          `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tType     Identifier_Type `protobuf:\"varint,2,opt,name=type,proto3,enum=api.Identifier_Type\" json:\"type,omitempty\"`\n\tNodeAddr string          `protobuf:\"bytes,3,opt,name=node_addr,json=nodeAddr,proto3\" json:\"node_addr,omitempty\"`\n}\n\nfunc (x *Identifier) Reset() {\n\t*x = Identifier{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_api_proto_msgTypes[4]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *Identifier) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Identifier) ProtoMessage() {}\n\nfunc (x *Identifier) ProtoReflect() protoreflect.Message {\n\tmi := &file_api_proto_msgTypes[4]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Identifier.ProtoReflect.Descriptor instead.\nfunc (*Identifier) Descriptor() ([]byte, []int) {\n\treturn file_api_proto_rawDescGZIP(), []int{4}\n}\n\nfunc (x *Identifier) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *Identifier) GetType() Identifier_Type {\n\tif x != nil {\n\t\treturn x.Type\n\t}\n\treturn Identifier_HOST\n}\n\nfunc (x *Identifier) GetNodeAddr() string {\n\tif x != nil {\n\t\treturn x.NodeAddr\n\t}\n\treturn \"\"\n}\n\nvar File_api_proto protoreflect.FileDescriptor\n\nvar file_api_proto_rawDesc = []byte{\n\t0x0a, 0x09, 0x61, 0x70, 0x69, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69,\n\t0x22, 0x13, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65,\n\t0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xda, 0x02, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73,\n\t0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a,\n\t0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63,\n\t0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f,\n\t0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x5f, 0x63,\n\t0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x66, 0x6f,\n\t0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f,\n\t0x73, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b,\n\t0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28,\n\t0x09, 0x52, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x41, 0x64, 0x64, 0x72, 0x12, 0x38, 0x0a, 0x11, 0x63,\n\t0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73,\n\t0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x43, 0x6c, 0x69,\n\t0x65, 0x6e, 0x74, 0x52, 0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x43, 0x6c,\n\t0x69, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,\n\t0x7a, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12,\n\t0x2e, 0x61, 0x70, 0x69, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x4b,\n\t0x65, 0x79, 0x52, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x4b, 0x65,\n\t0x79, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x73, 0x68, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x08,\n\t0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x73, 0x68, 0x55, 0x73, 0x65, 0x72, 0x12, 0x23, 0x0a,\n\t0x0d, 0x73, 0x66, 0x74, 0x70, 0x5f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x09,\n\t0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x73, 0x66, 0x74, 0x70, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c,\n\t0x65, 0x64, 0x22, 0x61, 0x0a, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64,\n\t0x4b, 0x65, 0x79, 0x12, 0x36, 0x0a, 0x17, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65,\n\t0x79, 0x5f, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x01,\n\t0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x46,\n\t0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x63,\n\t0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f,\n\t0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x7c, 0x0a, 0x06, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x12,\n\t0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,\n\t0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x64, 0x64,\n\t0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x61, 0x64, 0x64, 0x72, 0x12, 0x34, 0x0a,\n\t0x16, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x66, 0x69, 0x6e, 0x67,\n\t0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x70,\n\t0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72,\n\t0x69, 0x6e, 0x74, 0x22, 0x81, 0x01, 0x0a, 0x0a, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69,\n\t0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,\n\t0x69, 0x64, 0x12, 0x28, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e,\n\t0x32, 0x14, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65,\n\t0x72, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1b, 0x0a, 0x09,\n\t0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,\n\t0x08, 0x6e, 0x6f, 0x64, 0x65, 0x41, 0x64, 0x64, 0x72, 0x22, 0x1c, 0x0a, 0x04, 0x54, 0x79, 0x70,\n\t0x65, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x4f, 0x53, 0x54, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x43,\n\t0x4c, 0x49, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x32, 0x4f, 0x0a, 0x0c, 0x41, 0x64, 0x6d, 0x69, 0x6e,\n\t0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x3f, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x53, 0x65,\n\t0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x53,\n\t0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e,\n\t0x61, 0x70, 0x69, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65,\n\t0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68,\n\t0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x77, 0x65, 0x6e, 0x74, 0x68, 0x65, 0x72, 0x65,\n\t0x61, 0x6c, 0x2f, 0x75, 0x70, 0x74, 0x65, 0x72, 0x6d, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x2f, 0x61,\n\t0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,\n}\n\nvar (\n\tfile_api_proto_rawDescOnce sync.Once\n\tfile_api_proto_rawDescData = file_api_proto_rawDesc\n)\n\nfunc file_api_proto_rawDescGZIP() []byte {\n\tfile_api_proto_rawDescOnce.Do(func() {\n\t\tfile_api_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_proto_rawDescData)\n\t})\n\treturn file_api_proto_rawDescData\n}\n\nvar file_api_proto_enumTypes = make([]protoimpl.EnumInfo, 1)\nvar file_api_proto_msgTypes = make([]protoimpl.MessageInfo, 5)\nvar file_api_proto_goTypes = []interface{}{\n\t(Identifier_Type)(0),       // 0: api.Identifier.Type\n\t(*GetSessionRequest)(nil),  // 1: api.GetSessionRequest\n\t(*GetSessionResponse)(nil), // 2: api.GetSessionResponse\n\t(*AuthorizedKey)(nil),      // 3: api.AuthorizedKey\n\t(*Client)(nil),             // 4: api.Client\n\t(*Identifier)(nil),         // 5: api.Identifier\n}\nvar file_api_proto_depIdxs = []int32{\n\t4, // 0: api.GetSessionResponse.connected_clients:type_name -> api.Client\n\t3, // 1: api.GetSessionResponse.authorized_keys:type_name -> api.AuthorizedKey\n\t0, // 2: api.Identifier.type:type_name -> api.Identifier.Type\n\t1, // 3: api.AdminService.GetSession:input_type -> api.GetSessionRequest\n\t2, // 4: api.AdminService.GetSession:output_type -> api.GetSessionResponse\n\t4, // [4:5] is the sub-list for method output_type\n\t3, // [3:4] is the sub-list for method input_type\n\t3, // [3:3] is the sub-list for extension type_name\n\t3, // [3:3] is the sub-list for extension extendee\n\t0, // [0:3] is the sub-list for field type_name\n}\n\nfunc init() { file_api_proto_init() }\nfunc file_api_proto_init() {\n\tif File_api_proto != nil {\n\t\treturn\n\t}\n\tif !protoimpl.UnsafeEnabled {\n\t\tfile_api_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*GetSessionRequest); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_api_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*GetSessionResponse); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_api_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*AuthorizedKey); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_api_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*Client); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_api_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*Identifier); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n\ttype x struct{}\n\tout := protoimpl.TypeBuilder{\n\t\tFile: protoimpl.DescBuilder{\n\t\t\tGoPackagePath: reflect.TypeOf(x{}).PkgPath(),\n\t\t\tRawDescriptor: file_api_proto_rawDesc,\n\t\t\tNumEnums:      1,\n\t\t\tNumMessages:   5,\n\t\t\tNumExtensions: 0,\n\t\t\tNumServices:   1,\n\t\t},\n\t\tGoTypes:           file_api_proto_goTypes,\n\t\tDependencyIndexes: file_api_proto_depIdxs,\n\t\tEnumInfos:         file_api_proto_enumTypes,\n\t\tMessageInfos:      file_api_proto_msgTypes,\n\t}.Build()\n\tFile_api_proto = out.File\n\tfile_api_proto_rawDesc = nil\n\tfile_api_proto_goTypes = nil\n\tfile_api_proto_depIdxs = nil\n}\n"
  },
  {
    "path": "host/api/api.proto",
    "content": "syntax = \"proto3\";\n\npackage api;\n\noption go_package = \"github.com/owenthereal/upterm/host/api\";\n\nservice AdminService {\n  rpc GetSession(GetSessionRequest) returns (GetSessionResponse) {}\n}\n\nmessage GetSessionRequest {}\n\nmessage GetSessionResponse {\n  string session_id = 1;\n  repeated string command = 2;\n  repeated string force_command = 3;\n  string host = 4;\n  string node_addr = 5;\n  repeated Client connected_clients = 6;\n  repeated AuthorizedKey authorized_keys = 7;\n  string ssh_user = 8; // SSH username for client connections\n  bool sftp_disabled = 9; // true if SFTP is disabled (--no-sftp)\n}\n\nmessage AuthorizedKey {\n  repeated string public_key_fingerprints = 1;\n  string comment = 2;\n}\n\nmessage Client {\n  string id = 1;\n  string version = 2;\n  string addr = 3;\n  string public_key_fingerprint = 4;\n}\n\nmessage Identifier {\n  string id = 1;\n  Type type = 2;\n  string node_addr = 3;\n\n  enum Type {\n    HOST = 0;\n    CLIENT = 1;\n  }\n}\n"
  },
  {
    "path": "host/api/api_grpc.pb.go",
    "content": "// Code generated by protoc-gen-go-grpc. DO NOT EDIT.\n// versions:\n// - protoc-gen-go-grpc v1.2.0\n// - protoc             v3.21.6\n// source: api.proto\n\npackage api\n\nimport (\n\tcontext \"context\"\n\tgrpc \"google.golang.org/grpc\"\n\tcodes \"google.golang.org/grpc/codes\"\n\tstatus \"google.golang.org/grpc/status\"\n)\n\n// This is a compile-time assertion to ensure that this generated file\n// is compatible with the grpc package it is being compiled against.\n// Requires gRPC-Go v1.32.0 or later.\nconst _ = grpc.SupportPackageIsVersion7\n\n// AdminServiceClient is the client API for AdminService service.\n//\n// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.\ntype AdminServiceClient interface {\n\tGetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error)\n}\n\ntype adminServiceClient struct {\n\tcc grpc.ClientConnInterface\n}\n\nfunc NewAdminServiceClient(cc grpc.ClientConnInterface) AdminServiceClient {\n\treturn &adminServiceClient{cc}\n}\n\nfunc (c *adminServiceClient) GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) {\n\tout := new(GetSessionResponse)\n\terr := c.cc.Invoke(ctx, \"/api.AdminService/GetSession\", in, out, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\n// AdminServiceServer is the server API for AdminService service.\n// All implementations should embed UnimplementedAdminServiceServer\n// for forward compatibility\ntype AdminServiceServer interface {\n\tGetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error)\n}\n\n// UnimplementedAdminServiceServer should be embedded to have forward compatible implementations.\ntype UnimplementedAdminServiceServer struct {\n}\n\nfunc (UnimplementedAdminServiceServer) GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) {\n\treturn nil, status.Errorf(codes.Unimplemented, \"method GetSession not implemented\")\n}\n\n// UnsafeAdminServiceServer may be embedded to opt out of forward compatibility for this service.\n// Use of this interface is not recommended, as added methods to AdminServiceServer will\n// result in compilation errors.\ntype UnsafeAdminServiceServer interface {\n\tmustEmbedUnimplementedAdminServiceServer()\n}\n\nfunc RegisterAdminServiceServer(s grpc.ServiceRegistrar, srv AdminServiceServer) {\n\ts.RegisterService(&AdminService_ServiceDesc, srv)\n}\n\nfunc _AdminService_GetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(GetSessionRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(AdminServiceServer).GetSession(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: \"/api.AdminService/GetSession\",\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(AdminServiceServer).GetSession(ctx, req.(*GetSessionRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\n// AdminService_ServiceDesc is the grpc.ServiceDesc for AdminService service.\n// It's only intended for direct use with grpc.RegisterService,\n// and not to be introspected or modified (even as a copy)\nvar AdminService_ServiceDesc = grpc.ServiceDesc{\n\tServiceName: \"api.AdminService\",\n\tHandlerType: (*AdminServiceServer)(nil),\n\tMethods: []grpc.MethodDesc{\n\t\t{\n\t\t\tMethodName: \"GetSession\",\n\t\t\tHandler:    _AdminService_GetSession_Handler,\n\t\t},\n\t},\n\tStreams:  []grpc.StreamDesc{},\n\tMetadata: \"api.proto\",\n}\n"
  },
  {
    "path": "host/authorizedkeys.go",
    "content": "package host\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"log/slog\"\n\n\t\"github.com/cli/go-gh/v2/pkg/api\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\tcodebergKeysUrlFmt  = \"https://codeberg.org/%s\"\n\tgitHubKeysUrlFmt    = \"https://github.com/%s\"\n\tgitLabKeysUrlFmt    = \"https://gitlab.com/%s\"\n\tsourceHutKeysUrlFmt = \"https://meta.sr.ht/~%s\"\n)\n\ntype AuthorizedKey struct {\n\tPublicKeys []ssh.PublicKey\n\tComment    string\n}\n\nfunc AuthorizedKeysFromFile(file string) (*AuthorizedKey, error) {\n\tauthorizedKeysBytes, err := os.ReadFile(file)\n\tif err != nil {\n\t\treturn nil, nil\n\t}\n\n\treturn parseAuthorizedKeys(authorizedKeysBytes, file)\n}\n\nfunc CodebergUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, error) {\n\treturn usersPublicKeys(codebergKeysUrlFmt, usernames)\n}\n\nfunc GitHubUserAuthorizedKeys(usernames []string, logger *slog.Logger) ([]*AuthorizedKey, error) {\n\tvar (\n\t\tauthorizedKeys []*AuthorizedKey\n\t\tseen           = make(map[string]bool)\n\t)\n\tfor _, username := range usernames {\n\t\tif _, found := seen[username]; !found {\n\t\t\tseen[username] = true\n\n\t\t\tpks, err := githubUserPublicKeys(username, logger)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\taks, err := parseAuthorizedKeys(pks, username)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tauthorizedKeys = append(authorizedKeys, aks)\n\t\t}\n\t}\n\n\treturn authorizedKeys, nil\n}\n\nfunc GitLabUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, error) {\n\treturn usersPublicKeys(gitLabKeysUrlFmt, usernames)\n}\n\nfunc SourceHutUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, error) {\n\treturn usersPublicKeys(sourceHutKeysUrlFmt, usernames)\n}\n\nfunc parseAuthorizedKeys(keysBytes []byte, comment string) (*AuthorizedKey, error) {\n\tvar authorizedKeys []ssh.PublicKey\n\tfor len(keysBytes) > 0 {\n\t\tpubKey, _, _, rest, err := ssh.ParseAuthorizedKey(keysBytes)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tauthorizedKeys = append(authorizedKeys, pubKey)\n\t\tkeysBytes = rest\n\t}\n\n\treturn &AuthorizedKey{\n\t\tPublicKeys: authorizedKeys,\n\t\tComment:    comment,\n\t}, nil\n}\n\nfunc githubUserPublicKeys(username string, logger *slog.Logger) ([]byte, error) {\n\tclient, err := api.DefaultRESTClient()\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"authentication token not found for host\") {\n\t\t\t// fallback to use the public GH API\n\t\t\tlogger.Warn(\"no GitHub token found, falling back to public API\", \"error\", err)\n\t\t\treturn userPublicKeys(gitHubKeysUrlFmt, username)\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\tkeys := []struct {\n\t\tKey string `json:\"key\"`\n\t}{}\n\tif err := client.Get(fmt.Sprintf(\"users/%s/keys\", url.PathEscape(username)), &keys); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar authorizedKeys []string\n\tfor _, key := range keys {\n\t\tauthorizedKeys = append(authorizedKeys, key.Key)\n\t}\n\n\treturn []byte(strings.Join(authorizedKeys, \"\\n\")), nil\n}\n\nfunc usersPublicKeys(urlFmt string, usernames []string) ([]*AuthorizedKey, error) {\n\tvar (\n\t\tauthorizedKeys []*AuthorizedKey\n\t\tseen           = make(map[string]bool)\n\t)\n\tfor _, username := range usernames {\n\t\tif _, found := seen[username]; !found {\n\t\t\tseen[username] = true\n\n\t\t\tkeyBytes, err := userPublicKeys(urlFmt, username)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"[%s]: %s\", username, err)\n\t\t\t}\n\t\t\tuserKeys, err := parseAuthorizedKeys(keyBytes, username)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"[%s]: %s\", username, err)\n\t\t\t}\n\n\t\t\tauthorizedKeys = append(authorizedKeys, userKeys)\n\t\t}\n\t}\n\treturn authorizedKeys, nil\n}\n\nfunc userPublicKeys(urlFmt string, username string) ([]byte, error) {\n\tpath := url.PathEscape(fmt.Sprintf(\"%s.keys\", username))\n\n\tclient := http.Client{\n\t\tTimeout: 5 * time.Second,\n\t}\n\tresp, err := client.Get(fmt.Sprintf(urlFmt, path))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer func() {\n\t\t_ = resp.Body.Close()\n\t}()\n\n\treturn io.ReadAll(resp.Body)\n}\n"
  },
  {
    "path": "host/host.go",
    "content": "package host\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"log/slog\"\n\n\t\"github.com/oklog/run\"\n\t\"github.com/olebedev/emitter\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/host/internal\"\n\t\"github.com/owenthereal/upterm/host/sftp\"\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"golang.org/x/crypto/ssh/knownhosts\"\n)\n\nfunc NewPromptingHostKeyCallback(stdin io.Reader, stdout io.Writer, knownHostsFilename string) (ssh.HostKeyCallback, error) {\n\treturn newHostKeyCallback(stdin, stdout, knownHostsFilename, false)\n}\n\n// NewAutoAcceptingHostKeyCallback creates a host key callback that automatically\n// accepts unknown host keys and adds them to the known_hosts file without prompting.\n// This is similar to SSH's StrictHostKeyChecking=accept-new behavior:\n// - Unknown host keys are automatically accepted and added to known_hosts\n// - Known host keys are still validated (preventing MITM attacks on subsequent connections)\nfunc NewAutoAcceptingHostKeyCallback(stdout io.Writer, knownHostsFilename string) (ssh.HostKeyCallback, error) {\n\treturn newHostKeyCallback(nil, stdout, knownHostsFilename, true)\n}\n\nfunc newHostKeyCallback(stdin io.Reader, stdout io.Writer, knownHostsFilename string, autoAccept bool) (ssh.HostKeyCallback, error) {\n\tif err := createFileIfNotExist(knownHostsFilename); err != nil {\n\t\treturn nil, err\n\t}\n\n\tcb, err := knownhosts.New(knownHostsFilename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\thkcb := hostKeyCallback{\n\t\tstdin:           stdin,\n\t\tstdout:          stdout,\n\t\tfile:            knownHostsFilename,\n\t\tHostKeyCallback: cb,\n\t\tautoAccept:      autoAccept,\n\t}\n\n\treturn hkcb.checkHostKey, nil\n}\n\nconst (\n\tmarkerCert = \"@cert-authority\"\n\n\terrKeyMismatch = `\n@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\nIT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\nSomeone could be eavesdropping on you right now (man-in-the-middle attack)!\nIt is also possible that a host key has just been changed.\nThe fingerprint for the %s key sent by the remote host is\n%s.\nPlease contact your system administrator.\nAdd correct host key in %s to get rid of this message.\nOffending %s key in %s:%d`\n\terrNoAuthoritiesHostname = \"ssh: no authorities for hostname\"\n)\n\ntype hostKeyCallback struct {\n\tstdin      io.Reader\n\tstdout     io.Writer\n\tfile       string\n\tautoAccept bool\n\tssh.HostKeyCallback\n}\n\nfunc (cb hostKeyCallback) checkHostKey(hostname string, remote net.Addr, key ssh.PublicKey) error {\n\tif err := cb.HostKeyCallback(hostname, remote, key); err != nil {\n\t\tkerr, ok := err.(*knownhosts.KeyError)\n\t\t// Return err if it's neither key error or no authorities hostname error\n\t\tif !ok && !strings.HasPrefix(err.Error(), errNoAuthoritiesHostname) {\n\t\t\treturn err\n\t\t}\n\n\t\t// If keer.Want is non-empty, there was a mismatch, which can signify a MITM attack\n\t\tif kerr != nil && len(kerr.Want) != 0 {\n\t\t\tkk := kerr.Want[0] // TODO: take care of multiple key mismatches\n\t\t\tfp := utils.FingerprintSHA256(kk.Key)\n\t\t\tkt := keyType(kk.Key.Type())\n\t\t\treturn fmt.Errorf(errKeyMismatch, kt, fp, kk.Filename, kt, kk.Filename, kk.Line)\n\t\t}\n\n\t\t// Auto-accept unknown host keys if enabled\n\t\tif cb.autoAccept {\n\t\t\treturn cb.autoAcceptHostKey(hostname, key)\n\t\t}\n\n\t\treturn cb.promptForConfirmation(hostname, remote, key)\n\t}\n\n\treturn nil\n}\n\nfunc (cb hostKeyCallback) promptForConfirmation(hostname string, remote net.Addr, key ssh.PublicKey) error {\n\tcert, isCert := key.(*ssh.Certificate)\n\tif isCert {\n\t\tkey = cert.SignatureKey\n\t}\n\n\tfp := utils.FingerprintSHA256(key)\n\t_, _ = fmt.Fprintf(cb.stdout, \"The authenticity of host '%s (%s)' can't be established.\\n\", knownhosts.Normalize(hostname), knownhosts.Normalize(remote.String()))\n\t_, _ = fmt.Fprintf(cb.stdout, \"%s key fingerprint is %s.\\n\", keyType(key.Type()), fp)\n\t_, _ = fmt.Fprintf(cb.stdout, \"Are you sure you want to continue connecting (yes/no/[fingerprint])? \")\n\n\treader := bufio.NewReader(cb.stdin)\n\tfor {\n\t\tconfirm, err := reader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tconfirm = strings.TrimSpace(confirm)\n\n\t\tif confirm == \"yes\" || confirm == fp {\n\t\t\treturn cb.appendHostLine(isCert, hostname, key)\n\t\t}\n\n\t\tif confirm == \"no\" {\n\t\t\treturn fmt.Errorf(\"Host key verification failed\")\n\t\t}\n\n\t\t_, _ = fmt.Fprintf(cb.stdout, \"Please type 'yes', 'no' or the fingerprint: \")\n\t}\n}\n\nfunc (cb hostKeyCallback) autoAcceptHostKey(hostname string, key ssh.PublicKey) error {\n\tcert, isCert := key.(*ssh.Certificate)\n\tif isCert {\n\t\tkey = cert.SignatureKey\n\t}\n\n\t_, _ = fmt.Fprintf(cb.stdout, \"Warning: Permanently added '%s' (%s) to the list of known hosts.\\n\", knownhosts.Normalize(hostname), keyType(key.Type()))\n\n\treturn cb.appendHostLine(isCert, hostname, key)\n}\n\nfunc (cb hostKeyCallback) appendHostLine(isCert bool, hostname string, key ssh.PublicKey) error {\n\tf, err := os.OpenFile(cb.file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer func() {\n\t\t_ = f.Close()\n\t}()\n\n\t// Only store the hostname, not the IP address.\n\t// This prevents breakage when server IPs change due to:\n\t// - Load balancers and auto-scaling\n\t// - Cloud redeployments\n\t// - CDN/proxy rotation\n\t// - IPv6 address rotation\n\t// The security benefit of storing IPs is minimal in modern infrastructure\n\t// since we already trust DNS, and MITM attacks would need to compromise\n\t// both DNS and the host key.\n\taddr := []string{hostname}\n\n\tline := knownhosts.Line(addr, key)\n\n\tif isCert {\n\t\tline = fmt.Sprintf(\"%s %s\", markerCert, line)\n\t}\n\n\tif _, err := f.WriteString(line + \"\\n\"); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype Host struct {\n\tHost                           string\n\tKeepAliveDuration              time.Duration\n\tCommand                        []string\n\tForceCommand                   []string\n\tSigners                        []ssh.Signer\n\tHostKeyCallback                ssh.HostKeyCallback\n\tAuthorizedKeys                 []*AuthorizedKey\n\tAdminSocketFile                string\n\tSessionCreatedCallback         func(context.Context, *api.GetSessionResponse) error\n\tClientJoinedCallback           func(*api.Client)\n\tClientLeftCallback             func(*api.Client)\n\tLogger                         *slog.Logger\n\tStdin                          *os.File\n\tStdout                         *os.File\n\tReadOnly                       bool\n\tAllowLocalTCPForwarding        bool\n\tForceForwardingInputForTesting bool\n\n\t// SFTP configuration\n\tSFTPDisabled          bool                   // Disable SFTP subsystem entirely (--no-sftp)\n\tSFTPPermissionChecker sftp.PermissionChecker // Optional: prompts user for SFTP permissions (nil = auto-allow)\n}\n\nfunc (c *Host) Run(ctx context.Context) error {\n\tu, err := url.Parse(c.Host)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error parsing host url: %s\", err)\n\t}\n\n\tif c.Stdin == nil {\n\t\tc.Stdin = os.Stdin\n\t}\n\tif c.Stdout == nil {\n\t\tc.Stdout = os.Stdout\n\t}\n\n\tvar aks []ssh.PublicKey\n\tfor _, ak := range c.AuthorizedKeys {\n\t\taks = append(aks, ak.PublicKeys...)\n\t}\n\n\tlogger := c.Logger.With(\"server\", u.String())\n\tlogger.Info(\"Establishing reverse tunnel\")\n\trt := internal.ReverseTunnel{\n\t\tHost:              u,\n\t\tSigners:           c.Signers,\n\t\tHostKeyCallback:   c.HostKeyCallback,\n\t\tAuthorizedKeys:    aks,\n\t\tKeepAliveDuration: c.KeepAliveDuration,\n\t\tLogger:            logger.With(\"component\", \"reverse-tunnel\"),\n\t}\n\tsessResp, err := rt.Establish(ctx)\n\tif err != nil {\n\t\t// Log the error before returning to ensure it's captured in logs\n\t\t// This is especially important when running in detached/background mode\n\t\tlogger.Error(\"Failed to establish reverse tunnel\", \"error\", err)\n\t\treturn err\n\t}\n\tdefer rt.Close()\n\n\t// Check server version compatibility after establishing connection\n\tserverVersion := string(rt.ServerVersion())\n\tlogger.Debug(\"detected server version\", \"server_version\", serverVersion)\n\n\t// Check for version compatibility\n\tif result := version.CheckCompatibility(serverVersion); !result.Compatible {\n\t\tdisplayVersionWarning(c.Stdout, logger, result)\n\t}\n\n\tif c.AdminSocketFile == \"\" {\n\t\tdir, err := utils.CreateUptermRuntimeDir()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tc.AdminSocketFile = filepath.Join(dir, AdminSocketFile(sessResp.SessionID))\n\n\t\tdefer func() {\n\t\t\t_ = os.Remove(c.AdminSocketFile)\n\t\t}()\n\t}\n\n\tlogger = logger.With(\"session\", sessResp.SessionID)\n\tlogger.Info(\"Established reverse tunnel\")\n\n\tsession := &api.GetSessionResponse{\n\t\tSessionId:      sessResp.SessionID,\n\t\tHost:           u.String(),\n\t\tNodeAddr:       sessResp.NodeAddr,\n\t\tSshUser:        sessResp.SshUser,\n\t\tCommand:        c.Command,\n\t\tForceCommand:   c.ForceCommand,\n\t\tAuthorizedKeys: toApiAuthorizedKeys(c.AuthorizedKeys),\n\t\tSftpDisabled:   c.SFTPDisabled,\n\t}\n\n\tif c.SessionCreatedCallback != nil {\n\t\tif err := c.SessionCreatedCallback(ctx, session); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tclientRepo := internal.NewClientRepo()\n\teventEmitter := emitter.New(1)\n\n\tlogger = logger.With(\"cmd\", c.Command, \"force_cmd\", c.ForceCommand)\n\n\tvar g run.Group\n\t{\n\t\t// Handle OS signals for graceful shutdown\n\t\t// Platform-specific: Unix listens for SIGINT+SIGTERM, Windows only SIGTERM\n\t\tsetupSignalHandler(&g, ctx)\n\t}\n\t{\n\t\tctx, cancel := context.WithCancel(ctx)\n\t\ts := internal.AdminServer{\n\t\t\tSession:    session,\n\t\t\tClientRepo: clientRepo,\n\t\t}\n\t\tg.Add(func() error {\n\t\t\treturn s.Serve(ctx, c.AdminSocketFile)\n\t\t}, func(err error) {\n\t\t\t_ = s.Shutdown(ctx)\n\t\t\tcancel()\n\t\t})\n\t}\n\t{\n\t\tg.Add(func() error {\n\t\t\tfor evt := range eventEmitter.On(upterm.EventClientJoined) {\n\t\t\t\targs := evt.Args\n\t\t\t\tif len(args) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tclient, ok := args[0].(*api.Client)\n\t\t\t\tif ok {\n\t\t\t\t\t_ = clientRepo.Add(client)\n\t\t\t\t\tlogger.Info(\"Client joined\", \"client\", client.Addr)\n\t\t\t\t\tif c.ClientJoinedCallback != nil {\n\t\t\t\t\t\tc.ClientJoinedCallback(client)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}, func(err error) {\n\t\t\teventEmitter.Off(upterm.EventClientJoined)\n\t\t})\n\t}\n\t{\n\t\tg.Add(func() error {\n\t\t\tfor evt := range eventEmitter.On(upterm.EventClientLeft) {\n\t\t\t\targs := evt.Args\n\t\t\t\tif len(args) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tcid, ok := args[0].(string)\n\t\t\t\tif ok {\n\t\t\t\t\tclient := clientRepo.Get(cid)\n\t\t\t\t\tif client != nil {\n\t\t\t\t\t\tlogger.Info(\"Client left\", \"client\", client.Addr)\n\t\t\t\t\t\tclientRepo.Delete(cid)\n\t\t\t\t\t\tif c.ClientLeftCallback != nil {\n\t\t\t\t\t\t\tc.ClientLeftCallback(client)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}, func(err error) {\n\t\t\teventEmitter.Off(upterm.EventClientLeft)\n\t\t})\n\t}\n\t{\n\t\tlogger.Info(\"Starting sshd server\")\n\t\tdefer logger.Info(\"Finishing sshd server\")\n\n\t\tctx, cancel := context.WithCancel(ctx)\n\t\tsshServer := internal.Server{\n\t\t\tCommand:                        c.Command,\n\t\t\tCommandEnv:                     []string{fmt.Sprintf(\"%s=%s\", upterm.HostAdminSocketEnvVar, c.AdminSocketFile)},\n\t\t\tForceCommand:                   c.ForceCommand,\n\t\t\tSigners:                        c.Signers,\n\t\t\tAuthorizedKeys:                 aks,\n\t\t\tEventEmitter:                   eventEmitter,\n\t\t\tKeepAliveDuration:              c.KeepAliveDuration,\n\t\t\tStdin:                          c.Stdin,\n\t\t\tStdout:                         c.Stdout,\n\t\t\tLogger:                         logger.With(\"component\", \"server\"),\n\t\t\tReadOnly:                       c.ReadOnly,\n\t\t\tAllowLocalTCPForwarding:        c.AllowLocalTCPForwarding,\n\t\t\tForceForwardingInputForTesting: c.ForceForwardingInputForTesting,\n\t\t\tSFTPDisabled:                   c.SFTPDisabled,\n\t\t\tSFTPPermissionChecker:          c.SFTPPermissionChecker,\n\t\t}\n\t\tg.Add(func() error {\n\t\t\treturn sshServer.ServeWithContext(ctx, rt.Listener())\n\t\t}, func(err error) {\n\t\t\tcancel()\n\t\t})\n\t}\n\n\treturn g.Run()\n}\n\nfunc keyType(t string) string {\n\treturn strings.ToUpper(strings.TrimPrefix(t, \"ssh-\"))\n}\n\nfunc createFileIfNotExist(file string) error {\n\t_, err := os.Stat(file)\n\tif os.IsNotExist(err) {\n\t\tdir := filepath.Dir(file)\n\t\tif err := os.MkdirAll(dir, 0700); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfile, err := os.Create(file)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdefer func() {\n\t\t\t_ = file.Close()\n\t\t}()\n\t}\n\n\treturn nil\n}\n\nfunc toApiAuthorizedKeys(aks []*AuthorizedKey) []*api.AuthorizedKey {\n\tvar apiAks []*api.AuthorizedKey\n\tfor _, ak := range aks {\n\t\tvar fps []string\n\t\tfor _, pk := range ak.PublicKeys {\n\t\t\tfps = append(fps, utils.FingerprintSHA256(pk))\n\t\t}\n\n\t\tapiAks = append(apiAks, &api.AuthorizedKey{\n\t\t\tPublicKeyFingerprints: fps,\n\t\t\tComment:               ak.Comment,\n\t\t})\n\t}\n\n\treturn apiAks\n}\n\n// displayVersionWarning prints a formatted version mismatch warning to the given writer\nfunc displayVersionWarning(out io.Writer, logger *slog.Logger, result *version.CompatibilityResult) {\n\tmessages := []struct {\n\t\ttext     string\n\t\tdebugMsg string\n\t}{\n\t\t{\"[WARNING] VERSION MISMATCH DETECTED\\n\", \"failed to display version warning header\"},\n\t\t{result.Message + \"\\n\", \"failed to display version warning message\"},\n\t\t{fmt.Sprintf(\"Host version:   %s\\n\", result.HostVersion), \"failed to display host version\"},\n\t\t{fmt.Sprintf(\"Server version: %s\\n\", result.ServerVersion), \"failed to display server version\"},\n\t\t{\"\\nThis may cause compatibility issues. Consider updating to matching versions.\\n\\n\", \"failed to display version warning footer\"},\n\t}\n\n\tfor _, msg := range messages {\n\t\tif _, err := fmt.Fprint(out, msg.text); err != nil {\n\t\t\tlogger.Debug(msg.debugMsg, \"error\", err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "host/host_test.go",
    "content": "package host\n\nimport (\n\t\"bytes\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\ttestPublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdcHcuMfI8bGAyHPcGsAc/vd/gl5673pRkRBGY`\n)\n\nfunc Test_hostKeyCallbackKnowHostsFileNotExist(t *testing.T) {\n\tdir := t.TempDir()\n\n\tknownHostsFile := filepath.Join(dir, \"known_hosts\")\n\n\tstdin := bytes.NewBufferString(\"yes\\n\") // Simulate typing \"yes\" in stdin\n\tstdout := bytes.NewBuffer(nil)\n\n\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfp := utils.FingerprintSHA256(pk)\n\n\tcb, err := NewPromptingHostKeyCallback(stdin, stdout, knownHostsFile)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\taddr := &net.TCPAddr{\n\t\tIP:   net.IPv4(127, 0, 0, 1),\n\t\tPort: 22,\n\t}\n\tif err := cb(\"127.0.0.1:22\", addr, pk); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(stdout.String(), \"ED25519 key fingerprint is \"+fp) {\n\t\tt.Fatalf(\"stdout should contain fingerprint %s: %s\", fp, stdout)\n\t}\n}\n\nfunc Test_hostKeyCallback(t *testing.T) {\n\ttempfile := filepath.Join(t.TempDir(), \"known_hosts\")\n\terr := os.WriteFile(tempfile, []byte(\"[127.0.0.1]:23 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKpVcpc3t5GZHQFlbSLyj6sQY4wWLjNZsLTkfo9Cdjit\\n\"), 0600)\n\trequire.NoError(t, err)\n\n\tstdin := bytes.NewBufferString(\"yes\\n\") // Simulate typing \"yes\" in stdin\n\tstdout := bytes.NewBuffer(nil)\n\n\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey))\n\trequire.NoError(t, err)\n\tfp := utils.FingerprintSHA256(pk)\n\n\tcb, err := NewPromptingHostKeyCallback(stdin, stdout, tempfile)\n\trequire.NoError(t, err)\n\n\t// 127.0.0.1:22 is not in known_hosts\n\taddr := &net.TCPAddr{\n\t\tIP:   net.IPv4(127, 0, 0, 1),\n\t\tPort: 22,\n\t}\n\terr = cb(\"127.0.0.1:22\", addr, pk)\n\trequire.NoError(t, err)\n\tassert.Contains(t, stdout.String(), \"ED25519 key fingerprint is \"+fp)\n\n\t// 127.0.0.1:23 is in known_hosts\n\taddr = &net.TCPAddr{\n\t\tIP:   net.IPv4(127, 0, 0, 1),\n\t\tPort: 23,\n\t}\n\terr = cb(\"127.0.0.1:23\", addr, pk)\n\tassert.Error(t, err, \"key mismatched error is expected\")\n\tassert.Contains(t, err.Error(), \"Offending ED25519 key in \"+tempfile)\n}\n\nfunc Test_hostKeyCallbackIPv6WithPort(t *testing.T) {\n\ttempfile := filepath.Join(t.TempDir(), \"known_hosts\")\n\n\tstdin := bytes.NewBufferString(\"yes\\n\")\n\tstdout := bytes.NewBuffer(nil)\n\n\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey))\n\trequire.NoError(t, err)\n\n\tcb, err := NewPromptingHostKeyCallback(stdin, stdout, tempfile)\n\trequire.NoError(t, err)\n\n\t// Test IPv6 address with port - even though remote is IPv6,\n\t// only hostname should be stored for operational flexibility\n\taddr := &net.TCPAddr{\n\t\tIP:   net.ParseIP(\"2a09:8280:1::3:4b89\"),\n\t\tPort: 443,\n\t}\n\thostname := \"uptermd.upterm.dev:443\"\n\n\terr = cb(hostname, addr, pk)\n\trequire.NoError(t, err)\n\n\t// Read the known_hosts file and verify the entry is properly formatted\n\tcontent, err := os.ReadFile(tempfile)\n\trequire.NoError(t, err)\n\n\tcontentStr := string(content)\n\n\t// Should contain the hostname with port\n\tassert.Contains(t, contentStr, \"[uptermd.upterm.dev]:443\",\n\t\t\"known_hosts should contain hostname with port\")\n\n\t// Should NOT contain the IP address - only hostname for operational flexibility\n\t// This prevents breakage when IPs change due to load balancers, redeployments, etc.\n\tassert.NotContains(t, contentStr, \"2a09:8280:1::3:4b89\",\n\t\t\"known_hosts should NOT contain IP address to avoid breakage on IP changes\")\n}\n\nfunc Test_hostKeyCallbackIPv6WithCertAuthority(t *testing.T) {\n\ttempfile := filepath.Join(t.TempDir(), \"known_hosts\")\n\n\tstdin := bytes.NewBufferString(\"yes\\n\")\n\tstdout := bytes.NewBuffer(nil)\n\n\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey))\n\trequire.NoError(t, err)\n\n\t// Create a certificate\n\tcert := &ssh.Certificate{\n\t\tKey:          pk,\n\t\tCertType:     ssh.HostCert,\n\t\tSignatureKey: pk,\n\t}\n\n\tcb, err := NewPromptingHostKeyCallback(stdin, stdout, tempfile)\n\trequire.NoError(t, err)\n\n\t// Test IPv6 address with certificate authority\n\taddr := &net.TCPAddr{\n\t\tIP:   net.ParseIP(\"2a09:8280:1::3:4b89\"),\n\t\tPort: 443,\n\t}\n\thostname := \"uptermd.upterm.dev:443\"\n\n\terr = cb(hostname, addr, cert)\n\trequire.NoError(t, err)\n\n\t// Read the known_hosts file and verify the entry is properly formatted\n\tcontent, err := os.ReadFile(tempfile)\n\trequire.NoError(t, err)\n\n\tcontentStr := string(content)\n\n\t// Should contain @cert-authority marker\n\tassert.Contains(t, contentStr, \"@cert-authority\",\n\t\t\"known_hosts should contain @cert-authority marker\")\n\n\t// Should contain the hostname with port\n\tassert.Contains(t, contentStr, \"[uptermd.upterm.dev]:443\",\n\t\t\"known_hosts should contain hostname with port\")\n\n\t// Should NOT include the IP address for operational flexibility\n\tassert.NotContains(t, contentStr, \"2a09:8280:1::3:4b89\",\n\t\t\"known_hosts should NOT contain IP address to avoid breakage on IP changes\")\n\n\t// Expected format: @cert-authority [hostname]:port ssh-ed25519 key\n\t// NOT: @cert-authority [hostname]:port,[ip]:port ssh-ed25519 key\n\tassert.Contains(t, contentStr, \"@cert-authority [uptermd.upterm.dev]:443 ssh-ed25519\",\n\t\t\"known_hosts should have correct cert-authority format with only hostname\")\n}\n\nfunc Test_autoAcceptingHostKeyCallback(t *testing.T) {\n\ttempfile := filepath.Join(t.TempDir(), \"known_hosts\")\n\n\tstdout := bytes.NewBuffer(nil)\n\n\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey))\n\trequire.NoError(t, err)\n\n\tcb, err := NewAutoAcceptingHostKeyCallback(stdout, tempfile)\n\trequire.NoError(t, err)\n\n\t// Test auto-accepting an unknown host key\n\taddr := &net.TCPAddr{\n\t\tIP:   net.IPv4(127, 0, 0, 1),\n\t\tPort: 22,\n\t}\n\terr = cb(\"127.0.0.1:22\", addr, pk)\n\trequire.NoError(t, err)\n\n\t// Should contain warning message about permanently adding the host (matching SSH's accept-new behavior)\n\tassert.Contains(t, stdout.String(), \"Warning: Permanently added '127.0.0.1' (ED25519) to the list of known hosts.\")\n\n\t// Verify the key was written to known_hosts\n\tcontent, err := os.ReadFile(tempfile)\n\trequire.NoError(t, err)\n\tassert.Contains(t, string(content), \"ssh-ed25519\")\n}\n\nfunc Test_autoAcceptingHostKeyCallbackWithCertificate(t *testing.T) {\n\ttempfile := filepath.Join(t.TempDir(), \"known_hosts\")\n\n\tstdout := bytes.NewBuffer(nil)\n\n\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey))\n\trequire.NoError(t, err)\n\n\t// Create a certificate\n\tcert := &ssh.Certificate{\n\t\tKey:          pk,\n\t\tCertType:     ssh.HostCert,\n\t\tSignatureKey: pk,\n\t}\n\n\tcb, err := NewAutoAcceptingHostKeyCallback(stdout, tempfile)\n\trequire.NoError(t, err)\n\n\t// Test auto-accepting a certificate\n\taddr := &net.TCPAddr{\n\t\tIP:   net.ParseIP(\"2a09:8280:1::3:4b89\"),\n\t\tPort: 443,\n\t}\n\thostname := \"uptermd.upterm.dev:443\"\n\n\terr = cb(hostname, addr, cert)\n\trequire.NoError(t, err)\n\n\t// Verify the certificate was written with @cert-authority marker\n\tcontent, err := os.ReadFile(tempfile)\n\trequire.NoError(t, err)\n\n\tcontentStr := string(content)\n\tassert.Contains(t, contentStr, \"@cert-authority\")\n\tassert.Contains(t, contentStr, \"[uptermd.upterm.dev]:443\")\n\tassert.NotContains(t, contentStr, \"2a09:8280:1::3:4b89\")\n}\n\nfunc Test_autoAcceptingHostKeyCallbackValidatesKnownKeys(t *testing.T) {\n\ttempfile := filepath.Join(t.TempDir(), \"known_hosts\")\n\n\t// Pre-populate known_hosts with a different key\n\tdifferentKey := \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKpVcpc3t5GZHQFlbSLyj6sQY4wWLjNZsLTkfo9Cdjit\"\n\terr := os.WriteFile(tempfile, []byte(\"[127.0.0.1]:22 \"+differentKey+\"\\n\"), 0600)\n\trequire.NoError(t, err)\n\n\tstdout := bytes.NewBuffer(nil)\n\n\tpk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey))\n\trequire.NoError(t, err)\n\n\tcb, err := NewAutoAcceptingHostKeyCallback(stdout, tempfile)\n\trequire.NoError(t, err)\n\n\t// Try to connect with a different key - should fail (MITM protection)\n\taddr := &net.TCPAddr{\n\t\tIP:   net.IPv4(127, 0, 0, 1),\n\t\tPort: 22,\n\t}\n\terr = cb(\"127.0.0.1:22\", addr, pk)\n\tassert.Error(t, err, \"should reject mismatched key to prevent MITM\")\n\tassert.Contains(t, err.Error(), \"WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED\")\n}\n"
  },
  {
    "path": "host/host_unix.go",
    "content": "//go:build !windows\n\npackage host\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"syscall\"\n\n\t\"github.com/oklog/run\"\n)\n\n// setupSignalHandler configures OS signal handling for Unix systems.\n// Listens for both SIGINT (Ctrl+C) and SIGTERM for graceful shutdown.\n// On Unix, PTY isolation ensures that Ctrl+C sent to upterm's terminal\n// doesn't affect child processes in the PTY.\nfunc setupSignalHandler(g *run.Group, ctx context.Context) {\n\tg.Add(run.SignalHandler(ctx, os.Interrupt, syscall.SIGTERM))\n}\n"
  },
  {
    "path": "host/host_windows.go",
    "content": "//go:build windows\n\npackage host\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/oklog/run\"\n)\n\n// setupSignalHandler configures OS signal handling for Windows.\n// Only listens for SIGTERM (console close, logoff, shutdown) for graceful shutdown.\n// Explicitly ignores os.Interrupt (Ctrl+C, Ctrl+Break) to prevent upterm from dying\n// when SSH clients send Ctrl+C to child processes via ConPTY.\nfunc setupSignalHandler(g *run.Group, ctx context.Context) {\n\t// Listen for SIGTERM for graceful shutdown\n\tg.Add(run.SignalHandler(ctx, syscall.SIGTERM))\n\n\t// Consume and ignore os.Interrupt (Ctrl+C, Ctrl+Break)\n\t// This prevents the default OS behavior (process termination) while allowing\n\t// child processes in ConPTY to receive these signals normally.\n\t{\n\t\tsigCh := make(chan os.Signal, 1)\n\t\tsignal.Notify(sigCh, os.Interrupt)\n\t\tg.Add(func() error {\n\t\t\tfor range sigCh {\n\t\t\t\t// Consume and ignore - prevents upterm from being killed\n\t\t\t}\n\t\t\treturn nil\n\t\t}, func(err error) {\n\t\t\tsignal.Stop(sigCh)\n\t\t\tclose(sigCh)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "host/internal/adminserver.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"sync\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"google.golang.org/grpc\"\n)\n\ntype AdminServer struct {\n\tSession    *api.GetSessionResponse\n\tClientRepo *ClientRepo\n\tsrv        *grpc.Server\n\tsync.Mutex\n}\n\nfunc (s *AdminServer) Serve(ctx context.Context, sock string) error {\n\tln, err := net.Listen(\"unix\", sock)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ts.Lock()\n\ts.srv = grpc.NewServer()\n\tapi.RegisterAdminServiceServer(s.srv, &adminServiceServer{\n\t\tSession:    s.Session,\n\t\tClientRepo: s.ClientRepo,\n\t})\n\ts.Unlock()\n\n\treturn s.srv.Serve(ln)\n}\n\nfunc (s *AdminServer) Shutdown(ctx context.Context) error {\n\ts.Lock()\n\tdefer s.Unlock()\n\n\tif s.srv != nil {\n\t\ts.srv.GracefulStop()\n\t}\n\n\treturn nil\n}\n\ntype adminServiceServer struct {\n\tSession    *api.GetSessionResponse\n\tClientRepo *ClientRepo\n}\n\nfunc (s *adminServiceServer) GetSession(ctx context.Context, in *api.GetSessionRequest) (*api.GetSessionResponse, error) {\n\treturn &api.GetSessionResponse{\n\t\tSessionId:        s.Session.SessionId,\n\t\tHost:             s.Session.Host,\n\t\tNodeAddr:         s.Session.NodeAddr,\n\t\tSshUser:          s.Session.SshUser,\n\t\tCommand:          s.Session.Command,\n\t\tForceCommand:     s.Session.ForceCommand,\n\t\tAuthorizedKeys:   s.Session.AuthorizedKeys,\n\t\tConnectedClients: s.ClientRepo.Clients(),\n\t\tSftpDisabled:     s.Session.SftpDisabled,\n\t}, nil\n}\n"
  },
  {
    "path": "host/internal/client.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n)\n\nfunc NewClientRepo() *ClientRepo {\n\treturn &ClientRepo{}\n}\n\ntype ClientRepo struct {\n\tclients sync.Map\n}\n\nfunc (c *ClientRepo) Add(client *api.Client) error {\n\t_, loaded := c.clients.LoadOrStore(client.Id, client)\n\tif loaded {\n\t\treturn fmt.Errorf(\"client already exists\")\n\t}\n\n\treturn nil\n}\n\nfunc (c *ClientRepo) Delete(clientId string) {\n\tc.clients.Delete(clientId)\n}\n\nfunc (c *ClientRepo) Get(clientId string) *api.Client {\n\tval, _ := c.clients.Load(clientId)\n\tif val != nil {\n\t\treturn val.(*api.Client)\n\t}\n\n\treturn nil\n}\n\nfunc (c *ClientRepo) Clients() []*api.Client {\n\tvar clients []*api.Client\n\n\tc.clients.Range(func(key, value interface{}) bool {\n\t\tcc := value.(*api.Client)\n\t\tclients = append(clients, cc)\n\t\treturn true\n\t})\n\n\treturn clients\n}\n"
  },
  {
    "path": "host/internal/command.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\n\t\"github.com/oklog/run\"\n\t\"github.com/olebedev/emitter\"\n\tuio \"github.com/owenthereal/upterm/io\"\n\t\"golang.org/x/term\"\n)\n\nfunc newCommand(\n\tname string,\n\targs []string,\n\tenv []string,\n\tstdin *os.File,\n\tstdout *os.File,\n\teventEmitter *emitter.Emitter,\n\twriters *uio.MultiWriter,\n\tforceForwardingInputForTesting bool,\n) *command {\n\treturn &command{\n\t\tname:                           name,\n\t\targs:                           args,\n\t\tenv:                            env,\n\t\tstdin:                          stdin,\n\t\tstdout:                         stdout,\n\t\teventEmitter:                   eventEmitter,\n\t\twriters:                        writers,\n\t\tforceForwardingInputForTesting: forceForwardingInputForTesting,\n\t}\n}\n\ntype command struct {\n\tname string\n\targs []string\n\tenv  []string\n\n\tcmd  *exec.Cmd\n\tptmx PTY\n\n\tstdin  *os.File\n\tstdout *os.File\n\n\twriters *uio.MultiWriter\n\n\teventEmitter *emitter.Emitter\n\n\tctx context.Context\n\n\t// ForceForwardingInputForTesting forces stdin forwarding even when stdin is not a TTY.\n\t// This is used in tests where stdin is a pipe but we still want to forward test data.\n\tforceForwardingInputForTesting bool\n}\n\n// setupCommand creates an exec.Cmd with the given context, name, and args.\n// No special platform-specific handling is needed - signal handling is done\n// at the application level in host/host_*.go files.\nfunc setupCommand(ctx context.Context, name string, args []string) *exec.Cmd {\n\treturn exec.CommandContext(ctx, name, args...)\n}\n\nfunc (c *command) Start(ctx context.Context) (PTY, error) {\n\tc.ctx = ctx\n\tc.cmd = setupCommand(ctx, c.name, c.args)\n\tc.cmd.Env = append(c.env, os.Environ()...)\n\n\tvar err error\n\t// Pass stdin so startPty can get the initial terminal size\n\tc.ptmx, err = startPty(c.cmd, c.stdin)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to start pty: %w\", err)\n\t}\n\n\treturn c.ptmx, nil\n}\n\nfunc (c *command) Run() error {\n\t// Set stdin in raw mode.\n\tisTty := term.IsTerminal(int(c.stdin.Fd()))\n\n\tif isTty {\n\t\toldState, err := term.MakeRaw(int(c.stdin.Fd()))\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"unable to set terminal to raw mode: %w\", err)\n\t\t}\n\t\tdefer func() { _ = term.Restore(int(c.stdin.Fd()), oldState) }()\n\t}\n\n\tvar g run.Group\n\tif isTty {\n\t\t// Setup terminal resize handling (platform-specific)\n\t\tc.setupTerminalResize(&g, c.stdin, c.ptmx, c.eventEmitter)\n\t}\n\n\t// Forward stdin if it's a TTY or if forced for testing.\n\t// Do not forward stdin if it's not a TTY to avoid blocking indefinitely on io.Copy,\n\t// since non-TTY stdin (pipes, redirects) may never receive EOF in daemon-like scenarios.\n\tif isTty || c.forceForwardingInputForTesting {\n\t\t// input - forward stdin to PTY\n\t\tctx, cancel := context.WithCancel(c.ctx)\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(c.ptmx, uio.NewContextReader(ctx, c.stdin))\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\tcancel()\n\t\t})\n\t}\n\t{\n\t\t// output\n\t\tif err := c.writers.Append(c.stdout); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tctx, cancel := context.WithCancel(c.ctx)\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(c.writers, uio.NewContextReader(ctx, c.ptmx))\n\t\t\treturn ptyError(err)\n\t\t}, func(err error) {\n\t\t\tc.writers.Remove(os.Stdout)\n\t\t\tcancel()\n\t\t})\n\t}\n\t{\n\t\tctx, cancel := context.WithCancel(c.ctx)\n\t\tg.Add(func() error {\n\t\t\tdone := make(chan error, 1)\n\t\t\tgo func() {\n\t\t\t\tdone <- c.ptmx.Wait()\n\t\t\t}()\n\n\t\t\tselect {\n\t\t\tcase err := <-done:\n\t\t\t\treturn err\n\t\t\tcase <-ctx.Done():\n\t\t\t\t// Context cancelled, kill the process and wait for it to exit\n\t\t\t\t_ = c.ptmx.Kill()\n\t\t\t\t<-done // Wait for the process to actually exit\n\t\t\t\treturn ctx.Err()\n\t\t\t}\n\t\t}, func(err error) {\n\t\t\t_ = c.ptmx.Close()\n\t\t\tcancel()\n\t\t})\n\t}\n\n\treturn g.Run()\n}\n"
  },
  {
    "path": "host/internal/command_test.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"runtime\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/olebedev/emitter\"\n\tuio \"github.com/owenthereal/upterm/io\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/term\"\n)\n\n// TestCommand_NonTTY_WithForceFlag verifies that stdin forwarding IS enabled\n// when ForceForwardingInputForTesting is true, even with a non-TTY stdin.\nfunc TestCommand_NonTTY_WithForceFlag(t *testing.T) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create a pipe (non-TTY)\n\tstdinr, stdinw, err := os.Pipe()\n\trequire.NoError(err, \"failed to create stdin pipe\")\n\tdefer func() { _ = stdinr.Close() }()\n\tdefer func() { _ = stdinw.Close() }()\n\n\tstdoutr, stdoutw, err := os.Pipe()\n\trequire.NoError(err, \"failed to create stdout pipe\")\n\tdefer func() { _ = stdoutr.Close() }()\n\tdefer func() { _ = stdoutw.Close() }()\n\n\t// Verify stdin is not a TTY\n\tassert.False(term.IsTerminal(int(stdinr.Fd())), \"stdin should not be a TTY for this test\")\n\n\tee := &emitter.Emitter{}\n\twriters := uio.NewMultiWriter(5)\n\n\t// Create command WITH ForceForwardingInputForTesting\n\t// Use a command that reads from stdin and outputs it (cross-platform)\n\tvar shellCmd string\n\tvar shellArgs []string\n\tif runtime.GOOS == \"windows\" {\n\t\t// Windows: Use 'findstr' with regex that matches any line\n\t\t// This reads stdin line by line and outputs matching lines\n\t\tshellCmd = \"findstr\"\n\t\tshellArgs = []string{\"/r\", \".*\"}\n\t} else {\n\t\t// Unix: Use 'head' which reads exactly one line then exits\n\t\tshellCmd = \"head\"\n\t\tshellArgs = []string{\"-n\", \"1\"}\n\t}\n\n\tcmd := newCommand(\n\t\tshellCmd,\n\t\tshellArgs,\n\t\tnil,\n\t\tstdinr,\n\t\tstdoutw,\n\t\tee,\n\t\twriters,\n\t\ttrue, // Force stdin forwarding even though it's not a TTY\n\t)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\t_, err = cmd.Start(ctx)\n\trequire.NoError(err, \"failed to start command\")\n\n\t// Capture output in background\n\toutputCh := make(chan string, 1)\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tvar output []byte\n\t\tfor {\n\t\t\tn, err := stdoutr.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\toutput = append(output, buf[:n]...)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t// Only send if we captured output (don't send empty string)\n\t\tif len(output) > 0 {\n\t\t\toutputCh <- string(output)\n\t\t}\n\t}()\n\n\t// Run the command in a goroutine\n\terrCh := make(chan error, 1)\n\tgo func() {\n\t\terrCh <- cmd.Run()\n\t}()\n\n\t// Give it a moment to start and begin reading\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Send input through the pipe\n\ttestInput := \"test input from pipe\"\n\t_, err = stdinw.Write([]byte(testInput + \"\\n\"))\n\trequire.NoError(err, \"failed to write to stdin\")\n\n\t// Give a moment for data to be fully written and copied through the PTY\n\ttime.Sleep(50 * time.Millisecond)\n\n\t// Close stdin so 'more' on Windows will exit after reading\n\t// On Unix, 'head -n 1' exits immediately after reading one line\n\t_ = stdinw.Close()\n\n\t// The command should complete after receiving input\n\tselect {\n\tcase err := <-errCh:\n\t\t// Expected: command completes after reading one line\n\t\tif err != nil {\n\t\t\tt.Logf(\"command completed with error (might be expected): %v\", err)\n\t\t}\n\n\t\t// Verify the output shows our input was forwarded through stdin\n\t\t_ = stdoutw.Close()\n\t\toutput := <-outputCh\n\t\t// head -n 1 should output exactly the line we sent\n\t\tassert.Contains(output, testInput, \"should see our piped input in output, proving stdin was forwarded\")\n\tcase <-time.After(1500 * time.Millisecond):\n\t\tcancel()\n\t\tassert.Fail(\"command did not complete after receiving input\")\n\t}\n}\n\n// TestCommand_ContextCancellation verifies that context cancellation\n// properly terminates the command and cleans up resources.\nfunc TestCommand_ContextCancellation(t *testing.T) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\tstdoutr, stdoutw, err := os.Pipe()\n\trequire.NoError(err, \"failed to create stdout pipe\")\n\tdefer func() { _ = stdoutr.Close() }()\n\tdefer func() { _ = stdoutw.Close() }()\n\n\tee := &emitter.Emitter{}\n\twriters := uio.NewMultiWriter(5)\n\n\t// Use a long-running command that will only exit when interrupted\n\tvar shellCmd string\n\tvar shellArgs []string\n\tif runtime.GOOS == \"windows\" {\n\t\t// Windows: Use 'ping' with high count\n\t\tshellCmd = \"ping\"\n\t\tshellArgs = []string{\"-n\", \"1000\", \"127.0.0.1\"}\n\t} else {\n\t\t// Unix: Use 'sleep' for a long time\n\t\tshellCmd = \"sleep\"\n\t\tshellArgs = []string{\"1000\"}\n\t}\n\n\tcmd := newCommand(\n\t\tshellCmd,\n\t\tshellArgs,\n\t\tnil,\n\t\tos.Stdin,\n\t\tstdoutw,\n\t\tee,\n\t\twriters,\n\t\tfalse,\n\t)\n\n\t// Create a context with cancel\n\tctx, cancel := context.WithCancel(context.Background())\n\n\t_, err = cmd.Start(ctx)\n\trequire.NoError(err, \"failed to start command\")\n\n\t// Run the command in a goroutine\n\terrCh := make(chan error, 1)\n\tgo func() {\n\t\terrCh <- cmd.Run()\n\t}()\n\n\t// Give the command time to start\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Cancel the context - this should trigger cleanup\n\tcancel()\n\n\t// Command should terminate within reasonable time\n\tselect {\n\tcase err := <-errCh:\n\t\t// Context cancellation should cause command to exit\n\t\t// Error may be context.Canceled or exit status from kill\n\t\tif err != nil && err != context.Canceled {\n\t\t\tt.Logf(\"command exited with error (expected): %v\", err)\n\t\t}\n\t\t// Command terminated successfully - reaching here proves it worked\n\tcase <-time.After(2 * time.Second):\n\t\tassert.Fail(\"command did not terminate after context cancellation\")\n\t}\n}\n"
  },
  {
    "path": "host/internal/command_unix.go",
    "content": "//go:build !windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/oklog/run\"\n\t\"github.com/olebedev/emitter\"\n)\n\n// setupTerminalResize sets up terminal resize handling for Unix systems using SIGWINCH\nfunc (c *command) setupTerminalResize(g *run.Group, stdin *os.File, ptmx PTY, eventEmitter *emitter.Emitter) {\n\tch := make(chan os.Signal, 1)\n\tsignal.Notify(ch, syscall.SIGWINCH)\n\t// Note: Initial size is already set in startPty, so we only handle resize events here\n\tctx, cancel := context.WithCancel(c.ctx)\n\ttee := terminalEventEmitter{eventEmitter}\n\tg.Add(func() error {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tclose(ch)\n\t\t\t\treturn ctx.Err()\n\t\t\tcase <-ch:\n\t\t\t\th, w, err := getPtysize(stdin)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\ttee.TerminalWindowChanged(\"local\", ptmx, w, h)\n\t\t\t}\n\t\t}\n\t}, func(err error) {\n\t\ttee.TerminalDetached(\"local\", ptmx)\n\t\tcancel()\n\t})\n}\n"
  },
  {
    "path": "host/internal/command_unix_test.go",
    "content": "//go:build !windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\tptylib \"github.com/creack/pty\"\n\t\"github.com/olebedev/emitter\"\n\tuio \"github.com/owenthereal/upterm/io\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/term\"\n)\n\n// TestCommand_Unix_PTY verifies Unix-specific PTY functionality.\n// This test validates that a real PTY is properly detected as a TTY\n// and stdin forwarding is enabled.\nfunc TestCommand_Unix_PTY(t *testing.T) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create a real PTY\n\tptmx, tty, err := ptylib.Open()\n\trequire.NoError(err, \"failed to create PTY\")\n\tdefer func() { _ = ptmx.Close() }()\n\tdefer func() { _ = tty.Close() }()\n\n\t// Set PTY size\n\terr = ptylib.Setsize(ptmx, &ptylib.Winsize{Rows: 24, Cols: 80})\n\trequire.NoError(err, \"failed to set PTY size\")\n\n\t// Verify tty IS a terminal\n\tassert.True(term.IsTerminal(int(tty.Fd())), \"tty should be recognized as a terminal\")\n\n\tstdoutr, stdoutw, err := os.Pipe()\n\trequire.NoError(err, \"failed to create stdout pipe\")\n\tdefer func() { _ = stdoutr.Close() }()\n\tdefer func() { _ = stdoutw.Close() }()\n\n\tee := &emitter.Emitter{}\n\twriters := uio.NewMultiWriter(5)\n\n\t// Create command with real PTY (ForceForwardingInputForTesting not needed)\n\t// Use 'head -n 1' which exits immediately after reading one line\n\t// This is more reliable than 'read' which has timing issues with bash initialization\n\tcmd := newCommand(\n\t\t\"head\",\n\t\t[]string{\"-n\", \"1\"},\n\t\tnil,\n\t\ttty,\n\t\tstdoutw,\n\t\tee,\n\t\twriters,\n\t\tfalse, // Should not be needed for real TTY\n\t)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\t_, err = cmd.Start(ctx)\n\trequire.NoError(err, \"failed to start command\")\n\n\t// Capture output in background\n\toutputCh := make(chan string, 1)\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tvar output []byte\n\t\tfor {\n\t\t\tn, err := stdoutr.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\toutput = append(output, buf[:n]...)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t// Only send if we captured output (don't send empty string)\n\t\tif len(output) > 0 {\n\t\t\toutputCh <- string(output)\n\t\t}\n\t}()\n\n\t// Run the command in a goroutine\n\terrCh := make(chan error, 1)\n\tgo func() {\n\t\terrCh <- cmd.Run()\n\t}()\n\n\t// Give head time to start\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Send input through the PTY master\n\t// head -n 1 reads one line and exits immediately\n\ttestInput := \"hello from pty\"\n\t_, err = ptmx.Write([]byte(testInput + \"\\n\"))\n\trequire.NoError(err, \"failed to write to PTY\")\n\n\t// Wait for command to complete (head exits after reading one line)\n\tselect {\n\tcase err := <-errCh:\n\t\tif err != nil {\n\t\t\tt.Logf(\"command completed with error (might be expected): %v\", err)\n\t\t}\n\tcase <-time.After(1500 * time.Millisecond):\n\t\tcancel()\n\t\t<-errCh\n\t\tassert.Fail(\"command did not complete - stdin may not be forwarded for PTY\")\n\t\treturn\n\t}\n\n\t// Command has exited, now close stdout writer to signal EOF to output reader\n\t_ = stdoutw.Close()\n\n\t// Wait for output (should be available now since command has finished)\n\tselect {\n\tcase output := <-outputCh:\n\t\tassert.Contains(output, testInput, \"should see our input forwarded through PTY and output by head\")\n\tcase <-time.After(500 * time.Millisecond):\n\t\tassert.Fail(\"no output captured - PTY may not be forwarding data correctly\")\n\t}\n}\n"
  },
  {
    "path": "host/internal/command_windows.go",
    "content": "//go:build windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/oklog/run\"\n\t\"github.com/olebedev/emitter\"\n)\n\n// setupTerminalResize polls for terminal size changes on Windows\n// Windows doesn't have SIGWINCH signals like Unix, so we poll for terminal size changes\n// Note: Initial size is already set in startPty\nfunc (c *command) setupTerminalResize(g *run.Group, stdin *os.File, ptmx PTY, eventEmitter *emitter.Emitter) {\n\t// Get the initial size to track changes\n\th, w, err := getPtysize(stdin)\n\tif err != nil {\n\t\t// If we can't get the size, skip resize monitoring\n\t\treturn\n\t}\n\n\ttee := terminalEventEmitter{eventEmitter}\n\t// Track the last known size for comparison\n\tlastH, lastW := h, w\n\n\t// Poll for terminal size changes\n\tctx, cancel := context.WithCancel(c.ctx)\n\tg.Add(func() error {\n\t\tticker := time.NewTicker(500 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn ctx.Err()\n\t\t\tcase <-ticker.C:\n\t\t\t\th, w, err := getPtysize(stdin)\n\t\t\t\tif err != nil {\n\t\t\t\t\t// Can't get size, skip this check\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Only notify if size actually changed\n\t\t\t\tif h != lastH || w != lastW {\n\t\t\t\t\tlastH, lastW = h, w\n\t\t\t\t\ttee.TerminalWindowChanged(\"local\", ptmx, w, h)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, func(err error) {\n\t\ttee.TerminalDetached(\"local\", ptmx)\n\t\tcancel()\n\t})\n}\n"
  },
  {
    "path": "host/internal/command_windows_test.go",
    "content": "//go:build windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/olebedev/emitter\"\n\tuio \"github.com/owenthereal/upterm/io\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// TestCommand_Windows_BasicExecution verifies that commands can be started\n// and executed correctly on Windows with ConPTY.\nfunc TestCommand_Windows_BasicExecution(t *testing.T) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\tstdoutr, stdoutw, err := os.Pipe()\n\trequire.NoError(err, \"failed to create stdout pipe\")\n\tdefer func() { _ = stdoutr.Close() }()\n\tdefer func() { _ = stdoutw.Close() }()\n\n\tee := &emitter.Emitter{}\n\twriters := uio.NewMultiWriter(5)\n\n\t// Use a simple command\n\tcmd := newCommand(\n\t\t\"cmd\",\n\t\t[]string{\"/c\", \"echo\", \"test\"},\n\t\tnil,\n\t\tos.Stdin,\n\t\tstdoutw,\n\t\tee,\n\t\twriters,\n\t\tfalse,\n\t)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\n\tdefer cancel()\n\n\tptmx, err := cmd.Start(ctx)\n\trequire.NoError(err, \"failed to start command\")\n\n\t// Run the command\n\terrCh := make(chan error, 1)\n\tgo func() {\n\t\terrCh <- cmd.Run()\n\t}()\n\n\t// Wait for completion\n\tselect {\n\tcase err := <-errCh:\n\t\tassert.NoError(err, \"command should complete successfully\")\n\tcase <-time.After(2 * time.Second):\n\t\t_ = ptmx.Close()\n\t\tassert.Fail(\"command did not complete within timeout\")\n\t}\n}\n\n// TestCommand_Windows_JobObject verifies that on Windows,\n// a job object is created and assigned to the process.\nfunc TestCommand_Windows_JobObject(t *testing.T) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\tstdoutr, stdoutw, err := os.Pipe()\n\trequire.NoError(err, \"failed to create stdout pipe\")\n\tdefer func() { _ = stdoutr.Close() }()\n\tdefer func() { _ = stdoutw.Close() }()\n\n\tee := &emitter.Emitter{}\n\twriters := uio.NewMultiWriter(5)\n\n\t// Use a long-running command that won't exit on its own\n\tcmd := newCommand(\n\t\t\"ping\",\n\t\t[]string{\"-n\", \"100\", \"127.0.0.1\"},\n\t\tnil,\n\t\tos.Stdin,\n\t\tstdoutw,\n\t\tee,\n\t\twriters,\n\t\tfalse,\n\t)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tptmx, err := cmd.Start(ctx)\n\trequire.NoError(err, \"failed to start command\")\n\n\t// The pty struct should have a job handle (non-zero)\n\t// We can't directly access private fields, but we can verify\n\t// that the command was started and cleanup works\n\tassert.NotNil(ptmx, \"PTY should be created\")\n\n\t// Run the command in background\n\terrCh := make(chan error, 1)\n\trunningCh := make(chan bool, 1)\n\tgo func() {\n\t\trunningCh <- true // Signal that Run() has started\n\t\terrCh <- cmd.Run()\n\t}()\n\n\t// Wait for cmd.Run() to start\n\t<-runningCh\n\n\t// Give the command time to actually start executing\n\ttime.Sleep(200 * time.Millisecond)\n\n\t// Verify command is still running (hasn't terminated yet)\n\tselect {\n\tcase <-errCh:\n\t\tassert.Fail(\"command should still be running before Close()\")\n\tdefault:\n\t\t// Good - command is still running\n\t}\n\n\t// Close the PTY - this should:\n\t// 1. Close the job object handle\n\t// 2. OS sees JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE flag\n\t// 3. OS terminates all processes in the job\n\terr = ptmx.Close()\n\tassert.NoError(err, \"PTY close should succeed\")\n\n\t// Command should terminate shortly after PTY close\n\t// ONLY the job object can cause this - we didn't cancel context,\n\t// we didn't send any signals, we just closed the PTY.\n\t// The process termination proves job object cleanup worked.\n\tselect {\n\tcase err := <-errCh:\n\t\t// Command terminated - this is what we expect\n\t\t// The error might be non-nil (terminated by OS), which is fine\n\t\tif err != nil {\n\t\t\tt.Logf(\"command terminated with error (expected from job kill): %v\", err)\n\t\t}\n\t\t// Success - job object cleanup worked\n\tcase <-time.After(2 * time.Second):\n\t\t// If we reach here, the command is still running after Close()\n\t\t// This means job object cleanup FAILED\n\t\tassert.Fail(\"command did not terminate after job object close - job object cleanup may have failed\")\n\t}\n}\n\n// TestCommand_Windows_ConPTY verifies Windows-specific ConPTY functionality.\n// This test validates that ConPTY can be created and used to run commands,\n// and that command output is properly captured through the ConPTY.\nfunc TestCommand_Windows_ConPTY(t *testing.T) {\n\trequire := require.New(t)\n\tassert := assert.New(t)\n\n\t// Create a pipe to capture stdout\n\tstdoutr, stdoutw, err := os.Pipe()\n\trequire.NoError(err, \"failed to create stdout pipe\")\n\tdefer func() { _ = stdoutr.Close() }()\n\tdefer func() { _ = stdoutw.Close() }()\n\n\tee := &emitter.Emitter{}\n\twriters := uio.NewMultiWriter(5)\n\n\t// Run a simple command through ConPTY\n\t// Use 'cmd /c echo' which is simple and reliable on Windows\n\tcmd := newCommand(\n\t\t\"cmd\",\n\t\t[]string{\"/c\", \"echo\", \"ConPTY test successful\"},\n\t\tnil,\n\t\tos.Stdin, // Pass stdin so startPty can attempt to get size\n\t\tstdoutw,\n\t\tee,\n\t\twriters,\n\t\tfalse,\n\t)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\n\tdefer cancel()\n\n\t// Start the command - this will create the ConPTY\n\t_, err = cmd.Start(ctx)\n\trequire.NoError(err, \"failed to start command with ConPTY\")\n\n\t// Capture output in background\n\toutputCh := make(chan string, 1)\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tvar output []byte\n\t\tfor {\n\t\t\tn, err := stdoutr.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\toutput = append(output, buf[:n]...)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t// Always send to channel to avoid deadlock\n\t\toutputCh <- string(output)\n\t}()\n\n\t// Run the command\n\terrCh := make(chan error, 1)\n\tgo func() {\n\t\terrCh <- cmd.Run()\n\t}()\n\n\t// Wait for command to complete\n\tselect {\n\tcase err := <-errCh:\n\t\t// Command should complete successfully\n\t\tassert.NoError(err, \"command should complete successfully\")\n\n\t\t// Verify we got output through ConPTY\n\t\t_ = stdoutw.Close()\n\t\tselect {\n\t\tcase output := <-outputCh:\n\t\t\tassert.Contains(output, \"ConPTY test successful\", \"should see command output through ConPTY\")\n\t\t\tt.Logf(\"ConPTY output: %q\", output)\n\t\tcase <-time.After(500 * time.Millisecond):\n\t\t\tassert.Fail(\"timeout waiting for output\")\n\t\t}\n\tcase <-time.After(2500 * time.Millisecond):\n\t\tcancel()\n\t\t<-errCh // Wait for goroutine to finish\n\t\tassert.Fail(\"command did not complete within timeout\")\n\t}\n}\n"
  },
  {
    "path": "host/internal/event.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"log/slog\"\n\n\t\"github.com/olebedev/emitter\"\n\t\"github.com/owenthereal/upterm/upterm\"\n)\n\nconst (\n\terrBadFileDescriptor = \"bad file descriptor\"\n)\n\ntype terminal struct {\n\tID     string\n\tPty    PTY\n\tWindow window\n}\n\ntype window struct {\n\tWidth  int\n\tHeight int\n}\n\ntype terminalEventEmitter struct {\n\teventEmitter *emitter.Emitter\n}\n\nfunc (t terminalEventEmitter) TerminalWindowChanged(id string, pty PTY, w, h int) {\n\ttt := terminal{\n\t\tID:  id,\n\t\tPty: pty,\n\t\tWindow: window{\n\t\t\tWidth:  w,\n\t\t\tHeight: h,\n\t\t},\n\t}\n\tt.eventEmitter.Emit(upterm.EventTerminalWindowChanged, tt)\n}\n\nfunc (t terminalEventEmitter) TerminalDetached(id string, pty PTY) {\n\ttt := terminal{\n\t\tID:  id,\n\t\tPty: pty,\n\t}\n\tt.eventEmitter.Emit(upterm.EventTerminalDetached, tt)\n}\n\ntype terminalEventHandler struct {\n\teventEmitter *emitter.Emitter\n\tlogger       *slog.Logger\n}\n\nfunc (t terminalEventHandler) Handle(ctx context.Context) error {\n\twinCh := t.eventEmitter.On(upterm.EventTerminalWindowChanged, emitter.Sync, emitter.Skip)\n\tdtCh := t.eventEmitter.On(upterm.EventTerminalDetached, emitter.Sync, emitter.Skip)\n\n\tdefer func() {\n\t\tt.eventEmitter.Off(upterm.EventTerminalWindowChanged, winCh)\n\t\tt.eventEmitter.Off(upterm.EventTerminalDetached, dtCh)\n\t}()\n\n\tm := make(map[io.ReadWriteCloser]map[string]terminal)\n\tfor {\n\t\tselect {\n\t\tcase evt := <-winCh:\n\t\t\tif err := t.handleWindowChanged(evt, m); err != nil {\n\t\t\t\tt.logger.Error(\"error handling window changed\", \"error\", err)\n\t\t\t}\n\t\tcase evt := <-dtCh:\n\t\t\tif err := t.handleTerminalDetached(evt, m); err != nil {\n\t\t\t\tt.logger.Error(\"error handling terminal detached\", \"error\", err)\n\t\t\t}\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\t}\n\t}\n}\n\nfunc (t terminalEventHandler) handleWindowChanged(evt emitter.Event, m map[io.ReadWriteCloser]map[string]terminal) error {\n\targs := evt.Args\n\tif len(args) == 0 {\n\t\treturn fmt.Errorf(\"expect terminal window change event to have at least one argument\")\n\t}\n\n\ttt, ok := args[0].(terminal)\n\tif !ok {\n\t\treturn fmt.Errorf(\"expect terminal window change event to receive a terminal\")\n\t}\n\n\tpty := tt.Pty\n\tts, ok := m[pty]\n\tif !ok {\n\t\tts = make(map[string]terminal)\n\t\tm[pty] = ts\n\t}\n\tts[tt.ID] = tt\n\tif err := resizeWindow(pty, ts); err != nil && !strings.Contains(err.Error(), errBadFileDescriptor) {\n\t\treturn fmt.Errorf(\"error resizing window: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (t terminalEventHandler) handleTerminalDetached(evt emitter.Event, m map[io.ReadWriteCloser]map[string]terminal) error {\n\targs := evt.Args\n\tif len(args) == 0 {\n\t\treturn fmt.Errorf(\"expect terminal window change event to have at least one argument\")\n\t}\n\n\ttt, ok := args[0].(terminal)\n\tif !ok {\n\t\treturn fmt.Errorf(\"expect terminal window change event to receive a terminal\")\n\t}\n\n\tpty := tt.Pty\n\tts, ok := m[pty]\n\tif ok {\n\t\tdelete(ts, tt.ID)\n\t}\n\n\tif len(ts) == 0 {\n\t\tdelete(m, pty)\n\t}\n\n\treturn nil\n}\n\nfunc resizeWindow(ptmx PTY, ts map[string]terminal) error {\n\tvar w, h int\n\n\tfor _, t := range ts {\n\t\tif w == 0 || w > t.Window.Width {\n\t\t\tw = t.Window.Width\n\t\t}\n\n\t\tif h == 0 || h > t.Window.Height {\n\t\t\th = t.Window.Height\n\t\t}\n\t}\n\n\treturn ptmx.Setsize(h, w)\n}\n"
  },
  {
    "path": "host/internal/pty.go",
    "content": "package internal\n\nimport \"io\"\n\n// PTY represents a pseudo-terminal abstraction that works across platforms.\n// On Unix, it wraps a traditional PTY created via creack/pty.\n// On Windows, it wraps a ConPTY (Console Pseudo Terminal).\n//\n// The interface provides a common abstraction for:\n//   - Reading/writing terminal I/O (via io.ReadWriteCloser)\n//   - Resizing the terminal window\n//   - Managing process lifecycle (Wait/Kill)\n//\n// Platform-specific implementations:\n//   - Unix: see pty_unix.go\n//   - Windows: see pty_windows.go\ntype PTY interface {\n\tio.ReadWriteCloser\n\n\t// Setsize changes the terminal dimensions.\n\t// On Unix, this sends a SIGWINCH to the slave process.\n\t// On Windows, this resizes the ConPTY buffer.\n\tSetsize(h, w int) error\n\n\t// Wait waits for the process associated with this PTY to exit.\n\t// On Unix, this delegates to exec.Cmd.Wait().\n\t// On Windows, this waits on the process handle.\n\tWait() error\n\n\t// Kill terminates the process associated with this PTY.\n\t// On Unix, this delegates to exec.Cmd.Process.Kill().\n\t// On Windows, this calls TerminateProcess on the handle.\n\tKill() error\n}\n"
  },
  {
    "path": "host/internal/pty_unix.go",
    "content": "//go:build !windows\n\npackage internal\n\nimport (\n\t\"os\"\n\t\"os/exec\"\n\t\"sync\"\n\t\"syscall\"\n\n\tptylib \"github.com/creack/pty\"\n)\n\nfunc startPty(c *exec.Cmd, stdin *os.File) (PTY, error) {\n\t// Create PTY with kernel defaults first\n\tf, err := ptylib.Start(c)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Set the initial size from stdin if available\n\tif stdin != nil {\n\t\th, w, err := getPtysize(stdin)\n\t\tif err == nil && w > 0 && h > 0 {\n\t\t\t// Set the PTY size before returning\n\t\t\t// Ignore error - process is already running, will use kernel defaults if this fails\n\t\t\t_ = ptylib.Setsize(f, &ptylib.Winsize{\n\t\t\t\tRows: uint16(h),\n\t\t\t\tCols: uint16(w),\n\t\t\t})\n\t\t}\n\t}\n\n\treturn wrapPty(f, c), nil\n}\n\n// Linux kernel return EIO when attempting to read from a master pseudo\n// terminal which no longer has an open slave. So ignore error here.\n// See https://github.com/creack/pty/issues/21\nfunc ptyError(err error) error {\n\tif pathErr, ok := err.(*os.PathError); !ok || pathErr.Err != syscall.EIO {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc getPtysize(f *os.File) (h, w int, err error) {\n\treturn ptylib.Getsize(f)\n}\n\nfunc wrapPty(f *os.File, cmd *exec.Cmd) *pty {\n\treturn &pty{File: f, cmd: cmd}\n}\n\n// Pty is a wrapper of the pty *os.File that provides a read/write mutex.\n// This is to prevent data race that might happen for reszing, reading and closing.\n// See ftests failure:\n// * https://travis-ci.org/owenthereal/upterm/jobs/632489866\n// * https://travis-ci.org/owenthereal/upterm/jobs/632458125\ntype pty struct {\n\t*os.File\n\tcmd *exec.Cmd // Process started with this PTY\n\tsync.RWMutex\n}\n\nfunc (pty *pty) Setsize(h, w int) error {\n\tpty.RLock()\n\tdefer pty.RUnlock()\n\n\tsize := &ptylib.Winsize{\n\t\tRows: uint16(h),\n\t\tCols: uint16(w),\n\t}\n\treturn ptylib.Setsize(pty.File, size)\n}\n\nfunc (pty *pty) Read(p []byte) (n int, err error) {\n\tpty.RLock()\n\tdefer pty.RUnlock()\n\n\treturn pty.File.Read(p)\n}\n\nfunc (pty *pty) Close() error {\n\tpty.Lock()\n\tdefer pty.Unlock()\n\n\treturn pty.File.Close()\n}\n\n// Wait waits for the process to exit\nfunc (pty *pty) Wait() error {\n\tpty.RLock()\n\tcmd := pty.cmd\n\tpty.RUnlock()\n\n\tif cmd == nil {\n\t\treturn nil // No process to wait for\n\t}\n\treturn cmd.Wait()\n}\n\n// Kill terminates the process\nfunc (pty *pty) Kill() error {\n\tpty.RLock()\n\tcmd := pty.cmd\n\tpty.RUnlock()\n\n\tif cmd == nil || cmd.Process == nil {\n\t\treturn nil // No process to kill\n\t}\n\treturn cmd.Process.Kill()\n}\n"
  },
  {
    "path": "host/internal/pty_windows.go",
    "content": "//go:build windows\n\npackage internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"sync\"\n\t\"syscall\"\n\t\"unsafe\"\n\n\t\"github.com/charmbracelet/x/conpty\"\n\t\"golang.org/x/sys/windows\"\n\t\"golang.org/x/term\"\n)\n\n// Windows API proc handles (cached to avoid repeated lazy DLL loading)\nvar (\n\tmodkernel32                 = windows.NewLazySystemDLL(\"kernel32.dll\")\n\tprocSetInformationJobObject = modkernel32.NewProc(\"SetInformationJobObject\")\n)\n\n// startPty starts a PTY for the given command on Windows using ConPTY\nfunc startPty(c *exec.Cmd, stdin *os.File) (PTY, error) {\n\t// Get the actual terminal size from stdin if available\n\t// Otherwise, use default dimensions\n\theight := conpty.DefaultHeight\n\twidth := conpty.DefaultWidth\n\n\tif stdin != nil {\n\t\t// Try to get the terminal size from stdin\n\t\th, w, err := getPtysize(stdin)\n\t\tif err == nil && w > 0 && h > 0 {\n\t\t\twidth = w\n\t\t\theight = h\n\t\t}\n\t\t// If GetSize fails or returns invalid dimensions, we'll use the defaults\n\t}\n\n\t// conpty.New expects (width, height, flags)\n\tcpty, err := conpty.New(width, height, 0)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create conpty: %w\", err)\n\t}\n\n\t// Spawn the process with process attributes from the command\n\tpid, handle, err := cpty.Spawn(c.Path, c.Args, &syscall.ProcAttr{\n\t\tDir: c.Dir,\n\t\tEnv: c.Env,\n\t\tSys: c.SysProcAttr,\n\t})\n\tif err != nil {\n\t\tcpty.Close()\n\t\treturn nil, fmt.Errorf(\"failed to spawn process: %w\", err)\n\t}\n\n\t// Create a job object to ensure child processes are killed when upterm exits\n\t// This provides parity with Unix behavior where closing terminal kills all processes\n\tjob, err := createJobObject(syscall.Handle(handle))\n\tif err != nil {\n\t\tsyscall.TerminateProcess(syscall.Handle(handle), 1)\n\t\tsyscall.CloseHandle(syscall.Handle(handle))\n\t\tcpty.Close()\n\t\treturn nil, fmt.Errorf(\"failed to create job object: %w\", err)\n\t}\n\n\treturn &pty{\n\t\tcpty:   cpty,\n\t\thandle: handle,\n\t\tpid:    pid,\n\t\tjob:    job,\n\t}, nil\n}\n\n// Pty is a wrapper of the ConPTY that provides a read/write mutex.\ntype pty struct {\n\tcpty                *conpty.ConPty\n\thandle              uintptr\n\tpid                 int\n\tjob                 syscall.Handle // Job object handle\n\tconptyClosed        bool           // Tracks if ConPTY I/O has been closed\n\tprocessHandleClosed bool           // Tracks if process handle has been closed\n\tsync.RWMutex\n}\n\nfunc (p *pty) Setsize(h, w int) error {\n\tp.RLock()\n\tdefer p.RUnlock()\n\n\tif p.conptyClosed || p.cpty == nil {\n\t\treturn nil // Silently ignore resize on closed pty\n\t}\n\n\treturn p.cpty.Resize(w, h)\n}\n\nfunc (p *pty) Read(data []byte) (n int, err error) {\n\tp.RLock()\n\tconptyClosed := p.conptyClosed\n\tcpty := p.cpty\n\tp.RUnlock()\n\n\tif conptyClosed || cpty == nil {\n\t\treturn 0, io.EOF\n\t}\n\n\treturn cpty.Read(data)\n}\n\nfunc (p *pty) Write(data []byte) (n int, err error) {\n\tp.RLock()\n\tconptyClosed := p.conptyClosed\n\tcpty := p.cpty\n\tp.RUnlock()\n\n\tif conptyClosed || cpty == nil {\n\t\treturn 0, io.ErrClosedPipe\n\t}\n\n\treturn cpty.Write(data)\n}\n\nfunc (p *pty) Close() error {\n\tp.Lock()\n\tdefer p.Unlock()\n\n\tif p.conptyClosed {\n\t\treturn nil\n\t}\n\n\tp.conptyClosed = true // Mark as closed immediately so Read/Write return EOF\n\n\t// Close job object first - this will terminate all processes in the job\n\tif p.job != 0 {\n\t\tsyscall.CloseHandle(p.job)\n\t\tp.job = 0\n\t}\n\n\tvar err error\n\tif p.cpty != nil {\n\t\terr = p.cpty.Close()\n\t\tp.cpty = nil\n\t}\n\treturn err\n}\n\n// getPtysize gets the terminal size from a file descriptor on Windows\nfunc getPtysize(f *os.File) (h, w int, err error) {\n\tw, h, err = term.GetSize(int(f.Fd()))\n\treturn h, w, err\n}\n\n// Windows doesn't return EIO like Linux, so this is a no-op\nfunc ptyError(err error) error {\n\treturn err\n}\n\n// Wait waits for the process to exit on Windows\nfunc (p *pty) Wait() error {\n\tp.Lock()\n\thandle := p.handle\n\tprocessHandleClosed := p.processHandleClosed\n\tp.Unlock()\n\n\tif handle == 0 {\n\t\treturn fmt.Errorf(\"no process handle\")\n\t}\n\tif processHandleClosed {\n\t\treturn fmt.Errorf(\"process handle already closed\")\n\t}\n\n\t// Close the process handle when done, regardless of error paths\n\tdefer func() {\n\t\tp.Lock()\n\t\tif !p.processHandleClosed {\n\t\t\tsyscall.CloseHandle(syscall.Handle(p.handle))\n\t\t\tp.processHandleClosed = true\n\t\t}\n\t\tp.Unlock()\n\t}()\n\n\t// Wait for the process to exit\n\ts, err := syscall.WaitForSingleObject(syscall.Handle(handle), syscall.INFINITE)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"WaitForSingleObject failed: %w\", err)\n\t}\n\tif s != 0 {\n\t\treturn fmt.Errorf(\"WaitForSingleObject returned %d\", s)\n\t}\n\n\t// Get exit code\n\tvar exitCode uint32\n\tif err := syscall.GetExitCodeProcess(syscall.Handle(handle), &exitCode); err != nil {\n\t\treturn fmt.Errorf(\"GetExitCodeProcess failed: %w\", err)\n\t}\n\n\t// Don't close ConPTY here - let the run.Group interrupt handler do it\n\t// This ensures proper shutdown order\n\n\tif exitCode != 0 {\n\t\treturn fmt.Errorf(\"exit status %d\", exitCode)\n\t}\n\n\treturn nil\n}\n\n// Kill terminates the process on Windows\nfunc (p *pty) Kill() error {\n\tp.RLock()\n\thandle := p.handle\n\tprocessHandleClosed := p.processHandleClosed\n\tp.RUnlock()\n\n\tif handle == 0 {\n\t\treturn nil\n\t}\n\tif processHandleClosed {\n\t\t// Process already exited and handle closed, nothing to kill\n\t\treturn nil\n\t}\n\n\t// Terminate the process\n\terr := syscall.TerminateProcess(syscall.Handle(handle), 1)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"TerminateProcess failed: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// Windows job object structures and constants\nconst (\n\tJobObjectExtendedLimitInformation  = 9\n\tJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000\n)\n\ntype JOBOBJECT_BASIC_LIMIT_INFORMATION struct {\n\tPerProcessUserTimeLimit int64\n\tPerJobUserTimeLimit     int64\n\tLimitFlags              uint32\n\tMinimumWorkingSetSize   uintptr\n\tMaximumWorkingSetSize   uintptr\n\tActiveProcessLimit      uint32\n\tAffinity                uintptr\n\tPriorityClass           uint32\n\tSchedulingClass         uint32\n}\n\ntype IO_COUNTERS struct {\n\tReadOperationCount  uint64\n\tWriteOperationCount uint64\n\tOtherOperationCount uint64\n\tReadTransferCount   uint64\n\tWriteTransferCount  uint64\n\tOtherTransferCount  uint64\n}\n\ntype JOBOBJECT_EXTENDED_LIMIT_INFORMATION struct {\n\tBasicLimitInformation JOBOBJECT_BASIC_LIMIT_INFORMATION\n\tIoInfo                IO_COUNTERS\n\tProcessMemoryLimit    uintptr\n\tJobMemoryLimit        uintptr\n\tPeakProcessMemoryUsed uintptr\n\tPeakJobMemoryUsed     uintptr\n}\n\n// createJobObject creates a job object and assigns the process to it\n// The job object is configured with KILL_ON_JOB_CLOSE, ensuring all processes\n// in the job are terminated when the job handle is closed.\nfunc createJobObject(processHandle syscall.Handle) (syscall.Handle, error) {\n\t// Create an unnamed job object\n\tjob, err := windows.CreateJobObject(nil, nil)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"CreateJobObject failed: %w\", err)\n\t}\n\n\t// Configure the job to kill all processes when the job handle is closed\n\tvar info JOBOBJECT_EXTENDED_LIMIT_INFORMATION\n\tinfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE\n\n\t// SetInformationJobObject\n\tret, _, err := procSetInformationJobObject.Call(\n\t\tuintptr(job),\n\t\tuintptr(JobObjectExtendedLimitInformation),\n\t\tuintptr(unsafe.Pointer(&info)),\n\t\tuintptr(unsafe.Sizeof(info)),\n\t)\n\tif ret == 0 {\n\t\twindows.CloseHandle(job)\n\t\tif err != nil {\n\t\t\treturn 0, fmt.Errorf(\"SetInformationJobObject failed: %w\", err)\n\t\t}\n\t\treturn 0, fmt.Errorf(\"SetInformationJobObject failed\")\n\t}\n\n\t// Assign the process to the job object\n\t// Convert syscall.Handle to windows.Handle\n\terr = windows.AssignProcessToJobObject(job, windows.Handle(processHandle))\n\tif err != nil {\n\t\twindows.CloseHandle(job)\n\t\treturn 0, fmt.Errorf(\"AssignProcessToJobObject failed: %w\", err)\n\t}\n\n\t// Convert windows.Handle back to syscall.Handle for return\n\treturn syscall.Handle(job), nil\n}\n"
  },
  {
    "path": "host/internal/reversetunnel.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/url\"\n\t\"os/user\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/server\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/ws\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"google.golang.org/protobuf/proto\"\n)\n\nconst (\n\tpublickeyAuthError = \"ssh: unable to authenticate, attempted methods [none]\"\n)\n\ntype ReverseTunnel struct {\n\t*ssh.Client\n\n\tHost              *url.URL\n\tSigners           []ssh.Signer\n\tAuthorizedKeys    []ssh.PublicKey\n\tKeepAliveDuration time.Duration\n\tHostKeyCallback   ssh.HostKeyCallback\n\tLogger            *slog.Logger\n\n\tln net.Listener\n}\n\nfunc (c *ReverseTunnel) Close() {\n\t_ = c.ln.Close()\n\t_ = c.Client.Close()\n}\n\nfunc (c *ReverseTunnel) Listener() net.Listener {\n\treturn c.ln\n}\n\nfunc (c *ReverseTunnel) Establish(ctx context.Context) (*server.CreateSessionResponse, error) {\n\tuser, err := user.Current()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbaseLogger := c.Logger\n\tif baseLogger == nil {\n\t\tbaseLogger = slog.Default()\n\t}\n\n\tvar (\n\t\tauths          []ssh.AuthMethod\n\t\tpublicKeys     [][]byte\n\t\tauthorizedKeys [][]byte\n\t)\n\tfor _, signer := range c.Signers {\n\t\tauths = append(auths, ssh.PublicKeys(signer))\n\t\tpublicKeys = append(publicKeys, ssh.MarshalAuthorizedKey(signer.PublicKey()))\n\t}\n\tfor _, ak := range c.AuthorizedKeys {\n\t\tauthorizedKeys = append(authorizedKeys, ssh.MarshalAuthorizedKey(ak))\n\t}\n\n\tconfig := &ssh.ClientConfig{\n\t\tUser:          user.Username,\n\t\tAuth:          auths,\n\t\tClientVersion: upterm.HostSSHClientVersion,\n\t\t// Enforce a restricted set of algorithms for security\n\t\t// TODO: make this configurable if necessary\n\t\tHostKeyAlgorithms: []string{\n\t\t\tssh.CertAlgoED25519v01,\n\t\t\tssh.CertAlgoRSASHA512v01,\n\t\t\tssh.CertAlgoRSASHA256v01,\n\t\t\tssh.KeyAlgoED25519,\n\t\t\tssh.KeyAlgoRSASHA512,\n\t\t\tssh.KeyAlgoRSASHA256,\n\t\t},\n\t\tHostKeyCallback: c.HostKeyCallback,\n\t}\n\n\tif isWSScheme(c.Host.Scheme) {\n\t\tu, _ := url.Parse(c.Host.String()) // clone\n\t\tu.User = url.UserPassword(user.Username, \"\")\n\t\tc.Client, err = ws.NewSSHClient(u, config, false)\n\t} else {\n\t\tc.Client, err = ssh.Dial(\"tcp\", c.Host.Host, config)\n\t}\n\n\tif err != nil {\n\t\treturn nil, sshDialError(c.Host.String(), err)\n\t}\n\n\tsessResp, err := c.createSession(user.Username, publicKeys, authorizedKeys)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating session: %w\", err)\n\t}\n\n\tc.ln, err = c.Listen(\"unix\", sessResp.SessionID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create reverse tunnel: %w\", err)\n\t}\n\n\t// make sure connection is alive\n\tgo keepAlive(ctx, c.KeepAliveDuration, func() {\n\t\t// TODO: ping with session ID\n\t\t_, _, err := c.SendRequest(upterm.OpenSSHKeepAliveRequestType, true, nil)\n\t\tif err != nil {\n\t\t\tbaseLogger.Error(\"error pinging server\", \"error\", err)\n\t\t}\n\t})\n\n\treturn sessResp, nil\n}\n\nfunc (c *ReverseTunnel) createSession(user string, hostPublicKeys [][]byte, clientAuthorizedKeys [][]byte) (*server.CreateSessionResponse, error) {\n\treq := &server.CreateSessionRequest{\n\t\tHostUser:             user,\n\t\tHostPublicKeys:       hostPublicKeys,\n\t\tClientAuthorizedKeys: clientAuthorizedKeys,\n\t}\n\tb, err := proto.Marshal(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tok, body, err := c.SendRequest(upterm.ServerCreateSessionRequestType, true, b)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error initializing session: %w\", err)\n\t}\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"could not initialize session: %s\", body)\n\t}\n\n\tvar resp server.CreateSessionResponse\n\tif err := proto.Unmarshal(body, &resp); err != nil {\n\t\treturn nil, fmt.Errorf(\"error unmarshaling created session: %w\", err)\n\t}\n\n\treturn &resp, nil\n}\n\nfunc keepAlive(ctx context.Context, d time.Duration, fn func()) {\n\tticker := time.NewTicker(d)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tfn()\n\t\t}\n\t}\n}\n\nfunc isWSScheme(scheme string) bool {\n\treturn scheme == \"ws\" || scheme == \"wss\"\n}\n\ntype PermissionDeniedError struct {\n\thost string\n\terr  error\n}\n\nfunc (e *PermissionDeniedError) Error() string {\n\treturn fmt.Sprintf(\"%s: Permission denied (publickey).\", e.host)\n}\n\nfunc (e *PermissionDeniedError) Unwrap() error { return e.err }\n\nfunc sshDialError(host string, err error) error {\n\tif strings.Contains(err.Error(), publickeyAuthError) {\n\t\treturn &PermissionDeniedError{\n\t\t\thost: host,\n\t\t\terr:  err,\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"ssh dial error: %w\", err)\n}\n"
  },
  {
    "path": "host/internal/server.go",
    "content": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net\"\n\t\"os\"\n\t\"os/exec\"\n\t\"time\"\n\n\tgssh \"github.com/charmbracelet/ssh\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/host/sftp\"\n\t\"github.com/owenthereal/upterm/server\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/utils\"\n\n\t\"github.com/oklog/run\"\n\t\"github.com/olebedev/emitter\"\n\tuio \"github.com/owenthereal/upterm/io\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\ntype Server struct {\n\tCommand                 []string\n\tCommandEnv              []string\n\tForceCommand            []string\n\tSigners                 []ssh.Signer\n\tAuthorizedKeys          []ssh.PublicKey\n\tEventEmitter            *emitter.Emitter\n\tKeepAliveDuration       time.Duration\n\tStdin                   *os.File\n\tStdout                  *os.File\n\tLogger                  *slog.Logger\n\tReadOnly                bool\n\tAllowLocalTCPForwarding bool\n\t// ForceForwardingInputForTesting forces stdin forwarding even when stdin is not a TTY.\n\t// This is used in tests where stdin is a pipe but we still want to forward test data.\n\tForceForwardingInputForTesting bool\n\n\t// SFTP configuration\n\tSFTPDisabled          bool                   // Disable SFTP subsystem entirely\n\tSFTPPermissionChecker sftp.PermissionChecker // Optional: prompts user for SFTP permissions (nil = auto-allow)\n}\n\nfunc (s *Server) ServeWithContext(ctx context.Context, l net.Listener) error {\n\twriters := uio.NewMultiWriter(5)\n\n\tcmdCtx, cmdCancel := context.WithCancel(ctx)\n\tdefer cmdCancel()\n\tcmd := newCommand(\n\t\ts.Command[0],\n\t\ts.Command[1:],\n\t\ts.CommandEnv,\n\t\ts.Stdin,\n\t\ts.Stdout,\n\t\ts.EventEmitter,\n\t\twriters,\n\t\ts.ForceForwardingInputForTesting,\n\t)\n\tptmx, err := cmd.Start(cmdCtx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error starting command: %w\", err)\n\t}\n\n\tvar g run.Group\n\t{\n\t\tctx, cancel := context.WithCancel(ctx)\n\t\tteh := terminalEventHandler{\n\t\t\teventEmitter: s.EventEmitter,\n\t\t\tlogger:       s.Logger,\n\t\t}\n\t\tg.Add(func() error {\n\t\t\treturn teh.Handle(ctx)\n\t\t}, func(err error) {\n\t\t\tcancel()\n\t\t})\n\t}\n\t{\n\t\tg.Add(func() error {\n\t\t\treturn cmd.Run()\n\t\t}, func(err error) {\n\t\t\tcmdCancel()\n\t\t})\n\t}\n\t{\n\t\tctx, cancel := context.WithCancel(ctx)\n\t\tsh := sessionHandler{\n\t\t\tforceCommand:          s.ForceCommand,\n\t\t\tptmx:                  ptmx,\n\t\t\teventEmmiter:          s.EventEmitter,\n\t\t\twriters:               writers,\n\t\t\tkeepAliveDuration:     s.KeepAliveDuration,\n\t\t\tctx:                   ctx,\n\t\t\tlogger:                s.Logger,\n\t\t\treadonly:              s.ReadOnly,\n\t\t\tsftpPermissionChecker: s.SFTPPermissionChecker,\n\t\t}\n\t\tph := publicKeyHandler{\n\t\t\tAuthorizedKeys: s.AuthorizedKeys,\n\t\t\tEventEmmiter:   s.EventEmitter,\n\t\t\tLogger:         s.Logger,\n\t\t}\n\n\t\tvar ss []gssh.Signer\n\t\tfor _, signer := range s.Signers {\n\t\t\tss = append(ss, signer)\n\t\t}\n\n\t\t// Set up subsystem handlers (SFTP)\n\t\tvar subsystemHandlers map[string]gssh.SubsystemHandler\n\t\tif !s.SFTPDisabled {\n\t\t\tsubsystemHandlers = map[string]gssh.SubsystemHandler{\n\t\t\t\t\"sftp\": sh.HandleSFTP,\n\t\t\t}\n\t\t}\n\n\t\tserver := gssh.Server{\n\t\t\tHostSigners:      ss,\n\t\t\tHandler:          sh.HandleSession,\n\t\t\tVersion:          upterm.HostSSHServerVersion,\n\t\t\tPublicKeyHandler: ph.HandlePublicKey,\n\t\t\tLocalPortForwardingCallback: func(ctx gssh.Context, destinationHost string, destinationPort uint32) bool {\n\t\t\t\tlogArgs := []any{\n\t\t\t\t\t\"destination-host\", destinationHost,\n\t\t\t\t\t\"destination-port\", destinationPort,\n\t\t\t\t\t\"remote-addr\", ctx.RemoteAddr().String(),\n\t\t\t\t\t\"user\", ctx.User(),\n\t\t\t\t\t\"session-id\", ctx.SessionID(),\n\t\t\t\t}\n\t\t\t\tif !s.AllowLocalTCPForwarding {\n\t\t\t\t\ts.Logger.Warn(\"rejecting local port forwarding\", logArgs...)\n\t\t\t\t\treturn false\n\t\t\t\t}\n\n\t\t\t\ts.Logger.Info(\"allowing local port forwarding\", logArgs...)\n\t\t\t\treturn true\n\t\t\t},\n\t\t\tChannelHandlers: map[string]gssh.ChannelHandler{\n\t\t\t\t\"session\":      gssh.DefaultSessionHandler,\n\t\t\t\t\"direct-tcpip\": gssh.DirectTCPIPHandler,\n\t\t\t},\n\t\t\tSubsystemHandlers: subsystemHandlers,\n\t\t\tConnectionFailedCallback: func(conn net.Conn, err error) {\n\t\t\t\ts.Logger.Error(\"connection failed\", \"error\", err)\n\t\t\t},\n\t\t}\n\t\tg.Add(func() error {\n\t\t\treturn server.Serve(l)\n\t\t}, func(err error) {\n\t\t\t// kill ssh sessionHandler\n\t\t\tcancel()\n\t\t\t// shut down ssh server\n\t\t\t_ = server.Shutdown(ctx)\n\t\t})\n\t}\n\n\treturn g.Run()\n}\n\ntype publicKeyHandler struct {\n\tAuthorizedKeys []ssh.PublicKey\n\tEventEmmiter   *emitter.Emitter\n\tLogger         *slog.Logger\n}\n\nfunc (h *publicKeyHandler) HandlePublicKey(ctx gssh.Context, key gssh.PublicKey) bool {\n\tchecker := server.UserCertChecker{}\n\tauth, pk, err := checker.Authenticate(ctx.User(), key)\n\tif err != nil {\n\t\th.Logger.Error(\"error parsing auth request from cert\", \"error\", err)\n\t\treturn false\n\t}\n\n\t// TODO: sshproxy already rejects unauthorized keys\n\t// Does host still need to check them?\n\tif len(h.AuthorizedKeys) == 0 {\n\t\temitClientJoinEvent(h.EventEmmiter, ctx.SessionID(), auth, pk)\n\t\treturn true\n\t}\n\n\tfor _, k := range h.AuthorizedKeys {\n\t\tif utils.KeysEqual(k, pk) {\n\t\t\temitClientJoinEvent(h.EventEmmiter, ctx.SessionID(), auth, pk)\n\t\t\treturn true\n\t\t}\n\t}\n\n\th.Logger.Info(\"unauthorized public key\")\n\treturn false\n}\n\ntype sessionHandler struct {\n\tforceCommand      []string\n\tptmx              PTY\n\teventEmmiter      *emitter.Emitter\n\twriters           *uio.MultiWriter\n\tkeepAliveDuration time.Duration\n\tctx               context.Context\n\tlogger            *slog.Logger\n\treadonly          bool\n\n\t// SFTP configuration\n\tsftpPermissionChecker sftp.PermissionChecker // Optional: prompts user for SFTP permissions\n}\n\nfunc (h *sessionHandler) HandleSession(sess gssh.Session) {\n\tsessionID := sess.Context().Value(gssh.ContextKeySessionID).(string)\n\tdefer emitClientLeftEvent(h.eventEmmiter, sessionID)\n\n\tptyReq, winCh, isPty := sess.Pty()\n\tif !isPty {\n\t\t_, _ = io.WriteString(sess, \"PTY is required.\\n\")\n\t\t_ = sess.Exit(1)\n\t}\n\n\tvar (\n\t\tg    run.Group\n\t\terr  error\n\t\tptmx = h.ptmx\n\t)\n\n\t// simulate openssh keepalive\n\t{\n\t\tctx, cancel := context.WithCancel(h.ctx)\n\t\tg.Add(func() error {\n\t\t\tticker := time.NewTicker(h.keepAliveDuration)\n\t\t\tdefer ticker.Stop()\n\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase <-ticker.C:\n\t\t\t\t\tif _, err := sess.SendRequest(upterm.OpenSSHKeepAliveRequestType, true, nil); err != nil {\n\t\t\t\t\t\th.logger.Debug(\"error pinging client to keepalive\", \"error\", err)\n\t\t\t\t\t}\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn ctx.Err()\n\t\t\t\t}\n\t\t\t}\n\t\t}, func(err error) {\n\t\t\tcancel()\n\t\t})\n\t}\n\n\tif len(h.forceCommand) > 0 {\n\t\tctx, cancel := context.WithCancel(h.ctx)\n\t\tdefer cancel()\n\n\t\tptmx, err = startAttachCmd(ctx, h.forceCommand, ptyReq.Term)\n\t\tif err != nil {\n\t\t\th.logger.Error(\"error starting force command\", \"error\", err)\n\t\t\t_ = sess.Exit(1)\n\t\t\treturn\n\t\t}\n\n\t\t{\n\t\t\t// reattach output\n\t\t\tg.Add(func() error {\n\t\t\t\t_, err := io.Copy(sess, uio.NewContextReader(ctx, ptmx))\n\t\t\t\treturn ptyError(err)\n\t\t\t}, func(err error) {\n\t\t\t\tcancel()\n\t\t\t\t_ = ptmx.Close()\n\t\t\t})\n\t\t}\n\t\t{\n\t\t\tg.Add(func() error {\n\t\t\t\treturn ptmx.Wait()\n\t\t\t}, func(err error) {\n\t\t\t\tcancel()\n\t\t\t\t_ = ptmx.Close()\n\t\t\t})\n\t\t}\n\t} else {\n\t\t// output\n\t\t// Wrap SSH session with TerminalQueryFilter to filter out terminal query\n\t\t// sequences (like OSC 10/11 color queries, CSI 6n cursor position) before\n\t\t// they reach the client. This prevents client terminals from responding\n\t\t// to queries meant for the host terminal.\n\t\tfilteredOutput := uio.NewTerminalQueryFilter(sess)\n\t\tif err := h.writers.Append(filteredOutput); err != nil {\n\t\t\t_ = sess.Exit(1)\n\t\t\treturn\n\t\t}\n\n\t\tdefer h.writers.Remove(filteredOutput)\n\t}\n\n\t{\n\t\t// pty\n\t\tctx, cancel := context.WithCancel(h.ctx)\n\t\ttee := terminalEventEmitter{h.eventEmmiter}\n\t\tg.Add(func() error {\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase win := <-winCh:\n\t\t\t\t\ttee.TerminalWindowChanged(sessionID, ptmx, win.Width, win.Height)\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn ctx.Err()\n\t\t\t\t}\n\t\t\t}\n\t\t}, func(err error) {\n\t\t\ttee.TerminalDetached(sessionID, ptmx)\n\t\t\tcancel()\n\t\t})\n\t}\n\n\t// if a readonly session has been requested, don't connect stdin\n\tif h.readonly {\n\t\t// write to client to notify them that they have connected to a read-only session\n\t\t_, _ = io.WriteString(sess, \"\\r\\n=== Attached to read-only session ===\\r\\n\\r\\n\")\n\t} else {\n\t\t// input\n\t\tctx, cancel := context.WithCancel(h.ctx)\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(ptmx, uio.NewContextReader(ctx, sess))\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\tcancel()\n\t\t})\n\t}\n\n\tif err := g.Run(); err != nil {\n\t\tif exitError, ok := err.(*exec.ExitError); ok {\n\t\t\t_ = sess.Exit(exitError.ExitCode())\n\t\t} else {\n\t\t\t_ = sess.Exit(1)\n\t\t}\n\t} else {\n\t\t_ = sess.Exit(0)\n\t}\n}\n\nfunc emitClientJoinEvent(eventEmmiter *emitter.Emitter, sessionID string, auth *server.AuthRequest, pk ssh.PublicKey) {\n\tc := &api.Client{\n\t\tId:                   sessionID,\n\t\tVersion:              auth.ClientVersion,\n\t\tAddr:                 auth.RemoteAddr,\n\t\tPublicKeyFingerprint: utils.FingerprintSHA256(pk),\n\t}\n\teventEmmiter.Emit(upterm.EventClientJoined, c)\n}\n\nfunc emitClientLeftEvent(eventEmmiter *emitter.Emitter, sessionID string) {\n\teventEmmiter.Emit(upterm.EventClientLeft, sessionID)\n}\n\nfunc startAttachCmd(ctx context.Context, c []string, term string) (PTY, error) {\n\tcmd := setupCommand(ctx, c[0], c[1:])\n\tcmd.Env = append(os.Environ(), fmt.Sprintf(\"TERM=%s\", term))\n\t// Pass nil for stdin since this is a remote attach - size will come from SSH client\n\tpty, err := startPty(cmd, nil)\n\n\treturn pty, err\n}\n"
  },
  {
    "path": "host/internal/sftp.go",
    "content": "package internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\tgssh \"github.com/charmbracelet/ssh\"\n\thostsftp \"github.com/owenthereal/upterm/host/sftp\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/pkg/sftp\"\n)\n\n// SFTPSession tracks permission state for a single SFTP session\ntype SFTPSession struct {\n\treadOnly          bool                      // Only allow downloads (no upload/delete)\n\tpermissionChecker hostsftp.PermissionChecker // Optional: prompts user for permission (nil = auto-allow)\n\tclientInfo        hostsftp.ClientInfo        // Client information for permission dialogs\n}\n\n// HandleSFTP handles SFTP subsystem requests\nfunc (h *sessionHandler) HandleSFTP(sess gssh.Session) {\n\tsessionID := sess.Context().Value(gssh.ContextKeySessionID).(string)\n\tdefer emitClientLeftEvent(h.eventEmmiter, sessionID)\n\n\t// Clean up permission cache when session ends\n\tif h.sftpPermissionChecker != nil {\n\t\tdefer h.sftpPermissionChecker.ClearSession(sessionID)\n\t}\n\n\t// Get client info for permission dialogs\n\tclientInfo := hostsftp.ClientInfo{\n\t\tSessionID: sessionID,\n\t}\n\tif pk := sess.PublicKey(); pk != nil {\n\t\tclientInfo.Fingerprint = utils.FingerprintSHA256(pk)\n\t}\n\n\th.logger.Info(\"SFTP session started\", \"readonly\", h.readonly, \"client\", clientInfo.Fingerprint)\n\n\t// Create permission-checking handlers\n\tsftpSession := &SFTPSession{\n\t\treadOnly:          h.readonly,\n\t\tpermissionChecker: h.sftpPermissionChecker,\n\t\tclientInfo:        clientInfo,\n\t}\n\n\thandlers := sftp.Handlers{\n\t\tFileGet:  &sftpFileReader{session: sftpSession, logger: h.logger},\n\t\tFilePut:  &sftpFileWriter{session: sftpSession, logger: h.logger},\n\t\tFileCmd:  &sftpFileCmder{session: sftpSession, logger: h.logger},\n\t\tFileList: &sftpFileLister{session: sftpSession, logger: h.logger},\n\t}\n\n\t// Get user's home directory for SFTP start directory\n\tuserHome, err := os.UserHomeDir()\n\tif err != nil {\n\t\th.logger.Error(\"failed to get user home directory\", \"error\", err)\n\t\t_ = sess.Exit(1)\n\t\treturn\n\t}\n\n\t// Set start directory to user's home for relative path resolution\n\tserver := sftp.NewRequestServer(sess, handlers, sftp.WithStartDirectory(userHome))\n\tif err := server.Serve(); err != nil {\n\t\tif err != io.EOF {\n\t\t\th.logger.Error(\"sftp server error\", \"error\", err)\n\t\t}\n\t}\n\t_ = server.Close()\n\n\th.logger.Info(\"SFTP session ended\")\n}\n\n// checkPermission prompts user for permission if needed.\n// For two-path operations (rename, symlink, link), pass both source and target paths.\nfunc (s *SFTPSession) checkPermission(op hostsftp.Operation, paths ...string) error {\n\t// No checker = auto-allow (headless/CI mode)\n\tif s.permissionChecker == nil {\n\t\treturn nil\n\t}\n\n\t// Check permission (the checker handles caching of \"Allow All\" decisions)\n\tresult, err := s.permissionChecker.CheckPermission(op, s.clientInfo, paths...)\n\tif err != nil {\n\t\t// Checker unavailable (headless system)\n\t\t// Allow operation - connection-level consent is sufficient\n\t\treturn nil\n\t}\n\n\tif result == hostsftp.PermissionDenied {\n\t\treturn fmt.Errorf(\"permission denied by user\")\n\t}\n\treturn nil\n}\n\n// resolvePath resolves a path following standard OpenSSH/SCP semantics.\n// - Tilde paths (~ or ~/path) are expanded to home directory\n// - Absolute paths (starting with /) are used as-is\n// - Relative paths are resolved from the user's home directory\n//\n// Note: WithStartDirectory(home) is set on the SFTP server, which handles\n// relative path resolution at the protocol level.\nfunc (s *SFTPSession) resolvePath(reqPath string) (string, error) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Handle tilde expansion (OpenSSH may send literal ~ or ~/path)\n\tif reqPath == \"~\" {\n\t\treturn home, nil\n\t}\n\tif strings.HasPrefix(reqPath, \"~/\") {\n\t\treturn filepath.Join(home, reqPath[2:]), nil\n\t}\n\n\t// SFTP sends Windows paths like \"/C:/foo\" - strip leading \"/\" for proper handling\n\tif len(reqPath) >= 3 && reqPath[0] == '/' && reqPath[2] == ':' {\n\t\treqPath = reqPath[1:]\n\t}\n\n\treturn filepath.Clean(reqPath), nil\n}\n\n// sftpFileReader handles file download requests\ntype sftpFileReader struct {\n\tsession *SFTPSession\n\tlogger  *slog.Logger\n}\n\nfunc (h *sftpFileReader) Fileread(r *sftp.Request) (io.ReaderAt, error) {\n\tpath, err := h.session.resolvePath(r.Filepath)\n\tif err != nil {\n\t\treturn nil, sftp.ErrSSHFxPermissionDenied\n\t}\n\n\t// Check permission (shows zenity dialog if needed)\n\tif err := h.session.checkPermission(hostsftp.OpDownload, path); err != nil {\n\t\th.logger.Info(\"SFTP download denied\", \"path\", path)\n\t\treturn nil, sftp.ErrSSHFxPermissionDenied\n\t}\n\n\th.logger.Info(\"SFTP download\", \"path\", path)\n\treturn os.Open(path)\n}\n\n// sftpFileWriter handles file upload requests\ntype sftpFileWriter struct {\n\tsession *SFTPSession\n\tlogger  *slog.Logger\n}\n\nfunc (h *sftpFileWriter) Filewrite(r *sftp.Request) (io.WriterAt, error) {\n\t// Deny uploads in read-only mode\n\tif h.session.readOnly {\n\t\th.logger.Info(\"SFTP upload denied (read-only mode)\", \"path\", r.Filepath)\n\t\treturn nil, sftp.ErrSSHFxPermissionDenied\n\t}\n\n\tlogger := h.logger.With(\"original_path\", r.Filepath)\n\tlogger.Debug(\"SFTP upload request\", \"original_path\", r.Filepath)\n\n\tpath, err := h.session.resolvePath(r.Filepath)\n\tif err != nil {\n\t\treturn nil, sftp.ErrSSHFxPermissionDenied\n\t}\n\n\tlogger = logger.With(\"resolved_path\", path)\n\n\tif err := h.session.checkPermission(hostsftp.OpUpload, path); err != nil {\n\t\tlogger.Info(\"SFTP upload denied\")\n\t\treturn nil, sftp.ErrSSHFxPermissionDenied\n\t}\n\n\tlogger.Info(\"SFTP upload\")\n\n\t// Get file flags from request\n\tpflags := r.Pflags()\n\tosFlags := os.O_WRONLY\n\n\tif pflags.Creat {\n\t\tosFlags |= os.O_CREATE\n\t}\n\tif pflags.Trunc {\n\t\tosFlags |= os.O_TRUNC\n\t}\n\tif pflags.Append {\n\t\tosFlags |= os.O_APPEND\n\t}\n\tif pflags.Excl {\n\t\tosFlags |= os.O_EXCL\n\t}\n\n\treturn os.OpenFile(path, osFlags, 0644)\n}\n\n// sftpFileCmder handles mkdir, remove, rename, etc.\ntype sftpFileCmder struct {\n\tsession *SFTPSession\n\tlogger  *slog.Logger\n}\n\nfunc (h *sftpFileCmder) Filecmd(r *sftp.Request) error {\n\t// Deny all modifications in read-only mode\n\tif h.session.readOnly {\n\t\th.logger.Info(\"SFTP command denied (read-only mode)\", \"method\", r.Method, \"path\", r.Filepath)\n\t\treturn sftp.ErrSSHFxPermissionDenied\n\t}\n\n\tpath, err := h.session.resolvePath(r.Filepath)\n\tif err != nil {\n\t\treturn sftp.ErrSSHFxPermissionDenied\n\t}\n\n\t// Resolve target path upfront for two-path operations\n\tvar targetPath string\n\tisTwoPathOp := r.Method == \"Rename\" || r.Method == \"Symlink\" || r.Method == \"Link\"\n\tif isTwoPathOp {\n\t\ttargetPath, err = h.session.resolvePath(r.Target)\n\t\tif err != nil {\n\t\t\treturn sftp.ErrSSHFxPermissionDenied\n\t\t}\n\t}\n\n\t// Map request method to operation\n\tvar op hostsftp.Operation\n\tswitch r.Method {\n\tcase \"Remove\":\n\t\top = hostsftp.OpDelete\n\tcase \"Mkdir\":\n\t\top = hostsftp.OpMkdir\n\tcase \"Rename\":\n\t\top = hostsftp.OpRename\n\tcase \"Rmdir\":\n\t\top = hostsftp.OpRmdir\n\tcase \"Symlink\":\n\t\top = hostsftp.OpSymlink\n\tcase \"Link\":\n\t\top = hostsftp.OpLink\n\tcase \"Setstat\":\n\t\top = hostsftp.OpSetstat\n\tdefault:\n\t\th.logger.Info(\"SFTP unsupported command\", \"method\", r.Method, \"path\", path)\n\t\treturn sftp.ErrSSHFxOpUnsupported\n\t}\n\n\t// Check permission - pass both paths for two-path operations\n\tif isTwoPathOp {\n\t\terr = h.session.checkPermission(op, path, targetPath)\n\t} else {\n\t\terr = h.session.checkPermission(op, path)\n\t}\n\tif err != nil {\n\t\th.logger.Info(\"SFTP command denied\", \"method\", r.Method, \"path\", path)\n\t\treturn sftp.ErrSSHFxPermissionDenied\n\t}\n\n\th.logger.Info(\"SFTP command\", \"method\", r.Method, \"path\", path)\n\n\t// Execute the operation\n\tswitch r.Method {\n\tcase \"Remove\":\n\t\treturn os.Remove(path)\n\tcase \"Mkdir\":\n\t\treturn os.Mkdir(path, 0755)\n\tcase \"Rmdir\":\n\t\treturn os.Remove(path)\n\tcase \"Rename\":\n\t\treturn os.Rename(path, targetPath)\n\tcase \"Symlink\":\n\t\treturn os.Symlink(targetPath, path)\n\tcase \"Link\":\n\t\treturn os.Link(targetPath, path)\n\tcase \"Setstat\":\n\t\t// Handle file attribute changes\n\t\tattrs := r.Attributes()\n\t\tflags := r.AttrFlags()\n\n\t\t// Handle size change (truncate) - use flags to detect if size was set (allows truncate to 0)\n\t\tif flags.Size {\n\t\t\tif err := os.Truncate(path, int64(attrs.Size)); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Handle ownership change\n\t\tif flags.UidGid {\n\t\t\tif err := os.Chown(path, int(attrs.UID), int(attrs.GID)); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Handle permission change\n\t\tif flags.Permissions {\n\t\t\tif err := os.Chmod(path, attrs.FileMode()); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Handle timestamp change\n\t\tif flags.Acmodtime {\n\t\t\tatime := attrs.AccessTime()\n\t\t\tmtime := attrs.ModTime()\n\t\t\tif err := os.Chtimes(path, atime, mtime); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\treturn sftp.ErrSSHFxOpUnsupported\n}\n\n// sftpFileLister handles directory listing and stat\ntype sftpFileLister struct {\n\tsession *SFTPSession\n\tlogger  *slog.Logger\n}\n\nfunc (h *sftpFileLister) Filelist(r *sftp.Request) (sftp.ListerAt, error) {\n\tpath, err := h.session.resolvePath(r.Filepath)\n\tif err != nil {\n\t\treturn nil, sftp.ErrSSHFxPermissionDenied\n\t}\n\n\t// No permission prompt for listing - just path validation\n\t// (Listing directories doesn't expose file contents)\n\n\tswitch r.Method {\n\tcase \"List\":\n\t\tentries, err := os.ReadDir(path)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// Convert DirEntry to FileInfo\n\t\tvar infos []fs.FileInfo\n\t\tfor _, entry := range entries {\n\t\t\tinfo, err := entry.Info()\n\t\t\tif err != nil {\n\t\t\t\tcontinue // Skip entries we can't stat\n\t\t\t}\n\t\t\tinfos = append(infos, info)\n\t\t}\n\t\treturn listerat(infos), nil\n\n\tcase \"Stat\":\n\t\tinfo, err := os.Stat(path)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn listerat{info}, nil\n\n\tcase \"Lstat\":\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn listerat{info}, nil\n\n\tcase \"Readlink\":\n\t\ttarget, err := os.Readlink(path)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// Return a fake FileInfo with the link target as the name\n\t\treturn listerat{linkInfo{name: target}}, nil\n\t}\n\n\treturn nil, sftp.ErrSSHFxOpUnsupported\n}\n\n// listerat implements sftp.ListerAt\ntype listerat []fs.FileInfo\n\nfunc (l listerat) ListAt(ls []fs.FileInfo, offset int64) (int, error) {\n\tif offset >= int64(len(l)) {\n\t\treturn 0, io.EOF\n\t}\n\n\tn := copy(ls, l[offset:])\n\tif n < len(ls) {\n\t\treturn n, io.EOF\n\t}\n\treturn n, nil\n}\n\n// linkInfo is a minimal FileInfo for Readlink responses\ntype linkInfo struct {\n\tname string\n}\n\nfunc (l linkInfo) Name() string       { return l.name }\nfunc (l linkInfo) Size() int64        { return 0 }\nfunc (l linkInfo) Mode() fs.FileMode  { return 0 }\nfunc (l linkInfo) ModTime() time.Time { return time.Time{} }\nfunc (l linkInfo) IsDir() bool        { return false }\nfunc (l linkInfo) Sys() any           { return nil }\n"
  },
  {
    "path": "host/internal/sftp_test.go",
    "content": "package internal\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestSFTPSession_resolvePath(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err)\n\n\tsession := &SFTPSession{\n\t\treadOnly: false,\n\t}\n\n\t// Test cases for path resolution:\n\t// - Tilde paths (~ or ~/path) are expanded to home directory\n\t// - Absolute paths (starting with /) are used as-is\n\t// - Relative paths are passed through (WithStartDirectory handles them at protocol level)\n\ttests := []struct {\n\t\tname     string\n\t\treqPath  string\n\t\twantPath string\n\t}{\n\t\t// Absolute paths - used as-is\n\t\t{\n\t\t\tname:     \"filesystem root\",\n\t\t\treqPath:  \"/\",\n\t\t\twantPath: \"/\",\n\t\t},\n\t\t{\n\t\t\tname:     \"absolute path\",\n\t\t\treqPath:  \"/tmp/file.txt\",\n\t\t\twantPath: \"/tmp/file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"absolute nested path\",\n\t\t\treqPath:  \"/var/log/syslog\",\n\t\t\twantPath: \"/var/log/syslog\",\n\t\t},\n\t\t{\n\t\t\tname:     \"absolute path with double dots\",\n\t\t\treqPath:  \"/tmp/../etc/passwd\",\n\t\t\twantPath: \"/etc/passwd\",\n\t\t},\n\t\t// Tilde expansion (OpenSSH may send literal ~)\n\t\t{\n\t\t\tname:     \"tilde only expands to home\",\n\t\t\treqPath:  \"~\",\n\t\t\twantPath: home,\n\t\t},\n\t\t{\n\t\t\tname:     \"tilde with path expands to home subdir\",\n\t\t\treqPath:  \"~/Documents\",\n\t\t\twantPath: filepath.Join(home, \"Documents\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"tilde with nested path\",\n\t\t\treqPath:  \"~/Documents/projects/file.txt\",\n\t\t\twantPath: filepath.Join(home, \"Documents/projects/file.txt\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"tilde with trailing slash\",\n\t\t\treqPath:  \"~/upterm/\",\n\t\t\twantPath: filepath.Join(home, \"upterm\"),\n\t\t},\n\t\t// Relative paths - passed through as-is (library handles with WithStartDirectory)\n\t\t{\n\t\t\tname:     \"relative file passed through\",\n\t\t\treqPath:  \"file.txt\",\n\t\t\twantPath: \"file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"relative directory\",\n\t\t\treqPath:  \"Downloads\",\n\t\t\twantPath: \"Downloads\",\n\t\t},\n\t\t{\n\t\t\tname:     \"relative nested path passed through\",\n\t\t\treqPath:  \"Documents/file.txt\",\n\t\t\twantPath: \"Documents/file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"dot passed through\",\n\t\t\treqPath:  \".\",\n\t\t\twantPath: \".\",\n\t\t},\n\t\t{\n\t\t\tname:     \"dot-slash prefix cleaned\",\n\t\t\treqPath:  \"./Downloads\",\n\t\t\twantPath: \"Downloads\",\n\t\t},\n\t\t{\n\t\t\tname:     \"dot-slash nested cleaned\",\n\t\t\treqPath:  \"./Documents/file.txt\",\n\t\t\twantPath: \"Documents/file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"parent directory relative\",\n\t\t\treqPath:  \"../Downloads\",\n\t\t\twantPath: \"../Downloads\",\n\t\t},\n\t\t{\n\t\t\tname:     \"parent then child\",\n\t\t\treqPath:  \"../other/file.txt\",\n\t\t\twantPath: \"../other/file.txt\",\n\t\t},\n\t\t// Windows SFTP paths - leading \"/\" stripped for drive letter paths\n\t\t{\n\t\t\tname:     \"windows drive path from SFTP\",\n\t\t\treqPath:  \"/C:/Users/foo\",\n\t\t\twantPath: \"C:/Users/foo\",\n\t\t},\n\t\t{\n\t\t\tname:     \"windows drive path lowercase\",\n\t\t\treqPath:  \"/c:/temp/file.txt\",\n\t\t\twantPath: \"c:/temp/file.txt\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotPath, err := session.resolvePath(tt.reqPath)\n\t\t\trequire.NoError(t, err)\n\t\t\t// Use filepath.FromSlash to convert expected path to native separators\n\t\t\t// since filepath.Clean in resolvePath produces native paths\n\t\t\tassert.Equal(t, filepath.FromSlash(tt.wantPath), gotPath)\n\t\t})\n\t}\n}\n\nfunc TestListerat(t *testing.T) {\n\ttempDir := t.TempDir()\n\n\t// Create some test files\n\tfiles := []string{\"a.txt\", \"b.txt\", \"c.txt\"}\n\tfor _, f := range files {\n\t\terr := os.WriteFile(filepath.Join(tempDir, f), []byte(\"test\"), 0644)\n\t\trequire.NoError(t, err)\n\t}\n\n\t// Read directory\n\tentries, err := os.ReadDir(tempDir)\n\trequire.NoError(t, err)\n\n\tvar infos []os.FileInfo\n\tfor _, entry := range entries {\n\t\tinfo, err := entry.Info()\n\t\trequire.NoError(t, err)\n\t\tinfos = append(infos, info)\n\t}\n\n\tlister := listerat(infos)\n\n\t// Test ListAt with offset 0\n\tbuf := make([]os.FileInfo, 2)\n\tn, err := lister.ListAt(buf, 0)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 2, n)\n\n\t// Test ListAt with offset at end\n\tn, err = lister.ListAt(buf, int64(len(infos)))\n\tassert.ErrorIs(t, err, io.EOF)\n\tassert.Equal(t, 0, n)\n}\n"
  },
  {
    "path": "host/sftp/permission.go",
    "content": "package sftp\n\n// Operation represents an SFTP operation type\ntype Operation int\n\nconst (\n\tOpDownload Operation = iota\n\tOpUpload\n\tOpDelete\n\tOpMkdir\n\tOpRmdir\n\tOpRename\n\tOpSymlink\n\tOpLink\n\tOpSetstat\n)\n\n// String returns a human-readable name for the operation\nfunc (o Operation) String() string {\n\tswitch o {\n\tcase OpDownload:\n\t\treturn \"download\"\n\tcase OpUpload:\n\t\treturn \"upload\"\n\tcase OpDelete:\n\t\treturn \"delete\"\n\tcase OpMkdir:\n\t\treturn \"mkdir\"\n\tcase OpRmdir:\n\t\treturn \"rmdir\"\n\tcase OpRename:\n\t\treturn \"rename\"\n\tcase OpSymlink:\n\t\treturn \"symlink\"\n\tcase OpLink:\n\t\treturn \"link\"\n\tcase OpSetstat:\n\t\treturn \"setstat\"\n\tdefault:\n\t\treturn \"unknown\"\n\t}\n}\n\n// PermissionResult represents the user's response to a permission dialog\ntype PermissionResult int\n\nconst (\n\tPermissionDenied PermissionResult = iota\n\tPermissionAllowed\n\tPermissionAlwaysAllow\n)\n\n// ClientInfo contains information about the SFTP client\ntype ClientInfo struct {\n\tFingerprint string // SSH public key fingerprint (e.g., \"SHA256:...\")\n\tSessionID   string // SSH session ID (identifies a single scp/sftp command)\n}\n\n// PermissionChecker is called to check if an SFTP operation is allowed.\n// Implementations can show UI dialogs, auto-allow, or deny based on policy.\ntype PermissionChecker interface {\n\t// CheckPermission returns the user's decision for the operation.\n\t// For single-path operations (download, upload, delete, etc.), pass one path.\n\t// For two-path operations (rename, symlink, link), pass source and target paths.\n\t// Returns error if the checker is unavailable (e.g., no display).\n\tCheckPermission(op Operation, client ClientInfo, paths ...string) (PermissionResult, error)\n\n\t// ClearSession removes any cached permissions for the given session.\n\t// Called when an SFTP session ends to prevent memory leaks.\n\tClearSession(sessionID string)\n}\n"
  },
  {
    "path": "host/signer.go",
    "content": "package host\n\nimport (\n\t\"bytes\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"golang.org/x/crypto/ssh/agent\"\n\t\"golang.org/x/term\"\n)\n\nconst (\n\terrCannotDecodeEncryptedPrivateKeys = \"cannot decode encrypted private keys\"\n)\n\ntype errDescryptingPrivateKey struct {\n\tfile string\n}\n\nfunc (e *errDescryptingPrivateKey) Error() string {\n\treturn fmt.Sprintf(\"error decrypting private key %s\", e.file)\n}\n\n// Signers return signers based on the following conditions:\n// If SSH agent is running and has keys, it returns signers from SSH agent, otherwise return signers from private keys;\n// If neither works, it generates a signer on the fly.\nfunc Signers(privateKeys []string) ([]ssh.Signer, func(), error) {\n\tvar (\n\t\tsigners []ssh.Signer\n\t\tcleanup func()\n\t\terr     error\n\t)\n\n\tsigners, cleanup, err = signersFromSSHAgent(os.Getenv(\"SSH_AUTH_SOCK\"))\n\tif len(signers) == 0 || err != nil {\n\t\tsigners, err = SignersFromFiles(privateKeys)\n\t}\n\n\tif err != nil {\n\t\tsigners, err = utils.CreateSigners(nil)\n\t}\n\n\treturn signers, cleanup, err\n}\n\nfunc SignersFromFiles(privateKeys []string) ([]ssh.Signer, error) {\n\tvar signers []ssh.Signer\n\tfor _, file := range privateKeys {\n\t\ts, err := signerFromFile(file, promptForPassphrase)\n\t\tif err == nil {\n\t\t\tsigners = append(signers, s)\n\t\t}\n\t}\n\n\treturn signers, nil\n}\n\nfunc signersFromSSHAgent(socket string) ([]ssh.Signer, func(), error) {\n\tcleanup := func() {}\n\tif socket == \"\" {\n\t\treturn nil, cleanup, fmt.Errorf(\"SSH Agent is not running\")\n\t}\n\n\tconn, err := net.Dial(\"unix\", socket)\n\tif err != nil {\n\t\treturn nil, cleanup, err\n\t}\n\tcleanup = func() { _ = conn.Close() }\n\n\tclient := agent.NewClient(conn)\n\tsigners, err := client.Signers()\n\n\treturn signers, cleanup, err\n}\n\nfunc signerFromFile(file string, promptForPassphrase func(file string) ([]byte, error)) (ssh.Signer, error) {\n\tkey, err := readPrivateKeyFromFile(file, promptForPassphrase)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ssh.NewSignerFromKey(key)\n}\n\nfunc readPrivateKeyFromFile(file string, promptForPassphrase func(file string) ([]byte, error)) (interface{}, error) {\n\tpb, err := os.ReadFile(file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tkey, err := ssh.ParseRawPrivateKey(pb)\n\tif err == nil {\n\t\treturn key, err\n\t}\n\n\tvar e *ssh.PassphraseMissingError\n\tif !errors.As(err, &e) && !strings.Contains(err.Error(), errCannotDecodeEncryptedPrivateKeys) {\n\t\treturn nil, err\n\t}\n\n\t// simulate ssh client to retry 3 times\n\tfor i := 0; i < 3; i++ {\n\t\tpass, err := promptForPassphrase(file)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tkey, err := ssh.ParseRawPrivateKeyWithPassphrase(pb, bytes.TrimSpace(pass))\n\t\tif err == nil {\n\t\t\treturn key, nil\n\t\t}\n\n\t\tif !errors.Is(err, x509.IncorrectPasswordError) {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn nil, &errDescryptingPrivateKey{file}\n}\n\nfunc promptForPassphrase(file string) ([]byte, error) {\n\tdefer fmt.Println(\"\") // clear return\n\n\tfmt.Printf(\"Enter passphrase for key '%s': \", file)\n\n\treturn term.ReadPassword(int(syscall.Stdin))\n}\n"
  },
  {
    "path": "host/signer_test.go",
    "content": "package host\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n)\n\nconst (\n\t// Passphrase is \"1234\"\n\trsaPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBEESOQn3\nhoU95qcZuP7CjjAAAAEAAAAAEAAAIXAAAAB3NzaC1yc2EAAAADAQABAAACAQCt//y3H4he\nRi1+3bO+FsqKyGTw5YQnu6MChEaDJY2SJqFCHGEwBAWGsuaDZPb6P+V16I5u2H+MtKBWDb\nkVK9760DkAFimuQ4XTtIzPhyb+Jc95wvNW6pAYXoetVlZIbzNzMEykE41kOMq19SNS3snv\nE5mYzfd2B6AGw3T2SubfF6G0staxtEYwlsWP/N+YIR4yLz11bxwuuTee/eMldvKZfzQQXI\nuopANU2mnAmoOqsG0G+DKNuXw7F7zd7lxus8zBF3fszur7Sc9APbWab5phJXWzE81ME2Z0\nRro3/l/5mD3YqmS6+jyIqlvqR150Uf/2rKawVgzxZWvQhe1+gGIktavL85dO9VnxA/BASY\nM0caqFY/KwVpAs/mZ18SpjgPaEzm3H5eeTJRFMrwRusyunPTDv8lmAAXsr/ZMhd1gBkDiG\nE/YuTfekv8rMbkZNeB2HuXavcRTuCqY8y44ehe+sRug9nwykPnfRqjIeAm9zh+zknfJGl5\nTONHKnEj5tudpUvTd38ZnOM+CTzMHbSDOwkSLAp9mCzrbWc6OIeVqsqibPmia4SxyP7wxn\nszfH1MOkuCmphiCzk4nicYDhMW6jjXhRKok4yNIU9wqj0MMEKS3RKblAzCu7VFVTDrLSJI\nzs7Ch0RIuGnVTQ5S4O156Kr9hL535k6c6/NUXbylXtuwAAB0BoB2MIcsYKGTdrHayerk4e\nO+5ACB6GxBYT0xEQ9959ioF+RgFaXiGDv9fYrJSjUp11uok0LWoLzGXD0w2/+LCMiOO75R\nDSmiPRdQbfMgm8p+etBF5QHg4tcVnMjYHCqFtPDwyyYRHwHmYi9qx+iZqBooEgyExmuahu\noT/6Z5ntycBo9543oDVQVJOMYDNh/u9AC2Wke7j7LufhKNg/Rd3Gg1BWI9IwVCeU8A4Gqa\nZ9fnvrkTRcKFYF5fakxXKnfdHNQco3zxdXGEnbcY34PR/CD5R0J666zoJEeYp25jJd9Opb\nMBKss07yrZq90KIQEveVC6L7tCJpNmRXHN1iQpkq+WKSlX/lMxTanW7KFcKU6qFhFi2m4V\neEGPlAU6tOpsYthkElBhxeBRzwW4lzCDfZErYbtNFGiT+xGxVIzkQIQdH++Y8waNHZ4mNv\nxX5a0/CxMkipUS0CDfZ8XEwHDkQDko1cdwq13PD+AGZvWIDP20EQ3MqrWqb/ho0QJbWLd7\nW6QlwxVewUxfFlBdnpGFpin0RDXsQUZ2IxNuIzzpcAslILtBhQdfXkMAwomQWhs0qxiyEc\nW9jMyYv20J+oR+wc8xVZEBw7KfM52Zl9J90t0nBwghswiykx4BYzu9PwY52gdJOWkMZq1b\ncEPXBa7Y/nAPObXQp/hWwfhKdi5WjXiXCI//nMshRm1bLRqAmFVex9K6UpEY3Svl/fDBe3\nfR21qPi7zEmbZXh9abrPibF6UGZxl54yzKdSS3J/9BletdagYo+WhcV460Dg2hjy2XrrpM\ntLcmUQegINRmr3mJ2nHHw5b6X61UJDto/AgEDlZuTh35YBnUwibi7dlbepRfJ8XgWhHxM+\n21Qg+nmD+I2u8a8d2gnuTiv7m4A/M/bA9E4YbaligZvw4w7Zd18cBzYgb43nNiV82tBFz9\nhCd+9HlwNQnaJ9EL/tyFwJ1IkyQCF3YLTV3KaMUvHWDXYdNbktnrSgJazQgou7KNjN0nbm\nrtKAU1+iN/QgmBSOD3Rq6WnP2co4EqocEluBBb4eF6yOQ3jEd+icRkL/a0Wpc9NU7jMPll\nHfDIzaaGVGSFB13pYdIZNckgctpHGZnnB1jhLzaWzwjumBmt1g//wN3HeMxiQfFMrl78yP\nqJjceag13J+QdrSawLzf/ulXUALQKnpdPnvuwGlXnUbpXrqtP/J+qJwjpyuit8an5k7foI\nyK/1pPw0gz9j/KsebhXuZF7gxlZZCtkbdmCtrEVOqo/yIX02eGshFhO/h76QXvi1hz4SR2\nH6oB1KapvJlqd+txTIDf/zhlPP7vHPXsMUcumclsg+iP/mpleo51TFpD456A0g905fnjJr\nOKBftowi9IhYZM8bPKis1K7NWfz3uE90Mc8jky0e6XzlxKi4rF9ZKMuvm9b/QA9v6HYUNJ\nkadcq5GaVjL8hlgtMnRYDyClYoPsqyyuFP+rjWfEUPWANKzFR5rku6l2e5nTxm756azYDs\nXDupgd4Oo5w7KONgkLJffF+X4ClLfWnrlIm2EWF1Tw0E7XleaccnggUNUMg+4uY12IG8zb\n4CyDE4vFgeqY9+d3fxg/d33aOVyFB60hiqmYhni+Tf7z1yKRoqE44SiIEF+GDmnAaJuz91\nEO9r1P5xpW22Fgp3MFaqZh2Jrp9g5Ai4UC9mxQvXK/Y+He582IMoXaXEpp7SuIDOuDSv0i\nuU2Pm47jiPRFFIOtv1Zf6tJMAkY2wlN4W2nl5GdQwWQ7CNzaTtfquX1ckQIV8wlpbZH9Wn\nmyfnyc90/5ZQUlmX1nwg7UatSky1DxJfIMpePWqDNaeCJxKnMW/spO5PgEar/TKVzvYRh7\n0FCP0+c44GzgMkvx3HuPQro4LEeHUbeWjtQCKj1Vh/e8BIoUV4iw7vWJCk2/GzDm7QhcRD\n1rKbFgHs4aOg0gkCQOfHuE5jNJrBYO2PdmuQOT8v1um02yv1simYmmetLg+aW1t7akvbWM\nXztiGLpnwCRY3KemwNydG/5d/D+FvAovg8I7zAjLTIlnicL3P+MMt0O05e7mYYZXGKfb+S\nMd0uKlDJ+7DunkAH3P2NQe0AJVI6oX4oko20gx+3+XhL6SIsp3/Bo1KFG2utncUgnK1OMX\nezOenHBL3QdMsZHEmvgX+GqttoFO/ILqzXeRcn7bC0Vc1TdceDDjsVeWDiNlx6kRHCyYCM\nYXxsw1NVcpHtL9gAhqa8cyuHFpUCFA98EE0QX3bqbYtDlidqqsVUdprxXi3GfnpdhdRueM\nSVF3wK7yXwu2554Rs/HuLf+rxOrHsF8ZfSWAxtdDqxwuFt5UFstr9HupRgt8ik8XodIVdb\nWDzQFezZ5FudCmO23iNsXuUxs/j8lAYAC4gmTxoFYhAEhulUCjv+dVRe9lP0W1uygohVho\nXBcvbGIDxNBoIlPFRM+bqvIDC1sQi3MIh3l/NZQxxIkW/+I4uClYpmNGXPZaNZNhpdv1PJ\na9rQ==\n-----END OPENSSH PRIVATE KEY-----\n`\n\t// nolint\n\trsaPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCt//y3H4heRi1+3bO+FsqKyGTw5YQnu6MChEaDJY2SJqFCHGEwBAWGsuaDZPb6P+V16I5u2H+MtKBWDbkVK9760DkAFimuQ4XTtIzPhyb+Jc95wvNW6pAYXoetVlZIbzNzMEykE41kOMq19SNS3snvE5mYzfd2B6AGw3T2SubfF6G0staxtEYwlsWP/N+YIR4yLz11bxwuuTee/eMldvKZfzQQXIuopANU2mnAmoOqsG0G+DKNuXw7F7zd7lxus8zBF3fszur7Sc9APbWab5phJXWzE81ME2Z0Rro3/l/5mD3YqmS6+jyIqlvqR150Uf/2rKawVgzxZWvQhe1+gGIktavL85dO9VnxA/BASYM0caqFY/KwVpAs/mZ18SpjgPaEzm3H5eeTJRFMrwRusyunPTDv8lmAAXsr/ZMhd1gBkDiGE/YuTfekv8rMbkZNeB2HuXavcRTuCqY8y44ehe+sRug9nwykPnfRqjIeAm9zh+zknfJGl5TONHKnEj5tudpUvTd38ZnOM+CTzMHbSDOwkSLAp9mCzrbWc6OIeVqsqibPmia4SxyP7wxnszfH1MOkuCmphiCzk4nicYDhMW6jjXhRKok4yNIU9wqj0MMEKS3RKblAzCu7VFVTDrLSJIzs7Ch0RIuGnVTQ5S4O156Kr9hL535k6c6/NUXbylXtuw==`\n\n\t// Passphrase is \"1234\"\n\ted25519PriavteKey = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCGNomvLJ\nkXLr+TqkGZ2fuiAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIA9dIfLyILssYzKI\nVY7UQenn2Il6cUeeYppVwDSAiqPzAAAAsAied6o/EzONSz0GmRvzUIUmK899O+N/ARFc9c\nsSq5R8Qu+iqFOtgNFnPI1/wu22agUYxs3h6Su4Jv6WbySJpJhHhIN/6pZ4DZgj4zWGGSJl\n5Kt2/q0hzzuxmO6hTGUGLArVXbJEXxTPV/jo/1w8qBYyB1rdKal1dN0OzUlCP1568WR8wq\nCUI+b0Gxfqa/HSKlS23Iu7ZeWoMakwvcg5A5M8E/ihBLSDNsCJU8pgZ9FD\n-----END OPENSSH PRIVATE KEY-----\n`\n\t// nolint\n\ted25519PublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA9dIfLyILssYzKIVY7UQenn2Il6cUeeYppVwDSAiqPz jou@oou-ltm.internal.salesforce.com`\n)\n\nfunc Test_signerFromFile(t *testing.T) {\n\tcases := []struct {\n\t\tname       string\n\t\tprivateKey string\n\t\tpassphrase string\n\t\terrMsg     string\n\t}{\n\t\t{\n\t\t\tname:       \"rsa private key wrong passphrase\",\n\t\t\tprivateKey: rsaPrivateKey,\n\t\t\tpassphrase: \"wrong passphrase\",\n\t\t\terrMsg:     \"error decrypting private key\",\n\t\t},\n\t\t{\n\t\t\tname:       \"rsa private key correct passphrase\",\n\t\t\tprivateKey: rsaPrivateKey,\n\t\t\tpassphrase: \"1234\",\n\t\t},\n\t\t{\n\t\t\tname:       \"ed25519 private key wrong passphrase\",\n\t\t\tprivateKey: ed25519PriavteKey,\n\t\t\tpassphrase: \"wrong passphrase\",\n\t\t\terrMsg:     \"error decrypting private key\",\n\t\t},\n\t\t{\n\t\t\tname:       \"ed25519 private key correct passphrase\",\n\t\t\tprivateKey: ed25519PriavteKey,\n\t\t\tpassphrase: \"1234\",\n\t\t},\n\t}\n\n\tfor _, cc := range cases {\n\t\tc := cc\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tdir := t.TempDir()\n\t\t\ttmpfn := filepath.Join(dir, \"private_key\")\n\t\t\tif err := os.WriteFile(tmpfn, []byte(c.privateKey), 0600); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t_, err := signerFromFile(tmpfn, func(file string) ([]byte, error) {\n\t\t\t\tif want, got := tmpfn, file; want != got {\n\t\t\t\t\tt.Fatalf(\"file mismatched, want=%s got=%s:\\n%s\", want, got, cmp.Diff(want, got))\n\t\t\t\t}\n\n\t\t\t\treturn []byte(c.passphrase), nil\n\t\t\t})\n\n\t\t\tif err == nil && c.errMsg != \"\" {\n\t\t\t\tt.Fatal(\"error shouldn't be nil\")\n\t\t\t}\n\n\t\t\tif err != nil && c.errMsg == \"\" {\n\t\t\t\tt.Fatalf(\"error should be nil but it's %s\", err.Error())\n\t\t\t}\n\n\t\t\tif err != nil && !strings.Contains(err.Error(), c.errMsg) {\n\t\t\t\tt.Fatalf(\"unexpected error message, want=%q, got=%q\", c.errMsg, err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "icon/upterm.go",
    "content": "package icon\n\nimport (\n\t_ \"embed\"\n)\n\n//go:embed upterm.png\nvar Upterm []byte\n"
  },
  {
    "path": "internal/context/logging.go",
    "content": "package context\n\nimport (\n\t\"context\"\n\n\t\"github.com/owenthereal/upterm/internal/logging\"\n)\n\ntype contextKey string\n\nconst loggerKey contextKey = \"logger\"\n\nfunc WithLogger(ctx context.Context, logger *logging.Logger) context.Context {\n\treturn context.WithValue(ctx, loggerKey, logger)\n}\n\nfunc Logger(ctx context.Context) *logging.Logger {\n\tif logger, ok := ctx.Value(loggerKey).(*logging.Logger); ok {\n\t\treturn logger\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/e2e/e2e_test.go",
    "content": "package e2e\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/owenthereal/tmux\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nconst (\n\t// Test key material (same as ftests)\n\tHostPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDgPqzHMFhTyVRNoOhMET5GJjSX2kv/oJMmffWkf+ubswAAAIiu5GOBruRj\ngQAAAAtzc2gtZWQyNTUxOQAAACDgPqzHMFhTyVRNoOhMET5GJjSX2kv/oJMmffWkf+ubsw\nAAAEDBHlsR95C/pGVHtQGpgrUi+Qwgkfnp9QlRKdEhhx4rxOA+rMcwWFPJVE2g6EwRPkYm\nNJfaS/+gkyZ99aR/65uzAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----\n`\n\n\t// Client key (different from host key) for authorized keys tests\n\tClientPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHzlndir8KtqplpniMvYV3t7xqQz8jgIhP12WURQcQiY\n`\n\n\tClientPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB85Z3Yq/CraqZaZ4jL2Fd7e8akM/I4CIT9dllEUHEImAAAAIiFAKMkhQCj\nJAAAAAtzc2gtZWQyNTUxOQAAACB85Z3Yq/CraqZaZ4jL2Fd7e8akM/I4CIT9dllEUHEImA\nAAAEDcVndogRSlA4iO3Dkr0qIB2PJnH6llmTvAudZtQ84dgnzlndir8KtqplpniMvYV3t7\nxqQz8jgIhP12WURQcQiYAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----\n`\n)\n\n// uptermPrompt is the unique prompt used to detect when the shell is ready.\nconst uptermPrompt = \"UPTERM_READY>\"\n\n// ansiEscapeRe matches ANSI escape codes for stripping terminal colors.\nvar ansiEscapeRe = regexp.MustCompile(`\\x1b\\[[0-9;]*m`)\n\n// testHarness provides common setup and utilities for E2E tests.\n\ntype testHarness struct {\n\tt             *testing.T\n\tctx           context.Context\n\tsession       *tmux.Session\n\thost          *tmux.Pane\n\tserverURL     string\n\tkeyFile       string\n\tclientKeyFile string\n\trcFile        string\n\ttmpDir        string\n}\n\n// newTestHarness creates a new test harness with tmux session and host pane.\nfunc newTestHarness(t *testing.T, width int) *testHarness {\n\tt.Helper()\n\tskipIfNoTmux(t)\n\n\tserverURL := os.Getenv(\"UPTERM_E2E_SERVER\")\n\tif serverURL == \"\" {\n\t\tt.Fatal(\"UPTERM_E2E_SERVER environment variable is required\")\n\t}\n\n\tctx := context.Background()\n\ttm, err := tmux.Default()\n\trequire.NoError(t, err)\n\n\tsessionName := fmt.Sprintf(\"upterm-e2e-%s-%d\", t.Name(), time.Now().UnixNano())\n\tsession, err := tm.NewSession(ctx, &tmux.SessionOptions{\n\t\tName:         sessionName,\n\t\tWidth:        width,\n\t\tHeight:       24,\n\t\tShellCommand: \"bash --norc --noprofile\",\n\t})\n\trequire.NoError(t, err)\n\n\twindows, err := session.ListWindows(ctx)\n\trequire.NoError(t, err)\n\trequire.NotEmpty(t, windows)\n\n\tpanes, err := windows[0].ListPanes(ctx)\n\trequire.NoError(t, err)\n\trequire.NotEmpty(t, panes)\n\n\ttmpDir := t.TempDir()\n\tkeyFile := filepath.Join(tmpDir, \"id_ed25519\")\n\trequire.NoError(t, os.WriteFile(keyFile, []byte(HostPrivateKeyContent), 0600))\n\n\t// Create a client key for SSH connections\n\tclientKeyFile := filepath.Join(tmpDir, \"client_key\")\n\trequire.NoError(t, os.WriteFile(clientKeyFile, []byte(ClientPrivateKeyContent), 0600))\n\n\t// Create a bashrc file that sets a unique, deterministic prompt\n\trcFile := filepath.Join(tmpDir, \"bashrc\")\n\trequire.NoError(t, os.WriteFile(rcFile, fmt.Appendf(nil, \"PS1='%s '\\n\", uptermPrompt), 0644))\n\n\th := &testHarness{\n\t\tt:             t,\n\t\tctx:           ctx,\n\t\tsession:       session,\n\t\thost:          panes[0],\n\t\tserverURL:     serverURL,\n\t\tkeyFile:       keyFile,\n\t\tclientKeyFile: clientKeyFile,\n\t\trcFile:        rcFile,\n\t\ttmpDir:        tmpDir,\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = session.Kill(ctx)\n\t})\n\n\treturn h\n}\n\n// startHost starts upterm host with the given extra flags and returns the SSH command.\nfunc (h *testHarness) startHost(extraFlags string) string {\n\th.t.Helper()\n\n\thostCmd := fmt.Sprintf(\"upterm host --server %s --private-key %s --skip-host-key-check %s -- bash --rcfile %s --noprofile\",\n\t\th.serverURL, h.keyFile, extraFlags, h.rcFile)\n\trequire.NoError(h.t, h.host.SendLine(h.ctx, hostCmd))\n\trequire.NoError(h.t, h.waitForText(h.host, \"SSH:\", 30*time.Second), \"host failed to establish session\")\n\n\toutput, err := h.host.Capture(h.ctx)\n\trequire.NoError(h.t, err)\n\n\tsshCmd := extractSSHCommand(output)\n\trequire.NotEmpty(h.t, sshCmd, \"failed to extract SSH command from output:\\n%s\", output)\n\th.t.Logf(\"Extracted SSH command: %s\", sshCmd)\n\n\treturn sshCmd\n}\n\n// splitPane creates a new client pane by splitting the given pane.\nfunc (h *testHarness) splitPane(from *tmux.Pane) *tmux.Pane {\n\th.t.Helper()\n\tpane, err := from.SplitWindow(h.ctx, &tmux.SplitWindowOptions{\n\t\tSplitDirection: tmux.PaneSplitDirectionHorizontal,\n\t\tShellCommand:   \"bash --norc --noprofile\",\n\t})\n\trequire.NoError(h.t, err)\n\treturn pane\n}\n\n// connectClient connects a client pane using the SSH command with the default client key.\nfunc (h *testHarness) connectClient(client *tmux.Pane, sshCmd string) {\n\th.t.Helper()\n\tsshCmdWithOpts := strings.Replace(sshCmd, \"ssh \",\n\t\tfmt.Sprintf(\"ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \", h.clientKeyFile), 1)\n\trequire.NoError(h.t, client.SendLine(h.ctx, sshCmdWithOpts))\n\t// Wait for client to connect and see the deterministic prompt\n\terr := h.waitForText(client, uptermPrompt, 30*time.Second)\n\tif err != nil {\n\t\t// Debug: capture client pane content on failure\n\t\tcontent, _ := client.Capture(h.ctx)\n\t\th.t.Logf(\"Client pane content:\\n%s\", content)\n\t}\n\trequire.NoError(h.t, err, \"client failed to connect\")\n}\n\n// connectClientWithKey connects using a specific identity file.\nfunc (h *testHarness) connectClientWithKey(client *tmux.Pane, sshCmd, keyFile string) {\n\th.t.Helper()\n\tsshCmdWithOpts := strings.Replace(sshCmd, \"ssh \",\n\t\tfmt.Sprintf(\"ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \", keyFile), 1)\n\trequire.NoError(h.t, client.SendLine(h.ctx, sshCmdWithOpts))\n\t// Wait for client to connect and see the deterministic prompt\n\terr := h.waitForText(client, uptermPrompt, 30*time.Second)\n\tif err != nil {\n\t\tcontent, _ := client.Capture(h.ctx)\n\t\th.t.Logf(\"Client pane content:\\n%s\", content)\n\t}\n\trequire.NoError(h.t, err, \"client failed to connect\")\n}\n\n// waitForText polls pane content until expected string appears or timeout.\nfunc (h *testHarness) waitForText(pane *tmux.Pane, expected string, timeout time.Duration) error {\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\tcontent, err := pane.Capture(h.ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif strings.Contains(content, expected) {\n\t\t\treturn nil\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\tcontent, _ := pane.Capture(h.ctx)\n\treturn fmt.Errorf(\"timeout waiting for %q after %v\\nPane content:\\n%s\", expected, timeout, content)\n}\n\n// writeFile writes content to a file in the test's temp directory.\nfunc (h *testHarness) writeFile(name, content string, perm os.FileMode) string {\n\th.t.Helper()\n\tpath := filepath.Join(h.tmpDir, name)\n\trequire.NoError(h.t, os.WriteFile(path, []byte(content), perm))\n\treturn path\n}\n\nfunc skipIfNoTmux(t *testing.T) {\n\tt.Helper()\n\tif _, err := exec.LookPath(\"tmux\"); err != nil {\n\t\tt.Skip(\"tmux not installed, skipping E2E test\")\n\t}\n}\n\nfunc extractSSHCommand(output string) string {\n\tclean := ansiEscapeRe.ReplaceAllString(output, \"\")\n\n\t// Remove newlines/extra spaces caused by terminal wrapping\n\tclean = regexp.MustCompile(`\\s+`).ReplaceAllString(clean, \" \")\n\n\t// Match ssh command with optional -p port\n\tre := regexp.MustCompile(`SSH:\\s*(ssh\\s+\\S+(?:\\s+-p\\s+\\d+)?)`)\n\tmatches := re.FindStringSubmatch(clean)\n\tif len(matches) < 2 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(matches[1])\n}\n\n// extractScpUserHost extracts user and host separately from SSH command.\n// This is needed because upterm usernames contain ':' which scp misinterprets\n// as the host:path separator. By extracting them separately, we can use\n// scp -o User=username host:/path instead of user@host:/path.\n// e.g., \"ssh sessionid:base64@localhost -p 2222\" -> (\"sessionid:base64\", \"localhost\")\nfunc extractScpUserHost(sshCmd string) (string, string) {\n\tparts := strings.Fields(sshCmd)\n\tif len(parts) < 2 {\n\t\treturn \"\", \"\"\n\t}\n\t// parts[1] should be user@host\n\tuserHost := parts[1]\n\tatIndex := strings.LastIndex(userHost, \"@\")\n\tif atIndex == -1 {\n\t\treturn \"\", userHost\n\t}\n\treturn userHost[:atIndex], userHost[atIndex+1:]\n}\n\n// extractScpPortFlag extracts the -P flag for scp from an SSH command's -p flag.\n// e.g., \"ssh user@host -p 2222\" -> \"-P 2222\"\n// Note: scp uses -P (uppercase) for port, ssh uses -p (lowercase)\nfunc extractScpPortFlag(sshCmd string) string {\n\tparts := strings.Fields(sshCmd)\n\tfor i, part := range parts {\n\t\tif part == \"-p\" && i+1 < len(parts) {\n\t\t\treturn \"-P \" + parts[i+1]\n\t\t}\n\t}\n\treturn \"\"\n}\n\n\n// TestSync validates bidirectional real-time PTY sync between host and client.\nfunc TestSync(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\tsshCmd := h.startHost(\"--accept\")\n\n\tclient := h.splitPane(h.host)\n\th.connectClient(client, sshCmd)\n\n\t// Test Client -> Host sync\n\tclientText := \"hello from client\"\n\trequire.NoError(t, client.SendKeys(h.ctx, clientText))\n\trequire.NoError(t, h.waitForText(h.host, clientText, 10*time.Second), \"host did not receive keystrokes from client\")\n\n\t// Clear line and test Host -> Client sync\n\trequire.NoError(t, h.host.SendKeys(h.ctx, \"C-u\"))\n\ttime.Sleep(500 * time.Millisecond)\n\n\thostText := \"hello from host\"\n\trequire.NoError(t, h.host.SendKeys(h.ctx, hostText))\n\trequire.NoError(t, h.waitForText(client, hostText, 10*time.Second), \"client did not receive keystrokes from host\")\n}\n\n// TestMultipleClients validates that multiple clients can connect and see each other's keystrokes.\nfunc TestMultipleClients(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\tsshCmd := h.startHost(\"--accept\")\n\n\tclient1 := h.splitPane(h.host)\n\tclient2 := h.splitPane(client1)\n\n\th.connectClient(client1, sshCmd)\n\th.connectClient(client2, sshCmd)\n\n\t// Client1 types, Client2 and Host should see it\n\tclient1Text := \"from_client1\"\n\trequire.NoError(t, client1.SendKeys(h.ctx, client1Text))\n\trequire.NoError(t, h.waitForText(h.host, client1Text, 10*time.Second), \"host did not see client1 keystrokes\")\n\trequire.NoError(t, h.waitForText(client2, client1Text, 10*time.Second), \"client2 did not see client1 keystrokes\")\n\n\t// Clear and test Client2 types\n\trequire.NoError(t, h.host.SendKeys(h.ctx, \"C-u\"))\n\ttime.Sleep(500 * time.Millisecond)\n\n\tclient2Text := \"from_client2\"\n\trequire.NoError(t, client2.SendKeys(h.ctx, client2Text))\n\trequire.NoError(t, h.waitForText(h.host, client2Text, 10*time.Second), \"host did not see client2 keystrokes\")\n\trequire.NoError(t, h.waitForText(client1, client2Text, 10*time.Second), \"client1 did not see client2 keystrokes\")\n}\n\n// TestForceCommand validates that --force-command restricts client to the specified command.\nfunc TestForceCommand(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\tsshCmd := h.startHost(\"--accept -f 'echo FORCED_OUTPUT'\")\n\n\tclient := h.splitPane(h.host)\n\t// Don't use connectClient here - force command runs and closes connection immediately\n\t// (no interactive shell, so no prompt to wait for)\n\tsshCmdWithOpts := strings.Replace(sshCmd, \"ssh \",\n\t\tfmt.Sprintf(\"ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \", h.clientKeyFile), 1)\n\trequire.NoError(t, client.SendLine(h.ctx, sshCmdWithOpts))\n\n\trequire.NoError(t, h.waitForText(client, \"FORCED_OUTPUT\", 30*time.Second), \"client did not see forced command output\")\n}\n\n// TestAuthorizedKeys validates that only clients with authorized keys can connect.\nfunc TestAuthorizedKeys(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\t// Setup client keys\n\tclientKeyFile := h.writeFile(\"client_key\", ClientPrivateKeyContent, 0600)\n\tauthorizedKeysFile := h.writeFile(\"authorized_keys\", ClientPublicKeyContent, 0644)\n\n\t// Use both --accept (to auto-approve) and --authorized-keys (to restrict by key)\n\tsshCmd := h.startHost(fmt.Sprintf(\"--accept --authorized-keys %s\", authorizedKeysFile))\n\n\t// Test 1: Authorized client should connect successfully\n\tclient := h.splitPane(h.host)\n\th.connectClientWithKey(client, sshCmd, clientKeyFile)\n\n\ttestText := \"auth_success\"\n\trequire.NoError(t, client.SendKeys(h.ctx, testText))\n\trequire.NoError(t, h.waitForText(h.host, testText, 10*time.Second), \"authorized client could not connect\")\n\n\t// Test 2: Unauthorized client (using host key, not in authorized_keys) should be rejected\n\tunauthorizedClient := h.splitPane(client)\n\tsshCmdWithOpts := strings.Replace(sshCmd, \"ssh \",\n\t\tfmt.Sprintf(\"ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \", h.keyFile), 1)\n\trequire.NoError(t, unauthorizedClient.SendLine(h.ctx, sshCmdWithOpts))\n\n\t// Should see permission denied or connection closed\n\t// Use \"denied\" to avoid terminal line-wrapping splitting \"Permission denied\"\n\trequire.NoError(t, h.waitForText(unauthorizedClient, \"denied\", 30*time.Second),\n\t\t\"unauthorized client should be rejected\")\n}\n\n// TestSessionInfo validates that the TUI displays correct session information.\nfunc TestSessionInfo(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\th.startHost(\"--accept\")\n\n\toutput, err := h.host.Capture(h.ctx)\n\trequire.NoError(t, err)\n\n\t// Strip ANSI codes for easier verification\n\tclean := ansiEscapeRe.ReplaceAllString(output, \"\")\n\n\trequire.Contains(t, clean, \"Session:\", \"TUI should show Session ID\")\n\trequire.Contains(t, clean, \"Command:\", \"TUI should show Command\")\n\trequire.Contains(t, clean, \"bash\", \"TUI should show the command being run\")\n\trequire.Contains(t, clean, \"Host:\", \"TUI should show Host\")\n\trequire.Contains(t, clean, \"SSH:\", \"TUI should show SSH\")\n\trequire.Contains(t, clean, \"SFTP:\", \"TUI should show SFTP\")\n\trequire.Contains(t, clean, \"SCP:\", \"TUI should show SCP\")\n}\n"
  },
  {
    "path": "internal/e2e/sftp_test.go",
    "content": "package e2e\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\n// waitForFile polls until the file exists with non-zero size or timeout.\n// scp creates the destination file immediately but writes content progressively,\n// so we need to wait for actual content, not just file existence.\nfunc waitForFile(path string, timeout time.Duration) error {\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\tif info, err := os.Stat(path); err == nil && info.Size() > 0 {\n\t\t\treturn nil\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\treturn fmt.Errorf(\"timeout waiting for file %s\", path)\n}\n\n// TestSFTPDownload tests downloading a file via scp.\nfunc TestSFTPDownload(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\t// Create a test file on the host to download\n\ttestContent := \"hello from sftp download test\"\n\ttestFile := h.writeFile(\"download-test.txt\", testContent, 0644)\n\n\tsshCmd := h.startHost(\"--accept\")\n\tclient := h.splitPane(h.host)\n\n\t// Extract user and host separately (username contains ':' which scp misinterprets)\n\tscpUser, scpHost := extractScpUserHost(sshCmd)\n\trequire.NotEmpty(t, scpUser, \"failed to extract scp user from SSH command\")\n\trequire.NotEmpty(t, scpHost, \"failed to extract scp host from SSH command\")\n\n\t// Create destination for downloaded file\n\tdownloadDest := filepath.Join(h.tmpDir, \"downloaded.txt\")\n\n\t// Build scp command using -o User= to avoid ':' parsing issues\n\t// Use absolute path for remote file\n\tscpCmd := fmt.Sprintf(\"scp -o User=%s -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s %s:%s %s\",\n\t\tscpUser, h.clientKeyFile, extractScpPortFlag(sshCmd), scpHost, testFile, downloadDest)\n\n\trequire.NoError(t, client.SendLine(h.ctx, scpCmd))\n\trequire.NoError(t, waitForFile(downloadDest, 30*time.Second), \"scp download did not complete\")\n\n\t// Verify downloaded content\n\tcontent, err := os.ReadFile(downloadDest)\n\trequire.NoError(t, err, \"downloaded file should exist\")\n\trequire.Equal(t, testContent, string(content), \"downloaded content should match\")\n}\n\n// TestSFTPUpload tests uploading a file via scp.\nfunc TestSFTPUpload(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\t// Create a local file to upload\n\tuploadContent := \"hello from sftp upload test\"\n\tlocalFile := h.writeFile(\"upload-source.txt\", uploadContent, 0644)\n\n\tsshCmd := h.startHost(\"--accept\")\n\tclient := h.splitPane(h.host)\n\n\t// Extract user and host separately (username contains ':' which scp misinterprets)\n\tscpUser, scpHost := extractScpUserHost(sshCmd)\n\trequire.NotEmpty(t, scpUser, \"failed to extract scp user from SSH command\")\n\trequire.NotEmpty(t, scpHost, \"failed to extract scp host from SSH command\")\n\n\t// Build scp command using -o User= to avoid ':' parsing issues\n\t// Use absolute path for remote destination\n\tuploadedPath := filepath.Join(h.tmpDir, \"uploaded.txt\")\n\tscpCmd := fmt.Sprintf(\"scp -o User=%s -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s %s %s:%s\",\n\t\tscpUser, h.clientKeyFile, extractScpPortFlag(sshCmd), localFile, scpHost, uploadedPath)\n\n\trequire.NoError(t, client.SendLine(h.ctx, scpCmd))\n\n\t// Wait for uploaded file to appear on host\n\trequire.NoError(t, waitForFile(uploadedPath, 30*time.Second), \"scp upload did not complete\")\n\n\t// Verify uploaded content\n\tcontent, err := os.ReadFile(uploadedPath)\n\trequire.NoError(t, err, \"uploaded file should exist on host\")\n\trequire.Equal(t, uploadContent, string(content), \"uploaded content should match\")\n}\n\n// TestSFTPDisabled tests that --no-sftp disables SFTP/SCP.\nfunc TestSFTPDisabled(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\t// Create a test file that should NOT be downloadable\n\ttestFile := h.writeFile(\"forbidden.txt\", \"you should not see this\", 0644)\n\n\t// Start host with SFTP disabled\n\tsshCmd := h.startHost(\"--accept --no-sftp\")\n\tclient := h.splitPane(h.host)\n\n\t// Extract user and host separately (username contains ':' which scp misinterprets)\n\tscpUser, scpHost := extractScpUserHost(sshCmd)\n\trequire.NotEmpty(t, scpUser, \"failed to extract scp user from SSH command\")\n\trequire.NotEmpty(t, scpHost, \"failed to extract scp host from SSH command\")\n\n\t// Try to download - should fail\n\tdownloadDest := filepath.Join(h.tmpDir, \"should-not-exist.txt\")\n\tscpCmd := fmt.Sprintf(\"scp -o User=%s -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s %s:%s %s\",\n\t\tscpUser, h.clientKeyFile, extractScpPortFlag(sshCmd), scpHost, testFile, downloadDest)\n\n\trequire.NoError(t, client.SendLine(h.ctx, scpCmd))\n\n\t// Wait for scp error output (subsystem request failed)\n\trequire.NoError(t, h.waitForText(client, \"subsystem\", 30*time.Second), \"expected SFTP subsystem error\")\n\n\t// Verify file was NOT downloaded\n\t_, err := os.ReadFile(downloadDest)\n\trequire.Error(t, err, \"file should not have been downloaded when SFTP is disabled\")\n}\n\n// TestSFTPReadOnly tests that --read-only prevents uploads.\nfunc TestSFTPReadOnly(t *testing.T) {\n\th := newTestHarness(t, 200)\n\n\t// Create a test file that should be downloadable\n\tdownloadContent := \"read-only download test\"\n\ttestFile := h.writeFile(\"readonly-file.txt\", downloadContent, 0644)\n\n\t// Start host in read-only mode\n\tsshCmd := h.startHost(\"--accept --read-only\")\n\tclient := h.splitPane(h.host)\n\n\t// Extract user and host separately (username contains ':' which scp misinterprets)\n\tscpUser, scpHost := extractScpUserHost(sshCmd)\n\trequire.NotEmpty(t, scpUser, \"failed to extract scp user from SSH command\")\n\trequire.NotEmpty(t, scpHost, \"failed to extract scp host from SSH command\")\n\n\t// Test 1: Download should work\n\tdownloadDest := filepath.Join(h.tmpDir, \"downloaded-readonly.txt\")\n\tscpDownloadCmd := fmt.Sprintf(\"scp -o User=%s -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s %s:%s %s\",\n\t\tscpUser, h.clientKeyFile, extractScpPortFlag(sshCmd), scpHost, testFile, downloadDest)\n\n\trequire.NoError(t, client.SendLine(h.ctx, scpDownloadCmd))\n\trequire.NoError(t, waitForFile(downloadDest, 30*time.Second), \"scp download did not complete\")\n\n\tcontent, err := os.ReadFile(downloadDest)\n\trequire.NoError(t, err, \"download should work in read-only mode\")\n\trequire.Equal(t, downloadContent, string(content))\n\n\t// Test 2: Upload should fail\n\tuploadFile := h.writeFile(\"try-upload.txt\", \"this should fail\", 0644)\n\tuploadDest := filepath.Join(h.tmpDir, \"should-fail.txt\")\n\tscpUploadCmd := fmt.Sprintf(\"scp -o User=%s -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null %s %s %s:%s\",\n\t\tscpUser, h.clientKeyFile, extractScpPortFlag(sshCmd), uploadFile, scpHost, uploadDest)\n\n\trequire.NoError(t, client.SendLine(h.ctx, scpUploadCmd))\n\n\t// Wait for scp to produce error output (permission denied)\n\trequire.NoError(t, h.waitForText(client, \"denied\", 30*time.Second), \"expected permission denied error\")\n\n\t// Verify file was NOT uploaded\n\t_, err = os.Stat(uploadDest)\n\trequire.Error(t, err, \"upload should fail in read-only mode\")\n}\n"
  },
  {
    "path": "internal/logging/logging.go",
    "content": "package logging\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/getsentry/sentry-go\"\n\tslogsentry \"github.com/getsentry/sentry-go/slog\"\n\t\"github.com/owenthereal/upterm/internal/version\"\n\tslogmulti \"github.com/samber/slog-multi\"\n)\n\nconst (\n\tsentryFlushTimeout = 2 * time.Second\n)\n\n// Logger wraps slog.Logger with cleanup capability and dynamic handlers\ntype Logger struct {\n\t*slog.Logger\n\tcleanupFuncs []func() error\n}\n\n// Close cleans up resources\nfunc (l *Logger) Close() error {\n\tfor _, cleanup := range l.cleanupFuncs {\n\t\tif err := cleanup(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// With returns a new logger with additional attributes\nfunc (l *Logger) With(args ...any) *Logger {\n\treturn &Logger{\n\t\tLogger:       l.Logger.With(args...),\n\t\tcleanupFuncs: l.cleanupFuncs,\n\t}\n}\n\n// WithGroup returns a new logger with a group\nfunc (l *Logger) WithGroup(name string) *Logger {\n\treturn &Logger{\n\t\tLogger:       l.Logger.WithGroup(name),\n\t\tcleanupFuncs: l.cleanupFuncs,\n\t}\n}\n\n// Option configures a logger\ntype Option func(*config) error\n\ntype config struct {\n\tlevel        slog.Level\n\toutputs      []io.Writer\n\thandlers     []slog.Handler\n\tcleanupFuncs []func() error\n}\n\n// New creates a logger with options (for upterm client)\nfunc New(opts ...Option) (*Logger, error) {\n\tcfg := &config{\n\t\tlevel: slog.LevelInfo,\n\t}\n\tfor _, opt := range opts {\n\t\tif err := opt(cfg); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Default to stderr if no outputs specified\n\tif len(cfg.outputs) == 0 {\n\t\tcfg.outputs = []io.Writer{os.Stderr}\n\t}\n\n\t// Always add JSON handler for normal logging\n\tcfg.handlers = append(cfg.handlers, slog.NewJSONHandler(io.MultiWriter(cfg.outputs...), &slog.HandlerOptions{Level: cfg.level}))\n\n\treturn &Logger{\n\t\tLogger:       slog.New(slogmulti.Fanout(cfg.handlers...)),\n\t\tcleanupFuncs: cfg.cleanupFuncs,\n\t}, nil\n}\n\n// Must wraps NewWithOptions and panics on error\nfunc Must(opts ...Option) *Logger {\n\tlogger, err := New(opts...)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn logger\n}\n\n// Level sets the log level\nfunc Level(level slog.Level) Option {\n\treturn func(c *config) error {\n\t\tc.level = level\n\t\treturn nil\n\t}\n}\n\n// Debug sets debug level\nfunc Debug() Option {\n\treturn Level(slog.LevelDebug)\n}\n\n// Console logs to stderr\nfunc Console() Option {\n\treturn func(c *config) error {\n\t\tc.outputs = append(c.outputs, os.Stderr)\n\t\treturn nil\n\t}\n}\n\n// File logs to a file (path is required)\nfunc File(path string) Option {\n\treturn func(c *config) error {\n\t\tif path == \"\" {\n\t\t\treturn fmt.Errorf(\"log file path is required\")\n\t\t}\n\n\t\tif err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create log directory: %w\", err)\n\t\t}\n\n\t\tfile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to open log file %q: %w\", path, err)\n\t\t}\n\n\t\tc.outputs = append(c.outputs, file)\n\t\tc.cleanupFuncs = append(c.cleanupFuncs, file.Close)\n\t\treturn nil\n\t}\n}\n\n// Sentry enables Sentry error reporting\nfunc Sentry(dsn string) Option {\n\treturn func(c *config) error {\n\t\tif dsn == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\tsentryHandler, cleanup, err := newSentryHandler(dsn)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tc.handlers = append(c.handlers, sentryHandler)\n\t\tc.cleanupFuncs = append(c.cleanupFuncs, cleanup)\n\t\treturn nil\n\t}\n}\n\nfunc newSentryHandler(dsn string) (slog.Handler, func() error, error) {\n\terr := sentry.Init(sentry.ClientOptions{\n\t\tDsn:              dsn,\n\t\tEnvironment:      \"production\",\n\t\tRelease:          version.Version,\n\t\tAttachStacktrace: true,\n\t})\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\thandler := slogsentry.Option{\n\t\tLevel: slog.LevelError,\n\t}.NewSentryHandler(context.Background())\n\n\tcleanup := func() error {\n\t\tok := sentry.Flush(sentryFlushTimeout)\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"sentry flush timeout\")\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn handler, cleanup, nil\n}\n"
  },
  {
    "path": "internal/testhelpers/consul.go",
    "content": "package testhelpers\n\nimport (\n\t\"context\"\n\t\"net/url\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/hashicorp/consul/api\"\n)\n\nconst (\n\t// ConsulHealthCheckTimeout is the timeout for Consul health checks\n\tConsulHealthCheckTimeout = 2 * time.Second\n)\n\n// IsConsulAvailable checks if Consul is running and accessible with timeout handling\nfunc IsConsulAvailable() bool {\n\tconfig := api.DefaultConfig()\n\tconsulURLStr := ConsulURL()\n\tu, err := url.Parse(consulURLStr)\n\tif err != nil {\n\t\treturn false\n\t}\n\tconfig.Address = u.Host\n\n\tclient, err := api.NewClient(config)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\t// Try to get leader with timeout - simple health check\n\tctx, cancel := context.WithTimeout(context.Background(), ConsulHealthCheckTimeout)\n\tdefer cancel()\n\n\tdone := make(chan bool, 1)\n\tgo func() {\n\t\t_, err = client.Status().Leader()\n\t\tdone <- err == nil\n\t}()\n\n\tselect {\n\tcase result := <-done:\n\t\treturn result\n\tcase <-ctx.Done():\n\t\treturn false\n\t}\n}\n\n// ConsulURL returns the Consul URL from environment or default\nfunc ConsulURL() string {\n\taddr := os.Getenv(\"CONSUL_URL\")\n\tif addr == \"\" {\n\t\taddr = \"http://localhost:8500\"\n\t}\n\treturn addr\n}\n\n// ConsulClient creates a new Consul API client\nfunc ConsulClient() (*api.Client, error) {\n\tconfig := api.DefaultConfig()\n\tconsulURL, err := url.Parse(ConsulURL())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tconfig.Address = consulURL.Host\n\n\tclient, err := api.NewClient(config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn client, nil\n}\n"
  },
  {
    "path": "internal/version/version.go",
    "content": "// Package version provides version management and compatibility checking for upterm/uptermd.\n//\n// This package centralizes version handling across the upterm ecosystem, including:\n//   - Single source of truth for version constants\n//   - Semantic version parsing and comparison\n//   - SSH server version extraction and validation\n//   - Host/server compatibility checking with detailed results\n//\n// The main entry point is CheckCompatibility() which compares the current host version\n// with a server's SSH version string and returns detailed compatibility information.\n//\n// Example usage:\n//\n//\tresult := version.CheckCompatibility(\"SSH-2.0-uptermd-0.14.3\")\n//\tif !result.Compatible {\n//\t    fmt.Printf(\"Warning: %s\\n\", result.Message)\n//\t    fmt.Printf(\"Host: %s, Server: %s\\n\", result.HostVersion, result.ServerVersion)\n//\t}\npackage version\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\n\t\"github.com/hashicorp/go-version\"\n\t\"github.com/owenthereal/upterm/upterm\"\n)\n\n// Version is the semantic version of upterm/uptermd\n// This is the single source of truth for both client and server versions\n// Can be overridden at build time with ldflags\nvar Version = \"0.0.0+dev\"\n\n// Build-time variables set via ldflags\nvar (\n\tGitCommit string // Git commit SHA\n\tDate      string // Build date\n)\n\n// BuildInfo contains version and build information\ntype BuildInfo struct {\n\tVersion   string `json:\"version\"`\n\tGitCommit string `json:\"git_commit,omitempty\"`\n\tBuildDate string `json:\"build_date,omitempty\"`\n}\n\n// Parse parses a version string using hashicorp's go-version library\nfunc Parse(v string) (*version.Version, error) {\n\treturn version.NewVersion(v)\n}\n\n// ParseFromSSHVersion extracts version from SSH version strings like \"SSH-2.0-uptermd-0.14.3\"\n// Uses regex for precise parsing and supports complex semantic versions like \"1.0.0-beta.1+build.123\"\nfunc ParseFromSSHVersion(sshVersion string) (*version.Version, error) {\n\t// Build regex pattern using the constant to ensure consistency\n\t// Escape dots in the server version string for regex safety\n\tescapedServerVersion := regexp.QuoteMeta(upterm.ServerSSHServerVersion)\n\tpattern := fmt.Sprintf(`^%s-(.+)$`, escapedServerVersion)\n\n\tre := regexp.MustCompile(pattern)\n\tmatches := re.FindStringSubmatch(sshVersion)\n\tif len(matches) != 2 {\n\t\treturn nil, fmt.Errorf(\"not a valid uptermd SSH version: %s\", sshVersion)\n\t}\n\n\t// Extract and parse the version string\n\tversionStr := matches[1]\n\tv, err := Parse(versionStr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid version format in SSH string %s: %w\", sshVersion, err)\n\t}\n\n\treturn v, nil\n}\n\n// Current returns the current version as a parsed version object\n// Panics if Version constant is not a valid semantic version\nfunc Current() *version.Version {\n\tv, err := Parse(Version)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"invalid version constant %q: %v\", Version, err))\n\t}\n\treturn v\n}\n\n// String returns the current version as a string\nfunc String() string {\n\treturn Version\n}\n\n// GetBuildInfo returns comprehensive build information\nfunc GetBuildInfo() BuildInfo {\n\treturn BuildInfo{\n\t\tVersion:   Version,\n\t\tGitCommit: GitCommit,\n\t\tBuildDate: Date,\n\t}\n}\n\n// PrintVersion prints version information with the given binary name\nfunc PrintVersion(binaryName string) {\n\tbuildInfo := GetBuildInfo()\n\tfmt.Printf(\"%s version %s\\n\", binaryName, buildInfo.Version)\n\n\tif buildInfo.GitCommit != \"\" {\n\t\tfmt.Printf(\"Git commit: %s\\n\", buildInfo.GitCommit)\n\t}\n\n\tif buildInfo.BuildDate != \"\" {\n\t\tfmt.Printf(\"Build date: %s\\n\", buildInfo.BuildDate)\n\t}\n}\n\n// ServerSSHVersion returns the SSH server version string with embedded version\nfunc ServerSSHVersion() string {\n\treturn fmt.Sprintf(\"%s-%s\", upterm.ServerSSHServerVersion, Version)\n}\n\n// CompatibilityResult contains the result of version compatibility checking\ntype CompatibilityResult struct {\n\tCompatible    bool\n\tHostVersion   string\n\tServerVersion string\n\tMessage       string\n}\n\n// CheckCompatibility checks if the server version is compatible with the current host version\n// Always returns a result - Compatible=false for unparseable server versions to indicate incompatibility\nfunc CheckCompatibility(sshVersion string) *CompatibilityResult {\n\thostVersion := Current()\n\thostVersionStr := \"v\" + hostVersion.String()\n\n\tserverVersion, err := ParseFromSSHVersion(sshVersion)\n\tif err != nil {\n\t\t// Can't parse server version - could be older upterm server or non-upterm server\n\t\treturn &CompatibilityResult{\n\t\t\tCompatible:    false,\n\t\t\tHostVersion:   hostVersionStr,\n\t\t\tServerVersion: \"unknown\",\n\t\t\tMessage:       \"Unable to determine server version - possibly older upterm server or non-upterm server\",\n\t\t}\n\t}\n\n\tserverVersionStr := \"v\" + serverVersion.String()\n\n\t// Check if major versions match (semantic versioning compatibility)\n\thostMajor := hostVersion.Segments()[0]\n\tserverMajor := serverVersion.Segments()[0]\n\n\tif hostMajor != serverMajor {\n\t\treturn &CompatibilityResult{\n\t\t\tCompatible:    false,\n\t\t\tHostVersion:   hostVersionStr,\n\t\t\tServerVersion: serverVersionStr,\n\t\t\tMessage:       fmt.Sprintf(\"Major version mismatch: host %s, server %s\", hostVersionStr, serverVersionStr),\n\t\t}\n\t}\n\n\treturn &CompatibilityResult{\n\t\tCompatible:    true,\n\t\tHostVersion:   hostVersionStr,\n\t\tServerVersion: serverVersionStr,\n\t\tMessage:       \"\",\n\t}\n}\n"
  },
  {
    "path": "internal/version/version_test.go",
    "content": "package version\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestParseFromSSHVersion(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tsshVersion  string\n\t\texpectedVer string\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname:        \"valid uptermd SSH version\",\n\t\t\tsshVersion:  \"SSH-2.0-uptermd-0.14.3\",\n\t\t\texpectedVer: \"0.14.3\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"SSH version without numeric version\",\n\t\t\tsshVersion:  \"SSH-2.0-openssh\",\n\t\t\texpectedVer: \"\",\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"malformed version\",\n\t\t\tsshVersion:  \"SSH-2.0-uptermd-invalid\",\n\t\t\texpectedVer: \"\",\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"no version suffix\",\n\t\t\tsshVersion:  \"SSH-2.0-uptermd\",\n\t\t\texpectedVer: \"\",\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"complex semantic version with prerelease\",\n\t\t\tsshVersion:  \"SSH-2.0-uptermd-1.0.0-beta.1\",\n\t\t\texpectedVer: \"1.0.0-beta.1\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"complex semantic version with build metadata\",\n\t\t\tsshVersion:  \"SSH-2.0-uptermd-1.0.0+build.123\",\n\t\t\texpectedVer: \"1.0.0+build.123\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"complex semantic version with both prerelease and build\",\n\t\t\tsshVersion:  \"SSH-2.0-uptermd-2.0.0-rc.1+20220101\",\n\t\t\texpectedVer: \"2.0.0-rc.1+20220101\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"wrong server name - should fail\",\n\t\t\tsshVersion:  \"SSH-2.0-upterm-server-0.14.3\",\n\t\t\texpectedVer: \"\",\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tversion, err := ParseFromSSHVersion(tt.sshVersion)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expectedVer, version.String())\n\t\t})\n\t}\n}\n\nfunc TestCheckCompatibility(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tsshVersion   string\n\t\texpectedComp bool\n\t\texpectedHost string\n\t\texpectedSvr  string\n\t\texpectedMsg  string\n\t}{\n\t\t{\n\t\t\tname:         \"same versions\",\n\t\t\tsshVersion:   \"SSH-2.0-uptermd-\" + Version,\n\t\t\texpectedComp: true,\n\t\t\texpectedHost: \"v\" + Version,\n\t\t\texpectedSvr:  \"v\" + Version,\n\t\t\texpectedMsg:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:         \"same major, different minor\",\n\t\t\tsshVersion:   \"SSH-2.0-uptermd-0.15.0\",\n\t\t\texpectedComp: true,\n\t\t\texpectedHost: \"v\" + Version,\n\t\t\texpectedSvr:  \"v0.15.0\",\n\t\t\texpectedMsg:  \"\",\n\t\t},\n\t\t{\n\t\t\tname:         \"different major versions\",\n\t\t\tsshVersion:   \"SSH-2.0-uptermd-1.0.0\",\n\t\t\texpectedComp: false,\n\t\t\texpectedHost: \"v\" + Version,\n\t\t\texpectedSvr:  \"v1.0.0\",\n\t\t\texpectedMsg:  \"Major version mismatch: host v\" + Version + \", server v1.0.0\",\n\t\t},\n\t\t{\n\t\t\tname:         \"different server (openssh) - treated as unknown\",\n\t\t\tsshVersion:   \"SSH-2.0-openssh-8.0\",\n\t\t\texpectedComp: false,\n\t\t\texpectedHost: \"v\" + Version,\n\t\t\texpectedSvr:  \"unknown\",\n\t\t\texpectedMsg:  \"Unable to determine server version - possibly older upterm server or non-upterm server\",\n\t\t},\n\t\t{\n\t\t\tname:         \"unparseable server version (no version) - incompatible\",\n\t\t\tsshVersion:   \"SSH-2.0-openssh\",\n\t\t\texpectedComp: false,\n\t\t\texpectedHost: \"v\" + Version,\n\t\t\texpectedSvr:  \"unknown\",\n\t\t\texpectedMsg:  \"Unable to determine server version - possibly older upterm server or non-upterm server\",\n\t\t},\n\t\t{\n\t\t\tname:         \"malformed SSH version - incompatible\",\n\t\t\tsshVersion:   \"invalid-version-string\",\n\t\t\texpectedComp: false,\n\t\t\texpectedHost: \"v\" + Version,\n\t\t\texpectedSvr:  \"unknown\",\n\t\t\texpectedMsg:  \"Unable to determine server version - possibly older upterm server or non-upterm server\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := CheckCompatibility(tt.sshVersion)\n\n\t\t\tassert.Equal(t, tt.expectedComp, result.Compatible)\n\t\t\tassert.Equal(t, tt.expectedHost, result.HostVersion)\n\t\t\tassert.Equal(t, tt.expectedSvr, result.ServerVersion)\n\t\t\tassert.Equal(t, tt.expectedMsg, result.Message)\n\t\t})\n\t}\n}\n\nfunc TestServerSSHVersion(t *testing.T) {\n\texpected := \"SSH-2.0-uptermd-\" + Version\n\tactual := ServerSSHVersion()\n\n\tassert.Equal(t, expected, actual)\n}\n\nfunc TestCurrent(t *testing.T) {\n\t// Test that Current() returns a valid version and doesn't panic\n\tv := Current()\n\tassert.NotNil(t, v)\n\tassert.Equal(t, Version, v.String())\n}\n\nfunc TestCurrentPanic(t *testing.T) {\n\t// This test would only fail if Version constant is invalid\n\t// which would be a build-time issue, but we test the panic behavior\n\tassert.NotPanics(t, func() {\n\t\tCurrent()\n\t})\n}\n"
  },
  {
    "path": "io/query_filter.go",
    "content": "package io\n\nimport (\n\t\"io\"\n)\n\n// TerminalQueryFilter wraps an io.Writer and filters out terminal query\n// sequences from the output. This prevents queries sent by the host's shell\n// from reaching connected clients, whose terminals would otherwise respond\n// and pollute the PTY input.\n//\n// Filtered queries include:\n//   - OSC 10/11/12 queries (foreground/background/cursor color): ESC ] N ; ? BEL/ST\n//   - CSI 5 n (device status request)\n//   - CSI 6 n (cursor position request)\n//   - CSI c / CSI 0 c (primary device attributes)\n//   - CSI > c / CSI > 0 c (secondary device attributes)\n//   - CSI = c / CSI = 0 c (tertiary device attributes)\ntype TerminalQueryFilter struct {\n\tw      io.Writer\n\tstate  queryFilterState\n\tseqBuf []byte // accumulates current sequence\n\toutBuf []byte // reusable output buffer to reduce allocations\n\toscCmd int    // OSC command number being parsed\n}\n\ntype queryFilterState int\n\nconst (\n\tqfStateNormal      queryFilterState = iota\n\tqfStateEsc                          // saw ESC\n\tqfStateCSI                          // saw ESC [\n\tqfStateCSIParam                     // parsing CSI parameters\n\tqfStateOSC                          // saw ESC ]\n\tqfStateOSCParam                     // saw OSC number\n\tqfStateOSCSemi                      // saw OSC number ;\n\tqfStateOSCQuery                     // saw OSC N ; ? (query for color)\n\tqfStateOSCQueryEsc                  // saw ESC in OSC query (possible ST)\n\tqfStateOSCContent                   // saw OSC N ; <non-?> (not a query, pass through)\n\tqfStateOSCContentEsc                // saw ESC in OSC content (possible ST)\n)\n\n// NewTerminalQueryFilter creates a filter that removes terminal query\n// sequences from output before writing to the underlying writer.\nfunc NewTerminalQueryFilter(w io.Writer) *TerminalQueryFilter {\n\treturn &TerminalQueryFilter{\n\t\tw:      w,\n\t\tseqBuf: make([]byte, 0, 64),\n\t\toutBuf: make([]byte, 0, 4096),\n\t}\n}\n\n// Write filters terminal query sequences from p and writes the result to the\n// underlying writer. Returns len(p) on success to indicate all input bytes were\n// processed. On error, returns 0 because the filtered output is written atomically\n// (all or nothing) and input bytes don't map 1:1 to output bytes due to filtering.\nfunc (f *TerminalQueryFilter) Write(p []byte) (int, error) {\n\t// Reuse output buffer, reset to zero length but keep capacity\n\tf.outBuf = f.outBuf[:0]\n\n\t// Process each byte, filtering out query sequences\n\tfor _, b := range p {\n\t\tf.processByte(b)\n\t}\n\n\t// Write filtered output atomically\n\tif len(f.outBuf) > 0 {\n\t\t_, err := f.w.Write(f.outBuf)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t}\n\n\treturn len(p), nil\n}\n\n// processByte processes a single byte, appending non-filtered output to f.outBuf.\nfunc (f *TerminalQueryFilter) processByte(b byte) {\n\tswitch f.state {\n\tcase qfStateNormal:\n\t\tif b == 0x1b { // ESC\n\t\t\tf.state = qfStateEsc\n\t\t\tf.seqBuf = append(f.seqBuf[:0], b)\n\t\t\treturn // Don't output yet, might be a query\n\t\t}\n\t\tf.outBuf = append(f.outBuf, b)\n\n\tcase qfStateEsc:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tswitch b {\n\t\tcase '[': // CSI\n\t\t\tf.state = qfStateCSI\n\t\tcase ']': // OSC\n\t\t\tf.state = qfStateOSC\n\t\t\tf.oscCmd = 0\n\t\tdefault:\n\t\t\t// Not a sequence we filter, output buffered\n\t\t\tf.flushAndReset()\n\t\t}\n\n\tcase qfStateCSI:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b >= '0' && b <= '9' {\n\t\t\tf.state = qfStateCSIParam\n\t\t\treturn\n\t\t}\n\t\tif b == '>' || b == '?' || b == '=' {\n\t\t\tf.state = qfStateCSIParam\n\t\t\treturn\n\t\t}\n\t\t// Check for immediate CSI queries\n\t\tif b == 'c' {\n\t\t\t// CSI c - Primary Device Attributes query - FILTER\n\t\t\tf.state = qfStateNormal\n\t\t\tf.seqBuf = f.seqBuf[:0]\n\t\t\treturn\n\t\t}\n\t\t// Other CSI sequence, not a query we filter\n\t\tf.flushAndReset()\n\n\tcase qfStateCSIParam:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif (b >= '0' && b <= '9') || b == ';' || b == '>' || b == '?' || b == '=' {\n\t\t\tif len(f.seqBuf) > 32 {\n\t\t\t\tf.flushAndReset()\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t// Final byte - check if it's a query\n\t\tif f.isCSIQuery(b) {\n\t\t\tf.state = qfStateNormal\n\t\t\tf.seqBuf = f.seqBuf[:0]\n\t\t\treturn // Filter the query\n\t\t}\n\t\t// Not a query, output it\n\t\tf.flushAndReset()\n\n\tcase qfStateOSC:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b >= '0' && b <= '9' {\n\t\t\tf.oscCmd = f.oscCmd*10 + int(b-'0')\n\t\t\tf.state = qfStateOSCParam\n\t\t\treturn\n\t\t}\n\t\t// Invalid OSC, output it\n\t\tf.flushAndReset()\n\n\tcase qfStateOSCParam:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b >= '0' && b <= '9' {\n\t\t\t// Check before updating to prevent overflow (OSC commands are 1-3 digits)\n\t\t\tif f.oscCmd > 99 {\n\t\t\t\tf.flushAndReset()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tf.oscCmd = f.oscCmd*10 + int(b-'0')\n\t\t\treturn\n\t\t}\n\t\tif b == ';' {\n\t\t\tf.state = qfStateOSCSemi\n\t\t\treturn\n\t\t}\n\t\tif b == 0x07 { // BEL - OSC without content, not a query\n\t\t\tf.flushAndReset()\n\t\t\treturn\n\t\t}\n\t\t// Invalid, output it\n\t\tf.flushAndReset()\n\n\tcase qfStateOSCSemi:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b == '?' {\n\t\t\t// This might be a query - check if it's OSC 10, 11, or 12\n\t\t\tif f.oscCmd == 10 || f.oscCmd == 11 || f.oscCmd == 12 {\n\t\t\t\tf.state = qfStateOSCQuery\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Other OSC query (e.g., OSC 4;?), don't filter - treat as content\n\t\t\tf.state = qfStateOSCContent\n\t\t\treturn\n\t\t}\n\t\t// Not a query (it's setting a value), continue to end of OSC\n\t\tf.state = qfStateOSCContent\n\n\tcase qfStateOSCContent:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b == 0x07 { // BEL - end of OSC\n\t\t\t// Pass through the entire OSC sequence\n\t\t\tf.flushAndReset()\n\t\t\treturn\n\t\t}\n\t\tif b == 0x1b { // ESC - possible ST\n\t\t\tf.state = qfStateOSCContentEsc\n\t\t\treturn\n\t\t}\n\t\tif len(f.seqBuf) > 256 {\n\t\t\tf.flushAndReset()\n\t\t}\n\n\tcase qfStateOSCContentEsc:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b == '\\\\' { // ST (String Terminator)\n\t\t\t// Pass through the entire OSC sequence\n\t\t\tf.flushAndReset()\n\t\t\treturn\n\t\t}\n\t\t// Not ST, continue as content (the ESC might be part of content)\n\t\tf.state = qfStateOSCContent\n\n\tcase qfStateOSCQuery:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b == 0x07 { // BEL - end of OSC query\n\t\t\t// This is a color query (OSC 10/11/12), filter it\n\t\t\tf.state = qfStateNormal\n\t\t\tf.seqBuf = f.seqBuf[:0]\n\t\t\treturn\n\t\t}\n\t\tif b == 0x1b { // ESC - possible ST\n\t\t\tf.state = qfStateOSCQueryEsc\n\t\t\treturn\n\t\t}\n\t\tif len(f.seqBuf) > 32 {\n\t\t\tf.flushAndReset()\n\t\t}\n\n\tcase qfStateOSCQueryEsc:\n\t\tf.seqBuf = append(f.seqBuf, b)\n\t\tif b == '\\\\' { // ST (String Terminator)\n\t\t\t// This is a color query (OSC 10/11/12), filter it\n\t\t\tf.state = qfStateNormal\n\t\t\tf.seqBuf = f.seqBuf[:0]\n\t\t\treturn\n\t\t}\n\t\t// Not ST, output what we have\n\t\tf.flushAndReset()\n\n\tdefault:\n\t\tf.outBuf = append(f.outBuf, b)\n\t}\n}\n\n// isCSIQuery checks if the final byte indicates a CSI query we should filter.\nfunc (f *TerminalQueryFilter) isCSIQuery(finalByte byte) bool {\n\t// seqBuf contains: ESC [ params finalByte\n\t// We need at least ESC [ finalByte (3 bytes)\n\tn := len(f.seqBuf)\n\tif n < 3 {\n\t\treturn false\n\t}\n\n\t// Extract parameters (bytes between '[' and finalByte)\n\tparams := f.seqBuf[2 : n-1]\n\n\tswitch finalByte {\n\tcase 'n':\n\t\t// CSI 5 n - Device Status Report\n\t\t// CSI 6 n - Cursor Position Request\n\t\tif len(params) == 1 && (params[0] == '5' || params[0] == '6') {\n\t\t\treturn true\n\t\t}\n\tcase 'c':\n\t\t// CSI c, CSI 0 c - Primary Device Attributes\n\t\t// CSI > c, CSI > 0 c - Secondary Device Attributes\n\t\t// CSI = c, CSI = 0 c - Tertiary Device Attributes\n\t\tif len(params) == 0 {\n\t\t\treturn true\n\t\t}\n\t\tif len(params) == 1 && (params[0] == '0' || params[0] == '>' || params[0] == '=') {\n\t\t\treturn true\n\t\t}\n\t\tif len(params) == 2 && ((params[0] == '>' && params[1] == '0') || (params[0] == '=' && params[1] == '0')) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// flushAndReset appends buffered bytes to outBuf and resets state.\nfunc (f *TerminalQueryFilter) flushAndReset() {\n\tf.outBuf = append(f.outBuf, f.seqBuf...)\n\tf.state = qfStateNormal\n\tf.seqBuf = f.seqBuf[:0]\n}\n"
  },
  {
    "path": "io/query_filter_test.go",
    "content": "package io\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTerminalQueryFilter(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []byte\n\t\texpected []byte\n\t}{\n\t\t{\n\t\t\tname:     \"regular text passes through\",\n\t\t\tinput:    []byte(\"hello world\"),\n\t\t\texpected: []byte(\"hello world\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"filters OSC 11 query with BEL\",\n\t\t\tinput:    []byte(\"\\x1b]11;?\\x07\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters OSC 11 query with ST\",\n\t\t\tinput:    []byte(\"\\x1b]11;?\\x1b\\\\\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters OSC 10 query (foreground color)\",\n\t\t\tinput:    []byte(\"\\x1b]10;?\\x07\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters OSC 12 query (cursor color)\",\n\t\t\tinput:    []byte(\"\\x1b]12;?\\x07\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"passes OSC set (not query)\",\n\t\t\tinput:    []byte(\"\\x1b]11;rgb:ff/ff/ff\\x07\"),\n\t\t\texpected: []byte(\"\\x1b]11;rgb:ff/ff/ff\\x07\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"filters CSI 6 n (cursor position request)\",\n\t\t\tinput:    []byte(\"\\x1b[6n\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters CSI 5 n (device status request)\",\n\t\t\tinput:    []byte(\"\\x1b[5n\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters CSI c (primary device attributes)\",\n\t\t\tinput:    []byte(\"\\x1b[c\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters CSI 0 c (primary device attributes)\",\n\t\t\tinput:    []byte(\"\\x1b[0c\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters CSI > c (secondary device attributes)\",\n\t\t\tinput:    []byte(\"\\x1b[>c\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"filters CSI > 0 c (secondary device attributes)\",\n\t\t\tinput:    []byte(\"\\x1b[>0c\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"passes regular CSI sequences\",\n\t\t\tinput:    []byte(\"\\x1b[2J\"), // clear screen\n\t\t\texpected: []byte(\"\\x1b[2J\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"passes cursor movement\",\n\t\t\tinput:    []byte(\"\\x1b[10;20H\"),\n\t\t\texpected: []byte(\"\\x1b[10;20H\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"passes SGR (colors/styles)\",\n\t\t\tinput:    []byte(\"\\x1b[1;32m\"),\n\t\t\texpected: []byte(\"\\x1b[1;32m\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"mixed content with query in middle\",\n\t\t\tinput:    []byte(\"before\\x1b[6nafter\"),\n\t\t\texpected: []byte(\"beforeafter\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple queries filtered\",\n\t\t\tinput:    []byte(\"\\x1b]11;?\\x07\\x1b[6n\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"query followed by regular output\",\n\t\t\tinput:    []byte(\"\\x1b[6nhello\"),\n\t\t\texpected: []byte(\"hello\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"lone ESC passes through\",\n\t\t\tinput:    []byte{0x1b, 'a'},\n\t\t\texpected: []byte{0x1b, 'a'},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tvar buf bytes.Buffer\n\t\t\tfilter := NewTerminalQueryFilter(&buf)\n\n\t\t\tn, err := filter.Write(tt.input)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(len(tt.input), n)\n\t\t\tassert.Equal(tt.expected, buf.Bytes())\n\t\t})\n\t}\n}\n\nfunc TestTerminalQueryFilter_PreservesNonQueryOSC(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []byte\n\t\texpected []byte\n\t}{\n\t\t{\n\t\t\tname:     \"OSC 0 set window title with BEL\",\n\t\t\tinput:    []byte(\"\\x1b]0;My Title\\x07\"),\n\t\t\texpected: []byte(\"\\x1b]0;My Title\\x07\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"OSC 0 set window title with ST\",\n\t\t\tinput:    []byte(\"\\x1b]0;My Title\\x1b\\\\\"),\n\t\t\texpected: []byte(\"\\x1b]0;My Title\\x1b\\\\\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"OSC 11 set color (not query)\",\n\t\t\tinput:    []byte(\"\\x1b]11;rgb:ff/ff/ff\\x07\"),\n\t\t\texpected: []byte(\"\\x1b]11;rgb:ff/ff/ff\\x07\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"OSC 52 clipboard (not filtered)\",\n\t\t\tinput:    []byte(\"\\x1b]52;c;SGVsbG8=\\x07\"),\n\t\t\texpected: []byte(\"\\x1b]52;c;SGVsbG8=\\x07\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"OSC 4 palette query (not 10/11/12, should pass)\",\n\t\t\tinput:    []byte(\"\\x1b]4;1;?\\x07\"),\n\t\t\texpected: []byte(\"\\x1b]4;1;?\\x07\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"OSC 777 notification\",\n\t\t\tinput:    []byte(\"\\x1b]777;notify;Title;Body\\x07\"),\n\t\t\texpected: []byte(\"\\x1b]777;notify;Title;Body\\x07\"),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tvar buf bytes.Buffer\n\t\t\tfilter := NewTerminalQueryFilter(&buf)\n\n\t\t\tn, err := filter.Write(tt.input)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(len(tt.input), n)\n\t\t\tassert.Equal(tt.expected, buf.Bytes())\n\t\t})\n\t}\n}\n\nfunc TestTerminalQueryFilter_SplitWrites(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\twrites   [][]byte\n\t\texpected []byte\n\t}{\n\t\t{\n\t\t\tname:     \"OSC query split at ESC\",\n\t\t\twrites:   [][]byte{{0x1b}, {']', '1', '1', ';', '?', 0x07}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"OSC query split at semicolon\",\n\t\t\twrites:   [][]byte{{0x1b, ']', '1', '1', ';'}, {'?', 0x07}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"CSI query split at ESC\",\n\t\t\twrites:   [][]byte{{0x1b}, {'[', '6', 'n'}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"CSI query split mid-sequence\",\n\t\t\twrites:   [][]byte{{0x1b, '['}, {'6', 'n'}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"non-query OSC split across writes\",\n\t\t\twrites:   [][]byte{{0x1b, ']', '0', ';'}, {'T', 'i', 't', 'l', 'e', 0x07}},\n\t\t\texpected: []byte(\"\\x1b]0;Title\\x07\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"text with query in middle, split\",\n\t\t\twrites:   [][]byte{{'h', 'e', 'l', 'l', 'o', 0x1b}, {'[', '6', 'n', 'w', 'o', 'r', 'l', 'd'}},\n\t\t\texpected: []byte(\"helloworld\"),\n\t\t},\n\t\t{\n\t\t\tname:     \"incomplete sequence at end of write\",\n\t\t\twrites:   [][]byte{{'h', 'i', 0x1b}},\n\t\t\texpected: []byte(\"hi\"),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tvar buf bytes.Buffer\n\t\t\tfilter := NewTerminalQueryFilter(&buf)\n\n\t\t\tfor _, w := range tt.writes {\n\t\t\t\tn, err := filter.Write(w)\n\t\t\t\tassert.NoError(err)\n\t\t\t\tassert.Equal(len(w), n)\n\t\t\t}\n\n\t\t\tassert.Equal(tt.expected, buf.Bytes())\n\t\t})\n\t}\n}\n\nfunc TestTerminalQueryFilter_TertiaryDeviceAttributes(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []byte\n\t\texpected []byte\n\t}{\n\t\t{\n\t\t\tname:     \"CSI = c (tertiary device attributes)\",\n\t\t\tinput:    []byte(\"\\x1b[=c\"),\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"CSI = 0 c (tertiary device attributes)\",\n\t\t\tinput:    []byte(\"\\x1b[=0c\"),\n\t\t\texpected: nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tvar buf bytes.Buffer\n\t\t\tfilter := NewTerminalQueryFilter(&buf)\n\n\t\t\tn, err := filter.Write(tt.input)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(len(tt.input), n)\n\t\t\tassert.Equal(tt.expected, buf.Bytes())\n\t\t})\n\t}\n}\n\n// errWriter is a writer that always returns an error\ntype errWriter struct {\n\terr error\n}\n\nfunc (e *errWriter) Write(p []byte) (int, error) {\n\treturn 0, e.err\n}\n\nfunc TestTerminalQueryFilter_ErrorHandling(t *testing.T) {\n\tassert := assert.New(t)\n\n\texpectedErr := errors.New(\"write error\")\n\tw := &errWriter{err: expectedErr}\n\tfilter := NewTerminalQueryFilter(w)\n\n\t// Write some data that will produce output (not filtered)\n\tn, err := filter.Write([]byte(\"hello\"))\n\tassert.Equal(0, n)\n\tassert.Equal(expectedErr, err)\n}\n\nfunc TestTerminalQueryFilter_ErrorNotReturnedForFilteredContent(t *testing.T) {\n\tassert := assert.New(t)\n\n\texpectedErr := errors.New(\"write error\")\n\tw := &errWriter{err: expectedErr}\n\tfilter := NewTerminalQueryFilter(w)\n\n\t// Write a query that will be filtered (no output, so no error)\n\tn, err := filter.Write([]byte(\"\\x1b[6n\"))\n\tassert.Equal(4, n)\n\tassert.NoError(err) // No error because nothing was written to underlying writer\n}\n"
  },
  {
    "path": "io/reader.go",
    "content": "package io\n\nimport (\n\t\"context\"\n\t\"io\"\n)\n\nfunc NewContextReader(ctx context.Context, r io.Reader) io.Reader {\n\treturn contextReader{\n\t\tReader: r,\n\t\tctx:    ctx,\n\t}\n}\n\ntype contextReader struct {\n\tio.Reader\n\tctx context.Context\n}\n\ntype readResult struct {\n\tn   int\n\terr error\n}\n\nfunc (r contextReader) Read(p []byte) (n int, err error) {\n\tc := make(chan readResult, 1)\n\n\tgo func(ctx context.Context, reader io.Reader) {\n\t\t// close by the sender\n\t\tdefer close(c)\n\n\t\t// return early if context is done\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tn, err := reader.Read(p)\n\t\tc <- readResult{n, err}\n\t}(r.ctx, r.Reader)\n\n\tselect {\n\tcase rr := <-c:\n\t\treturn rr.n, rr.err\n\tcase <-r.ctx.Done():\n\t\treturn 0, r.ctx.Err()\n\t}\n}\n"
  },
  {
    "path": "io/reader_test.go",
    "content": "package io\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/go-cmp/cmp\"\n)\n\nfunc Test_ContextReader(t *testing.T) {\n\tt.Run(\"happy path\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tr := bytes.NewBufferString(\"hello1\")\n\t\tw := bytes.NewBuffer(nil)\n\n\t\t_, _ = io.Copy(w, NewContextReader(context.Background(), r))\n\t\twant := \"hello1\"\n\t\tgot := w.String()\n\t\tif diff := cmp.Diff(want, got); diff != \"\" {\n\t\t\tt.Errorf(\"want=%s got=%s:\\n%s\", want, got, diff)\n\t\t}\n\t})\n\n\tt.Run(\"pass in canceled context\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tr := readFunc(func(p []byte) (int, error) {\n\t\t\tt.Error(\"should never get here\")\n\t\t\treturn 0, nil\n\t\t})\n\t\tw := bytes.NewBuffer(nil)\n\n\t\tctx, cancel := context.WithCancel(context.Background())\n\t\tcancel()\n\t\t_, err := io.Copy(w, NewContextReader(ctx, r))\n\t\twant := context.Canceled\n\t\tgot := err\n\t\tif diff := cmp.Diff(want.Error(), got.Error()); diff != \"\" {\n\t\t\tt.Errorf(\"want=%s got=%s:\\n%s\", want, got, diff)\n\t\t}\n\t})\n\n\tt.Run(\"cancel context during copy\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tr := readFunc(func(p []byte) (int, error) {\n\t\t\ttime.Sleep(5 * time.Second) // simulate slow read\n\t\t\tt.Error(\"should never get here\")\n\t\t\treturn 0, nil\n\t\t})\n\t\tw := bytes.NewBuffer(nil)\n\n\t\tctx, cancel := context.WithCancel(context.Background())\n\t\tgo func() {\n\t\t\ttime.Sleep(1 * time.Second) // cancel ctx before any read\n\t\t\tcancel()\n\t\t}()\n\t\t_, err := io.Copy(w, NewContextReader(ctx, r))\n\t\twant := context.Canceled\n\t\tgot := err\n\t\tif diff := cmp.Diff(want.Error(), got.Error()); diff != \"\" {\n\t\t\tt.Errorf(\"want=%s got=%s:\\n%s\", want, got, diff)\n\t\t}\n\t})\n\n\tt.Run(\"cancel context after copy\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tch := make(chan string, 1)\n\t\tch <- \"hello2\" // feed with one read and then it hangs\n\t\tr := readFunc(func(p []byte) (int, error) {\n\t\t\ts := <-ch\n\t\t\treturn bytes.NewBufferString(s).Read(p)\n\t\t})\n\t\tw := bytes.NewBuffer(nil)\n\n\t\tctx, cancel := context.WithCancel(context.Background())\n\t\tgo func() {\n\t\t\ttime.Sleep(3 * time.Second) // cancel ctx after first read\n\t\t\tcancel()\n\t\t}()\n\t\t_, err := io.Copy(w, NewContextReader(ctx, r))\n\t\twant := context.Canceled\n\t\tgot := err\n\t\tif diff := cmp.Diff(want.Error(), got.Error()); diff != \"\" {\n\t\t\tt.Errorf(\"want=%s got=%s:\\n%s\", want, got, diff)\n\t\t}\n\t})\n}\n\ntype readFunc func(p []byte) (n int, err error)\n\nfunc (rf readFunc) Read(p []byte) (n int, err error) { return rf(p) }\n"
  },
  {
    "path": "io/writer.go",
    "content": "package io\n\nimport (\n\t\"io\"\n\t\"sync\"\n)\n\ntype buffer struct {\n\tmu sync.Mutex\n\n\tqueue [][]byte\n\tsize  int\n}\n\nfunc (c *buffer) Append(p []byte) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\t// remove first element if queue is full\n\tif len(c.queue) >= c.size {\n\t\tc.queue = c.queue[1:]\n\t}\n\n\tpp := make([]byte, len(p))\n\tcopy(pp, p)\n\n\tc.queue = append(c.queue, pp)\n}\n\nfunc (c *buffer) Size() int {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\treturn len(c.queue)\n}\n\nfunc (c *buffer) Data() [][]byte {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tresult := make([][]byte, len(c.queue))\n\treturn append(result, c.queue...)\n}\n\nfunc NewMultiWriter(bufferSize int, writers ...io.Writer) *MultiWriter {\n\treturn &MultiWriter{\n\t\twriters: writers,\n\t\tbuffer:  &buffer{size: bufferSize},\n\t}\n}\n\n// MultiWriter is a concurrent safe writer that allows appending/removing writers.\n// Newly appended writers get the last write to preserve last output.\ntype MultiWriter struct {\n\twriteMu sync.Mutex\n\twriters []io.Writer\n\n\tbuffer *buffer\n}\n\nfunc (t *MultiWriter) Append(writers ...io.Writer) error {\n\t// write last buffer to new writers\n\tif t.buffer.Size() > 0 {\n\t\tfor _, w := range writers {\n\t\t\tfor _, d := range t.buffer.Data() {\n\t\t\t\t_, err := w.Write(d)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tt.writeMu.Lock()\n\tdefer t.writeMu.Unlock()\n\tt.writers = append(t.writers, writers...)\n\n\treturn nil\n}\n\nfunc (t *MultiWriter) Remove(writers ...io.Writer) {\n\tt.writeMu.Lock()\n\tdefer t.writeMu.Unlock()\n\n\tfor i := len(t.writers) - 1; i > 0; i-- {\n\t\tfor _, v := range writers {\n\t\t\tif t.writers[i] == v {\n\t\t\t\tt.writers = append(t.writers[:i], t.writers[i+1:]...)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (t *MultiWriter) Write(p []byte) (n int, err error) {\n\tt.buffer.Append(p)\n\n\tt.writeMu.Lock()\n\tdefer t.writeMu.Unlock()\n\n\tfor _, w := range t.writers {\n\t\tn, err = w.Write(p)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tif n != len(p) {\n\t\t\terr = io.ErrShortWrite\n\t\t\treturn\n\t\t}\n\t}\n\n\treturn len(p), nil\n}\n"
  },
  {
    "path": "io/writer_test.go",
    "content": "package io\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_MultiWriter(t *testing.T) {\n\tassert := assert.New(t)\n\n\tw1 := bytes.NewBuffer(nil)\n\tw := NewMultiWriter(1, w1)\n\n\tr := bytes.NewBufferString(\"hello1\")\n\t_, _ = io.Copy(w, r)\n\n\tassert.Equal(\"hello1\", w1.String())\n\n\t// append w2\n\tr = bytes.NewBufferString(\"hello2\")\n\tw2 := bytes.NewBuffer(nil)\n\t_ = w.Append(w2)\n\t_, _ = io.Copy(w, r)\n\n\tassert.Equal(\"hello1hello2\", w1.String())\n\tassert.Equal(\"hello1hello2\", w2.String())\n\n\t// append w3\n\tr = bytes.NewBufferString(\"hello3\")\n\tw3 := bytes.NewBuffer(nil)\n\t_ = w.Append(w3)\n\t_, _ = io.Copy(w, r)\n\n\tassert.Equal(\"hello1hello2hello3\", w1.String())\n\tassert.Equal(\"hello1hello2hello3\", w2.String())\n\tassert.Equal(\"hello2hello3\", w3.String())\n\n\t// remove w2\n\tr = bytes.NewBufferString(\"hello4\")\n\tw.Remove(w2)\n\t_, _ = io.Copy(w, r)\n\n\tassert.Equal(\"hello1hello2hello3hello4\", w1.String())\n\tassert.Equal(\"hello1hello2hello3\", w2.String())\n\tassert.Equal(\"hello2hello3hello4\", w3.String())\n}\n"
  },
  {
    "path": "memlistener/memlistener.go",
    "content": "package memlistener\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\n\t\"google.golang.org/grpc/test/bufconn\"\n)\n\nvar (\n\terrMissingAddress = errors.New(\"missing address\")\n)\n\nconst (\n\tdefaultBufferSize = 256 * 1024\n)\n\ntype addr struct{}\n\nfunc (addr) Network() string { return \"mem\" }\nfunc (addr) String() string  { return \"mem\" }\n\ntype errListenerAlreadyExist struct {\n\taddr string\n}\n\nfunc (e errListenerAlreadyExist) Error() string {\n\treturn fmt.Sprintf(\"listener with address %s already exist\", e.addr)\n}\n\ntype errListenerNotFound struct {\n\taddr string\n}\n\nfunc (e errListenerNotFound) Error() string {\n\treturn fmt.Sprintf(\"listener with address %s not found\", e.addr)\n}\n\nfunc New() *MemoryListener {\n\treturn &MemoryListener{}\n}\n\ntype MemoryListener struct {\n\tlisteners sync.Map\n}\n\nfunc (l *MemoryListener) Listen(network, address string) (net.Listener, error) {\n\treturn l.ListenMem(network, address, defaultBufferSize)\n}\n\nfunc (l *MemoryListener) ListenMem(network, address string, sz int) (net.Listener, error) {\n\tswitch network {\n\tcase \"mem\", \"memory\":\n\tdefault:\n\t\treturn nil, &net.OpError{Op: \"listen\", Net: network, Source: nil, Addr: addr{}, Err: net.UnknownNetworkError(network)}\n\t}\n\n\tif address == \"\" {\n\t\treturn nil, &net.OpError{Op: \"listen\", Net: network, Source: nil, Addr: addr{}, Err: errMissingAddress}\n\t}\n\n\tln := &memlistener{\n\t\tListener:  bufconn.Listen(sz),\n\t\taddr:      address,\n\t\tcloseFunc: l.removeListener,\n\t}\n\tactual, loaded := l.listeners.LoadOrStore(address, ln)\n\tif loaded {\n\t\treturn nil, &net.OpError{Op: \"listen\", Net: network, Source: nil, Addr: addr{}, Err: errListenerAlreadyExist{address}}\n\t}\n\n\treturn actual.(net.Listener), nil\n}\n\nfunc (l *MemoryListener) Dial(network, address string) (net.Conn, error) {\n\tswitch network {\n\tcase \"mem\", \"memory\":\n\tdefault:\n\t\treturn nil, &net.OpError{Op: \"dial\", Net: network, Source: addr{}, Addr: addr{}, Err: net.UnknownNetworkError(network)}\n\t}\n\n\tif address == \"\" {\n\t\treturn nil, &net.OpError{Op: \"dial\", Net: network, Source: addr{}, Addr: addr{}, Err: errMissingAddress}\n\t}\n\n\tval, exist := l.listeners.Load(address)\n\tif !exist {\n\t\treturn nil, &net.OpError{Op: \"dial\", Net: network, Source: addr{}, Addr: addr{}, Err: errListenerNotFound{address}}\n\t}\n\n\tln := val.(*memlistener)\n\n\treturn ln.Dial()\n}\n\nfunc (l *MemoryListener) removeListener(address string) {\n\tl.listeners.Delete(address)\n}\n\ntype memlistener struct {\n\t*bufconn.Listener\n\taddr      string\n\tcloseFunc func(addr string)\n}\n\nfunc (m *memlistener) Close() error {\n\tdefer m.closeFunc(m.addr)\n\treturn m.Listener.Close()\n}\n"
  },
  {
    "path": "memlistener/memlistener_test.go",
    "content": "package memlistener\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n)\n\nfunc Test_MemListener_Listen(t *testing.T) {\n\tt.Parallel()\n\n\tl := New()\n\n\tsln, err := l.Listen(\"mem\", \"path_foo\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\t_ = sln.Close()\n\t}()\n\n\t// error on listener with the same address\n\t_, err = l.Listen(\"mem\", \"path_foo\")\n\twant, got := errListenerAlreadyExist{\"path_foo\"}, err\n\tif !strings.Contains(got.Error(), want.Error()) {\n\t\tt.Fatalf(\"got doesn't contain want (-want +got):\\n%s\", cmp.Diff(want.Error(), got.Error()))\n\t}\n}\n\nfunc Test_MemListener_Dial(t *testing.T) {\n\tt.Parallel()\n\n\tl := New()\n\n\t_, err := l.Dial(\"mem\", \"not_exist\")\n\twant, got := errListenerNotFound{\"not_exist\"}, err\n\tif !strings.Contains(got.Error(), want.Error()) {\n\t\tt.Fatalf(\"got doesn't contain want (-want +got):\\n%s\", cmp.Diff(want.Error(), got.Error()))\n\t}\n}\n\nfunc Test_MemListener_RemoveListener(t *testing.T) {\n\tt.Parallel()\n\n\tl := New()\n\n\tsln, err := l.Listen(\"mem\", \"path_bar\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsln2, ok := l.listeners.Load(\"path_bar\")\n\tif !ok {\n\t\tt.Fatal(\"listener path not found\")\n\t}\n\n\tif want, got := sln, sln2; want != got {\n\t\tt.Fatalf(\"listeners not equal: want=%v, got=%v\", want, got)\n\t}\n\n\tif err := sln.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t_, ok = l.listeners.Load(\"path_bar\")\n\tif ok {\n\t\tt.Fatal(\"listener path shouldn't be found\")\n\t}\n}\n"
  },
  {
    "path": "routing/encoding.go",
    "content": "package routing\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"strings\"\n)\n\nvar (\n\tErrInvalidSSHUser = fmt.Errorf(\"invalid SSH user\")\n)\n\n// Encoder defines the interface for encoding session information into SSH usernames\ntype Encoder interface {\n\t// Encode encodes a session ID and node address into an SSH username\n\tEncode(sessionID, nodeAddr string) string\n}\n\n// Decoder defines the interface for decoding SSH usernames into session information\ntype Decoder interface {\n\t// Decode decodes an SSH username into session ID and node address\n\tDecode(sshUser string) (sessionID, nodeAddr string, err error)\n}\n\n// ModeProvider defines the interface for getting the routing mode\ntype ModeProvider interface {\n\t// Mode returns the routing mode for this encoder/decoder\n\tMode() Mode\n}\n\n// EncodeDecoder defines the composite interface for encoding and decoding SSH usernames\ntype EncodeDecoder interface {\n\tEncoder\n\tDecoder\n\tModeProvider\n}\n\n// NewEncoder creates an Encoder for the specified routing mode\nfunc NewEncoder(mode Mode) Encoder {\n\treturn NewEncodeDecoder(mode)\n}\n\n// NewDecoder creates a Decoder for the specified routing mode\nfunc NewDecoder(mode Mode) Decoder {\n\treturn NewEncodeDecoder(mode)\n}\n\n// NewEncodeDecoder creates an EncodeDecoder for the specified routing mode\nfunc NewEncodeDecoder(mode Mode) EncodeDecoder {\n\tswitch mode {\n\tcase ModeEmbedded:\n\t\treturn &EmbeddedEncodeDecoder{}\n\tcase ModeConsul:\n\t\treturn &ConsulEncodeDecoder{}\n\tdefault:\n\t\treturn &EmbeddedEncodeDecoder{} // Default to embedded\n\t}\n}\n\n// EmbeddedEncodeDecoder implements EncodeDecoder for embedded routing mode\ntype EmbeddedEncodeDecoder struct{}\n\nfunc (e *EmbeddedEncodeDecoder) Encode(sessionID, nodeAddr string) string {\n\treturn sessionID + \":\" + base64.URLEncoding.EncodeToString([]byte(nodeAddr))\n}\n\nfunc (e *EmbeddedEncodeDecoder) Decode(sshUser string) (sessionID, nodeAddr string, err error) {\n\tsplit := strings.SplitN(sshUser, \":\", 2)\n\tif len(split) != 2 {\n\t\treturn \"\", \"\", ErrInvalidSSHUser\n\t}\n\n\tnodeAddrBytes, err := base64.URLEncoding.DecodeString(split[1])\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"failed to decode node address: %w\", err)\n\t}\n\n\treturn split[0], string(nodeAddrBytes), nil\n}\n\nfunc (e *EmbeddedEncodeDecoder) Mode() Mode {\n\treturn ModeEmbedded\n}\n\n// ConsulEncodeDecoder implements EncodeDecoder for Consul routing mode\ntype ConsulEncodeDecoder struct{}\n\nfunc (c *ConsulEncodeDecoder) Encode(sessionID, nodeAddr string) string {\n\treturn sessionID\n}\n\nfunc (c *ConsulEncodeDecoder) Decode(sshUser string) (sessionID, nodeAddr string, err error) {\n\tif sshUser == \"\" {\n\t\treturn \"\", \"\", ErrInvalidSSHUser\n\t}\n\n\t// In Consul mode, the SSH user is just the session ID\n\t// Handle mixed-mode scenarios: if SSH user contains \":\" (embedded format),\n\t// extract only the session ID part (before the colon) for compatibility\n\tif colonIndex := strings.Index(sshUser, \":\"); colonIndex != -1 {\n\t\tsessionID = sshUser[:colonIndex]\n\t} else {\n\t\tsessionID = sshUser\n\t}\n\n\treturn sessionID, \"\", nil\n}\n\nfunc (c *ConsulEncodeDecoder) Mode() Mode {\n\treturn ModeConsul\n}\n"
  },
  {
    "path": "routing/encoding_test.go",
    "content": "package routing\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/suite\"\n)\n\n// EncodeDecoderTestSuite tests the EncodeDecoder implementations\ntype EncodeDecoderTestSuite struct {\n\tsuite.Suite\n}\n\nfunc (suite *EncodeDecoderTestSuite) TestEmbeddedEncodeDecoder() {\n\tsessionID := \"test-session-123\"\n\tnodeAddr := \"node1.example.com:22\"\n\n\tencoder := NewEncodeDecoder(ModeEmbedded)\n\n\t// Test encoding\n\tencoded := encoder.Encode(sessionID, nodeAddr)\n\tsuite.Contains(encoded, \":\")\n\tsuite.Contains(encoded, sessionID)\n\n\t// Test decoding\n\tdecodedSessionID, decodedNodeAddr, err := encoder.Decode(encoded)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, decodedSessionID)\n\tsuite.Equal(nodeAddr, decodedNodeAddr)\n\tsuite.Equal(ModeEmbedded, encoder.Mode())\n}\n\nfunc (suite *EncodeDecoderTestSuite) TestConsulEncodeDecoder() {\n\tsessionID := \"test-session-456\"\n\n\tencoder := NewEncodeDecoder(ModeConsul)\n\n\t// Test encoding (should just return session ID)\n\tencoded := encoder.Encode(sessionID, \"any-node\")\n\tsuite.Equal(sessionID, encoded)\n\n\t// Test decoding\n\tdecodedSessionID, decodedNodeAddr, err := encoder.Decode(encoded)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, decodedSessionID)\n\tsuite.Empty(decodedNodeAddr)\n\tsuite.Equal(ModeConsul, encoder.Mode())\n}\n\nfunc (suite *EncodeDecoderTestSuite) TestEmbeddedDecodeInvalidFormats() {\n\tdecoder := NewEncodeDecoder(ModeEmbedded)\n\n\ttestCases := []struct {\n\t\tinput       string\n\t\tdescription string\n\t}{\n\t\t{\"no-colon-here\", \"no colon separator\"},\n\t\t{\"\", \"empty string\"},\n\t\t{\"session:invalid-base64===!@#$\", \"invalid base64 characters\"},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tsuite.Run(tc.description, func() {\n\t\t\t_, _, err := decoder.Decode(tc.input)\n\t\t\tsuite.Error(err)\n\t\t})\n\t}\n}\n\nfunc (suite *EncodeDecoderTestSuite) TestConsulDecodeInvalidFormats() {\n\tdecoder := NewEncodeDecoder(ModeConsul)\n\n\t// Test empty session ID\n\t_, _, err := decoder.Decode(\"\")\n\tsuite.Error(err)\n}\n\nfunc (suite *EncodeDecoderTestSuite) TestConsulDecodeBackwardCompatibility() {\n\tsessionID := \"test-session-123\"\n\tnodeAddr := \"127.0.0.1:2222\"\n\n\t// Create an embedded format SSH user (what old clients send)\n\tembeddedEncoder := NewEncodeDecoder(ModeEmbedded)\n\tembeddedSSHUser := embeddedEncoder.Encode(sessionID, nodeAddr)\n\n\t// Test that Consul decoder can handle embedded format\n\tconsulDecoder := NewEncodeDecoder(ModeConsul)\n\tdecodedSessionID, decodedNodeAddr, err := consulDecoder.Decode(embeddedSSHUser)\n\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, decodedSessionID, \"should extract session ID from embedded format\")\n\tsuite.Empty(decodedNodeAddr, \"consul decoder should return empty node address\")\n\n\t// Test that it still works with pure consul format\n\tdecodedSessionID2, decodedNodeAddr2, err2 := consulDecoder.Decode(sessionID)\n\tsuite.NoError(err2)\n\tsuite.Equal(sessionID, decodedSessionID2, \"should handle pure consul format\")\n\tsuite.Empty(decodedNodeAddr2, \"consul decoder should return empty node address\")\n}\n\n// Test suite runners\nfunc TestEncodeDecoderSuite(t *testing.T) {\n\tsuite.Run(t, new(EncodeDecoderTestSuite))\n}\n"
  },
  {
    "path": "routing/modes.go",
    "content": "package routing\n\n// Mode defines how session routing information is stored and encoded\ntype Mode string\n\nconst (\n\t// ModeEmbedded embeds node address in the session identifier (default)\n\tModeEmbedded Mode = \"embedded\"\n\t// ModeConsul looks up node address from Consul\n\tModeConsul Mode = \"consul\"\n)\n"
  },
  {
    "path": "script/changelog",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nhead=\"${1:-HEAD}\"\n\nfor sha in `git rev-list -n 100 --first-parent \"$head\"^`; do\n  previous_tag=\"$(git tag -l --points-at \"$sha\" 'v*' 2>/dev/null || true)\"\n  [ -z \"$previous_tag\" ] || break\ndone\n\nif [ -z \"$previous_tag\" ]; then\n  echo \"Couldn't detect previous version tag\" >&2\n  exit 1\nfi\n\ngit log --no-merges --format='%C(auto,green)* %s%C(auto,reset)%n%w(0,2,2)%+b' \\\n  --reverse \"${previous_tag}..${head}\"\n"
  },
  {
    "path": "script/do-install",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nfunction join { local IFS=\"$1\"; shift; echo \"$*\";  }\n\nSECRETS=($(ls -d $TF_VAR_uptermd_host_keys_dir))\n\nARRAY=()\nfor f in ${SECRETS[@]}\ndo\n  ARRAY+=(\"\\\"$(basename $f)\\\"=\\\"$(cat $f | base64 -w 0)\\\"\")\ndone\n\nHOST_KEYS=\"{\"\nHOST_KEYS+=$(join , ${ARRAY[@]})\nHOST_KEYS+=\"}\"\n\nTERRAFORM_STATES_DIR=$(PWD)/terraform_states\nmkdir -p $TERRAFORM_STATES_DIR\n\npushd  ./terraform/digitalocean > /dev/null\n\necho \"Initializing terraform...\"\nterraform init\n\necho \"Applying terraform...\"\nterraform apply \\\n  -state $TERRAFORM_STATES_DIR/digitalocean.tfstate \\\n  -var uptermd_host_keys=\"$HOST_KEYS\"\n\npopd > /dev/null\n"
  },
  {
    "path": "script/heroku-install",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nTERRAFORM_STATES_DIR=$(pwd)/terraform_states\nmkdir -p $TERRAFORM_STATES_DIR\n\npushd  ./terraform/heroku\n\necho \"Initializing terraform...\"\nterraform init\n\necho \"Applying terraform...\"\nexport TF_VAR_git_commit_sha=\"${TF_VAR_git_commit_sha:-$(git rev-parse HEAD)}\" # default version to current HEAD\nterraform apply -state $TERRAFORM_STATES_DIR/heroku.tfstate\n\npopd > /dev/null\n"
  },
  {
    "path": "script/publish-release",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nproject_name=\"owenthereal/upterm\"\ntag_name=\"${1?}\"\n[[ $tag_name == *-* ]] && pre=1 || pre=\n\nnotes=\"$(git tag --list \"$tag_name\" --format='%(contents:subject)%0a%0a%(contents:body)')\"\n\nif hub release --include-drafts | grep -q \"^${tag_name}\\$\"; then\n  hub release edit \"$tag_name\" -m \"\"\nelif [ $(wc -l <<<\"$notes\") -gt 1 ]; then\n  hub release create ${pre:+--prerelease} - \"$tag_name\" <<<\"$notes\"\nelse\n  { echo \"${project_name} ${tag_name#v}\"\n    echo\n    bin/changelog\n  } | hub release create --draft ${pre:+--prerelease} - \"$tag_name\"\nfi\n"
  },
  {
    "path": "script/publish-website",
    "content": "#!/usr/bin/env bash\n\nset -e\n\n# Cleanup function\ncleanup() {\n    if [ -d \"$tmp_dir\" ]; then\n        rm -rf \"$tmp_dir\"\n    fi\n    if [ -n \"$current_branch\" ] && [ \"$(git rev-parse --abbrev-ref HEAD)\" != \"$current_branch\" ]; then\n        git checkout \"$current_branch\" 2>/dev/null || true\n    fi\n}\ntrap cleanup EXIT\n\ncurrent_branch=$(git rev-parse --abbrev-ref HEAD)\ntmp_dir=$(mktemp -d -t upterm-XXXXXXXXXX)\nupterm_dir=${PWD}\n\npushd  $tmp_dir\nhelm package $upterm_dir/charts/uptermd && helm repo index .\ncp $upterm_dir/README.md index.md\ncp -r $upterm_dir/docs .\ncp $upterm_dir/fly.example.toml .\npopd > /dev/null\n\ngit checkout gh-pages\ncp -r $tmp_dir/* .\ncp -r $tmp_dir/.* . 2>/dev/null || true\n\ngit add .\nif git diff --staged --quiet; then\n    echo \"No changes to commit\"\nelse\n    git commit -m \"Generated website\"\n    git push origin gh-pages\nfi\n\n"
  },
  {
    "path": "script/tag-release",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nversion_file=\"cmd/upterm/command/version.go\"\n\nif git diff --exit-code >/dev/null -- \"$version_file\"; then\n  echo \"Update the version in $version_file and try again.\" >&2\n  exit 1\nfi\n\nversion=\"$(grep -w 'Version =' \"$version_file\" | cut -d'\"' -f2)\"\n\nsed -i'' \"s/appVersion: .*/appVersion: $version/g\" charts/uptermd/Chart.yaml\n\nmake docs\ngit commit -m \"Release Upterm $version\" -- \"$version_file\" \"docs/*\" \"etc/*\" \"charts/*\"\n\ngit tag \"v${version}\"\n\ngit push origin HEAD \"v${version}\"\n"
  },
  {
    "path": "server/cert.go",
    "content": "package server\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"google.golang.org/protobuf/proto\"\n)\n\nvar (\n\terrCertNotSignedByHost = fmt.Errorf(\"ssh cert not signed by host\")\n)\n\n// certClockSkewTolerance widens the user cert validity window symmetrically\n// around time.Now() so that the host's embedded sshd (which validates with\n// its own time.Now()) accepts the cert despite NTP drift between machines.\n// One minute matches step-ca's default and sits comfortably above typical\n// drift on dev environments where this fails (Docker Desktop / Rancher\n// Desktop / colima after suspend/resume). See issue #151.\nconst certClockSkewTolerance = 1 * time.Minute\n\ntype UserCertChecker struct {\n\tUserKeyFallback func(user string, key ssh.PublicKey) (ssh.PublicKey, error)\n}\n\n// Authenticate tries to pass auth request and public key from a cert.\n// If the public key is not a cert, it calls the UserKeyFallback func. Otherwise it returns an error.\nfunc (c *UserCertChecker) Authenticate(user string, key ssh.PublicKey) (*AuthRequest, ssh.PublicKey, error) {\n\tcert, ok := key.(*ssh.Certificate)\n\tif !ok {\n\t\tif c.UserKeyFallback != nil {\n\t\t\tkey, err := c.UserKeyFallback(user, key)\n\t\t\treturn nil, key, err\n\t\t}\n\n\t\treturn nil, nil, fmt.Errorf(\"public key not a cert\")\n\t}\n\n\treturn parseAuthRequestFromCert(user, cert)\n}\n\n// parseAuthRequestFromCert parses auth request and public key from a cert.\n// The public key is always the signature key of the cert.\nfunc parseAuthRequestFromCert(principal string, cert *ssh.Certificate) (*AuthRequest, ssh.PublicKey, error) {\n\tkey := cert.SignatureKey\n\n\tif cert.CertType != ssh.UserCert {\n\t\treturn nil, key, fmt.Errorf(\"ssh: cert has type %d\", cert.CertType)\n\t}\n\n\tchecker := &ssh.CertChecker{}\n\tif err := checker.CheckCert(principal, cert); err != nil {\n\t\treturn nil, key, err\n\t}\n\n\tif len(cert.Extensions) == 0 {\n\t\treturn nil, key, errCertNotSignedByHost\n\t}\n\n\text, ok := cert.Extensions[upterm.SSHCertExtension]\n\tif !ok {\n\t\treturn nil, key, errCertNotSignedByHost\n\t}\n\n\tvar auth AuthRequest\n\tif err := proto.Unmarshal([]byte(ext), &auth); err != nil {\n\t\treturn nil, key, err\n\t}\n\n\tkey, _, _, _, err := ssh.ParseAuthorizedKey(auth.AuthorizedKey)\n\tif err != nil {\n\t\treturn nil, key, fmt.Errorf(\"error parsing public key from auth request: %w\", err)\n\t}\n\n\treturn &auth, key, nil\n}\n\ntype UserCertSigner struct {\n\tSessionID   string\n\tUser        string\n\tAuthRequest *AuthRequest\n}\n\nfunc (g *UserCertSigner) SignCert(signer ssh.Signer) (ssh.Signer, error) {\n\tb, err := proto.Marshal(g.AuthRequest)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error marshaling auth request: %w\", err)\n\t}\n\n\tnow := time.Now()\n\tat := now.Add(-certClockSkewTolerance)\n\tbt := now.Add(certClockSkewTolerance)\n\tcert := &ssh.Certificate{\n\t\tKey:             signer.PublicKey(),\n\t\tCertType:        ssh.UserCert,\n\t\tKeyId:           g.SessionID,\n\t\tValidPrincipals: []string{g.User},\n\t\tValidAfter:      uint64(at.Unix()),\n\t\tValidBefore:     uint64(bt.Unix()),\n\t\tPermissions: ssh.Permissions{\n\t\t\tExtensions: map[string]string{upterm.SSHCertExtension: string(b)},\n\t\t},\n\t}\n\n\t// TODO: use different key to sign\n\tif err := cert.SignCert(rand.Reader, signer); err != nil {\n\t\treturn nil, fmt.Errorf(\"error signing host cert: %w\", err)\n\t}\n\n\tcs, err := ssh.NewCertSigner(cert, signer)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error generating host signer: %w\", err)\n\t}\n\n\treturn cs, nil\n}\n\ntype HostCertSigner struct {\n\tHostnames []string\n}\n\nfunc (s *HostCertSigner) SignCert(signer ssh.Signer) (ssh.Signer, error) {\n\tcert := &ssh.Certificate{\n\t\tKey:             signer.PublicKey(),\n\t\tCertType:        ssh.HostCert,\n\t\tKeyId:           \"uptermd\",\n\t\tValidPrincipals: s.Hostnames,\n\t\tValidBefore:     ssh.CertTimeInfinity,\n\t}\n\n\tif err := cert.SignCert(rand.Reader, signer); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ssh.NewCertSigner(cert, signer)\n}\n"
  },
  {
    "path": "server/metrics.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n)\n\ntype metricServer struct {\n\tserver *http.Server\n\tmux    sync.Mutex\n}\n\nfunc (m *metricServer) Shutdown(ctx context.Context) error {\n\tm.mux.Lock()\n\tdefer m.mux.Unlock()\n\n\tif m.server == nil {\n\t\treturn nil\n\t}\n\n\treturn m.server.Shutdown(ctx)\n}\n\nfunc (m *metricServer) ListenAndServe(addr string) error {\n\tmux := http.NewServeMux()\n\tmux.Handle(\"/metrics\", promhttp.Handler())\n\n\tm.mux.Lock()\n\tm.server = &http.Server{\n\t\tAddr:    addr,\n\t\tHandler: mux,\n\t}\n\tm.mux.Unlock()\n\n\treturn m.server.ListenAndServe()\n}\n"
  },
  {
    "path": "server/network.go",
    "content": "package server\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/owenthereal/upterm/memlistener\"\n\t\"github.com/rs/xid\"\n)\n\nvar networks networkProviders\n\nfunc init() {\n\tnetworks = []NetworkProvider{&UnixProvider{}, &MemoryProvider{}}\n}\n\ntype networkProviders []NetworkProvider\n\nfunc (n networkProviders) Get(name string) NetworkProvider {\n\tfor _, p := range n {\n\t\tif p.Name() == name {\n\t\t\treturn p\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype NetworkProvider interface {\n\tSetOpts(opts NetworkOptions) error\n\tSession() SessionDialListener\n\tSSHD() SSHDDialListener\n\tName() string\n\tOpts() string\n}\n\ntype NetworkOptions map[string]string\n\ntype SessionDialListener interface {\n\tListen(sesisonID string) (net.Listener, error)\n\tDial(sessionID string) (net.Conn, error)\n}\n\ntype SSHDDialListener interface {\n\tListen() (net.Listener, error)\n\tDial() (net.Conn, error)\n}\n\ntype MemoryProvider struct {\n\tSocketPath string\n\tmemln      *memlistener.MemoryListener\n}\n\nfunc (p *MemoryProvider) Name() string {\n\treturn \"mem\"\n}\n\nfunc (p *MemoryProvider) Opts() string {\n\treturn fmt.Sprintf(\"ssh-socket-path=%s\", p.SocketPath)\n}\n\nfunc (p *MemoryProvider) SetOpts(opts NetworkOptions) error {\n\tp.SocketPath = xid.New().String()\n\tp.memln = memlistener.New()\n\treturn nil\n}\n\nfunc (p *MemoryProvider) Session() SessionDialListener {\n\treturn &memorySessionDialListener{memln: p.memln}\n}\n\nfunc (p *MemoryProvider) SSHD() SSHDDialListener {\n\treturn &memorySSHDDialListener{socketPath: p.SocketPath, memln: p.memln}\n}\n\ntype memorySSHDDialListener struct {\n\tsocketPath string\n\tmemln      *memlistener.MemoryListener\n}\n\nfunc (l *memorySSHDDialListener) Listen() (net.Listener, error) {\n\treturn l.memln.Listen(\"mem\", l.socketPath)\n}\n\nfunc (l *memorySSHDDialListener) Dial() (net.Conn, error) {\n\treturn l.memln.Dial(\"mem\", l.socketPath)\n}\n\ntype memorySessionDialListener struct {\n\tmemln *memlistener.MemoryListener\n}\n\nfunc (d *memorySessionDialListener) Listen(sessionID string) (net.Listener, error) {\n\treturn d.memln.Listen(\"mem\", sessionID)\n}\n\nfunc (d *memorySessionDialListener) Dial(sessionID string) (net.Conn, error) {\n\treturn d.memln.Dial(\"mem\", sessionID)\n}\n\ntype UnixProvider struct {\n\tsessionSocketDir string\n\tsshdSocketPath   string\n}\n\nfunc (p *UnixProvider) Opts() string {\n\treturn fmt.Sprintf(\"session-socket-dir=%s,sshd-socket-path=%s\", p.sessionSocketDir, p.sshdSocketPath)\n}\n\nfunc (p *UnixProvider) SetOpts(opts NetworkOptions) error {\n\tvar ok bool\n\tp.sessionSocketDir, ok = opts[\"session-socket-dir\"]\n\tif !ok {\n\t\tdir, err := os.MkdirTemp(\"\", \"uptermd\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"missing \\\"session-socket-dir\\\" option for network provider %s\", p.Name())\n\t\t}\n\n\t\tp.sessionSocketDir = dir\n\t}\n\tp.sshdSocketPath, ok = opts[\"sshd-socket-path\"]\n\tif !ok {\n\t\tdir, err := os.MkdirTemp(\"\", \"uptermd\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"missing \\\"sshd-socket-path\\\" option for network provider %s\", p.Name())\n\t\t}\n\n\t\tp.sshdSocketPath = filepath.Join(dir, \"sshd.sock\")\n\t}\n\n\treturn nil\n}\n\nfunc (p *UnixProvider) Session() SessionDialListener {\n\treturn &unixSessionDialListener{SocketDir: p.sessionSocketDir}\n}\n\nfunc (p *UnixProvider) SSHD() SSHDDialListener {\n\treturn &unixSSHDDialListener{SocketPath: p.sshdSocketPath}\n}\n\nfunc (p *UnixProvider) Name() string {\n\treturn \"unix\"\n}\n\ntype unixSSHDDialListener struct {\n\tSocketPath string\n}\n\nfunc (d *unixSSHDDialListener) Listen() (net.Listener, error) {\n\treturn net.Listen(\"unix\", d.SocketPath)\n}\n\nfunc (d *unixSSHDDialListener) Dial() (net.Conn, error) {\n\treturn net.Dial(\"unix\", d.SocketPath)\n}\n\ntype unixSessionDialListener struct {\n\tSocketDir string\n}\n\nfunc (d *unixSessionDialListener) Listen(sessionID string) (net.Listener, error) {\n\treturn net.Listen(\"unix\", d.socketPath(sessionID))\n}\n\nfunc (d *unixSessionDialListener) Dial(sessionID string) (net.Conn, error) {\n\treturn net.Dial(\"unix\", d.socketPath(sessionID))\n}\n\nfunc (d *unixSessionDialListener) socketPath(sessionID string) string {\n\treturn filepath.Join(d.SocketDir, sessionID+\".sock\")\n}\n"
  },
  {
    "path": "server/server.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"log/slog\"\n\n\t\"github.com/go-kit/kit/metrics/provider\"\n\t\"github.com/oklog/run\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/owenthereal/upterm/ws\"\n\t\"github.com/pires/go-proxyproto\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\ttcpDialTimeout = 1 * time.Second\n)\n\ntype Opt struct {\n\tSSHAddr             string       `mapstructure:\"ssh-addr\"`\n\tSSHProxyProtocol    bool         `mapstructure:\"ssh-proxy-protocol\"`\n\tWSAddr              string       `mapstructure:\"ws-addr\"`\n\tNodeAddr            string       `mapstructure:\"node-addr\"`\n\tAuthorizedKeysFiles []string     `mapstructure:\"authorized-keys\"`\n\tPrivateKeys         []string     `mapstructure:\"private-key\"`\n\tHostnames           []string     `mapstructure:\"hostname\"`\n\tNetwork             string       `mapstructure:\"network\"`\n\tNetworkOpts         []string     `mapstructure:\"network-opt\"`\n\tMetricAddr          string       `mapstructure:\"metric-addr\"`\n\tDebug               bool         `mapstructure:\"debug\"`\n\tRouting             routing.Mode `mapstructure:\"routing\"`\n\tConsulURL           string       `mapstructure:\"consul-url\"`\n\tConsulSessionTTL    string       `mapstructure:\"consul-session-ttl\"`\n\tSentryDSN           string       `mapstructure:\"sentry-dsn\"`\n}\n\n// Validate validates the server configuration\nfunc (opt *Opt) Validate() error {\n\t// Basic validation\n\tif opt.SSHAddr == \"\" {\n\t\treturn fmt.Errorf(\"ssh-addr is required\")\n\t}\n\n\t// Routing-specific validation\n\troutingMode := opt.Routing\n\tif routingMode == \"\" {\n\t\troutingMode = routing.ModeEmbedded\n\t}\n\n\tswitch routingMode {\n\tcase routing.ModeConsul:\n\t\treturn opt.validateConsulConfig()\n\tcase routing.ModeEmbedded:\n\t\treturn opt.validateEmbeddedConfig()\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported routing mode: %s\", routingMode)\n\t}\n}\n\n// validateConsulConfig validates Consul-specific configuration\nfunc (opt *Opt) validateConsulConfig() error {\n\tif opt.ConsulURL == \"\" {\n\t\treturn fmt.Errorf(\"consul-url is required for consul routing mode\")\n\t}\n\n\t// Validate Consul URL format\n\tif _, err := url.Parse(opt.ConsulURL); err != nil {\n\t\treturn fmt.Errorf(\"invalid consul URL format: %w\", err)\n\t}\n\n\t// Validate TTL format if provided\n\tif opt.ConsulSessionTTL != \"\" {\n\t\tif _, err := time.ParseDuration(opt.ConsulSessionTTL); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid consul session TTL format: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// validateEmbeddedConfig validates embedded mode configuration\nfunc (opt *Opt) validateEmbeddedConfig() error {\n\t// No special validation needed for embedded mode\n\treturn nil\n}\n\nfunc Start(ctx context.Context, opt Opt, logger *slog.Logger) error {\n\t// Validate configuration upfront\n\tif err := opt.Validate(); err != nil {\n\t\treturn fmt.Errorf(\"configuration validation failed: %w\", err)\n\t}\n\n\tnetwork := networks.Get(opt.Network)\n\tif network == nil {\n\t\treturn fmt.Errorf(\"unsupported network provider %q\", opt.Network)\n\t}\n\n\topts := parseNetworkOpt(opt.NetworkOpts)\n\tif err := network.SetOpts(opts); err != nil {\n\t\treturn fmt.Errorf(\"network provider option error: %s\", err)\n\t}\n\n\tprivateKeys, err := utils.ReadFiles(opt.PrivateKeys)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tif pp := os.Getenv(\"PRIVATE_KEY\"); pp != \"\" {\n\t\tprivateKeys = append(privateKeys, []byte(pp))\n\t}\n\n\tsigners, err := utils.CreateSigners(privateKeys)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// key signers + corresponding cert signers\n\thostSigners := slices.Clone(signers)\n\tfor _, s := range signers {\n\t\ths := HostCertSigner{\n\t\t\tHostnames: opt.Hostnames,\n\t\t}\n\t\tss, err := hs.SignCert(s)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\thostSigners = append(hostSigners, ss)\n\t}\n\n\t// logger is already the parameter, just add context\n\tlogger = logger.With(\"app\", \"uptermd\", \"network\", opt.Network, \"network_opt\", opt.NetworkOpts)\n\n\tvar (\n\t\tsshln net.Listener\n\t\twsln  net.Listener\n\t)\n\n\tif opt.SSHAddr != \"\" {\n\t\tsshln, err = net.Listen(\"tcp\", opt.SSHAddr)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlogger = logger.With(\"ssh_addr\", sshln.Addr().String())\n\t\tif opt.SSHProxyProtocol {\n\t\t\t// Wrap the SSH listener with proxyproto.Listener to preserve the real client IP\n\t\t\t// when connections are coming through a TCP proxy (e.g., AWS ELB, HAProxy).\n\t\t\tsshln = &proxyproto.Listener{Listener: sshln}\n\t\t}\n\t}\n\n\tif opt.WSAddr != \"\" {\n\t\twsln, err = net.Listen(\"tcp\", opt.WSAddr)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlogger = logger.With(\"ws_addr\", wsln.Addr().String())\n\t}\n\n\t// fallback node addr to ssh addr or ws addr if empty\n\tnodeAddr := opt.NodeAddr\n\tif nodeAddr == \"\" && sshln != nil {\n\t\tnodeAddr = sshln.Addr().String()\n\t}\n\tif nodeAddr == \"\" && wsln != nil {\n\t\tnodeAddr = wsln.Addr().String()\n\t}\n\tif nodeAddr == \"\" {\n\t\treturn fmt.Errorf(\"node address can't by empty\")\n\t}\n\n\tlogger = logger.With(\"node_addr\", nodeAddr)\n\n\tvar g run.Group\n\t{\n\t\tvar mp provider.Provider\n\t\tif opt.MetricAddr == \"\" {\n\t\t\tmp = provider.NewDiscardProvider()\n\t\t} else {\n\t\t\tmp = provider.NewPrometheusProvider(\"upterm\", \"uptermd\")\n\t\t}\n\n\t\t// Determine session routing mode\n\t\tsessionRouting := opt.Routing\n\t\tif sessionRouting == \"\" {\n\t\t\tsessionRouting = routing.ModeEmbedded // Default to embedded mode\n\t\t}\n\n\t\t// Create session manager with the appropriate routing mode\n\t\tvar sessionManager *SessionManager\n\t\tswitch sessionRouting {\n\t\tcase routing.ModeConsul:\n\t\t\tvar consulTTL time.Duration\n\t\t\tif opt.ConsulSessionTTL != \"\" {\n\t\t\t\tif parsedTTL, err := time.ParseDuration(opt.ConsulSessionTTL); err == nil {\n\t\t\t\t\tconsulTTL = parsedTTL\n\t\t\t\t} else {\n\t\t\t\t\tlogger.Warn(\"invalid consul session TTL, using default\", \"error\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Parse Consul address as URL\n\t\t\tconsulURL, err := url.Parse(opt.ConsulURL)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid consul address URL: %w\", err)\n\t\t\t}\n\n\t\t\tsm, err := NewSessionManager(routing.ModeConsul,\n\t\t\t\tWithSessionManagerLogger(logger.With(\"component\", \"session-manager\")),\n\t\t\t\tWithSessionManagerConsulURL(consulURL),\n\t\t\t\tWithSessionManagerConsulTTL(consulTTL))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to create consul session manager: %w\", err)\n\t\t\t}\n\t\t\tsessionManager = sm\n\n\t\t\tlogger.Info(\"using consul session store for routing\")\n\t\tcase routing.ModeEmbedded:\n\t\t\tsm, err := NewSessionManager(routing.ModeEmbedded,\n\t\t\t\tWithSessionManagerLogger(logger.With(\"component\", \"session-manager\")))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to create embedded session manager: %w\", err)\n\t\t\t}\n\t\t\tsessionManager = sm\n\t\t\tlogger.Info(\"using embedded session routing (in-memory session store)\")\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"invalid session routing mode: %s (supported: %s, %s)\", sessionRouting, routing.ModeEmbedded, routing.ModeConsul)\n\t\t}\n\n\t\ts := &Server{\n\t\t\tNodeAddr:            nodeAddr,\n\t\t\tAuthorizedKeysFiles: opt.AuthorizedKeysFiles,\n\t\t\tHostSigners:         hostSigners,\n\t\t\tSigners:             signers,\n\t\t\tNetworkProvider:     network,\n\t\t\tSessionManager:      sessionManager,\n\t\t\tLogger:              logger.With(\"component\", \"server\"),\n\t\t\tMetricsProvider:     mp,\n\t\t}\n\t\tg.Add(func() error {\n\t\t\treturn s.ServeWithContext(ctx, sshln, wsln)\n\t\t}, func(err error) {\n\t\t\tif err := s.Shutdown(); err != nil {\n\t\t\t\tlogger.Error(\"error during server shutdown\", \"error\", err)\n\t\t\t}\n\t\t})\n\t}\n\t{\n\t\tif opt.MetricAddr != \"\" {\n\t\t\tlogger = logger.With(\"metric_addr\", opt.MetricAddr)\n\n\t\t\tm := &metricServer{}\n\t\t\tg.Add(func() error {\n\t\t\t\treturn m.ListenAndServe(opt.MetricAddr)\n\t\t\t}, func(err error) {\n\t\t\t\t_ = m.Shutdown(ctx)\n\t\t\t})\n\t\t}\n\t}\n\n\tlogger.Info(\"starting server\")\n\tdefer logger.Info(\"shutting down server\")\n\n\treturn g.Run()\n}\n\nfunc parseNetworkOpt(opts []string) NetworkOptions {\n\tresult := make(NetworkOptions)\n\tfor _, opt := range opts {\n\t\tsplit := strings.SplitN(opt, \"=\", 2)\n\t\tresult[split[0]] = split[1]\n\t}\n\n\treturn result\n}\n\ntype Server struct {\n\tNodeAddr            string\n\tAuthorizedKeysFiles []string\n\tHostSigners         []ssh.Signer\n\tSigners             []ssh.Signer\n\tNetworkProvider     NetworkProvider\n\tMetricsProvider     provider.Provider\n\tSessionManager      *SessionManager\n\tLogger              *slog.Logger\n\n\tsshln net.Listener\n\twsln  net.Listener\n\n\tmux    sync.Mutex\n\tctx    context.Context\n\tcancel func()\n}\n\nfunc (s *Server) Shutdown() error {\n\ts.mux.Lock()\n\tdefer s.mux.Unlock()\n\n\tvar err error\n\n\t// Stop accepting new connections first\n\tif s.sshln != nil {\n\t\tif sshErr := s.sshln.Close(); sshErr != nil {\n\t\t\terr = errors.Join(err, fmt.Errorf(\"ssh listener close: %w\", sshErr))\n\t\t}\n\t}\n\n\tif s.wsln != nil {\n\t\tif wsErr := s.wsln.Close(); wsErr != nil {\n\t\t\terr = errors.Join(err, fmt.Errorf(\"websocket listener close: %w\", wsErr))\n\t\t}\n\t}\n\n\t// Cancel context to signal graceful shutdown\n\tif s.cancel != nil {\n\t\ts.cancel()\n\t}\n\n\t// Clean up sessions created by this node\n\tif sessionErr := s.SessionManager.Shutdown(s.NodeAddr); sessionErr != nil {\n\t\ts.Logger.Error(\"failed to cleanup sessions during shutdown\", \"error\", sessionErr)\n\t\terr = errors.Join(err, fmt.Errorf(\"session cleanup: %w\", sessionErr))\n\t} else {\n\t\ts.Logger.Debug(\"cleaned up sessions during shutdown\")\n\t}\n\n\tif err == nil {\n\t\ts.Logger.Debug(\"server shutdown completed\")\n\t}\n\n\treturn err\n}\n\nfunc (s *Server) ServeWithContext(ctx context.Context, sshln net.Listener, wsln net.Listener) error {\n\ts.mux.Lock()\n\ts.sshln, s.wsln = sshln, wsln\n\ts.ctx, s.cancel = context.WithCancel(ctx)\n\ts.mux.Unlock()\n\n\tsshdDialListener := s.NetworkProvider.SSHD()\n\tsessionDialListener := s.NetworkProvider.Session()\n\n\tvar g run.Group\n\t{\n\t\tg.Add(func() error {\n\t\t\t<-s.ctx.Done()\n\t\t\treturn s.ctx.Err()\n\t\t}, func(err error) {\n\t\t\ts.cancel()\n\t\t})\n\t}\n\t{\n\t\tif sshln != nil {\n\t\t\tcd := sidewayConnDialer{\n\t\t\t\tNodeAddr:            s.NodeAddr,\n\t\t\t\tSSHDDialListener:    sshdDialListener,\n\t\t\t\tSessionDialListener: sessionDialListener,\n\t\t\t\tNeighbourDialer:     tcpConnDialer{},\n\t\t\t\tLogger:              s.Logger.With(\"component\", \"ssh-conn-dialer\"),\n\t\t\t}\n\t\t\tsp := &sshProxy{\n\t\t\t\tHostSigners:         s.HostSigners,\n\t\t\t\tSigners:             s.Signers,\n\t\t\t\tNodeAddr:            s.NodeAddr,\n\t\t\t\tAuthorizedKeysFiles: s.AuthorizedKeysFiles,\n\t\t\t\tConnDialer:          cd,\n\t\t\t\tSessionManager:      s.SessionManager,\n\t\t\t\tLogger:              s.Logger.With(\"component\", \"ssh-proxy\"),\n\t\t\t\tMetricsProvider:     s.MetricsProvider,\n\t\t\t}\n\t\t\tg.Add(func() error {\n\t\t\t\treturn sp.Serve(sshln)\n\t\t\t}, func(err error) {\n\t\t\t\tif err := sp.Shutdown(); err != nil {\n\t\t\t\t\tsp.Logger.Error(\"error during ssh proxy shutdown\", \"error\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n\t{\n\t\tif wsln != nil {\n\t\t\tvar cd connDialer\n\t\t\tif sshln == nil {\n\t\t\t\tcd = sidewayConnDialer{\n\t\t\t\t\tNodeAddr:            s.NodeAddr,\n\t\t\t\t\tSSHDDialListener:    sshdDialListener,\n\t\t\t\t\tSessionDialListener: sessionDialListener,\n\t\t\t\t\tNeighbourDialer:     wsConnDialer{},\n\t\t\t\t\tLogger:              s.Logger.With(\"component\", \"ws-conn-dialer\"),\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// If sshln is not nil, always dial to SSHProxy.\n\t\t\t\t// So Host/Client -> WSProxy -> SSHProxy -> sshd/Session\n\t\t\t\t// This makes sure that SSHProxy terminates all SSH requests\n\t\t\t\t// which provides a consistent authentication mechanism.\n\t\t\t\tcd = sshProxyDialer{\n\t\t\t\t\tsshProxyAddr: sshln.Addr().String(),\n\t\t\t\t\tLogger:       s.Logger.With(\"component\", \"ws-sshproxy-dialer\"),\n\t\t\t\t}\n\t\t\t}\n\t\t\tws := &webSocketProxy{\n\t\t\t\tConnDialer:     cd,\n\t\t\t\tSessionManager: s.SessionManager,\n\t\t\t\tLogger:         s.Logger.With(\"component\", \"ws-proxy\"),\n\t\t\t}\n\t\t\tg.Add(func() error {\n\t\t\t\treturn ws.Serve(wsln)\n\t\t\t}, func(err error) {\n\t\t\t\tif err := ws.Shutdown(); err != nil {\n\t\t\t\t\tws.Logger.Error(\"error during websocket proxy shutdown\", \"error\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n\t{\n\t\tln, err := sshdDialListener.Listen()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsshd := sshd{\n\t\t\tSessionManager:      s.SessionManager,\n\t\t\tHostSigners:         s.HostSigners, // TODO: use different host keys\n\t\t\tNodeAddr:            s.NodeAddr,\n\t\t\tSessionDialListener: sessionDialListener,\n\t\t\tLogger:              s.Logger.With(\"component\", \"sshd\"),\n\t\t}\n\t\tg.Add(func() error {\n\t\t\treturn sshd.Serve(ln)\n\t\t}, func(err error) {\n\t\t\tif err := sshd.Shutdown(); err != nil {\n\t\t\t\tsshd.Logger.Error(\"error during sshd shutdown\", \"error\", err)\n\t\t\t}\n\t\t})\n\t}\n\n\treturn g.Run()\n}\n\ntype connDialer interface {\n\tDial(id *api.Identifier) (net.Conn, error)\n}\n\ntype sshProxyDialer struct {\n\tsshProxyAddr string\n\tLogger       *slog.Logger\n}\n\nfunc (d sshProxyDialer) Dial(id *api.Identifier) (net.Conn, error) {\n\t// If it's a host request, dial to SSHProxy in the same node.\n\t// Otherwise, dial to the specified SSHProxy.\n\tif id.Type == api.Identifier_HOST {\n\t\td.Logger.With(\"host\", id.Id, \"sshproxy_addr\", d.sshProxyAddr).Info(\"dialing sshproxy sshd\")\n\t\treturn net.DialTimeout(\"tcp\", d.sshProxyAddr, tcpDialTimeout)\n\t}\n\n\td.Logger.With(\"session\", id.Id, \"sshproxy_addr\", d.sshProxyAddr, \"addr\", id.NodeAddr).Info(\"dialing sshproxy session\")\n\treturn net.DialTimeout(\"tcp\", id.NodeAddr, tcpDialTimeout)\n}\n\ntype tcpConnDialer struct {\n}\n\nfunc (d tcpConnDialer) Dial(id *api.Identifier) (net.Conn, error) {\n\treturn net.DialTimeout(\"tcp\", id.NodeAddr, tcpDialTimeout)\n}\n\ntype wsConnDialer struct {\n}\n\nfunc (d wsConnDialer) Dial(id *api.Identifier) (net.Conn, error) {\n\tu, err := url.Parse(\"ws://\" + id.NodeAddr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tencodedNodeAddr := base64.StdEncoding.EncodeToString([]byte(id.NodeAddr))\n\tu.User = url.UserPassword(id.Id, encodedNodeAddr)\n\n\treturn ws.NewWSConn(u, true)\n}\n\ntype sidewayConnDialer struct {\n\tNodeAddr            string\n\tSSHDDialListener    SSHDDialListener\n\tSessionDialListener SessionDialListener\n\tNeighbourDialer     connDialer\n\tLogger              *slog.Logger\n}\n\nfunc (cd sidewayConnDialer) Dial(id *api.Identifier) (net.Conn, error) {\n\tlogger := cd.Logger.With(\"session\", id.Id, \"node\", cd.NodeAddr, \"type\", api.Identifier_Type_name[int32(id.Type)])\n\n\tif id.Type == api.Identifier_HOST {\n\t\tlogger.Info(\"dialing sshd\")\n\t\treturn cd.SSHDDialListener.Dial()\n\t} else {\n\t\thost, port, ee := net.SplitHostPort(id.NodeAddr)\n\t\tif ee != nil {\n\t\t\treturn nil, fmt.Errorf(\"host address %s is malformed: %w\", id.NodeAddr, ee)\n\t\t}\n\t\taddr := net.JoinHostPort(host, port)\n\t\tlogger = logger.With(\"addr\", addr)\n\n\t\t// if current node is matching, dial to session.\n\t\t// Otherwise, dial to neighbour node\n\t\tif cd.NodeAddr == addr {\n\t\t\tlogger.Info(\"dialing session\")\n\t\t\treturn cd.SessionDialListener.Dial(id.Id)\n\t\t}\n\n\t\tlogger.Info(\"dialing neighbour\")\n\t\treturn cd.NeighbourDialer.Dial(id)\n\t}\n}\n"
  },
  {
    "path": "server/server.pb.go",
    "content": "// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n// \tprotoc-gen-go v1.28.1\n// \tprotoc        v3.21.6\n// source: server.proto\n\npackage server\n\nimport (\n\tprotoreflect \"google.golang.org/protobuf/reflect/protoreflect\"\n\tprotoimpl \"google.golang.org/protobuf/runtime/protoimpl\"\n\treflect \"reflect\"\n\tsync \"sync\"\n)\n\nconst (\n\t// Verify that this generated code is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)\n\t// Verify that runtime/protoimpl is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)\n)\n\ntype CreateSessionRequest struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tHostUser             string   `protobuf:\"bytes,1,opt,name=hostUser,proto3\" json:\"hostUser,omitempty\"`\n\tHostPublicKeys       [][]byte `protobuf:\"bytes,2,rep,name=hostPublicKeys,proto3\" json:\"hostPublicKeys,omitempty\"`\n\tClientAuthorizedKeys [][]byte `protobuf:\"bytes,3,rep,name=clientAuthorizedKeys,proto3\" json:\"clientAuthorizedKeys,omitempty\"`\n}\n\nfunc (x *CreateSessionRequest) Reset() {\n\t*x = CreateSessionRequest{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_server_proto_msgTypes[0]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *CreateSessionRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*CreateSessionRequest) ProtoMessage() {}\n\nfunc (x *CreateSessionRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_server_proto_msgTypes[0]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead.\nfunc (*CreateSessionRequest) Descriptor() ([]byte, []int) {\n\treturn file_server_proto_rawDescGZIP(), []int{0}\n}\n\nfunc (x *CreateSessionRequest) GetHostUser() string {\n\tif x != nil {\n\t\treturn x.HostUser\n\t}\n\treturn \"\"\n}\n\nfunc (x *CreateSessionRequest) GetHostPublicKeys() [][]byte {\n\tif x != nil {\n\t\treturn x.HostPublicKeys\n\t}\n\treturn nil\n}\n\nfunc (x *CreateSessionRequest) GetClientAuthorizedKeys() [][]byte {\n\tif x != nil {\n\t\treturn x.ClientAuthorizedKeys\n\t}\n\treturn nil\n}\n\ntype CreateSessionResponse struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tSessionID string `protobuf:\"bytes,1,opt,name=sessionID,proto3\" json:\"sessionID,omitempty\"`\n\tNodeAddr  string `protobuf:\"bytes,2,opt,name=nodeAddr,proto3\" json:\"nodeAddr,omitempty\"`\n\tSshUser   string `protobuf:\"bytes,3,opt,name=ssh_user,json=sshUser,proto3\" json:\"ssh_user,omitempty\"` // SSH username for client connections\n}\n\nfunc (x *CreateSessionResponse) Reset() {\n\t*x = CreateSessionResponse{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_server_proto_msgTypes[1]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *CreateSessionResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*CreateSessionResponse) ProtoMessage() {}\n\nfunc (x *CreateSessionResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_server_proto_msgTypes[1]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead.\nfunc (*CreateSessionResponse) Descriptor() ([]byte, []int) {\n\treturn file_server_proto_rawDescGZIP(), []int{1}\n}\n\nfunc (x *CreateSessionResponse) GetSessionID() string {\n\tif x != nil {\n\t\treturn x.SessionID\n\t}\n\treturn \"\"\n}\n\nfunc (x *CreateSessionResponse) GetNodeAddr() string {\n\tif x != nil {\n\t\treturn x.NodeAddr\n\t}\n\treturn \"\"\n}\n\nfunc (x *CreateSessionResponse) GetSshUser() string {\n\tif x != nil {\n\t\treturn x.SshUser\n\t}\n\treturn \"\"\n}\n\ntype AuthRequest struct {\n\tstate         protoimpl.MessageState\n\tsizeCache     protoimpl.SizeCache\n\tunknownFields protoimpl.UnknownFields\n\n\tClientVersion string `protobuf:\"bytes,1,opt,name=client_version,json=clientVersion,proto3\" json:\"client_version,omitempty\"`\n\tRemoteAddr    string `protobuf:\"bytes,2,opt,name=remote_addr,json=remoteAddr,proto3\" json:\"remote_addr,omitempty\"`\n\tAuthorizedKey []byte `protobuf:\"bytes,3,opt,name=authorized_key,json=authorizedKey,proto3\" json:\"authorized_key,omitempty\"`\n}\n\nfunc (x *AuthRequest) Reset() {\n\t*x = AuthRequest{}\n\tif protoimpl.UnsafeEnabled {\n\t\tmi := &file_server_proto_msgTypes[2]\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tms.StoreMessageInfo(mi)\n\t}\n}\n\nfunc (x *AuthRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*AuthRequest) ProtoMessage() {}\n\nfunc (x *AuthRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_server_proto_msgTypes[2]\n\tif protoimpl.UnsafeEnabled && x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use AuthRequest.ProtoReflect.Descriptor instead.\nfunc (*AuthRequest) Descriptor() ([]byte, []int) {\n\treturn file_server_proto_rawDescGZIP(), []int{2}\n}\n\nfunc (x *AuthRequest) GetClientVersion() string {\n\tif x != nil {\n\t\treturn x.ClientVersion\n\t}\n\treturn \"\"\n}\n\nfunc (x *AuthRequest) GetRemoteAddr() string {\n\tif x != nil {\n\t\treturn x.RemoteAddr\n\t}\n\treturn \"\"\n}\n\nfunc (x *AuthRequest) GetAuthorizedKey() []byte {\n\tif x != nil {\n\t\treturn x.AuthorizedKey\n\t}\n\treturn nil\n}\n\nvar File_server_proto protoreflect.FileDescriptor\n\nvar file_server_proto_rawDesc = []byte{\n\t0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06,\n\t0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x22, 0x8e, 0x01, 0x0a, 0x14, 0x43, 0x72, 0x65, 0x61, 0x74,\n\t0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,\n\t0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,\n\t0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x68,\n\t0x6f, 0x73, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x02, 0x20,\n\t0x03, 0x28, 0x0c, 0x52, 0x0e, 0x68, 0x6f, 0x73, 0x74, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b,\n\t0x65, 0x79, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x75, 0x74,\n\t0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,\n\t0x0c, 0x52, 0x14, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,\n\t0x7a, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x73, 0x22, 0x6c, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74,\n\t0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,\n\t0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20,\n\t0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x1a,\n\t0x0a, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x41, 0x64, 0x64, 0x72, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x73,\n\t0x68, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x73,\n\t0x68, 0x55, 0x73, 0x65, 0x72, 0x22, 0x7c, 0x0a, 0x0b, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71,\n\t0x75, 0x65, 0x73, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x76,\n\t0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6c,\n\t0x69, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x72,\n\t0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,\n\t0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x12, 0x25, 0x0a, 0x0e,\n\t0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03,\n\t0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64,\n\t0x4b, 0x65, 0x79, 0x42, 0x26, 0x5a, 0x24, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f,\n\t0x6d, 0x2f, 0x6f, 0x77, 0x65, 0x6e, 0x74, 0x68, 0x65, 0x72, 0x65, 0x61, 0x6c, 0x2f, 0x75, 0x70,\n\t0x74, 0x65, 0x72, 0x6d, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f,\n\t0x74, 0x6f, 0x33,\n}\n\nvar (\n\tfile_server_proto_rawDescOnce sync.Once\n\tfile_server_proto_rawDescData = file_server_proto_rawDesc\n)\n\nfunc file_server_proto_rawDescGZIP() []byte {\n\tfile_server_proto_rawDescOnce.Do(func() {\n\t\tfile_server_proto_rawDescData = protoimpl.X.CompressGZIP(file_server_proto_rawDescData)\n\t})\n\treturn file_server_proto_rawDescData\n}\n\nvar file_server_proto_msgTypes = make([]protoimpl.MessageInfo, 3)\nvar file_server_proto_goTypes = []interface{}{\n\t(*CreateSessionRequest)(nil),  // 0: server.CreateSessionRequest\n\t(*CreateSessionResponse)(nil), // 1: server.CreateSessionResponse\n\t(*AuthRequest)(nil),           // 2: server.AuthRequest\n}\nvar file_server_proto_depIdxs = []int32{\n\t0, // [0:0] is the sub-list for method output_type\n\t0, // [0:0] is the sub-list for method input_type\n\t0, // [0:0] is the sub-list for extension type_name\n\t0, // [0:0] is the sub-list for extension extendee\n\t0, // [0:0] is the sub-list for field type_name\n}\n\nfunc init() { file_server_proto_init() }\nfunc file_server_proto_init() {\n\tif File_server_proto != nil {\n\t\treturn\n\t}\n\tif !protoimpl.UnsafeEnabled {\n\t\tfile_server_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*CreateSessionRequest); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_server_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*CreateSessionResponse); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tfile_server_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {\n\t\t\tswitch v := v.(*AuthRequest); i {\n\t\t\tcase 0:\n\t\t\t\treturn &v.state\n\t\t\tcase 1:\n\t\t\t\treturn &v.sizeCache\n\t\t\tcase 2:\n\t\t\t\treturn &v.unknownFields\n\t\t\tdefault:\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n\ttype x struct{}\n\tout := protoimpl.TypeBuilder{\n\t\tFile: protoimpl.DescBuilder{\n\t\t\tGoPackagePath: reflect.TypeOf(x{}).PkgPath(),\n\t\t\tRawDescriptor: file_server_proto_rawDesc,\n\t\t\tNumEnums:      0,\n\t\t\tNumMessages:   3,\n\t\t\tNumExtensions: 0,\n\t\t\tNumServices:   0,\n\t\t},\n\t\tGoTypes:           file_server_proto_goTypes,\n\t\tDependencyIndexes: file_server_proto_depIdxs,\n\t\tMessageInfos:      file_server_proto_msgTypes,\n\t}.Build()\n\tFile_server_proto = out.File\n\tfile_server_proto_rawDesc = nil\n\tfile_server_proto_goTypes = nil\n\tfile_server_proto_depIdxs = nil\n}\n"
  },
  {
    "path": "server/server.proto",
    "content": "syntax = \"proto3\";\n\npackage server;\n\noption go_package = \"github.com/owenthereal/upterm/server\";\n\nmessage CreateSessionRequest {\n  string hostUser = 1;\n  repeated bytes hostPublicKeys = 2;\n  repeated bytes clientAuthorizedKeys = 3;\n}\n\nmessage CreateSessionResponse {\n  string sessionID = 1;\n  string nodeAddr = 2;\n  string ssh_user = 3; // SSH username for client connections\n}\n\nmessage AuthRequest {\n  string client_version = 1;\n  string remote_addr = 2;\n  bytes authorized_key = 3;\n}\n"
  },
  {
    "path": "server/session.go",
    "content": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"log/slog\"\n\n\t\"github.com/avast/retry-go/v4\"\n\t\"github.com/hashicorp/consul/api\"\n\t\"github.com/hashicorp/consul/api/watch\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// ErrSessionNotFound represents a non-retryable session not found error\ntype ErrSessionNotFound struct {\n\tSessionID string\n}\n\nfunc (e *ErrSessionNotFound) Error() string {\n\treturn fmt.Sprintf(\"session %s not found\", e.SessionID)\n}\n\nconst (\n\tDefaultSessionTTL    = 30 * time.Minute       // Default TTL for session data in Consul\n\tDefaultConsulTimeout = 5 * time.Second        // Default timeout for Consul operations\n\tDefaultWatchTimeout  = 10 * time.Minute       // Default timeout for Consul watch operations (long-polling)\n\tDefaultMaxRetries    = 3                      // Default number of retries for Consul operations\n\tDefaultRetryDelay    = 100 * time.Millisecond // Default delay between retries\n\tDefaultKeyPrefix     = \"uptermd\"              // Default key prefix for Consul storage\n\tUnusedNodeAddress    = \"localhost\"            // Placeholder address for node registration (not used but required by Consul)\n)\n\n// Session represents the complete session information\ntype Session struct {\n\tID                   string\n\tNodeAddr             string\n\tHostUser             string\n\tHostPublicKeys       []ssh.PublicKey\n\tClientAuthorizedKeys []ssh.PublicKey\n}\n\n// MarshalJSON implements custom JSON marshaling for Session\nfunc (s *Session) MarshalJSON() ([]byte, error) {\n\ttype sessionJSON struct {\n\t\tID                   string\n\t\tNodeAddr             string\n\t\tHostUser             string\n\t\tHostPublicKeys       [][]byte\n\t\tClientAuthorizedKeys [][]byte\n\t}\n\n\tvar hostKeys [][]byte\n\tfor _, key := range s.HostPublicKeys {\n\t\thostKeys = append(hostKeys, ssh.MarshalAuthorizedKey(key))\n\t}\n\n\tvar clientKeys [][]byte\n\tfor _, key := range s.ClientAuthorizedKeys {\n\t\tclientKeys = append(clientKeys, ssh.MarshalAuthorizedKey(key))\n\t}\n\n\treturn json.Marshal(sessionJSON{\n\t\tID:                   s.ID,\n\t\tNodeAddr:             s.NodeAddr,\n\t\tHostUser:             s.HostUser,\n\t\tHostPublicKeys:       hostKeys,\n\t\tClientAuthorizedKeys: clientKeys,\n\t})\n}\n\n// UnmarshalJSON implements custom JSON unmarshaling for Session\nfunc (s *Session) UnmarshalJSON(data []byte) error {\n\ttype sessionJSON struct {\n\t\tID                   string\n\t\tNodeAddr             string\n\t\tHostUser             string\n\t\tHostPublicKeys       [][]byte\n\t\tClientAuthorizedKeys [][]byte\n\t}\n\n\tvar temp sessionJSON\n\tif err := json.Unmarshal(data, &temp); err != nil {\n\t\treturn err\n\t}\n\n\ts.ID = temp.ID\n\ts.NodeAddr = temp.NodeAddr\n\ts.HostUser = temp.HostUser\n\n\t// Parse host public keys\n\tfor _, keyBytes := range temp.HostPublicKeys {\n\t\tkey, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse host public key: %w\", err)\n\t\t}\n\t\ts.HostPublicKeys = append(s.HostPublicKeys, key)\n\t}\n\n\t// Parse client authorized keys\n\tfor _, keyBytes := range temp.ClientAuthorizedKeys {\n\t\tkey, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse client authorized key: %w\", err)\n\t\t}\n\t\ts.ClientAuthorizedKeys = append(s.ClientAuthorizedKeys, key)\n\t}\n\n\treturn nil\n}\n\n// IsClientKeyAllowed checks if a client key is authorized for this session\nfunc (s *Session) IsClientKeyAllowed(key ssh.PublicKey) bool {\n\tif len(s.ClientAuthorizedKeys) == 0 {\n\t\treturn true\n\t}\n\n\tfor _, k := range s.ClientAuthorizedKeys {\n\t\tif utils.KeysEqual(k, key) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// SessionStore defines the interface for session storage\ntype SessionStore interface {\n\t// Store complete session data\n\tStore(session *Session) error\n\t// Get complete session data\n\tGet(sessionID string) (*Session, error)\n\t// Delete session data\n\tDelete(sessionID string) error\n\t// BatchDelete multiple sessions efficiently\n\tBatchDelete(sessionIDs []string) error\n\t// List all sessions (for cleanup and management)\n\tList() ([]*Session, error)\n\t// Close cleans up resources and stops background processes\n\tClose() error\n}\n\n// sessionCache provides thread-safe in-memory caching for sessions\ntype sessionCache struct {\n\tsessions map[string]*Session\n\tmutex    sync.RWMutex\n\tlogger   *slog.Logger\n}\n\n// newSessionCache creates a new session cache\nfunc newSessionCache(logger *slog.Logger) *sessionCache {\n\treturn &sessionCache{\n\t\tsessions: make(map[string]*Session),\n\t\tlogger:   logger,\n\t}\n}\n\n// Get retrieves a session from cache\nfunc (c *sessionCache) Get(sessionID string) (*Session, bool) {\n\tc.mutex.RLock()\n\tdefer c.mutex.RUnlock()\n\n\tsession, exists := c.sessions[sessionID]\n\treturn session, exists\n}\n\n// Has checks if a session exists in cache without retrieving it (useful for testing)\nfunc (c *sessionCache) Has(sessionID string) bool {\n\tc.mutex.RLock()\n\tdefer c.mutex.RUnlock()\n\n\t_, exists := c.sessions[sessionID]\n\treturn exists\n}\n\n// Set stores a session in cache\nfunc (c *sessionCache) Set(sessionID string, session *Session) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tc.sessions[sessionID] = session\n\tc.logger.Debug(\"cached session\", \"session\", sessionID)\n}\n\n// Delete removes a session from cache\nfunc (c *sessionCache) Delete(sessionID string) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tdelete(c.sessions, sessionID)\n\tc.logger.Debug(\"removed session from cache\", \"session\", sessionID)\n}\n\n// BatchDelete removes multiple sessions from cache\nfunc (c *sessionCache) BatchDelete(sessionIDs []string) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tfor _, sessionID := range sessionIDs {\n\t\tdelete(c.sessions, sessionID)\n\t}\n\tc.logger.Debug(\"batch removed sessions from cache\", \"count\", len(sessionIDs))\n}\n\n// ReplaceAll atomically replaces all sessions in cache\nfunc (c *sessionCache) ReplaceAll(newSessions map[string]*Session) (added, updated, deleted int) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\t// Calculate changes for logging\n\tfor sessionID, newSession := range newSessions {\n\t\tif oldSession, exists := c.sessions[sessionID]; exists {\n\t\t\tif !reflect.DeepEqual(oldSession, newSession) {\n\t\t\t\tupdated++\n\t\t\t}\n\t\t} else {\n\t\t\tadded++\n\t\t}\n\t}\n\n\t// Count deleted sessions\n\tfor sessionID := range c.sessions {\n\t\tif _, exists := newSessions[sessionID]; !exists {\n\t\t\tdeleted++\n\t\t}\n\t}\n\n\t// Replace the entire session map atomically\n\tc.sessions = newSessions\n\n\tif added > 0 || updated > 0 || deleted > 0 {\n\t\tc.logger.Info(\"updated session cache\", \"total\", len(newSessions), \"added\", added, \"updated\", updated, \"deleted\", deleted)\n\t}\n\n\treturn added, updated, deleted\n}\n\n// consulSessionStore implements SessionStore using Consul KV with hybrid read-through cache\ntype consulSessionStore struct {\n\tclient    *api.Client\n\tlogger    *slog.Logger\n\tttl       time.Duration\n\tkeyPrefix string\n\t// Hybrid cache for instant lookups with fallback to Consul\n\tcache *sessionCache\n\t// Watch management\n\twatchPlan *watch.Plan\n}\n\n// newConsulSessionStore creates a new ConsulSessionStore\nfunc newConsulSessionStore(consulURL *url.URL, ttl time.Duration, logger *slog.Logger) (*consulSessionStore, error) {\n\tconfig := api.DefaultConfig()\n\tconfig.Address = consulURL.Host\n\tconfig.Scheme = consulURL.Scheme\n\tconfig.HttpClient = &http.Client{\n\t\tTimeout: DefaultConsulTimeout,\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        100,\n\t\t\tMaxIdleConnsPerHost: 20,\n\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t\tDisableKeepAlives:   false,\n\t\t},\n\t}\n\tif u := consulURL.User; u != nil {\n\t\tconfig.Token, _ = u.Password()\n\t}\n\n\tclient, err := api.NewClient(config)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create consul client: %w\", err)\n\t}\n\n\tvar keyPrefix string\n\tif v := strings.TrimPrefix(consulURL.Path, \"/\"); v != \"\" {\n\t\tkeyPrefix = v\n\t} else {\n\t\tkeyPrefix = DefaultKeyPrefix\n\t}\n\n\tif ttl == 0 {\n\t\tttl = DefaultSessionTTL\n\t}\n\n\tstore := &consulSessionStore{\n\t\tclient:    client,\n\t\tlogger:    logger,\n\t\tttl:       ttl,\n\t\tkeyPrefix: keyPrefix,\n\t\tcache:     newSessionCache(logger),\n\t}\n\n\t// Register the node with Consul\n\tif err := store.registerNode(); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to register node with consul: %w\", err)\n\t}\n\n\t// Initialize session replication by starting the watch\n\tif err := store.startSessionWatch(config); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to start session watch: %w\", err)\n\t}\n\n\treturn store, nil\n}\n\n// Store complete session data in Consul\nfunc (c *consulSessionStore) Store(session *Session) error {\n\tif session == nil {\n\t\treturn fmt.Errorf(\"session cannot be nil\")\n\t}\n\tif session.ID == \"\" {\n\t\treturn fmt.Errorf(\"session ID cannot be empty\")\n\t}\n\n\t// Outside retry: deterministic operations\n\tkvStoreKey := c.SessionKey(session.ID)\n\n\t// Serialize session data as JSON first to fail fast on marshaling errors\n\tsessionData, err := json.Marshal(session)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal session data: %w\", err)\n\t}\n\n\t// Create a Consul session for distributed locking and TTL management\n\tconsulLockSession := c.createConsulLockSession(session.ID)\n\n\t// Inside retry: only network operations\n\terr = retry.Do(\n\t\tfunc() error {\n\t\t\tconsulLockSessionID, _, err := c.client.Session().CreateNoChecks(consulLockSession, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to create consul lock session: %w\", err)\n\t\t\t}\n\n\t\t\t// Store the complete session data with distributed lock and TTL\n\t\t\tkvPair := &api.KVPair{\n\t\t\t\tKey:     kvStoreKey,\n\t\t\t\tValue:   sessionData,\n\t\t\t\tSession: consulLockSessionID,\n\t\t\t}\n\n\t\t\tlockAcquired, _, err := c.client.KV().Acquire(kvPair, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to store session data: %w\", err)\n\t\t\t}\n\t\t\tif !lockAcquired {\n\t\t\t\treturn fmt.Errorf(\"failed to acquire distributed lock for session %s\", session.ID)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t\tretry.Attempts(DefaultMaxRetries),\n\t\tretry.Delay(DefaultRetryDelay),\n\t\tretry.OnRetry(func(n uint, err error) {\n\t\t\tc.logger.Debug(\"retrying consul store operation\",\n\t\t\t\t\"operation\", \"store\",\n\t\t\t\t\"attempt\", n+1,\n\t\t\t\t\"error\", err,\n\t\t\t)\n\t\t}),\n\t)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Immediately update local cache for strong consistency\n\tc.cache.Set(session.ID, session)\n\n\tc.logger.Debug(\"stored session data in consul and cache\",\n\t\t\"session\", session.ID,\n\t\t\"node\", session.NodeAddr,\n\t\t\"key\", kvStoreKey,\n\t)\n\n\treturn nil\n}\n\n// Get session data with hybrid read-through cache\nfunc (c *consulSessionStore) Get(sessionID string) (*Session, error) {\n\tif sessionID == \"\" {\n\t\treturn nil, fmt.Errorf(\"session ID cannot be empty\")\n\t}\n\n\t// Try local cache first for instant lookup\n\tif session, exists := c.cache.Get(sessionID); exists {\n\t\tc.logger.Debug(\"retrieved session data from cache\",\n\t\t\t\"session\", sessionID,\n\t\t\t\"node\", session.NodeAddr,\n\t\t)\n\t\treturn session, nil\n\t}\n\n\t// Cache miss - fetch from Consul for strong consistency\n\treturn c.getFromConsulAndCache(sessionID)\n}\n\n// getFromConsulAndCache fetches session from Consul and updates local cache\nfunc (c *consulSessionStore) getFromConsulAndCache(sessionID string) (*Session, error) {\n\tkvStoreKey := c.SessionKey(sessionID)\n\n\tvar session *Session\n\terr := retry.Do(\n\t\tfunc() error {\n\t\t\tkvPair, _, err := c.client.KV().Get(kvStoreKey, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get session data: %w\", err)\n\t\t\t}\n\t\t\tif kvPair == nil {\n\t\t\t\treturn &ErrSessionNotFound{SessionID: sessionID}\n\t\t\t}\n\n\t\t\tvar s Session\n\t\t\tif err := json.Unmarshal(kvPair.Value, &s); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to unmarshal session data: %w\", err)\n\t\t\t}\n\n\t\t\tsession = &s\n\t\t\treturn nil\n\t\t},\n\t\tretry.Attempts(DefaultMaxRetries),\n\t\tretry.Delay(DefaultRetryDelay),\n\t\tretry.RetryIf(func(err error) bool {\n\t\t\t// Don't retry if session is not found - it's a business logic error, not a network error\n\t\t\tvar notFoundErr *ErrSessionNotFound\n\t\t\treturn !errors.As(err, &notFoundErr)\n\t\t}),\n\t\tretry.OnRetry(func(n uint, err error) {\n\t\t\tc.logger.Debug(\"retrying consul get operation\",\n\t\t\t\t\"operation\", \"get_from_consul\",\n\t\t\t\t\"attempt\", n+1,\n\t\t\t\t\"error\", err,\n\t\t\t)\n\t\t}),\n\t)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Update local cache with fetched data\n\tc.cache.Set(sessionID, session)\n\n\tc.logger.Debug(\"retrieved session data from consul and cached\",\n\t\t\"session\", sessionID,\n\t\t\"node\", session.NodeAddr,\n\t)\n\n\treturn session, nil\n}\n\n// Delete session data from Consul\nfunc (c *consulSessionStore) Delete(sessionID string) error {\n\tif sessionID == \"\" {\n\t\treturn fmt.Errorf(\"session ID cannot be empty\")\n\t}\n\n\t// Outside retry: deterministic operations\n\tkvStoreKey := c.SessionKey(sessionID)\n\n\t// Inside retry: only network operations\n\terr := retry.Do(\n\t\tfunc() error {\n\t\t\t_, err := c.client.KV().Delete(kvStoreKey, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to delete session data: %w\", err)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t\tretry.Attempts(DefaultMaxRetries),\n\t\tretry.Delay(DefaultRetryDelay),\n\t\tretry.OnRetry(func(n uint, err error) {\n\t\t\tc.logger.Debug(\"retrying consul delete operation\",\n\t\t\t\t\"operation\", \"delete\",\n\t\t\t\t\"attempt\", n+1,\n\t\t\t\t\"error\", err,\n\t\t\t)\n\t\t}),\n\t)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Immediately remove from local cache for strong consistency\n\tc.cache.Delete(sessionID)\n\n\tc.logger.Debug(\"deleted session data from consul and cache\",\n\t\t\"session\", sessionID,\n\t\t\"key\", kvStoreKey,\n\t)\n\n\treturn nil\n}\n\n// BatchDelete multiple sessions efficiently using Consul transactions\nfunc (c *consulSessionStore) BatchDelete(sessionIDs []string) error {\n\tif len(sessionIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// Consul's official transaction limit is 64 operations\n\t// Reference: https://developer.hashicorp.com/consul/api-docs/txn\n\tconst maxBatchSize = 64\n\n\tfor i := 0; i < len(sessionIDs); i += maxBatchSize {\n\t\tend := min(i+maxBatchSize, len(sessionIDs))\n\n\t\tif err := c.deleteBatch(sessionIDs[i:end]); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Immediately remove from local cache for strong consistency\n\tc.cache.BatchDelete(sessionIDs)\n\n\tc.logger.Debug(\"batch deleted sessions from consul and cache\", \"count\", len(sessionIDs))\n\treturn nil\n}\n\n// deleteBatch deletes a batch of sessions using Consul transaction\nfunc (c *consulSessionStore) deleteBatch(sessionIDs []string) error {\n\tops := make([]*api.KVTxnOp, len(sessionIDs))\n\tfor i, sessionID := range sessionIDs {\n\t\tkvStoreKey := c.SessionKey(sessionID)\n\t\tops[i] = &api.KVTxnOp{\n\t\t\tVerb: api.KVDelete,\n\t\t\tKey:  kvStoreKey,\n\t\t}\n\t}\n\n\treturn retry.Do(\n\t\tfunc() error {\n\t\t\tok, response, _, err := c.client.KV().Txn(ops, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to execute batch delete transaction: %w\", err)\n\t\t\t}\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"batch delete transaction failed: %v\", response.Errors)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t\tretry.Attempts(DefaultMaxRetries),\n\t\tretry.Delay(DefaultRetryDelay),\n\t\tretry.OnRetry(func(n uint, err error) {\n\t\t\tc.logger.Debug(\"retrying consul batch delete operation\",\n\t\t\t\t\"operation\", \"batch_delete\",\n\t\t\t\t\"attempt\", n+1,\n\t\t\t\t\"count\", len(sessionIDs),\n\t\t\t\t\"error\", err,\n\t\t\t)\n\t\t}),\n\t)\n}\n\n// List all sessions from Consul\nfunc (c *consulSessionStore) List() ([]*Session, error) {\n\tvar sessions []*Session\n\n\terr := retry.Do(\n\t\tfunc() error {\n\t\t\tpairs, _, err := c.client.KV().List(c.SessionsKey(), nil)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to list sessions: %w\", err)\n\t\t\t}\n\n\t\t\tsessions = make([]*Session, 0, len(pairs))\n\t\t\tfor _, pair := range pairs {\n\t\t\t\tvar session Session\n\t\t\t\tif err := json.Unmarshal(pair.Value, &session); err != nil {\n\t\t\t\t\t// Skip invalid sessions but continue processing\n\t\t\t\t\tc.logger.Warn(\"failed to unmarshal session, skipping\", \"error\", err, \"key\", pair.Key)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tsessions = append(sessions, &session)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t\tretry.Attempts(DefaultMaxRetries),\n\t\tretry.Delay(DefaultRetryDelay),\n\t\tretry.OnRetry(func(n uint, err error) {\n\t\t\tc.logger.Debug(\"retrying consul list operation\",\n\t\t\t\t\"operation\", \"list\",\n\t\t\t\t\"attempt\", n+1,\n\t\t\t\t\"error\", err,\n\t\t\t)\n\t\t}),\n\t)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tc.logger.Debug(\"listed sessions from consul\", \"count\", len(sessions))\n\treturn sessions, nil\n}\n\nfunc (c *consulSessionStore) NodeName() string {\n\treturn path.Join(c.keyPrefix, DefaultKeyPrefix)\n}\n\n// SessionKey generates the Consul KV store key for a session\nfunc (c *consulSessionStore) SessionKey(sessionID string) string {\n\treturn path.Join(c.keyPrefix, \"sessions\", sessionID)\n}\n\nfunc (c *consulSessionStore) SessionsKey() string {\n\treturn path.Join(c.keyPrefix, \"sessions\")\n}\n\n// KeyPrefix returns the root key prefix used by this store (useful for cleanup in tests)\nfunc (c *consulSessionStore) KeyPrefix() string {\n\treturn c.keyPrefix + \"/\"\n}\n\n// registerNode registers this node with Consul\nfunc (c *consulSessionStore) registerNode() error {\n\t_, err := c.client.Catalog().Register(&api.CatalogRegistration{\n\t\tNode:    c.NodeName(),\n\t\tAddress: UnusedNodeAddress, // not used but required\n\t}, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"register node %q: %w\", c.NodeName(), err)\n\t}\n\treturn nil\n}\n\n// createConsulLockSession creates a Consul session for distributed locking\nfunc (c *consulSessionStore) createConsulLockSession(sessionID string) *api.SessionEntry {\n\treturn &api.SessionEntry{\n\t\tName:      sessionID,\n\t\tNode:      c.NodeName(),\n\t\tTTL:       c.ttl.String(),\n\t\tBehavior:  api.SessionBehaviorDelete,\n\t\tLockDelay: time.Second,\n\t}\n}\n\n// startSessionWatch initializes the Consul watch to maintain full session replica\nfunc (c *consulSessionStore) startSessionWatch(cfg *api.Config) error {\n\t// Create watch plan for all sessions\n\tparams := map[string]interface{}{\n\t\t\"type\":   \"keyprefix\",\n\t\t\"prefix\": c.SessionsKey(),\n\t\t\"token\":  cfg.Token,\n\t}\n\n\twatchPlan, err := watch.Parse(params)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create watch plan: %w\", err)\n\t}\n\n\t// Set up the handler to update local session replica\n\twatchPlan.Handler = func(idx uint64, data interface{}) {\n\t\tif kvPairs, ok := data.(api.KVPairs); ok {\n\t\t\tc.updateSessionReplica(kvPairs)\n\t\t}\n\t}\n\n\tc.watchPlan = watchPlan\n\n\t// Create a separate config for watch operations with longer timeout\n\t// Consul watches use long-polling and need extended timeouts\n\twatchConfig := *cfg // Copy the config\n\twatchConfig.HttpClient = &http.Client{\n\t\tTimeout: DefaultWatchTimeout, // Allow long-polling for Consul watches\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:        100,\n\t\t\tMaxIdleConnsPerHost: 20,\n\t\t\tIdleConnTimeout:     90 * time.Second,\n\t\t\tDisableKeepAlives:   false,\n\t\t},\n\t}\n\n\t// Start watching in background\n\tgo func() {\n\t\tc.logger.Info(\"starting session watch for full replication\")\n\t\tif err := watchPlan.RunWithConfig(watchConfig.Address, &watchConfig); err != nil {\n\t\t\tc.logger.Error(\"session watch failed\", \"error\", err)\n\t\t}\n\t}()\n\n\treturn nil\n}\n\n// updateSessionReplica updates the local session replica based on Consul data\nfunc (c *consulSessionStore) updateSessionReplica(kvPairs api.KVPairs) {\n\t// Create new session map from Consul data\n\tnewSessions := make(map[string]*Session)\n\n\tfor _, kvPair := range kvPairs {\n\t\tvar session Session\n\t\tif err := json.Unmarshal(kvPair.Value, &session); err != nil {\n\t\t\tc.logger.Warn(\"failed to unmarshal session data\", \"error\", err, \"key\", kvPair.Key)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Use session.ID from the unmarshaled value directly\n\t\tnewSessions[session.ID] = &session\n\t}\n\n\t// Atomically replace cache contents and get change statistics\n\tc.cache.ReplaceAll(newSessions)\n}\n\n// Close gracefully stops the session watch and cleans up resources\nfunc (c *consulSessionStore) Close() error {\n\tif c.watchPlan != nil {\n\t\tc.watchPlan.Stop()\n\t}\n\treturn nil\n}\n\n// HasInCache checks if a session exists in the local cache (useful for testing watch functionality)\nfunc (c *consulSessionStore) HasInCache(sessionID string) bool {\n\treturn c.cache.Has(sessionID)\n}\n\n// memorySessionStore is a simple in-memory implementation for testing/fallback\ntype memorySessionStore struct {\n\tsessions map[string]*Session\n\tlogger   *slog.Logger\n\tmutex    sync.RWMutex\n}\n\n// newMemorySessionStore creates a new MemorySessionStore\nfunc newMemorySessionStore(logger *slog.Logger) *memorySessionStore {\n\treturn &memorySessionStore{\n\t\tsessions: make(map[string]*Session),\n\t\tlogger:   logger,\n\t}\n}\n\nfunc (m *memorySessionStore) Store(session *Session) error {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tm.sessions[session.ID] = session\n\tm.logger.Debug(\"stored session data in memory\",\n\t\t\"session\", session.ID,\n\t\t\"node\", session.NodeAddr,\n\t)\n\treturn nil\n}\n\nfunc (m *memorySessionStore) Get(sessionID string) (*Session, error) {\n\tm.mutex.RLock()\n\tdefer m.mutex.RUnlock()\n\n\tsession, exists := m.sessions[sessionID]\n\tif !exists {\n\t\treturn nil, &ErrSessionNotFound{SessionID: sessionID}\n\t}\n\treturn session, nil\n}\n\nfunc (m *memorySessionStore) Delete(sessionID string) error {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tdelete(m.sessions, sessionID)\n\tm.logger.Debug(\"deleted session data from memory\",\n\t\t\"session\", sessionID,\n\t)\n\treturn nil\n}\n\n// BatchDelete multiple sessions efficiently from memory\nfunc (m *memorySessionStore) BatchDelete(sessionIDs []string) error {\n\tm.mutex.Lock()\n\tdefer m.mutex.Unlock()\n\n\tfor _, sessionID := range sessionIDs {\n\t\tdelete(m.sessions, sessionID)\n\t}\n\n\tm.logger.Debug(\"batch deleted sessions from memory\", \"count\", len(sessionIDs))\n\treturn nil\n}\n\n// List all sessions from memory\nfunc (m *memorySessionStore) List() ([]*Session, error) {\n\tm.mutex.RLock()\n\tdefer m.mutex.RUnlock()\n\n\tsessions := make([]*Session, 0, len(m.sessions))\n\tfor _, session := range m.sessions {\n\t\tsessions = append(sessions, session)\n\t}\n\n\tm.logger.Debug(\"listed sessions from memory\", \"count\", len(sessions))\n\treturn sessions, nil\n}\n\n// Close cleans up memory store resources (no-op for memory store)\nfunc (m *memorySessionStore) Close() error {\n\treturn nil\n}\n\n// NewSession creates Session from session parameters\nfunc NewSession(sessionID, nodeAddr, hostUser string, hostPublicKeys, clientAuthorizedKeys [][]byte) *Session {\n\tvar hostKeys []ssh.PublicKey\n\tfor _, keyBytes := range hostPublicKeys {\n\t\tif key, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes); err == nil {\n\t\t\thostKeys = append(hostKeys, key)\n\t\t}\n\t}\n\n\tvar clientKeys []ssh.PublicKey\n\tfor _, keyBytes := range clientAuthorizedKeys {\n\t\tif key, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes); err == nil {\n\t\t\tclientKeys = append(clientKeys, key)\n\t\t}\n\t}\n\n\treturn &Session{\n\t\tID:                   sessionID,\n\t\tNodeAddr:             nodeAddr,\n\t\tHostUser:             hostUser,\n\t\tHostPublicKeys:       hostKeys,\n\t\tClientAuthorizedKeys: clientKeys,\n\t}\n}\n\n// SessionManager provides a high-level interface for session management,\n// combining session storage with connection ID encoding based on routing mode\ntype SessionManager struct {\n\tstore         SessionStore\n\tencodeDecoder routing.EncodeDecoder\n}\n\n// SessionManagerConfig holds configuration for creating a SessionManager\ntype SessionManagerConfig struct {\n\tMode      routing.Mode\n\tLogger    *slog.Logger\n\tConsulURL *url.URL\n\tConsulTTL time.Duration\n}\n\n// SessionManagerOption is a functional option for configuring SessionManager\ntype SessionManagerOption func(*SessionManagerConfig)\n\n// WithSessionManagerLogger sets the logger for the session manager\nfunc WithSessionManagerLogger(logger *slog.Logger) SessionManagerOption {\n\treturn func(c *SessionManagerConfig) {\n\t\tc.Logger = logger\n\t}\n}\n\n// WithSessionManagerConsulURL sets the Consul URL for consul mode\nfunc WithSessionManagerConsulURL(consulURL *url.URL) SessionManagerOption {\n\treturn func(c *SessionManagerConfig) {\n\t\tc.ConsulURL = consulURL\n\t}\n}\n\n// WithSessionManagerConsulTTL sets the session TTL for consul mode\nfunc WithSessionManagerConsulTTL(ttl time.Duration) SessionManagerOption {\n\treturn func(c *SessionManagerConfig) {\n\t\tc.ConsulTTL = ttl\n\t}\n}\n\n// NewSessionManager creates a new SessionManager with the specified routing mode and options\n//\n// Examples:\n//\n//\t// Embedded mode (simple, with default logger)\n//\tsm, err := NewSessionManager(routing.ModeEmbedded)\n//\n//\t// Embedded mode with custom logger\n//\tsm, err := NewSessionManager(routing.ModeEmbedded, WithSessionManagerLogger(logger))\n//\n//\t// Consul mode with minimal configuration\n//\tsm, err := NewSessionManager(routing.ModeConsul, WithSessionManagerConsulURL(\"http://localhost:8500\"))\n//\n//\t// Consul mode with full configuration\n//\tsm, err := NewSessionManager(routing.ModeConsul,\n//\t    WithSessionManagerLogger(logger),\n//\t    WithSessionManagerConsulURL(\"http://consul.example.com:8500\"),\n//\t    WithSessionManagerConsulTTL(1*time.Hour))\nfunc NewSessionManager(mode routing.Mode, opts ...SessionManagerOption) (*SessionManager, error) {\n\tconfig := &SessionManagerConfig{\n\t\tMode:      mode,\n\t\tLogger:    slog.Default(),\n\t\tConsulTTL: DefaultSessionTTL,\n\t}\n\n\t// Apply all options\n\tfor _, opt := range opts {\n\t\topt(config)\n\t}\n\n\tswitch mode {\n\tcase routing.ModeEmbedded:\n\t\treturn newEmbeddedSessionManager(config.Logger), nil\n\tcase routing.ModeConsul:\n\t\treturn newConsulSessionManager(config.ConsulURL, config.ConsulTTL, config.Logger)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported routing mode: %s\", mode)\n\t}\n}\n\n// newSessionManagerWithStore creates a SessionManager with explicit store and encoder (for advanced testing)\nfunc newSessionManagerWithStore(store SessionStore, encodeDecoder routing.EncodeDecoder) *SessionManager {\n\treturn &SessionManager{\n\t\tstore:         store,\n\t\tencodeDecoder: encodeDecoder,\n\t}\n}\n\n// newEmbeddedSessionManager creates a SessionManager for embedded mode with memory storage\nfunc newEmbeddedSessionManager(logger *slog.Logger) *SessionManager {\n\tstore := newMemorySessionStore(logger)\n\tencodeDecoder := routing.NewEncodeDecoder(routing.ModeEmbedded)\n\treturn newSessionManagerWithStore(store, encodeDecoder)\n}\n\n// newConsulSessionManager creates a SessionManager for consul mode with Consul storage\nfunc newConsulSessionManager(consulURL *url.URL, ttl time.Duration, logger *slog.Logger) (*SessionManager, error) {\n\tstore, err := newConsulSessionStore(consulURL, ttl, logger)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tencodeDecoder := routing.NewEncodeDecoder(routing.ModeConsul)\n\treturn newSessionManagerWithStore(store, encodeDecoder), nil\n}\n\n// CreateSession stores the session and returns the encoded SSH user identifier\nfunc (sm *SessionManager) CreateSession(session *Session) (string, error) {\n\tif err := sm.store.Store(session); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Encode the SSH user identifier using the encoder\n\treturn sm.encodeDecoder.Encode(session.ID, session.NodeAddr), nil\n}\n\n// GetSession retrieves a session by ID\nfunc (sm *SessionManager) GetSession(sessionID string) (*Session, error) {\n\treturn sm.store.Get(sessionID)\n}\n\n// DeleteSession removes a session by ID\nfunc (sm *SessionManager) DeleteSession(sessionID string) error {\n\treturn sm.store.Delete(sessionID)\n}\n\n// shouldValidateSessionExistence returns true if session existence should be validated\n// based on the current routing mode and operational requirements.\n//\n// Both embedded and Consul modes are valid deployment options:\n// - Embedded mode: For single-node or simple deployments without external dependencies\n// - Consul mode: For multi-node deployments requiring shared session state\nfunc (sm *SessionManager) shouldValidateSessionExistence() bool {\n\t// In Consul mode: validate existence (shared store accessible across all nodes)\n\t// In embedded mode: skip validation (session data is local to each node)\n\treturn sm.encodeDecoder.Mode() == routing.ModeConsul\n}\n\n// ResolveSSHUser resolves an SSH username by decoding it and conditionally validating session existence\n// In embedded mode: only decodes (session may be on another node)\n// In consul mode: decodes and validates (shared store across all nodes)\nfunc (sm *SessionManager) ResolveSSHUser(sshUser string) (sessionID, nodeAddr string, err error) {\n\t// Decode the SSH user using our encoder\n\tsessionID, nodeAddr, err = sm.encodeDecoder.Decode(sshUser)\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"failed to decode SSH user: %w\", err)\n\t}\n\n\t// Validate session existence based on routing mode strategy\n\tif sm.shouldValidateSessionExistence() {\n\t\tsession, err := sm.store.Get(sessionID)\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", fmt.Errorf(\"session %s not found: %w\", sessionID, err)\n\t\t}\n\n\t\treturn session.ID, session.NodeAddr, nil\n\t}\n\n\treturn sessionID, nodeAddr, nil\n}\n\n// GetEncodeDecoder returns the EncodeDecoder used by this session manager\nfunc (sm *SessionManager) GetEncodeDecoder() routing.EncodeDecoder {\n\treturn sm.encodeDecoder\n}\n\n// GetRoutingMode returns the routing mode of this session manager\nfunc (sm *SessionManager) GetRoutingMode() routing.Mode {\n\treturn sm.encodeDecoder.Mode()\n}\n\n// GetStore returns the underlying SessionStore for compatibility\nfunc (sm *SessionManager) GetStore() SessionStore {\n\treturn sm.store\n}\n\n// Shutdown cleans up sessions created by this node during server shutdown\nfunc (sm *SessionManager) Shutdown(nodeAddr string) error {\n\t// Get all sessions\n\tsessions, err := sm.store.List()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list sessions for cleanup: %w\", err)\n\t}\n\n\tif len(sessions) > 0 {\n\t\t// Collect session IDs for this node\n\t\tvar sessionIDsToDelete []string\n\t\tfor _, session := range sessions {\n\t\t\tif session.NodeAddr == nodeAddr {\n\t\t\t\tsessionIDsToDelete = append(sessionIDsToDelete, session.ID)\n\t\t\t}\n\t\t}\n\n\t\t// Batch delete sessions for this node\n\t\tif len(sessionIDsToDelete) > 0 {\n\t\t\tif err := sm.store.BatchDelete(sessionIDsToDelete); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to batch delete sessions during shutdown: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Close the store to clean up resources (e.g., stop watch goroutines)\n\tif err := sm.store.Close(); err != nil {\n\t\treturn fmt.Errorf(\"failed to close session store: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "server/session_test.go",
    "content": "package server\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/hashicorp/consul/api\"\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/internal/testhelpers\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\nvar (\n\t// Shared debug logger for session tests\n\tsessionTestLogger = logging.Must(logging.Console(), logging.Debug()).Logger\n)\n\n// This file contains comprehensive tests for the session management system.\n// Tests are organized by layers with proper separation of concerns:\n//\n// SessionManager Layer (behavior tests):\n// - EmbeddedSessionManagerTestSuite: Tests SessionManager with embedded routing\n// - ConsulSessionManagerTestSuite: Tests SessionManager with consul routing\n//\n// Store Layer (implementation tests):\n// - MemoryStoreTestSuite: Tests memory store operations directly\n// - ConsulStoreTestSuite: Tests consul store operations and replication\n\n//\n// SessionManager Layer Tests - Focus on routing mode behavior\n//\n\n// EmbeddedSessionManagerTestSuite tests SessionManager behavior in embedded mode\ntype EmbeddedSessionManagerTestSuite struct {\n\tsuite.Suite\n\tsm     *SessionManager\n\tlogger *slog.Logger\n}\n\nfunc (suite *EmbeddedSessionManagerTestSuite) SetupTest() {\n\tsuite.logger = sessionTestLogger\n\tsuite.sm = newEmbeddedSessionManager(suite.logger)\n}\n\nfunc (suite *EmbeddedSessionManagerTestSuite) TestCreateAndResolveSession() {\n\tsessionID := \"test-session-123\"\n\tnodeAddr := \"127.0.0.1:2222\"\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: nodeAddr,\n\t}\n\n\t// Test CreateSession returns encoded SSH user for embedded mode\n\tsshUser, err := suite.sm.CreateSession(session)\n\tsuite.NoError(err)\n\tsuite.Contains(sshUser, sessionID)\n\tsuite.Contains(sshUser, \":\") // embedded mode uses \"sessionID:base64(nodeAddr)\" format\n\n\t// Test ResolveSSHUser decodes correctly\n\tresolvedSessionID, resolvedNodeAddr, err := suite.sm.ResolveSSHUser(sshUser)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, resolvedSessionID)\n\tsuite.Equal(nodeAddr, resolvedNodeAddr)\n\n\t// Test GetSession retrieves the session\n\tretrievedSession, err := suite.sm.GetSession(sessionID)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, retrievedSession.ID)\n\tsuite.Equal(nodeAddr, retrievedSession.NodeAddr)\n}\n\nfunc (suite *EmbeddedSessionManagerTestSuite) TestResolveSSHUser_DoesNotValidateExistence() {\n\t// In embedded mode, ResolveSSHUser should not validate session existence\n\t// because sessions may exist on other nodes in distributed deployments\n\tsessionID := \"nonexistent-session\"\n\tnodeAddr := \"127.0.0.1:2222\"\n\n\t// Create SSH user for non-existent session using embedded encoding\n\tencodeDecoder := suite.sm.GetEncodeDecoder()\n\tsshUser := encodeDecoder.Encode(sessionID, nodeAddr)\n\n\t// Should return session info even if it doesn't exist in store\n\tresolvedSessionID, resolvedNodeAddr, err := suite.sm.ResolveSSHUser(sshUser)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, resolvedSessionID)\n\tsuite.Equal(nodeAddr, resolvedNodeAddr)\n}\n\nfunc (suite *EmbeddedSessionManagerTestSuite) TestResolveSSHUser_InvalidFormats() {\n\ttestCases := []struct {\n\t\tinput       string\n\t\tdescription string\n\t}{\n\t\t{\"no-colon-here\", \"no colon separator\"},\n\t\t{\"\", \"empty string\"},\n\t\t{\"session:invalid-base64===!@#$\", \"invalid base64 characters\"},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tsuite.Run(tc.description, func() {\n\t\t\t_, _, err := suite.sm.ResolveSSHUser(tc.input)\n\t\t\tsuite.Error(err)\n\t\t})\n\t}\n}\n\nfunc (suite *EmbeddedSessionManagerTestSuite) TestRoutingMode() {\n\tsuite.Equal(routing.ModeEmbedded, suite.sm.GetRoutingMode())\n}\n\nfunc (suite *EmbeddedSessionManagerTestSuite) TestDeleteSession() {\n\tsessionID := \"test-delete-session\"\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: \"127.0.0.1:2222\",\n\t}\n\n\t// Create session\n\t_, err := suite.sm.CreateSession(session)\n\tsuite.NoError(err)\n\n\t// Verify it exists\n\t_, err = suite.sm.GetSession(sessionID)\n\tsuite.NoError(err)\n\n\t// Delete session\n\terr = suite.sm.DeleteSession(sessionID)\n\tsuite.NoError(err)\n\n\t// Verify it's deleted\n\t_, err = suite.sm.GetSession(sessionID)\n\tsuite.Error(err)\n}\n\n// ConsulSessionManagerTestSuite tests SessionManager behavior in consul mode\ntype ConsulSessionManagerTestSuite struct {\n\tsuite.Suite\n\tsm     *SessionManager\n\tclient *api.Client\n}\n\nfunc (suite *ConsulSessionManagerTestSuite) SetupSuite() {\n\t// Skip if Consul is not available\n\tif !testhelpers.IsConsulAvailable() {\n\t\tsuite.T().Skip(\"Consul not available - set CONSUL_URL or ensure Consul is running on localhost:8500\")\n\t}\n\n\tconsulURL, err := url.Parse(testhelpers.ConsulURL())\n\tsuite.Require().NoError(err)\n\n\tsm, err := newConsulSessionManager(consulURL, 5*time.Minute, sessionTestLogger)\n\tsuite.Require().NoError(err)\n\tsuite.sm = sm\n\n\t// Setup client for cleanup\n\tclient, err := testhelpers.ConsulClient()\n\tsuite.Require().NoError(err)\n\tsuite.client = client\n}\n\nfunc (suite *ConsulSessionManagerTestSuite) TearDownSuite() {\n\tif suite.client != nil && suite.sm != nil {\n\t\t// Clean up test data using the actual key prefix from the store\n\t\tif store, ok := suite.sm.GetStore().(*consulSessionStore); ok {\n\t\t\t_, err := suite.client.KV().DeleteTree(store.KeyPrefix(), nil)\n\t\t\tsuite.NoError(err)\n\t\t}\n\t}\n}\n\nfunc (suite *ConsulSessionManagerTestSuite) TestCreateAndResolveSession() {\n\tsessionID := fmt.Sprintf(\"test-consul-session-%d\", time.Now().UnixNano())\n\tnodeAddr := \"192.168.1.100:2222\"\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: nodeAddr,\n\t\tHostUser: \"testuser\",\n\t}\n\n\t// Test CreateSession returns just session ID for consul mode\n\tsshUser, err := suite.sm.CreateSession(session)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, sshUser) // Consul mode returns just session ID\n\n\t// Test GetSession retrieves the session immediately (strong consistency)\n\tretrievedSession, err := suite.sm.GetSession(sessionID)\n\tsuite.NoError(err)\n\tsuite.NotNil(retrievedSession)\n\tsuite.Equal(sessionID, retrievedSession.ID)\n\tsuite.Equal(nodeAddr, retrievedSession.NodeAddr)\n\n\t// Test ResolveSSHUser validates session existence and returns session info\n\tresolvedSessionID, resolvedNodeAddr, err := suite.sm.ResolveSSHUser(sessionID)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, resolvedSessionID)\n\tsuite.Equal(nodeAddr, resolvedNodeAddr)\n\n\t// Cleanup\n\terr = suite.sm.DeleteSession(sessionID)\n\tsuite.NoError(err)\n}\n\nfunc (suite *ConsulSessionManagerTestSuite) TestResolveSSHUser_ValidatesExistence() {\n\t// In consul mode, ResolveSSHUser should validate session existence because\n\t// all nodes share the same Consul store\n\tsessionID := fmt.Sprintf(\"nonexistent-session-%d\", time.Now().UnixNano())\n\n\t// Try to resolve non-existent session - should fail\n\t_, _, err := suite.sm.ResolveSSHUser(sessionID)\n\tsuite.Error(err, \"Should fail for non-existent session in consul mode\")\n\n\t// Create the session and try again\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: \"192.168.1.100:2222\",\n\t}\n\t_, err = suite.sm.CreateSession(session)\n\tsuite.NoError(err)\n\n\t// Now it should work immediately (strong consistency)\n\tresolvedSessionID, resolvedNodeAddr, err := suite.sm.ResolveSSHUser(sessionID)\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, resolvedSessionID)\n\tsuite.Equal(session.NodeAddr, resolvedNodeAddr)\n\n\t// Clean up\n\terr = suite.sm.DeleteSession(sessionID)\n\tsuite.NoError(err)\n}\n\nfunc (suite *ConsulSessionManagerTestSuite) TestRoutingMode() {\n\tsuite.Equal(routing.ModeConsul, suite.sm.GetRoutingMode())\n}\n\n//\n// Store Layer Tests - Focus on storage implementation details\n//\n\n// MemoryStoreTestSuite tests the memory store implementation directly\ntype MemoryStoreTestSuite struct {\n\tsuite.Suite\n\tstore  *memorySessionStore\n\tlogger *slog.Logger\n}\n\nfunc (suite *MemoryStoreTestSuite) SetupTest() {\n\tsuite.logger = sessionTestLogger\n\tsuite.store = newMemorySessionStore(suite.logger)\n}\n\nfunc (suite *MemoryStoreTestSuite) TestStoreOperations() {\n\tsessionID := \"test-memory-session\"\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: \"127.0.0.1:2222\",\n\t}\n\n\t// Test Store\n\terr := suite.store.Store(session)\n\tsuite.NoError(err)\n\n\t// Test Store duplicate (should succeed - overwrites)\n\terr = suite.store.Store(session)\n\tsuite.NoError(err)\n\n\t// Test Get\n\tretrievedSession, err := suite.store.Get(sessionID)\n\tsuite.NoError(err)\n\tsuite.Equal(session.ID, retrievedSession.ID)\n\tsuite.Equal(session.NodeAddr, retrievedSession.NodeAddr)\n\n\t// Test Update (Store is used for both create and update)\n\tsession.NodeAddr = \"192.168.1.100:2222\"\n\terr = suite.store.Store(session)\n\tsuite.NoError(err)\n\n\tretrievedSession, err = suite.store.Get(sessionID)\n\tsuite.NoError(err)\n\tsuite.Equal(\"192.168.1.100:2222\", retrievedSession.NodeAddr)\n\n\t// Test Delete\n\terr = suite.store.Delete(sessionID)\n\tsuite.NoError(err)\n\n\t// Verify deletion\n\t_, err = suite.store.Get(sessionID)\n\tsuite.Error(err)\n\n\t// Test Delete non-existent (should succeed - idempotent)\n\terr = suite.store.Delete(\"nonexistent\")\n\tsuite.NoError(err)\n}\n\nfunc (suite *MemoryStoreTestSuite) TestBatchOperations() {\n\tsessions := []*Session{\n\t\t{ID: \"batch-1\", NodeAddr: \"192.168.1.1:2222\"},\n\t\t{ID: \"batch-2\", NodeAddr: \"192.168.1.2:2222\"},\n\t\t{ID: \"batch-3\", NodeAddr: \"192.168.1.3:2222\"},\n\t}\n\n\t// Store all sessions\n\tfor _, session := range sessions {\n\t\terr := suite.store.Store(session)\n\t\tsuite.NoError(err)\n\t}\n\n\t// Test List\n\tallSessions, err := suite.store.List()\n\tsuite.NoError(err)\n\tsuite.Len(allSessions, 3)\n\n\t// Test BatchDelete\n\tsessionIDs := []string{\"batch-1\", \"batch-2\", \"batch-3\"}\n\terr = suite.store.BatchDelete(sessionIDs)\n\tsuite.NoError(err)\n\n\t// Verify all deleted\n\tfor _, sessionID := range sessionIDs {\n\t\t_, err = suite.store.Get(sessionID)\n\t\tsuite.Error(err)\n\t}\n}\n\nfunc (suite *MemoryStoreTestSuite) TestClose() {\n\t// Memory store Close is a no-op but should not error\n\terr := suite.store.Close()\n\tsuite.NoError(err)\n}\n\n// ConsulStoreTestSuite tests the consul store implementation directly including replication\ntype ConsulStoreTestSuite struct {\n\tsuite.Suite\n\tstore1 *consulSessionStore // First store instance\n\tstore2 *consulSessionStore // Second store instance\n\tclient *api.Client\n}\n\nfunc (suite *ConsulStoreTestSuite) SetupSuite() {\n\t// Skip if Consul is not available\n\tif !testhelpers.IsConsulAvailable() {\n\t\tsuite.T().Skip(\"Consul not available - set CONSUL_URL or ensure Consul is running on localhost:8500\")\n\t}\n\n\tconsulURL, err := url.Parse(testhelpers.ConsulURL())\n\tsuite.Require().NoError(err)\n\n\t// Create two store instances to simulate multi-node setup\n\tstore1, err := newConsulSessionStore(consulURL, 5*time.Minute, sessionTestLogger)\n\tsuite.Require().NoError(err)\n\tsuite.store1 = store1\n\n\tstore2, err := newConsulSessionStore(consulURL, 5*time.Minute, sessionTestLogger)\n\tsuite.Require().NoError(err)\n\tsuite.store2 = store2\n\n\t// Setup client for cleanup\n\tclient, err := testhelpers.ConsulClient()\n\tsuite.Require().NoError(err)\n\tsuite.client = client\n}\n\nfunc (suite *ConsulStoreTestSuite) TearDownSuite() {\n\tif suite.store1 != nil {\n\t\t_ = suite.store1.Close()\n\t}\n\tif suite.store2 != nil {\n\t\t_ = suite.store2.Close()\n\t}\n\tif suite.client != nil {\n\t\t// Clean up test data using the actual key prefix from the store\n\t\t_, err := suite.client.KV().DeleteTree(suite.store1.KeyPrefix(), nil)\n\t\tsuite.NoError(err)\n\t}\n}\n\nfunc (suite *ConsulStoreTestSuite) TestBasicStoreOperations() {\n\tsessionID := fmt.Sprintf(\"test-consul-basic-%d\", time.Now().UnixNano())\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: \"127.0.0.1:2222\",\n\t\tHostUser: \"testuser\",\n\t}\n\n\t// Test Store\n\terr := suite.store1.Store(session)\n\tsuite.NoError(err)\n\n\t// Test Get - should be immediately available (strong consistency)\n\tretrievedSession, err := suite.store1.Get(sessionID)\n\tsuite.NoError(err)\n\tsuite.Equal(session.ID, retrievedSession.ID)\n\tsuite.Equal(session.NodeAddr, retrievedSession.NodeAddr)\n\tsuite.Equal(session.HostUser, retrievedSession.HostUser)\n\n\t// Test Update (requires delete first due to Consul session locking mechanism)\n\terr = suite.store1.Delete(sessionID)\n\tsuite.NoError(err)\n\n\t// Verify deletion with eventual consistency (Consul may take a moment to propagate)\n\tsuite.EventuallyWithT(func(t *assert.CollectT) {\n\t\t_, err = suite.store1.Get(sessionID)\n\t\tassert.Error(t, err, \"session should be deleted\")\n\t}, 2*time.Second, 50*time.Millisecond)\n\n\tsession.NodeAddr = \"192.168.1.100:2222\"\n\terr = suite.store1.Store(session)\n\tsuite.NoError(err)\n\n\t// Should be immediately available (strong consistency)\n\tretrievedSession, err = suite.store1.Get(sessionID)\n\tsuite.NoError(err)\n\tsuite.Equal(\"192.168.1.100:2222\", retrievedSession.NodeAddr)\n\n\t// Test Delete\n\terr = suite.store1.Delete(sessionID)\n\tsuite.NoError(err)\n\n\t// Verify deletion with eventual consistency (Consul may take a moment to propagate)\n\tsuite.EventuallyWithT(func(t *assert.CollectT) {\n\t\t_, err = suite.store1.Get(sessionID)\n\t\tassert.Error(t, err, \"session should be deleted\")\n\t}, 2*time.Second, 50*time.Millisecond)\n}\n\nfunc (suite *ConsulStoreTestSuite) TestReplicationViaCacheAndWatch() {\n\tsessionID := \"replication-test\"\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: \"192.168.1.100:2222\",\n\t\tHostUser: \"testuser\",\n\t}\n\n\t// Store session in store1\n\terr := suite.store1.Store(session)\n\tsuite.NoError(err)\n\tdefer func() {\n\t\t_ = suite.store1.Delete(sessionID)\n\t}()\n\n\t// Wait for watch to propagate to store2's cache\n\tsuite.waitForSessionInCache(sessionID)\n\n\t// Verify data integrity and measure lookup performance\n\tstart := time.Now()\n\tretrievedSession, err := suite.store2.Get(sessionID)\n\tduration := time.Since(start)\n\n\tsuite.NoError(err)\n\tsuite.Equal(sessionID, retrievedSession.ID)\n\tsuite.Equal(\"192.168.1.100:2222\", retrievedSession.NodeAddr)\n\tsuite.Equal(\"testuser\", retrievedSession.HostUser)\n\tsuite.Less(duration, 1*time.Millisecond, \"Memory lookup should be instant\")\n}\n\nfunc (suite *ConsulStoreTestSuite) TestReplicationHandlesDeletion() {\n\tsessionID := \"deletion-replication-test\"\n\tsession := &Session{\n\t\tID:       sessionID,\n\t\tNodeAddr: \"172.16.0.1:2222\",\n\t\tHostUser: \"deleteuser\",\n\t}\n\n\t// Store session and wait for replication\n\terr := suite.store1.Store(session)\n\tsuite.NoError(err)\n\tsuite.waitForSessionInCache(sessionID)\n\n\t// Delete from store1\n\terr = suite.store1.Delete(sessionID)\n\tsuite.NoError(err)\n\n\t// Wait for deletion to propagate via watch\n\tsuite.waitForSessionRemovedFromCache(sessionID)\n\n\t// Verify session is actually deleted\n\t_, err = suite.store2.Get(sessionID)\n\tsuite.Error(err, \"Session should not be accessible after deletion\")\n}\n\nfunc (suite *ConsulStoreTestSuite) TestSessionNotFoundNoRetry() {\n\t// Test that session not found errors are not retried unnecessarily\n\tnonExistentSessionID := fmt.Sprintf(\"nonexistent-%d\", time.Now().UnixNano())\n\n\t// This should fail quickly without retries\n\tstart := time.Now()\n\t_, err := suite.store1.Get(nonExistentSessionID)\n\tduration := time.Since(start)\n\n\tsuite.Error(err)\n\tsuite.Contains(err.Error(), \"not found\")\n\t// Should fail quickly (under 100ms) since we don't retry \"not found\" errors\n\tsuite.Less(duration, 100*time.Millisecond, \"Session not found should fail quickly without retries\")\n}\n\nfunc (suite *ConsulStoreTestSuite) TestBatchOperations() {\n\tsessions := []*Session{\n\t\t{ID: \"batch-consul-1\", NodeAddr: \"192.168.1.1:2222\", HostUser: \"user1\"},\n\t\t{ID: \"batch-consul-2\", NodeAddr: \"192.168.1.2:2222\", HostUser: \"user2\"},\n\t\t{ID: \"batch-consul-3\", NodeAddr: \"192.168.1.3:2222\", HostUser: \"user3\"},\n\t}\n\n\t// Store all sessions in store1\n\tfor _, session := range sessions {\n\t\terr := suite.store1.Store(session)\n\t\tsuite.NoError(err)\n\t}\n\tdefer func() {\n\t\tfor _, session := range sessions {\n\t\t\t_ = suite.store1.Delete(session.ID)\n\t\t}\n\t}()\n\n\t// Wait for all sessions to propagate via watch\n\tsuite.EventuallyWithT(func(t *assert.CollectT) {\n\t\tassert := assert.New(t)\n\t\tfor _, session := range sessions {\n\t\t\tassert.True(suite.store2.HasInCache(session.ID), \"Session %s should be in store2's cache\", session.ID)\n\t\t}\n\t}, 2*time.Second, 10*time.Millisecond)\n\n\t// Test List operation\n\tallSessions, err := suite.store1.List()\n\tsuite.NoError(err)\n\tsuite.GreaterOrEqual(len(allSessions), 3) // May have other sessions from parallel tests\n\n\t// Test BatchDelete\n\tsessionIDs := []string{\"batch-consul-1\", \"batch-consul-2\", \"batch-consul-3\"}\n\terr = suite.store1.BatchDelete(sessionIDs)\n\tsuite.NoError(err)\n\n\t// Verify all sessions are deleted\n\tfor _, sessionID := range sessionIDs {\n\t\t_, err = suite.store1.Get(sessionID)\n\t\tsuite.Error(err)\n\t}\n}\n\n// Helper methods for replication testing\nfunc (suite *ConsulStoreTestSuite) waitForSessionInCache(sessionID string) {\n\tsuite.EventuallyWithT(func(t *assert.CollectT) {\n\t\tassert := assert.New(t)\n\t\tassert.True(suite.store2.HasInCache(sessionID), \"Session should be in store2's cache via watch\")\n\t}, 2*time.Second, 10*time.Millisecond)\n}\n\nfunc (suite *ConsulStoreTestSuite) waitForSessionRemovedFromCache(sessionID string) {\n\tsuite.EventuallyWithT(func(t *assert.CollectT) {\n\t\tassert := assert.New(t)\n\t\tassert.False(suite.store2.HasInCache(sessionID), \"Session should be removed from store2's cache via watch\")\n\t}, 2*time.Second, 10*time.Millisecond)\n}\n\n//\n// Test Suite Runners\n//\n\nfunc TestEmbeddedSessionManagerTestSuite(t *testing.T) {\n\tsuite.Run(t, new(EmbeddedSessionManagerTestSuite))\n}\n\nfunc TestConsulSessionManagerTestSuite(t *testing.T) {\n\tsuite.Run(t, new(ConsulSessionManagerTestSuite))\n}\n\nfunc TestMemoryStoreTestSuite(t *testing.T) {\n\tsuite.Run(t, new(MemoryStoreTestSuite))\n}\n\nfunc TestConsulStoreTestSuite(t *testing.T) {\n\tsuite.Run(t, new(ConsulStoreTestSuite))\n}\n"
  },
  {
    "path": "server/sshd.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"log/slog\"\n\tgossh \"golang.org/x/crypto/ssh\"\n\t\"google.golang.org/protobuf/proto\"\n)\n\nvar (\n\tserverShutDownDeadline = 1 * time.Second\n)\n\ntype ServerInfo struct {\n\tNodeAddr string\n}\n\ntype sshd struct {\n\tSessionManager      *SessionManager\n\tHostSigners         []gossh.Signer\n\tNodeAddr            string\n\tSessionDialListener SessionDialListener\n\tLogger              *slog.Logger\n\n\tserver *ssh.Server\n\tmux    sync.Mutex\n}\n\nfunc (s *sshd) Shutdown() error {\n\ts.mux.Lock()\n\tdefer s.mux.Unlock()\n\n\tif s.server != nil {\n\t\tctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(serverShutDownDeadline))\n\t\tdefer cancel()\n\n\t\treturn s.server.Shutdown(ctx)\n\t}\n\n\treturn nil\n}\n\nfunc (s *sshd) Serve(ln net.Listener) error {\n\tvar signers []ssh.Signer\n\tfor _, signer := range s.HostSigners {\n\t\tsigners = append(signers, signer)\n\t}\n\n\tsh := newStreamlocalForwardHandler(\n\t\ts.SessionManager,\n\t\ts.SessionDialListener,\n\t\ts.Logger.With(\"com\", \"stream-local-handler\"),\n\t)\n\ts.mux.Lock()\n\ts.server = &ssh.Server{\n\t\tHostSigners: signers,\n\t\tHandler: func(s ssh.Session) {\n\t\t\t_ = s.Exit(1) // disable ssh login\n\t\t},\n\t\tConnectionFailedCallback: func(conn net.Conn, err error) {\n\t\t\ts.Logger.Error(\"connection failed\", \"error\", err)\n\t\t},\n\t\tServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {\n\t\t\tconfig := &gossh.ServerConfig{\n\t\t\t\tServerVersion: version.ServerSSHVersion(),\n\t\t\t}\n\t\t\treturn config\n\t\t},\n\t\tReversePortForwardingCallback: ssh.ReversePortForwardingCallback(func(ctx ssh.Context, host string, port uint32) (granted bool) {\n\t\t\ts.Logger.Info(\"attempt to bind\", \"tunnel-host\", host, \"tunnel-port\", port)\n\t\t\treturn true\n\t\t}),\n\t\tPublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {\n\t\t\tchecker := UserCertChecker{}\n\t\t\t_, _, err := checker.Authenticate(ctx.User(), key)\n\t\t\tif err != nil {\n\t\t\t\ts.Logger.Error(\"error parsing auth request from cert\", \"error\", err)\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\t// TOOD: validate pk\n\n\t\t\treturn true\n\t\t},\n\t\tChannelHandlers: make(map[string]ssh.ChannelHandler), // disallow channel requests, e.g. shell\n\t\tRequestHandlers: map[string]ssh.RequestHandler{\n\t\t\tstreamlocalForwardChannelType:         sh.Handler,\n\t\t\tcancelStreamlocalForwardChannelType:   sh.Handler,\n\t\t\tupterm.ServerCreateSessionRequestType: s.createSessionHandler,\n\t\t},\n\t}\n\ts.mux.Unlock()\n\n\treturn s.server.Serve(ln)\n}\n\nfunc (s *sshd) createSessionHandler(ctx ssh.Context, srv *ssh.Server, req *gossh.Request) (bool, []byte) {\n\tvar sessReq CreateSessionRequest\n\tif err := proto.Unmarshal(req.Payload, &sessReq); err != nil {\n\t\treturn false, []byte(err.Error())\n\t}\n\n\tsessionID := utils.GenerateSessionID()\n\n\t// Store complete session data for routing and session management\n\tsession := NewSession(\n\t\tsessionID,\n\t\ts.NodeAddr,\n\t\tsessReq.HostUser,\n\t\tsessReq.HostPublicKeys,\n\t\tsessReq.ClientAuthorizedKeys,\n\t)\n\n\tsshUser, err := s.SessionManager.CreateSession(session)\n\tif err != nil {\n\t\ts.Logger.Error(\"failed to create session\",\n\t\t\t\"error\", err,\n\t\t\t\"session\", sessionID,\n\t\t\t\"node\", s.NodeAddr,\n\t\t)\n\t\treturn false, []byte(fmt.Sprintf(\"failed to create session: %v\", err))\n\t}\n\n\tsessResp := &CreateSessionResponse{\n\t\tSessionID: sessionID,\n\t\tNodeAddr:  s.NodeAddr,\n\t\tSshUser:   sshUser,\n\t}\n\n\tb, err := proto.Marshal(sessResp)\n\tif err != nil {\n\t\treturn false, []byte(err.Error())\n\t}\n\n\treturn true, b\n}\n"
  },
  {
    "path": "server/sshd_test.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\tTestPublicKeyContent  = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdcHcuMfI8bGAyHPcGsAc/vd/gl5673pRkRBGY`\n\tTestPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDdBFq43XB3LjHyPGxgMhz3BrAHP73f4Jeeu96UZEQRmAAAAIiRPFazkTxW\nswAAAAtzc2gtZWQyNTUxOQAAACDdBFq43XB3LjHyPGxgMhz3BrAHP73f4Jeeu96UZEQRmA\nAAAEDmpjZHP/SIyBTp6YBFPzUi18iDo2QHolxGRDpx+m7let0EWrjdcHcuMfI8bGAyHPcG\nsAc/vd/gl5673pRkRBGYAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----`\n)\n\nfunc Test_sshd_DisallowSession(t *testing.T) {\n\tlogger := logging.Must(logging.Console(), logging.Debug()).Logger\n\n\tln, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\t_ = ln.Close()\n\t}()\n\n\taddr := ln.Addr().String()\n\n\tsigner, err := ssh.ParsePrivateKey([]byte(TestPrivateKeyContent))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Set up cert signer for sshd public key validation\n\tcs := UserCertSigner{\n\t\tSessionID: \"1234\",\n\t\tUser:      \"owen\",\n\t\tAuthRequest: &AuthRequest{\n\t\t\tClientVersion: upterm.HostSSHClientVersion,\n\t\t\tRemoteAddr:    addr,\n\t\t\tAuthorizedKey: []byte(TestPublicKeyContent),\n\t\t},\n\t}\n\tcertSigner, err := cs.SignCert(signer)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsshd := &sshd{\n\t\tSessionManager: func() *SessionManager {\n\t\t\tsm, _ := NewSessionManager(routing.ModeEmbedded,\n\t\t\t\tWithSessionManagerLogger(logger))\n\t\t\treturn sm\n\t\t}(),\n\t\tHostSigners: []ssh.Signer{signer},\n\t\tNodeAddr:    addr,\n\t\tLogger:      logger,\n\t}\n\n\tgo func() {\n\t\t_ = sshd.Serve(ln)\n\t}()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\n\tif err := utils.WaitForServer(ctx, addr); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tconfig := &ssh.ClientConfig{\n\t\tAuth:            []ssh.AuthMethod{ssh.PublicKeys(certSigner)},\n\t\tUser:            \"owen\",\n\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(),\n\t}\n\tclient, err := ssh.Dial(\"tcp\", addr, config)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t_, err = client.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"unsupported channel type\") {\n\t\tt.Fatalf(\"expect unsupported channel type error but got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "server/sshhandler.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/oklog/run\"\n\t\"log/slog\"\n\tgossh \"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\tforwardedStreamlocalChannelType     = \"forwarded-streamlocal@openssh.com\"\n\tstreamlocalForwardChannelType       = \"streamlocal-forward@openssh.com\"\n\tcancelStreamlocalForwardChannelType = \"cancel-streamlocal-forward@openssh.com\"\n)\n\ntype streamlocalChannelForwardMsg struct {\n\tSocketPath string\n}\n\ntype forwardedStreamlocalPayload struct {\n\tSocketPath string\n\tReserved0  string\n}\n\n// isExpectedShutdownError returns true if the error is expected during normal session shutdown\nfunc isExpectedShutdownError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\t// Context cancellation is normal during shutdown\n\tif errors.Is(err, context.Canceled) {\n\t\treturn true\n\t}\n\n\t// EOF and connection closed errors are normal during shutdown\n\tif errors.Is(err, io.EOF) {\n\t\treturn true\n\t}\n\n\terrStr := err.Error()\n\t// Common shutdown-related error messages\n\tshutdownMessages := []string{\n\t\t\"closed\",\n\t\t\"connection reset\",\n\t\t\"broken pipe\",\n\t\t\"use of closed network connection\",\n\t}\n\n\tfor _, msg := range shutdownMessages {\n\t\tif strings.Contains(errStr, msg) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc newStreamlocalForwardHandler(\n\tsessionManager *SessionManager,\n\tsessionDialListener SessionDialListener,\n\tlogger *slog.Logger,\n) *streamlocalForwardHandler {\n\treturn &streamlocalForwardHandler{\n\t\tsessionManager:      sessionManager,\n\t\tsessionDialListener: sessionDialListener,\n\t\tforwards:            make(map[string]net.Listener),\n\t\tlogger:              logger,\n\t}\n}\n\ntype streamlocalForwardHandler struct {\n\tsessionManager      *SessionManager\n\tsessionDialListener SessionDialListener\n\tforwards            map[string]net.Listener\n\tlogger              *slog.Logger\n\tsync.Mutex\n}\n\nfunc (h *streamlocalForwardHandler) listen(ctx ssh.Context, ln net.Listener, sessionID string, logger *slog.Logger) error {\n\tconn := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)\n\n\tfor {\n\t\tc, err := ln.Accept()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tgo h.handleConnection(ctx, conn, c, sessionID, logger)\n\t}\n}\n\nfunc (h *streamlocalForwardHandler) handleConnection(ctx ssh.Context, conn *gossh.ServerConn, localConn net.Conn, sessionID string, logger *slog.Logger) {\n\tdefer func() {\n\t\tif err := localConn.Close(); err != nil {\n\t\t\tlogger.Debug(\"error closing local connection\", \"error\", err)\n\t\t}\n\t}()\n\n\tpayload := gossh.Marshal(&forwardedStreamlocalPayload{\n\t\tSocketPath: sessionID,\n\t})\n\n\tch, reqs, err := conn.OpenChannel(forwardedStreamlocalChannelType, payload)\n\tif err != nil {\n\t\tlogger.Error(\"error opening channel\", \"error\", err)\n\t\treturn\n\t}\n\tdefer func() {\n\t\tif err := ch.Close(); err != nil {\n\t\t\tlogger.Debug(\"error closing SSH channel\", \"error\", err)\n\t\t}\n\t}()\n\n\tvar g run.Group\n\n\t// Context cancellation handler\n\t{\n\t\tg.Add(func() error {\n\t\t\t<-ctx.Done()\n\t\t\treturn ctx.Err()\n\t\t}, func(err error) {\n\t\t\t// Context cancelled, close all connections\n\t\t})\n\t}\n\n\t// SSH request handler\n\t{\n\t\tg.Add(func() error {\n\t\t\tgossh.DiscardRequests(reqs)\n\t\t\treturn nil\n\t\t}, func(err error) {\n\t\t\t// Requests handler stopped\n\t\t})\n\t}\n\n\t// Copy from local to SSH channel\n\t{\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(ch, localConn)\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\t// Copy stopped\n\t\t})\n\t}\n\n\t// Copy from SSH channel to local\n\t{\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(localConn, ch)\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\t// Copy stopped\n\t\t})\n\t}\n\n\tif err := g.Run(); err != nil && err != context.Canceled {\n\t\tlogger.Error(\"error handling connection\", \"error\", err)\n\t}\n}\n\nfunc (h *streamlocalForwardHandler) Handler(ctx ssh.Context, srv *ssh.Server, req *gossh.Request) (bool, []byte) {\n\tswitch req.Type {\n\tcase streamlocalForwardChannelType:\n\t\tvar reqPayload streamlocalChannelForwardMsg\n\t\tif err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil {\n\t\t\th.logger.Error(\"error parsing streamlocal payload\", \"error\", err)\n\t\t\treturn false, []byte(err.Error())\n\t\t}\n\n\t\tif srv.ReversePortForwardingCallback == nil || !srv.ReversePortForwardingCallback(ctx, reqPayload.SocketPath, 0) {\n\t\t\treturn false, []byte(\"port forwarding is disabled\")\n\t\t}\n\n\t\tsessionID := reqPayload.SocketPath\n\t\tlogger := h.logger.With(\"session-id\", sessionID)\n\n\t\t// validate session exists\n\t\tif _, err := h.sessionManager.GetSession(sessionID); err != nil {\n\t\t\treturn false, []byte(err.Error())\n\t\t}\n\n\t\tln, err := h.sessionDialListener.Listen(sessionID)\n\t\tif err != nil {\n\t\t\tlogger.Error(\"error listening socket\", \"error\", err)\n\t\t\treturn false, []byte(err.Error())\n\t\t}\n\n\t\th.trackListener(sessionID, ln)\n\n\t\tvar g run.Group\n\t\t{\n\t\t\tg.Add(func() error {\n\t\t\t\t<-ctx.Done()\n\t\t\t\treturn ctx.Err()\n\t\t\t}, func(err error) {\n\t\t\t\th.closeListener(sessionID)\n\t\t\t})\n\t\t}\n\t\t{\n\t\t\tg.Add(func() error {\n\t\t\t\treturn h.listen(ctx, ln, sessionID, logger)\n\t\t\t}, func(err error) {\n\t\t\t\th.closeListener(sessionID)\n\t\t\t})\n\t\t}\n\n\t\tgo func(sessionID string) {\n\t\t\tif err := g.Run(); err != nil {\n\t\t\t\t// Log expected shutdown errors at debug level, critical errors at error level\n\t\t\t\tif isExpectedShutdownError(err) {\n\t\t\t\t\th.logger.Debug(\"ssh session ended\", \"session-id\", sessionID)\n\t\t\t\t} else {\n\t\t\t\t\th.logger.Error(\"error handling ssh session\", \"error\", err, \"session-id\", sessionID)\n\t\t\t\t}\n\t\t\t}\n\t\t}(sessionID)\n\n\t\treturn true, nil\n\tcase cancelStreamlocalForwardChannelType:\n\t\tvar reqPayload streamlocalChannelForwardMsg\n\t\tif err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil {\n\t\t\th.logger.Error(\"error parsing streamlocal payload\", \"error\", err)\n\t\t\treturn false, []byte(err.Error())\n\t\t}\n\n\t\tsessionID := reqPayload.SocketPath\n\t\th.closeListener(sessionID)\n\n\t\treturn true, nil\n\n\tdefault:\n\t\treturn false, nil\n\t}\n}\n\nfunc (h *streamlocalForwardHandler) trackListener(sessionID string, ln net.Listener) {\n\th.Lock()\n\tdefer h.Unlock()\n\th.forwards[sessionID] = ln\n}\n\nfunc (h *streamlocalForwardHandler) closeListener(sessionID string) {\n\th.Lock()\n\tdefer h.Unlock()\n\n\tlogger := h.logger.With(\"session-id\", sessionID)\n\n\tln, ok := h.forwards[sessionID]\n\tif !ok {\n\t\t// Already closed\n\t\treturn\n\t}\n\n\tif err := ln.Close(); err != nil {\n\t\tlogger.Error(\"error closing listener\", \"error\", err)\n\t} else {\n\t\tlogger.Debug(\"closed listener\")\n\t}\n\n\tdelete(h.forwards, sessionID)\n\n\tif err := h.sessionManager.DeleteSession(sessionID); err != nil {\n\t\tlogger.Error(\"error deleting session\", \"error\", err)\n\t}\n}\n"
  },
  {
    "path": "server/sshhandler_test.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\ntype SSHHandlerTestSuite struct {\n\tsuite.Suite\n}\n\nfunc TestSSHHandlerTestSuite(t *testing.T) {\n\tsuite.Run(t, new(SSHHandlerTestSuite))\n}\n\nfunc (s *SSHHandlerTestSuite) TestIsExpectedShutdownError() {\n\ttests := []struct {\n\t\tname     string\n\t\terr      error\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"nil error\",\n\t\t\terr:      nil,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"context canceled\",\n\t\t\terr:      context.Canceled,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"context deadline exceeded\",\n\t\t\terr:      context.DeadlineExceeded,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"io.EOF\",\n\t\t\terr:      io.EOF,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"connection closed\",\n\t\t\terr:      errors.New(\"connection closed\"),\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"use of closed network connection\",\n\t\t\terr:      errors.New(\"use of closed network connection\"),\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"connection reset by peer\",\n\t\t\terr:      errors.New(\"read tcp 127.0.0.1:8080->127.0.0.1:8081: connection reset by peer\"),\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"broken pipe\",\n\t\t\terr:      errors.New(\"write tcp 127.0.0.1:8080->127.0.0.1:8081: broken pipe\"),\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"generic network error with connection reset\",\n\t\t\terr:      &net.OpError{Op: \"read\", Net: \"tcp\", Err: errors.New(\"connection reset by peer\")},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"unexpected error\",\n\t\t\terr:      errors.New(\"unexpected database error\"),\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"authentication failure\",\n\t\t\terr:      errors.New(\"ssh: handshake failed: authentication failed\"),\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"permission denied\",\n\t\t\terr:      errors.New(\"permission denied\"),\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\ts.Run(tt.name, func() {\n\t\t\tresult := isExpectedShutdownError(tt.err)\n\t\t\tassert.Equal(s.T(), tt.expected, result,\n\t\t\t\t\"isExpectedShutdownError(%v) should return %v\", tt.err, tt.expected)\n\t\t})\n\t}\n}\n\nfunc (s *SSHHandlerTestSuite) TestIsExpectedShutdownError_EdgeCases() {\n\t// Test error with \"closed\" in middle of message\n\terr := errors.New(\"the connection was closed unexpectedly\")\n\tassert.True(s.T(), isExpectedShutdownError(err),\n\t\t\"Expected 'closed' substring to be detected as shutdown error\")\n\n\t// Test multiple shutdown indicators\n\terr = errors.New(\"broken pipe: connection closed\")\n\tassert.True(s.T(), isExpectedShutdownError(err),\n\t\t\"Expected multiple shutdown indicators to be detected\")\n\n\t// Test partial matches that will trigger (which is fine for \"closed\")\n\terr = errors.New(\"unclosed parenthesis\")\n\tassert.True(s.T(), isExpectedShutdownError(err),\n\t\t\"'unclosed' contains 'closed' substring and should match\")\n\n\t// Test empty error message\n\terr = errors.New(\"\")\n\tassert.False(s.T(), isExpectedShutdownError(err),\n\t\t\"Empty error message should not be expected shutdown error\")\n}\n\nfunc (s *SSHHandlerTestSuite) TestIsExpectedShutdownError_WrappedErrors() {\n\t// Test wrapped context.Canceled\n\twrappedCanceled := errors.New(\"operation failed: context canceled\")\n\tassert.False(s.T(), isExpectedShutdownError(wrappedCanceled),\n\t\t\"String-wrapped context canceled should not match errors.Is check\")\n\n\t// Test actual wrapped context.Canceled using fmt.Errorf\n\tactualWrapped := fmt.Errorf(\"operation failed: %w\", context.Canceled)\n\tassert.True(s.T(), isExpectedShutdownError(actualWrapped),\n\t\t\"Properly wrapped context.Canceled should be detected\")\n\n\t// Test wrapped io.EOF\n\twrappedEOF := fmt.Errorf(\"read operation failed: %w\", io.EOF)\n\tassert.True(s.T(), isExpectedShutdownError(wrappedEOF),\n\t\t\"Properly wrapped io.EOF should be detected\")\n}\n"
  },
  {
    "path": "server/sshproxy.go",
    "content": "package server\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"os\"\n\t\"sync\"\n\n\t\"github.com/go-kit/kit/metrics/provider\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\ntype sshProxy struct {\n\tHostSigners         []ssh.Signer\n\tSigners             []ssh.Signer\n\tNodeAddr            string\n\tAuthorizedKeysFiles []string\n\tConnDialer          connDialer\n\tSessionManager      *SessionManager\n\tLogger              *slog.Logger\n\tMetricsProvider     provider.Provider\n\n\trouting *SSHRouting\n\tmux     sync.Mutex\n}\n\nfunc (r *sshProxy) Shutdown() error {\n\tr.mux.Lock()\n\tdefer r.mux.Unlock()\n\n\tif r.routing != nil {\n\t\treturn r.routing.Shutdown()\n\t}\n\n\treturn nil\n}\n\nfunc (r *sshProxy) Serve(ln net.Listener) error {\n\tauthorizedKeys, err := loadAuthorizedKeys(r.AuthorizedKeysFiles)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tr.mux.Lock()\n\tr.routing = &SSHRouting{\n\t\tHostSigners: r.HostSigners,\n\t\tAuthPiper: &authPiper{\n\t\t\tHostSigners:    r.HostSigners,\n\t\t\tSigners:        r.Signers,\n\t\t\tSessionManager: r.SessionManager,\n\t\t\tConnDialer:     r.ConnDialer,\n\t\t\tNodeAddr:       r.NodeAddr,\n\t\t\tauthorizedKeys: authorizedKeys,\n\t\t\tLogger:         r.Logger.With(\"component\", \"auth\"),\n\t\t},\n\t\tDecoder:         r.SessionManager.GetEncodeDecoder(),\n\t\tMetricsProvider: r.MetricsProvider,\n\t\tLogger:          r.Logger,\n\t}\n\tr.mux.Unlock()\n\n\treturn r.routing.Serve(ln)\n}\n\ntype authPiper struct {\n\tNodeAddr       string\n\tauthorizedKeys map[string]struct{} // SHA256 fingerprints; nil disables the gate\n\tSessionManager *SessionManager\n\tConnDialer     connDialer\n\tSigners        []ssh.Signer\n\tHostSigners    []ssh.Signer\n\n\tLogger *slog.Logger\n}\n\nfunc (a authPiper) checkAuthorizedKeys(conn ssh.ConnMetadata, pk ssh.PublicKey) error {\n\tif a.authorizedKeys == nil {\n\t\treturn nil\n\t}\n\n\t// Only HOST connections (uptermd hosts registering with the proxy) are gated by authorized_keys.\n\tif string(conn.ClientVersion()) != upterm.HostSSHClientVersion {\n\t\treturn nil\n\t}\n\n\tfp := publicKeyFingerprint(pk)\n\tif _, ok := a.authorizedKeys[fp]; ok {\n\t\ta.Logger.Info(\"access granted\", \"fingerprint\", fp)\n\t\treturn nil\n\t}\n\n\ta.Logger.Warn(\"access denied\", \"fingerprint\", fp)\n\treturn fmt.Errorf(\"public key is not authorized\")\n}\n\n// publicKeyFingerprint returns the SHA256 fingerprint of the underlying\n// public key, unwrapping any SSH certificate. authorized_keys files contain\n// raw key entries, but hosts authenticating with a CertSigner (commonly\n// supplied by ssh-agent) present a certificate; matching must be done on\n// the underlying key identity, not the certificate blob.\nfunc publicKeyFingerprint(pk ssh.PublicKey) string {\n\tif cert, ok := pk.(*ssh.Certificate); ok {\n\t\tpk = cert.Key\n\t}\n\treturn utils.FingerprintSHA256(pk)\n}\n\n// loadAuthorizedKeys reads the configured authorized_keys files once at\n// startup and returns the set of SHA256 fingerprints permitted to register\n// as hosts. Returns nil when paths is empty, signaling that the gate is\n// disabled. Edits to the files require restarting uptermd to take effect.\nfunc loadAuthorizedKeys(paths []string) (map[string]struct{}, error) {\n\tif len(paths) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tfps := make(map[string]struct{})\n\tfor _, path := range paths {\n\t\trest, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"read authorized_keys %s: %w\", path, err)\n\t\t}\n\n\t\tfor len(rest) > 0 {\n\t\t\tpk, _, _, next, perr := ssh.ParseAuthorizedKey(rest)\n\t\t\tif perr != nil {\n\t\t\t\t// No more parseable keys (trailing comments, blanks, or junk).\n\t\t\t\tbreak\n\t\t\t}\n\t\t\trest = next\n\t\t\tfps[publicKeyFingerprint(pk)] = struct{}{}\n\t\t}\n\t}\n\treturn fps, nil\n}\n\nfunc (a authPiper) PublicKeyCallback(conn ssh.ConnMetadata, pk ssh.PublicKey, challengeCtx ssh.ChallengeContext) (*ssh.Upstream, error) {\n\tchecker := UserCertChecker{\n\t\tUserKeyFallback: func(user string, key ssh.PublicKey) (ssh.PublicKey, error) {\n\t\t\treturn key, nil\n\t\t},\n\t}\n\n\t// Gate registration based on authorized_keys before any cert/upstream work.\n\tif err := a.checkAuthorizedKeys(conn, pk); err != nil {\n\t\treturn nil, err\n\t}\n\n\tauth, key, err := checker.Authenticate(conn.User(), pk)\n\tif err == errCertNotSignedByHost {\n\t\terr = nil\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error checking user cert: %w\", err)\n\t}\n\n\t// Use the public-key if a key can't be parsed from cert\n\tif key == nil {\n\t\tkey = pk\n\t}\n\n\tif auth == nil {\n\t\tauth = &AuthRequest{\n\t\t\tClientVersion: string(conn.ClientVersion()),\n\t\t\tRemoteAddr:    conn.RemoteAddr().String(),\n\t\t\tAuthorizedKey: ssh.MarshalAuthorizedKey(key),\n\t\t}\n\t}\n\n\thostSess, err := a.hostSession(conn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// TODO: simplify auth key validation by moving it to host validation only\n\tif hostSess != nil && !hostSess.IsClientKeyAllowed(key) {\n\t\treturn nil, fmt.Errorf(\"public key not allowed\")\n\t}\n\n\tsigners, err := a.newUserCertSigners(conn, auth)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating cert signers: %w\", err)\n\t}\n\n\tc, err := a.dialUpstream(conn)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error dialing upstream: %w\", err)\n\t}\n\n\thostKeyCb := func(hostname string, remote net.Addr, key ssh.PublicKey) error {\n\t\tif hostSess == nil {\n\t\t\t// check host keys for sideway connections\n\t\t\tfor _, s := range a.HostSigners {\n\t\t\t\tif utils.KeysEqual(key, s.PublicKey()) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tfor _, pk := range hostSess.HostPublicKeys {\n\t\t\t\tif utils.KeysEqual(key, pk) {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn fmt.Errorf(\"ssh: host key mismatch\")\n\t}\n\n\treturn &ssh.Upstream{\n\t\tConn:    c,\n\t\tAddress: conn.RemoteAddr().String(),\n\t\tClientConfig: ssh.ClientConfig{\n\t\t\tHostKeyCallback: hostKeyCb,\n\t\t\tAuth:            []ssh.AuthMethod{ssh.PublicKeys(signers...)},\n\t\t},\n\t}, nil\n}\n\nfunc (a *authPiper) dialUpstream(conn ssh.ConnMetadata) (net.Conn, error) {\n\tvar (\n\t\tuser          = conn.User()\n\t\tclientVersion = string(conn.ClientVersion())\n\t)\n\n\t// Determine connection type and create identifier accordingly\n\tvar id *api.Identifier\n\tif clientVersion == upterm.HostSSHClientVersion {\n\t\t// HOST connection: user is the session ID\n\t\tid = &api.Identifier{\n\t\t\tId:   user,\n\t\t\tType: api.Identifier_HOST,\n\t\t}\n\t} else {\n\t\t// CLIENT connection: decode the SSH user\n\t\tsessionID, nodeAddr, err := a.SessionManager.ResolveSSHUser(user)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error resolving SSH user %s: %w\", user, err)\n\t\t}\n\n\t\tid = &api.Identifier{\n\t\t\tId:       sessionID,\n\t\t\tNodeAddr: nodeAddr,\n\t\t\tType:     api.Identifier_CLIENT,\n\t\t}\n\t}\n\n\tc, err := a.ConnDialer.Dial(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn c, nil\n}\n\nfunc (a authPiper) newUserCertSigners(conn ssh.ConnMetadata, auth *AuthRequest) ([]ssh.Signer, error) {\n\tvar certSigners []ssh.Signer\n\tfor _, s := range a.Signers {\n\t\tucs := UserCertSigner{\n\t\t\tSessionID:   string(conn.SessionID()),\n\t\t\tUser:        conn.User(),\n\t\t\tAuthRequest: auth,\n\t\t}\n\n\t\tcs, err := ucs.SignCert(s)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tcertSigners = append(certSigners, cs)\n\t}\n\n\treturn certSigners, nil\n}\n\n// hostSession returns a session if the routing is required to be done on client side and the current\n// is proxy node.\nfunc (a *authPiper) hostSession(conn ssh.ConnMetadata) (*Session, error) {\n\tuser := conn.User()\n\tclientVersion := string(conn.ClientVersion())\n\n\t// HOST connections don't validate authorized keys\n\tif clientVersion == upterm.HostSSHClientVersion {\n\t\treturn nil, nil\n\t}\n\n\t// CLIENT connection: decode the SSH user to get session ID and node address\n\tsessionID, nodeAddr, err := a.SessionManager.ResolveSSHUser(user)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error decoding SSH user %s: %w\", user, err)\n\t}\n\n\t// Don't validate authorized key if the node does not match the request that routing is needed\n\tif a.NodeAddr != nodeAddr {\n\t\treturn nil, nil\n\t}\n\n\treturn a.SessionManager.GetSession(sessionID)\n}\n"
  },
  {
    "path": "server/sshproxy_test.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-kit/kit/metrics/provider\"\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/rs/xid\"\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\tHostPublicKeyContent  = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOA+rMcwWFPJVE2g6EwRPkYmNJfaS/+gkyZ99aR/65uz`\n\tHostPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDgPqzHMFhTyVRNoOhMET5GJjSX2kv/oJMmffWkf+ubswAAAIiu5GOBruRj\ngQAAAAtzc2gtZWQyNTUxOQAAACDgPqzHMFhTyVRNoOhMET5GJjSX2kv/oJMmffWkf+ubsw\nAAAEDBHlsR95C/pGVHtQGpgrUi+Qwgkfnp9QlRKdEhhx4rxOA+rMcwWFPJVE2g6EwRPkYm\nNJfaS/+gkyZ99aR/65uzAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----`\n)\n\nfunc Test_sshProxy_dialUpstream(t *testing.T) {\n\tlogger := logging.Must(logging.Console(), logging.Debug()).Logger\n\n\tsigner, err := ssh.ParsePrivateKey([]byte(TestPrivateKeyContent))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcs := HostCertSigner{\n\t\tHostnames: []string{\"127.0.0.1\"},\n\t}\n\thostSigner, err := cs.SignCert(signer)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tproxyLn, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\t_ = proxyLn.Close()\n\t}()\n\n\tproxyAddr := proxyLn.Addr().String()\n\tcd := sidewayConnDialer{\n\t\tNodeAddr:        proxyAddr,\n\t\tNeighbourDialer: tcpConnDialer{},\n\t\tLogger:          logger,\n\t}\n\tproxy := &sshProxy{\n\t\tHostSigners:     []ssh.Signer{hostSigner},\n\t\tSigners:         []ssh.Signer{signer},\n\t\tSessionManager:  newEmbeddedSessionManager(logger),\n\t\tNodeAddr:        proxyAddr,\n\t\tConnDialer:      cd,\n\t\tLogger:          logger,\n\t\tMetricsProvider: provider.NewDiscardProvider(),\n\t}\n\n\tgo func() {\n\t\t_ = proxy.Serve(proxyLn)\n\t}()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\n\tif err := utils.WaitForServer(ctx, proxyAddr); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tsshLn, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\t_ = sshLn.Close()\n\t}()\n\n\tsshdAddr := sshLn.Addr().String()\n\tsshd := &sshd{\n\t\tSessionManager: newEmbeddedSessionManager(logger),\n\t\tHostSigners:    []ssh.Signer{signer},\n\t\tNodeAddr:       sshdAddr,\n\t\tLogger:         logger,\n\t}\n\n\tgo func() {\n\t\t_ = sshd.Serve(sshLn)\n\t}()\n\n\tif err := utils.WaitForServer(ctx, sshdAddr); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tencoder := routing.NewEncodeDecoder(routing.ModeEmbedded)\n\tuser := encoder.Encode(xid.New().String(), sshdAddr)\n\tucs, err := testCertSigner(user, signer)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcases := []struct {\n\t\tName   string\n\t\tSigner ssh.Signer\n\t}{\n\t\t{\n\t\t\tName:   \"public-key auth\",\n\t\t\tSigner: signer,\n\t\t},\n\t\t{\n\t\t\tName:   \"public-key user cert auth\",\n\t\t\tSigner: ucs,\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tcc := c\n\n\t\tt.Run(c.Name, func(t *testing.T) {\n\t\t\tconfig := &ssh.ClientConfig{\n\t\t\t\tUser:            user,\n\t\t\t\tAuth:            []ssh.AuthMethod{ssh.PublicKeys(cc.Signer)},\n\t\t\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(),\n\t\t\t}\n\t\t\tclient, err := ssh.Dial(\"tcp\", proxyAddr, config) // proxy to sshd\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\t_, err = client.NewSession()\n\t\t\tif err == nil || !strings.Contains(err.Error(), \"unsupported channel type\") {\n\t\t\t\tt.Fatalf(\"expect unsupported channel type error but got %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testCertSigner(user string, signer ssh.Signer) (ssh.Signer, error) {\n\tcert := &ssh.Certificate{\n\t\tKey:             signer.PublicKey(),\n\t\tCertType:        ssh.UserCert,\n\t\tKeyId:           \"1234\",\n\t\tValidPrincipals: []string{user},\n\t\tValidBefore:     ssh.CertTimeInfinity,\n\t}\n\n\tif err := cert.SignCert(rand.Reader, signer); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ssh.NewCertSigner(cert, signer)\n}\n\n// fakeConnMetadata is a minimal ssh.ConnMetadata stub for unit-testing\n// authPiper.checkAuthorizedKeys, which only consults ClientVersion.\ntype fakeConnMetadata struct {\n\tclientVersion string\n}\n\nfunc (f *fakeConnMetadata) User() string          { return \"\" }\nfunc (f *fakeConnMetadata) SessionID() []byte     { return nil }\nfunc (f *fakeConnMetadata) ClientVersion() []byte { return []byte(f.clientVersion) }\nfunc (f *fakeConnMetadata) ServerVersion() []byte { return nil }\nfunc (f *fakeConnMetadata) RemoteAddr() net.Addr  { return nil }\nfunc (f *fakeConnMetadata) LocalAddr() net.Addr   { return nil }\n\nfunc writeKeyFile(t *testing.T, content string) string {\n\tt.Helper()\n\tpath := filepath.Join(t.TempDir(), \"authorized_keys\")\n\trequire.NoError(t, os.WriteFile(path, []byte(content), 0600))\n\treturn path\n}\n\nfunc Test_loadAuthorizedKeys(t *testing.T) {\n\thostSigner, err := ssh.ParsePrivateKey([]byte(HostPrivateKeyContent))\n\trequire.NoError(t, err)\n\thostFp := utils.FingerprintSHA256(hostSigner.PublicKey())\n\n\totherSigner, err := ssh.ParsePrivateKey([]byte(TestPrivateKeyContent))\n\trequire.NoError(t, err)\n\totherPubKeyLine := string(ssh.MarshalAuthorizedKey(otherSigner.PublicKey()))\n\totherFp := utils.FingerprintSHA256(otherSigner.PublicKey())\n\n\tt.Run(\"nil paths returns nil set (gate disabled)\", func(t *testing.T) {\n\t\tgot, err := loadAuthorizedKeys(nil)\n\t\trequire.NoError(t, err)\n\t\trequire.Nil(t, got)\n\t})\n\n\tt.Run(\"single file is parsed into fingerprint set\", func(t *testing.T) {\n\t\tgot, err := loadAuthorizedKeys([]string{writeKeyFile(t, HostPublicKeyContent+\"\\n\")})\n\t\trequire.NoError(t, err)\n\t\trequire.Contains(t, got, hostFp)\n\t\trequire.Len(t, got, 1)\n\t})\n\n\tt.Run(\"multiple files are unioned\", func(t *testing.T) {\n\t\tgot, err := loadAuthorizedKeys([]string{\n\t\t\twriteKeyFile(t, HostPublicKeyContent+\"\\n\"),\n\t\t\twriteKeyFile(t, otherPubKeyLine),\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Contains(t, got, hostFp)\n\t\trequire.Contains(t, got, otherFp)\n\t})\n\n\tt.Run(\"comment-only file yields empty set, not an error\", func(t *testing.T) {\n\t\tgot, err := loadAuthorizedKeys([]string{writeKeyFile(t, \"# header\\n# nothing else\\n\")})\n\t\trequire.NoError(t, err)\n\t\trequire.Empty(t, got)\n\t})\n\n\tt.Run(\"missing file fails fast\", func(t *testing.T) {\n\t\t_, err := loadAuthorizedKeys([]string{filepath.Join(t.TempDir(), \"does-not-exist\")})\n\t\trequire.Error(t, err)\n\t})\n}\n\nfunc Test_authPiper_checkAuthorizedKeys(t *testing.T) {\n\tlogger := logging.Must(logging.Console(), logging.Debug()).Logger\n\n\thostSigner, err := ssh.ParsePrivateKey([]byte(HostPrivateKeyContent))\n\trequire.NoError(t, err)\n\thostPubKey := hostSigner.PublicKey()\n\thostFp := utils.FingerprintSHA256(hostPubKey)\n\n\t// Wrap the host's key in a certificate to mimic an agent-provided CertSigner:\n\t// the SSH library passes the certificate (not the raw key) to PublicKeyCallback.\n\thostCertSigner, err := testCertSigner(\"host\", hostSigner)\n\trequire.NoError(t, err)\n\thostCertAsPubKey := hostCertSigner.PublicKey()\n\n\totherSigner, err := ssh.ParsePrivateKey([]byte(TestPrivateKeyContent))\n\trequire.NoError(t, err)\n\totherFp := utils.FingerprintSHA256(otherSigner.PublicKey())\n\n\thostMeta := &fakeConnMetadata{clientVersion: upterm.HostSSHClientVersion}\n\tclientMeta := &fakeConnMetadata{clientVersion: \"SSH-2.0-OpenSSH_9.0\"}\n\n\tcases := []struct {\n\t\tname      string\n\t\tkeys      map[string]struct{}\n\t\tmeta      ssh.ConnMetadata\n\t\toffered   ssh.PublicKey\n\t\twantGrant bool\n\t}{\n\t\t{\n\t\t\tname:      \"nil set bypasses gate\",\n\t\t\tkeys:      nil,\n\t\t\tmeta:      hostMeta,\n\t\t\toffered:   hostPubKey,\n\t\t\twantGrant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"non-host client version bypasses gate\",\n\t\t\tkeys:      map[string]struct{}{otherFp: {}}, // host key NOT in set\n\t\t\tmeta:      clientMeta,\n\t\t\toffered:   hostPubKey,\n\t\t\twantGrant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"host key present in set is granted\",\n\t\t\tkeys:      map[string]struct{}{hostFp: {}},\n\t\t\tmeta:      hostMeta,\n\t\t\toffered:   hostPubKey,\n\t\t\twantGrant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"host key absent from non-empty set is denied\",\n\t\t\tkeys:      map[string]struct{}{otherFp: {}},\n\t\t\tmeta:      hostMeta,\n\t\t\toffered:   hostPubKey,\n\t\t\twantGrant: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"empty (non-nil) set denies all hosts\",\n\t\t\tkeys:      map[string]struct{}{},\n\t\t\tmeta:      hostMeta,\n\t\t\toffered:   hostPubKey,\n\t\t\twantGrant: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"cert is matched against its underlying key (in set)\",\n\t\t\tkeys:      map[string]struct{}{hostFp: {}},\n\t\t\tmeta:      hostMeta,\n\t\t\toffered:   hostCertAsPubKey,\n\t\t\twantGrant: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"cert is matched against its underlying key (absent)\",\n\t\t\tkeys:      map[string]struct{}{otherFp: {}},\n\t\t\tmeta:      hostMeta,\n\t\t\toffered:   hostCertAsPubKey,\n\t\t\twantGrant: false,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\ta := authPiper{\n\t\t\t\tauthorizedKeys: tc.keys,\n\t\t\t\tLogger:         logger,\n\t\t\t}\n\t\t\terr := a.checkAuthorizedKeys(tc.meta, tc.offered)\n\t\t\tif tc.wantGrant {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t} else {\n\t\t\t\trequire.Error(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/sshrouting.go",
    "content": "package server\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/go-kit/kit/metrics\"\n\t\"github.com/go-kit/kit/metrics/provider\"\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nvar (\n\tErrListnerClosed        = errors.New(\"routing: listener closed\")\n\tpipeEstablishingTimeout = 60 * time.Second\n)\n\ntype SSHRouting struct {\n\tHostSigners     []ssh.Signer\n\tAuthPiper       *authPiper\n\tDecoder         routing.Decoder\n\tLogger          *slog.Logger\n\tMetricsProvider provider.Provider\n\n\tlistener net.Listener\n\tmux      sync.Mutex\n\tdoneChan chan struct{}\n}\n\ntype routingInstruments struct {\n\tconnections        metrics.Counter\n\tactiveConnections  metrics.Gauge\n\tconnectionDuration metrics.Histogram\n\terrors             metrics.Counter\n\tconnectionTimeouts metrics.Counter\n}\n\nfunc newSSHRoutingInstruments(p provider.Provider) *routingInstruments {\n\treturn &routingInstruments{\n\t\tconnections:        p.NewCounter(\"routing_connections_count\"),\n\t\terrors:             p.NewCounter(\"routing_errors_count\"),\n\t\tactiveConnections:  p.NewGauge(\"routing_active_connections_count\"),\n\t\tconnectionDuration: p.NewHistogram(\"routing_connection_duration_seconds\", 50),\n\t\tconnectionTimeouts: p.NewCounter(\"routing_connection_timeout_count\"),\n\t}\n}\n\nfunc (p *SSHRouting) Serve(ln net.Listener) error {\n\tp.mux.Lock()\n\tp.listener = ln\n\tp.mux.Unlock()\n\n\tpiperCfg := &ssh.PiperConfig{\n\t\tPublicKeyCallback: p.AuthPiper.PublicKeyCallback,\n\t\tServerVersion:     version.ServerSSHVersion(),\n\t\tNextAuthMethods: func(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext) ([]string, error) {\n\t\t\t// Fail early if the user is not a valid identifier.\n\t\t\tuser := conn.User()\n\t\t\tif user != \"\" {\n\t\t\t\tclientVersion := string(conn.ClientVersion())\n\n\t\t\t\t// HOST connections: just validate user is not empty\n\t\t\t\tif clientVersion == upterm.HostSSHClientVersion {\n\t\t\t\t\tif user == \"\" {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"empty session ID for host connection\")\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// CLIENT connections: validate SSH user format\n\t\t\t\t\t_, _, err := p.Decoder.Decode(user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"invalid SSH user format: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn []string{\"publickey\"}, nil\n\t\t},\n\t}\n\tfor _, s := range p.HostSigners {\n\t\tpiperCfg.AddHostKey(s)\n\t}\n\n\tinst := newSSHRoutingInstruments(p.MetricsProvider)\n\n\tvar tempDelay time.Duration // how long to sleep on accept failure\n\tfor {\n\t\tdconn, err := ln.Accept()\n\t\tif err != nil {\n\t\t\tselect {\n\t\t\tcase <-p.getDoneChan():\n\t\t\t\treturn ErrListnerClosed\n\t\t\tdefault:\n\t\t\t}\n\n\t\t\tif ne, ok := err.(net.Error); ok && ne.Timeout() {\n\t\t\t\tif tempDelay == 0 {\n\t\t\t\t\ttempDelay = 5 * time.Millisecond\n\t\t\t\t} else {\n\t\t\t\t\ttempDelay *= 2\n\t\t\t\t}\n\t\t\t\tif max := 1 * time.Second; tempDelay > max {\n\t\t\t\t\ttempDelay = max\n\t\t\t\t}\n\t\t\t\tp.Logger.Error(\"tcp: Accept error; retrying\", \"error\", err, \"retry_delay\", tempDelay)\n\t\t\t\ttime.Sleep(tempDelay)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tp.Logger.Error(\"failed to accept connection\", \"error\", err)\n\t\t\tinst.errors.Add(1)\n\t\t\treturn err\n\t\t}\n\n\t\ttempDelay = 0\n\n\t\tlogger := p.Logger.With(\"addr\", dconn.RemoteAddr())\n\t\tgo func(dconn net.Conn, inst *routingInstruments, logger *slog.Logger) {\n\t\t\tstartTime := time.Now()\n\n\t\t\tdefer func() {\n\t\t\t\t_ = dconn.Close()\n\t\t\t}()\n\t\t\tdefer func() {\n\t\t\t\t// Track connection duration in seconds\n\t\t\t\tdurationSec := time.Since(startTime).Seconds()\n\t\t\t\tinst.connectionDuration.Observe(durationSec)\n\t\t\t}()\n\t\t\tdefer inst.activeConnections.Add(-1)\n\n\t\t\tinst.connections.Add(1)\n\t\t\tinst.activeConnections.Add(1)\n\n\t\t\tpipec := make(chan *ssh.PiperConn)\n\t\t\terrorc := make(chan error)\n\n\t\t\tgo func() {\n\t\t\t\tdefer func() {\n\t\t\t\t\tclose(pipec)\n\t\t\t\t\tclose(errorc)\n\t\t\t\t}()\n\n\t\t\t\tpconn, err := ssh.NewSSHPiperConn(dconn, piperCfg)\n\t\t\t\tif err != nil {\n\t\t\t\t\terrorc <- err\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tpipec <- pconn\n\t\t\t}()\n\n\t\t\tselect {\n\t\t\tcase pconn := <-pipec:\n\t\t\t\tdefer pconn.Close()\n\n\t\t\t\tif err := pconn.Wait(); err != nil {\n\t\t\t\t\tlogger.Debug(\"error waiting for pipe\", \"error\", err)\n\t\t\t\t\tinst.errors.Add(1)\n\t\t\t\t}\n\t\t\tcase err := <-errorc:\n\t\t\t\tlogger.Debug(\"connection establishing failed\", \"error\", err)\n\t\t\t\tinst.errors.Add(1)\n\t\t\tcase <-time.After(pipeEstablishingTimeout):\n\t\t\t\tlogger.Debug(\"pipe establishing timeout\")\n\t\t\t\tinst.connectionTimeouts.Add(1)\n\t\t\t}\n\t\t}(dconn, inst, logger)\n\t}\n}\n\nfunc (p *SSHRouting) Shutdown() error {\n\tp.mux.Lock()\n\tlnerr := p.closeListenersLocked()\n\tp.closeDoneChanLocked()\n\tp.mux.Unlock()\n\n\treturn lnerr\n}\n\nfunc (p *SSHRouting) getDoneChan() <-chan struct{} {\n\tp.mux.Lock()\n\tdefer p.mux.Unlock()\n\n\treturn p.getDoneChanLocked()\n}\n\nfunc (p *SSHRouting) getDoneChanLocked() chan struct{} {\n\tif p.doneChan == nil {\n\t\tp.doneChan = make(chan struct{})\n\t}\n\n\treturn p.doneChan\n}\n\nfunc (p *SSHRouting) closeDoneChanLocked() {\n\tch := p.getDoneChanLocked()\n\tselect {\n\tcase <-ch:\n\t\t// Already closed. Don't close again.\n\tdefault:\n\t\t// Safe to close here. We're the only closer, guarded\n\t\t// by p.mux.\n\t\tclose(ch)\n\t}\n}\n\nfunc (p *SSHRouting) closeListenersLocked() error {\n\treturn p.listener.Close()\n}\n"
  },
  {
    "path": "server/wsproxy.go",
    "content": "package server\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/oklog/run\"\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owenthereal/upterm/routing\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"github.com/owenthereal/upterm/ws\"\n\t\"log/slog\"\n)\n\ntype webSocketProxy struct {\n\tConnDialer     connDialer\n\tSessionManager *SessionManager\n\tLogger         *slog.Logger\n\n\tsrv *http.Server\n\tmux sync.Mutex\n}\n\nfunc webHandler(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch {\n\t\tcase r.URL.Path == \"/health\":\n\t\t\tw.Header().Set(\"Content-Type\", \"text/plain\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_, _ = w.Write([]byte(\"OK\"))\n\t\t\treturn\n\t\tcase strings.HasPrefix(r.URL.Path, \"/getting-started\"):\n\t\t\tw.Header().Add(\"Content-Type\", \"text/plain\")\n\t\t\t// TODO: better getting-started guide\n\t\t\tdata := `1. Install the upterm CLI by following https://github.com/owenthereal/upterm#installation.\n2. On your machine, host a session with \"upterm host --server wss://%s -- YOUR_COMMAND\". More details in https://github.com/owenthereal/upterm#quick-start.\n3. Your pair(s) join the session with \"ssh -o ProxyCommand='upterm proxy wss://TOKEN@%s' TOKEN@%s:443\".\n`\n\t\t\t_, _ = fmt.Fprintf(w, data, r.Host, r.Host, r.Host)\n\t\t\treturn\n\t\tdefault:\n\t\t\th.ServeHTTP(w, r)\n\t\t}\n\t})\n}\n\nfunc (s *webSocketProxy) Serve(ln net.Listener) error {\n\ts.mux.Lock()\n\ts.srv = &http.Server{\n\t\tHandler: webHandler(&wsHandler{\n\t\t\tConnDialer:     s.ConnDialer,\n\t\t\tSessionManager: s.SessionManager,\n\t\t\tLogger:         s.Logger,\n\t\t}),\n\t}\n\ts.mux.Unlock()\n\n\treturn s.srv.Serve(ln)\n}\n\nfunc (s *webSocketProxy) Shutdown() error {\n\ts.mux.Lock()\n\tdefer s.mux.Unlock()\n\n\tif s.srv != nil {\n\t\tctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(serverShutDownDeadline))\n\t\tdefer cancel()\n\n\t\treturn s.srv.Shutdown(ctx)\n\t}\n\n\treturn nil\n}\n\nvar upgrader = websocket.Upgrader{\n\tReadBufferSize:  1024,\n\tWriteBufferSize: 1024,\n\tCheckOrigin:     func(r *http.Request) bool { return true },\n}\n\ntype wsHandler struct {\n\tConnDialer     connDialer\n\tSessionManager *SessionManager\n\tLogger         *slog.Logger\n}\n\n// ServeHTTP checks the following header:\n// * Authorization\n// * Upterm-Client-Version\nfunc (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tclientVersion := r.Header.Get(upterm.HeaderUptermClientVersion)\n\tif clientVersion == \"\" {\n\t\th.httpError(w, fmt.Errorf(\"missing upterm client version\"))\n\t\treturn\n\t}\n\n\tuser, pass, ok := r.BasicAuth()\n\tif !ok {\n\t\th.httpError(w, fmt.Errorf(\"basic auth failed\"))\n\t\treturn\n\t}\n\n\tsshUser := user\n\tif h.SessionManager.GetRoutingMode() == routing.ModeEmbedded {\n\t\tsshUser = user + \":\" + pass\n\t}\n\n\twsc, err := upgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\th.httpError(w, fmt.Errorf(\"ws upgrade failed\"))\n\t\treturn\n\t}\n\twsconn := ws.WrapWSConn(wsc)\n\tdefer func() {\n\t\t_ = wsconn.Close()\n\t}()\n\n\t// Determine connection type and decode identifier using SessionManager\n\tvar id *api.Identifier\n\tif string(clientVersion) == upterm.HostSSHClientVersion {\n\t\t// HOST connection: sshUser is the session ID\n\t\tid = &api.Identifier{\n\t\t\tId:   sshUser,\n\t\t\tType: api.Identifier_HOST,\n\t\t}\n\t} else {\n\t\t// CLIENT connection: decode the SSH user using SessionManager\n\t\tsessionID, nodeAddr, err := h.SessionManager.ResolveSSHUser(sshUser)\n\t\tif err != nil {\n\t\t\th.wsError(wsc, fmt.Errorf(\"error resolving SSH user %s: %w\", sshUser, err), \"error resolving SSH user\")\n\t\t\treturn\n\t\t}\n\n\t\tid = &api.Identifier{\n\t\t\tId:       sessionID,\n\t\t\tNodeAddr: nodeAddr,\n\t\t\tType:     api.Identifier_CLIENT,\n\t\t}\n\t}\n\n\tconn, err := h.ConnDialer.Dial(id)\n\tif err != nil {\n\t\th.wsError(wsc, err, \"error dialing\")\n\t\treturn\n\t}\n\n\tvar o sync.Once\n\tcl := func() {\n\t\t_ = wsconn.Close()\n\t\t_ = conn.Close()\n\t}\n\n\tvar g run.Group\n\t{\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(wsconn, conn)\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\to.Do(cl)\n\t\t})\n\t}\n\t{\n\t\tg.Add(func() error {\n\t\t\t_, err := io.Copy(conn, wsconn)\n\t\t\treturn err\n\t\t}, func(err error) {\n\t\t\to.Do(cl)\n\t\t})\n\t}\n\n\tif err := g.Run(); err != nil {\n\t\th.wsError(wsc, err, \"error piping\")\n\t}\n}\n\nfunc (h *wsHandler) httpError(w http.ResponseWriter, err error) {\n\th.Logger.Error(\"http error\", \"error\", err)\n\tw.WriteHeader(400)\n\t_, _ = w.Write([]byte(err.Error()))\n}\n\nfunc (h *wsHandler) wsError(ws *websocket.Conn, err error, msg string) {\n\th.Logger.Error(msg, \"error\", err)\n\t_ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, err.Error()))\n}\n"
  },
  {
    "path": "server/wsproxy_test.go",
    "content": "package server\n\nimport (\n\t\"bufio\"\n\t\"io\"\n\t\"net\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/owenthereal/upterm/internal/logging\"\n\t\"github.com/owenthereal/upterm/ws\"\n\t\"google.golang.org/grpc/test/bufconn\"\n)\n\ntype testSshdDialListener struct {\n\t*bufconn.Listener\n}\n\nfunc (l *testSshdDialListener) Dial() (net.Conn, error) {\n\treturn l.Listener.Dial()\n}\n\nfunc (l *testSshdDialListener) Listen() (net.Listener, error) {\n\treturn l.Listener, nil\n}\n\ntype testSessionDialListener struct {\n\t*bufconn.Listener\n}\n\nfunc (l *testSessionDialListener) Dial(id string) (net.Conn, error) {\n\treturn l.Listener.Dial()\n}\n\nfunc (l *testSessionDialListener) Listen(id string) (net.Listener, error) {\n\treturn l.Listener, nil\n}\n\nfunc Test_WebSocketProxy_Host(t *testing.T) {\n\ttestLogger := logging.Must(logging.Console(), logging.Debug()).Logger\n\tcd := sidewayConnDialer{\n\t\tSSHDDialListener:    &testSshdDialListener{bufconn.Listen(1024)},\n\t\tSessionDialListener: &testSessionDialListener{bufconn.Listen(1024)},\n\t\tLogger:              testLogger,\n\t}\n\tlogger := testLogger\n\twsh := &wsHandler{\n\t\tConnDialer:     cd,\n\t\tSessionManager: newEmbeddedSessionManager(logger),\n\t\tLogger:         logger,\n\t}\n\tts := httptest.NewServer(wsh)\n\tdefer ts.Close()\n\n\tu, err := url.Parse(ts.URL)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tu.Scheme = \"ws\"\n\tu.User = url.UserPassword(\"owen\", \"\")\n\n\twsc, err := ws.NewWSConn(u, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr, rw := io.Pipe()\n\trs := bufio.NewScanner(rr)\n\tgo func(wsc net.Conn, w io.Writer) {\n\t\t_, _ = io.Copy(w, wsc)\n\t}(wsc, rw)\n\n\tln, err := cd.SSHDDialListener.Listen()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tconn, err := ln.Accept()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twr, ww := io.Pipe()\n\tws := bufio.NewScanner(wr)\n\tgo func() {\n\t\t_, _ = io.Copy(ww, conn)\n\t}()\n\n\t// test read\n\t_, _ = conn.Write([]byte(\"read\\n\")) // need CR because func scan scans by line\n\tif diff := cmp.Diff(\"read\", scan(rs)); diff != \"\" {\n\t\tt.Fatal(diff)\n\t}\n\n\t// test write\n\tif _, err := wsc.Write([]byte(\"write\\n\")); err != nil { // need CR because func scan scans by line\n\t\tt.Fatal(err)\n\t}\n\tif diff := cmp.Diff(\"write\", scan(ws)); diff != \"\" {\n\t\tt.Fatal(diff)\n\t}\n}\n\nfunc scan(s *bufio.Scanner) string {\n\tfor s.Scan() {\n\t\treturn s.Text()\n\t}\n\n\treturn s.Err().Error()\n}\n"
  },
  {
    "path": "systemd/uptermd.service",
    "content": "[Unit]\nDescription=upterm secure terminal sharing\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nExecStart=/usr/bin/uptermd --ssh-addr 0.0.0.0:2222\n\nIPAccounting=yes\nIPAddressAllow=localhost\nIPAddressDeny=any\nDynamicUser=yes\nPrivateTmp=yes\nPrivateUsers=yes\nPrivateDevices=yes\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=yes\nProtectClock=yes\nProtectControlGroups=yes\nProtectKernelLogs=yes\nProtectKernelModules=yes\nProtectKernelTunables=yes\nProtectProc=invisible\nCapabilityBoundingSet=CAP_NET_BIND_SERVICE\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "terraform/digitalocean/charts.tf",
    "content": "locals {\n  ingress_nginx_values = {\n    controller = {\n      ingressClassResource = {\n        name = \"nginx\"\n        controllerValue : \"k8s.io/ingress-nginx\"\n      }\n\n      admissionWebhooks = {\n        enabled = false\n      }\n\n      service = {\n        type = \"LoadBalancer\"\n        annotations = {\n          \"service.beta.kubernetes.io/do-loadbalancer-name\"     = \"${var.do_k8s_name}-lb\"\n          \"service.beta.kubernetes.io/do-loadbalancer-protocol\" = \"tcp\"\n        }\n      }\n    }\n\n    tcp = {\n      22 = \"uptermd/uptermd:22\"\n    }\n  }\n\n  cert_manager_values = {\n    installCRDs = true\n    global = {\n      leaderElection = {\n        namespace = \"cert-manager\"\n      }\n    }\n  }\n\n  metrics_server_values = {\n    extraArgs = {\n      \"kubelet-preferred-address-types\" = \"InternalIP\"\n    }\n  }\n\n  uptermd_values = {\n    image = {\n      repository = \"ghcr.io/owenthereal/upterm/uptermd\"\n      tag        = data.github_release.upterm.release_tag\n    }\n    autoscaling = {\n      minReplicas = 2\n      maxReplicas = 5\n    }\n    hostname = var.uptermd_host\n    websocket = {\n      enabled                     = true\n      ingress_nginx_ingress_class = \"nginx\"\n      cert_manager_acme_email     = var.uptermd_acme_email\n    }\n    host_keys = {\n      for k, v in var.uptermd_host_keys :\n      k => v\n    }\n    debug = true\n  }\n}\n\ndata \"github_release\" \"upterm\" {\n  owner       = \"owenthereal\"\n  repository  = \"upterm\"\n  retrieve_by = \"latest\"\n}\n\nprovider \"helm\" {\n  kubernetes {\n    host                   = digitalocean_kubernetes_cluster.upterm.endpoint\n    token                  = digitalocean_kubernetes_cluster.upterm.kube_config[0].token\n    cluster_ca_certificate = base64decode(digitalocean_kubernetes_cluster.upterm.kube_config[0].cluster_ca_certificate)\n  }\n}\n\nresource \"helm_release\" \"ingress_nginx\" {\n  depends_on       = [digitalocean_kubernetes_cluster.upterm, local_file.kubeconfig]\n  name             = \"ingress-nginx\"\n  chart            = \"ingress-nginx\"\n  repository       = \"https://kubernetes.github.io/ingress-nginx\"\n  version          = \"4.0.16\"\n  namespace        = \"upterm-ingress-nginx\"\n  wait             = var.wait_for_k8s_resources\n  create_namespace = true\n  values           = [yamlencode(local.ingress_nginx_values)]\n}\n\nresource \"helm_release\" \"cert_manager\" {\n  depends_on       = [digitalocean_kubernetes_cluster.upterm, local_file.kubeconfig]\n  name             = \"cert-manager\"\n  chart            = \"cert-manager\"\n  repository       = \"https://charts.jetstack.io\"\n  version          = \"1.7.0\"\n  namespace        = \"cert-manager\"\n  wait             = var.wait_for_k8s_resources\n  create_namespace = true\n  values           = [yamlencode(local.cert_manager_values)]\n}\n\nresource \"helm_release\" \"upterm_metrics_server\" {\n  depends_on       = [digitalocean_kubernetes_cluster.upterm, local_file.kubeconfig]\n  name             = \"metrics-server\"\n  chart            = \"metrics-server\"\n  repository       = \"https://charts.bitnami.com/bitnami\"\n  version          = \"5.5.1\"\n  namespace        = \"metrics-server\"\n  wait             = var.wait_for_k8s_resources\n  create_namespace = true\n  values           = [yamlencode(local.metrics_server_values)]\n}\n\nresource \"helm_release\" \"uptermd\" {\n  depends_on       = [helm_release.ingress_nginx, helm_release.cert_manager, helm_release.upterm_metrics_server]\n  name             = \"uptermd\"\n  chart            = \"uptermd\"\n  repository       = var.uptermd_helm_repo\n  namespace        = \"uptermd\"\n  create_namespace = true\n  wait             = var.wait_for_k8s_resources\n  values           = [yamlencode(local.uptermd_values)]\n}\n"
  },
  {
    "path": "terraform/digitalocean/do.tf",
    "content": "data \"digitalocean_kubernetes_versions\" \"k8s_version\" {}\n\nresource \"digitalocean_kubernetes_cluster\" \"upterm\" {\n  name         = var.do_k8s_name\n  region       = var.do_region\n  auto_upgrade = false\n  version      = data.digitalocean_kubernetes_versions.k8s_version.latest_version\n\n  node_pool {\n    name       = \"autoscale-worker-pool\"\n    size       = var.do_node_size\n    auto_scale = true\n    min_nodes  = var.do_min_nodes\n    max_nodes  = var.do_max_nodes\n    tags       = [var.do_k8s_name]\n    labels     = { \"app\" = var.do_k8s_name }\n  }\n}\n\nresource \"local_file\" \"kubeconfig\" {\n  count                = var.write_kubeconfig ? 1 : 0\n  content              = digitalocean_kubernetes_cluster.upterm.kube_config[0].raw_config\n  filename             = var.kubeconfig_path\n  file_permission      = \"0644\"\n  directory_permission = \"0755\"\n}\n"
  },
  {
    "path": "terraform/digitalocean/output.tf",
    "content": "output \"kubeconfig\" {\n  depends_on = [digitalocean_kubernetes_cluster.upterm]\n  value      = digitalocean_kubernetes_cluster.upterm.kube_config[0].raw_config\n  sensitive  = true\n}\n"
  },
  {
    "path": "terraform/digitalocean/providers.tf",
    "content": "terraform {\n  required_providers {\n    digitalocean = {\n      source  = \"digitalocean/digitalocean\"\n      version = \"~> 2.0\"\n    }\n    helm = {\n      source  = \"hashicorp/helm\"\n      version = \"~> 2.0\"\n    }\n    github = {\n      source  = \"integrations/github\"\n      version = \"~> 4.0\"\n    }\n  }\n  required_version = \">= 0.13\"\n}\n\nprovider \"digitalocean\" {\n  token = var.do_token\n}\n"
  },
  {
    "path": "terraform/digitalocean/variables.tf",
    "content": "### Digital Ocean ###\nvariable \"do_token\" {}\n\nvariable \"do_region\" {\n  type    = string\n  default = \"sfo2\"\n}\n\nvariable \"do_k8s_name\" {\n  type    = string\n  default = \"upterm-cluster\"\n}\n\nvariable \"do_min_nodes\" {\n  type    = number\n  default = 1\n}\n\nvariable \"do_max_nodes\" {\n  type    = number\n  default = 3\n}\n\nvariable \"do_node_size\" {\n  type    = string\n  default = \"s-2vcpu-4gb\"\n}\n\nvariable \"write_kubeconfig\" {\n  type    = bool\n  default = false\n}\n\nvariable \"kubeconfig_path\" {\n  type    = string\n  default = \"~/.kube/config\"\n}\n### Digital Ocean ###\n\n### Charts ###\nvariable \"wait_for_k8s_resources\" {\n  type    = bool\n  default = true\n}\n\nvariable \"uptermd_host\" {\n  type = string\n}\n\nvariable \"uptermd_acme_email\" {\n  type = string\n}\n\nvariable \"uptermd_host_keys\" {\n  type        = map(string) # { filename=content }\n  description = \"Host keys in the format of {\\\"rsa_key.pub\\\"=\\\"...\\\", \\\"rsa_key\\\"=\\\"...\\\"}\"\n}\n\nvariable \"uptermd_helm_repo\" {\n  type        = string\n  default     = \"https://upterm.dev\"\n  description = \"Configurable for testing purpose\"\n}\n### Charts ###\n"
  },
  {
    "path": "terraform/heroku/main.tf",
    "content": "variable \"heroku_app_name\" {\n  description = \"Heroku app name\"\n}\n\nvariable \"heroku_region\" {\n  description = \"Heroku region\"\n  default     = \"us\"\n}\n\nvariable \"heroku_space\" {\n  description = \"Name of the Heroku space\"\n  default     = \"\"\n}\n\nvariable \"git_commit_sha\" {\n  description = \"Git commit sha on GitHub\"\n  default     = \"master\"\n}\n\nvariable \"heroku_team\" {\n  description = \"Heroku team\"\n  default     = \"\"\n}\n\nlocals {\n  app_id   = var.heroku_space == \"\" ? heroku_app.uptermd_common_runtime.*.id[0] : heroku_app.uptermd_private_spaces.*.id[0]\n  app_name = var.heroku_space == \"\" ? heroku_app.uptermd_common_runtime.*.name[0] : heroku_app.uptermd_private_spaces.*.name[0]\n}\n\nresource \"heroku_app\" \"uptermd_common_runtime\" {\n  count = var.heroku_team == \"\" ? 1 : 0\n\n  name       = var.heroku_app_name\n  region     = var.heroku_region\n  buildpacks = [\"heroku/go\"]\n  space      = var.heroku_space\n  acm        = false\n\n  sensitive_config_vars = {\n    PRIVATE_KEY = \"${tls_private_key.private_key.private_key_pem}\"\n  }\n}\n\nresource \"heroku_app\" \"uptermd_private_spaces\" {\n  count = var.heroku_team == \"\" ? 0 : 1\n\n  name       = var.heroku_app_name\n  region     = var.heroku_region\n  buildpacks = [\"heroku/go\"]\n  space      = var.heroku_space\n  acm        = false\n\n  sensitive_config_vars = {\n    PRIVATE_KEY = \"${tls_private_key.private_key.private_key_pem}\"\n  }\n\n  organization {\n    name = var.heroku_team\n  }\n}\n\nresource \"tls_private_key\" \"private_key\" {\n  algorithm = \"RSA\"\n  rsa_bits  = \"4096\"\n}\n\nresource \"heroku_app_feature\" \"spaces-dns-discovery\" {\n  app_id  = local.app_id\n  name    = \"spaces-dns-discovery\"\n  enabled = var.heroku_space == \"\" ? false : true\n}\n\nresource \"heroku_build\" \"uptermd\" {\n  app_id = local.app_id\n\n  source {\n    url     = \"https://github.com/owenthereal/upterm/archive/${var.git_commit_sha}.tar.gz\"\n    version = var.git_commit_sha\n  }\n}\n\nresource \"heroku_formation\" \"uptermd\" {\n  app_id     = local.app_id\n  type       = \"web\"\n  quantity   = var.heroku_space == \"\" ? 1 : 2\n  size       = var.heroku_space == \"\" ? \"eco\" : \"private-s\"\n  depends_on = [heroku_build.uptermd]\n}\n\noutput \"step_1_share_session\" {\n  value = \"upterm host --server wss://${local.app_name}.herokuapp.com -- YOUR_COMMAND\"\n}\n\noutput \"step_2_join_session\" {\n  value = \"ssh -o ProxyCommand='upterm proxy wss://TOKEN@${local.app_name}.herokuapp.com' TOKEN@${local.app_name}.herokuapp.com:443\"\n}\n"
  },
  {
    "path": "terraform/heroku/providers.tf",
    "content": "terraform {\n  required_providers {\n    heroku = {\n      source  = \"heroku/heroku\"\n      version = \"5.2.1\"\n    }\n  }\n}\n\nprovider \"heroku\" {\n}\n\nprovider \"tls\" {\n}\n"
  },
  {
    "path": "upterm/const.go",
    "content": "package upterm\n\nconst (\n\t// host\n\tHostSSHClientVersion  = \"SSH-2.0-upterm-host-client\"\n\tHostSSHServerVersion  = \"SSH-2.0-upterm-host-server\"\n\tHostAdminSocketEnvVar = \"UPTERM_ADMIN_SOCKET\"\n\n\t// client\n\tClientSSHClientVersion = \"SSH-2.0-upterm-client-client\"\n\n\t// server\n\tServerSSHServerVersion         = \"SSH-2.0-uptermd\"\n\tServerServerInfoRequestType    = \"upterm-server-info@upterm.dev\"\n\tServerCreateSessionRequestType = \"upterm-create-session@upterm.dev\"\n\n\t// header\n\tHeaderUptermClientVersion = \"Upterm-Client-Version\"\n\n\t// misc\n\tOpenSSHKeepAliveRequestType = \"keepalive@openssh.com\"\n\n\tSSHCertExtension = \"upterm-auth-request\"\n\n\tEventClientJoined          = \"client-joined\"\n\tEventClientLeft            = \"client-left\"\n\tEventTerminalWindowChanged = \"terminal-window-changed\"\n\tEventTerminalDetached      = \"terminal-detached\"\n)\n"
  },
  {
    "path": "utils/testing.go",
    "content": "package utils\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"time\"\n)\n\n// WaitForServer waits for a server to be available at the given address with context support\nfunc WaitForServer(ctx context.Context, addr string) error {\n\tticker := time.NewTicker(100 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn fmt.Errorf(\"timeout waiting for server at %s: %w\", addr, ctx.Err())\n\t\tcase <-ticker.C:\n\t\t\tconn, err := net.DialTimeout(\"tcp\", addr, 100*time.Millisecond)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif err := conn.Close(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error closing connection: %w\", err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "utils/utils.go",
    "content": "package utils\n\nimport (\n\t\"crypto/ed25519\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/adrg/xdg\"\n\tgssh \"github.com/charmbracelet/ssh\"\n\t\"github.com/dchest/uniuri\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\tlogFile    = \"upterm.log\"\n\tconfigFile = \"config.yaml\"\n\tappName    = \"upterm\"\n)\n\n// envGetter is a function type for getting environment variables.\n// This allows for dependency injection in tests.\ntype envGetter func(string) string\n\n// xdgDirWithFallbackEnv returns an XDG directory path with fallback to HOME-based directory.\n// It follows this priority:\n//  1. If envVar is explicitly set, use it (trust user configuration)\n//  2. If xdgPath exists and is accessible, use it\n//  3. Fall back to $HOME/.upterm\n//  4. Final fallback to os.TempDir()/.upterm (if HOME unavailable)\n//\n// This handles cases where XDG defaults point to system directories that may not exist\n// or be writable in non-interactive environments (e.g., /run/user/<uid> in CI/containers).\n//\n// The getenv parameter allows for dependency injection in tests.\nfunc xdgDirWithFallbackEnv(envVar, xdgPath string, getenv envGetter) string {\n\t// If user explicitly set the XDG env var, respect it unconditionally\n\t// Use the actual env var value, not the xdg library's cached value\n\tif envValue := getenv(envVar); envValue != \"\" {\n\t\treturn filepath.Join(envValue, appName)\n\t}\n\n\t// Check if the XDG default path exists before creating the appName subdirectory within it\n\tif _, err := os.Stat(xdgPath); err == nil {\n\t\treturn filepath.Join(xdgPath, appName)\n\t}\n\n\t// Fall back to home directory structure\n\thome, err := os.UserHomeDir()\n\tif err != nil || home == \"\" {\n\t\thome = xdg.Home // Try xdg library's cached value\n\t}\n\n\t// Final fallback: use temp directory if home is unavailable\n\tif home == \"\" {\n\t\thome = os.TempDir()\n\t}\n\n\treturn filepath.Join(home, \".\"+appName)\n}\n\n// xdgDirWithFallback returns an XDG directory path with fallback to HOME-based directory.\n// This is a convenience wrapper around xdgDirWithFallbackEnv that uses os.Getenv.\nfunc xdgDirWithFallback(envVar, xdgPath string) string {\n\treturn xdgDirWithFallbackEnv(envVar, xdgPath, os.Getenv)\n}\n\n// UptermRuntimeDir returns the directory for runtime files (sockets).\n//\n// Following the XDG Base Directory Specification, this directory maps to\n// XDG_RUNTIME_DIR/upterm when available and is typically cleaned on logout/reboot.\n//\n// Directory selection priority:\n//  1. $XDG_RUNTIME_DIR/upterm (if XDG_RUNTIME_DIR is explicitly set)\n//  2. Platform default if accessible:\n//     - Linux:   /run/user/1000/upterm (requires login session)\n//     - macOS:   $TMPDIR/upterm (e.g., /var/folders/.../T/upterm)\n//     - Windows: %LOCALAPPDATA%\\upterm\n//  3. Fallback: $HOME/.upterm (for non-interactive environments)\n//  4. Final fallback: os.TempDir()/.upterm (if HOME unavailable)\nfunc UptermRuntimeDir() string {\n\treturn xdgDirWithFallback(\"XDG_RUNTIME_DIR\", xdg.RuntimeDir)\n}\n\n// UptermStateDir returns the directory for state files (logs).\n//\n// Following the XDG Base Directory Specification, this directory maps to\n// XDG_STATE_HOME/upterm and persists across sessions.\n//\n// Directory selection priority:\n//  1. $XDG_STATE_HOME/upterm (if XDG_STATE_HOME is explicitly set)\n//  2. Platform default if accessible:\n//     - Linux:   ~/.local/state/upterm\n//     - macOS:   ~/Library/Application Support/upterm\n//     - Windows: %LOCALAPPDATA%\\upterm\n//  3. Fallback: $HOME/.upterm\n//  4. Final fallback: os.TempDir()/.upterm (if HOME unavailable)\nfunc UptermStateDir() string {\n\treturn xdgDirWithFallback(\"XDG_STATE_HOME\", xdg.StateHome)\n}\n\n// UptermLogFilePath returns the path to the log file in the state directory.\n//\n// Following the XDG Base Directory Specification, this file is stored in\n// XDG_STATE_HOME/upterm and persists across sessions.\n//\n// Platform-specific paths:\n//   - Linux:   ~/.local/state/upterm/upterm.log\n//   - macOS:   ~/Library/Application Support/upterm/upterm.log\n//   - Windows: %LOCALAPPDATA%\\upterm\\upterm.log\n//\n// Note: The directory is created lazily by the logging system when the file is opened.\nfunc UptermLogFilePath() string {\n\treturn filepath.Join(UptermStateDir(), logFile)\n}\n\n// UptermConfigDir returns the directory for configuration files.\n//\n// Following the XDG Base Directory Specification, this directory maps to\n// XDG_CONFIG_HOME/upterm and persists across sessions.\n//\n// Directory selection priority:\n//  1. $XDG_CONFIG_HOME/upterm (if XDG_CONFIG_HOME is explicitly set)\n//  2. Platform default if accessible:\n//     - Linux:   ~/.config/upterm\n//     - macOS:   ~/Library/Application Support/upterm\n//     - Windows: %LOCALAPPDATA%\\upterm\n//  3. Fallback: $HOME/.upterm\n//  4. Final fallback: os.TempDir()/.upterm (if HOME unavailable)\nfunc UptermConfigDir() string {\n\treturn xdgDirWithFallback(\"XDG_CONFIG_HOME\", xdg.ConfigHome)\n}\n\n// UptermConfigFilePath returns the path to the config file.\n//\n// Following the XDG Base Directory Specification, this file is stored in\n// XDG_CONFIG_HOME/upterm and persists across sessions.\n//\n// Platform-specific paths:\n//   - Linux:   ~/.config/upterm/config.yaml\n//   - macOS:   ~/Library/Application Support/upterm/config.yaml\n//   - Windows: %LOCALAPPDATA%\\upterm\\config.yaml\n//\n// Note: The config file is optional and created manually by users.\nfunc UptermConfigFilePath() string {\n\treturn filepath.Join(UptermConfigDir(), configFile)\n}\n\n// CreateUptermRuntimeDir creates the runtime directory for sockets.\n// Mode 0700 ensures only the current user can access sockets.\nfunc CreateUptermRuntimeDir() (string, error) {\n\tdir := UptermRuntimeDir()\n\tif err := os.MkdirAll(dir, 0700); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn dir, nil\n}\n\nfunc DefaultLocalhost(defaultPort string) string {\n\tport := os.Getenv(\"PORT\")\n\tif port == \"\" {\n\t\tport = defaultPort\n\t}\n\n\treturn fmt.Sprintf(\"127.0.0.1:%s\", port)\n}\n\nfunc CreateSigners(privateKeys [][]byte) ([]ssh.Signer, error) {\n\tvar signers []ssh.Signer\n\n\tfor _, pk := range privateKeys {\n\t\tsigner, err := ssh.ParsePrivateKey(pk)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tsigners = append(signers, signer)\n\t}\n\n\t// generate one if no signer\n\tif len(signers) == 0 {\n\t\t_, epk, err := ed25519.GenerateKey(nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tsigner, err := ssh.NewSignerFromKey(epk)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tsigners = append(signers, signer)\n\n\t}\n\n\treturn signers, nil\n}\n\nfunc ReadFiles(paths []string) ([][]byte, error) {\n\tvar files [][]byte\n\n\tfor _, p := range paths {\n\t\tb, err := os.ReadFile(p)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to read file %s: %w\", p, err)\n\t\t}\n\n\t\tfiles = append(files, b)\n\t}\n\n\treturn files, nil\n}\n\nfunc GenerateSessionID() string {\n\treturn uniuri.NewLen(uniuri.UUIDLen)\n}\n\nfunc FingerprintSHA256(key ssh.PublicKey) string {\n\thash := sha256.Sum256(key.Marshal())\n\tb64hash := base64.StdEncoding.EncodeToString(hash[:])\n\treturn fmt.Sprintf(\"SHA256:%s\", strings.TrimRight(b64hash, \"=\"))\n}\n\nfunc KeysEqual(pk1 ssh.PublicKey, pk2 ssh.PublicKey) bool {\n\treturn gssh.KeysEqual(publicKey(pk1), publicKey(pk2))\n}\n\nfunc publicKey(pk ssh.PublicKey) ssh.PublicKey {\n\tcert, ok := pk.(*ssh.Certificate)\n\tif ok {\n\t\tpk = cert.Key\n\t}\n\n\treturn pk\n}\n\n// ShortenHomePath replaces the home directory prefix with ~ for cleaner display.\n// If the path doesn't start with home directory, it's returned unchanged.\n// Always uses forward slash after ~ for consistency across platforms.\nfunc ShortenHomePath(path string) string {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil || homeDir == \"\" {\n\t\treturn path\n\t}\n\tif after, found := strings.CutPrefix(path, homeDir); found {\n\t\t// Use forward slashes after ~ for consistent display (e.g., ~/Documents not ~\\Documents)\n\t\tafter = strings.ReplaceAll(after, \"\\\\\", \"/\")\n\t\treturn \"~\" + after\n\t}\n\treturn path\n}\n"
  },
  {
    "path": "utils/utils_test.go",
    "content": "package utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestShortenHomePath(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err, \"failed to get user home dir\")\n\n\ttests := []struct {\n\t\tname string\n\t\tpath string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"home directory\",\n\t\t\tpath: home,\n\t\t\twant: \"~\",\n\t\t},\n\t\t{\n\t\t\tname: \"path under home\",\n\t\t\tpath: filepath.Join(home, \"Documents/file.txt\"),\n\t\t\twant: \"~/Documents/file.txt\",\n\t\t},\n\t\t{\n\t\t\tname: \"path outside home unchanged\",\n\t\t\tpath: \"/etc/passwd\",\n\t\t\twant: \"/etc/passwd\",\n\t\t},\n\t\t{\n\t\t\tname: \"relative path unchanged\",\n\t\t\tpath: \"relative/path\",\n\t\t\twant: \"relative/path\",\n\t\t},\n\t\t{\n\t\t\tname: \"empty path unchanged\",\n\t\t\tpath: \"\",\n\t\t\twant: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := ShortenHomePath(tt.path)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestXDGDirWithFallback(t *testing.T) {\n\t// Get the actual home directory for fallback tests\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err, \"failed to get user home dir\")\n\n\txdgPath := t.TempDir()\n\n\ttests := []struct {\n\t\tname    string\n\t\tenvVar  string\n\t\tenvMap  map[string]string\n\t\txdgPath string\n\t\twant    string\n\t}{\n\t\t{\n\t\t\tname:    \"respects explicitly set env var\",\n\t\t\tenvVar:  \"XDG_RUNTIME_DIR\",\n\t\t\tenvMap:  map[string]string{\"XDG_RUNTIME_DIR\": filepath.Join(\"/tmp\", \"custom-runtime\")},\n\t\t\txdgPath: filepath.Join(\"/run\", \"user\", \"1000\"), // This would be the default\n\t\t\twant:    filepath.Join(\"/tmp\", \"custom-runtime\", \"upterm\"),\n\t\t},\n\t\t{\n\t\t\tname:    \"uses xdg path when it exists\",\n\t\t\tenvVar:  \"XDG_RUNTIME_DIR\",\n\t\t\tenvMap:  map[string]string{},\n\t\t\txdgPath: xdgPath,\n\t\t\twant:    filepath.Join(xdgPath, \"upterm\"),\n\t\t},\n\t\t{\n\t\t\tname:    \"falls back to HOME when xdg path doesn't exist\",\n\t\t\tenvVar:  \"XDG_RUNTIME_DIR\",\n\t\t\tenvMap:  map[string]string{},\n\t\t\txdgPath: filepath.Join(\"/nonexistent\", \"path\"),\n\t\t\twant:    filepath.Join(home, \".upterm\"),\n\t\t},\n\t\t{\n\t\t\tname:    \"falls back to HOME for all directory types\",\n\t\t\tenvVar:  \"XDG_STATE_HOME\",\n\t\t\tenvMap:  map[string]string{},\n\t\t\txdgPath: filepath.Join(\"/nonexistent\", \"path\"),\n\t\t\twant:    filepath.Join(home, \".upterm\"),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create a mock env getter - no need for os.Setenv!\n\t\t\tgetenv := func(key string) string {\n\t\t\t\treturn tt.envMap[key]\n\t\t\t}\n\n\t\t\tgot := xdgDirWithFallbackEnv(tt.envVar, tt.xdgPath, getenv)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "ws/client.go",
    "content": "package ws\n\nimport (\n\t\"encoding/base64\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/gorilla/websocket\"\n\tchshare \"github.com/jpillora/chisel/share\"\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// NewSSHClient creates a ssh client via ws.\n// The url must include username as session id and password as encoded node address.\n// isUptermClient indicates whether the client is host client or client client.\nfunc NewSSHClient(u *url.URL, config *ssh.ClientConfig, isUptermClient bool) (*ssh.Client, error) {\n\tconn, err := NewWSConn(u, isUptermClient)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tc, chans, reqs, err := ssh.NewClientConn(conn, u.Host, config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ssh.NewClient(c, chans, reqs), nil\n}\n\n// NewWSConn creates a ws net.Conn.\n// The url must include username as session id and password as encoded node address.\n// isUptermClient indicates whether the client is host client or client client.\nfunc NewWSConn(u *url.URL, isUptermClient bool) (net.Conn, error) {\n\tu, _ = url.Parse(u.String()) // clone\n\tuser := u.User\n\tu.User = nil // ws spec doesn't support basic auth\n\n\tencodedNodeAddr, _ := user.Password()\n\theader := webSocketDialHeader(user.Username(), encodedNodeAddr, isUptermClient)\n\twsc, _, err := websocket.DefaultDialer.Dial(u.String(), header)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn WrapWSConn(wsc), nil\n}\n\nfunc WrapWSConn(ws *websocket.Conn) net.Conn {\n\treturn chshare.NewWebSocketConn(ws)\n}\n\nfunc webSocketDialHeader(sessionID, encodedNodeAddr string, isClient bool) http.Header {\n\tauth := base64.StdEncoding.EncodeToString([]byte(sessionID + \":\" + encodedNodeAddr))\n\theader := make(http.Header)\n\theader.Add(\"Authorization\", \"Basic \"+auth)\n\n\tver := upterm.HostSSHClientVersion\n\tif isClient {\n\t\tver = upterm.ClientSSHClientVersion\n\t}\n\theader.Add(upterm.HeaderUptermClientVersion, ver)\n\n\treturn header\n}\n"
  }
]