Skip to content

feat: Rbac more coderd endpoints, unit test to confirm #1406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
80 changes: 54 additions & 26 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type Options struct {
SecureAuthCookie bool
SSHKeygenAlgorithm gitsshkey.Algorithm
TURNServer *turnconn.Server
Authorizer *rbac.RegoAuthorizer
Authorizer rbac.Authorizer
}

// New constructs the Coder API into an HTTP handler.
Expand Down Expand Up @@ -83,8 +83,8 @@ func New(options *Options) (http.Handler, func()) {
// TODO: @emyrk we should just move this into 'ExtractAPIKey'.
authRolesMiddleware := httpmw.ExtractUserRoles(options.Database)

authorize := func(f http.HandlerFunc, actions rbac.Action) http.HandlerFunc {
return httpmw.Authorize(api.Logger, api.Authorizer, actions)(f).ServeHTTP
authorize := func(f http.HandlerFunc, actions ...rbac.Action) http.HandlerFunc {
return httpmw.Authorize(api.Logger, api.Authorizer, actions...)(f).ServeHTTP
}

r := chi.NewRouter()
Expand Down Expand Up @@ -127,20 +127,25 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/files", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
authRolesMiddleware,
// This number is arbitrary, but reading/writing
// file content is expensive so it should be small.
httpmw.RateLimitPerMinute(12),
// TODO: @emyrk (rbac) Currently files are owned by the site?
// Should files be org scoped? User scoped?
httpmw.WithRBACObject(rbac.ResourceFile),
Comment on lines +134 to +136
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving some TODOs to come back to as I add authorize to more endpoints. There are some routes I might want to move around or scope differently.

)
r.Get("/{hash}", api.fileByHash)
r.Post("/", api.postFile)
})
r.Route("/organizations/{organization}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
authRolesMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
)
r.Get("/", api.organization)
r.With(httpmw.WithRBACObject(rbac.ResourceOrganization)).
Get("/", authorize(api.organization, rbac.ActionRead))
Comment on lines +147 to +148
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This indent makes this harder to read. Can we not push this middleware into the r.Use() block?

Copy link
Member Author

@Emyrk Emyrk May 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just for 1 endpoint. I can put it inside a group block by itself with use?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

r.With(httpmw.WithRBACObject(rbac.ResourceOrganization)).
    Get("/", authorize(api.organization, rbac.ActionRead))

// ------------------
// Or
// ------------------

r.Group(func(r chi.Router) {
    r.Use(httpmw.WithRBACObject(rbac.ResourceOrganization))
    r.Get("/", authorize(api.organization, rbac.ActionRead))
})

r.Get("/provisionerdaemons", api.provisionerDaemonsByOrganization)
r.Post("/templateversions", api.postTemplateVersionsByOrganization)
r.Route("/templates", func(r chi.Router) {
Expand All @@ -149,12 +154,16 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/{templatename}", api.templateByOrganizationAndName)
})
r.Route("/workspaces", func(r chi.Router) {
r.Post("/", api.postWorkspacesByOrganization)
r.Get("/", api.workspacesByOrganization)
r.Use(httpmw.WithRBACObject(rbac.ResourceWorkspace))
// Posting a workspace is inherently owned by the api key creating it.
r.With(httpmw.WithAPIKeyAsOwner()).
Post("/", authorize(api.postWorkspacesByOrganization, rbac.ActionCreate))
r.Get("/", authorize(api.workspacesByOrganization, rbac.ActionRead))
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/{workspace}", api.workspaceByOwnerAndName)
r.Get("/", api.workspacesByOwner)
// TODO: @emyrk add the resource id to this authorize.
r.Get("/{workspace}", authorize(api.workspaceByOwnerAndName, rbac.ActionRead))
r.Get("/", authorize(api.workspacesByOwner, rbac.ActionRead))
})
})
r.Route("/members", func(r chi.Router) {
Expand Down Expand Up @@ -228,37 +237,56 @@ func New(options *Options) (http.Handler, func()) {
apiKeyMiddleware,
authRolesMiddleware,
)
r.Post("/", api.postUser)
r.Get("/", api.users)
r.Group(func(r chi.Router) {
// Site wide, all users
r.Use(httpmw.WithRBACObject(rbac.ResourceUser.All()))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't rbac.ResourceUser.All() identical to rbac.ResourceUser due to zero values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea it is. I added that when walking through the code to illustrate what it's doing. I forgot to remove it.

r.Post("/", authorize(api.postUser, rbac.ActionCreate))
r.Get("/", authorize(api.users, rbac.ActionRead))
})
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole))
r.Get("/", authorize(api.assignableSiteRoles, rbac.ActionRead))
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Put("/suspend", api.putUserSuspend)
r.Route("/password", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUserPasswordRole))
r.Put("/", authorize(api.putUserPassword, rbac.ActionUpdate))
r.Group(func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUser))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might add more resources for granular control of user settings, but for now, it's all clumped under update for the user resource.

r.Get("/", authorize(api.userByName, rbac.ActionRead))
r.Put("/profile", authorize(api.putUserProfile, rbac.ActionUpdate))
// suspension is deleting for a user
r.Put("/suspend", authorize(api.putUserSuspend, rbac.ActionDelete))
r.Route("/password", func(r chi.Router) {
r.Put("/", authorize(api.putUserPassword, rbac.ActionUpdate))
})
// This route technically also fetches the organization member struct, but only
// returns the roles.
r.Get("/roles", authorize(api.userRoles, rbac.ActionRead))

// This has 2 authorize calls. The second is explicitly called
// in the handler.
Comment on lines +267 to +268
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make it a middleware? I feel like calling authorize in the handler is a sign of a failure in what we originally want.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be a middleware made for just this endpoint. The authorize check has to be called on each role assigned/deleted. So it depends on the payload of the request body.

Some endpoints will likely have this because it's really difficult to determine the authorize input at the middleware level, as it might be dependent on the request body.


One good thing to note is that my unit test still ensures if we don't use the middleware, an authorize check is still called. We still need to add tests for the endpoint to test auth though and make sure a user can't give themselves admin for example.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, is this the only endpoint where we require multiple RBAC checks for a specific action? Or, do we need to make things more general?

r.Put("/roles", authorize(api.putUserRoles, rbac.ActionUpdate))

// For now, just use the "user" role for their ssh keys.
// We can always split this out to it's own resource if we need to.
r.Get("/gitsshkey", authorize(api.gitSSHKey, rbac.ActionRead))
r.Put("/gitsshkey", authorize(api.regenerateGitSSHKey, rbac.ActionUpdate))
})
r.Get("/organizations", api.organizationsByUser)
r.Post("/organizations", api.postOrganizationsByUser)
// These roles apply to the site wide permissions.
r.Put("/roles", api.putUserRoles)
r.Get("/roles", api.userRoles)

r.Post("/keys", api.postAPIKey)
r.With(httpmw.WithRBACObject(rbac.ResourceAPIKey)).Post("/keys", authorize(api.postAPIKey, rbac.ActionCreate))
r.Get("/workspaces", api.workspacesByUser)

r.Route("/organizations", func(r chi.Router) {
// TODO: @emyrk This creates an organization, so why is it nested under {user}?
// Shouldn't this be outside the {user} param subpath? Maybe in the organizations/
// path?
r.Post("/", api.postOrganizationsByUser)

r.Get("/", api.organizationsByUser)

// TODO: @emyrk why is this nested under {user} when the user param is not used?
r.Get("/{organizationname}", api.organizationByUserAndName)
})
r.Get("/gitsshkey", api.gitSSHKey)
r.Put("/gitsshkey", api.regenerateGitSSHKey)
r.Get("/workspaces", api.workspacesByUser)
})
})
})
Expand Down
183 changes: 182 additions & 1 deletion coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ package coderd_test

import (
"context"
"net/http"
"strings"
"testing"

"go.uber.org/goleak"
"golang.org/x/xerrors"

"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"

"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
)

func TestMain(m *testing.M) {
Expand All @@ -24,3 +30,178 @@ func TestBuildInfo(t *testing.T) {
require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL")
require.Equal(t, buildinfo.Version(), buildInfo.Version, "version")
}

// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered.
func TestAuthorizeAllEndpoints(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: This is a really useful check to have. Nice one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

t.Parallel()

authorizer := &fakeAuthorizer{}
srv, client := coderdtest.NewMemoryCoderd(t, &coderdtest.Options{
Authorizer: authorizer,
})
admin := coderdtest.CreateFirstUser(t, client)
organization, err := client.Organization(context.Background(), admin.OrganizationID)
require.NoError(t, err, "fetch org")

// Always fail auth from this point forward
authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)

// skipRoutes allows skipping routes from being checked.
type routeCheck struct {
NoAuthorize bool
AssertObject rbac.Object
StatusCode int
}
assertRoute := map[string]routeCheck{
// These endpoints do not require auth
"GET:/api/v2": {NoAuthorize: true},
"GET:/api/v2/buildinfo": {NoAuthorize: true},
"GET:/api/v2/users/first": {NoAuthorize: true},
"POST:/api/v2/users/first": {NoAuthorize: true},
"POST:/api/v2/users/login": {NoAuthorize: true},
"POST:/api/v2/users/logout": {NoAuthorize: true},
"GET:/api/v2/users/authmethods": {NoAuthorize: true},

// All workspaceagents endpoints do not use rbac
"POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true},
"POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true},
"POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/iceservers": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/listen": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/dial": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/pty": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true},

// TODO: @emyrk these need to be fixed by adding authorize calls
"GET:/api/v2/workspaceresources/{workspaceresource}": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}/logs": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}/resources": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}/state": {NoAuthorize: true},
"PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": {NoAuthorize: true},
"GET:/api/v2/workspaces/{workspace}/builds/{workspacebuildname}": {NoAuthorize: true},

"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},

"POST:/api/v2/users/{user}/organizations/": {NoAuthorize: true},
"PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true},
"GET:/api/v2/organizations/{organization}/provisionerdaemons": {NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templates": {NoAuthorize: true},
"GET:/api/v2/organizations/{organization}/templates": {NoAuthorize: true},
"GET:/api/v2/organizations/{organization}/templates/{templatename}": {NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templateversions": {NoAuthorize: true},

"POST:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true},
"GET:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true},
"DELETE:/api/v2/parameters/{scope}/{id}/{name}": {NoAuthorize: true},

"GET:/api/v2/provisionerdaemons/me/listen": {NoAuthorize: true},

"DELETE:/api/v2/templates/{template}": {NoAuthorize: true},
"GET:/api/v2/templates/{template}": {NoAuthorize: true},
"GET:/api/v2/templates/{template}/versions": {NoAuthorize: true},
"PATCH:/api/v2/templates/{template}/versions": {NoAuthorize: true},
"GET:/api/v2/templates/{template}/versions/{templateversionname}": {NoAuthorize: true},

"GET:/api/v2/templateversions/{templateversion}": {NoAuthorize: true},
"PATCH:/api/v2/templateversions/{templateversion}/cancel": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/logs": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/parameters": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/resources": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/schema": {NoAuthorize: true},

"POST:/api/v2/users/{user}/organizations": {NoAuthorize: true},

"GET:/api/v2/workspaces/{workspace}": {NoAuthorize: true},
"PUT:/api/v2/workspaces/{workspace}/autostart": {NoAuthorize: true},
"PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true},
"GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true},
"POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true},

"POST:/api/v2/files": {NoAuthorize: true},
"GET:/api/v2/files/{hash}": {NoAuthorize: true},

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my TODO homework for my next PR. You can see that I would rather get some approvals before I continue the grind.

// These endpoints have more assertions. This is good, add more endpoints to assert if you can!
"GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(admin.OrganizationID)},
"GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization},
"GET:/api/v2/users/{user}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace},
}

c, _ := srv.Config.Handler.(*chi.Mux)
err = chi.Walk(c, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
name := method + ":" + route
t.Run(name, func(t *testing.T) {
authorizer.reset()
routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")]
if !ok {
// By default, all omitted routes check for just "authorize" called
routeAssertions = routeCheck{}
}
if routeAssertions.StatusCode == 0 {
routeAssertions.StatusCode = http.StatusUnauthorized
}

// Replace all url params with known values
route = strings.ReplaceAll(route, "{organization}", admin.OrganizationID.String())
route = strings.ReplaceAll(route, "{user}", admin.UserID.String())
route = strings.ReplaceAll(route, "{organizationname}", organization.Name)

resp, err := client.Request(context.Background(), method, route, nil)
require.NoError(t, err, "do req")
_ = resp.Body.Close()

if !routeAssertions.NoAuthorize {
assert.NotNil(t, authorizer.Called, "authorizer expected")
assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized")
if routeAssertions.AssertObject.Type != "" {
assert.Equal(t, routeAssertions.AssertObject.Type, authorizer.Called.Object.Type, "resource type")
}
if routeAssertions.AssertObject.Owner != "" {
assert.Equal(t, routeAssertions.AssertObject.Owner, authorizer.Called.Object.Owner, "resource owner")
}
if routeAssertions.AssertObject.OrgID != "" {
assert.Equal(t, routeAssertions.AssertObject.OrgID, authorizer.Called.Object.OrgID, "resource org")
}
if routeAssertions.AssertObject.ResourceID != "" {
assert.Equal(t, routeAssertions.AssertObject.ResourceID, authorizer.Called.Object.ResourceID, "resource ID")
}
} else {
assert.Nil(t, authorizer.Called, "authorize not expected")
}
})
return nil
})
require.NoError(t, err)
}

type authCall struct {
SubjectID string
Roles []string
Action rbac.Action
Object rbac.Object
}

type fakeAuthorizer struct {
Called *authCall
AlwaysReturn error
}

func (f *fakeAuthorizer) AuthorizeByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
f.Called = &authCall{
SubjectID: subjectID,
Roles: roleNames,
Action: action,
Object: object,
}
return f.AlwaysReturn
}

func (f *fakeAuthorizer) reset() {
f.Called = nil
}
15 changes: 13 additions & 2 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"testing"
"time"

"github.com/coder/coder/coderd/rbac"

"cloud.google.com/go/compute/metadata"
"github.com/fullsailor/pkcs7"
"github.com/golang-jwt/jwt"
Expand Down Expand Up @@ -53,6 +55,7 @@ import (

type Options struct {
AWSCertificates awsidentity.Certificates
Authorizer rbac.Authorizer
AzureCertificates x509.VerifyOptions
GithubOAuth2Config *coderd.GithubOAuth2Config
GoogleTokenValidator *idtoken.Validator
Expand All @@ -63,7 +66,7 @@ type Options struct {

// New constructs an in-memory coderd instance and returns
// the connected client.
func New(t *testing.T, options *Options) *codersdk.Client {
func NewMemoryCoderd(t *testing.T, options *Options) (*httptest.Server, *codersdk.Client) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd tried something before where I returned a coderdtest.API struct to which we could add extra fields, but it touched basically everything. This is probably a way nicer/better way of doing it.

Also, having control of the test server is definitely something we want to have available in unit tests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I really didn't want to touch much. I just needed access to the server. If you pass things into the Options, you do have some of those fields you'd include in that API type.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the test consuming this is just using the HTTP handler returned and not the actual server, it might be clearer to just call coderd.New directly in that test instead of changing this.

Considering none of our other tests have required access to the HTTP server object, it seems like that could be cleaner.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is, if I don't use the coderdtest for the setup, I hit a lot of random issues with endpoints because the underlying infrastructure is not implemented. I do use the server, as I am using the sdk Request function to ensure the proper auth, and I have to do setup for the unit tests.

I will need to make a template & workspace for certain endpoints to not return 404's.

It is much easier to know the coderd I am interacting with works when I use coderdtest.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing it should need to check for permissions is Database and Authorize!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but the unit test calls all possible routes, and checks if authorize was called since I fake the Authorizer and can check if it was called. So I need to setup all the routes on a server.

I could do this just on the handler returned by coderd.New(), but it does make it kinda annoying to make the setup. I need to make a first user for the api key middleware to work, which requires a client for all the helper funcs. I need to make an organization for some of the routes. I probably need to make a template & workspace too. All this setup means if I only have the DB, I have to make the DB calls directly. It's much easier to to just the sdk.

So I could probably make it work without calling codertest.New(), but I don't see the value in doing all this work to prop up all this "fake" stuff, when I can just use coderdtest.New(). It does all that for me and I have the same tools as all the other tests 🤷.

The only downside is that I need to expose the server to the unit test to grab the handler to walk it.

if options == nil {
options = &Options{}
}
Expand Down Expand Up @@ -144,6 +147,7 @@ func New(t *testing.T, options *Options) *codersdk.Client {
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,
TURNServer: turnServer,
APIRateLimit: options.APIRateLimit,
Authorizer: options.Authorizer,
})
t.Cleanup(func() {
cancelFunc()
Expand All @@ -152,7 +156,14 @@ func New(t *testing.T, options *Options) *codersdk.Client {
closeWait()
})

return codersdk.New(serverURL)
return srv, codersdk.New(serverURL)
}

// New constructs an in-memory coderd instance and returns
// the connected client.
func New(t *testing.T, options *Options) *codersdk.Client {
_, cli := NewMemoryCoderd(t, options)
return cli
}

// NewProvisionerDaemon launches a provisionerd instance configured to work
Expand Down
Loading