-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'v3' into svc-acc-manager
- Loading branch information
Showing
6 changed files
with
348 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
// Copyright 2025 Canonical. | ||
|
||
package ssh | ||
|
||
type ForwardMessage = forwardMessage |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
// Copyright 2025 Canonical. | ||
|
||
package ssh | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"io" | ||
"net" | ||
|
||
"github.com/gliderlabs/ssh" | ||
"github.com/juju/zaputil/zapctx" | ||
"go.uber.org/zap" | ||
gossh "golang.org/x/crypto/ssh" | ||
|
||
"github.com/canonical/jimm/v3/internal/openfga" | ||
) | ||
|
||
// juju_ssh_default_port is the default port we expect the juju controllers to respond on. | ||
const juju_ssh_default_port = 17022 | ||
|
||
// Resolver is the interface with the methods needed by the ssh jump server to route request. | ||
type Resolver interface { | ||
// AddrFromModelUUID is the method to resolve the address of the controller to contact given the model UUID. | ||
AddrFromModelUUID(ctx context.Context, user openfga.User, modelUUID string) (string, error) | ||
} | ||
|
||
// fowardMessage is the struct holding the information about the jump message received by the ssh client. | ||
type forwardMessage struct { | ||
DestAddr string | ||
DestPort uint32 | ||
SrcAddr string | ||
SrcPort uint32 | ||
} | ||
|
||
// Server is the custom struct to embed the gliderlabs.ssh server and a resolver. | ||
type Server struct { | ||
*ssh.Server | ||
|
||
resolver Resolver | ||
} | ||
|
||
// NewJumpSSHServer creates the jump server struct. | ||
func NewJumpSSHServer(ctx context.Context, port int, resolver Resolver) (Server, error) { | ||
zapctx.Info(ctx, "NewSSHServer") | ||
|
||
if resolver == nil { | ||
return Server{}, fmt.Errorf("Cannot create JumpSSHServer with a nil resolver.") | ||
} | ||
server := Server{ | ||
Server: &ssh.Server{ | ||
Addr: fmt.Sprintf(":%d", port), | ||
ChannelHandlers: map[string]ssh.ChannelHandler{ | ||
"direct-tcpip": directTCPIPHandler(resolver), | ||
}, | ||
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { | ||
return true | ||
}, | ||
}, | ||
resolver: resolver, | ||
} | ||
|
||
return server, nil | ||
} | ||
|
||
func directTCPIPHandler(resolver Resolver) func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { | ||
return func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { | ||
d := forwardMessage{} | ||
|
||
k := newChan.ExtraData() | ||
|
||
if err := gossh.Unmarshal(k, &d); err != nil { | ||
rejectConnectionAndLogError(ctx, newChan, "Failed to parse channel data", err) | ||
return | ||
} | ||
if d.DestPort == 0 { | ||
d.DestPort = juju_ssh_default_port | ||
} | ||
addr, err := resolver.AddrFromModelUUID(ctx, openfga.User{}, d.DestAddr) | ||
if err != nil { | ||
rejectConnectionAndLogError(ctx, newChan, "Failed to resolve address from model uuid", err) | ||
return | ||
} | ||
dest := net.JoinHostPort(addr, fmt.Sprint(d.DestPort)) | ||
// this is temporary. The way we dial to the controller will heavily change. | ||
client, err := gossh.Dial("tcp", dest, &gossh.ClientConfig{ | ||
//nolint:gosec // this will be removed once we handle hostkeys | ||
HostKeyCallback: gossh.InsecureIgnoreHostKey(), | ||
Auth: []gossh.AuthMethod{ | ||
gossh.PasswordCallback(func() (secret string, err error) { | ||
return "jwt", nil | ||
}), | ||
}, | ||
}) | ||
if err != nil { | ||
rejectConnectionAndLogError(ctx, newChan, fmt.Sprintf("Failed to connect to %s: %v", dest, err), err) | ||
return | ||
} | ||
|
||
dstChan, reqs, err := client.OpenChannel("direct-tcpip", gossh.Marshal(d)) | ||
if err != nil { | ||
rejectConnectionAndLogError(ctx, newChan, "Failed to open destination channel", err) | ||
return | ||
} | ||
// gossh.Request are requests sent outside of the normal stream of data (ex. pty-req for an interactive session). | ||
// Since we only need the raw data to redirect, we can discard them. | ||
go gossh.DiscardRequests(reqs) | ||
|
||
srcDest, reqs, err := newChan.Accept() | ||
if err != nil { | ||
dstChan.Close() | ||
return | ||
} | ||
// gossh.Request are requests sent outside of the normal stream of data (ex. pty-req for an interactive session). | ||
// Since we only need the raw data to redirect, we can discard them. | ||
go gossh.DiscardRequests(reqs) | ||
|
||
go func() { | ||
defer srcDest.Close() | ||
defer dstChan.Close() | ||
_, err := io.Copy(srcDest, dstChan) | ||
if err != nil { | ||
rejectConnectionAndLogError(ctx, newChan, "Failed to copy data from src to dts", err) | ||
} | ||
}() | ||
go func() { | ||
defer srcDest.Close() | ||
defer dstChan.Close() | ||
_, err := io.Copy(dstChan, srcDest) | ||
if err != nil { | ||
rejectConnectionAndLogError(ctx, newChan, "Failed to copy data from dst to src", err) | ||
} | ||
}() | ||
zapctx.Info(ctx, fmt.Sprintf("Proxying connection from %s:%d to %s:%d \n", d.SrcAddr, d.SrcPort, d.DestAddr, d.DestPort)) | ||
} | ||
} | ||
|
||
func rejectConnectionAndLogError(ctx context.Context, newChan gossh.NewChannel, msg string, err error) { | ||
zapctx.Error(ctx, msg, zap.Error(err)) | ||
err = newChan.Reject(gossh.ConnectionFailed, msg) | ||
if err != nil { | ||
zapctx.Error(ctx, msg, zap.Error(err)) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
// Copyright 2025 Canonical. | ||
|
||
package ssh_test | ||
|
||
import ( | ||
"context" | ||
"crypto/rand" | ||
"crypto/rsa" | ||
"crypto/x509" | ||
"encoding/pem" | ||
"fmt" | ||
"strconv" | ||
"strings" | ||
"testing" | ||
"time" | ||
|
||
qt "github.com/frankban/quicktest" | ||
"github.com/frankban/quicktest/qtsuite" | ||
gliderssh "github.com/gliderlabs/ssh" | ||
gossh "golang.org/x/crypto/ssh" | ||
|
||
"github.com/canonical/jimm/v3/internal/openfga" | ||
"github.com/canonical/jimm/v3/internal/ssh" | ||
"github.com/canonical/jimm/v3/internal/testutils/jimmtest" | ||
) | ||
|
||
type resolver struct{} | ||
|
||
func (r resolver) AddrFromModelUUID(ctx context.Context, user openfga.User, modelName string) (string, error) { | ||
return "", nil | ||
} | ||
|
||
type sshSuite struct { | ||
destinationJujuSSHServer gliderssh.Server | ||
destinationServerPort int | ||
jumpSSHServer ssh.Server | ||
jumpServerPort int | ||
privateKey gossh.Signer | ||
testInDestinationServerF func(fm ssh.ForwardMessage) | ||
received chan bool | ||
} | ||
|
||
func (s *sshSuite) Init(c *qt.C) { | ||
s.received = make(chan bool) | ||
port, err := jimmtest.GetFreePort() | ||
c.Assert(err, qt.IsNil) | ||
s.destinationServerPort = port | ||
s.destinationJujuSSHServer = gliderssh.Server{ | ||
Addr: fmt.Sprintf(":%d", port), | ||
ChannelHandlers: map[string]gliderssh.ChannelHandler{ | ||
"direct-tcpip": func(srv *gliderssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx gliderssh.Context) { | ||
d := ssh.ForwardMessage{} | ||
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil { | ||
err := newChan.Reject(gossh.ConnectionFailed, "Failed to parse channel data") | ||
c.Assert(err, qt.IsNil) | ||
return | ||
} | ||
_, _, err := newChan.Accept() | ||
c.Assert(err, qt.IsNil) | ||
s.testInDestinationServerF(d) | ||
s.received <- true | ||
}, | ||
}, | ||
} | ||
go func() { | ||
_ = s.destinationJujuSSHServer.ListenAndServe() | ||
}() | ||
s.destinationServerPort, err = strconv.Atoi(strings.Split(s.destinationJujuSSHServer.Addr, ":")[1]) | ||
c.Assert(err, qt.IsNil) | ||
|
||
port, err = jimmtest.GetFreePort() | ||
c.Assert(err, qt.IsNil) | ||
s.jumpServerPort = port | ||
s.jumpSSHServer, err = ssh.NewJumpSSHServer(context.Background(), port, resolver{}) | ||
c.Assert(err, qt.IsNil) | ||
go func() { | ||
_ = s.jumpSSHServer.ListenAndServe() | ||
}() | ||
|
||
k, err := rsa.GenerateKey(rand.Reader, 2048) | ||
c.Assert(err, qt.IsNil) | ||
keyPEM := pem.EncodeToMemory( | ||
&pem.Block{ | ||
Type: "RSA PRIVATE KEY", | ||
Bytes: x509.MarshalPKCS1PrivateKey(k), | ||
}, | ||
) | ||
|
||
s.privateKey, err = gossh.ParsePrivateKey(keyPEM) | ||
c.Assert(err, qt.IsNil) | ||
c.Cleanup(func() { | ||
err := s.destinationJujuSSHServer.Close() | ||
c.Check(err, qt.IsNil) | ||
err = s.jumpSSHServer.Close() | ||
c.Check(err, qt.IsNil) | ||
}) | ||
} | ||
|
||
func (s *sshSuite) TestSSHJump(c *qt.C) { | ||
client, err := gossh.Dial("tcp", fmt.Sprintf(":%d", s.jumpServerPort), &gossh.ClientConfig{ | ||
//nolint:gosec // this will be removed once we handle hostkeys | ||
HostKeyCallback: gossh.InsecureIgnoreHostKey(), | ||
Auth: []gossh.AuthMethod{ | ||
gossh.PublicKeys(s.privateKey), | ||
}, | ||
}) | ||
c.Assert(err, qt.IsNil) | ||
defer client.Close() | ||
|
||
// send forward message | ||
msg := ssh.ForwardMessage{ | ||
DestAddr: "model1", | ||
//nolint:gosec | ||
DestPort: uint32(s.destinationServerPort), | ||
SrcAddr: "localhost", | ||
SrcPort: 0, | ||
} | ||
s.testInDestinationServerF = func(fm ssh.ForwardMessage) { | ||
c.Check(fm.DestAddr, qt.Equals, "model1") | ||
} | ||
ch, _, err := client.OpenChannel("direct-tcpip", gossh.Marshal(&msg)) | ||
c.Check(err, qt.IsNil) | ||
defer ch.Close() | ||
select { | ||
case <-s.received: | ||
case <-time.After(100 * time.Millisecond): | ||
c.Fail() | ||
} | ||
} | ||
|
||
func (s *sshSuite) TestSSHJumpDialFail(c *qt.C) { | ||
_, err := gossh.Dial("tcp", fmt.Sprintf(":%d", 1), &gossh.ClientConfig{ | ||
//nolint:gosec // this will be removed once we handle hostkeys | ||
HostKeyCallback: gossh.InsecureIgnoreHostKey(), | ||
Auth: []gossh.AuthMethod{ | ||
gossh.PublicKeys(s.privateKey), | ||
}, | ||
}) | ||
c.Assert(err, qt.ErrorMatches, ".*connect: connection refused.*") | ||
} | ||
|
||
func (s *sshSuite) TestSSHFinalDestinationDialFail(c *qt.C) { | ||
|
||
client, err := gossh.Dial("tcp", fmt.Sprintf(":%d", s.jumpServerPort), &gossh.ClientConfig{ | ||
//nolint:gosec // this will be removed once we handle hostkeys | ||
HostKeyCallback: gossh.InsecureIgnoreHostKey(), | ||
Auth: []gossh.AuthMethod{ | ||
gossh.PublicKeys(s.privateKey), | ||
}, | ||
}) | ||
c.Assert(err, qt.IsNil) | ||
|
||
// send forward message | ||
msg := ssh.ForwardMessage{ | ||
DestAddr: "model1", | ||
//nolint:gosec | ||
DestPort: 1, // the test fails because there is no ssh server on this port. | ||
SrcAddr: "localhost", | ||
SrcPort: 0, | ||
} | ||
s.testInDestinationServerF = func(fm ssh.ForwardMessage) { | ||
c.Check(fm.DestAddr, qt.Equals, "model1") | ||
} | ||
_, _, err = client.OpenChannel("direct-tcpip", gossh.Marshal(&msg)) | ||
c.Assert(err, qt.ErrorMatches, ".*connect failed.*") | ||
|
||
} | ||
|
||
func TestIdentityManager(t *testing.T) { | ||
qtsuite.Run(qt.New(t), &sshSuite{}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
// Copyright 2025 Canonical. | ||
|
||
package jimmtest | ||
|
||
import ( | ||
"errors" | ||
"net" | ||
) | ||
|
||
// GetFreePort asks the kernel for a free open port that is ready to use. | ||
func GetFreePort() (int, error) { | ||
if a, err := net.ResolveTCPAddr("tcp", "localhost:0"); err == nil { | ||
var l *net.TCPListener | ||
if l, err = net.ListenTCP("tcp", a); err == nil { | ||
defer l.Close() | ||
return l.Addr().(*net.TCPAddr).Port, nil | ||
} | ||
} | ||
return 0, errors.New("Couldn't find any free port") | ||
} |