├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── auth └── key.go ├── client.go ├── configurer.go ├── go.mod ├── go.sum ├── protocol.go ├── scp.go ├── tests ├── basic_test.go ├── data │ ├── Exöt1ç download file.txt.txt │ ├── another_file.txt │ └── upload_file.txt ├── entrypoint.d │ └── setpasswd.sh ├── run_all.sh └── tmp │ └── .gitkeep └── utils.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: Run tests 23 | shell: bash 24 | run: | 25 | cd tests && ./run_all.sh 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Copy files over SCP with Go 2 | ============================= 3 | [![Go Report Card](https://goreportcard.com/badge/bramvdbogaerde/go-scp)](https://goreportcard.com/report/bramvdbogaerde/go-scp) [![](https://godoc.org/github.com/bramvdbogaerde/go-scp?status.svg)](https://godoc.org/github.com/bramvdbogaerde/go-scp) 4 | 5 | This package makes it very easy to copy files over scp in Go. 6 | It uses the golang.org/x/crypto/ssh package to establish a secure connection to a remote server in order to copy the files via the SCP protocol. 7 | 8 | ### Example usage 9 | 10 | 11 | ```go 12 | package main 13 | 14 | import ( 15 | "fmt" 16 | scp "github.com/bramvdbogaerde/go-scp" 17 | "github.com/bramvdbogaerde/go-scp/auth" 18 | "golang.org/x/crypto/ssh" 19 | "os" 20 | "context" 21 | ) 22 | 23 | func main() { 24 | // Use SSH key authentication from the auth package 25 | // we ignore the host key in this example, please change this if you use this library 26 | clientConfig, _ := auth.PrivateKey("username", "/path/to/rsa/key", ssh.InsecureIgnoreHostKey()) 27 | 28 | // For other authentication methods see ssh.ClientConfig and ssh.AuthMethod 29 | 30 | // Create a new SCP client 31 | client := scp.NewClient("example.com:22", &clientConfig) 32 | 33 | // Connect to the remote server 34 | err := client.Connect() 35 | if err != nil { 36 | fmt.Println("Couldn't establish a connection to the remote server ", err) 37 | return 38 | } 39 | 40 | // Open a file 41 | f, _ := os.Open("/path/to/local/file") 42 | 43 | // Close client connection after the file has been copied 44 | defer client.Close() 45 | 46 | // Close the file after it has been copied 47 | defer f.Close() 48 | 49 | // Finally, copy the file over 50 | // Usage: CopyFromFile(context, file, remotePath, permission) 51 | 52 | // the context can be adjusted to provide time-outs or inherit from other contexts if this is embedded in a larger application. 53 | err = client.CopyFromFile(context.Background(), *f, "/home/server/test.txt", "0655") 54 | 55 | if err != nil { 56 | fmt.Println("Error while copying file ", err) 57 | } 58 | } 59 | ``` 60 | 61 | #### Using an existing SSH connection 62 | 63 | If you have an existing established SSH connection, you can use that instead. 64 | 65 | ```go 66 | func connectSSH() *ssh.Client { 67 | // setup SSH connection 68 | } 69 | 70 | func main() { 71 | sshClient := connectSSH() 72 | 73 | // Create a new SCP client, note that this function might 74 | // return an error, as a new SSH session is established using the existing connecton 75 | 76 | client, err := scp.NewClientBySSH(sshClient) 77 | if err != nil { 78 | fmt.Println("Error creating new SSH session from existing connection", err) 79 | } 80 | 81 | // No `client.Connect` necessary as an existing SSH connection will be used. 82 | 83 | // Open a file 84 | f, _ := os.Open("/path/to/local/file") 85 | 86 | /* .. same as above .. */ 87 | } 88 | ``` 89 | 90 | #### Copying Files from Remote Server 91 | 92 | It is also possible to copy remote files using this library. 93 | The usage is similar to the example at the top of this section, except that `CopyFromRemote` needsto be used instead. 94 | 95 | For a more comprehensive example, please consult the `TestDownloadFile` function in t he `tests/basic_test.go` file. 96 | 97 | ### License 98 | 99 | This library is licensed under the Mozilla Public License 2.0. 100 | A copy of the license is provided in the `LICENSE.txt` file. 101 | 102 | Copyright (c) 2020 Bram Vandenbogaerde 103 | -------------------------------------------------------------------------------- /auth/key.go: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2020 Bram Vandenbogaerde 2 | * You may use, distribute or modify this code under the 3 | * terms of the Mozilla Public License 2.0, which is distributed 4 | * along with the source code. 5 | */ 6 | package auth 7 | 8 | import ( 9 | "io/ioutil" 10 | "net" 11 | "os" 12 | 13 | "golang.org/x/crypto/ssh" 14 | "golang.org/x/crypto/ssh/agent" 15 | ) 16 | 17 | // PrivateKey Loads a private and public key from "path" and returns a SSH ClientConfig to authenticate with the server 18 | func PrivateKey(username string, path string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { 19 | privateKey, err := ioutil.ReadFile(path) 20 | 21 | if err != nil { 22 | return ssh.ClientConfig{}, err 23 | } 24 | 25 | signer, err := ssh.ParsePrivateKey(privateKey) 26 | 27 | if err != nil { 28 | return ssh.ClientConfig{}, err 29 | } 30 | 31 | return ssh.ClientConfig{ 32 | User: username, 33 | Auth: []ssh.AuthMethod{ 34 | ssh.PublicKeys(signer), 35 | }, 36 | HostKeyCallback: keyCallBack, 37 | }, nil 38 | } 39 | 40 | // Creates the configuration for a client that authenticates with a password protected private key 41 | func PrivateKeyWithPassphrase(username string, passpharase []byte, path string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { 42 | privateKey, err := ioutil.ReadFile(path) 43 | 44 | if err != nil { 45 | return ssh.ClientConfig{}, err 46 | } 47 | signer, err := ssh.ParsePrivateKeyWithPassphrase(privateKey, passpharase) 48 | 49 | if err != nil { 50 | return ssh.ClientConfig{}, err 51 | } 52 | 53 | return ssh.ClientConfig{ 54 | User: username, 55 | Auth: []ssh.AuthMethod{ 56 | ssh.PublicKeys(signer), 57 | }, 58 | HostKeyCallback: keyCallBack, 59 | }, nil 60 | } 61 | 62 | // Creates a configuration for a client that fetches public-private key from the SSH agent for authentication 63 | func SshAgent(username string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { 64 | socket := os.Getenv("SSH_AUTH_SOCK") 65 | conn, err := net.Dial("unix", socket) 66 | if err != nil { 67 | return ssh.ClientConfig{}, err 68 | } 69 | 70 | agentClient := agent.NewClient(conn) 71 | return ssh.ClientConfig{ 72 | User: username, 73 | Auth: []ssh.AuthMethod{ 74 | ssh.PublicKeysCallback(agentClient.Signers), 75 | }, 76 | HostKeyCallback: keyCallBack, 77 | }, nil 78 | } 79 | 80 | // Creates a configuration for a client that authenticates using username and password 81 | func PasswordKey(username string, password string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { 82 | 83 | return ssh.ClientConfig{ 84 | User: username, 85 | Auth: []ssh.AuthMethod{ 86 | ssh.Password(password), 87 | }, 88 | HostKeyCallback: keyCallBack, 89 | }, nil 90 | } 91 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2024 Bram Vandenbogaerde And Contributors 2 | * You may use, distribute or modify this code under the 3 | * terms of the Mozilla Public License 2.0, which is distributed 4 | * along with the source code. 5 | */ 6 | 7 | package scp 8 | 9 | import ( 10 | "bytes" 11 | "context" 12 | "fmt" 13 | "io" 14 | "io/ioutil" 15 | "os" 16 | "path" 17 | "sync" 18 | "time" 19 | 20 | "golang.org/x/crypto/ssh" 21 | ) 22 | 23 | // Callback for freeing managed resources 24 | type ICloseHandler interface { 25 | Close() 26 | } 27 | 28 | // Close handler equivalent to a no-op. Used by default 29 | // when no resources have to be cleaned. 30 | type EmptyHandler struct{} 31 | 32 | func (EmptyHandler) Close() {} 33 | 34 | // Close handler to close an SSH client 35 | type CloseSSHCLient struct { 36 | // Reference to the used SSH client 37 | sshClient *ssh.Client 38 | } 39 | 40 | func (scp CloseSSHCLient) Close() { 41 | scp.sshClient.Close() 42 | } 43 | 44 | type PassThru func(r io.Reader, total int64) io.Reader 45 | 46 | type Client struct { 47 | // Host the host to connect to. 48 | Host string 49 | 50 | // ClientConfig the client config to use. 51 | ClientConfig *ssh.ClientConfig 52 | 53 | // Keep the ssh client around for generating new sessions 54 | sshClient *ssh.Client 55 | 56 | // Timeout the maximal amount of time to wait for a file transfer to complete. 57 | // Deprecated: use context.Context for each function instead. 58 | Timeout time.Duration 59 | 60 | // RemoteBinary the absolute path to the remote SCP binary. 61 | RemoteBinary string 62 | 63 | // Handler called when calling `Close` to clean up any remaining 64 | // resources managed by `Client`. 65 | closeHandler ICloseHandler 66 | } 67 | 68 | // Connect connects to the remote SSH server, returns an error if it couldn't establish a session to the SSH server. 69 | // This method is NOT meant to be called when the client was created through `NewClientBySSH` as an SSH 70 | // connection will already exist. 71 | func (a *Client) Connect() error { 72 | client, err := ssh.Dial("tcp", a.Host, a.ClientConfig) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | a.sshClient = client 78 | a.closeHandler = CloseSSHCLient{sshClient: client} 79 | return nil 80 | } 81 | 82 | // Returns the underlying SSH client, this should be used carefully as 83 | // it will be closed by `client.Close`. 84 | func (a *Client) SSHClient() *ssh.Client { 85 | return a.sshClient 86 | } 87 | 88 | // CopyFromFile copies the contents of an os.File to a remote location, it will get the length of the file by looking it up from the filesystem. 89 | func (a *Client) CopyFromFile( 90 | ctx context.Context, 91 | file os.File, 92 | remotePath string, 93 | permissions string, 94 | ) error { 95 | return a.CopyFromFilePassThru(ctx, file, remotePath, permissions, nil) 96 | } 97 | 98 | // CopyFromFilePassThru copies the contents of an os.File to a remote location, it will get the length of the file by looking it up from the filesystem. 99 | // Access copied bytes by providing a PassThru reader factory. 100 | func (a *Client) CopyFromFilePassThru( 101 | ctx context.Context, 102 | file os.File, 103 | remotePath string, 104 | permissions string, 105 | passThru PassThru, 106 | ) error { 107 | stat, err := file.Stat() 108 | if err != nil { 109 | return fmt.Errorf("failed to stat file: %w", err) 110 | } 111 | return a.CopyPassThru(ctx, &file, remotePath, permissions, stat.Size(), passThru) 112 | } 113 | 114 | // CopyFile copies the contents of an io.Reader to a remote location, the length is determined by reading the io.Reader until EOF 115 | // if the file length in know in advance please use "Copy" instead. 116 | func (a *Client) CopyFile( 117 | ctx context.Context, 118 | fileReader io.Reader, 119 | remotePath string, 120 | permissions string, 121 | ) error { 122 | return a.CopyFilePassThru(ctx, fileReader, remotePath, permissions, nil) 123 | } 124 | 125 | // CopyFilePassThru copies the contents of an io.Reader to a remote location, the length is determined by reading the io.Reader until EOF 126 | // if the file length in know in advance please use "Copy" instead. 127 | // Access copied bytes by providing a PassThru reader factory. 128 | func (a *Client) CopyFilePassThru( 129 | ctx context.Context, 130 | fileReader io.Reader, 131 | remotePath string, 132 | permissions string, 133 | passThru PassThru, 134 | ) error { 135 | contentsBytes, err := ioutil.ReadAll(fileReader) 136 | if err != nil { 137 | return fmt.Errorf("failed to read all data from reader: %w", err) 138 | } 139 | bytesReader := bytes.NewReader(contentsBytes) 140 | 141 | return a.CopyPassThru( 142 | ctx, 143 | bytesReader, 144 | remotePath, 145 | permissions, 146 | int64(len(contentsBytes)), 147 | passThru, 148 | ) 149 | } 150 | 151 | // wait waits for the waitgroup for the specified max timeout. 152 | // Returns true if waiting timed out. 153 | func wait(wg *sync.WaitGroup, ctx context.Context) error { 154 | c := make(chan struct{}) 155 | go func() { 156 | defer close(c) 157 | wg.Wait() 158 | }() 159 | 160 | select { 161 | case <-c: 162 | return nil 163 | 164 | case <-ctx.Done(): 165 | return ctx.Err() 166 | } 167 | } 168 | 169 | // checkResponse checks the response it reads from the remote, and will return a single error in case 170 | // of failure. 171 | func checkResponse(r io.Reader) error { 172 | _, err := ParseResponse(r, nil) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | return nil 178 | 179 | } 180 | 181 | // Copy copies the contents of an io.Reader to a remote location. 182 | func (a *Client) Copy( 183 | ctx context.Context, 184 | r io.Reader, 185 | remotePath string, 186 | permissions string, 187 | size int64, 188 | ) error { 189 | return a.CopyPassThru(ctx, r, remotePath, permissions, size, nil) 190 | } 191 | 192 | // CopyPassThru copies the contents of an io.Reader to a remote location. 193 | // Access copied bytes by providing a PassThru reader factory 194 | func (a *Client) CopyPassThru( 195 | ctx context.Context, 196 | r io.Reader, 197 | remotePath string, 198 | permissions string, 199 | size int64, 200 | passThru PassThru, 201 | ) error { 202 | session, err := a.sshClient.NewSession() 203 | if err != nil { 204 | return fmt.Errorf("Error creating ssh session in copy to remote: %v", err) 205 | } 206 | defer session.Close() 207 | 208 | stdout, err := session.StdoutPipe() 209 | if err != nil { 210 | return err 211 | } 212 | w, err := session.StdinPipe() 213 | if err != nil { 214 | return err 215 | } 216 | defer w.Close() 217 | 218 | if passThru != nil { 219 | r = passThru(r, size) 220 | } 221 | 222 | filename := path.Base(remotePath) 223 | 224 | // Start the command first and get confirmation that it has been started 225 | // before sending anything through the pipes. 226 | err = session.Start(fmt.Sprintf("%s -qt %q", a.RemoteBinary, remotePath)) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | wg := sync.WaitGroup{} 232 | wg.Add(2) 233 | 234 | errCh := make(chan error, 2) 235 | 236 | // SCP protocol and file sending 237 | go func() { 238 | defer wg.Done() 239 | defer w.Close() 240 | 241 | _, err = fmt.Fprintln(w, "C"+permissions, size, filename) 242 | if err != nil { 243 | errCh <- err 244 | return 245 | } 246 | 247 | if err = checkResponse(stdout); err != nil { 248 | errCh <- err 249 | return 250 | } 251 | 252 | _, err = io.Copy(w, r) 253 | if err != nil { 254 | errCh <- err 255 | return 256 | } 257 | 258 | _, err = fmt.Fprint(w, "\x00") 259 | if err != nil { 260 | errCh <- err 261 | return 262 | } 263 | 264 | if err = checkResponse(stdout); err != nil { 265 | errCh <- err 266 | return 267 | } 268 | }() 269 | 270 | // Wait for the process to exit 271 | go func() { 272 | defer wg.Done() 273 | err := session.Wait() 274 | if err != nil { 275 | errCh <- err 276 | return 277 | } 278 | }() 279 | 280 | // If there is a timeout, stop the transfer if it has been exceeded 281 | if a.Timeout > 0 { 282 | var cancel context.CancelFunc 283 | ctx, cancel = context.WithTimeout(ctx, a.Timeout) 284 | defer cancel() 285 | } 286 | 287 | // Wait for one of the conditions (error/timeout/completion) to occur 288 | if err := wait(&wg, ctx); err != nil { 289 | return err 290 | } 291 | 292 | close(errCh) 293 | 294 | // Collect any errors from the error channel 295 | for err := range errCh { 296 | if err != nil { 297 | return err 298 | } 299 | } 300 | 301 | return nil 302 | } 303 | 304 | // CopyFromRemote copies a file from the remote to the local file given by the `file` 305 | // parameter. Use `CopyFromRemotePassThru` if a more generic writer 306 | // is desired instead of writing directly to a file on the file system. 307 | func (a *Client) CopyFromRemote(ctx context.Context, file *os.File, remotePath string) error { 308 | return a.CopyFromRemotePassThru(ctx, file, remotePath, nil) 309 | } 310 | 311 | // CopyFromRemotePassThru copies a file from the remote to the given writer. The passThru parameter can be used 312 | // to keep track of progress and how many bytes that were download from the remote. 313 | // `passThru` can be set to nil to disable this behaviour. 314 | func (a *Client) CopyFromRemotePassThru( 315 | ctx context.Context, 316 | w io.Writer, 317 | remotePath string, 318 | passThru PassThru, 319 | ) error { 320 | _, err := a.copyFromRemote(ctx, w, remotePath, passThru, false) 321 | 322 | return err 323 | } 324 | 325 | // CopyFroRemoteFileInfos copies a file from the remote to a given writer and return a FileInfos struct 326 | // containing information about the file such as permissions, the file size, modification time and access time 327 | func (a *Client) CopyFromRemoteFileInfos( 328 | ctx context.Context, 329 | w io.Writer, 330 | remotePath string, 331 | passThru PassThru, 332 | ) (*FileInfos, error) { 333 | return a.copyFromRemote(ctx, w, remotePath, passThru, true) 334 | } 335 | 336 | func (a *Client) copyFromRemote( 337 | ctx context.Context, 338 | w io.Writer, 339 | remotePath string, 340 | passThru PassThru, 341 | preserveFileTimes bool, 342 | ) (*FileInfos, error) { 343 | session, err := a.sshClient.NewSession() 344 | if err != nil { 345 | return nil, fmt.Errorf("Error creating ssh session in copy from remote: %v", err) 346 | } 347 | defer session.Close() 348 | 349 | wg := sync.WaitGroup{} 350 | errCh := make(chan error, 4) 351 | var fileInfos *FileInfos 352 | 353 | wg.Add(1) 354 | go func() { 355 | var err error 356 | 357 | defer func() { 358 | // NOTE: this might send an already sent error another time, but since we only receive one, this is fine. On the "happy-path" of this function, the error will be `nil` therefore completing the "err<-errCh" at the bottom of the function. 359 | errCh <- err 360 | // We must unblock the go routine first as we block on reading the channel later 361 | wg.Done() 362 | 363 | }() 364 | 365 | r, err := session.StdoutPipe() 366 | if err != nil { 367 | errCh <- err 368 | return 369 | } 370 | 371 | in, err := session.StdinPipe() 372 | if err != nil { 373 | errCh <- err 374 | return 375 | } 376 | defer in.Close() 377 | 378 | if preserveFileTimes { 379 | err = session.Start(fmt.Sprintf("%s -pf %q", a.RemoteBinary, remotePath)) 380 | } else { 381 | err = session.Start(fmt.Sprintf("%s -f %q", a.RemoteBinary, remotePath)) 382 | } 383 | if err != nil { 384 | errCh <- err 385 | return 386 | } 387 | 388 | err = Ack(in) 389 | if err != nil { 390 | errCh <- err 391 | return 392 | } 393 | 394 | fileInfo, err := ParseResponse(r, in) 395 | if err != nil { 396 | errCh <- err 397 | return 398 | } 399 | 400 | fileInfos = fileInfo 401 | 402 | err = Ack(in) 403 | if err != nil { 404 | errCh <- err 405 | return 406 | } 407 | 408 | if passThru != nil { 409 | r = passThru(r, fileInfo.Size) 410 | } 411 | 412 | _, err = CopyN(w, r, fileInfo.Size) 413 | if err != nil { 414 | errCh <- err 415 | return 416 | } 417 | 418 | err = Ack(in) 419 | if err != nil { 420 | errCh <- err 421 | return 422 | } 423 | 424 | err = session.Wait() 425 | if err != nil { 426 | errCh <- err 427 | return 428 | } 429 | }() 430 | 431 | if a.Timeout > 0 { 432 | var cancel context.CancelFunc 433 | ctx, cancel = context.WithTimeout(ctx, a.Timeout) 434 | defer cancel() 435 | } 436 | 437 | if err := wait(&wg, ctx); err != nil { 438 | return nil, err 439 | } 440 | 441 | finalErr := <-errCh 442 | close(errCh) 443 | return fileInfos, finalErr 444 | } 445 | 446 | func (a *Client) Close() { 447 | a.closeHandler.Close() 448 | } 449 | -------------------------------------------------------------------------------- /configurer.go: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2020 Bram Vandenbogaerde 2 | * You may use, distribute or modify this code under the 3 | * terms of the Mozilla Public License 2.0, which is distributed 4 | * along with the source code. 5 | */ 6 | 7 | package scp 8 | 9 | import ( 10 | "time" 11 | 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | // ClientConfigurer a struct containing all the configuration options 16 | // used by an scp client. 17 | type ClientConfigurer struct { 18 | host string 19 | clientConfig *ssh.ClientConfig 20 | session *ssh.Session 21 | timeout time.Duration 22 | remoteBinary string 23 | sshClient *ssh.Client 24 | } 25 | 26 | // NewConfigurer creates a new client configurer. 27 | // It takes the required parameters: the host and the ssh.ClientConfig and 28 | // returns a configurer populated with the default values for the optional 29 | // parameters. 30 | // 31 | // These optional parameters can be set by using the methods provided on the 32 | // ClientConfigurer struct. 33 | func NewConfigurer(host string, config *ssh.ClientConfig) *ClientConfigurer { 34 | return &ClientConfigurer{ 35 | host: host, 36 | clientConfig: config, 37 | timeout: 0, // no timeout by default 38 | remoteBinary: "scp", 39 | } 40 | } 41 | 42 | // RemoteBinary sets the path of the location of the remote scp binary 43 | // Defaults to: /usr/bin/scp. 44 | func (c *ClientConfigurer) RemoteBinary(path string) *ClientConfigurer { 45 | c.remoteBinary = path 46 | return c 47 | } 48 | 49 | // Host alters the host of the client connects to. 50 | func (c *ClientConfigurer) Host(host string) *ClientConfigurer { 51 | c.host = host 52 | return c 53 | } 54 | 55 | // Timeout Changes the connection timeout. 56 | // Defaults to one minute. 57 | func (c *ClientConfigurer) Timeout(timeout time.Duration) *ClientConfigurer { 58 | c.timeout = timeout 59 | return c 60 | } 61 | 62 | // ClientConfig alters the ssh.ClientConfig. 63 | func (c *ClientConfigurer) ClientConfig(config *ssh.ClientConfig) *ClientConfigurer { 64 | c.clientConfig = config 65 | return c 66 | } 67 | 68 | func (c *ClientConfigurer) SSHClient(sshClient *ssh.Client) *ClientConfigurer { 69 | c.sshClient = sshClient 70 | return c 71 | } 72 | 73 | // Create builds a client with the configuration stored within the ClientConfigurer. 74 | func (c *ClientConfigurer) Create() Client { 75 | return Client{ 76 | Host: c.host, 77 | ClientConfig: c.clientConfig, 78 | Timeout: c.timeout, 79 | RemoteBinary: c.remoteBinary, 80 | sshClient: c.sshClient, 81 | closeHandler: EmptyHandler{}, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bramvdbogaerde/go-scp 2 | 3 | go 1.21 4 | 5 | require golang.org/x/crypto v0.18.0 6 | 7 | require golang.org/x/sys v0.16.0 // indirect 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= 2 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 3 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 4 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 5 | golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= 6 | golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= 7 | -------------------------------------------------------------------------------- /protocol.go: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2021 Bram Vandenbogaerde And Contributors 2 | * You may use, distribute or modify this code under the 3 | * terms of the Mozilla Public License 2.0, which is distributed 4 | * along with the source code. 5 | */ 6 | 7 | package scp 8 | 9 | import ( 10 | "bufio" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "strconv" 15 | "strings" 16 | ) 17 | 18 | type ResponseType = byte 19 | 20 | const ( 21 | Ok ResponseType = 0 22 | Warning ResponseType = 1 23 | Error ResponseType = 2 24 | Create ResponseType = 'C' 25 | Time ResponseType = 'T' 26 | ) 27 | 28 | // ParseResponse reads from the given reader (assuming it is the output of the remote) and parses it into a Response structure. 29 | func ParseResponse(reader io.Reader, writer io.Writer) (*FileInfos, error) { 30 | fileInfos := NewFileInfos() 31 | 32 | buffer := make([]uint8, 1) 33 | _, err := reader.Read(buffer) 34 | if err != nil { 35 | return fileInfos, err 36 | } 37 | 38 | responseType := buffer[0] 39 | message := "" 40 | if responseType > 0 { 41 | bufferedReader := bufio.NewReader(reader) 42 | message, err = bufferedReader.ReadString('\n') 43 | if err != nil { 44 | return fileInfos, err 45 | } 46 | 47 | if responseType == Warning || responseType == Error { 48 | return fileInfos, errors.New(message) 49 | } 50 | 51 | // Exit early because we're only interested in the ok response 52 | if responseType == Ok { 53 | return fileInfos, nil 54 | } 55 | 56 | if !(responseType == Create || responseType == Time) { 57 | return fileInfos, errors.New( 58 | fmt.Sprintf( 59 | "Message does not follow scp protocol: %s\n Cmmmm or T 0 0", 60 | message, 61 | ), 62 | ) 63 | } 64 | 65 | if responseType == Time { 66 | err = ParseFileTime(message, fileInfos) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | // A custom ssh server can send both time, permissions and size information at once 72 | // without needing an Ack response. Example: wish from charmbracelet while using their default scp implementation 73 | // If the buffer is empty, then it's likely the default implementation for ssh, so send Ack 74 | if bufferedReader.Buffered() == 0 { 75 | err = Ack(writer) 76 | if err != nil { 77 | return fileInfos, err 78 | } 79 | } 80 | 81 | message, err = bufferedReader.ReadString('\n') 82 | 83 | if err != nil { 84 | return fileInfos, err 85 | } 86 | 87 | responseType = message[0] 88 | } 89 | 90 | if responseType == Create { 91 | err = ParseFileInfos(message, fileInfos) 92 | if err != nil { 93 | return nil, err 94 | } 95 | } 96 | } 97 | 98 | return fileInfos, nil 99 | } 100 | 101 | type FileInfos struct { 102 | Message string 103 | Filename string 104 | Permissions uint32 105 | Size int64 106 | Atime int64 107 | Mtime int64 108 | } 109 | 110 | func NewFileInfos() *FileInfos { 111 | return &FileInfos{} 112 | } 113 | 114 | func (fileInfos *FileInfos) Update(new *FileInfos) { 115 | if new == nil { 116 | return 117 | } 118 | if new.Filename != "" { 119 | fileInfos.Filename = new.Filename 120 | } 121 | if new.Permissions != 0 { 122 | fileInfos.Permissions = new.Permissions 123 | } 124 | if new.Size != 0 { 125 | fileInfos.Size = new.Size 126 | } 127 | if new.Atime != 0 { 128 | fileInfos.Atime = new.Atime 129 | } 130 | if new.Mtime != 0 { 131 | fileInfos.Mtime = new.Mtime 132 | } 133 | } 134 | 135 | func ParseFileInfos(message string, fileInfos *FileInfos) error { 136 | processMessage := strings.ReplaceAll(message, "\n", "") 137 | parts := strings.Split(processMessage, " ") 138 | if len(parts) < 3 { 139 | return errors.New("unable to parse Chmod protocol") 140 | } 141 | 142 | permissions, err := strconv.ParseUint(parts[0][1:], 0, 32) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | size, err := strconv.ParseInt(parts[1], 10, 64) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | fileInfos.Update(&FileInfos{ 153 | Filename: parts[2], 154 | Permissions: uint32(permissions), 155 | Size: int64(size), 156 | }) 157 | 158 | return nil 159 | } 160 | 161 | func ParseFileTime( 162 | message string, 163 | fileInfos *FileInfos, 164 | ) error { 165 | processMessage := strings.ReplaceAll(message, "\n", "") 166 | parts := strings.Split(processMessage, " ") 167 | if len(parts) < 3 { 168 | return errors.New("unable to parse Time protocol") 169 | } 170 | 171 | if len(parts[0]) != 10 { 172 | return errors.New("length of ATime is not 10") 173 | } 174 | mTime, err := strconv.Atoi(parts[0][0:10]) 175 | if err != nil { 176 | return errors.New("unable to parse ATime component of message") 177 | } 178 | 179 | if len(parts[2]) != 10 { 180 | return errors.New("length of MTime is not 10") 181 | } 182 | aTime, err := strconv.Atoi(parts[2][0:10]) 183 | if err != nil { 184 | return errors.New("unable to parse MTime component of message") 185 | } 186 | 187 | fileInfos.Update(&FileInfos{ 188 | Atime: int64(aTime), 189 | Mtime: int64(mTime), 190 | }) 191 | return nil 192 | } 193 | 194 | // Ack writes an `Ack` message to the remote, does not await its response, a seperate call to ParseResponse is 195 | // therefore required to check if the acknowledgement succeeded. 196 | func Ack(writer io.Writer) error { 197 | var msg = []byte{0} 198 | n, err := writer.Write(msg) 199 | if err != nil { 200 | return err 201 | } 202 | if n < len(msg) { 203 | return errors.New("failed to write ack buffer") 204 | } 205 | return nil 206 | } 207 | -------------------------------------------------------------------------------- /scp.go: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2021 Bram Vandenbogaerde 2 | * You may use, distribute or modify this code under the 3 | * terms of the Mozilla Public License 2.0, which is distributed 4 | * along with the source code. 5 | */ 6 | 7 | // Package scp. 8 | // Simple scp package to copy files over SSH. 9 | package scp 10 | 11 | import ( 12 | "time" 13 | 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | // NewClient returns a new scp.Client with provided host and ssh.clientConfig. 18 | func NewClient(host string, config *ssh.ClientConfig) Client { 19 | return NewConfigurer(host, config).Create() 20 | } 21 | 22 | // NewClientWithTimeout returns a new scp.Client with provides host, ssh.ClientConfig and timeout. 23 | // Deprecated: provide meaningful context to each "Copy*" function instead. 24 | func NewClientWithTimeout(host string, config *ssh.ClientConfig, timeout time.Duration) Client { 25 | return NewConfigurer(host, config).Timeout(timeout).Create() 26 | } 27 | 28 | // NewClientBySSH returns a new scp.Client using an already existing established SSH connection. 29 | func NewClientBySSH(ssh *ssh.Client) (Client, error) { 30 | return NewConfigurer("", nil).SSHClient(ssh).Create(), nil 31 | } 32 | 33 | // NewClientBySSHWithTimeout same as NewClientWithTimeout but uses an existing SSH client. 34 | // Deprecated: provide meaningful context to each "Copy*" function instead. 35 | func NewClientBySSHWithTimeout(ssh *ssh.Client, timeout time.Duration) (Client, error) { 36 | return NewConfigurer("", nil).SSHClient(ssh).Timeout(timeout).Create(), nil 37 | } 38 | -------------------------------------------------------------------------------- /tests/basic_test.go: -------------------------------------------------------------------------------- 1 | package scp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/bramvdbogaerde/go-scp" 13 | "github.com/bramvdbogaerde/go-scp/auth" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | // password | private key | private key with passphrase | ssh agent 18 | func buildClientConfig() (ssh.ClientConfig, error) { 19 | method := os.Getenv("METHOD") 20 | if method == "" { 21 | method = "password" 22 | } 23 | 24 | var clientConfig ssh.ClientConfig 25 | switch method { 26 | case "password": 27 | // Use SSH key authentication from the auth package. 28 | // During testing we ignore the host key, don't do that when you use this. 29 | config, _ := auth.PasswordKey("bram", "test", ssh.InsecureIgnoreHostKey()) 30 | return config, nil 31 | case "private_key": 32 | config, _ := auth.PrivateKey("bram", "./tmp/id_rsa", ssh.InsecureIgnoreHostKey()) 33 | return config, nil 34 | case "private_key_with_passphrase": 35 | config, _ := auth.PrivateKeyWithPassphrase( 36 | "bram", []byte("passphrase"), "./tmp/id_rsa", ssh.InsecureIgnoreHostKey(), 37 | ) 38 | return config, nil 39 | case "ssh_agent": 40 | config, _ := auth.SshAgent("bram", ssh.InsecureIgnoreHostKey()) 41 | return config, nil 42 | } 43 | return clientConfig, fmt.Errorf("Unknown method: %s", method) 44 | } 45 | 46 | func establishConnection(t *testing.T) scp.Client { 47 | // Build the client configuration. 48 | clientConfig, err := buildClientConfig() 49 | if err != nil { 50 | t.Fatalf("Couldn't build the client configuration: %s", err) 51 | } 52 | 53 | // Create a new SCP client. 54 | client := scp.NewClient("127.0.0.1:2244", &clientConfig) 55 | 56 | // Connect to the remote server. 57 | err = client.Connect() 58 | if err != nil { 59 | t.Fatalf("Couldn't establish a connection to the remote server: %s", err) 60 | } 61 | return client 62 | } 63 | 64 | // TestCopy tests the basic functionality of copying a file to the remote 65 | // destination. 66 | // 67 | // It assumes that a Docker container is running an SSH server at port 2244 68 | // that is using password authentication. It also assumes that the directory 69 | // /data is writable within that container and is mapped to ./tmp/ within the 70 | // directory the test is run from. 71 | func TestCopy(t *testing.T) { 72 | client := establishConnection(t) 73 | defer client.Close() 74 | 75 | // Open a file we can transfer to the remote container. 76 | f, _ := os.Open("./data/upload_file.txt") 77 | defer f.Close() 78 | 79 | // Create a file name with exotic characters and spaces in them. 80 | // If this test works for this, simpler files should not be a problem. 81 | filename := "Exöt1ç uploaded file.txt" 82 | 83 | // Finaly, copy the file over. 84 | // Usage: CopyFile(fileReader, remotePath, permission). 85 | err := client.CopyFile(context.Background(), f, "/data/"+filename, "0777") 86 | if err != nil { 87 | t.Errorf("Error while copying file: %s", err) 88 | } 89 | 90 | // Read what the receiver have written to disk. 91 | content, err := os.ReadFile("./tmp/" + filename) 92 | if err != nil { 93 | t.Errorf("Result file could not be read: %s", err) 94 | } 95 | 96 | text := string(content) 97 | expected := "It Works\n" 98 | if strings.Compare(text, expected) != 0 { 99 | t.Errorf("Got different text than expected, expected %q got, %q", expected, text) 100 | } 101 | } 102 | 103 | // TestCopy tests the basic functionality of copying a file to the remote 104 | // destination. 105 | // 106 | // It assumes that a Docker container is running an SSH server at port 2244 107 | // that is using password authentication. It also assumes that the directory 108 | // /data is writable within that container and is mapped to ./tmp/ within the 109 | // directory the test is run from. 110 | func TestMultipleUploadsAndDownloads(t *testing.T) { 111 | client := establishConnection(t) 112 | defer client.Close() 113 | 114 | // Open a file we can transfer to the remote container. 115 | f1, _ := os.Open("./data/upload_file.txt") 116 | defer f1.Close() 117 | 118 | f2, _ := os.Open("./data/another_file.txt") 119 | defer f2.Close() 120 | 121 | // Open files to be written too from downloads 122 | f_download_1, _ := os.Create("./tmp/download_result_1") 123 | defer f_download_1.Close() 124 | f_download_2, _ := os.Create("./tmp/download_result_2") 125 | defer f_download_2.Close() 126 | 127 | // Create a file name with exotic characters and spaces in them. 128 | // If this test works for this, simpler files should not be a problem. 129 | remoteFilename1 := "Exöt1ç uploaded file.txt" 130 | remoteFilename2 := "verywow.txt" 131 | 132 | err := upload(&client, f1, "/data/"+remoteFilename1, "0777") 133 | if err != nil { 134 | t.Errorf("Error while copying file: %s", err) 135 | } 136 | 137 | err = upload(&client, f2, "/data/"+remoteFilename2, "0777") 138 | if err != nil { 139 | t.Errorf("Error while copying file: %s", err) 140 | } 141 | 142 | err = download(&client, f_download_1, "/data/"+remoteFilename1) 143 | if err != nil { 144 | t.Errorf("Error while downloading file: %s", err) 145 | } 146 | 147 | err = download(&client, f_download_2, "/data/"+remoteFilename2) 148 | if err != nil { 149 | t.Errorf("Error while downloading file: %s", err) 150 | } 151 | 152 | // Read what the receiver have written to disk. 153 | content, err := os.ReadFile("./tmp/" + remoteFilename1) 154 | if err != nil { 155 | t.Errorf("Result file could not be read: %s", err) 156 | } 157 | 158 | // Read what the receiver have written to disk. 159 | content2, err := os.ReadFile("./tmp/" + remoteFilename2) 160 | if err != nil { 161 | t.Errorf("Result file could not be read: %s", err) 162 | } 163 | 164 | download_result_1, _ := os.ReadFile("./tmp/download_result_1") 165 | download_result_2, _ := os.ReadFile("./tmp/download_result_2") 166 | 167 | text1 := string(content) 168 | expected := "It Works\n" 169 | if strings.Compare(text1, expected) != 0 { 170 | t.Errorf("Got different text than expected, expected %q got, %q", expected, text1) 171 | } 172 | 173 | text2 := string(content2) 174 | expected = "Here is some stuff and things.\nEven another line.\n" 175 | if strings.Compare(text2, expected) != 0 { 176 | t.Errorf("Got different text than expected, expected %q got, %q", expected, text2) 177 | } 178 | 179 | // Compare downloaded content to written content 180 | download_result_1_content := string(download_result_1) 181 | if strings.Compare(text1, download_result_1_content) != 0 { 182 | t.Errorf("Downloaded result different from disk: %q %q", download_result_1_content, text1) 183 | } 184 | 185 | download_result_2_content := string(download_result_2) 186 | if strings.Compare(text2, download_result_2_content) != 0 { 187 | t.Errorf("Downloaded result different from disk: %q %q", download_result_2_content, text2) 188 | } 189 | 190 | } 191 | 192 | func upload(client *scp.Client, file *os.File, remoteFilename, perm string) error { 193 | return client.CopyFile(context.Background(), file, remoteFilename, "0777") 194 | } 195 | 196 | func download(client *scp.Client, file *os.File, remotePath string) error { 197 | return client.CopyFromRemote(context.Background(), file, remotePath) 198 | } 199 | 200 | // TestDownloadFile tests the basic functionality of copying a file from the 201 | // remote destination. 202 | // 203 | // It assumes that a Docker container is running an SSH server at port 2244 204 | // that is using password authentication. It also assumes that the directory 205 | // /data is writable within that container and is mapped to ./tmp/ within the 206 | // directory the test is run from. 207 | func TestDownloadFile(t *testing.T) { 208 | client := establishConnection(t) 209 | defer client.Close() 210 | 211 | // Create a local file to write to. 212 | f, err := os.OpenFile("./tmp/output.txt", os.O_RDWR|os.O_CREATE, 0777) 213 | if err != nil { 214 | t.Errorf("Couldn't open the output file") 215 | } 216 | defer f.Close() 217 | 218 | // Use a file name with exotic characters and spaces in them. 219 | // If this test works for this, simpler files should not be a problem. 220 | err = client.CopyFromRemote(context.Background(), f, "/input/Exöt1ç download file.txt.txt") 221 | if err != nil { 222 | t.Errorf("Copy failed from remote: %s", err.Error()) 223 | } 224 | 225 | content, err := os.ReadFile("./tmp/output.txt") 226 | if err != nil { 227 | t.Errorf("Result file could not be read: %s", err) 228 | } 229 | 230 | text := string(content) 231 | expected := "It works for download!\n" 232 | if strings.Compare(text, expected) != 0 { 233 | t.Errorf("Got different text than expected, expected %q got, %q", expected, text) 234 | } 235 | } 236 | 237 | func TestDownloadFileInfo(t *testing.T) { 238 | client := establishConnection(t) 239 | defer client.Close() 240 | 241 | // Create a local file to write the remote file to. 242 | f, err := os.OpenFile("./tmp/output.txt", os.O_RDWR|os.O_CREATE, 0777) 243 | if err != nil { 244 | t.Errorf("Couldn't open the output file") 245 | } 246 | defer f.Close() 247 | 248 | // Use a file name with exotic characters and spaces in them. 249 | // If this test works for this, simpler files should not be a problem. 250 | fileInfos, err := client.CopyFromRemoteFileInfos( 251 | context.Background(), 252 | f, 253 | "/input/Exöt1ç download file.txt.txt", 254 | nil, 255 | ) 256 | if err != nil { 257 | t.Errorf("Copy failed from remote: %s", err.Error()) 258 | } 259 | 260 | content, err := os.ReadFile("./tmp/output.txt") 261 | if err != nil { 262 | t.Errorf("Result file could not be read: %s", err) 263 | } 264 | 265 | text := string(content) 266 | expected := "It works for download!\n" 267 | if strings.Compare(text, expected) != 0 { 268 | t.Errorf("Got different text than expected, expected %q got, %q", expected, text) 269 | } 270 | 271 | fileStat, err := os.Stat("./data/Exöt1ç download file.txt.txt") 272 | if err != nil { 273 | t.Errorf("Result file could not be read: %s", err) 274 | } 275 | 276 | if fileInfos.Size != fileStat.Size() { 277 | t.Errorf("File size does not match") 278 | } 279 | 280 | if fs.FileMode(fileInfos.Permissions) != fileStat.Mode() { 281 | t.Errorf( 282 | "File permissions don't match %s vs %s", 283 | fs.FileMode(fileInfos.Permissions), 284 | fileStat.Mode().Perm(), 285 | ) 286 | } 287 | 288 | if fileInfos.Mtime != fileStat.ModTime().Unix() { 289 | t.Errorf( 290 | "File modification time does not match %d vs %d", 291 | fileInfos.Mtime, 292 | fileStat.ModTime().Unix(), 293 | ) 294 | } 295 | } 296 | 297 | // TestTimeoutDownload tests that a timeout error is produced if the file is not copied in the given 298 | // amount of time. 299 | func TestTimeoutDownload(t *testing.T) { 300 | client := establishConnection(t) 301 | defer client.Close() 302 | client.Timeout = 1 * time.Millisecond 303 | 304 | // Open a file we can transfer to the remote container. 305 | f, _ := os.Open("./data/upload_file.txt") 306 | defer f.Close() 307 | 308 | // Create a file name with exotic characters and spaces in them. 309 | // If this test works for this, simpler files should not be a problem. 310 | filename := "Exöt1ç uploaded file.txt" 311 | 312 | err := client.CopyFile(context.Background(), f, "/data/"+filename, "0777") 313 | if err != context.DeadlineExceeded { 314 | t.Errorf("Expected a timeout error but got succeeded without error") 315 | } 316 | } 317 | 318 | // TestContextCancelDownload tests that a a copy is immediately cancelled if we call context.cancel() 319 | func TestContextCancelDownload(t *testing.T) { 320 | client := establishConnection(t) 321 | defer client.Close() 322 | 323 | ctx, cancel := context.WithCancel(context.Background()) 324 | cancel() 325 | 326 | // Open a file we can transfer to the remote container. 327 | f, _ := os.Open("./data/upload_file.txt") 328 | defer f.Close() 329 | 330 | // Create a file name with exotic characters and spaces in them. 331 | // If this test works for this, simpler files should not be a problem. 332 | filename := "Exöt1ç uploaded file.txt" 333 | 334 | err := client.CopyFile(ctx, f, "/data/"+filename, "0777") 335 | if err != context.Canceled { 336 | t.Errorf("Expected a canceled error but transfer succeeded without error") 337 | } 338 | } 339 | 340 | func TestDownloadBadLocalFilePermissions(t *testing.T) { 341 | client := establishConnection(t) 342 | defer client.Close() 343 | 344 | // Create a file with local bad permissions 345 | // This happens only on Linux 346 | f, err := os.OpenFile("./tmp/output_bdf.txt", os.O_CREATE, 0644) 347 | if err != nil { 348 | t.Error("Couldn't open the output file", err.Error()) 349 | } 350 | defer f.Close() 351 | 352 | // This should not timeout and throw an error 353 | err = client.CopyFromRemote(context.Background(), f, "/input/Exöt1ç download file.txt.txt") 354 | if err == nil { 355 | t.Errorf("Expected error thrown. Got nil") 356 | } 357 | } 358 | 359 | func TestFileNotFound(t *testing.T) { 360 | client := establishConnection(t) 361 | defer client.Close() 362 | 363 | f, err := os.OpenFile("./tmp/output_fnf.txt", os.O_CREATE|os.O_WRONLY, 0644) 364 | if err != nil { 365 | t.Error("Couldn't open the output file", err.Error()) 366 | } 367 | // This should throw file not found on remote 368 | err = client.CopyFromRemote(context.Background(), f, "/input/no_such_file.txt") 369 | if err == nil { 370 | t.Errorf("Expected error thrown. Got nil") 371 | } 372 | expected := "scp: /input/no_such_file.txt: No such file or directory\n" 373 | if err.Error() != expected { 374 | t.Errorf("Expected %v, got %v", expected, err.Error()) 375 | } 376 | } 377 | 378 | func TestUserSuppliedSSHClientDoesNotClose(t *testing.T) { 379 | // create the SSH connection 380 | clientConfig, err := buildClientConfig() 381 | if err != nil { 382 | t.Error("Could not build client config", clientConfig) 383 | } 384 | 385 | sshClient, err := ssh.Dial("tcp", "127.0.0.1:2244", &clientConfig) 386 | if err != nil { 387 | t.Error("Could not establish SSH connection", err) 388 | } 389 | defer sshClient.Close() 390 | 391 | // create the SCP client 392 | client, err := scp.NewClientBySSH(sshClient) 393 | if err != nil { 394 | t.Error("Could not create SCP client", err) 395 | } 396 | 397 | // copy a file for good measure 398 | 399 | f, _ := os.Open("./data/upload_file.txt") 400 | defer f.Close() 401 | 402 | err = client.CopyFile(context.Background(), f, "/data/test.txt", "0777") 403 | 404 | if err != nil { 405 | t.Error("Could not copy file to remote", err) 406 | } 407 | 408 | // then close the SCP client 409 | client.Close() 410 | 411 | var session *ssh.Session 412 | 413 | // ensure that the SSH client is still opened 414 | // we do so by creating a new session, if this fails 415 | // the SSH connection was already closed 416 | if session, err = sshClient.NewSession(); err != nil { 417 | t.Fatal("SSH session was already closed.") 418 | } 419 | 420 | session.Close() 421 | } 422 | 423 | // Ensure that the underlying SSH client managed by the library is correctly closed 424 | // after closing the SCP connection 425 | func TestSSHClientNoLeak(t *testing.T) { 426 | client := establishConnection(t) 427 | 428 | // copy a file for good measure 429 | f, _ := os.Open("./data/upload_file.txt") 430 | defer f.Close() 431 | 432 | err := client.CopyFile(context.Background(), f, "/data/test.txt", "0777") 433 | 434 | if err != nil { 435 | t.Error("Could not copy file to remote", err) 436 | } 437 | 438 | // then close the SCP client 439 | client.Close() 440 | 441 | // ensure that the SSH client is still opened 442 | // we do so by creating a new session, if this fails 443 | // the SSH connection was already closed 444 | if session, err := client.SSHClient().NewSession(); err == nil { 445 | session.Close() 446 | t.Fatal("SSH session was not closed.") 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /tests/data/Exöt1ç download file.txt.txt: -------------------------------------------------------------------------------- 1 | It works for download! 2 | -------------------------------------------------------------------------------- /tests/data/another_file.txt: -------------------------------------------------------------------------------- 1 | Here is some stuff and things. 2 | Even another line. 3 | -------------------------------------------------------------------------------- /tests/data/upload_file.txt: -------------------------------------------------------------------------------- 1 | It Works 2 | -------------------------------------------------------------------------------- /tests/entrypoint.d/setpasswd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo 'bram:$6$9CLVhdvYPsRYjJZJ$UoJbmXrl6F.kL1u1Mnio6gRgKAB7HG0eSeATa.HMu7liRGAINTicM0Ql5/AONVwKXsrA0BbMOOr3BHrODnP2s0' | chpasswd --encrypted 4 | -------------------------------------------------------------------------------- /tests/run_all.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | fix_permissions () { 4 | chmod -R 777 tmp/ 5 | if [ -f "tmp/id_rsa.pub" ] ; then 6 | chmod 0600 tmp/id_rsa.pub 7 | fi 8 | } 9 | 10 | cleanup() { 11 | local auth_method=$1 12 | 13 | echo "Tearing down docker containers" 14 | docker stop go-scp-test 15 | docker rm go-scp-test 16 | 17 | echo "Cleaning up" 18 | if [[ "$auth_method" == "ssh_agent" ]]; then 19 | ssh-add -d ./tmp/id_rsa 20 | fi 21 | rm -r tmp/ 22 | mkdir tmp/ 23 | touch tmp/.gitkeep 24 | } 25 | 26 | run_test() { 27 | local auth_method=$1 28 | 29 | fix_permissions 30 | echo "Testing with auth method: $auth_method" 31 | 32 | echo "Running tests" 33 | METHOD="$auth_method" go test -v 34 | if [ $? -ne 0 ]; then 35 | cleanup 36 | exit 1 37 | fi 38 | } 39 | 40 | run_docker_container() { 41 | local enable_password_auth=$1 42 | 43 | docker run -d \ 44 | --name go-scp-test \ 45 | -p 2244:22 \ 46 | -e SSH_USERS=bram:1000:1000 \ 47 | -e SSH_ENABLE_PASSWORD_AUTH=$enable_password_auth \ 48 | -v $(pwd)/tmp:/data/ \ 49 | -v $(pwd)/data:/input \ 50 | -v $(pwd)/entrypoint.d/:/etc/entrypoint.d/ \ 51 | ${extra_mount:-} \ 52 | panubo/sshd 53 | } 54 | 55 | if [ -z "${GITHUB_ACTIONS}" ]; then 56 | AUTH_METHODS="password private_key private_key_with_passphrase ssh_agent" 57 | else 58 | AUTH_METHODS="password" 59 | fi 60 | 61 | for auth_method in $AUTH_METHODS ; do 62 | case "$auth_method" in 63 | "password") 64 | echo "Testing with password auth" 65 | run_docker_container true 66 | sleep 5 67 | run_test "$auth_method" 68 | cleanup 69 | ;; 70 | "private_key" | "private_key_with_passphrase" | "ssh_agent") 71 | echo "Testing with $auth_method auth" 72 | ssh-keygen -t rsa -f ./tmp/id_rsa -N "" 73 | if [[ "$auth_method" == "private_key_with_passphrase" ]]; then 74 | ssh-keygen -p -f ./tmp/id_rsa -P "" -N "passphrase" 75 | fi 76 | if [[ "$auth_method" == "ssh_agent" ]]; then 77 | ssh-add ./tmp/id_rsa 78 | fi 79 | extra_mount="-v $(pwd)/tmp/id_rsa.pub:/etc/authorized_keys/bram:ro" 80 | run_docker_container false 81 | sleep 5 82 | run_test "$auth_method" 83 | cleanup "$auth_method" 84 | ;; 85 | *) 86 | echo "Unsupported auth method $auth_method" 87 | exit 1 88 | ;; 89 | esac 90 | done 91 | -------------------------------------------------------------------------------- /tests/tmp/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bramvdbogaerde/go-scp/c2410915e3da161dcf056b6486f5401eb3819b48/tests/tmp/.gitkeep -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2021 Bram Vandenbogaerde And Contributors 2 | * You may use, distribute or modify this code under the 3 | * terms of the Mozilla Public License 2.0, which is distributed 4 | * along with the source code. 5 | */ 6 | 7 | package scp 8 | 9 | import "io" 10 | 11 | // CopyN an adaptation of io.CopyN that keeps reading if it did not return 12 | // a sufficient amount of bytes. 13 | func CopyN(writer io.Writer, src io.Reader, size int64) (int64, error) { 14 | var total int64 15 | total = 0 16 | for total < size { 17 | n, err := io.CopyN(writer, src, size) 18 | if err != nil { 19 | return 0, err 20 | } 21 | total += n 22 | } 23 | 24 | return total, nil 25 | } 26 | --------------------------------------------------------------------------------