Skip to content

Commit

Permalink
Merge pull request #143 from mhilton/013-juju-websocket-api-login
Browse files Browse the repository at this point in the history
Add login to websocket API

Support macaroon login on the websocket API. If connecting to a
non-controller model, send a redirect to the hosting controller.
  • Loading branch information
jujugui authored Jul 6, 2016
2 parents 7bcb61a + 6d905d5 commit 544fed8
Show file tree
Hide file tree
Showing 8 changed files with 443 additions and 147 deletions.
2 changes: 1 addition & 1 deletion dependencies.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ github.com/juju/gomaasapi git 5bd7212f416a2d801e4a39800b66e1ee4461c42e 2016-05-0
github.com/juju/httpprof git 14bf14c307672fd2456bdbf35d19cf0ccd3cf565 2014-12-17T16:00:36Z
github.com/juju/httprequest git 796aaafaf712f666df58d31a482c51233038bf9f 2016-05-03T15:03:27Z
github.com/juju/idmclient git 3dda079a75cccb85083d4c3877e638f5d6ab79c2 2016-05-26T05:00:34Z
github.com/juju/juju git 894de5b4a1d3955f5edd79f19255e41c0ab23d9c 2016-06-30T14:53:07Z
github.com/juju/juju git 50dd7bb3961f0fb8090c438dc111f68efc4e605e 2016-07-01T23:42:52Z
github.com/juju/loggo git 8477fc936adf0e382d680310047ca27e128a309a 2015-05-27T03:58:39Z
github.com/juju/mgoutil git 5f725bbe1f9842b097129570e79441be24012e9c 2016-05-20T10:19:24Z
github.com/juju/mutex git 59c26ee163447c5c57f63ff71610d433862013de 2016-06-17T01:09:07Z
Expand Down
63 changes: 63 additions & 0 deletions internal/apitest/apitest.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/juju/idmclient/idmtest"
corejujutesting "github.com/juju/juju/juju/testing"
"github.com/juju/testing"
jc "github.com/juju/testing/checkers"
"github.com/juju/testing/httptesting"
gc "gopkg.in/check.v1"
"gopkg.in/macaroon-bakery.v1/bakery"
Expand Down Expand Up @@ -127,6 +128,68 @@ func (s *Suite) NewServer(c *gc.C, session *mgo.Session, idmSrv *idmtest.Server)
return srv.(*jemserver.Server)
}

// AssertAddController adds the specified controller using AddController
// and checks that id succeeds. It returns the controller id.
func (s *Suite) AssertAddController(c *gc.C, path params.EntityPath, loc map[string]string) params.EntityPath {
err := s.AddController(c, path, loc)
c.Assert(err, jc.ErrorIsNil)
return path
}

// AddController adds a new controller with the provided path and any
// specified location parameters.
func (s *Suite) AddController(c *gc.C, path params.EntityPath, loc map[string]string) error {
// Note that because the cookies acquired in this request don't
// persist, the discharge macaroon we get won't affect subsequent
// requests in the caller.
info := s.APIInfo(c)
p := &params.AddController{
EntityPath: path,
Info: params.ControllerInfo{
HostPorts: info.Addrs,
CACert: info.CACert,
User: info.Tag.Id(),
Password: info.Password,
ControllerUUID: info.ModelTag.Id(),
Location: loc,
},
}
// If locations are specified then make the controller public so
// that it can be found by seraching on those locations.
if len(loc) > 0 {
p.Info.Public = true
s.IDMSrv.AddUser(string(path.User), "controller-admin")
}
return s.NewClient(path.User).AddController(p)
}

const dummySSHKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDOjaOjVRHchF2RFCKQdgBqrIA5nOoqSprLK47l2th5I675jw+QYMIihXQaITss3hjrh3+5ITyBO41PS5rHLNGtlYUHX78p9CHNZsJqHl/z1Ub1tuMe+/5SY2MkDYzgfPtQtVsLasAIiht/5g78AMMXH3HeCKb9V9cP6/lPPq6mCMvg8TDLrPp/P2vlyukAsJYUvVgoaPDUBpedHbkMj07pDJqe4D7c0yEJ8hQo/6nS+3bh9Q1NvmVNsB1pbtk3RKONIiTAXYcjclmOljxxJnl1O50F5sOIi38vyl7Q63f6a3bXMvJEf1lnPNJKAxspIfEu8gRasny3FEsbHfrxEwVj rog@rog-x220"

var dummyModelConfig = map[string]interface{}{
"authorized-keys": dummySSHKey,
"controller": true,
}

// CreateModel creates a new model with the specified path on the
// specified controller, using the specified templates. It returns the
// new model's path, user and uuid.
func (s *Suite) CreateModel(c *gc.C, path, ctlPath params.EntityPath, templates ...params.EntityPath) (modelPath params.EntityPath, user, uuid string) {
// Note that because the cookies acquired in this request don't
// persist, the discharge macaroon we get won't affect subsequent
// requests in the caller.
resp, err := s.NewClient(path.User).NewModel(&params.NewModel{
User: path.User,
Info: params.NewModelInfo{
Name: path.Name,
Controller: &ctlPath,
Config: dummyModelConfig,
TemplatePaths: templates,
},
})
c.Assert(err, gc.IsNil)
return resp.Path, resp.User, resp.UUID
}

// Do returns a Do function appropriate for using in httptesting.AssertJSONCall.Do
// that makes its HTTP request acting as the given client.
// If client is nil, it uses httpbakery.NewClient instead.
Expand Down
15 changes: 15 additions & 0 deletions internal/jem/jem.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,21 @@ func (j *JEM) Model(path params.EntityPath) (*mongodoc.Model, error) {
return &m, nil
}

// ModelFromUUID returns the document representing the model with the
// given UUID. It returns an error with a params.ErrNotFound cause if the
// controller was not found.
func (j *JEM) ModelFromUUID(uuid string) (*mongodoc.Model, error) {
var m mongodoc.Model
err := j.DB.Models().Find(bson.D{{"uuid", uuid}}).One(&m)
if err == mgo.ErrNotFound {
return nil, errgo.WithCausef(nil, params.ErrNotFound, "model %q not found", uuid)
}
if err != nil {
return nil, errgo.Notef(err, "cannot get model %q", uuid)
}
return &m, nil
}

// ErrAPIConnection is returned by OpenAPI and OpenAPIFromDocs
// when the API connection cannot be made.
var ErrAPIConnection = errgo.New("cannot connect to API")
Expand Down
26 changes: 26 additions & 0 deletions internal/jem/jem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,32 @@ func (s *jemSuite) TestAddModel(c *gc.C) {
c.Assert(errgo.Cause(err), gc.Equals, params.ErrAlreadyExists)
}

func (s *jemSuite) TestModelFromUUID(c *gc.C) {
uuid := "99999999-9999-9999-9999-999999999999"
path := params.EntityPath{"bob", "x"}
m := &mongodoc.Model{
Id: "ignored",
Path: path,
UUID: uuid,
}
err := s.store.AddModel(m)
c.Assert(err, gc.IsNil)
c.Assert(m, jc.DeepEquals, &mongodoc.Model{
Id: "bob/x",
Path: path,
UUID: uuid,
})

m1, err := s.store.ModelFromUUID(uuid)
c.Assert(err, gc.IsNil)
c.Assert(m1, jc.DeepEquals, m)

m2, err := s.store.ModelFromUUID("no-such-uuid")
c.Assert(err, gc.ErrorMatches, `model "no-such-uuid" not found`)
c.Assert(errgo.Cause(err), gc.Equals, params.ErrNotFound)
c.Assert(m2, gc.IsNil)
}

func (s *jemSuite) TestAddTemplate(c *gc.C) {
path := params.EntityPath{"bob", "x"}
tmpl := &mongodoc.Template{
Expand Down
2 changes: 1 addition & 1 deletion internal/jujuapi/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/CanonicalLtd/jem/internal/jemserver"
)

func NewAPIHandler(jp *jem.Pool, sp jemserver.Params) ([]httprequest.Handler, error) {
func NewAPIHandler(jp *jem.Pool, _ jemserver.Params) ([]httprequest.Handler, error) {
return []httprequest.Handler{
newWebSocketHandler(jp),
}, nil
Expand Down
177 changes: 158 additions & 19 deletions internal/jujuapi/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,51 @@
package jujuapi

import (
"reflect"

"github.com/juju/juju/apiserver/common"
"github.com/juju/juju/apiserver/observer"
jujuparams "github.com/juju/juju/apiserver/params"
"github.com/juju/juju/network"
"github.com/juju/juju/rpc"
"github.com/juju/juju/rpc/jsoncodec"
"github.com/juju/juju/rpc/rpcreflect"
"github.com/juju/loggo"
"golang.org/x/net/websocket"
"gopkg.in/errgo.v1"
"gopkg.in/juju/names.v2"
"gopkg.in/macaroon-bakery.v1/bakery"
"gopkg.in/macaroon-bakery.v1/bakery/checkers"

"github.com/CanonicalLtd/jem/internal/jem"
"github.com/CanonicalLtd/jem/internal/mongodoc"
"github.com/CanonicalLtd/jem/params"
)

// newWSServer creates a new websocket server suitible for handling the api for modelUUID.
var logger = loggo.GetLogger("jem.internal.jujuapi")

// mapError maps JEM errors to errors suitable for use with the juju API.
func mapError(err error) error {
if err == nil {
return nil
}
logger.Debugf("error: %s\n details: %s", err.Error(), errgo.Details(err))
if _, ok := err.(*jujuparams.Error); ok {
return err
}
msg := err.Error()
code := ""
switch errgo.Cause(err) {
case params.ErrNotFound:
code = jujuparams.CodeNotFound
}
return &jujuparams.Error{
Message: msg,
Code: code,
}
}

// newWSServer creates a new WebSocket server suitible for handling the API for modelUUID.
func newWSServer(jem *jem.JEM, modelUUID string) websocket.Server {
hnd := wsHandler{
jem: jem,
Expand All @@ -24,42 +58,147 @@ func newWSServer(jem *jem.JEM, modelUUID string) websocket.Server {
}
}

// wsHandler is a handler for a particular websocket connection.
// wsHandler is a handler for a particular WebSocket connection.
type wsHandler struct {
jem *jem.JEM
modelUUID string
jem *jem.JEM
modelUUID string
conn *rpc.Conn
model *mongodoc.Model
controller *mongodoc.Controller
}

// handle handles the connection.
func (h *wsHandler) handle(wsConn *websocket.Conn) {
codec := jsoncodec.NewWebsocket(wsConn)
conn := rpc.NewConn(codec, observer.None())
h.conn = rpc.NewConn(codec, observer.None())

// TODO(mhilton) serve something useful on this connection.
err := common.UnknownModelError(h.modelUUID)
conn.ServeFinder(&errRoot{err}, serverError)
conn.Start()
var root rpc.MethodFinder
root = adminRoot{admin{h}}
err := h.resolveUUID()
if err != nil {
root = &errRoot{err}
}
h.conn.ServeFinder(root, mapError)
h.conn.Start()
select {
case <-conn.Dead():
case <-h.conn.Dead():
}
conn.Close()
h.conn.Close()
}

func serverError(err error) error {
if err := common.ServerError(err); err != nil {
return err
func (h *wsHandler) resolveUUID() error {
var err error
h.model, err = h.jem.ModelFromUUID(h.modelUUID)
if err != nil {
return errgo.Mask(err, errgo.Is(params.ErrNotFound))
}
h.controller, err = h.jem.Controller(h.model.Controller)
return errgo.Mask(err)
}

type admin struct {
handler *wsHandler
}

func (a admin) Admin(id string) (admin, error) {
if id != "" {
// Safeguard id for possible future use.
return admin{}, common.ErrBadId
}
return a, nil
}

// Login implements the Login method on the Admin facade.
func (a admin) Login(req jujuparams.LoginRequest) (jujuparams.LoginResultV1, error) {
// JAAS only supports macaroon login, ignore all the other fields.
attr, err := a.handler.jem.Bakery.CheckAny(req.Macaroons, nil, checkers.TimeBefore)
if err != nil {
if verr, ok := err.(*bakery.VerificationError); ok {
m, err := a.handler.jem.NewMacaroon()
if err != nil {
return jujuparams.LoginResultV1{}, errgo.Notef(err, "cannot create macaroon")
}
return jujuparams.LoginResultV1{
DischargeRequired: m,
DischargeRequiredReason: verr.Error(),
}, nil
}
return jujuparams.LoginResultV1{}, errgo.Mask(err)
}
a.handler.jem.Auth.Username = attr["username"]
if err := a.handler.jem.CheckCanRead(a.handler.model); err != nil {
return jujuparams.LoginResultV1{}, errgo.Mask(err, errgo.Is(params.ErrUnauthorized))
}

// Login successful
a.handler.jem.Auth = jem.Authorization{attr["username"]}

// If the UUID is for a model send a redirect error.
if a.handler.model.Id != a.handler.controller.Id {
return jujuparams.LoginResultV1{}, &jujuparams.Error{
Code: jujuparams.CodeRedirect,
Message: "redirect required",
}
}

// TODO (mhilton) serve some new methods
return jujuparams.LoginResultV1{
ModelTag: names.NewModelTag(a.handler.model.UUID).String(),
ControllerTag: names.NewModelTag(a.handler.controller.UUID).String(),
ServerVersion: "2.0.0",
}, nil
}

// RedirectInfo implements the RedirectInfo method on the Admin facade.
func (a admin) RedirectInfo() (jujuparams.RedirectInfoResult, error) {
if a.handler.jem.Auth.Username == "" {
return jujuparams.RedirectInfoResult{}, params.ErrUnauthorized
}
if err := a.handler.jem.CheckCanRead(a.handler.model); err != nil {
return jujuparams.RedirectInfoResult{}, errgo.Mask(err, errgo.Is(params.ErrUnauthorized))
}
if a.handler.model.Id == a.handler.controller.Id {
return jujuparams.RedirectInfoResult{}, errgo.New("not redirected")
}
nhps, err := network.ParseHostPorts(a.handler.controller.HostPorts...)
if err != nil {
return jujuparams.RedirectInfoResult{}, errgo.Mask(err)
}
hps := jujuparams.FromNetworkHostPorts(nhps)
return jujuparams.RedirectInfoResult{
Servers: [][]jujuparams.HostPort{hps},
CACert: a.handler.controller.CACert,
}, nil
}

// adminRoot is a rpc.MethodFinder that implements the admin interface.
type adminRoot struct {
admin
}

// FindMethod implements rpc.MethodFinder.
func (r adminRoot) FindMethod(rootName string, version int, methodName string) (rpcreflect.MethodCaller, error) {
if rootName != "Admin" {
return nil, &rpcreflect.CallNotImplementedError{
RootMethod: rootName,
Version: version,
}
}
if version < 3 {
return nil, &rpc.RequestError{
Code: jujuparams.CodeNotSupported,
Message: "JAAS does not support login from old clients",
}
}
return nil
return rpcreflect.ValueOf(reflect.ValueOf(r.admin)).FindMethod("Admin", 0, methodName)
}

// errRoot implements the API that a client first sees
// when connecting to the API. It exposes the same API as initialRoot, except
// it returns the requested error when the client makes any request.
// errRoot is a rpc.MethodFinder that always returns an error.
type errRoot struct {
err error
}

// FindMethod conforms to the same API as initialRoot, but we'll always return (nil, err)
// FindMethod implements rpc.MethodFinder, but will always return (nil, err)
func (r *errRoot) FindMethod(rootName string, version int, methodName string) (rpcreflect.MethodCaller, error) {
return nil, r.err
}
Loading

0 comments on commit 544fed8

Please sign in to comment.