├── .github └── workflows │ ├── release.yml │ ├── tests.yml │ ├── update-homebrew-formula.yml │ └── update-winget-manifest.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.Debug ├── LICENSE.txt ├── README.md ├── docs ├── CNAME ├── diagrams.svg ├── index.html └── writeup.md ├── go.mod ├── go.sum ├── main.go ├── pkg ├── actions │ ├── challenge-response.go │ ├── download.go │ ├── interactive │ │ ├── main.go │ │ └── states │ │ │ ├── common.go │ │ │ ├── delete-ssh-key.go │ │ │ ├── error-state.go │ │ │ ├── main-menu.go │ │ │ ├── main.go │ │ │ ├── ssh-key-content.go │ │ │ ├── ssh-key-manager.go │ │ │ ├── ssh-key-options.go │ │ │ └── styles.go │ ├── list-machines.go │ ├── remove-machine.go │ ├── reset.go │ ├── setup.go │ └── upload.go ├── dto │ └── main.go ├── models │ ├── host.go │ └── profile.go ├── retrieval │ ├── data.go │ ├── data_test.go │ ├── machines.go │ └── machines_test.go └── utils │ ├── decrypt.go │ ├── encrypt.go │ ├── getprofile.go │ ├── io.go │ ├── keyretrieval.go │ ├── parseconfig.go │ ├── setup.go │ ├── sockets.go │ ├── tokengen.go │ └── write.go ├── todo.txt └── win-build ├── LICENSE.txt ├── modpath.iss └── setup.iss /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build-docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Login to Docker Hub 15 | uses: docker/login-action@v3 16 | with: 17 | username: ${{ secrets.DOCKERHUB_USERNAME }} 18 | password: ${{ secrets.DOCKERHUB_TOKEN }} 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v2 21 | - name: Build and push 22 | uses: docker/build-push-action@v3 23 | with: 24 | context: . 25 | file: ./Dockerfile 26 | push: true 27 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/ssh-sync:latest 28 | 29 | build-windows: 30 | runs-on: windows-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - uses: actions/setup-go@v5 35 | with: 36 | go-version: ">=1.19.7" 37 | - name: Go Build 38 | run: go build -o ./win-build/ssh-sync.exe -ldflags "-X main.version=${{github.ref_name}}" 39 | shell: powershell 40 | - name: Inno Build 41 | run: | 42 | & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" /dMyAppVersion="${{github.ref_name}}" "$env:GITHUB_WORKSPACE\win-build\setup.iss" 43 | shell: powershell 44 | - name: "Upload Artifact" 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: ssh-sync-setup 48 | path: ./win-build/Output/ssh-sync-setup.exe 49 | retention-days: 5 50 | 51 | build-linux: 52 | runs-on: ubuntu-latest 53 | strategy: 54 | matrix: 55 | arch: [amd64, arm64, arm] 56 | steps: 57 | - uses: actions/checkout@v4 58 | - uses: actions/setup-go@v5 59 | with: 60 | go-version: ">=1.19.7" 61 | 62 | - name: Set GOARCH environment variable 63 | run: | 64 | if [ "${{ matrix.arch }}" = "amd64" ]; then 65 | echo "GOARCH=amd64" >> $GITHUB_ENV 66 | elif [ "${{ matrix.arch }}" = "arm64" ]; then 67 | echo "GOARCH=arm64" >> $GITHUB_ENV 68 | elif [ "${{ matrix.arch }}" = "arm" ]; then 69 | echo "GOARCH=arm" >> $GITHUB_ENV 70 | echo "GOARM=7" >> $GITHUB_ENV 71 | fi 72 | 73 | - name: Go Build 74 | run: go build -o ssh-sync-${{ matrix.arch }} -ldflags "-X main.version=${{github.ref_name}}" 75 | 76 | - name: Upload Artifact 77 | uses: actions/upload-artifact@v4 78 | with: 79 | name: ssh-sync-${{ matrix.arch }} 80 | path: ./ssh-sync-${{ matrix.arch }} 81 | retention-days: 5 82 | 83 | - name: Install FPM 84 | run: | 85 | sudo apt-get update 86 | sudo apt-get install -y ruby ruby-dev rubygems build-essential rpm 87 | sudo gem install --no-document fpm 88 | 89 | - name: Create a .deb package 90 | run: | 91 | fpm -s dir -t deb -a ${{ matrix.arch }} -n ssh-sync -v ${{ github.ref_name }} --description "ssh-sync" \ 92 | --deb-no-default-config-files \ 93 | ./ssh-sync-${{ matrix.arch }}=/usr/local/bin/ssh-sync 94 | 95 | - name: Create an .rpm package 96 | run: | 97 | fpm -s dir -t rpm -a ${{ matrix.arch }} -n ssh-sync -v ${{ github.ref_name }} --description "ssh-sync" \ 98 | ./ssh-sync-${{ matrix.arch }}=/usr/local/bin/ssh-sync 99 | 100 | - name: Create an .apk package (Alpine) 101 | run: | 102 | fpm -s dir -t apk -a ${{ matrix.arch }} -n ssh-sync -v ${{ github.ref_name }} --description "ssh-sync" \ 103 | ./ssh-sync-${{ matrix.arch }}=/usr/local/bin/ssh-sync 104 | 105 | - name: Organize Packages by Type 106 | run: | 107 | mkdir -p ./debian/${{ matrix.arch }} 108 | mkdir -p ./rpm/${{ matrix.arch }} 109 | 110 | # Move packages to their respective directories 111 | mv *.deb ./debian/${{ matrix.arch }}/ || true 112 | mv *.rpm ./rpm/${{ matrix.arch }}/ || true 113 | 114 | - name: Upload Linux Packages as Artifacts 115 | uses: actions/upload-artifact@v4 116 | with: 117 | name: packages-${{ matrix.arch }} 118 | path: | 119 | *.apk 120 | *.tar.zst 121 | ./debian/${{ matrix.arch }}/*.deb 122 | ./rpm/${{ matrix.arch }}/*.rpm 123 | 124 | - name: Deploy Packages to Repository Server 125 | if: startsWith(github.ref, 'refs/tags/') 126 | uses: appleboy/scp-action@v0.1.4 127 | with: 128 | host: ${{ secrets.REPO_SERVER_HOST }} 129 | username: ${{ secrets.REPO_SERVER_USER }} 130 | key: ${{ secrets.REPO_SERVER_SSH_KEY }} 131 | port: ${{ secrets.REPO_SERVER_PORT }} 132 | source: "./debian/${{ matrix.arch }}/*.deb,./rpm/${{ matrix.arch }}/*.rpm" 133 | target: ${{ secrets.REPO_SERVER_PATH }} 134 | 135 | sign-and-update-repositories: 136 | needs: [build-linux] 137 | if: startsWith(github.ref, 'refs/tags/') 138 | runs-on: ubuntu-latest 139 | steps: 140 | - name: Setup GPG for Repository Signing 141 | uses: appleboy/ssh-action@v0.1.10 142 | with: 143 | host: ${{ secrets.REPO_SERVER_HOST }} 144 | username: ${{ secrets.REPO_SERVER_USER }} 145 | key: ${{ secrets.REPO_SERVER_SSH_KEY }} 146 | port: ${{ secrets.REPO_SERVER_PORT }} 147 | script: | 148 | # Import GPG key if not already present 149 | if ! gpg --list-secret-keys | grep -q "${{ secrets.REPO_SERVER_GPG_KEY_ID }}"; then 150 | echo "${{ secrets.REPO_SERVER_GPG_KEY }}" | base64 -d | gpg --batch --import 151 | fi 152 | 153 | # Configure GPG for unattended operation 154 | mkdir -p ~/.gnupg 155 | echo "use-agent" > ~/.gnupg/gpg.conf 156 | echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf 157 | echo "default-cache-ttl 86400" > ~/.gnupg/gpg-agent.conf 158 | echo "max-cache-ttl 86400" >> ~/.gnupg/gpg-agent.conf 159 | echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf 160 | 161 | # Restart GPG agent 162 | gpgconf --kill gpg-agent || true 163 | gpgconf --launch gpg-agent || true 164 | 165 | - name: Sign RPM Packages and Update Repository 166 | uses: appleboy/ssh-action@v0.1.10 167 | with: 168 | host: ${{ secrets.REPO_SERVER_HOST }} 169 | username: ${{ secrets.REPO_SERVER_USER }} 170 | key: ${{ secrets.REPO_SERVER_SSH_KEY }} 171 | port: ${{ secrets.REPO_SERVER_PORT }} 172 | script: | 173 | cd ${{ secrets.REPO_SERVER_PATH }}/rpm 174 | 175 | # Configure RPM signing 176 | cat > ~/.rpmmacros << EOF 177 | %_gpg_name ${{ secrets.GPG_KEY_ID }} 178 | %_gpg_path $HOME/.gnupg 179 | %__gpg_sign_cmd %{__gpg} \ 180 | gpg --batch --verbose --no-armor --no-secmem-warning \ 181 | --passphrase-fd 3 --pinentry-mode loopback \ 182 | -u "%{_gpg_name}" -sbo %{__signature_filename} %{__plaintext_filename} 183 | EOF 184 | 185 | # Sign all unsigned RPM packages 186 | for rpm in */*.rpm *.rpm; do 187 | if [ -f "$rpm" ]; then 188 | # Check if already signed 189 | if ! rpm -qp --qf '%{SIGPGP:pgpsig}' "$rpm" 2>/dev/null | grep -q "Key ID"; then 190 | echo "Signing $rpm..." 191 | echo "${{ secrets.REPO_SERVER_GPG_PASSPHRASE }}" | rpm --addsign "$rpm" 3<&0 192 | else 193 | echo "$rpm is already signed" 194 | fi 195 | fi 196 | done 197 | 198 | # Update repository metadata 199 | createrepo_c --update . 200 | 201 | # Sign repository metadata 202 | if [ -f "repodata/repomd.xml" ]; then 203 | echo "${{ secrets.REPO_SERVER_GPG_PASSPHRASE }}" | gpg --batch --yes --pinentry-mode loopback \ 204 | --passphrase-fd 0 --armor --detach-sign repodata/repomd.xml 205 | fi 206 | 207 | echo "RPM repository updated and signed successfully" 208 | 209 | - name: Sign Debian Repository and Update Metadata 210 | uses: appleboy/ssh-action@v0.1.10 211 | with: 212 | host: ${{ secrets.REPO_SERVER_HOST }} 213 | username: ${{ secrets.REPO_SERVER_USER }} 214 | key: ${{ secrets.REPO_SERVER_SSH_KEY }} 215 | port: ${{ secrets.REPO_SERVER_PORT }} 216 | script: | 217 | cd ${{ secrets.REPO_SERVER_PATH }}/debian 218 | 219 | # Flatten directory structure for Debian repo (combine all architectures) 220 | find . -name "*.deb" -path "*/amd64/*" -exec mv {} . \; 221 | find . -name "*.deb" -path "*/arm64/*" -exec mv {} . \; 222 | find . -name "*.deb" -path "*/arm/*" -exec mv {} . \; 223 | 224 | # Remove empty architecture directories 225 | rmdir */amd64 */arm64 */arm 2>/dev/null || true 226 | 227 | # Generate Packages file 228 | dpkg-scanpackages --multiversion . > Packages 229 | gzip -k -f Packages 230 | 231 | # Generate Release file 232 | apt-ftparchive release . > Release 233 | 234 | # Sign the Release file (InRelease) 235 | echo "${{ secrets.REPO_SERVER_GPG_PASSPHRASE }}" | gpg --batch --yes --pinentry-mode loopback \ 236 | --passphrase-fd 0 --local-user "${{ secrets.GPG_KEY_ID }}" --clearsign -o InRelease Release 237 | 238 | # Create detached signature (Release.gpg) 239 | echo "${{ secrets.REPO_SERVER_GPG_PASSPHRASE }}" | gpg --batch --yes --pinentry-mode loopback \ 240 | --passphrase-fd 0 --local-user "${{ secrets.GPG_KEY_ID }}" --armor --detach-sign -o Release.gpg Release 241 | 242 | echo "Debian repository updated and signed successfully" 243 | 244 | - name: Export Public Key for Distribution 245 | uses: appleboy/ssh-action@v0.1.10 246 | with: 247 | host: ${{ secrets.REPO_SERVER_HOST }} 248 | username: ${{ secrets.REPO_SERVER_USER }} 249 | key: ${{ secrets.REPO_SERVER_SSH_KEY }} 250 | port: ${{ secrets.REPO_SERVER_PORT }} 251 | script: | 252 | cd ${{ secrets.REPO_SERVER_PATH }} 253 | 254 | # Export public key for distribution 255 | gpg --armor --export "${{ secrets.GPG_KEY_ID }}" > pubkey.gpg 256 | gpg --armor --export "${{ secrets.GPG_KEY_ID }}" > ssh-sync-repo.asc 257 | 258 | # Set proper permissions 259 | chmod 644 pubkey.gpg ssh-sync-repo.asc 260 | 261 | echo "Public key exported successfully" 262 | 263 | release: 264 | needs: 265 | [ 266 | build-docker, 267 | build-windows, 268 | build-linux, 269 | sign-and-update-repositories, 270 | ] 271 | runs-on: ubuntu-latest 272 | steps: 273 | - uses: actions/checkout@v4 274 | - name: Download all workflow run artifacts 275 | uses: actions/download-artifact@v4 276 | - uses: ncipollo/release-action@v1 277 | with: 278 | artifacts: "./ssh-sync-setup/ssh-sync-setup.exe,./ssh-sync-amd64/ssh-sync-amd64,./ssh-sync-arm64/ssh-sync-arm64,./ssh-sync-arm/ssh-sync-arm,./packages-amd64/*.deb,./packages-amd64/*.rpm,./packages-amd64/*.apk,./packages-amd64/*.tar.zst,./packages-arm64/*.deb,./packages-arm64/*.rpm,./packages-arm64/*.apk,./packages-arm64/*.tar.zst,./packages-arm/*.deb,./packages-arm/*.rpm,./packages-arm/*.apk,./packages-arm/*.tar.zst" 279 | token: ${{ secrets.ACCESS_TOKEN_CLASSIC }} 280 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Builds 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main" ] 6 | 7 | jobs: 8 | build-docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Checkout 13 | uses: actions/checkout@v3 14 | - 15 | name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | - 18 | name: Build 19 | uses: docker/build-push-action@v3 20 | with: 21 | context: . 22 | file: ./Dockerfile 23 | push: false 24 | build-windows: 25 | runs-on: windows-latest 26 | steps: 27 | - 28 | name: Checkout 29 | uses: actions/checkout@v3 30 | - uses: actions/setup-go@v4 31 | with: 32 | go-version: ">=1.19.7" 33 | - 34 | name: Go Build 35 | run: go build -o ./win-build/ssh-sync.exe -ldflags "-X main.version=${{github.ref_name}}" 36 | shell: powershell 37 | - 38 | name: Inno Build 39 | run: | 40 | & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" /dMyAppVersion="${{github.ref_name}}" "$env:GITHUB_WORKSPACE\win-build\setup.iss" 41 | shell: powershell 42 | build-linux: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v3 46 | - uses: actions/setup-go@v4 47 | with: 48 | go-version: ">=1.19.7" 49 | - name: Go Build 50 | run: go build -o ssh-sync -ldflags "-X main.version=${{github.ref_name}}" 51 | -------------------------------------------------------------------------------- /.github/workflows/update-homebrew-formula.yml: -------------------------------------------------------------------------------- 1 | name: Update Homebrew Formula 2 | 3 | on: 4 | release: 5 | types: [published] 6 | branches: [main] 7 | 8 | jobs: 9 | update-formula: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout software repo 13 | uses: actions/checkout@v4 14 | with: 15 | path: 'software' 16 | 17 | - name: Checkout Homebrew tap 18 | uses: actions/checkout@v4 19 | with: 20 | repository: 'therealpaulgg/homebrew-ssh-sync' 21 | token: ${{ secrets.ACCESS_TOKEN_CLASSIC }} 22 | path: 'homebrew-tap' 23 | 24 | - name: Update Homebrew formula 25 | run: | 26 | cd homebrew-tap 27 | 28 | GITHUB_REPO="therealpaulgg/ssh-sync" 29 | FORMULA_PATH="Formula/ssh-sync.rb" 30 | TAP_REPO="therealpaulgg/homebrew-ssh-sync" 31 | 32 | # Fetch the latest release data from GitHub 33 | LATEST_RELEASE=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest") 34 | 35 | # Extract the version and tarball URL from the release data 36 | VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name') 37 | TARBALL_URL=$(echo "$LATEST_RELEASE" | jq -r '.tarball_url') 38 | 39 | # Download the tarball and calculate its SHA256 40 | SHA256=$(curl -Ls $TARBALL_URL | shasum -a 256 | awk '{print $1}') 41 | 42 | # Update the formula with the new version and sha256 43 | sed -i "s|url \".*\"|url \"$TARBALL_URL\"|g" $FORMULA_PATH 44 | sed -i "s|sha256 \".*\"|sha256 \"$SHA256\"|g" $FORMULA_PATH 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN_CLASSIC }} 47 | 48 | - name: Commit and push updates to the tap 49 | run: | 50 | cd homebrew-tap 51 | git config --local user.email "action@github.com" 52 | git config --local user.name "GitHub Action" 53 | git commit -am "Update formula to version ${{ github.event.release.tag_name }}" || echo "No changes to commit" 54 | git push 55 | -------------------------------------------------------------------------------- /.github/workflows/update-winget-manifest.yml: -------------------------------------------------------------------------------- 1 | name: Update Winget Manifest 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | update-winget-manifest: 9 | runs-on: windows-latest 10 | steps: 11 | - uses: vedantmgoyal2009/winget-releaser@main 12 | with: 13 | identifier: therealpaulgg.ssh-sync 14 | version: ${{ github.ref_name }} 15 | release-tag: ${{ github.ref_name }} 16 | token: ${{ secrets.ACCESS_TOKEN_CLASSIC }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ssh-sync 2 | ssh-sync.exe 3 | ssh-sync-installer.exe 4 | win-build/Output/ 5 | __debug_bin* -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Attach to Sunbeam", 5 | "type": "go", 6 | "debugAdapter": "dlv-dap", 7 | "request": "attach", 8 | "mode": "remote", 9 | "remotePath": "${workspaceFolder}", 10 | "port": 2345, 11 | "host": "127.0.0.1", 12 | "preLaunchTask": "Run headless dlv" // Here ! 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "discord.enabled": true 3 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run headless dlv", 6 | "type": "process", 7 | "command": "dlv", 8 | "args": [ 9 | "debug", 10 | "--headless", 11 | "--listen=:2345", 12 | "--api-version=2", 13 | "${workspaceFolder}/main.go", 14 | "--", 15 | "interactive" 16 | ], 17 | "isBackground": true, 18 | "problemMatcher": { 19 | "owner": "go", 20 | "pattern": { 21 | "regexp": "^API server listening at: .+:\\d+$", 22 | "line": 1, 23 | "message": 1 24 | }, 25 | "background": { 26 | "activeOnStart": true, 27 | "beginsPattern": "^API server listening at:", 28 | "endsPattern": "^API server listening at:" 29 | } 30 | } 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | paul@paul.systems. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to SSH-Sync 3 | 4 | We welcome contributions to the SSH-Sync project! Whether you're looking to fix bugs, add new features, or improve documentation, your help is appreciated. Here's how you can contribute: 5 | 6 | ## Reporting Issues 7 | 8 | If you encounter a bug or have a suggestion for improving SSH-Sync, please first check the [issue tracker](https://github.com/therealpaulgg/ssh-sync/issues) to see if it has already been reported. If not, feel free to open a new issue. Please provide as much detail as possible to help us understand the problem or enhancement. 9 | 10 | ## Submitting Pull Requests 11 | 12 | We accept contributions through pull requests. Here are the steps to submit your contributions: 13 | 14 | 1. Fork the repository. 15 | 2. Create a new branch for your feature or bug fix. 16 | 3. Make your changes. 17 | 4. Ensure your code follows the project's coding standards. 18 | 5. Submit a pull request against the main branch. 19 | 20 | Please provide a clear description of the problem and solution, including any relevant issue numbers. 21 | 22 | ## Code of Conduct 23 | 24 | We expect all contributors to adhere to our Code of Conduct. By participating in this project, you agree to abide by its terms. 25 | 26 | ## Questions? 27 | 28 | If you have any questions about contributing, please feel free to ask in the issue tracker or reach out to the maintainers. 29 | 30 | Thank you for your interest in contributing to SSH-Sync! 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang 2 | RUN mkdir /app 3 | COPY ./pkg/ /app/pkg 4 | COPY . /app 5 | WORKDIR /app 6 | RUN go build -o /app/main /app/main.go 7 | ENTRYPOINT ["/app/main"] -------------------------------------------------------------------------------- /Dockerfile.Debug: -------------------------------------------------------------------------------- 1 | FROM golang:buster 2 | RUN mkdir /app 3 | COPY ./pkg/ /app/pkg 4 | COPY . /app 5 | WORKDIR /app 6 | ENV CGO_ENABLED=0 7 | ENV GOOS=linux 8 | ENV GOARCH=amd64 9 | RUN go build -o /app/main /app/main.go 10 | CMD ["/bin/bash"] -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Paul Gellai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # ssh-sync: Seamless SSH Key Management 3 | 4 | ssh-sync is a powerful CLI tool designed to simplify the way you manage and synchronize your SSH keys and configurations across multiple machines. With ssh-sync, gone are the days of manually copying SSH keys or adjusting configurations when switching devices. Whether you're moving between workstations or setting up a new machine, ssh-sync ensures your SSH environment is up and running effortlessly. 5 | 6 | [![release](https://github.com/therealpaulgg/ssh-sync/actions/workflows/release.yml/badge.svg)](https://github.com/therealpaulgg/ssh-sync/actions/workflows/release.yml) 7 | 8 | ## Quick Start 9 | 10 | ### Installation 11 | 12 | ssh-sync is available on Windows, macOS, and Linux. Choose the installation method that best suits your operating system: 13 | 14 | #### Windows 15 | 16 | Install ssh-sync using Winget: 17 | 18 | ```shell 19 | winget install therealpaulgg.ssh-sync 20 | ``` 21 | 22 | #### macOS 23 | 24 | ssh-sync can be installed using Homebrew: 25 | 26 | ```shell 27 | brew tap therealpaulgg/ssh-sync 28 | brew install ssh-sync 29 | ``` 30 | 31 | #### Linux 32 | 33 | For Linux users, you can install ssh-sync through our official package repositories or by downloading packages directly from our [GitHub Releases](https://github.com/therealpaulgg/ssh-sync/releases) page: 34 | 35 | ##### Using the Official Repository 36 | 37 | ###### Debian/Ubuntu and derivatives: 38 | 39 | ```shell 40 | # Import the GPG key 41 | curl -fsSL https://repo.sshsync.io/ssh-sync-repo.asc | sudo gpg --dearmor -o /usr/share/keyrings/ssh-sync-archive-keyring.gpg 42 | 43 | # Add the repository 44 | echo "deb [signed-by=/usr/share/keyrings/ssh-sync-archive-keyring.gpg] https://repo.sshsync.io/debian $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/ssh-sync.list 45 | 46 | # Update package lists 47 | sudo apt update 48 | 49 | # Install ssh-sync 50 | sudo apt install ssh-sync 51 | ``` 52 | 53 | ###### Fedora/RHEL/CentOS and derivatives: 54 | 55 | ```shell 56 | # Import the GPG key 57 | sudo rpm --import https://repo.sshsync.io/ssh-sync-repo.asc 58 | 59 | # Add the repository 60 | cat < 190 | ``` 191 | 192 | Specify the machine you wish to remove following the command. 193 | 194 | You may optionally provide the machine name on the command line so you don't have to type it in when running the command. 195 | 196 | ### Reset 197 | 198 | To remove the current machine from your account and clear all SSH-Sync data: 199 | 200 | ```shell 201 | ssh-sync reset 202 | ``` 203 | 204 | This command is useful if you're decommissioning a machine or wish to start fresh. 205 | 206 | By following these steps, you can seamlessly sync and manage your SSH keys across all your machines with SSH-Sync. 207 | 208 | ## Self-Hosting ssh-sync-server 209 | 210 | In general, for self-hosting, we recommend a setup where ssh-sync-server is behind a reverse proxy (i.e Nginx), and SSL is handled via LetsEncrypt. 211 | 212 | ### Docker 213 | 214 | Docker is the easiest way to run the server. Here is a simple `docker-compose` file you can use: 215 | 216 | ```yaml 217 | version: '3.3' 218 | services: 219 | ssh-sync-server: 220 | restart: always 221 | environment: 222 | - PORT= 223 | - NO_DOTENV=1 224 | - DATABASE_USERNAME=sshsync 225 | - DATABASE_PASSWORD=${POSTGRES_PASSWORD} 226 | - DATABASE_HOST=ssh-sync-db:5432 227 | logging: 228 | driver: json-file 229 | options: 230 | max-size: 10m 231 | ports: 232 | - ':' 233 | image: therealpaulgg/ssh-sync-server:latest 234 | container_name: ssh-sync-server 235 | ssh-sync-db: 236 | image: therealpaulgg/ssh-sync-db:latest 237 | container_name: ssh-sync-db 238 | volumes: 239 | - /path/to/db-volume:/var/lib/postgresql/data 240 | environment: 241 | - POSTGRES_USER=sshsync 242 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 243 | - POSTGRES_DB=sshsync 244 | restart: always 245 | ``` 246 | 247 | ### Nginx 248 | 249 | Example Nginx config (must support websockets) 250 | 251 | ```nginx 252 | server { 253 | listen [::]:443 ssl ipv6only=on; # managed by Certbot 254 | listen 443 ssl; # managed by Certbot 255 | ssl_certificate /etc/letsencrypt/live/server.sshsync.io/fullchain.pem; # managed by Certbot 256 | ssl_certificate_key /etc/letsencrypt/live/server.sshsync.io/privkey.pem; # managed by Certbot 257 | include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot 258 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot 259 | server_name server.sshsync.io; 260 | location / { 261 | proxy_pass http://127.0.0.1:; 262 | proxy_http_version 1.1; 263 | proxy_set_header Upgrade $http_upgrade; 264 | proxy_set_header Connection "Upgrade"; 265 | proxy_set_header Host $host; 266 | proxy_set_header X-Forwarded-For $remote_addr; 267 | proxy_set_header X-Real-IP $remote_addr; 268 | } 269 | 270 | 271 | } 272 | server { 273 | if ($host = server.sshsync.io) { 274 | return 301 https://$host$request_uri; 275 | } # managed by Certbot 276 | 277 | 278 | listen 80; 279 | listen [::]:80; 280 | server_name server.sshsync.io; 281 | return 404; # managed by Certbot 282 | 283 | 284 | } 285 | ``` 286 | 287 | If you don't want to use docker, other methods of running are not supported at this time, but the source repos are linked below so you can configure your own server as you wish. 288 | 289 | [ssh-sync-server Github](https://github.com/therealpaulgg/ssh-sync-server) 290 | [ssh-sync-db](https://github.com/therealpaulgg/ssh-sync-db) 291 | 292 | ## How ssh-sync Works 293 | 294 | ssh-sync leverages a client-server model to store and synchronize your SSH keys securely. The diagram below outlines the ssh-sync architecture and its workflow: 295 | 296 | ![ssh-sync Architecture](https://raw.githubusercontent.com/therealpaulgg/ssh-sync/main/docs/diagrams.svg) 297 | 298 | For a deep dive into the technicalities of ssh-sync, including its security model, data storage, and key synchronization process, check out our [Wiki](https://github.com/therealpaulgg/ssh-sync/wiki). 299 | 300 | ## Why Choose ssh-sync? 301 | 302 | - **Simplify SSH Key Management:** Easily sync your SSH keys and configurations across all your devices. 303 | - **Enhanced Security:** ssh-sync uses advanced cryptographic techniques to ensure your SSH keys are securely transmitted and stored. 304 | - **Effortless Setup:** With support for Windows, macOS, and Linux, setting up ssh-sync is straightforward, regardless of your operating system. 305 | 306 | ## Contributing 307 | 308 | ssh-sync is an open-source project, and contributions are welcome! If you're interested in contributing, please check out our [contribution guidelines](https://github.com/therealpaulgg/ssh-sync/blob/main/CONTRIBUTING.md). 309 | 310 | ## License 311 | 312 | ssh-sync is released under the [MIT License](https://github.com/therealpaulgg/ssh-sync/blob/main/LICENSE.txt). 313 | 314 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | sshsync.io -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 |

ssh-sync: Seamless SSH Key Management

2 |

ssh-sync is a powerful CLI tool designed to simplify the way you manage and synchronize your SSH keys and configurations across multiple machines. With ssh-sync, gone are the days of manually copying SSH keys or adjusting configurations when switching devices. Whether you're moving between workstations or setting up a new machine, ssh-sync ensures your SSH environment is up and running effortlessly.

3 |

Quick Start

4 |

Installation

5 |

ssh-sync is available on Windows, macOS, and Linux. Choose the installation method that best suits your operating system:

6 |

Windows

7 |

Install ssh-sync using Winget:

8 |
winget install ssh-sync
  9 | 
10 |

macOS

11 |

ssh-sync can be installed using Homebrew:

12 |
brew tap therealpaulgg/ssh-sync
 13 | brew install ssh-sync
 14 | 
15 |

Linux

16 |

For Linux users, you can install ssh-sync through our official package repositories or by downloading packages directly from our GitHub Releases page:

17 | 18 |
Using the Official Repository
19 | 20 |
Debian/Ubuntu and derivatives:
21 | 22 |
# Import the GPG key
 23 | curl -fsSL https://repo.sshsync.io/ssh-sync-repo.asc | sudo gpg --dearmor -o /usr/share/keyrings/ssh-sync-archive-keyring.gpg
 24 | 
 25 | # Add the repository
 26 | echo "deb [signed-by=/usr/share/keyrings/ssh-sync-archive-keyring.gpg] https://repo.sshsync.io/debian $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/ssh-sync.list
 27 | 
 28 | # Update package lists
 29 | sudo apt update
 30 | 
 31 | # Install ssh-sync
 32 | sudo apt install ssh-sync
 33 | 
34 | 35 |
Fedora/RHEL/CentOS and derivatives:
36 | 37 |
# Import the GPG key
 38 | sudo rpm --import https://repo.sshsync.io/ssh-sync-repo.asc
 39 | 
 40 | # Add the repository
 41 | cat <
53 | 54 |
Manual Installation
55 | 56 |

If you prefer to download and install the package manually:

57 |
    58 |
  • For Debian-based distributions (e.g., Ubuntu):
  • 59 |
60 |
wget https://github.com/therealpaulgg/ssh-sync/releases/latest/download/ssh-sync_VERSION_ARCH.deb
 61 | sudo dpkg -i ssh-sync_VERSION_ARCH.deb
 62 | 
63 |
    64 |
  • For RPM-based distributions (e.g., Fedora, CentOS):
  • 65 |
66 |
wget https://github.com/therealpaulgg/ssh-sync/releases/latest/download/ssh-sync-VERSION-ARCH.rpm
 67 | sudo rpm -i ssh-sync-VERSION-ARCH.rpm
 68 | 
69 |
    70 |
  • For Alpine Linux:
  • 71 |
72 |
wget https://github.com/therealpaulgg/ssh-sync/releases/latest/download/ssh-sync_VERSION_ARCH.apk
 73 | sudo apk add --allow-untrusted ssh-sync_VERSION_ARCH.apk
 74 | 
75 |
    76 |
  • For Arch-based distributions (e.g., Arch Linux, Manjaro):
  • 77 |
78 |
wget https://github.com/therealpaulgg/ssh-sync/releases/latest/download/ssh-sync-VERSION-ARCH.tar.zst
 79 | sudo pacman -U ssh-sync-VERSION-ARCH.tar.zst
 80 | 
81 |

We provide packages for multiple architectures including x86_64 (amd64), ARM64 (aarch64), and ARMv7. Choose the appropriate package for your system architecture.

82 |

Getting Started with SSH-Sync

83 |

SSH-Sync makes managing and syncing your SSH keys across multiple machines effortless. Here's how to get started:

84 |

Setup

85 |

First, you'll need to set up SSH-Sync on your machine. Run the following command:

86 |
ssh-sync setup
 87 | 
88 |

During setup, you'll be prompted to choose between using your own server or the sshsync.io hosted server. Next, you'll specify whether you have an existing account. If you do not, you'll be guided through creating an account, naming your machine, and generating a keypair for it. If you have an existing account, you'll be given a challenge phrase, which you must enter on another one of your machines using the challenge-response command. This process securely adds your new machine to your SSH-Sync account.

89 |

Uploading Keys

90 |

To upload your SSH keys and configuration to the server, run:

91 |
ssh-sync upload
 92 | 
93 |

This command securely transmits your SSH keys and configuration to the chosen server, making them accessible from your other machines.

94 |

Downloading Keys

95 |

To download your SSH keys to a new or existing machine, ensuring it's set up for remote access, use:

96 |
ssh-sync download
 97 | 
98 |

This command fetches your SSH keys from the server, setting up your SSH environment on the machine.

99 |

Challenge Response

100 |

If setting up a new machine with an existing account, use:

101 |
ssh-sync challenge-response
102 | 
103 |

Enter the challenge phrase received during the setup of another machine. This verifies your new machine and securely transfers the necessary keys.

104 |

Managing Machines

105 |

To list all machines configured with your SSH-Sync account, run:

106 |
ssh-sync list-machines
107 | 
108 |

If you need to remove a machine from your SSH-Sync account, use:

109 |
ssh-sync remove-machine
110 | 
111 |

Specify the machine you wish to remove following the command.

112 |

Reset

113 |

To remove the current machine from your account and clear all SSH-Sync data:

114 |
ssh-sync reset
115 | 
116 |

This command is useful if you're decommissioning a machine or wish to start fresh.

117 |

By following these steps, you can seamlessly sync and manage your SSH keys across all your machines with SSH-Sync.

118 |

How ssh-sync Works

119 |

ssh-sync leverages a client-server model to store and synchronize your SSH keys securely. The diagram below outlines the ssh-sync architecture and its workflow:

120 |

ssh-sync Architecture

121 |

For a deep dive into the technicalities of ssh-sync, including its security model, data storage, and key synchronization process, check out our Wiki.

122 |

Why Choose ssh-sync?

123 |
    124 |
  • Simplify SSH Key Management: Easily sync your SSH keys and configurations across all your devices.
  • 125 |
  • Enhanced Security: ssh-sync uses advanced cryptographic techniques to ensure your SSH keys are securely transmitted and stored.
  • 126 |
  • Effortless Setup: With support for Windows, macOS, and Linux, setting up ssh-sync is straightforward, regardless of your operating system.
  • 127 |
128 |

Contributing

129 |

ssh-sync is an open-source project, and contributions are welcome! If you're interested in contributing, please check out our contribution guidelines.

130 |

License

131 |

ssh-sync is released under the MIT License.

132 | -------------------------------------------------------------------------------- /docs/writeup.md: -------------------------------------------------------------------------------- 1 | # SSH Sync 2 | 3 | A CLI application to sync SSH keys along with a SSH configuration between machines on-demand. 4 | 5 | ## Diagram 6 | 7 | This diagram depicts the following: 8 | 9 | 1. Initial user setup with server 10 | 2. Sequence diagram illustrating a user configuring a new PC 11 | 3. Authenticated download request 12 | 4. Authenticated upload request 13 | 14 | ![svg-of-ssh-sync-architecture](https://raw.githubusercontent.com/therealpaulgg/ssh-sync/main/docs/diagrams.svg) 15 | 16 | ## High-Level Concept 17 | 18 | You have a computer which has SSH keys and configuration on them, and on a secondary machine you would like these keys so you are able to access your servers from another machine. This often involves copy-pasting files and also rewriting an SSH configuration file because file paths change, or the operating system changes. This can be quite tedious. This program aims to replace the manual steps involved by allowing a secure method of transferring keys and keeping them all synced in a remote server. 19 | 20 | ## Why not P2P? 21 | 22 | P2P was considered (and may still be done at a later date) but ultimately dismissed because every key sync request would have to involve two machines, which would get tedious quickly. The main idea is a transaction involving two machines at the same time would only have to be done once (to allow a new machine access to a user's keys). After this point, the new machine can communicate to the server freely, uploading and downloading new keys. A P2P implementation would mean that a machine would have to do a 'handshake' with another machine each time. There would also be no central source to keep key data synchronized. 23 | 24 | # Technical Details (Crypto) 25 | 26 | ## Initial Setup 27 | 28 | On initial setup, a user will be asked to provide a username and name for the current machine. A ECDSA keypair will also be created, and the user's public key will be uploaded to the server, corresponding to the username and machine name. From this point on, the user will be able to communicate to the server and upload/download keys, as well as config data. 29 | 30 | ## Communication to Server 31 | 32 | ### JWT for Requests 33 | 34 | All requests to the server (besides initial setup) will be done with a JWT. This JWT will be crafted on the client-side. It will be crafted using the ES512 algorithm, using the user's private key. The token will then be sent to the server, and validated with the user's public key. The crafted token needs to contain the following: 35 | 36 | - Username 37 | - Machine Name 38 | 39 | the server will then look up the public key corresponding to the username and machine name provided, and attempt to validate the signature. It will be impossible for someone to forge a JWT for a particular user/machine pair because it would require the private key. 40 | 41 | For example, if Eve crafts a JWT that says `{"username": "Alice", "machineName": "my-computer"}`, the server will attempt to verify the JWT using Alice's 'my-computer' keypair. Eve cannot impersonate Alice unless she gets her hands on Alice's private key. 42 | 43 | ## Storing Keys On The Server Securely 44 | 45 | ### Master Key 46 | 47 | Each user will have a 'Master Key'. This will be a unique symmetric key. This symmetric key will be stored on the server, one copy for each keypair. For example, on the server, there would be `E_pubA(master_key)` and `E_pubB(master_key)`. All of the user's data will be stored on the server in an encrypted AES 256 GCM format. This data can only be decrypted with the master symmetric key, which can only be decrypted by the user using one of their user/machine keypairs. 48 | 49 | #### Upload 50 | 51 | Whenever the user wants to upload new data, the server will send the encrypted master key. The user will then decrypt the master key, and send encrypted keys to the server. 52 | 53 | Server sends: `E_pubMachine(master_key)` 54 | 55 | Machine decrypts master key, `D_privMachine(E_pubMachine(master_key))` 56 | 57 | Machine encrypts keys with `master_key` and also signs the data. 58 | 59 | `E_privMachine(E_masterKey(plaintext))` 60 | 61 | Server receives this data and validates the signature: 62 | 63 | `D_pubMachine(ciphertext)` 64 | 65 | The server will then store this `ciphertext`. 66 | 67 | #### Download 68 | 69 | Whenever the user wants to download data, the server will send all the user's data in encrypted format, as well as the encrypted master key. 70 | 71 | The user, once it receives the data, will decrypt the master key using their private key, and then decrypt the ciphertext. 72 | 73 | Server sends: `E_pubMachine(ciphertext), E_pubMachine(master_key)` 74 | 75 | Machine decrypts master key, `D_privMachine(E_pubMachine(master_key))` 76 | 77 | Machine decrypts ciphertext. 78 | 79 | `D_masterKey(D_privMachine(ciphertext))` 80 | 81 | ## Adding New Machines 82 | 83 | Adding a new machine will require one of the user's other machines. The new machine will make a request to the server to be added. The server will then respond with some sort of challenge, requesting that the user enters a phrase on one of their old machines. Once the user enters the phrase on their old machine, the server will then allow them to upload a public keypair so they can do communication with the server. The old machine will need to be involved a little longer so that it can decrypt the master key, and upload a new master key using this new machine's public key. Here is the full process laid out: 84 | 85 | Machine B requests to be added to allowed clients 86 | Server gives Machine B a challenge phrase which must be entered on Machine A 87 | Challenge phrase is entered on Machine A. Machine A awaits response from server (Machine B's public key) 88 | Machine B generates keypair and uploads public key to server. 89 | Server saves B's public key and then sends `E_aPub(master_key)` & B's public key to Machine A. 90 | Machine A does `D_aPriv(enc_master_key)`, and then sends `E_bPub(master_key)` to the server. 91 | Server then saves this new encrypted master key. 92 | 93 | # Other Technical Details 94 | 95 | ## SSH Config Parsing 96 | 97 | Part of the syncing process will be where the program parses a user's SSH config file, and then sends the parsed format over to the server. Other machines, when syncing, will be able to have new config files generated for them based on what is in the server (and the CLI will handle changing user directories & OS changes). 98 | 99 | ## Data Conflicts 100 | 101 | TODO: after parsing SSH config, if there are duplicate entries, attempt to merge, but with conflicts, ask user how to resolve. 102 | 103 | TODO: what if duplicate keys get uploaded? ask user to replace/skip 104 | 105 | # P2P Concept 106 | 107 | If P2P was to be implemented, a lot of the crypto needed for the server implementation would be unnecessary. This is how a request would probably go: 108 | 109 | Machine B wants Machine A's keys and config. 110 | Machine A challenges Machine B with a phrase. 111 | Machine B responds to challenge, and sends its public key. 112 | Assuming challenge is passed, Machine A encrypts its data using EC-DH-A256GCM (Machine B public key) and sends it over to Machine B. 113 | Machine B would receive the data, decrypt it, and the program would manage things as necessary. 114 | 115 | All the other functionality of the program (i.e SSH config parser) would remain the same. 116 | 117 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/therealpaulgg/ssh-sync 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.20.0 9 | github.com/charmbracelet/bubbletea v1.1.0 10 | github.com/charmbracelet/lipgloss v0.13.1 11 | github.com/gobwas/ws v1.1.0 12 | github.com/google/uuid v1.3.0 13 | github.com/lestrrat-go/jwx/v2 v2.0.21 14 | github.com/samber/lo v1.37.0 15 | github.com/stretchr/testify v1.9.0 16 | github.com/urfave/cli/v2 v2.23.7 17 | ) 18 | 19 | require ( 20 | github.com/atotto/clipboard v0.1.4 // indirect 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 22 | github.com/charmbracelet/x/ansi v0.3.2 // indirect 23 | github.com/charmbracelet/x/term v0.2.0 // indirect 24 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 27 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 28 | github.com/gobwas/httphead v0.1.0 // indirect 29 | github.com/gobwas/pool v0.2.1 // indirect 30 | github.com/goccy/go-json v0.10.2 // indirect 31 | github.com/kr/pretty v0.1.0 // indirect 32 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 33 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 34 | github.com/lestrrat-go/httprc v1.0.5 // indirect 35 | github.com/lestrrat-go/iter v1.0.2 // indirect 36 | github.com/lestrrat-go/option v1.0.1 // indirect 37 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/mattn/go-localereader v0.0.1 // indirect 40 | github.com/mattn/go-runewidth v0.0.16 // indirect 41 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 42 | github.com/muesli/cancelreader v0.2.2 // indirect 43 | github.com/muesli/termenv v0.15.2 // indirect 44 | github.com/pmezard/go-difflib v1.0.0 // indirect 45 | github.com/rivo/uniseg v0.4.7 // indirect 46 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 47 | github.com/sahilm/fuzzy v0.1.1 // indirect 48 | github.com/segmentio/asm v1.2.0 // indirect 49 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 50 | golang.org/x/crypto v0.35.0 // indirect 51 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 52 | golang.org/x/sync v0.11.0 // indirect 53 | golang.org/x/sys v0.30.0 // indirect 54 | golang.org/x/text v0.22.0 // indirect 55 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 6 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 7 | github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= 8 | github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= 9 | github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A= 10 | github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U= 11 | github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= 12 | github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 13 | github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= 14 | github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 21 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 22 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 24 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 25 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 26 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 27 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 28 | github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= 29 | github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= 30 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 31 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 32 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 33 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 34 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 35 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 36 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 37 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 38 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 39 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 40 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 41 | github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 42 | github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 43 | github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 44 | github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 45 | github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk= 46 | github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 47 | github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 48 | github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 49 | github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0= 50 | github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM= 51 | github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 52 | github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 53 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 54 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 55 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 56 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 57 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 58 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 59 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 60 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 61 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 62 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 63 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 64 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 65 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 66 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 67 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 68 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 69 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 70 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 71 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 72 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 73 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 74 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 75 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 76 | github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw= 77 | github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA= 78 | github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 79 | github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 80 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 81 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 82 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 83 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 84 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 85 | github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY= 86 | github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= 87 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 88 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 89 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 90 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 91 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= 92 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= 93 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 94 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 95 | golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 96 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 99 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 100 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 101 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 102 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 103 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 104 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 106 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 107 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 108 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/therealpaulgg/ssh-sync/pkg/actions" 8 | "github.com/therealpaulgg/ssh-sync/pkg/actions/interactive" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | var version string 13 | 14 | func main() { 15 | app := &cli.App{ 16 | Name: "ssh-sync", 17 | Usage: "sync your ssh keys to a remote server", 18 | Version: version, 19 | Description: "Syncs your ssh keys to a remote server", 20 | Commands: []*cli.Command{ 21 | { 22 | Name: "setup", 23 | Description: "Set up your system to use ssh-sync.", 24 | Action: actions.Setup, 25 | }, 26 | { 27 | Name: "upload", 28 | Flags: []cli.Flag{ 29 | &cli.StringFlag{ 30 | Name: "path", 31 | Aliases: []string{"p"}, 32 | Usage: "Path to the ssh keys", 33 | }, 34 | }, 35 | Action: actions.Upload, 36 | }, 37 | { 38 | Name: "download", 39 | Flags: []cli.Flag{ 40 | &cli.BoolFlag{ 41 | Name: "safe-mode", 42 | Aliases: []string{"s"}, 43 | Usage: "Safe mode will sync to an alternate directory (.ssh-sync-data)", 44 | }, 45 | }, 46 | Action: actions.Download, 47 | }, 48 | { 49 | Name: "challenge-response", 50 | ArgsUsage: "[challenge-phrase]", 51 | Action: actions.ChallengeResponse, 52 | }, 53 | { 54 | Name: "list-machines", 55 | Action: actions.ListMachines, 56 | }, 57 | { 58 | Name: "remove-machine", 59 | ArgsUsage: "[machine-name]", 60 | Action: actions.RemoveMachine, 61 | }, 62 | { 63 | Name: "reset", 64 | Action: actions.Reset, 65 | }, 66 | { 67 | Name: "interactive", 68 | Description: "Uses a TUI mode for interacting with keys and config", 69 | Usage: "Interactively manage your ssh keys with a TUI", 70 | Action: interactive.Interactive, 71 | }, 72 | }, 73 | } 74 | if err := app.Run(os.Args); err != nil { 75 | fmt.Fprintln(os.Stderr, err.Error()) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/actions/challenge-response.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/gobwas/ws" 11 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 12 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | func ChallengeResponse(c *cli.Context) error { 17 | setup, err := utils.CheckIfSetup() 18 | if err != nil { 19 | return err 20 | } 21 | if !setup { 22 | fmt.Fprintln(os.Stderr, "ssh-sync has not been set up on this system. Please set up before continuing.") 23 | return nil 24 | } 25 | token, err := utils.GetToken() 26 | if err != nil { 27 | return err 28 | } 29 | profile, err := utils.GetProfile() 30 | if err != nil { 31 | return err 32 | } 33 | dialer := ws.Dialer{} 34 | dialer.Header = ws.HandshakeHeaderHTTP(http.Header{ 35 | "Authorization": []string{"Bearer " + token}, 36 | }) 37 | wsUrl := profile.ServerUrl 38 | if wsUrl.Scheme == "http" { 39 | wsUrl.Scheme = "ws" 40 | } else { 41 | wsUrl.Scheme = "wss" 42 | } 43 | wsUrl.Path = "/api/v1/setup/challenge" 44 | conn, _, _, err := dialer.Dial(context.Background(), wsUrl.String()) 45 | if err != nil { 46 | return err 47 | } 48 | defer conn.Close() 49 | answer := c.Args().First() 50 | if answer == "" { 51 | fmt.Print("Please enter the challenge phrase: ") 52 | scanner := bufio.NewScanner(os.Stdin) 53 | if err := utils.ReadLineFromStdin(scanner, &answer); err != nil { 54 | return err 55 | } 56 | } 57 | if err := utils.WriteClientMessage(&conn, dto.ChallengeResponseDto{ 58 | Challenge: answer, 59 | }); err != nil { 60 | return err 61 | } 62 | response, err := utils.ReadServerMessage[dto.ChallengeSuccessEncryptedKeyDto](&conn) 63 | if err != nil { 64 | return err 65 | } 66 | masterKey, err := utils.RetrieveMasterKey() 67 | if err != nil { 68 | return err 69 | } 70 | encryptedMasterKey, err := utils.EncryptWithPublicKey(masterKey, response.Data.PublicKey) 71 | if err != nil { 72 | return err 73 | } 74 | if err := utils.WriteClientMessage(&conn, dto.EncryptedMasterKeyDto{EncryptedMasterKey: encryptedMasterKey}); err != nil { 75 | return err 76 | } 77 | fmt.Println("Challenge has been successfully completed and your new encrypted master key has been sent to server. You may now use ssh-sync on your new machine.") 78 | // now send 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/actions/download.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/samber/lo" 10 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 11 | "github.com/therealpaulgg/ssh-sync/pkg/models" 12 | "github.com/therealpaulgg/ssh-sync/pkg/retrieval" 13 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 14 | "github.com/urfave/cli/v2" 15 | ) 16 | 17 | func Download(c *cli.Context) error { 18 | setup, err := utils.CheckIfSetup() 19 | if err != nil { 20 | return err 21 | } 22 | if !setup { 23 | fmt.Fprintln(os.Stderr, "ssh-sync has not been set up on this system. Please set up before continuing.") 24 | return nil 25 | } 26 | profile, err := utils.GetProfile() 27 | if err != nil { 28 | return err 29 | } 30 | data, err := retrieval.GetUserData(profile) 31 | if err != nil { 32 | return err 33 | } 34 | isSafeMode := c.Bool("safe-mode") 35 | var directory string 36 | if isSafeMode { 37 | fmt.Println("Executing in safe mode (keys writing to .ssh-sync-data)") 38 | directory = ".ssh-sync-data" 39 | } else { 40 | directory = ".ssh" 41 | } 42 | if err := utils.WriteConfig(lo.Map(data.SshConfig, func(config dto.SshConfigDto, i int) models.Host { 43 | return models.Host{ 44 | Host: config.Host, 45 | Values: config.Values, 46 | IdentityFiles: config.IdentityFiles, 47 | } 48 | }), directory); err != nil { 49 | return err 50 | } 51 | for _, key := range data.Keys { 52 | if err := utils.WriteKey(key.Data, key.Filename, directory); err != nil { 53 | return err 54 | } 55 | } 56 | 57 | err = checkForDeletedKeys(data.Keys, directory) 58 | 59 | if err != nil { 60 | return err 61 | } 62 | fmt.Println("Successfully downloaded keys.") 63 | return nil 64 | } 65 | 66 | func checkForDeletedKeys(keys []dto.KeyDto, directory string) error { 67 | sshDir, err := utils.GetAndCreateSshDirectory(directory) 68 | if err != nil { 69 | return err 70 | } 71 | err = filepath.WalkDir(sshDir, func(p string, d os.DirEntry, err error) error { 72 | if err != nil { 73 | return err 74 | } 75 | if d.IsDir() { 76 | return nil 77 | } 78 | if d.Name() == "config" { 79 | return nil 80 | } 81 | _, exists := lo.Find(keys, func(key dto.KeyDto) bool { 82 | return key.Filename == d.Name() 83 | }) 84 | if exists { 85 | return nil 86 | } 87 | fmt.Printf("Key %s detected on your filesystem that is not in the database. Delete? (y/n): ", d.Name()) 88 | var answer string 89 | scanner := bufio.NewScanner(os.Stdin) 90 | if err := utils.ReadLineFromStdin(scanner, &answer); err != nil { 91 | return err 92 | } 93 | if answer == "y" { 94 | if err := os.Remove(p); err != nil { 95 | return err 96 | } 97 | } 98 | return nil 99 | }) 100 | if err != nil { 101 | return err 102 | } 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/actions/interactive/main.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/therealpaulgg/ssh-sync/pkg/actions/interactive/states" 8 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 9 | "github.com/urfave/cli/v2" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | func Interactive(c *cli.Context) error { 15 | // get user data 16 | setup, err := utils.CheckIfSetup() 17 | if err != nil { 18 | return err 19 | } 20 | if !setup { 21 | fmt.Fprintln(os.Stderr, "ssh-sync has not been set up on this system. Please set up before continuing.") 22 | return nil 23 | } 24 | 25 | model := states.NewModel() 26 | 27 | p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) 28 | if _, err := p.Run(); err != nil { 29 | fmt.Printf("Alas, there's been an error: %v", err) 30 | return err 31 | } 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/common.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "strings" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | type baseState struct { 11 | width int 12 | height int 13 | } 14 | 15 | func (b *baseState) SetSize(width, height int) { 16 | b.width = width 17 | b.height = height 18 | } 19 | 20 | // State represents a single screen or state in the application 21 | 22 | type State interface { 23 | Update(msg tea.Msg) (State, tea.Cmd) 24 | View() string 25 | SetSize(width, height int) 26 | Initialize() 27 | PrettyName() string 28 | } 29 | 30 | // Init code that should be always run after a new state is created 31 | func (b *baseState) Initialize() { 32 | b.SetSize(b.width, b.height) 33 | } 34 | 35 | // item represents a selectable item in a list 36 | type item struct { 37 | title, desc string 38 | index int 39 | } 40 | 41 | func (i item) Title() string { return i.title } 42 | func (i item) Description() string { return i.desc } 43 | func (i item) FilterValue() string { return i.title } 44 | 45 | // Helper functions 46 | func headerView(title string, width int) string { 47 | titleStyle := TitleStyle.Render(title) 48 | line := strings.Repeat("─", max(0, width-lipgloss.Width(titleStyle)-4)) 49 | return lipgloss.JoinHorizontal(lipgloss.Center, titleStyle, BasicColorStyle.Render(line)) 50 | } 51 | 52 | func footerView(info string, width int) string { 53 | infoStyle := InfoStyle.Render(info) 54 | line := strings.Repeat("─", max(0, width-lipgloss.Width(infoStyle)-4)) 55 | return lipgloss.JoinHorizontal(lipgloss.Center, BasicColorStyle.Render(line), infoStyle) 56 | } 57 | 58 | func max(a, b int) int { 59 | if a > b { 60 | return a 61 | } 62 | return b 63 | } 64 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/delete-ssh-key.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 8 | "github.com/therealpaulgg/ssh-sync/pkg/retrieval" 9 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 10 | ) 11 | 12 | // DeleteSSHKey 13 | type DeleteSSHKey struct { 14 | baseState 15 | key dto.KeyDto 16 | } 17 | 18 | func NewDeleteSSHKey(b baseState, key dto.KeyDto) *DeleteSSHKey { 19 | d := &DeleteSSHKey{ 20 | key: key, 21 | baseState: b, 22 | } 23 | d.Initialize() 24 | return d 25 | } 26 | 27 | func (d *DeleteSSHKey) PrettyName() string { 28 | return "Delete Key" 29 | } 30 | 31 | func (d *DeleteSSHKey) Update(msg tea.Msg) (State, tea.Cmd) { 32 | switch msg := msg.(type) { 33 | case tea.KeyMsg: 34 | switch msg.String() { 35 | case "q", "ctrl+c": 36 | return d, tea.Quit 37 | case "y", "Y": 38 | err := d.deleteKey() 39 | if err != nil { 40 | return NewErrorState(d.baseState, err), nil 41 | } 42 | sshKeyManager, err := NewSSHKeyManager(d.baseState) 43 | if err != nil { 44 | return NewErrorState(d.baseState, err), nil 45 | } 46 | return sshKeyManager, nil 47 | case "n", "N", "backspace": 48 | return NewSSHKeyOptions(d.baseState, d.key), nil 49 | } 50 | } 51 | return d, nil 52 | } 53 | 54 | func (d *DeleteSSHKey) deleteKey() error { 55 | profile, err := utils.GetProfile() 56 | if err != nil { 57 | return err 58 | } 59 | err = retrieval.DeleteKey(profile, d.key) 60 | return err 61 | } 62 | 63 | func (d *DeleteSSHKey) View() string { 64 | content := fmt.Sprintf("Are you sure you want to delete the key %s? (y/n)", d.key.Filename) 65 | return content 66 | } 67 | 68 | func (d *DeleteSSHKey) SetSize(width, height int) { 69 | d.baseState.SetSize(width, height) 70 | } 71 | 72 | func (d *DeleteSSHKey) Initialize() { 73 | d.SetSize(d.width, d.height) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/error-state.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | // ErrorState 10 | type ErrorState struct { 11 | baseState 12 | err error 13 | } 14 | 15 | func NewErrorState(b baseState, err error) *ErrorState { 16 | e := &ErrorState{ 17 | err: err, 18 | baseState: b, 19 | } 20 | e.Initialize() 21 | return e 22 | } 23 | 24 | func (e *ErrorState) PrettyName() string { 25 | return "Error" 26 | } 27 | 28 | func (e *ErrorState) Update(msg tea.Msg) (State, tea.Cmd) { 29 | switch msg := msg.(type) { 30 | case tea.KeyMsg: 31 | if msg.String() == "backspace" || msg.String() == "q" { 32 | return NewMainMenu(e.baseState), nil 33 | } 34 | } 35 | return e, nil 36 | } 37 | 38 | func (e *ErrorState) View() string { 39 | return fmt.Sprintf("An error occurred: %v\nPress 'backspace' or 'q' to return to the main menu.", e.err) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/main-menu.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | tea "github.com/charmbracelet/bubbletea" 6 | ) 7 | 8 | // MainMenu 9 | type MainMenu struct { 10 | baseState 11 | list list.Model 12 | } 13 | 14 | func NewMainMenu(b baseState) *MainMenu { 15 | items := []list.Item{ 16 | item{title: "Manage SSH Keys", desc: "View and manage your SSH keys"}, 17 | } 18 | l := list.New(items, list.NewDefaultDelegate(), 0, 0) 19 | l.Title = "Main Menu" 20 | m := &MainMenu{ 21 | list: l, 22 | baseState: b, 23 | } 24 | m.Initialize() 25 | return m 26 | } 27 | 28 | func (m *MainMenu) PrettyName() string { 29 | return m.list.Title 30 | } 31 | 32 | func (m *MainMenu) Update(msg tea.Msg) (State, tea.Cmd) { 33 | switch msg := msg.(type) { 34 | case tea.KeyMsg: 35 | switch msg.String() { 36 | case "q", "ctrl+c": 37 | return m, tea.Quit 38 | case "enter": 39 | i := m.list.SelectedItem().(item) 40 | switch i.title { 41 | case "Manage SSH Keys": 42 | sshKeyManager, err := NewSSHKeyManager(m.baseState) 43 | if err != nil { 44 | return NewErrorState(m.baseState, err), nil 45 | } 46 | sshKeyManager.height = m.height 47 | sshKeyManager.width = m.width 48 | return sshKeyManager, nil 49 | } 50 | } 51 | } 52 | var cmd tea.Cmd 53 | m.list, cmd = m.list.Update(msg) 54 | return m, cmd 55 | } 56 | 57 | func (m *MainMenu) View() string { 58 | return m.list.View() 59 | } 60 | 61 | func (m *MainMenu) SetSize(width, height int) { 62 | m.baseState.SetSize(width, height) 63 | m.list.SetSize(width, height) 64 | } 65 | 66 | func (m *MainMenu) Initialize() { 67 | m.SetSize(m.width, m.height) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/main.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | type Model struct { 11 | currentState State 12 | width int 13 | height int 14 | } 15 | 16 | func NewModel() Model { 17 | return Model{ 18 | currentState: NewMainMenu(baseState{}), 19 | } 20 | } 21 | 22 | func (m Model) Init() tea.Cmd { 23 | return nil 24 | } 25 | 26 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 27 | var cmd tea.Cmd 28 | switch msg := msg.(type) { 29 | case tea.WindowSizeMsg: 30 | h, v := AppStyle.GetFrameSize() 31 | headerAndFooterHeight := lipgloss.Height(m.Header()) + lipgloss.Height(m.Footer()) 32 | adjustedWidth, adjustedHeight := msg.Width-h, msg.Height-v-headerAndFooterHeight 33 | m.width = adjustedWidth 34 | m.height = adjustedHeight 35 | m.currentState.SetSize(m.width, m.height) 36 | } 37 | m.currentState, cmd = m.currentState.Update(msg) 38 | return m, cmd 39 | } 40 | 41 | func (m Model) Header() string { 42 | return headerView(m.currentState.PrettyName(), m.width) 43 | } 44 | 45 | func (m Model) Footer() string { 46 | return footerView(m.currentState.PrettyName(), m.width) 47 | } 48 | 49 | func (m Model) View() string { 50 | return AppStyle.Render(fmt.Sprintf("%s\n%s\n%s", 51 | m.Header(), 52 | m.currentState.View(), 53 | m.Footer(), 54 | )) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/ssh-key-content.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/key" 8 | ke "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 13 | ) 14 | 15 | // SSHKeyContent 16 | type SSHKeyContent struct { 17 | baseState 18 | viewport viewport.Model 19 | help help.Model 20 | keymap help.KeyMap 21 | key dto.KeyDto 22 | } 23 | 24 | type SSHKeyContentHelp struct { 25 | } 26 | 27 | func (s *SSHKeyContentHelp) ShortHelp() []key.Binding { 28 | keymap := viewport.DefaultKeyMap() 29 | return []ke.Binding{ 30 | keymap.Up, 31 | keymap.Down, 32 | ke.NewBinding(ke.WithKeys("backspace"), ke.WithHelp("backspace", "back")), 33 | ke.NewBinding(ke.WithKeys("q"), ke.WithHelp("q", "quit")), 34 | ke.NewBinding(ke.WithKeys("?"), ke.WithHelp("?", "more")), 35 | } 36 | } 37 | 38 | func (s *SSHKeyContentHelp) FullHelp() [][]key.Binding { 39 | keymap := viewport.DefaultKeyMap() 40 | return [][]key.Binding{ 41 | []ke.Binding{ 42 | keymap.Up, 43 | keymap.Down, 44 | keymap.PageUp, 45 | keymap.PageDown, 46 | keymap.HalfPageUp, 47 | keymap.HalfPageDown, 48 | }, 49 | []ke.Binding{ 50 | ke.NewBinding(ke.WithKeys("backspace"), ke.WithHelp("backspace", "back")), 51 | ke.NewBinding(ke.WithKeys("q"), ke.WithHelp("q", "quit")), 52 | ke.NewBinding(ke.WithKeys("?"), ke.WithHelp("?", "close help")), 53 | }, 54 | } 55 | } 56 | 57 | func NewSSHKeyContent(b baseState, key dto.KeyDto) *SSHKeyContent { 58 | v := viewport.New(80, 20) 59 | v.SetContent(string(key.Data)) 60 | c := &SSHKeyContent{ 61 | viewport: v, 62 | key: key, 63 | baseState: b, 64 | } 65 | c.help = help.New() 66 | c.keymap = &SSHKeyContentHelp{} 67 | c.Initialize() 68 | return c 69 | } 70 | 71 | func (s *SSHKeyContent) PrettyName() string { 72 | return "Key Content" 73 | } 74 | 75 | func (s *SSHKeyContent) Update(msg tea.Msg) (State, tea.Cmd) { 76 | switch msg := msg.(type) { 77 | case tea.KeyMsg: 78 | if msg.String() == "q" || msg.String() == "ctrl+c" { 79 | return s, tea.Quit 80 | } 81 | if msg.String() == "backspace" { 82 | return NewSSHKeyOptions(s.baseState, s.key), nil 83 | } 84 | if msg.String() == "?" { 85 | s.help.ShowAll = !s.help.ShowAll 86 | s.SetSize(s.width, s.height) 87 | return s, nil 88 | } 89 | } 90 | var cmd tea.Cmd 91 | s.viewport, cmd = s.viewport.Update(msg) 92 | return s, cmd 93 | } 94 | 95 | func (s *SSHKeyContent) View() string { 96 | return fmt.Sprintf("%s\n%s", s.viewport.View(), s.help.View(s.keymap)) 97 | } 98 | 99 | func (s *SSHKeyContent) SetSize(width, height int) { 100 | s.baseState.SetSize(width, height) 101 | s.viewport.Width = width 102 | s.viewport.Height = height - lipgloss.Height(s.help.View(s.keymap)) 103 | } 104 | 105 | func (s *SSHKeyContent) Initialize() { 106 | s.SetSize(s.width, s.height) 107 | } 108 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/ssh-key-manager.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/list" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 9 | "github.com/therealpaulgg/ssh-sync/pkg/retrieval" 10 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 11 | ) 12 | 13 | // SSHKeyManager 14 | type SSHKeyManager struct { 15 | baseState 16 | list list.Model 17 | keys []dto.KeyDto 18 | } 19 | 20 | func NewSSHKeyManager(baseState baseState) (*SSHKeyManager, error) { 21 | profile, err := utils.GetProfile() 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to get profile: %w", err) 24 | } 25 | data, err := retrieval.GetUserData(profile) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to get user data: %w", err) 28 | } 29 | 30 | items := make([]list.Item, len(data.Keys)) 31 | for i, key := range data.Keys { 32 | items[i] = item{title: key.Filename, desc: "", index: i} 33 | } 34 | 35 | l := list.New(items, list.NewDefaultDelegate(), 0, 0) 36 | l.Title = "SSH Keys" 37 | 38 | m := &SSHKeyManager{ 39 | list: l, 40 | keys: data.Keys, 41 | baseState: baseState, 42 | } 43 | m.Initialize() 44 | return m, nil 45 | } 46 | 47 | func (s *SSHKeyManager) PrettyName() string { 48 | return s.list.Title 49 | } 50 | 51 | func (s *SSHKeyManager) Update(msg tea.Msg) (State, tea.Cmd) { 52 | switch msg := msg.(type) { 53 | case tea.KeyMsg: 54 | switch msg.String() { 55 | case "q", "ctrl+c": 56 | return s, tea.Quit 57 | case "enter": 58 | if !s.list.SettingFilter() { 59 | selected := s.list.SelectedItem().(item) 60 | return NewSSHKeyOptions(s.baseState, s.keys[selected.index]), nil 61 | } 62 | case "backspace": 63 | if !s.list.SettingFilter() { 64 | return NewMainMenu(s.baseState), nil 65 | } 66 | } 67 | } 68 | var cmd tea.Cmd 69 | s.list, cmd = s.list.Update(msg) 70 | return s, cmd 71 | } 72 | 73 | func (s *SSHKeyManager) View() string { 74 | return s.list.View() 75 | } 76 | 77 | func (s *SSHKeyManager) SetSize(width, height int) { 78 | s.baseState.SetSize(width, height) 79 | s.list.SetSize(width, height) 80 | } 81 | 82 | func (s *SSHKeyManager) Initialize() { 83 | s.SetSize(s.width, s.height) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/ssh-key-options.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 7 | ) 8 | 9 | // SSHKeyOptions 10 | type SSHKeyOptions struct { 11 | baseState 12 | list list.Model 13 | selectedKey dto.KeyDto 14 | } 15 | 16 | func NewSSHKeyOptions(b baseState, key dto.KeyDto) *SSHKeyOptions { 17 | items := []list.Item{ 18 | item{title: "View Content", desc: "View the content of the SSH key"}, 19 | item{title: "Delete Key", desc: "Delete the SSH key from the store"}, 20 | } 21 | l := list.New(items, list.NewDefaultDelegate(), 0, 0) 22 | l.Title = "Options for " + key.Filename 23 | // l.SetShowHelp(false) 24 | s := &SSHKeyOptions{ 25 | list: l, 26 | selectedKey: key, 27 | baseState: b, 28 | } 29 | s.Initialize() 30 | return s 31 | } 32 | 33 | func (s *SSHKeyOptions) PrettyName() string { 34 | return "Key Options" 35 | } 36 | 37 | func (s *SSHKeyOptions) Update(msg tea.Msg) (State, tea.Cmd) { 38 | switch msg := msg.(type) { 39 | case tea.KeyMsg: 40 | switch msg.String() { 41 | case "q", "ctrl+c": 42 | return s, tea.Quit 43 | case "enter": 44 | i := s.list.SelectedItem().(item) 45 | switch i.title { 46 | case "View Content": 47 | return NewSSHKeyContent(s.baseState, s.selectedKey), nil 48 | case "Delete Key": 49 | return NewDeleteSSHKey(s.baseState, s.selectedKey), nil 50 | } 51 | case "backspace": 52 | sshKeyManager, err := NewSSHKeyManager(s.baseState) 53 | if err != nil { 54 | return NewErrorState(s.baseState, err), nil 55 | } 56 | return sshKeyManager, nil 57 | } 58 | } 59 | var cmd tea.Cmd 60 | s.list, cmd = s.list.Update(msg) 61 | return s, cmd 62 | } 63 | 64 | func (s *SSHKeyOptions) View() string { 65 | return s.list.View() 66 | } 67 | 68 | func (s *SSHKeyOptions) SetSize(width, height int) { 69 | s.baseState.SetSize(width, height) 70 | s.list.SetSize(width, height) 71 | } 72 | 73 | func (s *SSHKeyOptions) Initialize() { 74 | s.SetSize(s.width, s.height) 75 | } 76 | -------------------------------------------------------------------------------- /pkg/actions/interactive/states/styles.go: -------------------------------------------------------------------------------- 1 | package states 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | // using this is causing problems with rendering due to caching...WHY?!?!?!?!?!? 7 | // if the layout shifts by even one character, previous screen's state may be displayed, resulting in glitches 8 | AppStyle = func() lipgloss.Style { 9 | return lipgloss.NewStyle().Padding(1, 2) 10 | }() 11 | TitleStyle = func() lipgloss.Style { 12 | b := lipgloss.RoundedBorder() 13 | b.Right = "├" 14 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1).BorderForeground(lipgloss.Color("#FF00FF")).Bold(true) 15 | }() 16 | 17 | InfoStyle = func() lipgloss.Style { 18 | b := lipgloss.RoundedBorder() 19 | b.Left = "┤" 20 | return TitleStyle.BorderStyle(b) 21 | }() 22 | 23 | BasicColorStyle = func() lipgloss.Style { 24 | return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF00FF")) 25 | }() 26 | ) 27 | -------------------------------------------------------------------------------- /pkg/actions/list-machines.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/therealpaulgg/ssh-sync/pkg/retrieval" 8 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func ListMachines(c *cli.Context) error { 13 | setup, err := utils.CheckIfSetup() 14 | if err != nil { 15 | return err 16 | } 17 | if !setup { 18 | fmt.Fprintln(os.Stderr, "ssh-sync has not been set up on this system. Please set up before continuing.") 19 | return nil 20 | } 21 | profile, err := utils.GetProfile() 22 | if err != nil { 23 | return err 24 | } 25 | machines, err := retrieval.GetMachines(profile) 26 | if err != nil { 27 | return err 28 | } 29 | for _, machine := range machines { 30 | fmt.Println(machine.Name) 31 | } 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/actions/remove-machine.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/samber/lo" 9 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 10 | "github.com/therealpaulgg/ssh-sync/pkg/retrieval" 11 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | func RemoveMachine(c *cli.Context) error { 16 | setup, err := utils.CheckIfSetup() 17 | if err != nil { 18 | return err 19 | } 20 | if !setup { 21 | fmt.Fprintln(os.Stderr, "ssh-sync has not been set up on this system. Please set up before continuing.") 22 | return nil 23 | } 24 | profile, err := utils.GetProfile() 25 | if err != nil { 26 | return err 27 | } 28 | answer := c.Args().First() 29 | scanner := bufio.NewScanner(os.Stdin) 30 | if answer == "" { 31 | fmt.Print("Please enter the machine name: ") 32 | if err := utils.ReadLineFromStdin(scanner, &answer); err != nil { 33 | return err 34 | } 35 | } 36 | machines, err := retrieval.GetMachines(profile) 37 | if err != nil { 38 | return err 39 | } 40 | _, exists := lo.Find(machines, func(x dto.MachineDto) bool { 41 | return x.Name == answer 42 | }) 43 | if !exists { 44 | fmt.Println("Machine not found in your list, exiting.") 45 | return nil 46 | } 47 | fmt.Println("This will remove this machine's public key from your account and you will no longer be able to use it to perform operations on your account.") 48 | fmt.Printf("Please confirm your intent to delete the following machine: %s (y/n): ", answer) 49 | if err := utils.ReadLineFromStdin(scanner, &answer); err != nil { 50 | return err 51 | } 52 | if answer != "y" { 53 | return nil 54 | } 55 | err = retrieval.DeleteMachine(profile, answer) 56 | return err 57 | } 58 | -------------------------------------------------------------------------------- /pkg/actions/reset.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "os/user" 11 | "path/filepath" 12 | 13 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 14 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | func Reset(c *cli.Context) error { 19 | setup, err := utils.CheckIfSetup() 20 | if err != nil { 21 | return err 22 | } 23 | if !setup { 24 | fmt.Fprintln(os.Stderr, "ssh-sync has not been set up on this system. Please set up before continuing.") 25 | return nil 26 | } 27 | fmt.Print("This will delete all ssh-sync data relating to this machine. Continue? (y/n): ") 28 | scanner := bufio.NewScanner(os.Stdin) 29 | var answer string 30 | if err := utils.ReadLineFromStdin(scanner, &answer); err != nil { 31 | return err 32 | } 33 | if answer != "y" { 34 | return nil 35 | } 36 | prof, err := getProfile() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | buf := new(bytes.Buffer) 42 | if err := json.NewEncoder(buf).Encode(dto.MachineDto{ 43 | Name: prof.MachineName, 44 | }); err != nil { 45 | return err 46 | } 47 | url := prof.ServerUrl 48 | url.Path = "/api/v1/machines/" 49 | req, err := http.NewRequest("DELETE", url.String(), buf) 50 | if err != nil { 51 | return err 52 | } 53 | token, err := utils.GetToken() 54 | if err != nil { 55 | return err 56 | } 57 | req.Header.Set("Authorization", "Bearer "+token) 58 | req.Header.Set("Content-Type", "application/json") 59 | resp, err := http.DefaultClient.Do(req) 60 | if err != nil { 61 | return err 62 | } 63 | if resp.StatusCode != http.StatusOK { 64 | fmt.Printf("unexpected status code when attempting to delete machine from endpoint: %d\n Continue with deletion? (y/n): ", resp.StatusCode) 65 | scanner := bufio.NewScanner(os.Stdin) 66 | var answer string 67 | if err := utils.ReadLineFromStdin(scanner, &answer); err != nil { 68 | return err 69 | } 70 | if answer != "y" { 71 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 72 | } 73 | } 74 | user, err := user.Current() 75 | if err != nil { 76 | return err 77 | } 78 | p := filepath.Join(user.HomeDir, ".ssh-sync") 79 | if err := os.RemoveAll(p); err != nil { 80 | return err 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/actions/setup.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "crypto/ecdsa" 8 | "crypto/elliptic" 9 | "crypto/rand" 10 | "crypto/x509" 11 | "encoding/json" 12 | "encoding/pem" 13 | "errors" 14 | "fmt" 15 | "io" 16 | "mime/multipart" 17 | "net/http" 18 | "net/url" 19 | "os" 20 | "os/user" 21 | "path/filepath" 22 | "strconv" 23 | 24 | "github.com/gobwas/ws" 25 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 26 | "github.com/therealpaulgg/ssh-sync/pkg/models" 27 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 28 | "github.com/urfave/cli/v2" 29 | ) 30 | 31 | func generateKey() (*ecdsa.PrivateKey, *ecdsa.PublicKey, error) { 32 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 33 | pub := &priv.PublicKey 34 | if err != nil { 35 | return nil, nil, err 36 | } 37 | // then the program will save the keypair to ~/.ssh-sync/keypair.pub and ~/.ssh-sync/keypair 38 | user, err := user.Current() 39 | if err != nil { 40 | return nil, nil, err 41 | } 42 | p := filepath.Join(user.HomeDir, ".ssh-sync") 43 | if err := os.MkdirAll(p, 0700); err != nil { 44 | return nil, nil, err 45 | } 46 | pubBytes, err := x509.MarshalPKIXPublicKey(pub) 47 | if err != nil { 48 | return nil, nil, err 49 | } 50 | privBytes, err := x509.MarshalECPrivateKey(priv) 51 | if err != nil { 52 | return nil, nil, err 53 | } 54 | pubOut, err := os.OpenFile(filepath.Join(p, "keypair.pub"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 55 | if err != nil { 56 | return nil, nil, err 57 | } 58 | defer pubOut.Close() 59 | if err := pem.Encode(pubOut, &pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}); err != nil { 60 | return nil, nil, err 61 | } 62 | privOut, err := os.OpenFile(filepath.Join(p, "keypair"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 63 | if err != nil { 64 | return nil, nil, err 65 | } 66 | defer privOut.Close() 67 | if err := pem.Encode(privOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil { 68 | return nil, nil, err 69 | } 70 | 71 | return priv, pub, nil 72 | } 73 | 74 | func saveMasterKey(masterKey []byte) error { 75 | user, err := user.Current() 76 | if err != nil { 77 | return err 78 | } 79 | p := filepath.Join(user.HomeDir, ".ssh-sync") 80 | masterOut, err := os.OpenFile(filepath.Join(p, "master_key"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 81 | if err != nil { 82 | return err 83 | } 84 | defer masterOut.Close() 85 | if _, err := masterOut.Write(masterKey); err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | 91 | func getProfile() (*models.Profile, error) { 92 | user, err := user.Current() 93 | if err != nil { 94 | return nil, err 95 | } 96 | p := filepath.Join(user.HomeDir, ".ssh-sync", "profile.json") 97 | dat, err := os.ReadFile(p) 98 | if err != nil { 99 | return nil, err 100 | } 101 | var profile models.Profile 102 | if err := json.Unmarshal(dat, &profile); err != nil { 103 | return nil, err 104 | } 105 | return &profile, nil 106 | } 107 | 108 | func saveProfile(username string, machineName string, serverUrl url.URL) error { 109 | // then the program will save the profile to ~/.ssh-sync/profile.json 110 | user, err := user.Current() 111 | if err != nil { 112 | return err 113 | } 114 | p := filepath.Join(user.HomeDir, ".ssh-sync", "profile.json") 115 | profile := models.Profile{ 116 | Username: username, 117 | MachineName: machineName, 118 | ServerUrl: serverUrl, 119 | } 120 | profileBytes, err := json.Marshal(profile) 121 | if err != nil { 122 | return err 123 | } 124 | if err := os.WriteFile(p, profileBytes, 0600); err != nil { 125 | return err 126 | } 127 | return nil 128 | } 129 | 130 | func checkIfAccountExists(username string, serverUrl *url.URL) (bool, error) { 131 | url := *serverUrl 132 | url.Path = "/api/v1/users/" + username 133 | res, err := http.Get(url.String()) 134 | if err != nil { 135 | return false, err 136 | } 137 | if res.StatusCode == 404 { 138 | return false, nil 139 | } 140 | return true, nil 141 | } 142 | 143 | func getPubkeyFile() (*os.File, error) { 144 | user, err := user.Current() 145 | if err != nil { 146 | return nil, err 147 | } 148 | pubkeyFile, err := os.Open(filepath.Join(user.HomeDir, ".ssh-sync", "keypair.pub")) 149 | if err != nil { 150 | return nil, err 151 | } 152 | return pubkeyFile, nil 153 | } 154 | 155 | func createMasterKey() ([]byte, error) { 156 | masterKey := make([]byte, 32) 157 | _, err := rand.Read(masterKey) 158 | if err != nil { 159 | return nil, err 160 | } 161 | return masterKey, nil 162 | } 163 | 164 | func newAccountSetup(serverUrl *url.URL) error { 165 | scanner := bufio.NewScanner(os.Stdin) 166 | // ask user to pick a username. 167 | fmt.Print("Please enter a username. This will be used to identify your account on the server: ") 168 | var username string 169 | err := utils.ReadLineFromStdin(scanner, &username) 170 | if err != nil { 171 | return err 172 | } 173 | exists, err := checkIfAccountExists(username, serverUrl) 174 | if err != nil { 175 | return err 176 | } 177 | if exists { 178 | return errors.New("user already exists on the server") 179 | } 180 | // ask user to pick a name for this machine (default to current system name) 181 | fmt.Print("Please enter a name for this machine: ") 182 | var machineName string 183 | if err := utils.ReadLineFromStdin(scanner, &machineName); err != nil { 184 | return err 185 | } 186 | // then the program will generate a keypair, and upload the public key to the server 187 | fmt.Println("Generating keypair...") 188 | if _, _, err := generateKey(); err != nil { 189 | return err 190 | } 191 | masterKey, err := createMasterKey() 192 | if err != nil { 193 | return err 194 | } 195 | encryptedMasterKey, err := utils.Encrypt(masterKey) 196 | if err != nil { 197 | return err 198 | } 199 | if err := saveMasterKey(encryptedMasterKey); err != nil { 200 | return err 201 | } 202 | 203 | // then the program will save the profile to ~/.ssh-sync/profile.json 204 | if err := saveProfile(username, machineName, *serverUrl); err != nil { 205 | return err 206 | } 207 | var multipartBody bytes.Buffer 208 | multipartWriter := multipart.NewWriter(&multipartBody) 209 | pubkeyFile, err := getPubkeyFile() 210 | if err != nil { 211 | return err 212 | } 213 | fileWriter, _ := multipartWriter.CreateFormFile("key", pubkeyFile.Name()) 214 | io.Copy(fileWriter, pubkeyFile) 215 | multipartWriter.WriteField("username", username) 216 | multipartWriter.WriteField("machine_name", machineName) 217 | multipartWriter.Close() 218 | setupUrl := *serverUrl 219 | setupUrl.Path = "/api/v1/setup" 220 | req, err := http.NewRequest("POST", setupUrl.String(), &multipartBody) 221 | if err != nil { 222 | return err 223 | } 224 | req.Header.Add("Content-Type", multipartWriter.FormDataContentType()) 225 | res, err := http.DefaultClient.Do(req) 226 | if err != nil { 227 | return err 228 | } 229 | if res.StatusCode != 200 { 230 | return errors.New("failed to create user. status code: " + strconv.Itoa(res.StatusCode)) 231 | } 232 | return nil 233 | } 234 | 235 | func existingAccountSetup(serverUrl *url.URL) error { 236 | scanner := bufio.NewScanner(os.Stdin) 237 | fmt.Print("Please enter a username. This will be used to identify your account on the server: ") 238 | var username string 239 | err := utils.ReadLineFromStdin(scanner, &username) 240 | if err != nil { 241 | return err 242 | } 243 | exists, err := checkIfAccountExists(username, serverUrl) 244 | if err != nil { 245 | return err 246 | } 247 | if !exists { 248 | return errors.New("user doesn't exist. try creating a new account") 249 | } 250 | fmt.Print("Please enter a name for this machine: ") 251 | var machineName string 252 | if err := utils.ReadLineFromStdin(scanner, &machineName); err != nil { 253 | return err 254 | } 255 | wsUrl := *serverUrl 256 | if wsUrl.Scheme == "http" { 257 | wsUrl.Scheme = "ws" 258 | } else { 259 | wsUrl.Scheme = "wss" 260 | } 261 | wsUrl.Path = "/api/v1/setup/existing" 262 | dialer := ws.Dialer{} 263 | conn, _, _, err := dialer.Dial(context.Background(), wsUrl.String()) 264 | if err != nil { 265 | return err 266 | } 267 | defer conn.Close() 268 | userMachine := dto.UserMachineDto{ 269 | Username: username, 270 | MachineName: machineName, 271 | } 272 | if err := utils.WriteClientMessage(&conn, userMachine); err != nil { 273 | return err 274 | } 275 | challengePhrase, err := utils.ReadServerMessage[dto.MessageDto](&conn) 276 | if err != nil { 277 | return err 278 | } 279 | fmt.Printf("Please enter this phrase using the 'challenge-response' command on another machine: %s\n", challengePhrase.Data.Message) 280 | challengeSuccessResponse, err := utils.ReadServerMessage[dto.MessageDto](&conn) 281 | if err != nil { 282 | return err 283 | } 284 | fmt.Println(challengeSuccessResponse.Data.Message) 285 | fmt.Println("Generating keypair...") 286 | if _, _, err := generateKey(); err != nil { 287 | return err 288 | } 289 | saveProfile(username, machineName, *serverUrl) 290 | f, err := getPubkeyFile() 291 | if err != nil { 292 | return err 293 | } 294 | defer f.Close() 295 | pubkey, err := io.ReadAll(f) 296 | if err != nil { 297 | return err 298 | } 299 | if err := utils.WriteClientMessage(&conn, dto.PublicKeyDto{PublicKey: pubkey}); err != nil { 300 | return err 301 | } 302 | encryptedMasterKey, err := utils.ReadServerMessage[dto.EncryptedMasterKeyDto](&conn) 303 | if err != nil { 304 | return err 305 | } 306 | if err := saveMasterKey(encryptedMasterKey.Data.EncryptedMasterKey); err != nil { 307 | return err 308 | } 309 | finalResponse, err := utils.ReadServerMessage[dto.MessageDto](&conn) 310 | if err != nil { 311 | return err 312 | } 313 | fmt.Println(finalResponse.Data.Message) 314 | return nil 315 | } 316 | 317 | func Setup(c *cli.Context) error { 318 | scanner := bufio.NewScanner(os.Stdin) 319 | // all files will be stored in ~/.ssh-sync 320 | // there will be a profile.json file containing the machine name and the username 321 | // there will also be a keypair. 322 | // check if setup has been completed before 323 | setup, err := utils.CheckIfSetup() 324 | if err != nil { 325 | return err 326 | } 327 | if setup { 328 | // if it has been completed, the user may want to restart. 329 | // if so this is a destructive operation and will result in the deletion of all saved data relating to ssh-sync. 330 | fmt.Println("ssh-sync has already been set up on this system.") 331 | return nil 332 | } 333 | fmt.Println("We recommend for the security-conscious that you use your own self-hosted ssh-sync-server.") 334 | fmt.Println("If you don't have one, you'll be able to use the one hosted at https://server.sshsync.io.") 335 | fmt.Print("Do you want to use your own server? (y/n): ") 336 | var useOwnServer string 337 | if err := utils.ReadLineFromStdin(scanner, &useOwnServer); err != nil { 338 | return err 339 | } 340 | var serverUrl *url.URL 341 | if useOwnServer == "n" { 342 | serverUrl, err = url.Parse("https://server.sshsync.io") 343 | if err != nil { 344 | return err 345 | } 346 | } else { 347 | // ask user if they already have an account on the ssh-sync server. 348 | fmt.Print("Please enter your server address (http/https): ") 349 | var serverAddress string 350 | if err := utils.ReadLineFromStdin(scanner, &serverAddress); err != nil { 351 | return err 352 | } 353 | serverUrl, err = url.Parse(serverAddress) 354 | if err != nil { 355 | return err 356 | } else if serverUrl.Scheme == "" || serverUrl.Host == "" { 357 | return errors.New("invalid server address") 358 | } else if serverUrl.Scheme != "http" && serverUrl.Scheme != "https" { 359 | return errors.New("server must use http or https") 360 | } 361 | if serverUrl.Scheme == "http" { 362 | fmt.Println("WARNING: Your server is using HTTP. This is not secure. You should use HTTPS.") 363 | } 364 | } 365 | 366 | // test connection to server 367 | if _, err := http.Get(serverUrl.String()); err != nil { 368 | fmt.Println("It seems we are unable to connect to this ssh-sync server at the moment. Please check your configuration and try again.") 369 | return err 370 | } 371 | fmt.Print("Do you already have an account on the ssh-sync server? (y/n): ") 372 | var answer string 373 | if err := utils.ReadLineFromStdin(scanner, &answer); err != nil { 374 | return err 375 | } 376 | if answer == "y" { 377 | return existingAccountSetup(serverUrl) 378 | } 379 | return newAccountSetup(serverUrl) 380 | } 381 | -------------------------------------------------------------------------------- /pkg/actions/upload.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "mime/multipart" 10 | "net/http" 11 | "os" 12 | "os/user" 13 | "path/filepath" 14 | "strconv" 15 | 16 | "github.com/therealpaulgg/ssh-sync/pkg/models" 17 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 18 | "github.com/urfave/cli/v2" 19 | ) 20 | 21 | func Upload(c *cli.Context) error { 22 | setup, err := utils.CheckIfSetup() 23 | if err != nil { 24 | return err 25 | } 26 | if !setup { 27 | fmt.Fprintln(os.Stderr, "ssh-sync has not been set up on this system. Please set up before continuing.") 28 | return nil 29 | } 30 | profile, err := utils.GetProfile() 31 | if err != nil { 32 | return err 33 | } 34 | token, err := utils.GetToken() 35 | if err != nil { 36 | return err 37 | } 38 | url := profile.ServerUrl 39 | url.Path = "/api/v1/data" 40 | req, err := http.NewRequest("GET", url.String(), nil) 41 | if err != nil { 42 | return err 43 | } 44 | req.Header.Add("Authorization", "Bearer "+token) 45 | res, err := http.DefaultClient.Do(req) 46 | if err != nil { 47 | return err 48 | } 49 | if res.StatusCode != 200 { 50 | return errors.New("failed to get data. status code: " + strconv.Itoa(res.StatusCode)) 51 | } 52 | masterKey, err := utils.RetrieveMasterKey() 53 | if err != nil { 54 | return err 55 | } 56 | var multipartBody bytes.Buffer 57 | multipartWriter := multipart.NewWriter(&multipartBody) 58 | p := c.String("path") 59 | if p == "" { 60 | user, err := user.Current() 61 | if err != nil { 62 | return err 63 | } 64 | p = filepath.Join(user.HomeDir, ".ssh") 65 | } 66 | data, err := os.ReadDir(p) 67 | if err != nil { 68 | return err 69 | } 70 | hosts := []models.Host{} 71 | for _, file := range data { 72 | if file.IsDir() || file.Name() == "authorized_keys" { 73 | continue 74 | } else if file.Name() == "config" { 75 | hosts, err = utils.ParseConfig() 76 | if err != nil { 77 | return err 78 | } 79 | if len(hosts) == 0 { 80 | return errors.New("your ssh config is empty. Please add some hosts to your ssh config so data can be uploaded.") 81 | } 82 | continue 83 | } 84 | f, err := os.OpenFile(filepath.Join(p, file.Name()), os.O_RDONLY, 0600) 85 | if err != nil { 86 | return err 87 | } 88 | // read file into buffer 89 | data, err := io.ReadAll(f) 90 | if err != nil { 91 | return err 92 | } 93 | encBytes, err := utils.EncryptWithMasterKey(data, masterKey) 94 | if err != nil { 95 | return err 96 | } 97 | w, _ := multipartWriter.CreateFormFile("keys[]", file.Name()) 98 | if _, err := io.Copy(w, bytes.NewReader(encBytes)); err != nil { 99 | return err 100 | } 101 | } 102 | if hosts != nil { 103 | jsonBytes, err := json.Marshal(hosts) 104 | if err != nil { 105 | return err 106 | } 107 | w, err := multipartWriter.CreateFormField("ssh_config") 108 | if err != nil { 109 | return err 110 | } 111 | if _, err := w.Write(jsonBytes); err != nil { 112 | return err 113 | } 114 | } 115 | multipartWriter.Close() 116 | url2 := profile.ServerUrl 117 | url2.Path = "/api/v1/data" 118 | req2, err := http.NewRequest("POST", url2.String(), &multipartBody) 119 | if err != nil { 120 | return err 121 | } 122 | req2.Header.Add("Authorization", "Bearer "+token) 123 | req2.Header.Add("Content-Type", multipartWriter.FormDataContentType()) 124 | res2, err := http.DefaultClient.Do(req2) 125 | if err != nil { 126 | return err 127 | } 128 | if res2.StatusCode != 200 { 129 | return errors.New("failed to upload data. status code: " + strconv.Itoa(res2.StatusCode)) 130 | } 131 | fmt.Println("Successfully uploaded keys.") 132 | return nil 133 | } 134 | -------------------------------------------------------------------------------- /pkg/dto/main.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "github.com/google/uuid" 4 | 5 | type Dto interface { 6 | DataDto | UserDto | UserMachineDto | ChallengeResponseDto | ChallengeSuccessEncryptedKeyDto | MessageDto | EncryptedMasterKeyDto | MasterKeyDto | PublicKeyDto 7 | } 8 | 9 | type DataDto struct { 10 | ID uuid.UUID `json:"id"` 11 | Username string `json:"username"` 12 | Keys []KeyDto `json:"keys"` 13 | SshConfig []SshConfigDto `json:"ssh_config"` 14 | Machines []MachineDto `json:"machines"` 15 | } 16 | 17 | type KeyDto struct { 18 | ID uuid.UUID `json:"id"` 19 | UserID uuid.UUID `json:"user_id"` 20 | Filename string `json:"filename"` 21 | Data []byte `json:"data"` 22 | } 23 | 24 | type SshConfigDto struct { 25 | Host string `json:"host"` 26 | Values map[string][]string `json:"values"` 27 | IdentityFiles []string `json:"identity_files"` 28 | } 29 | 30 | type MachineDto struct { 31 | Name string `json:"machine_name"` 32 | } 33 | 34 | type UserDto struct { 35 | Username string `json:"username"` 36 | Machines []MachineDto `json:"machines"` 37 | } 38 | 39 | type UserMachineDto struct { 40 | Username string `json:"username"` 41 | MachineName string `json:"machine_name"` 42 | } 43 | 44 | type ChallengeResponseDto struct { 45 | Challenge string `json:"challenge"` 46 | } 47 | 48 | type ChallengeSuccessEncryptedKeyDto struct { 49 | PublicKey []byte `json:"public_key"` 50 | } 51 | 52 | type MessageDto struct { 53 | Message string `json:"message"` 54 | } 55 | 56 | type EncryptedMasterKeyDto struct { 57 | EncryptedMasterKey []byte `json:"encrypted_master_key"` 58 | } 59 | 60 | type MasterKeyDto struct { 61 | MasterKey []byte `json:"master_key"` 62 | } 63 | 64 | type PublicKeyDto struct { 65 | PublicKey []byte `json:"public_key"` 66 | } 67 | 68 | type ServerMessageDto[T Dto] struct { 69 | Data T `json:"data"` 70 | Error bool `json:"error"` 71 | ErrorMessage string `json:"error_message"` 72 | } 73 | 74 | type ClientMessageDto[T Dto] struct { 75 | Message string `json:"message"` 76 | Data T `json:"data"` 77 | } 78 | -------------------------------------------------------------------------------- /pkg/models/host.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Host struct { 4 | Host string `json:"host"` 5 | IdentityFiles []string `json:"identity_files"` 6 | Values map[string][]string `json:"values"` 7 | } 8 | -------------------------------------------------------------------------------- /pkg/models/profile.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "net/url" 4 | 5 | type Profile struct { 6 | Username string `json:"username"` 7 | MachineName string `json:"machine_name"` 8 | ServerUrl url.URL `json:"server_url"` 9 | } 10 | -------------------------------------------------------------------------------- /pkg/retrieval/data.go: -------------------------------------------------------------------------------- 1 | package retrieval 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 11 | "github.com/therealpaulgg/ssh-sync/pkg/models" 12 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 13 | ) 14 | 15 | func GetUserData(profile *models.Profile) (dto.DataDto, error) { 16 | var data dto.DataDto 17 | token, err := utils.GetToken() 18 | if err != nil { 19 | return data, err 20 | } 21 | dataUrl := profile.ServerUrl 22 | dataUrl.Path = "/api/v1/data" 23 | req, err := http.NewRequest("GET", dataUrl.String(), nil) 24 | if err != nil { 25 | return data, err 26 | } 27 | req.Header.Add("Authorization", "Bearer "+token) 28 | res, err := http.DefaultClient.Do(req) 29 | if err != nil { 30 | return data, err 31 | } 32 | if res.StatusCode != 200 { 33 | return data, errors.New("failed to get data. status code: " + strconv.Itoa(res.StatusCode)) 34 | } 35 | if err := json.NewDecoder(res.Body).Decode(&data); err != nil { 36 | return data, err 37 | } 38 | masterKey, err := utils.RetrieveMasterKey() 39 | if err != nil { 40 | return data, err 41 | } 42 | for i, key := range data.Keys { 43 | decryptedKey, err := utils.DecryptWithMasterKey(key.Data, masterKey) 44 | if err != nil { 45 | return data, err 46 | } 47 | data.Keys[i].Data = decryptedKey 48 | } 49 | return data, nil 50 | } 51 | 52 | func DeleteKey(profile *models.Profile, key dto.KeyDto) error { 53 | token, err := utils.GetToken() 54 | if err != nil { 55 | return err 56 | } 57 | dataUrl := profile.ServerUrl 58 | dataUrl.Path = fmt.Sprintf("/api/v1/data/key/%s", key.ID) 59 | req, err := http.NewRequest("DELETE", dataUrl.String(), nil) 60 | if err != nil { 61 | return err 62 | } 63 | req.Header.Add("Authorization", "Bearer "+token) 64 | res, err := http.DefaultClient.Do(req) 65 | if err != nil { 66 | return err 67 | } 68 | if res.StatusCode != 200 { 69 | return errors.New("failed to delete data. status code: " + strconv.Itoa(res.StatusCode)) 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/retrieval/data_test.go: -------------------------------------------------------------------------------- 1 | package retrieval 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 13 | "github.com/therealpaulgg/ssh-sync/pkg/models" 14 | ) 15 | 16 | func TestDownloadData(t *testing.T) { 17 | // Arrange 18 | // TODO generate ecdsa key 19 | // TODO encrypt key with a user's private key 20 | key := []byte{} 21 | profile := &models.Profile{} 22 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | json.NewEncoder(w).Encode([]dto.DataDto{ 24 | { 25 | ID: uuid.New(), 26 | Username: "test", 27 | Keys: []dto.KeyDto{ 28 | { 29 | ID: uuid.New(), 30 | UserID: uuid.New(), 31 | Filename: "test", 32 | Data: key, 33 | }, 34 | }, 35 | SshConfig: []dto.SshConfigDto{ 36 | { 37 | Host: "test", 38 | Values: map[string][]string{ 39 | "foo": {"bar"}, 40 | }, 41 | IdentityFiles: []string{"test"}, 42 | }, 43 | }, 44 | }, 45 | }) 46 | })) 47 | url, _ := url.Parse(server.URL) 48 | profile.ServerUrl = *url 49 | // Act 50 | data, err := GetUserData(profile) 51 | // Assert 52 | assert.Nil(t, err) 53 | assert.Equal(t, 1, len(data.Keys)) 54 | } 55 | 56 | func TestDeleteKey(t *testing.T) { 57 | // Arrange 58 | key := dto.KeyDto{ 59 | ID: uuid.New(), 60 | UserID: uuid.New(), 61 | Filename: "test", 62 | } 63 | profile := &models.Profile{} 64 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 65 | w.WriteHeader(http.StatusOK) 66 | })) 67 | url, _ := url.Parse(server.URL) 68 | profile.ServerUrl = *url 69 | // Act 70 | err := DeleteKey(profile, key) 71 | // Assert 72 | assert.Nil(t, err) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/retrieval/machines.go: -------------------------------------------------------------------------------- 1 | package retrieval 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 10 | "github.com/therealpaulgg/ssh-sync/pkg/models" 11 | "github.com/therealpaulgg/ssh-sync/pkg/utils" 12 | ) 13 | 14 | func GetMachines(profile *models.Profile) ([]dto.MachineDto, error) { 15 | url := profile.ServerUrl 16 | url.Path = "/api/v1/machines/" 17 | req, err := http.NewRequest("GET", url.String(), nil) 18 | if err != nil { 19 | return nil, err 20 | } 21 | token, err := utils.GetToken() 22 | if err != nil { 23 | return nil, err 24 | } 25 | req.Header.Set("Authorization", "Bearer "+token) 26 | req.Header.Set("Content-Type", "application/json") 27 | resp, err := http.DefaultClient.Do(req) 28 | if err != nil { 29 | return nil, err 30 | } 31 | if resp.StatusCode != http.StatusOK { 32 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 33 | } 34 | machines := []dto.MachineDto{} 35 | err = json.NewDecoder(resp.Body).Decode(&machines) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return machines, nil 40 | } 41 | 42 | func DeleteMachine(profile *models.Profile, machineName string) error { 43 | buf := new(bytes.Buffer) 44 | if err := json.NewEncoder(buf).Encode(dto.MachineDto{ 45 | Name: machineName, 46 | }); err != nil { 47 | return err 48 | } 49 | url := profile.ServerUrl 50 | url.Path = "/api/v1/machines/" 51 | req, err := http.NewRequest("DELETE", url.String(), buf) 52 | if err != nil { 53 | return err 54 | } 55 | token, err := utils.GetToken() 56 | if err != nil { 57 | return err 58 | } 59 | req.Header.Set("Authorization", "Bearer "+token) 60 | req.Header.Set("Content-Type", "application/json") 61 | resp, err := http.DefaultClient.Do(req) 62 | if err != nil { 63 | return err 64 | } 65 | if resp.StatusCode != http.StatusOK { 66 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/retrieval/machines_test.go: -------------------------------------------------------------------------------- 1 | package retrieval 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 12 | "github.com/therealpaulgg/ssh-sync/pkg/models" 13 | ) 14 | 15 | func TestGetMachines(t *testing.T) { 16 | // Arrange 17 | profile := &models.Profile{} 18 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | json.NewEncoder(w).Encode([]dto.MachineDto{ 20 | { 21 | Name: "test", 22 | }, 23 | }) 24 | })) 25 | url, _ := url.Parse(server.URL) 26 | profile.ServerUrl = *url 27 | // Act 28 | machines, err := GetMachines(profile) 29 | // Assert 30 | assert.Nil(t, err) 31 | assert.Equal(t, 1, len(machines)) 32 | assert.Equal(t, "test", machines[0].Name) 33 | } 34 | 35 | func TestDeleteMachine(t *testing.T) { 36 | // Arrange 37 | profile := &models.Profile{} 38 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | w.WriteHeader(http.StatusOK) 40 | })) 41 | url, _ := url.Parse(server.URL) 42 | profile.ServerUrl = *url 43 | // Act 44 | err := DeleteMachine(profile, "test") 45 | // Assert 46 | assert.Nil(t, err) 47 | } 48 | 49 | func TestDeleteMachineDoesNotExist(t *testing.T) { 50 | // Arrange 51 | profile := &models.Profile{} 52 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | w.WriteHeader(http.StatusNotFound) 54 | })) 55 | url, _ := url.Parse(server.URL) 56 | profile.ServerUrl = *url 57 | // Act 58 | err := DeleteMachine(profile, "test") 59 | // Assert 60 | assert.NotNil(t, err) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/utils/decrypt.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | 8 | "github.com/lestrrat-go/jwx/v2/jwa" 9 | "github.com/lestrrat-go/jwx/v2/jwe" 10 | ) 11 | 12 | func Decrypt(b []byte) ([]byte, error) { 13 | key, err := RetrievePrivateKey() 14 | if err != nil { 15 | return nil, err 16 | } 17 | plaintext, err := jwe.Decrypt(b, jwe.WithKey(jwa.ECDH_ES_A256KW, key)) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return plaintext, nil 22 | } 23 | 24 | func DecryptWithMasterKey(b []byte, key []byte) ([]byte, error) { 25 | decryptedBuf := bytes.NewBuffer(nil) 26 | blockCipher, err := aes.NewCipher(key) 27 | if err != nil { 28 | return nil, err 29 | } 30 | gcm, err := cipher.NewGCM(blockCipher) 31 | if err != nil { 32 | return nil, err 33 | } 34 | data, err := gcm.Open(nil, b[:gcm.NonceSize()], b[gcm.NonceSize():], nil) 35 | if err != nil { 36 | return nil, err 37 | } 38 | if _, err := decryptedBuf.Write(data); err != nil { 39 | return nil, err 40 | } 41 | plaintext := decryptedBuf.Bytes() 42 | return plaintext, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/utils/encrypt.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | 8 | "github.com/lestrrat-go/jwx/v2/jwa" 9 | "github.com/lestrrat-go/jwx/v2/jwe" 10 | "github.com/lestrrat-go/jwx/v2/jwk" 11 | ) 12 | 13 | func EncryptWithMasterKey(plaintext []byte, key []byte) ([]byte, error) { 14 | blockCipher, err := aes.NewCipher(key) 15 | if err != nil { 16 | return nil, err 17 | } 18 | gcm, err := cipher.NewGCM(blockCipher) 19 | if err != nil { 20 | return nil, err 21 | } 22 | nonce := make([]byte, gcm.NonceSize()) 23 | if n, err := rand.Read(nonce); err != nil || n != len(nonce) { 24 | return nil, err 25 | } 26 | outBuf := gcm.Seal(nonce, nonce, plaintext, nil) 27 | return outBuf, nil 28 | } 29 | 30 | func Encrypt(b []byte) ([]byte, error) { 31 | key, err := RetrievePublicKey() 32 | if err != nil { 33 | return nil, err 34 | } 35 | ciphertext, err := jwe.Encrypt(b, jwe.WithKey(jwa.ECDH_ES_A256KW, key)) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return ciphertext, nil 40 | } 41 | 42 | func EncryptWithPublicKey(b []byte, key []byte) ([]byte, error) { 43 | pubKey, err := jwk.ParseKey(key, jwk.WithPEM(true)) 44 | if err != nil { 45 | return nil, err 46 | } 47 | ciphertext, err := jwe.Encrypt(b, jwe.WithKey(jwa.ECDH_ES_A256KW, pubKey)) 48 | if err != nil { 49 | return nil, err 50 | } 51 | return ciphertext, nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/utils/getprofile.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | 9 | "github.com/therealpaulgg/ssh-sync/pkg/models" 10 | ) 11 | 12 | func GetProfile() (*models.Profile, error) { 13 | user, err := user.Current() 14 | if err != nil { 15 | return nil, err 16 | } 17 | p := filepath.Join(user.HomeDir, ".ssh-sync", "profile.json") 18 | jsonBytes, err := os.ReadFile(p) 19 | if err != nil { 20 | return nil, err 21 | } 22 | var profile models.Profile 23 | if err := json.Unmarshal(jsonBytes, &profile); err != nil { 24 | return nil, err 25 | } 26 | return &profile, nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/utils/io.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | ) 7 | 8 | func ReadLineFromStdin(reader *bufio.Scanner, input *string) error { 9 | if reader == nil { 10 | reader = bufio.NewScanner(os.Stdin) 11 | } 12 | if reader.Scan() { 13 | *input = reader.Text() 14 | return nil 15 | } 16 | return reader.Err() 17 | } 18 | -------------------------------------------------------------------------------- /pkg/utils/keyretrieval.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "os/user" 6 | "path/filepath" 7 | 8 | "github.com/lestrrat-go/jwx/v2/jwa" 9 | "github.com/lestrrat-go/jwx/v2/jwe" 10 | "github.com/lestrrat-go/jwx/v2/jwk" 11 | ) 12 | 13 | func RetrievePrivateKey() (jwk.Key, error) { 14 | user, err := user.Current() 15 | if err != nil { 16 | return nil, err 17 | } 18 | p := filepath.Join(user.HomeDir, ".ssh-sync", "keypair") 19 | file, err := os.ReadFile(p) 20 | if err != nil { 21 | return nil, err 22 | } 23 | key, err := jwk.ParseKey(file, jwk.WithPEM(true)) 24 | return key, err 25 | } 26 | 27 | func RetrievePublicKey() (jwk.Key, error) { 28 | user, err := user.Current() 29 | if err != nil { 30 | return nil, err 31 | } 32 | p := filepath.Join(user.HomeDir, ".ssh-sync", "keypair.pub") 33 | file, err := os.ReadFile(p) 34 | if err != nil { 35 | return nil, err 36 | } 37 | key, err := jwk.ParseKey(file, jwk.WithPEM(true)) 38 | return key, err 39 | } 40 | 41 | func RetrieveMasterKey() ([]byte, error) { 42 | user, err := user.Current() 43 | if err != nil { 44 | return nil, err 45 | } 46 | p := filepath.Join(user.HomeDir, ".ssh-sync", "master_key") 47 | file, err := os.ReadFile(p) 48 | if err != nil { 49 | return nil, err 50 | } 51 | privateKey, err := RetrievePrivateKey() 52 | if err != nil { 53 | return nil, err 54 | } 55 | masterKey, err := jwe.Decrypt(file, jwe.WithKey(jwa.ECDH_ES_A256KW, privateKey)) 56 | return masterKey, err 57 | } 58 | -------------------------------------------------------------------------------- /pkg/utils/parseconfig.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | "regexp" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/therealpaulgg/ssh-sync/pkg/models" 13 | ) 14 | 15 | func ParseConfig() ([]models.Host, error) { 16 | // parse the ssh config file and return a list of hosts 17 | // the ssh config file is located at ~/.ssh/config 18 | user, err := user.Current() 19 | if err != nil { 20 | return nil, err 21 | } 22 | p := filepath.Join(user.HomeDir, ".ssh", "config") 23 | file, err := os.Open(p) 24 | if err != nil { 25 | return nil, err 26 | } 27 | var hosts []models.Host 28 | scanner := bufio.NewScanner(file) 29 | var currentHost *models.Host 30 | re := regexp.MustCompile(`^\s+(\w+)[ =](.+)$`) 31 | for scanner.Scan() { 32 | line := scanner.Text() 33 | if strings.HasPrefix(line, "Host ") { 34 | if currentHost != nil { 35 | hosts = append(hosts, *currentHost) 36 | } 37 | currentHost = &models.Host{ 38 | Host: strings.TrimPrefix(line, "Host "), 39 | Values: make(map[string][]string), 40 | } 41 | } else if re.Match([]byte(line)) { 42 | key := re.FindStringSubmatch(line)[1] 43 | value := re.FindStringSubmatch(line)[2] 44 | if strings.ToLower(key) == "identityfile" { 45 | homeDir := user.HomeDir 46 | if runtime.GOOS == "windows" { 47 | value = strings.ToLower(value) 48 | homeDir = strings.ToLower(user.HomeDir) 49 | } 50 | identityFile := strings.TrimPrefix(value, homeDir) 51 | normalizedIdentityFilePath := filepath.ToSlash(identityFile) 52 | currentHost.IdentityFiles = append(currentHost.IdentityFiles, normalizedIdentityFilePath) 53 | } else { 54 | currentHost.Values[key] = append(currentHost.Values[key], value) 55 | } 56 | 57 | } 58 | } 59 | if currentHost != nil { 60 | hosts = append(hosts, *currentHost) 61 | } 62 | return hosts, nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/utils/setup.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | ) 9 | 10 | func CheckIfSetup() (bool, error) { 11 | // check if ~/.ssh-sync/profile.json exists 12 | // if it does, return true 13 | // if it doesn't, return false 14 | user, err := user.Current() 15 | if err != nil { 16 | return false, err 17 | } 18 | p := filepath.Join(user.HomeDir, ".ssh-sync", "profile.json") 19 | if _, err := os.Stat(p); err != nil { 20 | if errors.Is(err, os.ErrNotExist) { 21 | return false, nil 22 | } 23 | return false, err 24 | } 25 | return true, nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/utils/sockets.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net" 7 | 8 | "github.com/gobwas/ws/wsutil" 9 | "github.com/therealpaulgg/ssh-sync/pkg/dto" 10 | ) 11 | 12 | func ReadClientMessage[T dto.Dto](conn *net.Conn) (*dto.ClientMessageDto[T], error) { 13 | connInstance := *conn 14 | data, err := wsutil.ReadClientBinary(connInstance) 15 | if err != nil { 16 | return nil, err 17 | } 18 | var clientMessageDto dto.ClientMessageDto[T] 19 | if err := json.Unmarshal(data, &clientMessageDto); err != nil { 20 | return nil, err 21 | } 22 | return &clientMessageDto, nil 23 | } 24 | 25 | func WriteClientMessage[T dto.Dto](conn *net.Conn, message T) error { 26 | connInstance := *conn 27 | clientMessageDto := dto.ClientMessageDto[T]{ 28 | Data: message, 29 | } 30 | b, err := json.Marshal(clientMessageDto) 31 | if err != nil { 32 | return err 33 | } 34 | if err := wsutil.WriteClientBinary(connInstance, b); err != nil { 35 | return err 36 | } 37 | return nil 38 | } 39 | 40 | func ReadServerMessage[T dto.Dto](conn *net.Conn) (*dto.ServerMessageDto[T], error) { 41 | connInstance := *conn 42 | data, err := wsutil.ReadServerBinary(connInstance) 43 | if err != nil { 44 | return nil, err 45 | } 46 | var serverMessageDto dto.ServerMessageDto[T] 47 | if err := json.Unmarshal(data, &serverMessageDto); err != nil { 48 | return nil, err 49 | } 50 | if serverMessageDto.Error { 51 | return &serverMessageDto, errors.New("error from server: " + serverMessageDto.ErrorMessage) 52 | } 53 | return &serverMessageDto, nil 54 | } 55 | 56 | func WriteServerError[T dto.Dto](conn *net.Conn, message string) error { 57 | return writeToServer(conn, dto.ServerMessageDto[T]{ 58 | ErrorMessage: message, 59 | Error: true, 60 | }) 61 | } 62 | 63 | func WriteServerMessage[T dto.Dto](conn *net.Conn, data T) error { 64 | return writeToServer(conn, dto.ServerMessageDto[T]{ 65 | Data: data, 66 | Error: false, 67 | }) 68 | } 69 | 70 | func writeToServer[T dto.Dto](conn *net.Conn, data dto.ServerMessageDto[T]) error { 71 | connInstance := *conn 72 | b, err := json.Marshal(data) 73 | if err != nil { 74 | return err 75 | } 76 | if err := wsutil.WriteServerBinary(connInstance, b); err != nil { 77 | return err 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/utils/tokengen.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lestrrat-go/jwx/v2/jwa" 7 | "github.com/lestrrat-go/jwx/v2/jwt" 8 | ) 9 | 10 | func GetToken() (string, error) { 11 | profile, err := GetProfile() 12 | if err != nil { 13 | return "", err 14 | } 15 | key, err := RetrievePrivateKey() 16 | if err != nil { 17 | return "", err 18 | } 19 | builder := jwt.NewBuilder() 20 | builder.Issuer("github.com/therealpaulgg/ssh-sync") 21 | builder.IssuedAt(time.Now().Add(-1 * time.Minute)) 22 | builder.Expiration(time.Now().Add(2 * time.Minute)) 23 | builder.Claim("username", profile.Username) 24 | builder.Claim("machine", profile.MachineName) 25 | tok, err := builder.Build() 26 | if err != nil { 27 | return "", err 28 | } 29 | signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES512, key)) 30 | if err != nil { 31 | return "", err 32 | } 33 | return string(signed), nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/utils/write.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/user" 9 | "path/filepath" 10 | 11 | "github.com/therealpaulgg/ssh-sync/pkg/models" 12 | ) 13 | 14 | func GetAndCreateSshDirectory(sshDirectory string) (string, error) { 15 | user, err := user.Current() 16 | if err != nil { 17 | return "", err 18 | } 19 | p := filepath.Join(user.HomeDir, sshDirectory) 20 | if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { 21 | if err := os.MkdirAll(p, 0700); err != nil { 22 | return "", err 23 | } 24 | } 25 | return p, nil 26 | } 27 | 28 | func WriteConfig(hosts []models.Host, sshDirectory string) error { 29 | user, err := user.Current() 30 | if err != nil { 31 | return err 32 | } 33 | p, err := GetAndCreateSshDirectory(sshDirectory) 34 | if err != nil { 35 | return err 36 | } 37 | file, err := os.OpenFile(filepath.Join(p, "config"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 38 | if err != nil { 39 | return err 40 | } 41 | defer file.Close() 42 | for _, host := range hosts { 43 | if _, err := file.WriteString(fmt.Sprintf("Host %s\n", host.Host)); err != nil { 44 | return err 45 | } 46 | if host.IdentityFiles != nil { 47 | for _, identityFile := range host.IdentityFiles { 48 | if _, err := file.WriteString(fmt.Sprintf("\t%s %s\n", "IdentityFile", filepath.Join(user.HomeDir, identityFile))); err != nil { 49 | return err 50 | } 51 | } 52 | } 53 | for key, value := range host.Values { 54 | for _, item := range value { 55 | if _, err := file.WriteString(fmt.Sprintf("\t%s %s\n", key, item)); err != nil { 56 | return err 57 | } 58 | } 59 | } 60 | } 61 | return nil 62 | } 63 | 64 | func WriteKey(key []byte, filename string, sshDirectory string) error { 65 | p, err := GetAndCreateSshDirectory(sshDirectory) 66 | if err != nil { 67 | return err 68 | } 69 | _, err = os.OpenFile(filepath.Join(p, filename), os.O_RDONLY, 0600) 70 | if err != nil && !errors.Is(err, os.ErrNotExist) { 71 | return err 72 | } else if err == nil { 73 | existingData, err := os.ReadFile(filepath.Join(p, filename)) 74 | if err != nil { 75 | return err 76 | } 77 | if string(existingData) != string(key) { 78 | var answer string 79 | scanner := bufio.NewScanner(os.Stdin) 80 | fmt.Printf("diff detected for %s.\n", filename) 81 | fmt.Println("1. Overwrite") 82 | fmt.Println("2. Skip") 83 | fmt.Println("3. Save new file (as .duplicate extension for manual resolution)") 84 | fmt.Print("Please choose an option (will skip by default): ") 85 | 86 | if err := ReadLineFromStdin(scanner, &answer); err != nil { 87 | return err 88 | } 89 | fmt.Println() 90 | if answer == "3" { 91 | filename = filename + ".duplicate" 92 | } else if answer == "2" || answer != "1" { 93 | return nil 94 | } 95 | } 96 | } 97 | file, err := os.OpenFile(filepath.Join(p, filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 98 | if err != nil { 99 | return err 100 | } 101 | defer file.Close() 102 | if _, err := file.Write(key); err != nil { 103 | return err 104 | } 105 | 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | MUST HAVES: 2 | - data conflicts - how to resolve merging? 3 | - Allow users to delete certain keys or config entries 4 | - test delete code more 5 | - if EOF is received, need to have nicer error message. Also cleanup better - what if part way thru setup and EOF happens? 6 | - server seems to sometimes panic when websocket connection gets closed in a strange way. 7 | 8 | Nice to haves: 9 | - Apparently you can have duplicate keys in a ssh config. Will have to not use a map[string]string and instead use a []keyValuePair or something. 10 | - IdentityFile - due to this duplicate attribute issue, probably forget about storing it in its own column in the database and just ensure that the CLI parses it carefully 11 | - add a space to each host entry in the ssh config generation 12 | - this is a zero knowledge app, but maybe there are some things we should do to prevent the client from just sending whatever the hell it wants to the server? 13 | -------------------------------------------------------------------------------- /win-build/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Paul Gellai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /win-build/modpath.iss: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- 2 | // 3 | // Inno Setup Ver: 5.4.2 4 | // Script Version: 1.4.2 5 | // Author: Jared Breland 6 | // Homepage: http://www.legroom.net/software 7 | // License: GNU Lesser General Public License (LGPL), version 3 8 | // http://www.gnu.org/licenses/lgpl.html 9 | // 10 | // Script Function: 11 | // Allow modification of environmental path directly from Inno Setup installers 12 | // 13 | // Instructions: 14 | // Copy modpath.iss to the same directory as your setup script 15 | // 16 | // Add this statement to your [Setup] section 17 | // ChangesEnvironment=true 18 | // 19 | // Add this statement to your [Tasks] section 20 | // You can change the Description or Flags 21 | // You can change the Name, but it must match the ModPathName setting below 22 | // Name: modifypath; Description: &Add application directory to your environmental path; Flags: unchecked 23 | // 24 | // Add the following to the end of your [Code] section 25 | // ModPathName defines the name of the task defined above 26 | // ModPathType defines whether the 'user' or 'system' path will be modified; 27 | // this will default to user if anything other than system is set 28 | // setArrayLength must specify the total number of dirs to be added 29 | // Result[0] contains first directory, Result[1] contains second, etc. 30 | // const 31 | // ModPathName = 'modifypath'; 32 | // ModPathType = 'user'; 33 | // 34 | // function ModPathDir(): TArrayOfString; 35 | // begin 36 | // setArrayLength(Result, 1); 37 | // Result[0] := ExpandConstant('{app}'); 38 | // end; 39 | // #include "modpath.iss" 40 | // ---------------------------------------------------------------------------- 41 | 42 | procedure ModPath(); 43 | var 44 | oldpath: String; 45 | newpath: String; 46 | updatepath: Boolean; 47 | pathArr: TArrayOfString; 48 | aExecFile: String; 49 | aExecArr: TArrayOfString; 50 | i, d: Integer; 51 | pathdir: TArrayOfString; 52 | regroot: Integer; 53 | regpath: String; 54 | 55 | begin 56 | // Get constants from main script and adjust behavior accordingly 57 | // ModPathType MUST be 'system' or 'user'; force 'user' if invalid 58 | if ModPathType = 'system' then begin 59 | regroot := HKEY_LOCAL_MACHINE; 60 | regpath := 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; 61 | end else begin 62 | regroot := HKEY_CURRENT_USER; 63 | regpath := 'Environment'; 64 | end; 65 | 66 | // Get array of new directories and act on each individually 67 | pathdir := ModPathDir(); 68 | for d := 0 to GetArrayLength(pathdir)-1 do begin 69 | updatepath := true; 70 | 71 | // Modify WinNT path 72 | if UsingWinNT() = true then begin 73 | 74 | // Get current path, split into an array 75 | RegQueryStringValue(regroot, regpath, 'Path', oldpath); 76 | oldpath := oldpath + ';'; 77 | i := 0; 78 | 79 | while (Pos(';', oldpath) > 0) do begin 80 | SetArrayLength(pathArr, i+1); 81 | pathArr[i] := Copy(oldpath, 0, Pos(';', oldpath)-1); 82 | oldpath := Copy(oldpath, Pos(';', oldpath)+1, Length(oldpath)); 83 | i := i + 1; 84 | 85 | // Check if current directory matches app dir 86 | if pathdir[d] = pathArr[i-1] then begin 87 | // if uninstalling, remove dir from path 88 | if IsUninstaller() = true then begin 89 | continue; 90 | // if installing, flag that dir already exists in path 91 | end else begin 92 | updatepath := false; 93 | end; 94 | end; 95 | 96 | // Add current directory to new path 97 | if i = 1 then begin 98 | newpath := pathArr[i-1]; 99 | end else begin 100 | newpath := newpath + ';' + pathArr[i-1]; 101 | end; 102 | end; 103 | 104 | // Append app dir to path if not already included 105 | if (IsUninstaller() = false) AND (updatepath = true) then 106 | newpath := newpath + ';' + pathdir[d]; 107 | 108 | // Write new path 109 | RegWriteStringValue(regroot, regpath, 'Path', newpath); 110 | 111 | // Modify Win9x path 112 | end else begin 113 | 114 | // Convert to shortened dirname 115 | pathdir[d] := GetShortName(pathdir[d]); 116 | 117 | // If autoexec.bat exists, check if app dir already exists in path 118 | aExecFile := 'C:\AUTOEXEC.BAT'; 119 | if FileExists(aExecFile) then begin 120 | LoadStringsFromFile(aExecFile, aExecArr); 121 | for i := 0 to GetArrayLength(aExecArr)-1 do begin 122 | if IsUninstaller() = false then begin 123 | // If app dir already exists while installing, skip add 124 | if (Pos(pathdir[d], aExecArr[i]) > 0) then 125 | updatepath := false; 126 | break; 127 | end else begin 128 | // If app dir exists and = what we originally set, then delete at uninstall 129 | if aExecArr[i] = 'SET PATH=%PATH%;' + pathdir[d] then 130 | aExecArr[i] := ''; 131 | end; 132 | end; 133 | end; 134 | 135 | // If app dir not found, or autoexec.bat didn't exist, then (create and) append to current path 136 | if (IsUninstaller() = false) AND (updatepath = true) then begin 137 | SaveStringToFile(aExecFile, #13#10 + 'SET PATH=%PATH%;' + pathdir[d], True); 138 | 139 | // If uninstalling, write the full autoexec out 140 | end else begin 141 | SaveStringsToFile(aExecFile, aExecArr, False); 142 | end; 143 | end; 144 | end; 145 | end; 146 | 147 | // Split a string into an array using passed delimeter 148 | procedure MPExplode(var Dest: TArrayOfString; Text: String; Separator: String); 149 | var 150 | i: Integer; 151 | begin 152 | i := 0; 153 | repeat 154 | SetArrayLength(Dest, i+1); 155 | if Pos(Separator,Text) > 0 then begin 156 | Dest[i] := Copy(Text, 1, Pos(Separator, Text)-1); 157 | Text := Copy(Text, Pos(Separator,Text) + Length(Separator), Length(Text)); 158 | i := i + 1; 159 | end else begin 160 | Dest[i] := Text; 161 | Text := ''; 162 | end; 163 | until Length(Text)=0; 164 | end; 165 | 166 | 167 | procedure CurStepChanged(CurStep: TSetupStep); 168 | var 169 | taskname: String; 170 | begin 171 | taskname := ModPathName; 172 | if CurStep = ssPostInstall then 173 | if IsTaskSelected(taskname) then 174 | ModPath(); 175 | end; 176 | 177 | procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); 178 | var 179 | aSelectedTasks: TArrayOfString; 180 | i: Integer; 181 | taskname: String; 182 | regpath: String; 183 | regstring: String; 184 | appid: String; 185 | begin 186 | // only run during actual uninstall 187 | if CurUninstallStep = usUninstall then begin 188 | // get list of selected tasks saved in registry at install time 189 | appid := '{#emit SetupSetting("AppId")}'; 190 | if appid = '' then appid := '{#emit SetupSetting("AppName")}'; 191 | regpath := ExpandConstant('Software\Microsoft\Windows\CurrentVersion\Uninstall\'+appid+'_is1'); 192 | RegQueryStringValue(HKLM, regpath, 'Inno Setup: Selected Tasks', regstring); 193 | if regstring = '' then RegQueryStringValue(HKCU, regpath, 'Inno Setup: Selected Tasks', regstring); 194 | 195 | // check each task; if matches modpath taskname, trigger patch removal 196 | if regstring <> '' then begin 197 | taskname := ModPathName; 198 | MPExplode(aSelectedTasks, regstring, ','); 199 | if GetArrayLength(aSelectedTasks) > 0 then begin 200 | for i := 0 to GetArrayLength(aSelectedTasks)-1 do begin 201 | if comparetext(aSelectedTasks[i], taskname) = 0 then 202 | ModPath(); 203 | end; 204 | end; 205 | end; 206 | end; 207 | end; 208 | 209 | function NeedRestart(): Boolean; 210 | var 211 | taskname: String; 212 | begin 213 | taskname := ModPathName; 214 | if IsTaskSelected(taskname) and not UsingWinNT() then begin 215 | Result := True; 216 | end else begin 217 | Result := False; 218 | end; 219 | end; 220 | -------------------------------------------------------------------------------- /win-build/setup.iss: -------------------------------------------------------------------------------- 1 | ; Script generated by the Inno Setup Script Wizard. 2 | ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! 3 | 4 | #define MyAppName "ssh-sync" 5 | #define MyAppPublisher "therealpaulgg" 6 | #define MyAppURL "https://github.com/therealpaulgg/ssh-sync" 7 | #define MyAppExeName "ssh-sync.exe" 8 | 9 | [Setup] 10 | ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. 11 | ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) 12 | ChangesEnvironment=true 13 | AppId={{AB1F111C-F569-4D24-B9D4-2204FE2D9DB2} 14 | AppName={#MyAppName} 15 | AppVersion={#MyAppVersion} 16 | AppVerName={#MyAppName} {#MyAppVersion} 17 | AppPublisher={#MyAppPublisher} 18 | AppPublisherURL={#MyAppURL} 19 | AppSupportURL={#MyAppURL} 20 | AppUpdatesURL={#MyAppURL} 21 | DefaultDirName={localappdata}\{#MyAppName} 22 | DefaultGroupName={#MyAppName} 23 | DisableProgramGroupPage=yes 24 | LicenseFile=.\LICENSE.txt 25 | ; Remove the following line to run in administrative install mode (install for all users.) 26 | PrivilegesRequired=lowest 27 | OutputBaseFilename=ssh-sync-setup 28 | Compression=lzma 29 | SolidCompression=yes 30 | WizardStyle=modern 31 | 32 | [Languages] 33 | Name: "english"; MessagesFile: "compiler:Default.isl" 34 | 35 | [Files] 36 | Source: "{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion 37 | ; NOTE: Don't use "Flags: ignoreversion" on any shared system files 38 | 39 | [Icons] 40 | Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" 41 | Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" 42 | 43 | [Tasks] 44 | Name: modifypath; Description: &Add application directory to your environmental path; 45 | 46 | [Code] 47 | const 48 | ModPathName = 'modifypath'; 49 | ModPathType = 'user'; 50 | 51 | function ModPathDir(): TArrayOfString; 52 | begin 53 | setArrayLength(Result, 1); 54 | Result[0] := ExpandConstant('{app}'); 55 | end; 56 | #include "modpath.iss" --------------------------------------------------------------------------------