├── .github └── workflows │ └── test.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── doc.go ├── go.mod ├── go.sum ├── magefile.go └── pkg └── netrasp ├── asa.go ├── asa_test.go ├── config.go ├── connection.go ├── fileconnection_test.go ├── helpers.go ├── ios.go ├── ios_test.go ├── knownhosts.go ├── knownhosts_test.go ├── netrasp.go ├── netrasp_test.go ├── nxos.go ├── nxos_test.go ├── platform.go ├── platform_test.go ├── reader.go ├── reader_test.go ├── sros.go ├── sros_test.go ├── ssh.go └── testdata ├── asa └── basic │ ├── initial.txt │ └── no_terminal_pager.txt ├── invalid_known_hosts ├── ios ├── basic │ ├── initial.txt │ ├── show_version.txt │ ├── terminal_length_0.txt │ └── terminal_width_511.txt └── enable_with_config │ ├── configure_terminal.txt │ ├── enable.txt │ ├── end.txt │ ├── initial.txt │ ├── ip_access-list_extended_netrasp-test.txt │ ├── password.txt │ ├── permit_ip_any_any.txt │ ├── remark_running_some_tests.txt │ ├── terminal_length_0.txt │ └── terminal_width_511.txt ├── nxos └── basic │ ├── initial.txt │ ├── terminal_length_0.txt │ └── terminal_width_511.txt └── sros └── basic ├── environment_more_false.txt ├── initial.txt └── show_version.txt /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | lint: 5 | env: 6 | GOPROXY: https://proxy.golang.org 7 | GO111MODULE: on 8 | strategy: 9 | matrix: 10 | go-version: [1.15.x] 11 | os: [ubuntu-latest] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@37335c7bb261b353407cff977110895fa0b4f7d8 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | - name: Install Mage 19 | run: go get github.com/magefile/mage@07afc7d24f4d6d6442305d49552f04fbda5ccb3e 20 | - name: Checkout code 21 | uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f 22 | - name: Test 23 | run: | 24 | mage -v lint 25 | test: 26 | env: 27 | GOPROXY: https://proxy.golang.org 28 | GO111MODULE: on 29 | strategy: 30 | matrix: 31 | go-version: [1.15.x] 32 | os: [ubuntu-latest] 33 | runs-on: ${{ matrix.os }} 34 | steps: 35 | - name: Install Go 36 | uses: actions/setup-go@37335c7bb261b353407cff977110895fa0b4f7d8 37 | with: 38 | go-version: ${{ matrix.go-version }} 39 | - name: Install Mage 40 | run: go get github.com/magefile/mage@07afc7d24f4d6d6442305d49552f04fbda5ccb3e 41 | - name: Checkout code 42 | uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f 43 | - name: Test 44 | run: | 45 | mage -v test 46 | test_integration: 47 | env: 48 | GOPROXY: https://proxy.golang.org 49 | GO111MODULE: on 50 | strategy: 51 | matrix: 52 | go-version: [1.15.x] 53 | os: [ubuntu-latest] 54 | runs-on: ${{ matrix.os }} 55 | steps: 56 | - name: Install Go 57 | uses: actions/setup-go@37335c7bb261b353407cff977110895fa0b4f7d8 58 | with: 59 | go-version: ${{ matrix.go-version }} 60 | - name: Install Mage 61 | run: go get github.com/magefile/mage@07afc7d24f4d6d6442305d49552f04fbda5ccb3e 62 | - name: Checkout code 63 | uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f 64 | - name: Test 65 | run: | 66 | mage -v integration 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linters: 3 | enable-all: true 4 | disable: 5 | - gochecknoglobals 6 | - gochecknoinits 7 | - lll 8 | - wsl 9 | - gomnd 10 | - gofumpt 11 | - paralleltest 12 | - exhaustivestruct 13 | - testpackage 14 | - forbidigo 15 | 16 | issues: 17 | max-issues-per-linter: 0 18 | max-same-issues: 0 19 | exclude-rules: 20 | - path: _test\.go 21 | linters: 22 | - funlen 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Patrick Ogenstad 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | netrasp 2 | ======= 3 | 4 | Netrasp is a package that communicates to network devices over SSH. It takes 5 | care of handling the pty terminal of network devices giving you an API with 6 | common actions such as executing commands and configuring devices. 7 | 8 | Warning 9 | ------- 10 | Netrasp is in pre release mode so some parts of the API might change before 11 | the initial version is released. 12 | 13 | Example 14 | ------- 15 | 16 | ```go 17 | package main 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "log" 23 | "time" 24 | 25 | "github.com/networklore/netrasp/pkg/netrasp" 26 | ) 27 | 28 | func main() { 29 | device, err := netrasp.New("switch1", 30 | netrasp.WithUsernamePassword("my_user", "my_password123"), 31 | netrasp.WithDriver("ios"), 32 | ) 33 | if err != nil { 34 | log.Fatalf("unable to create client: %v", err) 35 | } 36 | 37 | ctx, cancelOpen := context.WithTimeout(context.Background(), 2000*time.Millisecond) 38 | defer cancelOpen() 39 | err = device.Dial(ctx) 40 | if err != nil { 41 | fmt.Printf("unable to connect: %v\n", err) 42 | 43 | return 44 | } 45 | defer device.Close(context.Background()) 46 | 47 | ctx, cancelRun := context.WithTimeout(context.Background(), 300*time.Millisecond) 48 | defer cancelRun() 49 | output, err := device.Run(ctx, "show running") 50 | if err != nil { 51 | fmt.Printf("unable to run command: %v\n", err) 52 | 53 | return 54 | } 55 | fmt.Println(output) 56 | } 57 | ``` 58 | 59 | Network Device Support 60 | ---------------------- 61 | The initial release of Netrasp comes with support for the following platforms: 62 | 63 | * Cisco IOS: netrasp.WithDriver("ios") 64 | * Cisco NXOS: netrasp.WithDriver("nxos") 65 | * Cisco ASA: netrasp.WithDriver("asa") 66 | * Nokia SR OS: netrasp.WithDriver("sros") 67 | 68 | Use cases 69 | --------- 70 | 71 | You can use Netrasp as a package as in the example above of combine it with 72 | something like [Gornir](https://github.com/nornir-automation/gornir) to get 73 | the same type of experience you'd have from using [Netmiko](https://github.com/ktbyers/netmiko) 74 | and [Nornir](https://github.com/nornir-automation/nornir) in the Python world. 75 | 76 | Blog Posts 77 | ---------- 78 | 79 | * [Hello Netrasp](https://networklore.com/hello-netrasp/) 80 | 81 | Credits 82 | ------- 83 | 84 | Netrasp was created by [Patrick Ogenstad](https://github.com/ogenstad). Special 85 | thanks to [David Barroso](https://github.com/dbarrosop) for providing feedback 86 | and recommendations of the structure and code. 87 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Netrasp documentation 2 | package netrasp 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/networklore/netrasp 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/magefile/mage v1.11.0 7 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= 2 | github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 3 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 4 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k= 5 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 6 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= 7 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 8 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 9 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= 10 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 11 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM= 12 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 13 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 14 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 15 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | //+build mage 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "github.com/magefile/mage/sh" 8 | "os" 9 | ) 10 | 11 | // Run Integrationtests 12 | func Integration() error { 13 | params := []string{ 14 | "test", 15 | "-v", 16 | "./...", 17 | "-p", 18 | "1", 19 | "-coverprofile=cover.out", 20 | "-coverprofile=coverage.txt", 21 | "-covermode=atomic", 22 | } 23 | return sh.RunV("go", params...) 24 | } 25 | 26 | // Run Unittests 27 | func Test() error { 28 | params := []string{ 29 | "test", 30 | "-v", 31 | "./...", 32 | "-short", 33 | "-coverprofile=cover.out", 34 | "-coverprofile=coverage.txt", 35 | "-covermode=atomic", 36 | } 37 | return sh.RunV("go", params...) 38 | } 39 | 40 | // Run linting checks 41 | func Lint() error { 42 | cwd, err := os.Getwd() 43 | if err != nil { 44 | return err 45 | } 46 | params := []string{ 47 | "run", 48 | "--rm", 49 | "-v", 50 | fmt.Sprintf("%s:/go/src/netrasp", cwd), 51 | "-w", 52 | "/go/src/netrasp", 53 | "-e", 54 | "GO111MODULE=on", 55 | "-e", 56 | "GOPROXY=https://proxy.golang.org", 57 | "golangci/golangci-lint:v1.36", 58 | "golangci-lint", 59 | "run", 60 | "--timeout", 61 | "300s", 62 | } 63 | return sh.RunV("docker", params...) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/netrasp/asa.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Asa is the Netrasp driver for Cisco ASA. 8 | type asa struct { 9 | ios 10 | } 11 | 12 | // Dial opens a connection to a device. 13 | func (a asa) Dial(ctx context.Context) error { 14 | return establishConnection(ctx, a, a.Connection, a.basePrompt(), []string{"no terminal pager"}) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/netrasp/asa_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestFakeASA(t *testing.T) { 9 | device, err := New("test1", 10 | WithUsernamePassword("username", "password"), 11 | WithInsecureIgnoreHostKey(), 12 | WithDriver("asa"), 13 | withFakeFileConnection("testdata/asa/basic"), 14 | ) 15 | if err != nil { 16 | t.Fatalf("unable to create device: %v", err) 17 | } 18 | 19 | err = device.Dial(context.Background()) 20 | if err != nil { 21 | t.Fatalf("could not establish connection. Error: '%v'", err) 22 | } 23 | defer device.Close(context.Background()) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/netrasp/config.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | // ConfigResult is returned by a Configure operation and contains output and results. 4 | type ConfigResult struct { 5 | ConfigCommands []ConfigCommand 6 | } 7 | 8 | // ConfigCommand contains a configuration command together with the output from that command. 9 | type ConfigCommand struct { 10 | // The command that was sent to the device 11 | Command string 12 | // The output seen after entering the command 13 | Output string 14 | } 15 | -------------------------------------------------------------------------------- /pkg/netrasp/connection.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | // connection defines an interface for Netrasp connections. 9 | type connection interface { 10 | Dial(context.Context) error 11 | Close(context.Context) error 12 | Send(context.Context, string) error 13 | Recv(context.Context) io.Reader 14 | GetHost() *host 15 | } 16 | 17 | // host defines host specific information. 18 | type host struct { 19 | Address string 20 | Port int 21 | Platform Platform 22 | password string 23 | } 24 | -------------------------------------------------------------------------------- /pkg/netrasp/fileconnection_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "strings" 9 | ) 10 | 11 | // fileConnection is a fake connection that reads input from files. 12 | type fileConnection struct { 13 | command string 14 | directory string 15 | host *host 16 | } 17 | 18 | // Dial opens an SSH connection. 19 | func (f *fileConnection) Dial(ctx context.Context) error { 20 | return nil 21 | } 22 | 23 | // GetHost returns information about the connected host. 24 | func (f *fileConnection) GetHost() *host { 25 | return f.host 26 | } 27 | 28 | // Close disconnects from the device. 29 | func (f *fileConnection) Close(ctx context.Context) error { 30 | return nil 31 | } 32 | 33 | // Send is used to write commands to the device. 34 | func (f *fileConnection) Send(ctx context.Context, command string) error { 35 | f.command = command 36 | 37 | return nil 38 | } 39 | 40 | // Recv is used to read data from the device. 41 | func (f *fileConnection) Recv(ctx context.Context) io.Reader { 42 | command := strings.ReplaceAll(f.command, " ", "_") 43 | content, err := ioutil.ReadFile(f.directory + "/" + command + ".txt") 44 | if err != nil { 45 | log.Fatalf("unable load output from command: %v", err) 46 | } 47 | output := string(content) 48 | output = strings.TrimRight(output, "\n") 49 | 50 | return newContextReader(ctx, strings.NewReader(output)) 51 | } 52 | 53 | func withFakeFileConnection(directory string) ConfigOpt { 54 | return newFuncConfigOpt(func(c *config) { 55 | c.Connection = &fileConnection{directory: directory, command: "initial", host: c.Host} 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/netrasp/helpers.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | func establishConnection(ctx context.Context, p Platform, c connection, prompt *regexp.Regexp, preparationCommands []string) error { 10 | err := c.Dial(ctx) 11 | if err != nil { 12 | return fmt.Errorf("unable to open connection: %w", err) 13 | } 14 | 15 | reader := c.Recv(ctx) 16 | // Make sure that we find the initial prompt to clear the buffer before we continue 17 | _, err = readUntilPrompt(ctx, reader, prompt) 18 | if err != nil { 19 | return fmt.Errorf("unable to find the initial prompt: %w", err) 20 | } 21 | 22 | for _, command := range preparationCommands { 23 | output, err := p.Run(ctx, command) 24 | if err != nil { 25 | return fmt.Errorf("unable to prepare session using the command '%s': %w", output, err) 26 | } 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/netrasp/ios.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var generalPrompt = regexp.MustCompile(`(^[a-zA-Z0-9_-]+[#|>])|(^[a-zA-Z0-9_-]+\([a-z-]+\)#)`) 11 | var enablePrompt = regexp.MustCompile(`^[Pp]assword:`) 12 | 13 | // Ios is the Netrasp driver for Cisco IOS. 14 | type ios struct { 15 | Connection connection 16 | } 17 | 18 | // Close connection to device. 19 | func (i ios) Close(ctx context.Context) error { 20 | i.Connection.Close(ctx) 21 | 22 | return nil 23 | } 24 | 25 | // Configure device. 26 | func (i ios) Configure(ctx context.Context, commands []string) (ConfigResult, error) { 27 | var result ConfigResult 28 | _, err := i.Run(ctx, "configure terminal") 29 | if err != nil { 30 | return result, fmt.Errorf("unable to enter config mode: %w", err) 31 | } 32 | for _, command := range commands { 33 | output, err := i.Run(ctx, command) 34 | configCommand := ConfigCommand{Command: command, Output: output} 35 | result.ConfigCommands = append(result.ConfigCommands, configCommand) 36 | if err != nil { 37 | return result, fmt.Errorf("unable to run command '%s': %w", command, err) 38 | } 39 | } 40 | _, err = i.Run(ctx, "end") 41 | if err != nil { 42 | return result, fmt.Errorf("unable to exit from config mode: %w", err) 43 | } 44 | 45 | return result, nil 46 | } 47 | 48 | // Dial opens a connection to a device. 49 | func (i ios) Dial(ctx context.Context) error { 50 | commands := []string{"terminal length 0", "terminal width 511"} 51 | 52 | return establishConnection(ctx, i, i.Connection, i.basePrompt(), commands) 53 | } 54 | 55 | // Enable elevates privileges. 56 | func (i ios) Enable(ctx context.Context) error { 57 | _, err := i.RunUntil(ctx, "enable", enablePrompt) 58 | if err != nil { 59 | return err 60 | } 61 | host := i.Connection.GetHost() 62 | _, err = i.Run(ctx, host.password) 63 | 64 | if err != nil { 65 | return err 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // Run executes a command on a device. 72 | func (i ios) Run(ctx context.Context, command string) (string, error) { 73 | output, err := i.RunUntil(ctx, command, i.basePrompt()) 74 | if err != nil { 75 | return "", err 76 | } 77 | 78 | output = strings.ReplaceAll(output, "\r\n", "\n") 79 | lines := strings.Split(output, "\n") 80 | result := "" 81 | 82 | for i := 1; i < len(lines)-1; i++ { 83 | result += lines[i] + "\n" 84 | } 85 | 86 | return result, nil 87 | } 88 | 89 | // RunUntil executes a command and reads until the provided prompt. 90 | func (i ios) RunUntil(ctx context.Context, command string, prompt *regexp.Regexp) (string, error) { 91 | err := i.Connection.Send(ctx, command) 92 | if err != nil { 93 | return "", fmt.Errorf("unable to send command to device: %w", err) 94 | } 95 | 96 | reader := i.Connection.Recv(ctx) 97 | output, err := readUntilPrompt(ctx, reader, prompt) 98 | if err != nil { 99 | return "", err 100 | } 101 | 102 | return output, nil 103 | } 104 | 105 | func (i ios) basePrompt() *regexp.Regexp { 106 | return generalPrompt 107 | } 108 | -------------------------------------------------------------------------------- /pkg/netrasp/ios_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestFakeIOSRunCommand(t *testing.T) { 10 | device, err := New("test1", 11 | WithUsernamePassword("username", "password"), 12 | WithInsecureIgnoreHostKey(), 13 | WithDriver("ios"), 14 | withFakeFileConnection("testdata/ios/basic"), 15 | ) 16 | if err != nil { 17 | t.Fatalf("unable to create device: %v", err) 18 | } 19 | defer device.Close(context.Background()) 20 | 21 | err = device.Dial(context.Background()) 22 | if err != nil { 23 | t.Fatalf("could not establish connection. Error: '%v'", err) 24 | } 25 | output, err := device.Run(context.Background(), "show version") 26 | if err != nil { 27 | t.Fatalf("unable to run command. Error: %v", err) 28 | } 29 | want := "Cisco IOS XE Software, Version 16.09.03" 30 | if !strings.Contains(output, want) { 31 | t.Fatalf("expected to find '%s' in '%s'", want, output) 32 | } 33 | } 34 | 35 | func TestFakeIOSEnableAndConfigureCommand(t *testing.T) { 36 | device, err := New("test1", 37 | WithUsernamePassword("username", "password"), 38 | WithInsecureIgnoreHostKey(), 39 | WithDriver("ios"), 40 | withFakeFileConnection("testdata/ios/enable_with_config"), 41 | ) 42 | if err != nil { 43 | t.Fatalf("unable to create device: %v", err) 44 | } 45 | defer device.Close(context.Background()) 46 | 47 | err = device.Dial(context.Background()) 48 | if err != nil { 49 | t.Fatalf("could not establish connection. Error: '%v'", err) 50 | } 51 | 52 | err = device.Enable(context.Background()) 53 | if err != nil { 54 | t.Fatalf("could not establish connection. Error: '%v'", err) 55 | } 56 | 57 | config := []string{ 58 | "ip access-list extended netrasp-test", 59 | "remark running some tests", 60 | "permit ip any any", 61 | } 62 | 63 | _, err = device.Configure(context.Background(), config) 64 | 65 | if err != nil { 66 | t.Fatalf("could not send config. Error: '%v'", err) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/netrasp/knownhosts.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "golang.org/x/crypto/ssh" 8 | "golang.org/x/crypto/ssh/knownhosts" 9 | ) 10 | 11 | func defaultKnownHosts() []string { 12 | hostFiles := []string{"/etc/ssh/ssh_known_hosts"} 13 | userHome, err := os.UserHomeDir() 14 | if err == nil { 15 | hostFiles = append(hostFiles, fmt.Sprintf("%s/.ssh/known_hosts", userHome)) 16 | } 17 | 18 | return hostFiles 19 | } 20 | 21 | // KnownHosts loads ssh known_hosts from default locations. 22 | func knownHosts(hostFiles []string) (ssh.HostKeyCallback, error) { 23 | var existingFiles []string 24 | for _, hostFile := range hostFiles { 25 | _, err := os.Stat(hostFile) 26 | if err == nil { 27 | existingFiles = append(existingFiles, hostFile) 28 | } 29 | } 30 | 31 | callback, err := knownhosts.New(existingFiles...) 32 | if err != nil { 33 | return nil, fmt.Errorf("unable to parse known_hosts files: %w", err) 34 | } 35 | 36 | return callback, nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/netrasp/knownhosts_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestInvalidKnownHostsFiles(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | file []string 11 | }{ 12 | { 13 | name: "invalid_file", 14 | file: []string{"testdata/invalid_known_hosts"}, 15 | }, 16 | } 17 | 18 | for _, tc := range cases { 19 | tc := tc 20 | t.Run(tc.name, func(t *testing.T) { 21 | _, err := knownHosts(tc.file) 22 | if err == nil { 23 | t.Fatalf("parsing invalid file '%s' was expected to fail but didn't", tc.file) 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/netrasp/netrasp.go: -------------------------------------------------------------------------------- 1 | // Package netrasp provides an easy way to communicate with network devices 2 | // 3 | // Using an SSH connection it lets you send commands and configure devices 4 | // that only supports screen scraping. 5 | package netrasp 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "time" 11 | 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | var errUserNotSpecified = errors.New("username is not specified") 16 | 17 | // Config contains the information needed to connect to a device. 18 | type config struct { 19 | Platform Platform 20 | Connection connection 21 | SSHConfig *ssh.ClientConfig 22 | Host *host 23 | driver string 24 | } 25 | 26 | // New creates a new SSH connection to the device. 27 | func New(hostname string, opts ...ConfigOpt) (Platform, error) { 28 | config := &config{ 29 | SSHConfig: &ssh.ClientConfig{}, 30 | Host: &host{ 31 | Address: hostname, 32 | Port: 22, 33 | password: "", 34 | }, 35 | driver: "", 36 | } 37 | config.SSHConfig.SetDefaults() 38 | for _, opt := range opts { 39 | opt.apply(config) 40 | } 41 | if len(config.SSHConfig.User) == 0 { 42 | return nil, errUserNotSpecified 43 | } 44 | 45 | if config.SSHConfig.HostKeyCallback == nil { 46 | hostKeyCallback, err := knownHosts(defaultKnownHosts()) 47 | if err != nil { 48 | return nil, err 49 | } 50 | config.SSHConfig.HostKeyCallback = hostKeyCallback 51 | } 52 | 53 | if config.Connection == nil { 54 | config.Connection = &sshConnection{Config: config.SSHConfig, Host: config.Host} 55 | } 56 | 57 | if config.Platform == nil { 58 | device, err := initDevice(config.driver, config.Connection) 59 | if err != nil { 60 | return nil, fmt.Errorf("unable to use selected platform: %w", err) 61 | } 62 | 63 | return device, nil 64 | } 65 | 66 | return config.Platform, nil 67 | } 68 | 69 | // ConfigOpt configures a Netrasp connection and platform. 70 | type ConfigOpt interface { 71 | apply(*config) 72 | } 73 | 74 | type funcConfigOpt struct { 75 | f func(*config) 76 | } 77 | 78 | func (fco *funcConfigOpt) apply(c *config) { 79 | fco.f(c) 80 | } 81 | 82 | func newFuncConfigOpt(f func(*config)) *funcConfigOpt { 83 | return &funcConfigOpt{ 84 | f: f, 85 | } 86 | } 87 | 88 | func WithUsernamePassword(username string, password string) ConfigOpt { 89 | return newFuncConfigOpt(func(c *config) { 90 | c.SSHConfig.User = username 91 | c.SSHConfig.Auth = []ssh.AuthMethod{ 92 | ssh.Password(password), 93 | } 94 | c.Host.password = password 95 | }) 96 | } 97 | 98 | // WithSSHPort allows you to specify an alternate SSH port, defaults to 22. 99 | func WithSSHPort(port int) ConfigOpt { 100 | return newFuncConfigOpt(func(c *config) { 101 | c.Host.Port = port 102 | }) 103 | } 104 | 105 | // WithInsecureIgnoreHostKey allows you to ignore the validation of the public 106 | // SSH key of a device against a reference in a known_hosts file. Using this 107 | // option should be considered a security risk. 108 | func WithInsecureIgnoreHostKey() ConfigOpt { 109 | return newFuncConfigOpt(func(c *config) { 110 | c.SSHConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey() // nolint: gosec 111 | }) 112 | } 113 | 114 | // WithDriver tells Netrasp which network driver to use for the connection 115 | // for example "asa", "ios", "nxos". 116 | func WithDriver(name string) ConfigOpt { 117 | return newFuncConfigOpt(func(c *config) { 118 | c.driver = name 119 | }) 120 | } 121 | 122 | // WithSSHCipher allows you to configure additional SSH Ciphers that the connection 123 | // will use. The parameter can be useful if your device doesn't support the default 124 | // ciphers. 125 | func WithSSHCipher(name string) ConfigOpt { 126 | return newFuncConfigOpt(func(c *config) { 127 | c.SSHConfig.Ciphers = append(c.SSHConfig.Ciphers, name) 128 | }) 129 | } 130 | 131 | // WithSSHKeyExchange allows you to configure additional SSH key exchange algorithms. 132 | // The parameter can be useful if your device doesn't support the default 133 | // algorithms. 134 | func WithSSHKeyExchange(name string) ConfigOpt { 135 | return newFuncConfigOpt(func(c *config) { 136 | c.SSHConfig.KeyExchanges = append(c.SSHConfig.KeyExchanges, name) 137 | }) 138 | } 139 | 140 | // WithDialTimeout allows you to configure timeout for dialing SSH server. 141 | func WithDialTimeout(t time.Duration) ConfigOpt { 142 | return newFuncConfigOpt(func(c *config) { 143 | c.SSHConfig.Timeout = t 144 | }) 145 | } 146 | -------------------------------------------------------------------------------- /pkg/netrasp/netrasp_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestFailWithNoUser(t *testing.T) { 11 | _, err := New("testdevice", WithDriver("ios")) 12 | if errors.Is(err, errUserNotSpecified) != true { 13 | t.Fatalf("expencted to encounter error '%v', actual error '%v'", errUserNotSpecified, err) 14 | } 15 | } 16 | 17 | func TestConfigOptions(t *testing.T) { 18 | _, err := New("testdevice", 19 | WithDriver("asa"), 20 | WithUsernamePassword("admin", "password"), 21 | WithInsecureIgnoreHostKey(), 22 | WithSSHPort(2222), 23 | WithSSHCipher("aes128-cbc"), 24 | ) 25 | if err != nil { 26 | t.Fatalf("expected device to be initialized: %v", err) 27 | } 28 | } 29 | 30 | func TestNxosTimingRun(t *testing.T) { 31 | if testing.Short() { 32 | t.Skip("skipping nxos integration test in short mode") 33 | } 34 | connection := connect(t, "sbx-nxos-mgmt.cisco.com", "nxos", "admin", "Admin_1234!") 35 | defer connection.Close(context.Background()) 36 | 37 | cases := []struct { 38 | name string 39 | command string 40 | want string 41 | }{ 42 | { 43 | name: "show_version", 44 | command: "show version", 45 | want: "Cisco Nexus Operating System (NX-OS) Software", 46 | }, 47 | { 48 | name: "show_inventory", 49 | command: "show inventory", 50 | want: "Nexus 9000v", 51 | }, 52 | } 53 | err := connection.Enable(context.Background()) 54 | if err != nil { 55 | t.Fatalf("unable to run enable") 56 | } 57 | 58 | for _, tc := range cases { 59 | tc := tc 60 | t.Run(tc.name, func(t *testing.T) { 61 | result, err := connection.Run(context.Background(), tc.command) 62 | if err != nil { 63 | t.Fatalf("unable to run command '%s'. Error: %v", tc.command, err) 64 | } 65 | 66 | if !strings.Contains(result, tc.want) { 67 | t.Fatalf("expected to find '%s' in '%s'", tc.want, result) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func TestIosEnable(t *testing.T) { 74 | if testing.Short() { 75 | t.Skip("skipping ios integration test in short mode") 76 | } 77 | connection := connect(t, "ios-xe-mgmt.cisco.com", "ios", "developer", "C1sco12345") 78 | defer connection.Close(context.Background()) 79 | 80 | _, err := connection.Run(context.Background(), "disable") 81 | if err != nil { 82 | t.Fatalf("could not send disable command. Error: '%v'", err) 83 | } 84 | err = connection.Enable(context.Background()) 85 | if err != nil { 86 | t.Fatalf("cound not enter enable mode. Error: '%v'", err) 87 | } 88 | } 89 | 90 | func TestIosConfigure(t *testing.T) { 91 | if testing.Short() { 92 | t.Skip("skipping ios integration test in short mode") 93 | } 94 | connection := connect(t, "ios-xe-mgmt.cisco.com", "ios", "developer", "C1sco12345") 95 | defer connection.Close(context.Background()) 96 | 97 | config := []string{ 98 | "ip access-list extended netrasp-test", 99 | "remark running some tests", 100 | "permit ip any any", 101 | } 102 | 103 | _, err := connection.Configure(context.Background(), config) 104 | 105 | if err != nil { 106 | t.Fatalf("could not send config. Error: '%v'", err) 107 | } 108 | 109 | result, err := connection.Run(context.Background(), "show ip access-list netrasp-test") 110 | if err != nil { 111 | t.Fatalf("could run command. Error: '%v'", err) 112 | } 113 | 114 | if want := "permit ip any any"; !strings.Contains(result, want) { 115 | t.Fatalf("expected to find '%s' in '%s'", want, result) 116 | } 117 | 118 | _, err = connection.Configure(context.Background(), []string{"no ip access-list extended netrasp-test"}) 119 | if err != nil { 120 | t.Fatalf("unable to remove config. Error: '%v'", err) 121 | } 122 | } 123 | 124 | func TestIosTimingRun(t *testing.T) { 125 | if testing.Short() { 126 | t.Skip("skipping ios integration test in short mode") 127 | } 128 | connection := connect(t, "ios-xe-mgmt.cisco.com", "ios", "developer", "C1sco12345") 129 | defer connection.Close(context.Background()) 130 | 131 | cases := []struct { 132 | name string 133 | command string 134 | want string 135 | }{ 136 | { 137 | name: "show_version", 138 | command: "show version", 139 | want: "Cisco IOS Software", 140 | }, 141 | { 142 | name: "show_inventory", 143 | command: "show inventory", 144 | want: "CSR1000V", 145 | }, 146 | } 147 | 148 | for _, tc := range cases { 149 | tc := tc 150 | t.Run(tc.name, func(t *testing.T) { 151 | result, err := connection.Run(context.Background(), tc.command) 152 | if err != nil { 153 | t.Fatalf("unable to run command '%s'. Error: %v", tc.command, err) 154 | } 155 | 156 | if !strings.Contains(result, tc.want) { 157 | t.Fatalf("expected to find '%s' in '%s'", tc.want, result) 158 | } 159 | }) 160 | } 161 | } 162 | 163 | func connect(t *testing.T, host string, platform string, username string, password string) Platform { 164 | t.Helper() 165 | 166 | device, err := New(host, 167 | WithUsernamePassword(username, password), 168 | WithDriver(platform), 169 | WithInsecureIgnoreHostKey(), 170 | WithSSHPort(8181), 171 | ) 172 | if err != nil { 173 | t.Fatalf("unable to create device. Error: '%v':", err) 174 | } 175 | 176 | err = device.Dial(context.Background()) 177 | if err != nil { 178 | t.Fatalf("could not establish connection. Error: '%v'", err) 179 | } 180 | 181 | return device 182 | } 183 | -------------------------------------------------------------------------------- /pkg/netrasp/nxos.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Nxos is the Netrasp driver for Cisco NXOS. 8 | type nxos struct { 9 | ios 10 | } 11 | 12 | // Enable just silently works on Nxos. 13 | func (n nxos) Enable(ctx context.Context) error { 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /pkg/netrasp/nxos_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestFakeNxos(t *testing.T) { 9 | device, err := New("test1", 10 | WithUsernamePassword("username", "password"), 11 | WithInsecureIgnoreHostKey(), 12 | WithDriver("nxos"), 13 | withFakeFileConnection("testdata/nxos/basic"), 14 | ) 15 | if err != nil { 16 | t.Fatalf("unable to create device: %v", err) 17 | } 18 | 19 | err = device.Dial(context.Background()) 20 | if err != nil { 21 | t.Fatalf("could not establish connection. Error: '%v'", err) 22 | } 23 | 24 | err = device.Enable(context.Background()) 25 | if err != nil { 26 | t.Fatalf("enable should always work on nxos. Error: '%v'", err) 27 | } 28 | 29 | defer device.Close(context.Background()) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/netrasp/platform.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "regexp" 7 | ) 8 | 9 | var errInvalidPlatformError = errors.New("invalid platform selected") 10 | 11 | // Platform defines an interface for network drivers. 12 | type Platform interface { 13 | // Disconnect from a device 14 | Close(context.Context) error 15 | // Configure device with the provided commands 16 | Configure(context.Context, []string) (ConfigResult, error) 17 | // Open a connection to a device 18 | Dial(context.Context) error 19 | // Elevate privileges on device 20 | Enable(context.Context) error 21 | // Run a command against a device 22 | Run(context.Context, string) (string, error) 23 | // Run a command against a device and search for a specific prompt 24 | RunUntil(context.Context, string, *regexp.Regexp) (string, error) 25 | } 26 | 27 | // initDevice returns a platform / network driver. 28 | func initDevice(platform string, conn connection) (Platform, error) { 29 | switch platform { 30 | case "asa": 31 | driver := &asa{} 32 | driver.Connection = conn 33 | 34 | return driver, nil 35 | case "nxos": 36 | driver := &nxos{} 37 | driver.Connection = conn 38 | 39 | return driver, nil 40 | case "ios": 41 | driver := &ios{} 42 | driver.Connection = conn 43 | 44 | return driver, nil 45 | case "sros": 46 | driver := &sros{} 47 | driver.Connection = conn 48 | 49 | return driver, nil 50 | } 51 | 52 | return nil, errInvalidPlatformError 53 | } 54 | -------------------------------------------------------------------------------- /pkg/netrasp/platform_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewDevice(t *testing.T) { 8 | cases := []struct { 9 | name string 10 | platform string 11 | succeed bool 12 | }{ 13 | { 14 | name: "cisco_asa", 15 | platform: "asa", 16 | succeed: true, 17 | }, 18 | { 19 | name: "cisco_ios", 20 | platform: "ios", 21 | succeed: true, 22 | }, 23 | { 24 | name: "cisco_nxos", 25 | platform: "nxos", 26 | succeed: true, 27 | }, 28 | { 29 | name: "driver_missing", 30 | platform: "", 31 | succeed: false, 32 | }, 33 | { 34 | name: "bogus_driver", 35 | platform: "doesn_not_exist", 36 | succeed: false, 37 | }, 38 | } 39 | 40 | for _, tc := range cases { 41 | tc := tc 42 | t.Run(tc.name, func(t *testing.T) { 43 | _, err := New("device1", WithUsernamePassword("admin", "password"), WithDriver(tc.platform)) 44 | status := err == nil 45 | if status != tc.succeed { 46 | t.Fatalf("platform '%s' was expected to return '%v', actual result '%v'", tc.platform, tc.succeed, status) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/netrasp/reader.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "regexp" 10 | ) 11 | 12 | var errRead = errors.New("reader error") 13 | 14 | var minReadSize = 10000 15 | var maxReadSize = 10 * 1000 * 1000 16 | 17 | type contextReader struct { 18 | ctx context.Context 19 | r io.Reader 20 | } 21 | 22 | type readResult struct { 23 | n int 24 | err error 25 | } 26 | 27 | func (c *contextReader) Read(p []byte) (n int, err error) { 28 | ctx, cancel := context.WithCancel(c.ctx) 29 | defer cancel() 30 | rrCh := make(chan *readResult) 31 | 32 | go func() { 33 | select { 34 | case rrCh <- c.read(p): 35 | case <-ctx.Done(): 36 | } 37 | }() 38 | 39 | select { 40 | case <-ctx.Done(): 41 | return 0, ctx.Err() 42 | case rr := <-rrCh: 43 | return rr.n, rr.err 44 | } 45 | } 46 | 47 | func (c *contextReader) read(p []byte) *readResult { 48 | n, err := c.r.Read(p) 49 | 50 | return &readResult{n, err} 51 | } 52 | 53 | func newContextReader(ctx context.Context, r io.Reader) io.Reader { 54 | return &contextReader{ 55 | ctx: ctx, 56 | r: r, 57 | } 58 | } 59 | 60 | // readUntilPrompt reads until the specified prompt is found and returns the read data. 61 | func readUntilPrompt(ctx context.Context, r io.Reader, prompt *regexp.Regexp) (string, error) { 62 | var output = new(bytes.Buffer) 63 | rc := newContextReader(ctx, r) 64 | readSize := minReadSize 65 | for { 66 | b := make([]byte, readSize) 67 | 68 | n, err := rc.Read(b) 69 | if err != nil { 70 | return "", fmt.Errorf("error reading output from device %w: %v", errRead, err) 71 | } 72 | if readSize == n && readSize < maxReadSize { 73 | readSize *= 2 74 | } 75 | output.Write(b[:n]) 76 | tempSlice := bytes.ReplaceAll(output.Bytes(), []byte("\r\n"), []byte("\n")) 77 | tempSlice = bytes.ReplaceAll(tempSlice, []byte("\r"), []byte("\n")) 78 | if prompt.Match(tempSlice[bytes.LastIndex(tempSlice, []byte("\n"))+1:]) { 79 | break 80 | } 81 | } 82 | 83 | return output.String(), nil 84 | } 85 | -------------------------------------------------------------------------------- /pkg/netrasp/reader_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var testReaderBasic = ` 11 | here is your string 12 | router1#` 13 | 14 | var ns string 15 | 16 | func TestReader(t *testing.T) { 17 | r := strings.NewReader(testReaderBasic) 18 | 19 | got, err := readUntilPrompt(context.Background(), r, generalPrompt) 20 | if err != nil { 21 | t.Fatalf("error reading until prompt: %v", err) 22 | } 23 | if got != testReaderBasic { 24 | t.Fatalf("expected '%s' got '%s'", testReaderBasic, got) 25 | } 26 | } 27 | 28 | func TestReaderTimeout(t *testing.T) { 29 | r := strings.NewReader(testReaderBasic) 30 | 31 | ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second) 32 | defer cancel() 33 | out, err := readUntilPrompt(ctx, r, generalPrompt) 34 | if err == nil { 35 | t.Fatalf("expected reader to timeout, but got %v", out) 36 | } 37 | } 38 | 39 | func generatePrompt(lines int) string { 40 | return strings.Repeat(strings.Repeat("dummy", 10)+"\r\r\n", lines-1) + "prompt#" 41 | } 42 | 43 | func runBenchmarkReaderBytesBufferNLines(b *testing.B, i int) { 44 | b.Helper() 45 | var s string 46 | var err error 47 | b.ReportAllocs() 48 | for n := 0; n < b.N; n++ { 49 | r := strings.NewReader(generatePrompt(i)) 50 | s, err = readUntilPrompt(context.Background(), r, generalPrompt) 51 | if err != nil { 52 | b.Fail() 53 | } 54 | } 55 | ns = s 56 | } 57 | 58 | // 100 lines. 59 | func BenchmarkReaderBytesBuffer100Lines(b *testing.B) { 60 | runBenchmarkReaderBytesBufferNLines(b, 100) 61 | } 62 | 63 | // 1K lines. 64 | func BenchmarkReaderBytesBuffer1KLines(b *testing.B) { 65 | runBenchmarkReaderBytesBufferNLines(b, 1000) 66 | } 67 | 68 | // 10K lines. 69 | func BenchmarkReaderBytesBuffer10KLines(b *testing.B) { 70 | runBenchmarkReaderBytesBufferNLines(b, 10000) 71 | } 72 | 73 | // 50k Lines. 74 | func BenchmarkReaderBytesBuffer50KLines(b *testing.B) { 75 | runBenchmarkReaderBytesBufferNLines(b, 50000) 76 | } 77 | 78 | // 100k Lines. 79 | func BenchmarkReaderBytesBuffer100KLines(b *testing.B) { 80 | runBenchmarkReaderBytesBufferNLines(b, 100000) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/netrasp/sros.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | // SROS is the Netrasp driver for Nokia SR OS (MD-CLI). 11 | type sros struct { 12 | Connection connection 13 | } 14 | 15 | // Close connection to device. 16 | func (s sros) Close(ctx context.Context) error { 17 | s.Connection.Close(ctx) 18 | 19 | return nil 20 | } 21 | 22 | // Configure device. 23 | func (s sros) Configure(ctx context.Context, commands []string) (ConfigResult, error) { 24 | var result ConfigResult 25 | 26 | _, err := s.Run(ctx, "edit-config exclusive") 27 | if err != nil { 28 | return result, fmt.Errorf("unable to enter exclusive edit mode: %w", err) 29 | } 30 | for _, command := range commands { 31 | output, err := s.Run(ctx, command) 32 | configCommand := ConfigCommand{Command: command, Output: output} 33 | result.ConfigCommands = append(result.ConfigCommands, configCommand) 34 | if err != nil { 35 | return result, fmt.Errorf("unable to run command '%s': %w", command, err) 36 | } 37 | } 38 | _, err = s.Run(ctx, "commit") 39 | if err != nil { 40 | return result, fmt.Errorf("unable to commit configuration: %w", err) 41 | } 42 | 43 | _, err = s.Run(ctx, "quit-config") 44 | if err != nil { 45 | return result, fmt.Errorf("unable to quit from configuration mode: %w", err) 46 | } 47 | 48 | return result, nil 49 | } 50 | 51 | // Dial opens a connection to a device. 52 | func (s sros) Dial(ctx context.Context) error { 53 | return establishConnection(ctx, s, s.Connection, s.basePrompt(), []string{"environment more false"}) 54 | } 55 | 56 | // Enable elevates privileges. 57 | func (s sros) Enable(ctx context.Context) error { 58 | return nil 59 | } 60 | 61 | // Run executes a command on a device. 62 | func (s sros) Run(ctx context.Context, command string) (string, error) { 63 | output, err := s.RunUntil(ctx, command, s.basePrompt()) 64 | if err != nil { 65 | return "", err 66 | } 67 | 68 | output = strings.ReplaceAll(output, "\r\n", "\n") 69 | lines := strings.Split(output, "\n") 70 | result := "" 71 | // len-2 to cut off the context piece of the prompt aka [/] 72 | for i := 1; i < len(lines)-2; i++ { 73 | // skip empty lines that sros adds for visual separation of diff commands 74 | // as we add it manually 75 | if (i == len(lines)-3) && (lines[i] == "") { 76 | continue 77 | } else { 78 | result += lines[i] + "\n" 79 | } 80 | } 81 | 82 | return result, nil 83 | } 84 | 85 | // RunUntil executes a command and reads until the provided prompt. 86 | func (s sros) RunUntil(ctx context.Context, command string, prompt *regexp.Regexp) (string, error) { 87 | err := s.Connection.Send(ctx, command) 88 | if err != nil { 89 | return "", fmt.Errorf("unable to send command to device: %w", err) 90 | } 91 | 92 | reader := s.Connection.Recv(ctx) 93 | output, err := readUntilPrompt(ctx, reader, prompt) 94 | if err != nil { 95 | return "", err 96 | } 97 | 98 | return output, nil 99 | } 100 | 101 | func (s sros) basePrompt() *regexp.Regexp { 102 | return regexp.MustCompile(`^[ABCD]:\S+@\S+#`) 103 | } 104 | -------------------------------------------------------------------------------- /pkg/netrasp/sros_test.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestFakeSROSRunCommand(t *testing.T) { 10 | device, err := New("test1", 11 | WithUsernamePassword("username", "password"), 12 | WithInsecureIgnoreHostKey(), 13 | WithDriver("sros"), 14 | withFakeFileConnection("testdata/sros/basic"), 15 | ) 16 | if err != nil { 17 | t.Fatalf("unable to create device: %v", err) 18 | } 19 | defer device.Close(context.Background()) 20 | 21 | err = device.Dial(context.Background()) 22 | if err != nil { 23 | t.Fatalf("could not establish connection. Error: '%v'", err) 24 | } 25 | output, err := device.Run(context.Background(), "show version") 26 | if err != nil { 27 | t.Fatalf("unable to run command. Error: %v", err) 28 | } 29 | want := "TiMOS-B-21.2.R1 both/x86_64 Nokia 7750 SR Copyright (c) 2000-2021 Nokia." 30 | if !strings.Contains(output, want) { 31 | t.Fatalf("expected to find '%s' in '%s'", want, output) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/netrasp/ssh.go: -------------------------------------------------------------------------------- 1 | package netrasp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | // sshConnection contains configuration and connection information for SSH. 12 | type sshConnection struct { 13 | Config *ssh.ClientConfig 14 | Host *host 15 | reader io.Reader 16 | writer io.Writer 17 | session *ssh.Session 18 | } 19 | 20 | // Dial opens an SSH connection. 21 | func (s *sshConnection) Dial(ctx context.Context) error { 22 | client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", s.Host.Address, s.Host.Port), s.Config) 23 | if err != nil { 24 | return fmt.Errorf("unable to establish connection: %w", err) 25 | } 26 | 27 | session, err := client.NewSession() 28 | if err != nil { 29 | return fmt.Errorf("unable to open new session: %w", err) 30 | } 31 | 32 | terminalMode := ssh.TerminalModes{ 33 | ssh.ECHO: 0, 34 | ssh.TTY_OP_ISPEED: 28800, 35 | ssh.TTY_OP_OSPEED: 28800, 36 | } 37 | err = session.RequestPty("xterm", 80, 40, terminalMode) 38 | if err != nil { 39 | return fmt.Errorf("error requesting pty terminal: %w", err) 40 | } 41 | 42 | s.reader, err = session.StdoutPipe() 43 | if err != nil { 44 | return fmt.Errorf("error requesting StdoutPipe: %w", err) 45 | } 46 | s.writer, err = session.StdinPipe() 47 | if err != nil { 48 | return fmt.Errorf("error requesting StdinPipe: %w", err) 49 | } 50 | 51 | err = session.Shell() 52 | if err != nil { 53 | return fmt.Errorf("failed to start shell: %w", err) 54 | } 55 | 56 | s.session = session 57 | 58 | return nil 59 | } 60 | 61 | // GetHost returns information about the connected host. 62 | func (s *sshConnection) GetHost() *host { 63 | return s.Host 64 | } 65 | 66 | // Close disconnects from the device. 67 | func (s *sshConnection) Close(ctx context.Context) error { 68 | s.session.Close() 69 | 70 | return nil 71 | } 72 | 73 | // Send is used to write commands to the device. 74 | func (s *sshConnection) Send(ctx context.Context, command string) error { 75 | _, err := s.writer.Write([]byte(command + "\n")) 76 | if err != nil { 77 | return fmt.Errorf("unable to send command to device: %w", err) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // Recv is used to read data from the device. 84 | func (s *sshConnection) Recv(ctx context.Context) io.Reader { 85 | return newContextReader(ctx, s.reader) 86 | } 87 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/asa/basic/initial.txt: -------------------------------------------------------------------------------- 1 | asa-firewall# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/asa/basic/no_terminal_pager.txt: -------------------------------------------------------------------------------- 1 | asa-firewall# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/invalid_known_hosts: -------------------------------------------------------------------------------- 1 | this is just wrong! 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/basic/initial.txt: -------------------------------------------------------------------------------- 1 | Welcome to the Dummy Device 2 | 3 | Thanks for stopping by. 4 | 5 | 6 | router1# 7 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/basic/show_version.txt: -------------------------------------------------------------------------------- 1 | 2 | Cisco IOS XE Software, Version 16.09.03 3 | Cisco IOS Software [Fuji], Virtual XE Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 16.9.3, RELEASE SOFTWARE (fc2) 4 | Technical Support: http://www.cisco.com/techsupport 5 | Copyright (c) 1986-2019 by Cisco Systems, Inc. 6 | Compiled Wed 20-Mar-19 07:56 by mcpre 7 | 8 | 9 | Cisco IOS-XE software, Copyright (c) 2005-2019 by cisco Systems, Inc. 10 | All rights reserved. Certain components of Cisco IOS-XE software are 11 | licensed under the GNU General Public License ("GPL") Version 2.0. The 12 | software code licensed under GPL Version 2.0 is free software that comes 13 | with ABSOLUTELY NO WARRANTY. You can redistribute and/or modify such 14 | GPL code under the terms of GPL Version 2.0. For more details, see the 15 | documentation or "License Notice" file accompanying the IOS-XE software, 16 | or the applicable URL provided on the flyer accompanying the IOS-XE 17 | software. 18 | 19 | 20 | ROM: IOS-XE ROMMON 21 | 22 | csr1000v uptime is 19 hours, 28 minutes 23 | Uptime for this control processor is 19 hours, 29 minutes 24 | System returned to ROM by reload 25 | System image file is "bootflash:packages.conf" 26 | Last reload reason: Reload Command 27 | 28 | 29 | 30 | This product contains cryptographic features and is subject to United 31 | States and local country laws governing import, export, transfer and 32 | use. Delivery of Cisco cryptographic products does not imply 33 | third-party authority to import, export, distribute or use encryption. 34 | Importers, exporters, distributors and users are responsible for 35 | compliance with U.S. and local country laws. By using this product you 36 | agree to comply with applicable laws and regulations. If you are unable 37 | to comply with U.S. and local laws, return this product immediately. 38 | 39 | A summary of U.S. laws governing Cisco cryptographic products may be found at: 40 | http://www.cisco.com/wwl/export/crypto/tool/stqrg.html 41 | 42 | If you require further assistance please contact us by sending email to 43 | export@cisco.com. 44 | 45 | License Level: ax 46 | License Type: Default. No valid license found. 47 | Next reload license Level: ax 48 | 49 | 50 | Smart Licensing Status: Smart Licensing is DISABLED 51 | 52 | cisco CSR1000V (VXE) processor (revision VXE) with 2392579K/3075K bytes of memory. 53 | Processor board ID 9AQ33OWS5WO 54 | 3 Gigabit Ethernet interfaces 55 | 32768K bytes of non-volatile configuration memory. 56 | 8113280K bytes of physical memory. 57 | 7774207K bytes of virtual hard disk at bootflash:. 58 | 0K bytes of WebUI ODM Files at webui:. 59 | 60 | Configuration register is 0x2102 61 | 62 | csr1000v# 63 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/basic/terminal_length_0.txt: -------------------------------------------------------------------------------- 1 | router1# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/basic/terminal_width_511.txt: -------------------------------------------------------------------------------- 1 | router1# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/configure_terminal.txt: -------------------------------------------------------------------------------- 1 | Enter configuration commands, one per line. End with CNTL/Z. 2 | router1(config)# 3 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/enable.txt: -------------------------------------------------------------------------------- 1 | 2 | Password: 3 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/end.txt: -------------------------------------------------------------------------------- 1 | router1# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/initial.txt: -------------------------------------------------------------------------------- 1 | router1> 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/ip_access-list_extended_netrasp-test.txt: -------------------------------------------------------------------------------- 1 | router1(config)# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/password.txt: -------------------------------------------------------------------------------- 1 | router1# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/permit_ip_any_any.txt: -------------------------------------------------------------------------------- 1 | router1(config)# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/remark_running_some_tests.txt: -------------------------------------------------------------------------------- 1 | router1(config)# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/terminal_length_0.txt: -------------------------------------------------------------------------------- 1 | 2 | router1> 3 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/ios/enable_with_config/terminal_width_511.txt: -------------------------------------------------------------------------------- 1 | router1# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/nxos/basic/initial.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome to my special router! 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | sr# 20 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/nxos/basic/terminal_length_0.txt: -------------------------------------------------------------------------------- 1 | sr# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/nxos/basic/terminal_width_511.txt: -------------------------------------------------------------------------------- 1 | sr# 2 | -------------------------------------------------------------------------------- /pkg/netrasp/testdata/sros/basic/environment_more_false.txt: -------------------------------------------------------------------------------- 1 | 2 | [/] 3 | A:admin@sr1# -------------------------------------------------------------------------------- /pkg/netrasp/testdata/sros/basic/initial.txt: -------------------------------------------------------------------------------- 1 | 2 | SR OS Software 3 | Copyright (c) Nokia 2021. All Rights Reserved. 4 | 5 | Trademarks 6 | 7 | Nokia and the Nokia logo are registered trademarks of Nokia. All other 8 | trademarks are the property of their respective owners. 9 | 10 | IMPORTANT: READ CAREFULLY 11 | 12 | The SR OS Software (the "Software") is proprietary to Nokia and is subject 13 | to and governed by the terms and conditions of the End User License 14 | Agreement accompanying the product, made available at the time of your order, 15 | or posted on the Nokia website (collectively, the "EULA"). As set forth 16 | more fully in the EULA, use of the Software is strictly limited to your 17 | internal use. Downloading, installing, or using the Software constitutes 18 | acceptance of the EULA and you are binding yourself and the business entity 19 | that you represent to the EULA. If you do not agree to all of the terms of 20 | the EULA, then Nokia is unwilling to license the Software to you and (a) you 21 | may not download, install or use the Software, and (b) you may return the 22 | Software as more fully set forth in the EULA. 23 | 24 | This product contains cryptographic features and is subject to United States 25 | and local country laws governing import, export, transfer and use. Delivery 26 | of Nokia cryptographic products does not imply third-party authority to 27 | import, export, distribute or use encryption. 28 | 29 | If you require further assistance please contact us by sending an email 30 | to support@nokia.com. 31 | 32 | 33 | [/] 34 | A:admin@sr1# -------------------------------------------------------------------------------- /pkg/netrasp/testdata/sros/basic/show_version.txt: -------------------------------------------------------------------------------- 1 | show version 2 | TiMOS-B-21.2.R1 both/x86_64 Nokia 7750 SR Copyright (c) 2000-2021 Nokia. 3 | All rights reserved. All use subject to applicable license agreements. 4 | Built on Thu Feb 25 15:50:28 PST 2021 by builder in /builds/c/212B/R1/panos/main/sros 5 | 6 | [/] 7 | A:admin@sr1# --------------------------------------------------------------------------------