Go Programming For Network Operations A Golang Network Automation Handbook
Go Programming For Network Operations A Golang Network Automation Handbook
Disclaimer
The information provided in this book is for educational purposes only. The
reader is responsible for his or her own actions. While all attempts have
been made to verify the information provided in this book, the author does
not assume any responsibility for errors, omissions, or contrary
interpretations of the subject matter contained within. The author does not
accept any responsibility for any liabilities or damages, real or perceived,
resulting from the use of this information.
All trademarks and brands within this book are for clarifying purposes only,
are owned by the owners themselves, and are not affiliated with this
document. The trademarks used are without any consent, and the publication
of the trademark is without permission or backing by the trademark owner.
Introduction
Go is the most exciting new mainstream programming language to appear in
recent decades. Like modern network operations, Go has taken aim at 21st
century infrastructures, particularly in the cloud. This book illustrates how to
apply Go programming to network operations. The topics cover common use
cases through examples that are designed to act as a guide and serve as a
reference.
The examples in this book are designed for e-book format. They are short
and concise, while maintaining enough content to run and illustrate the point.
While error handling is omitted for brevity, it is always recommended to
include it, along with other best practices, when developing production code.
First, this section looks at the fundamentals of opening and closing, and
adding and removing files. Then, it covers getting attributes of files, such as
size, permissions, ownership and modification date. Finally, the last two
sections demonstrate listing files and directories, both in the same directory
and recursively.
This chapter will cover the following file and directory topics:
Flag Description
O_RDONLY Open the file read-only.
O_WRONLY Open the file write-only.
O_RDWR Open the file read-write.
O_APPEND Append data to the file when writing.
O_CREATE Create a new file if none exists.
O_EXCL Used with O_CREATE. The file must not exist.
O_SYNC Open for Synchronous I/O.
O_TRUNC If possible, truncate the file when opened.
The example below demonstrates the more enhanced option by using the
os.OpenFile() function to return a pointer to a value of type os.File. The flag
combination creates a write-only file if none exists, otherwise, it appends to
the file. The os.File.Write() method is then called on the os.File object to
append a byte slice to the file. Finally, the os.File.Close() method is called
on the os.File object to render the I/O unusable.
package main
import "os"
func main() {
file, _ := os.OpenFile("file.txt",
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
file.Write([]byte("append data\n"))
file.Close()
}
Chapter 1.2: Add/Remove
This next example demonstrates adding a directory and file, then removing
them. The example starts out by declaring a directory path and file name.
Then, leveraging the directory path and setting permission bits, the
os.MkdirAll() function is used to create the directory named path. Once the
directory is created, the full directory path is built by calling the
filepath.Join() function together with the path name. This full path is then
used as a parameter in the os.Create() function to create the file and return an
os.File object. At this point, the os.File.Close() method is called to close the
file and then remove it with the os.Remove() function. Finally, the directory
is deleted using the os.RemoveAll() function, which removes the path and
any dependent children.
package main
import (
"os"
"path/filepath"
)
func main() {
dpath := "./dir"
fname := "file.txt"
_ = os.MkdirAll(dpath, 0777)
file, _ := os.Create(fpath)
file.Close()
_ = os.Remove(fpath)
_ = os.RemoveAll(dpath)
}
Chapter 1.3: Get Info
Getting information from files and directories is straightforward using the os
package. This example leverages the os.Stat() function to return an
os.FileInfo object that is then used to read various attributes that include file
name, size, permissions, last modified timestamp, and whether it is a
directory or not. The os.FileInfo object is then used on the respective
methods to retrieve the file and directory metadata.
package main
import (
"fmt"
"os"
)
func main() {
file, _ := os.Stat("main.go")
fl := fmt.Println
fl("File name: ", file.Name())
fl("Size in bytes: ", file.Size())
fl("Last modified: ", file.ModTime())
fl("Is a directory: ", file.IsDir())
ff := fmt.Printf
ff("Permission 9-bit: %s\n", file.Mode())
ff("Permission 3-digit: %o\n", file.Mode())
ff("Permission 4-digit: %04o\n",file.Mode())
}
There are three different permission representations in the output below that
take their form in the standard Unix 9-bit format, 3-digit octal, and 4-digit
octal format. These permission bits are formatted using the fmt package string
and base integer verbs. Also, take note that the os.FileInfo.ModTime()
method returns a time.Time object which is ideal for additional parsing and
formatting.
package main
import (
"fmt"
"io/ioutil"
)
func main() {
files, _ := ioutil.ReadDir(".")
Notice that the output below is intentionally sorted by file name. This is an
inherent feature of the ioutil.ReadDir() function.
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
scan := func(
path string, i os.FileInfo, _ error) error {
fmt.Println(i.IsDir(), path)
return nil
}
_ = filepath.Walk(".", scan)
}
The output below is intentionally listed in lexical order. This sorting is an
inherent feature of the filepath.Walk() function.
false main.go
true dir1
false dir1/file1.txt
false dir1/file2.txt
Chapter 2: Reading and Writing
File Types
Reading and writing different file types is a critical ingredient in many
programs. This chapter walks through examples that read and write the
common file types found in most environments; these include Plain Text,
CSV, YAML, JSON, and XML.
Plain text
CSV
YAML
JSON
XML
Chapter 2.1: Plain Text
Reading and writing plain text is typically done line-by-line or word-by-
word. The examples in the following subchapters will demonstrate reading
and writing lines of text using a native buffer.
Leveraging a buffer can be helpful when working with large files since it
stores data until a certain size is reached before committing it to IO; this
reduces the number of expensive system calls and other mechanics involved.
RTR1 1.1.1.1
RTR2 2.2.2.2
RTR3 3.3.3.3
To begin, the os.Open() function is used to open the above plain text file in
read-only mode and then return a pointer to a value of type os.File. Next, the
bufio.NewScanner() function is called with the os.File object as its
parameter which returns a bufio.Scanner object that is used on the
bufio.Scanner.Split() method.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, _ := os.Open("./file.txt")
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
file.Close()
RTR1 1.1.1.1
RTR2 2.2.2.2
RTR3 3.3.3.3
Chapter 2.1.2: Writing Plain Text
The bufio package provides an efficient buffered Writer that can be used to
write data to a file. A buffered writer queues up bytes until a threshold is
reached, then completes the write operation to minimize resources. This
example walks through writing a string slice to a plain text file line-by-line.
package main
import (
"bufio"
"os"
)
func main() {
lines := []string{"RTR1 1.1.1.1",
"RTR2 2.2.2.2",
"RTR3 3.3.3.3"}
file, _ := os.OpenFile("./file.txt",
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
writer := bufio.NewWriter(file)
for _, line := range lines {
_, _ = writer.WriteString(line + "\n")
}
writer.Flush()
file.Close()
}
Chapter 2.2: CSV
Comma Separated Values (CSV) is a format often used to import, export, and
store data. It is also commonly used to move tabular data between
incompatible formats.
The csv package supports the standard format described in RFC 4180. Each
CSV file contains zero or more rows with one or more fields per row that
adhere to the constraints identified below.
The CSV file below contains three columns, a header row with labels, and
two rows with data. This example will demonstrate how to read a CSV file
and print each of the corresponding cell values.
Below is the same CSV file as above viewed as plain text in a text editor.
HOST,IP ADDR,LOCATION
RTR1,1.1.1.1,"Seattle, WA"
RTR2,2.2.2.2,"Nevada, NV"
In this example, the file is opened in read-only mode using the os.Open()
function, which returns an instance of os.File. Next, the os.File object is
handed off as a parameter to the csv.NewReader() function which returns a
buffered csv.Reader object. Then, the csv.Reader.Read() method is used to
decode each file record into the struct and then store them in a slice until
io.EOF is returned, indicating the end of the file has been reached. Finally,
the slice iterates over each value.
package main
import (
"encoding/csv"
"fmt"
"io"
"os"
)
func main() {
file, _ := os.Open("file.csv")
reader := csv.NewReader(file)
rows := []Row{}
for {
package main
import (
"encoding/csv"
"os"
)
func main() {
rows := [][]string{
{"HOST", "IP ADDR", "LOCATION"},
{"RTR1", "1.1.1.1", "Seattle, WA"},
{"RTR2", "2.2.2.2", "Nevada, NV"}}
file, _ := os.Create("file.csv")
writer := csv.NewWriter(file)
writer.Flush()
file.Close()
}
The same file viewed in plain text from a text editor below.
HOST,IP ADDR,LOCATION
RTR1,1.1.1.1,"Seattle, WA"
RTR2,2.2.2.2,"Nevada, NV"
Chapter 2.3: YAML
YAML Ain't Markup Language (YAML) is a popular format that is commonly
used to read and write configuration files. It can also be leveraged to
translate data and support compatibility.
The yaml.v2 package is compatible with most of YAML 1.1 and 1.2. It is
often used as the go-to package to parse and generate YAML formatted data
quickly and reliably.
host: localhost
ports:
- 80
- 443
First, a struct is defined with the first letter in upper case and ″yaml″ field
tags to identify the keys. Then, the file is read using the ioutil.ReadFile()
function which returns a byte slice that is used to decode the data into a struct
instance with the yaml.Unmarshal() function. Finally, the struct instance
member values are printed to demonstrate the decoded YAML file.
package main
import (
"fmt"
"gopkg.in/yaml.v2"
"io/ioutil"
)
func main() {
data, _ := ioutil.ReadFile("file.yaml")
conf := Config{}
_ = yaml.Unmarshal(data, &conf)
The output below displays each value formatted on either side of a colon.
localhost:[80,443]
Chapter 2.3.2: Writing YAML
The yaml.v2 package supports a Marshal() function that is used to serialized
values into YAML format. While structs are typically used to support the
values, maps and pointers are accepted as well. Struct fields are only
serialized if they are exported and therefore have an upper case first letter.
Custom keys are defined by using ″yaml″ struct field tags.
package main
import (
"gopkg.in/yaml.v2"
"io/ioutil"
)
func main() {
conf := Config{
Host: "localhost",
Ports: []int{80,443},
}
data, _ := yaml.Marshal(&conf)
_ = ioutil.WriteFile("file.yaml", data, 0644)
}
The output below displays the file created in the example that contains each
values written in YAML format.
host: localhost
ports:
- 80
- 443
Chapter 2.4: JSON
JavaScript Object Notation (JSON) is a data interchange format typically
used between web front-end and back-end servers and mobile applications.
Most modern network appliances support a REST interface that exchanges
JSON encoded data.
The json package supports reading and writing the JSON format as defined in
RFC 7159. There are a few specific Go rules that must be followed:
{
"total": 3,
"devices": [
"SW1",
"SW2",
"SW3"
]
}
First, a struct is defined that aligns with the data in the JSON file. Then,
″json″ field tags are used to identify the individual keys. Next, the file is read
with the ioutil.ReadFile() function, which returns a byte slice that is decoded
into the struct instance using the json.Unmarshal() function. Finally, the struct
instance member values are printed to demonstrate that the JSON file was
decoded.
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
)
func main() {
data, _ := ioutil.ReadFile("file.json")
inv := Inventory{}
_ = json.Unmarshal([]byte(data), &inv)
The output displays each value on the right side of a colon next to the
corresponding label. The devices remain in string slice format for clarity.
Total: 3
Devices: ["SW1" "SW2" "SW3"]
Chapter 2.4.2: Writing JSON
The json package supports a Marshal() function that is used to serialize
values into JSON format. While structs are typically used to group the
values, maps are accepted as well. Struct fields are only serialized if they
are exported and therefore have an upper case first letter. Custom keys are
defined by using ″json″ struct field tags.
package main
import (
"encoding/json"
"fmt"
)
func main() {
data := &Inventory{
Total: 3,
Devices: []string{"SW1", "SW2", "SW3"},
}
The output displays a file with each value in JSON format. While the JSON
formatted data was written to a file to demonstrate the capability, it is also
common to write JSON formatted data to a REST API, as shown later in
Chapter 4.2.
{
"total": 3,
"devices": [
"SW1",
"SW2",
"SW3"
]
}
Chapter 2.5: XML
Extensible Markup Language (XML) is a markup language commonly used as
a data communication format in web services. The streamlined format is
often preferred over JSON due to its compact structure.
The xml package supports reading and writing XML 1.0. Since XML forms a
tree data structure, can be define in a similar hierarchy using structs in Go.
The examples in the subchapters that follow will leverage structs to encode
and decode XML.
<campus name="campus1">
<!-- building-comment -->
<building name="bldg1">
<!-- device-comment -->
<device type="router" name="rtr1"></device>
</building>
</campus>
First, a struct is defined and ″xml″ field tags are used to identify the keys in
the file. Next, the file is read with the ioutil.ReadFile() function and a byte
slice is returned, which is then decoded into a struct instance with the
xml.Unmarshal() function. Finally, the struct instance member values are
printed to demonstrate the decoded data.
package main
import (
"encoding/xml"
"fmt"
"io/ioutil"
)
func main() {
data, _ := ioutil.ReadFile("file.xml")
camp := &Campus{}
_ = xml.Unmarshal([]byte(data), &camp)
fl := fmt.Println
fl("Campus Name: ", camp.Name)
fl("Building Name: ", camp.Building.Name)
fl("Building Comment:", camp.Comment)
fl("Device Comment: ", camp.Building.Comment)
fl("Device Name: ", camp.Building.Device.Name)
fl("Device Type: ", camp.Building.Device.Type)
}
The output displays each value after its respective field name.
package main
import (
"encoding/xml"
"io/ioutil"
)
func main() {
camp := &Campus{Name: "campus1",
Comment: "building-comment",
Building: Building{Name: "bldg1",
Comment: "device-comment",
Device: Device{Name: "rtr1",
Type: "router"}}}
The output displays the file with each value in XML format. While the XML
was written to a file to demonstrate the process, it is also common to write
XML to a REST API.
<campus name="campus1">
<!-- building-comment -->
<building name="bldg1">
<!-- device-comment -->
<device type="router" name="rtr1"></device>
</building>
</campus>
Chapter 3: Text Templates
Text templates are often used in network operations to generate a large
number of configurations that consistently adhere to a standard template.
They enable consistent accuracy and allow for revision control.
Element Description
{{.}} Root element
{{.Var}} Variable
Remove whitespace on either
{{.Var}}
side
{{ $version := ″0.1″}} {{ $version }} Internal variable assignment
{{/* comment here */}} Comment
Name: {{if .Name }} {{ .Name }} {{
If-else conditional
else }} anonymous {{end}}.
{{with .Var}} {{end}} With statement
{{range .Array}} {{end}} Range over slice, map or channel
Less than (other boolean
{{ lt 3 4 }}
operators: eq, ne, lt, le, gt, ge)
Chapter 3.2: Basic Template
A basic template consists of a data source and a single template. Template
inheritance, which is covered in the next section, is used to piece together
nested templates.
In this example, three files are used, a CSV data file, a template file, and a
Go file. The CSV data file below represents a list of VLANs, one per row
with numbers in the first column and names in the second. The template file
will represent the format to configure each VLAN. It will iterate over each
row of the data file and format the data from both columns in the respective
layout. The Go file will be used to merge the CSV and template files together
then generate an output.
1,ONE
2,TWO
3,THREE
Now that the CSV data file is defined above, the template file is used to
position the data. The template file, defined below, uses the range element to
encapsulate the middle two lines. It iterates over the root element slice struct
of data that represents the rows in the CSV data file. Within the loop, the
plain text is followed by a variable that is used to apply the struct member.
Take note of the dash element that is used to trim the whitespace after the
name in the template file below.
{{range .}}
vlan {{ .Id }}
name {{ .Name -}}
{{end}}
Walking through the Go file below, the CSV data file is opened in read-only
mode with the os.Open() function, returning an instance of os.File. Next, the
os.File instance is handed off as a parameter to the csv.NewReader()
function in order to receive a buffered csv.Reader object. Then, a slice
instance of the Vlans struct is created that is used to store the values from
each row of the CSV file. The csv.Reader.Read() method is used to decode
the file line-by-line into the struct fields. It then stores them in a slice until
the expected io.EOF error is returned, indicating the end of the file has been
reached. Finally, the template file is parsed with the template.ParseFiles()
function and then merged together with the template file by calling the
tmpl.Execute() function, sending the result to the terminal standard output.
package main
import (
"encoding/csv"
"io"
"os"
"text/template"
)
func main() {
data, _ := os.Open("file.csv")
reader := csv.NewReader(data)
vlans := []Vlans{}
for {
tmpl, _ := template.ParseFiles("file.tmpl")
_ = tmpl.Execute(os.Stdout, vlans)
}
vlan 1
name ONE
vlan 2
name TWO
vlan 3
name THREE
Chapter 3.3: Template Inheritance
Template inheritance is used to work with nested templates by building a
base skeleton that contains child templates. This enables the ability to reuse
child templates across multiple base templates. Imagine multiple types of
router templates, all sharing the same common child BGP or NTP template.
base:
hostname: router1
bgp:
as: 1234
id: 4.4.4.4
neighbors:
- ip: 1.1.1.1
as: 1
- ip: 2.2.2.2
as: 2
ntp:
source: Loopback0
primary: 11.11.11.11
secondary: 22.22.22.22
Three template files will be used in total, a base template and two nested
templates. In each file, the comment element is leveraged at the top to
identify the file name and also the define element to assign the template name.
Since this is template inheritance, the child templates are associated to the
base by referencing the child template name along with the template element
in the respective position.
{{/* base.tmpl */}}
{{define "base"}}
hostname {{.Base.Hostname}}
{{template "bgp" .}}
{{template "ntp" .}}
{{end}}
{{ define "bgp" }}
router {{ .Bgp.As }}
router-id {{ .Bgp.Id -}}
{{range .Bgp.Neighbors}}
neighbor {{ .Ip }} remote-as {{ .As -}}
{{end}}
{{ end }}
{{ define "ntp" }}
ntp source-interface {{.Ntp.Source}}
ntp server {{.Ntp.Primary}} prefer
ntp server {{.Ntp.Secondary}}
{{ end }}
In the Go file below, the YAML file is read into a byte slice using the
ioutil.ReadFile() function. Then the yaml.Unmarshal() function is used to
parse the byte slice into the struct instance. Next, a map of the templates is
created by parsing the base and child templates. Finally, the data is merged
with the template map using the template.Template.ExecuteTemplate()
method to send the data to the terminal standard output.
package main
import (
"text/template"
"os"
"gopkg.in/yaml.v2"
"io/ioutil"
)
func main() {
config := Config{}
data, _ := ioutil.ReadFile("file.yaml")
_ = yaml.Unmarshal(data, &config)
tmpl := make(map[string]*template.Template)
files, _ := template.ParseFiles("base.tmpl",
"bgp.tmpl", "ntp.tmpl")
_ = tmpl["base.tmpl"].ExecuteTemplate(
os.Stdout, "base", config)
hostname router1
router 1234
router-id 4.4.4.4
neighbor 1.1.1.1 remote-as 1
neighbor 2.2.2.2 remote-as 2
GET request
POST request
Chapter 4.1: GET
The http package supports the GET method as defined in RFC 7231, section
4.3.1. A simple GET request can be done with the http.Client.Get() method
or, if custom headers are needed, a combination of the http.NewRequest()
function and the http.Client.Do() method can be used.
In this example, the method, URL, and body are setup using the
http.NewRequest() function in order to receive an http.Request object. Then,
a header is set with the http.Request.Header.Set() method using standard key-
value pair as input. Next, the Timeout field is initialized in the http.Client
struct which returns a new http.Client object. Now that the GET request is
built with the URL, and the client header is defined, they are used together
with the custom timeout to send and receive the HTTP request using the
http.Client.Do() method. Finally, the Response object is received from the
http.Client.Do() method and the request body is closed prior to printing the
response.
package main
import (
"fmt"
"io/ioutil"
"net/http"
"time"
)
func main() {
req, _ := http.NewRequest("GET",
"http://example.com", nil)
req.Header.Set("Cache-Control", "no-cache")
body, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%s\n", body)
}
Chapter 4.2: POST
The http package supports the POST method as defined in RFC 7231, section
4.3.3. A simple POST request can be done with the http.Client.Post()
method. If custom headers are needed, a combination of the
http.NewRequest() function and the http.Client.Do() method can be used.
In this example, the method, URL, and body are defined using the
http.NewRequest() function in order to receive an http.Request object. Then,
the header is set with the http.Request.Header.Set() method using standard
key-value pair as input. Next, the cookie Name and Value fields are defined
in the http.Cookie struct, which returns a new Cookie object that is added to
the request with the http.Request.AddCookie() method. Next, the Timeout
field is set in the http.Client struct and then a new http.Client object is
returned. Now that the POST request is built and the header is set, they are
used together with the new client to send and receive the HTTP request in the
http.Client.Do() method. Finally, the Response object is returned from the
http.Client.Do() method and the request body is closed prior to printing the
response.
package main
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"time"
)
func main() {
url := "http://example.com"
req.Header.Set(
"Content-Type", "application/json")
req.AddCookie(&cookie)
resp, _ := client.Do(req)
body, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%s\n", body)
}
Chapter 5: SSH Client
Secure Shell (SSH) is commonly used to execute commands on remote
servers. It can also be used to transfer files with Secure File Transfer
Protocol (SFTP). While the ssh package is not native to the Go standard
library, it is the official SSH package written and supported by the Go team.
The ssh package supports several methods for authenticating, such as using a
password or private key. This chapter will cover both forms of
authentication by walking through examples that demonstrate executing
commands and transferring files.
To prepare for this example, the public and private keys are generated on the
local machine using the ssh-keygen utility. Ssh-keygen is available on most
Linux or Mac installations. This utility creates a public (id_rsa.pub) and
private key file (id_rsa) as shown below.
$ ssh-keygen −b 4096
Generating public/private rsa key pair.
Enter file in which to save the key (/Users/username/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /Users/username/.ssh/id_rsa.
Your public key has been saved in /Users/username/.ssh/id_rsa.pub.
The key fingerprint is:
SHA256:2Bx44Vo8c6l...BeawUlC+z9l9UW5F username@mycomputer.local
The key's randomart image is:
+---[RSA 4096]----+
| o.@=...ooo|
| o.*+@+ oO|
| ..=..*= oB|
| . o+.=o& Eo|
| + To.+.=. |
| o .o |
| .. |
| |
| |
+----[SHA256]-----+
Next, the public key (id_rsa.pub) is copied over to the remote host using the
ssh-copy-id tool. It will be placed inside the ~/.ssh/authorized_keys file on
that host.
$ ssh-copy-id remotehost
Password:
Now that the public key is on the remote host and the private key is in place
locally, both keys are properly staged for private key authentication.
Next, to prepare to validate the identity of the remote host, the remote host
public key is obtained as a fingerprint in known_hosts file format using the
ssh-keyscan utility. The fingerprint may already be present in the local
~/.ssh/known_hosts file if the local machine has previously connected to the
host via SSH.
$ ssh-keyscan remotehost
# remotehost:22 SSH-2.0-OpenSSH_7.6
remotehost ecdsa-sha2-nistp256 AAAAE...jZHN
This example now begins by copying the remote host public key into a
variable that will be used to verify the remote host and prevent spoofing in
the client configuration. Using the ssh.ParseKnownHosts() function, the
public key is parsed in known_hosts file format. The key is hardcoded for
simplicity sake; however, in practice, it may be parse from a file or database.
Next, the private key file is read with the ioutil.ReadFile() function and is
returned as a byte slice. It is then used together with the passphrase in the
ssh.ParsePrivateKeyWithPassphrase() function to obtain a Signer instance.
Then, the ssh.ClientConfig struct is built by adding the username and then the
Signer signature into the ssh.PublicKeys() function for authentication. Next,
the HostKeyCallback is defined with the ssh.FixedHostKey() function for
authorization. Finally, the maximum amount of time for the TCP connection to
establish is set as the Timeout value, where zero means no timeout.
While not covered in this example, a list of acceptable key types and ciphers
could optionally be defined along with an order of preference in the
ssh.ClientConfig struct. The key types identified by KeyAlgoXxxx constants
would be defined as values in the HostKeyAlgorithms slice. The acceptable
cipher list would be defined in the Cipher field in the ssh.Config struct that is
embedded in the ssh.ClientConfig struct. Additionally, legacy CBC mode
ciphers may be added as well.
Now that the ssh.ClientConfig struct is built, it is used together with the
remote host to start the SSH connection by using the ssh.Dial() function to
return a ssh.Client instance. Next, a new session is created with the
ssh.Client.NewSession() method which is then mapped to the terminal's local
standard output and error for demonstration purpose. Finally, the command is
ran on the remote host by using the ssh.Session.Run() function and then
calling the ssh.Session.Close() method to terminate the connection.
package main
import (
"golang.org/x/crypto/ssh"
"io/ioutil"
"os"
"time"
)
func main() {
user := "username"
pass := []byte("password")
remotehost := "remotehost:22"
cmd := "hostname"
privkeyfile := "/Users/username/.ssh/id_rsa"
knownhost := []byte(
"remotehost ecdsa-sha2-nistp256 AAE...jZHN")
_, _, hostkey, _, _, _ := ssh.ParseKnownHosts(
knownhost)
privkeydata, _ := ioutil.ReadFile(privkeyfile)
parsekey := ssh.ParsePrivateKeyWithPassphrase
privkey, _ := parsekey(privkeydata, pass)
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(privkey),
},
HostKeyCallback: ssh.FixedHostKey(hostkey),
Timeout: 5 * time.Second,
}
conn, _ := ssh.Dial("tcp", remotehost, config)
sess, _ := conn.NewSession()
sess.Stdout = os.Stdout
sess.Stderr = os.Stderr
_ = sess.Run(cmd)
sess.Close()
}
Chapter 5.2: Multiple Commands
with Password Authentication
This next example starts out by gathering the remote host public key that will
be used to validate the identity of the remote host and build the client
configuration.
As with the previous example, the remote host public key is obtained as a
fingerprint in known_hosts file format using the ssh-keyscan utility. Again, it
may also be present in the local ~/.ssh/known_hosts file if the local machine
has previously connected to the host via SSH.
$ ssh-keyscan remotehost
# remotehost:22 SSH-2.0-OpenSSH_7.6
remotehost ecdsa-sha2-nistp256 AAE...jZHN
Next, the remote host public key is copied into a variable that is used to
prevent spoofing in the client configuration. Then, the public key is parsed in
known_hosts file format using the ssh.ParseKnownHosts() function. While
the key is hardcoded for simplicity sake, it may be parsed from a file or
database in practice. Likewise, while the username and password are
hardcoded in this example, they should be retrieved using more secure and
convenient means in a production environment. Next, the ssh.ClientConfig
struct is built by adding the username and then the password into the
ssh.Password() function for authentication. Authorization is enabled by
defining the HostKeyCallback with the ssh.FixedHostKey() function. Finally,
the maximum amount of time for the TCP connection to establish is set as the
Timeout value, where zero means no timeout.
Now that the ssh.ClientConfig struct has been built, it is used together with
the remote host to start the SSH connection by using the ssh.Dial() function to
return the ssh.Client instance. Next, a new session is created with the
ssh.Client.NewSession() method and then is mapped to the terminal's local
standard error and output for demonstration purpose. Then, a shell is started
on the remote host using the ssh.Session.Shell() method to run the commands
in. Finally, the commands are ran and then the ssh.Session.Wait() method is
used to wait until the commands execute prior to calling the
ssh.Session.Close() method to close the session and terminate the connection.
package main
import (
"fmt"
"golang.org/x/crypto/ssh"
"os"
"time"
)
func main() {
user := "username"
pass := "password"
remotehost:= "remotehost:22"
knownhost := []byte(
"remotehost ecdsa-sha2-nistp256 AAE...jZHN")
_, _, hostkey, _, _, _ := ssh.ParseKnownHosts(
knownhost)
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.Password(pass),
},
HostKeyCallback: ssh.FixedHostKey(hostkey),
Timeout: 5 * time.Second,
}
conn, _ := ssh.Dial("tcp", remotehost, config)
sess, _ := conn.NewSession()
stdin, _ := sess.StdinPipe()
sess.Stdout = os.Stdout
sess.Stderr = os.Stderr
_ = sess.Shell()
fmt.Fprintf(
stdin,"%s\n%s\n%s\n","pwd", "ls", "exit")
_ = sess.Wait()
sess.Close()
}
Chapter 5.3: SFTP File Copy with
Password Authentication
In this SFTP example, the remote host public key is retrieved to validate the
identity of the remote host. It will be used to build the client configuration.
$ ssh-keyscan remotehost
# remotehost:22 SSH-2.0-OpenSSH_7.6
remotehost ecdsa-sha2-nistp256 AAE...jZHN
Next, the remote host public key is copied into a variable that is used to
prevent spoofing in the client configuration. Then, the public key is parsed
from the entry in known_hosts file format using the ssh.ParseKnownHosts()
function. The key is hardcoded for simplicity sake; however, in practice, it
may be parsed from a file or database. Next, the ssh.ClientConfig struct is
built by adding the username and password into the ssh.Password() function
for authentication. Then, the HostKeyCallback is defined with the
ssh.FixedHostKey() function for authorization. Finally, the maximum amount
of time for the TCP connection to establish is set in Timeout, where a value
of zero means no timeout.
While not covered in this example, a list of acceptable key types and ciphers
could optionally be defined along with an order of preference in the
ssh.ClientConfig struct. The key types identified by KeyAlgoXxxx constants
would be defined as values in the HostKeyAlgorithms slice. The acceptable
cipher list would be defined in the Cipher field in the ssh.Config struct that is
embedded in the ssh.ClientConfig struct. Additionally, legacy CBC mode
ciphers may be added as well.
Now that the ssh.ClientConfig struct is built, it is used together with the
remote host to start the SSH connection by using the ssh.Dial() function to
return the ssh.Client instance.
package main
import (
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"io"
"os"
"time"
)
func main() {
user := "username"
pass := "password"
remotehost:= "remotehost:22"
knownhost := []byte(
"remotehost ecdsa-sha2-nistp256 AAE...jZHN")
_, _, hostkey, _, _, _ := ssh.ParseKnownHosts(
knownhost)
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.Password(pass),
},
HostKeyCallback: ssh.FixedHostKey(hostkey),
Timeout: 5 * time.Second,
}
conn, _ := ssh.Dial("tcp", remotehost, config)
client, _ := sftp.NewClient(conn)
_ = client.MkdirAll("./dir")
dstfile, _ := client.Create("./dir/dest.txt")
srcfile, _ := os.Open("./src.txt")
_, _ = io.Copy(dstfile, srcfile)
dstfile.Close()
client.Close()
conn.Close()
}
Chapter 6: IP Address
Manipulation
IP address manipulation is used to efficiently parse and process IP addresses
and their respective network masks. The net package provides methods for
working with IP addresses and CIDR notation that conform to RFC 4632 and
RFC 4291. This chapter will cover common tasks associated with
manipulating IP addresses, such as listing all IPs within a network,
determining whether an IP is contained within a network, and converting
masks between CIDR and dot-decimal notation.
package main
import (
"fmt"
"net"
)
func main() {
netw := "1.1.1.0/24"
ips := []string{"1.1.1.1", "2.2.2.2"}
addr := net.ParseIP(ip)
result := cidrnet.Contains(addr)
Next, a final loop is defined that iterates through the network IPs. First, the
net.IP value is initialized by using the net.IP.Mask() method to retrieve the
starting network address. Then, the net.IPNet.Contains() method is put in
place as the terminating condition to end the loop. Finally, the increment
function is used to walk through the respective network.
package main
import (
"fmt"
"net"
)
func main() {
netw := "1.1.1.1/30"
ip, ipnet, _ := net.ParseCIDR(netw)
ipmask := ip.Mask(ipnet.Mask)
The output below lists all IP addresses within the CIDR network, including
the network and broadcast addresses.
1.1.1.0
1.1.1.1
1.1.1.2
1.1.1.3
Chapter 6.3: Mask Conversion
This example will convert an IPv4 mask from slash notation to dot-decimal
notation, then from dot-decimal back to IPv4 slash notation.
Next, to convert the mask back to slash notation, the dot-decimal mask is fed
into the net.ParseIP() function to return a net.IP object. The net.IP object is
implemented on the net.IP.To4() method to convert the IP back to a 4-byte
representation. This net.IP object is enclosed in the net.IPMask() function to
return a net.IPMask object. Finally, the net.IPMask.Size() method is used to
return the mask length, or the number of leading ones in the mask.
package main
import (
"fmt"
"net"
)
func main() {
netw := "1.1.1.1/27"
ipv4IP, ipv4Net, _ := net.ParseCIDR(netw)
m := ipv4Net.Mask
dotmask := fmt.Sprintf("%d.%d.%d.%d",
m[0], m[1], m[2], m[3])
length, _ := cidrmask.Size()
The output below displays the IP with the dot-decimal mask followed by the
CIDR mask representation.
Forward (A)
Reverse (PTR)
Canonical Name (CNAME)
Mail Exchanger (MX)
Name Servers (NS)
Service (SRV)
Text (TXT)
Chapter 7.1: Forward (A)
The net.LookupIP() function accepts a string and returns a slice of net.IP
objects that represent that host's IPv4 and/or IPv6 addresses.
package main
import (
"fmt"
"net"
)
func main() {
ips, _ := net.LookupIP("google.com")
The output below lists the A records for google.com that were returned in
both IPv4 and IPv6 formats.
172.217.1.238
2607:f8b0:4000:80e::200e
Chapter 7.2: Reverse (PTR)
The net.LookupAddr() function performs a reverse lookup for the address
and returns a list of names that map to the address. Be aware that the host C
library resolver will only return one result. To bypass the host resolver, a
custom resolver must be used.
package main
import (
"fmt"
"net"
)
func main() {
names, _ := net.LookupAddr("8.8.8.8")
The single reverse record that was returned for the address is shown below,
including the trailing period symbol at the end.
google-public-dns-a.google.com.
Chapter 7.3: Canonical Name
(CNAME)
The net.LookupCNAME() function accepts a hostname as a string and returns
a single canonical name for the provided host.
package main
import (
"fmt"
"net"
)
func main() {
cname, _ := net.LookupCNAME(
"research.swtch.com")
fmt.Println(cname)
}
The CNAME record that was returned for the research.swtch.com domain is
shown below, including the trailing period symbol at the end.
ghs.google.com.
Chapter 7.4: Mail Exchanger (MX)
The net.LookupMX() function accepts a domain name as a string and returns
a slice of MX structs sorted by preference. An MX struct is made up of a
Host as a string and Pref as a uint16.
package main
import (
"fmt"
"net"
)
func main() {
mxs, _ := net.LookupMX("google.com")
The output lists each MX record for the domain followed by each respective
preference.
aspmx.l.google.com. 10
alt1.aspmx.l.google.com. 20
alt2.aspmx.l.google.com. 30
alt3.aspmx.l.google.com. 40
alt4.aspmx.l.google.com. 50
Chapter 7.5: Name Servers (NS)
The net.LookupNS() function accepts a domain name as a string and returns
DNS NS records as a slice of NS structs. An NS struct is made up of a Host
as a string.
package main
import (
"fmt"
"net"
)
func main() {
nss, _ := net.LookupNS("gmail.com")
The NS records that support the domain are shown below, including the
trailing period symbol at the end.
ns1.google.com.
ns4.google.com.
ns3.google.com.
ns2.google.com.
Chapter 7.6: Service (SRV)
The net.LookupSRV() function accepts a service, protocol, and domain name
as a string. It returns a canonical name as a string along with a slice of SRV
structs. An SRV struct supports a Target as a string and Port, Priority, and
Weight as uint16's. The net.LookupSRV() function attempts to resolve an SRV
query of the service, protocol, and domain name, sorted by priority and
randomized by weight within a priority.
package main
import (
"fmt"
"net"
)
func main() {
cname, srvs, _ := net.LookupSRV(
"xmpp-server", "tcp", "google.com")
cname: _xmpp-server._tcp.google.com.
xmpp-server.l.google.com.:5269:5:0
alt2.xmpp-server.l.google.com.:5269:20:0
alt1.xmpp-server.l.google.com.:5269:20:0
alt3.xmpp-server.l.google.com.:5269:20:0
alt4.xmpp-server.l.google.com.:5269:20:0
Chapter 7.7: Text (TXT)
The net.LookupTXT() function accepts a domain name as a string and returns
a list of DNS TXT records as a slice of strings.
package main
import (
"fmt"
"net"
)
func main() {
txts, _ := net.LookupTXT("gmail.com")
v=spf1 redirect=_spf.google.com
Chapter 8: Regex Pattern Matching
Regular expression pattern matching is used to match a sequence of
characters that are defined by a search pattern. The regexp package leverages
the fast and thread-safe RE2 regular expression engine. While it does not
support backtracking, it does guarantee linear time execution. This chapter
covers simple matching, match groups, named matches, template expansion,
and multi-line split.
Simple matching
Match groups
Named capture groups
Template expansion
Multi-line delimited split
Chapter 8.1: Simple Matching
Simple pattern matching covers the basic concept of returning a result from
processing a regular expression pattern against a data set. In this example, the
data set is first identified and then the regexp.MustCompile() function is used
to define the pattern. The pattern leverages the \w+ syntax to match one or
more word characters on either side of the dot. Then, using the dataset as
input, the regexp.Regexp.FindString() method is called on the pattern to
return the result. If multiple string matches were needed from the pattern, the
regexp.Regexp.FindAllString() method would have been used and a slice
returned.
package main
import (
"fmt"
"regexp"
)
func main() {
data := `# host.domain.tld #`
pattern := regexp.MustCompile(`\w+.\w+.\w+`)
result := pattern.FindString(data)
fmt.Println(result)
}
The simple match using the \w+.\w+.\w+ pattern against the data set is shown
below.
host.domain.tld
Chapter 8.2: Match Groups
Using parentheses in the search pattern identifies individual match groups.
Subpatterns are placed within parentheses to identify each individual
matched group across the data set. In this example, match groups are
demonstrated by matching each of the three sections of a domain name into
their own group.
To start, the data set is identified and then the regexp.MustCompile() function
is used to define the pattern. The pattern uses the \w+ syntax to match one or
more word characters on either side of the dot. Then, using the dataset as
input, the regexp.Regexp.FindAllStringSubmatch() method is called to return
a two-dimensional slice, where the first slice value is the entire set of
matches and each consecutive value is the respective matched group. A -1 is
used as an input to the regexp.Regexp.FindAllStringSubmatch() method
which signifies unlimited matches; otherwise, a positive number could have
been set to limit the result.
package main
import (
"fmt"
"regexp"
)
func main() {
data := `# host.domain.tld #`
pattern := regexp.MustCompile(
`(\w+).(\w+).(\w+)`)
groups := pattern.FindAllStringSubmatch(
data, -1)
fmt.Printf("\n%q\n", groups)
fmt.Printf("groups[0][0]: %s\n", groups[0][0])
fmt.Printf("groups[0][1]: %s\n", groups[0][1])
fmt.Printf("groups[0][2]: %s\n", groups[0][2])
fmt.Printf("groups[0][3]: %s\n", groups[0][3])
}
The output below first demonstrates the entire match, followed by each
individual group.
This example first identifies the data set and then uses the
regexp.MustCompile() function to define the pattern. The pattern uses the \w+
syntax to match one or more word characters on either side of the dot. Since
named capture groups are used, each pattern is defined with the (?
P<name>re) syntax, where name is the capture group name. Then, using the
dataset as input, the regexp.Regexp.FindStringSubmatch() method is called to
return a slice, where the first slice value is the leftmost match. Next, a map is
created to hold the result and then the slice that is returned from the
regexp.Regexp.SubExpNames() method is looped over, which returns each
name of the parenthesized subexpressions. As each name is iterated over, the
subexpression name ischecked to ensure it is not empty. If the name is valid,
the map entry is created using the name as the key and the corresponding
submatch index value as the value, before finally printing the new map.
package main
import (
"fmt"
"regexp"
)
func main() {
dat := `# host.domain.tld #`
pat := regexp.MustCompile(
`(?P<third>(\w+)).(?P<sec>(\w+)).(?P<first>(\w+))`)
mch := pat.FindStringSubmatch(dat)
res := make(map[string]string)
ff := fmt.Printf
ff("\n%q\n", res)
ff("res[\"first\"]: %s\n", res["first"])
ff("res[\"sec\"]: %s\n", res["sec"])
ff("res[\"third\"]: %s\n", res["third"])
}
The output below first demonstrates the entire match, followed by each value
that is referenced by the named match key in the result map.
To begin the example, the data set is identified as the source and then the
regexp.MustCompile() function is used to define the pattern. The pattern uses
the \w+ syntax to match one or more word characters on either side of the
dot. Next, the string template is defined. It takes the named match as a
variable by prefixing it with a dollar sign. Then, each matched line is looped
over and returned from the regexp.Regexp.FindAllStringSubmatchIndex()
method. Each match is then expanded into the template by using the
regexp.Regexp.ExpandString() method to produce the output.
package main
import (
"fmt"
"regexp"
)
func main() {
src := `
host1.domain.tld some text
host2.domain.tld more text
`
re := regexp.MustCompile(
`(?m)(?P<thir>\w+).(?P<sec>\w+).(?P<fir>\w+)\s(?
P<text>.*)$`)
tpl := "$thir.$sec.$fir.\tIN\tTXT\t\"$text\"\n"
dst := []byte{}
findidx := re.FindAllStringSubmatchIndex
for _, matches := range findidx(src, -1) {
dst = re.ExpandString(dst, tpl, src, matches)
}
fmt.Println(string(dst))
}
After initializing the two-dimensional slice, the values that are returned from
splitting the configuration are iterated over. Finally, within the loop, the
trailing new-line character is trimmed with the strings.TrimSuffix() function
and then each string is split by their new-line character, returning and then
appending a new slice with values returned from splitting each line.
package main
import (
"fmt"
"regexp"
"strings"
)
func main() {
config := `int Vlan1
desc v1
!
int Vlan2
desc v2
`
a := regexp.MustCompile("(?m)(\n^!$\n)")
m := a.Split(config, -1)
arr := [][]string{}
for _, s := range m {
s := strings.TrimSuffix(s, "\n")
m := strings.Split(s, "\n")
arr = append(arr, m)
}
fmt.Printf("%q\n", arr)
}
The output is shown with double quotes and brackets to demonstrate the
result.
This chapter works through examples that demonstrate combining stderr and
stdout and another that separates the two. Having the choice to natively
handle redirection can lend itself to cleanliness and readability.
Combined Stdout/Stderr
Separate Stdout/Stderr
Chapter 9.1: Combined
Stdout/Stderr
The exec package supports the ability to combine the terminal standard
output and error after a command has executed. This combined output can
streamline output processing and save several steps.
A scenario will be setup to demonstrate where two files will be created; one
that is writable and the other where write privileges are removed. Using the
cat command, we will read each of the files and expect the to see the
standard output from the readable file (r.txt) and the standard error from the
non-readable file (w.txt).
package main
import (
"fmt"
"os/exec"
)
func main() {
fmt.Printf("%s", data)
}
Take note that the output first displays the standard output text from the
readable file (r.txt) and then the error text from the non-readable file (w.txt).
hi from r.txt
cat: w.txt: Permission denied
Chapter 9.2: Separate
Stdout/Stderr
This example will show how to execute a command and separate the
standard terminal output and error. The exec package easily executes external
commands and distinguishes between standard output and standard error
through its native functions.
package main
import (
"fmt"
"io/ioutil"
"os/exec"
)
func main() {
stdoutpipe, _ := cmd.StdoutPipe()
stderrpipe, _ := cmd.StderrPipe()
_ = cmd.Start()
stdout, _ := ioutil.ReadAll(stdoutpipe)
stderr, _ := ioutil.ReadAll(stderrpipe)
fmt.Printf(
"stdout: %sstderr: %s", stdout, stderr)
_ = cmd.Wait()
}
The output first displays the standard output text from the readable file (r.txt)
and then the error text from the non-readable file (w.txt).
This chapter will cover three different methods for reading in data. User
input from a prompt is demonstrated for sensitive and non-sensitive data,
followed by command-line arguments and flags for in-line parameters.
Finally, column formatted output is examined to demonstrate an effective
pattern to present data.
This chapter will cover the following CLI application helper topics:
User input
Command-line arguments
Command-line flags
Column formatting
Chapter 10.1: User Input
Reading user input from the terminal is often needed to read in both sensitive
and non-sensitive data. This example takes in a username and a password to
demonstrate the two. To keep it short, the terminal.ReadPassword() function
will be used to read the sensitive password text from the terminal without
local echo. The bufio.Reader.Readstring() method will be used for non-
sensitive input.
package main
import (
"bufio"
"fmt"
"golang.org/x/crypto/ssh/terminal"
"os"
"syscall"
)
func main() {
reader := bufio.NewReader(os.Stdin)
The output displays the username and password prompts and then the output
of the result below. The password is not displayed when the user types it in
to avoid shoulder surfing.
In the example program, only two user actions are allowed, add or delete. As
a result, only three arguments are accepted, (1) the program itself, (2) the
keywords ″add″ or ″del″, and (3) the item that is acted on.
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) != 3 {
fmt.Printf(
"usage: %s add|del \n", os.Args[0])
os.Exit(1)
}
switch os.Args[1] {
case "add":
fmt.Println("adding item")
case "del":
fmt.Println("deleting item")
default:
fmt.Printf(
"usage: %s add|del \n", os.Args[0])
os.Exit(1)
}
}
Chapter 10.3: Command-line Flags
Command-line flags are another common method to specify options for
command-line programs. This example uses the flag package to demonstrate
parsing arguments and will parse int, bool, and string types. By default, the
flag package will accept a ″-h″ or ″--help″ flag to display the help dialog. If
no flag value is entered the default value, defined as the second flag
parameter, is assigned.
In the first section of the example, the flags are declared using the
appropriate flag function according to the type. Next, the flag name is
specified, the default value, and usage string. Then, after all flags are
defined, the flag.Parse() function is called to parse the command line user
input into the defined flags. Finally, the flag values are accessed as pointers.
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
fmt.Println("port = ", *port)
fmt.Println("enable = ", *enable)
fmt.Println("name = ", *name)
}
The program is ran from the local directory using flags as input. The output
displays the flag values.
port = 80
enable = true
name = test
Chapter 10.4: Column Formatting
Command-line application output is often viewed in column format to
correlate items. The tabwriter package enables easy formatting of columns
using tab-terminated cells in contiguous lines.
package main
import (
"fmt"
"os"
"text/tabwriter"
)
func main() {
w := tabwriter.NewWriter(
os.Stdout, 8, 8, 0, '\t', 0)
for i := 0; i 5; i++ {
fmt.Fprintf(
w, "\n %d\t%d\t%d\t", i, i+1, i+2)
}
w.Flush()
}