Initial Commit

This commit is contained in:
2021-11-11 16:13:40 -08:00
commit 0645bc876c
28 changed files with 3363 additions and 0 deletions

222
cmd/lasso/cmd/client.go Normal file
View File

@@ -0,0 +1,222 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bytes"
"crypto/tls"
"net"
"net/http"
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/vmihailenco/msgpack/v5"
"go.arsenm.dev/lasso/internal/types"
)
// clientCmd represents the client command
var clientCmd = &cobra.Command{
Use: "client",
Short: "Start the lasso client",
Run: func(cmd *cobra.Command, args []string) {
// Disable certificate verification as server uses self-signed key
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
// Perform server status check
res, err := http.Get(url("status"))
if err != nil {
log.Fatal().Err(err).Msg("Server status check failed")
}
res.Body.Close()
// Get node list from server
res, err = http.Get(url("node", "list"))
if err != nil {
log.Fatal().Err(err).Msg("Error getting node list from server")
}
var resp types.Response
// Decode server response
err = msgpack.NewDecoder(res.Body).Decode(&resp)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding response body")
}
res.Body.Close()
var nodes map[string]types.Node
// Decode response data as node list
err = mapstructure.Decode(resp.Data, &nodes)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding response")
}
// Get local IP
ip, err := localIP()
if err != nil {
log.Fatal().Err(err).Msg("Error getting local IP")
}
// Attempt to get node from list
node, ok := nodes[viper.GetString("node.name")]
// If node does not exist
if !ok {
// Encode node as msgpack
data, err := msgpack.Marshal(types.Node{IP: ip})
if err != nil {
log.Fatal().Err(err).Msg("Error encoding new node")
}
// Send node to server
res, err = http.Post(url("node", viper.GetString("node.name")), "text/plain", bytes.NewReader(data))
if err != nil {
log.Fatal().Msg("Error adding new node to server")
}
var resp types.Response
// Decode server response
err = msgpack.NewDecoder(res.Body).Decode(&resp)
if err != nil {
log.Fatal().Msg("Error decoding server response")
}
// If server returned error
if resp.Error {
log.Fatal().Str("error", resp.Message).Msg("Error returned by server")
}
// Set new node IP
node.IP = ip
// Set node ID in viper
viper.Set("node.id", resp.Data)
// Attempt to write new ID to config
if err := viper.WriteConfig(); err != nil {
log.Fatal().Err(err).Msg("Error writing new ID to config")
}
}
// If IP does not match current
if node.IP != ip {
// Encode new node as msgpack
data, err := msgpack.Marshal(types.Node{
IP: ip,
ID: viper.GetString("node.id"),
})
if err != nil {
log.Fatal().Err(err).Msg("Error encoding updated node")
}
// Create new PATCH request with new node as data
req, err := http.NewRequest(
http.MethodPatch,
url("node", viper.GetString("node.name")),
bytes.NewReader(data),
)
if err != nil {
log.Fatal().Err(err).Msg("Error creating PATCH request")
}
// Perform request
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal().Err(err).Msg("Error sending update request to server")
}
var resp types.Response
// Decode server response
err = msgpack.NewDecoder(res.Body).Decode(&resp)
if err != nil {
log.Fatal().Msg("Error decoding server response")
}
// If server returned error
if resp.Error {
log.Fatal().Str("error", resp.Message).Msg("Error returned by server")
}
}
// Every minute
for range time.Tick(time.Minute) {
// Get local IP
newIP, err := localIP()
if err != nil {
log.Error().Err(err).Msg("Error getting new local IP")
continue
}
// If IP has changed since last check
if newIP != ip {
// Set IP to new IP
ip = newIP
// Encode new node as msgpack
data, err := msgpack.Marshal(types.Node{
IP: newIP,
ID: viper.GetString("node.id"),
})
if err != nil {
log.Error().Err(err).Msg("Error encoding updated node")
continue
}
// Create new PATCH request with new node as data
req, err := http.NewRequest(
http.MethodPatch,
url("node", viper.GetString("node.name")),
bytes.NewReader(data),
)
if err != nil {
log.Fatal().Err(err).Msg("Error creating PATCH request")
}
// Perform request
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal().Err(err).Msg("Error sending update request to server")
}
var resp types.Response
// Decode server response
err = msgpack.NewDecoder(res.Body).Decode(&resp)
if err != nil {
log.Fatal().Msg("Error decoding server response")
}
// If server returned error
if resp.Error {
log.Fatal().Str("error", resp.Message).Msg("Error returned by server")
}
}
}
},
}
func init() {
rootCmd.AddCommand(clientCmd)
}
// localIP returns the IP address of the default network interface
func localIP() (string, error) {
// Make UDP "connection" to nonexistant address
conn, err := net.Dial("udp", "255.255.255.255:65535")
if err != nil {
return "", err
}
defer conn.Close()
// Get local address
addr := conn.LocalAddr().String()
// Get host from address
host, _, _ := net.SplitHostPort(addr)
return host, nil
}
// url generates a URL for the given path on the server
func url(path ...string) string {
// Get server address with port
serverAddr := net.JoinHostPort(viper.GetString("server.addr"), viper.GetString("server.port"))
// Return HTTPS address
return "https://" + serverAddr + "/" + strings.Join(path, "/")
}

124
cmd/lasso/cmd/gencert.go Normal file
View File

@@ -0,0 +1,124 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
// gencertCmd represents the gencert command
var gencertCmd = &cobra.Command{
Use: "gencert <cert file> <key file>",
Short: "Generate self-signed TLS certificate for master server",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 2 {
cmd.Usage()
os.Exit(1)
}
// Generate an ed25519 key from rand reader
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
// Get current time to use for cert creation time
notBefore := time.Now()
// Get expiration time 10 years from now
notAfter := notBefore.Add(time.Hour * 24 * 365 * 10)
// Set limit for serial number
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
// Create random serial number
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatal().Err(err).Msg("Failed to generate serial number")
}
// Get local IP
ip, err := localIP()
if err != nil {
log.Fatal().Err(err).Msg("Error getting local IP")
}
// Create x509 certificate template
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Lasso"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP(ip)},
}
// Create certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, pub, priv)
if err != nil {
log.Fatal().Err(err).Msg("Failed to create certificate")
}
// Create certificate output file
certOut, err := os.Create(args[0])
if err != nil {
log.Fatal().Err(err).Msg("Failed to open cert file for writing")
}
// Enode certificate as PEM file
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
log.Fatal().Err(err).Msg("Failed to write data to cert file")
}
// Close certificate file
certOut.Close()
log.Info().Str("path", args[0]).Msg("Wrote cert file")
// Create key output file
keyOut, err := os.OpenFile(args[1], os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
log.Fatal().Err(err).Msg("Failed to open key file for writing")
return
}
// Encode private key as PKCS8
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
log.Fatal().Err(err).Msg("Unable to marshal private key file")
}
// Encode private key as PEM
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
log.Fatal().Err(err).Msg("Failed to write data to key file")
}
// Close key file
keyOut.Close()
log.Info().Str("path", args[1]).Msg("Wrote key file")
},
}
func init() {
rootCmd.AddCommand(gencertCmd)
}

89
cmd/lasso/cmd/root.go Normal file
View File

@@ -0,0 +1,89 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"net/http"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/vmihailenco/msgpack/v5"
"go.arsenm.dev/lasso/internal/types"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "lasso",
Short: "A brief description of your application",
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
cobra.CheckErr(rootCmd.Execute())
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Config file path")
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Search for config in config directory with name "lasso" (without extension).
viper.AddConfigPath("/etc/lasso")
viper.SetConfigType("toml")
viper.SetConfigName("lasso")
}
viper.SetEnvPrefix("lasso")
viper.AutomaticEnv() // Read in environment variables that match
// If a config file is found, read it in.
viper.ReadInConfig()
}
// httpError logs an error as well as writing it to the HTTP response
func httpError(res http.ResponseWriter, statusCode int, err error, msg string) {
// Write status code
res.WriteHeader(statusCode)
var message string
if err != nil {
// Get message string containing error string
message = fmt.Sprintf("%s: %s", msg, err)
// Log error
log.Error().Err(err).Msg(msg)
} else {
// Set message string to given msg
message = msg
// Log message
log.Error().Msg(msg)
}
// Encode error response to HTTP response
msgpack.NewEncoder(res).Encode(types.Response{
Error: true,
Message: message,
})
}

227
cmd/lasso/cmd/server.go Normal file
View File

@@ -0,0 +1,227 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"errors"
"net"
"net/http"
"github.com/dgraph-io/badger/v3"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/vmihailenco/msgpack/v5"
"go.arsenm.dev/lasso/internal/logging"
"go.arsenm.dev/lasso/internal/types"
)
var (
ErrAlreadyExists = errors.New("node already exists in database")
ErrNoExists = errors.New("node does not exist in database")
)
// serverCmd represents the server command
var serverCmd = &cobra.Command{
Use: "server",
Short: "Start the lasso master server",
Run: func(cmd *cobra.Command, args []string) {
// Open nodes database
nodes, err := badger.Open(
badger.DefaultOptions("/etc/lasso/nodes").
WithLogger(logging.BadgerLogger{}),
)
if err != nil {
log.Fatal().Err(err).Msg("Error opening badger database")
}
// Close database at end of function
defer nodes.Close()
// Create new router
router := chi.NewMux()
router.Use(logging.ChiLogger)
// GET /status (Status check)
router.Get("/status", func(res http.ResponseWriter, req *http.Request) {
// Encode empty response to connection
msgpack.NewEncoder(res).Encode(types.Response{})
})
router.Route("/node", func(node chi.Router) {
// POST /node/:name (Create new node and return id)
node.Post("/{name}", func(res http.ResponseWriter, req *http.Request) {
// Get name parameter from URL
name := chi.URLParam(req, "name")
var nodeReq types.Node
// Decode request body as a node
err = msgpack.NewDecoder(req.Body).Decode(&nodeReq)
if err != nil {
httpError(res, http.StatusBadRequest, err, "Unable to decode request body")
return
}
// Create new UUID
id := uuid.New().String()
// Set node ID
nodeReq.ID = id
// Update nodes database
err = nodes.Update(func(txn *badger.Txn) error {
// Attempt to get node from database
item, err := txn.Get([]byte(name))
// If error exists but item found
if err != nil && err != badger.ErrKeyNotFound {
return err
} else if item != nil {
return ErrAlreadyExists
}
// Encode request node to msgpack
data, _ := msgpack.Marshal(nodeReq)
// Set new node in database and return error
return txn.Set([]byte(name), data)
})
if err != nil {
httpError(res, http.StatusInternalServerError, err, "Error adding node to database")
return
}
// Encode response with id to connection
msgpack.NewEncoder(res).Encode(types.Response{Data: id})
})
// PATCH /node/:name (Update IP address of a node)
node.Patch("/{name}", func(res http.ResponseWriter, req *http.Request) {
// Get name parameter from URL
name := chi.URLParam(req, "name")
var nodeReq types.Node
// Decode request body as node
err = msgpack.NewDecoder(req.Body).Decode(&nodeReq)
if err != nil {
httpError(res, http.StatusBadRequest, err, "Unable to decode request body")
return
}
var dbNode types.Node
// View nodes database
err = nodes.View(func(txn *badger.Txn) error {
// Attempt to get node from database
item, err := txn.Get([]byte(name))
if err != nil {
return err
}
// Decode node and return error
return item.Value(func(val []byte) error {
return msgpack.Unmarshal(val, &dbNode)
})
})
if err != nil {
httpError(res, http.StatusBadRequest, err, "Unable to get node from database")
return
}
// If request and database IDs are not the same
if nodeReq.ID != dbNode.ID {
httpError(res, http.StatusForbidden, nil, "Incorrect UUID for specified node")
return
}
// Update nodes database
err = nodes.Update(func(txn *badger.Txn) error {
// Encode node sent in request
data, err := msgpack.Marshal(nodeReq)
if err != nil {
return err
}
// Set new node in database and return error
return txn.Set([]byte(name), data)
})
if err != nil {
httpError(res, http.StatusInternalServerError, err, "Error updating node in database")
return
}
// Encode response to connection
msgpack.NewEncoder(res).Encode(types.Response{})
})
// GET /node/list (Get list of all nodes)
node.Get("/list", func(res http.ResponseWriter, req *http.Request) {
// Create map of nodes for output
out := map[string]types.Node{}
// View nodes database
nodes.View(func(txn *badger.Txn) error {
// Create new database iterator
it := txn.NewIterator(badger.DefaultIteratorOptions)
// Close iterator at end of function
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
// Get item from iterator
item := it.Item()
// Get key from item
key := item.Key()
var node types.Node
// Get value from iterator
err = item.Value(func(val []byte) error {
// Decode value as node and return error
return msgpack.Unmarshal(val, &node)
})
if err != nil {
return err
}
// Remove node ID
node.ID = ""
// Set node in map
out[string(key)] = node
}
return nil
})
// Encode map in response on connection
msgpack.NewEncoder(res).Encode(types.Response{
Data: out,
})
})
})
// Get listen address for server from config
addr := net.JoinHostPort(viper.GetString("server.addr"), viper.GetString("server.port"))
// Log HTTPS server starting
log.Info().Str("addr", addr).Msg("Starting HTTPS server")
// Start HTTPS server using certificate paths from config
err = http.ListenAndServeTLS(
addr,
viper.GetString("server.tls.cert"),
viper.GetString("server.tls.key"),
router,
)
if err != nil {
log.Fatal().Err(err).Msg("Error while running server")
}
},
}
func init() {
rootCmd.AddCommand(serverCmd)
}

33
cmd/lasso/main.go Normal file
View File

@@ -0,0 +1,33 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package main
import (
"os"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go.arsenm.dev/lasso/cmd/lasso/cmd"
)
func init() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
func main() {
cmd.Execute()
}

31
cmd/lassoctl/cmd/file.go Normal file
View File

@@ -0,0 +1,31 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"github.com/spf13/cobra"
)
// fileCmd represents the file command
var fileCmd = &cobra.Command{
Use: "file",
Short: "Send and retrieve files from a lasso node",
}
func init() {
rootCmd.AddCommand(fileCmd)
}

41
cmd/lassoctl/cmd/list.go Normal file
View File

@@ -0,0 +1,41 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// listCmd represents the list command
var listCmd = &cobra.Command{
Use: "list",
Short: "Display a list of available nodes on the server",
Aliases: []string{"ls"},
Run: func(cmd *cobra.Command, args []string) {
nodes := getNodeList()
for name, node := range nodes {
fmt.Println(name+":", node.IP)
}
},
}
func init() {
rootCmd.AddCommand(listCmd)
}

361
cmd/lassoctl/cmd/mssh.go Normal file
View File

@@ -0,0 +1,361 @@
/*
Copyright © 2021 NAME HERE <EMAIL ADDRESS>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"bufio"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/abiosoft/ishell"
"github.com/melbahja/goph"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// msshCmd represents the mssh command
var msshCmd = &cobra.Command{
Use: "mssh <user@node...>",
Short: "Make an SSH connection to multiple nodes simultaneously",
Run: func(cmd *cobra.Command, args []string) {
// Create new shell
shell := ishell.New()
var password string
// If password prompt requested
if viper.GetBool("password") {
// Print prompt
shell.Print("Password: ")
// Read password into variable
password = shell.ReadPassword()
}
// Get node list from server
nodes := getNodeList()
// Create new goph auth
var auth goph.Auth
// If password prompt requested
if viper.GetBool("password") {
// Add password method to auth
auth = append(auth, goph.Password(password)...)
}
// If identity given
if viper.GetString("identity") != "" {
// Get identity as ssh key
keyAuth, err := goph.Key(viper.GetString("identity"), "")
if err != nil {
log.Fatal().Err(err).Msg("Error getting SSH key:")
}
// Add key to auth
auth = append(auth, keyAuth...)
}
// Get all identities in default location
keys, err := getAllIdentities()
if err != nil {
log.Fatal().Msg("Error getting SSH identities")
}
// Add keys to auth
auth = append(auth, keys...)
// If ssh agent exists
if goph.HasAgent() {
// Get ssh agent
agent, err := goph.UseAgent()
if err != nil {
log.Fatal().Msg("Error getting SSH agent")
}
// Add ssh agent to auth
auth = append(auth, agent...)
}
// Create map to store goph clients
clients := map[string]*goph.Client{}
// For every device
for _, device := range args {
// Split device by "@"
splitArg := strings.Split(device, "@")
// If split device has less than two elements, it is invalid
if len(splitArg) != 2 {
log.Fatal().Msg("Invalid username/node argument")
}
// Get variables from split device
username, nodeName := splitArg[0], splitArg[1]
// Get node from list if it exists
node, ok := nodes[nodeName]
if !ok {
log.Fatal().Str("node", nodeName).Msg("Node does not exist on the server")
}
// Connect to node without verifying known hosts
client, err := goph.NewUnknown(username, node.IP, auth)
if err != nil {
log.Fatal().Err(err).Msg("Error connecting to node")
}
// Add device to client list
clients[device] = client
}
// Clode all clients at the end of the function
defer closeClients(clients)
// Add run command to shell
shell.AddCmd(&ishell.Cmd{
Name: "run",
Help: "Run a shell command on all nodes simultaneously.",
Func: func(c *ishell.Context) {
// Create new wait group
wg := &sync.WaitGroup{}
// For every client
for name, client := range clients {
// Add goroutine to wait group
wg.Add(1)
go func(client *goph.Client, name string) {
// Remove from wait group at the end of the function
defer wg.Done()
// Create command from given arguments
cmd, err := client.Command(c.Args[0], c.Args[1:]...)
if err != nil {
cmdError(shell, name, err)
return
}
// Get command Stdout pipe
stdout, err := cmd.StdoutPipe()
if err != nil {
cmdError(shell, name, err)
return
}
// Get command Stderr pipe
stderr, err := cmd.StderrPipe()
if err != nil {
cmdError(shell, name, err)
return
}
// Combine Stdout and Stderr
combined := io.MultiReader(stdout, stderr)
// Start command without waiting for it to finish
if err := cmd.Start(); err != nil {
cmdError(shell, name, err)
return
}
// Create new scanner for combined output
scanner := bufio.NewScanner(combined)
for scanner.Scan() {
// Print command output
cmdOut(shell, name, scanner.Text())
}
}(client, name)
}
// Wait for all goroutines to complete
wg.Wait()
},
})
// Create file command
file := &ishell.Cmd{
Name: "file",
Help: "Transfer files to/from nodes",
}
// Add send command to file command
file.AddCmd(&ishell.Cmd{
Name: "send",
Help: "Send a file to all nodes. '~' will expand to device's home directory.",
Func: func(c *ishell.Context) {
// Get local and remote paths
localPath := c.Args[0]
remotePath := c.Args[1]
// Create new wait group
wg := &sync.WaitGroup{}
// For every client
for name, client := range clients {
// Set new remote to remote path
newRemote := remotePath
// If new remote starts with "~"
if strings.HasPrefix(newRemote, "~") {
// Replace ~ with "/home/user"
newRemote = filepath.Join(
"/home/"+client.User(),
strings.TrimPrefix(newRemote, "~"),
)
}
// Add one goroutine to wait group
wg.Add(1)
go func(client *goph.Client, name string) {
// Remove from wait group at the end of the function
defer wg.Done()
// Upload file to remote
err := client.Upload(localPath, newRemote)
if err != nil {
shell.Printf("%s [error]: %v\n", name, err)
return
}
// Print success message
cmdSuccess(shell, name, "upload complete")
}(client, name)
// Wait for all added goroutines to complete
wg.Wait()
}
},
})
// Add retrieve command to shell
file.AddCmd(&ishell.Cmd{
Name: "retrieve",
Aliases: []string{"retr", "recv", "get"},
Help: "Retrieve file from all nodes. File will be saved as 'file-user@node.ext'",
Func: func(c *ishell.Context) {
// Get remote and local paths
remotePath := c.Args[0]
localPath := c.Args[1]
// Create new wait group
wg := &sync.WaitGroup{}
// For every client
for name, client := range clients {
// Get extension, base, and directory of local path
localExt := filepath.Ext(localPath)
localBase := filepath.Base(localPath)
localDir := filepath.Dir(localPath)
// Attempt to stat local path
info, err := os.Stat(localPath)
if err != nil {
log.Fatal().Err(err).Msg("Error getting file info")
}
// If local path is a directory
if info.IsDir() {
// Set local directory to local path
localDir = localPath
// Set local extention to remote extension
localExt = filepath.Ext(remotePath)
// Set local base to remote base
localBase = filepath.Base(remotePath)
}
// Remove extension from local base
localBaseNoExt := strings.TrimSuffix(localBase, localExt)
// Create new local, formatted as "filename-user@node.extension"
newLocal := filepath.Join(localDir, localBaseNoExt+"-"+name+localExt)
// Set new remote to remote path
newRemote := remotePath
// If new remote starts with "~"
if strings.HasPrefix(newRemote, "~") {
// Replace ~ with "/home/user"
newRemote = filepath.Join(
"/home/"+client.User(),
strings.TrimPrefix(newRemote, "~"),
)
}
// Add goroutine to wait group
wg.Add(1)
go func(client *goph.Client, name string) {
// Remove from wait group at end of function
defer wg.Done()
// Download file from remote
err := client.Download(newRemote, newLocal)
if err != nil {
cmdError(shell, name, err)
return
}
// Print success message
cmdSuccess(shell, name, "download complete")
}(client, name)
// Wait for all added goroutines to complete
wg.Wait()
}
},
})
// Add file command to shell
shell.AddCmd(file)
// Run shell
shell.Run()
},
}
func init() {
rootCmd.AddCommand(msshCmd)
msshCmd.Flags().BoolP("password", "p", false, "Prompt for SSH password on start")
msshCmd.Flags().StringP("identity", "i", "", "SSH identity file to use")
viper.BindPFlags(msshCmd.Flags())
}
func getAllIdentities() (goph.Auth, error) {
// Get user's home directory
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
// Get all ssh keys in "~/.ssh"
matches, err := filepath.Glob(filepath.Join(home, ".ssh", "id_*.pub"))
if err != nil {
return nil, err
}
// Create goph auth for keys
var out goph.Auth
// For every glob match
for _, match := range matches {
// Get path of private key
privKeyPath := strings.TrimSuffix(match, ".pub")
// Get SSH key as goph ket
auth, err := goph.Key(privKeyPath, "")
if err != nil {
return out, err
}
// Add goph key to auth
out = append(out, auth...)
}
return out, nil
}
func closeClients(clients map[string]*goph.Client) {
// For every client
for _, client := range clients {
// Close client
client.Close()
}
}

74
cmd/lassoctl/cmd/proxy.go Normal file
View File

@@ -0,0 +1,74 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"os"
"os/exec"
"strings"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
// proxyCmd represents the proxy command
var proxyCmd = &cobra.Command{
Use: "proxy [flags] <user@node> <local-port:host:remote-port>",
Short: "Proxy a particular port from a node to a local port",
Example: "proxy pi@raspberrypi 8080:localhost:80",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 2 {
cmd.Usage()
os.Exit(1)
}
// Get node list from server
nodes := getNodeList()
// Split first argument by "@"
splitArg := strings.Split(args[0], "@")
// If less than two elements, argumen is invalid
if len(splitArg) != 2 {
log.Fatal().Msg("Invalid username/node argument")
}
// Get variables from split argument
username, nodeName := splitArg[0], splitArg[1]
// Get node from list if it exists
node, ok := nodes[nodeName]
if !ok {
log.Fatal().Str("node", nodeName).Msg("Node does not exist on the server")
}
// Create ssh command
ssh := exec.Command("ssh", username+"@"+node.IP, "-L", args[1], "-N")
ssh.Stdin = os.Stdin
ssh.Stdout = os.Stdout
ssh.Stderr = os.Stderr
// Run ssh command
if err := ssh.Run(); err != nil {
log.Fatal().Err(err).Msg("Error received from ssh command")
}
},
}
func init() {
rootCmd.AddCommand(proxyCmd)
}

View File

@@ -0,0 +1,95 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"os"
"os/exec"
"regexp"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
// retrieveCmd represents the retrieve command
var retrieveCmd = &cobra.Command{
Use: "retrieve",
Short: "A brief description of your command",
Aliases: []string{"retr", "recv", "get"},
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 2 {
cmd.Usage()
os.Exit(1)
}
// Get list of nodes from server
nodes := getNodeList()
// Compile regular expression for target argument
regex := regexp.MustCompile(`(.+)@(.+):(.+)`)
var sources []string
// For every argument
for index, arg := range args {
// Stop if last argument
if index == len(args)-1 {
break
}
// Find submatches in current argument
submatches := regex.FindStringSubmatch(arg)
// If no submatches found
if submatches == nil {
log.Fatal().Msg("Invalid target argument")
}
// Get variables from submatches
username, nodeName, path := submatches[1], submatches[2], submatches[3]
// Get node from list if it exists
node, ok := nodes[nodeName]
if !ok {
log.Fatal().Str("node", nodeName).Msg("Node does not exist on the server")
}
// Add new source to sources slice
sources = append(sources, username+"@"+node.IP+":"+path)
}
// Get last argument
file := args[len(args)-1]
// Create arguments for scp command
var scpArgs []string
scpArgs = append(scpArgs, sources...)
scpArgs = append(scpArgs, file)
// Create scp command
scp := exec.Command("scp", scpArgs...)
scp.Stdin = os.Stdin
scp.Stdout = os.Stdout
scp.Stderr = os.Stderr
// Run scp command
if err := scp.Run(); err != nil {
log.Fatal().Err(err).Msg("Error received from scp command")
}
},
}
func init() {
fileCmd.AddCommand(retrieveCmd)
}

126
cmd/lassoctl/cmd/root.go Normal file
View File

@@ -0,0 +1,126 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"net"
"net/http"
"os"
"github.com/abiosoft/ishell"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/vmihailenco/msgpack/v5"
"go.arsenm.dev/lasso/internal/types"
"github.com/spf13/viper"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "lassoctl",
Short: "Manage devices using IPs retrieved from a lasso server",
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
cobra.CheckErr(rootCmd.Execute())
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "Config file to use")
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".lassoctl" (without extension).
viper.AddConfigPath(".")
viper.AddConfigPath(home)
viper.SetConfigType("toml")
viper.SetConfigName("lassoctl")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
viper.ReadInConfig()
}
// getNodeList gets a list of nodes from the lasso server
func getNodeList() map[string]types.Node {
// Get server address
addr := net.JoinHostPort(viper.GetString("server.addr"), viper.GetString("server.port"))
// Create base url for HTTPS server
baseURL := "https://" + addr
// Get node list
res, err := http.Get(baseURL + "/node/list")
if err != nil {
log.Fatal().Err(err).Msg("Error getting node list from server")
}
var resp types.Response
// Decode server response
err = msgpack.NewDecoder(res.Body).Decode(&resp)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding server response")
}
// If server response is an error
if resp.Error {
log.Fatal().Str("error", resp.Message).Msg("Server returned error")
}
var nodes map[string]types.Node
// Decode response data as node map
err = mapstructure.Decode(resp.Data, &nodes)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding nodes map")
}
// Return node map
return nodes
}
// cmdError prints an error while running mssh
func cmdError(shell *ishell.Shell, node string, err error) {
shell.Printf("%s [error]: %v\n", node, err)
}
// cmdOut prints output from a command while running mssh
func cmdOut(shell *ishell.Shell, node, out string) {
shell.Printf("%s [out]: %s\n", node, out)
}
// cmdSucces prints a success message while running mssh
func cmdSuccess(shell *ishell.Shell, node, msg string) {
shell.Printf("%s [success]: %s\n", node, msg)
}

81
cmd/lassoctl/cmd/send.go Normal file
View File

@@ -0,0 +1,81 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"os"
"os/exec"
"regexp"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
// sendCmd represents the send command
var sendCmd = &cobra.Command{
Use: "send <file...> <username@node:path>",
Short: "Send a file to the node via ssh",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 2 {
cmd.Usage()
os.Exit(1)
}
// Get list of nodes from server
nodes := getNodeList()
// Compile regular expression for target argument
regex := regexp.MustCompile(`(.+)@(.+):(.+)`)
// Find submatches within last argument
submatches := regex.FindStringSubmatch(args[len(args)-1])
// If no submatches found
if submatches == nil {
log.Fatal().Msg("Invalid target argument")
}
// Files are all arguments except last one
files := args[0 : len(args)-1]
// Get variables from submatches
username, nodeName, path := submatches[1], submatches[2], submatches[3]
// Get node from list if it exists
node, ok := nodes[nodeName]
if !ok {
log.Fatal().Str("node", nodeName).Msg("Node does not exist on the server")
}
// Create arguments for scp command
var scpArgs []string
scpArgs = append(scpArgs, files...)
scpArgs = append(scpArgs, username+"@"+node.IP+":"+path)
// Create scp command
scp := exec.Command("scp", scpArgs...)
scp.Stdin = os.Stdin
scp.Stdout = os.Stdout
scp.Stderr = os.Stderr
// Run scp command
if err := scp.Run(); err != nil {
log.Fatal().Err(err).Msg("Error received from scp command")
}
},
}
func init() {
fileCmd.AddCommand(sendCmd)
}

72
cmd/lassoctl/cmd/ssh.go Normal file
View File

@@ -0,0 +1,72 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"os"
"os/exec"
"strings"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
// sshCmd represents the ssh command
var sshCmd = &cobra.Command{
Use: "ssh <user@node>",
Short: "Make an ssh connection to the specified node",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
cmd.Usage()
os.Exit(1)
}
// Get list of nodes from server
nodes := getNodeList()
// Split first argument by "@"
splitArg := strings.Split(args[0], "@")
// If split string has less than two elements, argument is invalid
if len(splitArg) != 2 {
log.Fatal().Msg("Invalid username/node argument")
}
// Get variables from split string
username, nodeName := splitArg[0], splitArg[1]
// Get node from list if it exists
node, ok := nodes[nodeName]
if !ok {
log.Fatal().Str("node", nodeName).Msg("Node does not exist on the server")
}
// Create SSH command using node IP
ssh := exec.Command("ssh", username+"@"+node.IP)
ssh.Stdin = os.Stdin
ssh.Stdout = os.Stdout
ssh.Stderr = os.Stderr
// Attempt to run command
if err := ssh.Run(); err != nil {
log.Fatal().Err(err).Msg("Error received from ssh command")
}
},
}
func init() {
rootCmd.AddCommand(sshCmd)
}

37
cmd/lassoctl/main.go Normal file
View File

@@ -0,0 +1,37 @@
/*
Copyright © 2021 Arsen Musayelyan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package main
import (
"crypto/tls"
"net/http"
"os"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go.arsenm.dev/lasso/cmd/lassoctl/cmd"
)
func init() {
// Disable certificate verification as server uses self-signed key
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
func main() {
cmd.Execute()
}