diff --git a/callhome.go b/callhome.go new file mode 100644 index 0000000..80fadb5 --- /dev/null +++ b/callhome.go @@ -0,0 +1,210 @@ +package netconf + +import ( + "crypto/tls" + "errors" + "fmt" + "github.com/nemith/netconf/transport" + ncssh "github.com/nemith/netconf/transport/ssh" + nctls "github.com/nemith/netconf/transport/tls" + "golang.org/x/crypto/ssh" + "net" +) + +var ErrNoClientConfig = errors.New("missing transport configuration") + +// CallHomeTransport interface allows for upgrading an incoming callhome TCP connection into a transport +type CallHomeTransport interface { + DialWithConn(conn net.Conn) (transport.Transport, error) +} + +// SSHCallHomeTransport implements the CallHomeTransport on SSH +type SSHCallHomeTransport struct { + Config *ssh.ClientConfig +} + +// DialWithConn is same as Dial but creates the transport on top of input net.Conn +func (t *SSHCallHomeTransport) DialWithConn(conn net.Conn) (transport.Transport, error) { + sshConn, chans, reqs, err := ssh.NewClientConn(conn, conn.RemoteAddr().String(), t.Config) + if err != nil { + return nil, err + } + client := ssh.NewClient(sshConn, chans, reqs) + return ncssh.NewTransport(client) +} + +// TLSCallHomeTransport implements the CallHomeTransport on TLS +type TLSCallHomeTransport struct { + Config *tls.Config +} + +// DialWithConn is same as Dial but creates the transport on top of input net.Conn +func (t *TLSCallHomeTransport) DialWithConn(conn net.Conn) (transport.Transport, error) { + tlsConn := tls.Client(conn, t.Config) + return nctls.NewTransport(tlsConn), nil +} + +/* +CallHomeClientConfig holds connecting callhome device information +*/ +type CallHomeClientConfig struct { + Transport CallHomeTransport + Address string +} + +type CallHomeClient struct { + session *Session + *CallHomeClientConfig +} + +func (chc *CallHomeClient) Session() *Session { + return chc.session +} + +type ClientError struct { + Address string + Err error +} + +func (ce *ClientError) Error() string { + return fmt.Sprintf("client %s: %s", ce.Address, ce.Err.Error()) +} + +/* +CallHomeServer implements netconf callhome procedure as specified in RFC 8071 +*/ +type CallHomeServer struct { + listener net.Listener + network string + addr string + clientsConfig map[string]*CallHomeClientConfig + clientsChannel chan *CallHomeClient + errorChannel chan *ClientError +} + +type CallHomeOption func(*CallHomeServer) + +// WithAddress sets the address (as required by net.Listen) the CallHomeServer server listen to +func WithAddress(addr string) CallHomeOption { + return func(ch *CallHomeServer) { + ch.addr = addr + } +} + +// WithNetwork set the network (as required by net.Listen) the CallHomeServer server listen to +func WithNetwork(network string) CallHomeOption { + return func(ch *CallHomeServer) { + ch.network = network + } +} + +// WithCallHomeClientConfig set the netconf callhome clientsConfig +func WithCallHomeClientConfig(chc ...*CallHomeClientConfig) CallHomeOption { + return func(chs *CallHomeServer) { + for _, c := range chc { + chs.clientsConfig[c.Address] = c + } + } +} + +// NewCallHomeServer creates a CallHomeServer +func NewCallHomeServer(opts ...CallHomeOption) (*CallHomeServer, error) { + const ( + defaultAddress = "0.0.0.0:4334" + defaultNetwork = "tcp" + ) + + ch := &CallHomeServer{ + addr: defaultAddress, + network: defaultNetwork, + clientsConfig: map[string]*CallHomeClientConfig{}, + clientsChannel: make(chan *CallHomeClient), + errorChannel: make(chan *ClientError), + } + + for _, opt := range opts { + opt(ch) + } + + if ch.network != "tcp" && ch.network != "tcp4" && ch.network != "tcp6" { + return nil, fmt.Errorf("invalid network, must be one of: tcp, tcp4, tcp6") + } + + return ch, nil +} + +// Listen waits for incoming callhome connections and handles them. +// Send ClientError messages to the ErrChan whenever a callhome connection to a host fails and +// send a new CallHomeClient every time a callhome connection is successful +func (chs *CallHomeServer) Listen() error { + ln, err := net.Listen(chs.network, chs.addr) + if err != nil { + return err + } + chs.listener = ln + defer func() { + _ = chs.Close() + }() + for { + conn, err := chs.listener.Accept() + if err != nil { + return err + } + go func() { + chc, err := chs.handleConnection(conn) + if err != nil { + chs.errorChannel <- &ClientError{ + Address: conn.RemoteAddr().String(), + Err: err, + } + } else { + chs.clientsChannel <- chc + } + }() + } +} + +// handleConnection upgrade input net.Conn to establish a netconf session +func (chs *CallHomeServer) handleConnection(conn net.Conn) (*CallHomeClient, error) { + addr, ok := conn.RemoteAddr().(*net.TCPAddr) + if !ok { + return nil, errors.New("invalid network connection, callhome support tcp only") + } + chcc, ok := chs.clientsConfig[addr.IP.String()] + if !ok { + return nil, ErrNoClientConfig + } + + t, err := chcc.Transport.DialWithConn(conn) + if err != nil { + return nil, err + } + + s, err := Open(t) + if err != nil { + return nil, err + } + + return &CallHomeClient{ + session: s, + CallHomeClientConfig: chcc, + }, nil +} + +// Close terminates the callhome server connection +func (chs *CallHomeServer) Close() error { + return chs.listener.Close() +} + +func (chs *CallHomeServer) ErrorChannel() chan *ClientError { + return chs.errorChannel +} + +func (chs *CallHomeServer) CallHomeClientChannel() chan *CallHomeClient { + return chs.clientsChannel +} + +// SetCallHomeClientConfig adds a new callhome client configuration to the callhome server +func (chs *CallHomeServer) SetCallHomeClientConfig(chcc *CallHomeClientConfig) { + chs.clientsConfig[chcc.Address] = chcc +} diff --git a/examples/callhome_ssh/callhome_ssh.go b/examples/callhome_ssh/callhome_ssh.go new file mode 100644 index 0000000..f372efb --- /dev/null +++ b/examples/callhome_ssh/callhome_ssh.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "github.com/nemith/netconf" + "golang.org/x/crypto/ssh" + "log" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + sigChannel := make(chan os.Signal, 1) + signal.Notify(sigChannel, os.Interrupt, syscall.SIGTERM) + + chcList := []*netconf.CallHomeClientConfig{ + { + Transport: &netconf.SSHCallHomeTransport{ + Config: &ssh.ClientConfig{ + User: "foo", + Auth: []ssh.AuthMethod{ + ssh.Password("bar"), + }, + // as specified in rfc8071 3.1 C5 netconf client must validate host keys + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }, + }, + Address: "192.168.121.17", + }, + } + + chs, err := netconf.NewCallHomeServer(netconf.WithCallHomeClientConfig(chcList...), netconf.WithAddress("0.0.0.0:4339")) + if err != nil { + panic(err) + } + log.Printf("callhome server listening on: %s", "0.0.0.0:4339") + go func() { + err := chs.Listen() + if err != nil { + panic(err) + } + }() + + go func() { + for { + select { + case e := <-chs.ErrorChannel(): + fmt.Println(e.Error()) + case chc := <-chs.CallHomeClientChannel(): + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + deviceConfig, err := chc.Session().GetConfig(ctx, "running") + cancel() + if err != nil { + log.Fatalf("failed to get config: %v", err) + } + log.Printf("Config:\n%s\n", deviceConfig) + } + } + }() + select { + case <-sigChannel: + if err := chs.Close(); err != nil { + log.Print(err) + } + os.Exit(0) + } +} diff --git a/transport/ssh/ssh.go b/transport/ssh/ssh.go index 89e4c98..b8d2737 100644 --- a/transport/ssh/ssh.go +++ b/transport/ssh/ssh.go @@ -84,8 +84,8 @@ func newTransport(client *ssh.Client, owned bool) (*Transport, error) { }, nil } -// Close will close the underlying transport. If the connection was created -// with Dial then then underlying ssh.Client is closed as well. If not only +// Close will close the underlying transport. If the connection was created +// with Dial then underlying ssh.Client is closed as well. If not only // the sessions is closed. func (t *Transport) Close() error { if err := t.sess.Close(); err != nil {