Compare commits

...

7 Commits

Author SHA1 Message Date
0b34b485da feat(server): assert agent is accepted for api operations
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-04 19:03:17 +01:00
ab08d30d2a feat(server): allow registering renewal for forgotten agents
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-04 18:52:19 +01:00
f6ffb68c43 feat(client): show response body on json parsing error
Some checks reported errors
arcad/emissary/pipeline/head Something is wrong with the build of this commit
2024-03-04 18:51:36 +01:00
4a1a434556 fix(migrations): disable foreign keys for migrating tenants
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-04 09:09:44 +01:00
76718722cc feat(server): add /api/v1/session endpoint
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-03 18:40:56 +01:00
8f2131338d Merge pull request 'Page de statut + enrôlement sur l'agent' (#22) from issue-21 into master
All checks were successful
arcad/emissary/pipeline/head This commit looks good
Reviewed-on: #22
2024-03-01 11:41:44 +01:00
56558d7241 feat(agent): add status controller 2024-03-01 11:19:03 +01:00
21 changed files with 528 additions and 28 deletions

View File

@ -38,6 +38,7 @@ func (a *Agent) Run(ctx context.Context) error {
client := client.New(a.serverURL, client.WithToken(token))
ctx = withClient(ctx, client)
ctx = withThumbprint(ctx, a.thumbprint)
tick := func() {
logger.Debug(ctx, "registering agent")

View File

@ -10,7 +10,8 @@ import (
type contextKey string
const (
contextKeyClient contextKey = "client"
contextKeyClient contextKey = "client"
contextKeyThumbprint contextKey = "thumbprint"
)
func withClient(ctx context.Context, client *client.Client) context.Context {
@ -25,3 +26,16 @@ func Client(ctx context.Context) *client.Client {
return client
}
func withThumbprint(ctx context.Context, thumbprint string) context.Context {
return context.WithValue(ctx, contextKeyThumbprint, thumbprint)
}
func Thumbprint(ctx context.Context) string {
thumbprint, ok := ctx.Value(contextKeyThumbprint).(string)
if !ok {
panic(errors.New("could not retrieve thumbprint from context"))
}
return thumbprint
}

View File

@ -0,0 +1,138 @@
package status
import (
"context"
"fmt"
"net/http"
"sync/atomic"
"forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type Status struct {
Agent *datastore.Agent
Connected bool
Claimed bool
Thumbprint string
ServerURL string
ClaimURL string
AgentURL string
AgentVersion string
}
type Controller struct {
status *atomic.Value
server *atomic.Value
addr string
claimURL string
agentURL string
agentVersion string
}
// Name implements node.Controller.
func (c *Controller) Name() string {
return "status-controller"
}
// Reconcile implements node.Controller.
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
cl := agent.Client(ctx)
thumbprint := agent.Thumbprint(ctx)
connected := true
agent, err := cl.GetAgent(ctx, state.AgentID())
if err != nil {
logger.Error(ctx, "could not get agent", logger.E(errors.WithStack(err)))
var apiErr *api.Error
if errors.As(err, &apiErr) {
switch apiErr.Code {
case api.ErrCodeForbidden:
// Contact is ok but agent may be not claimed yet
default:
connected = false
}
} else {
connected = false
}
}
claimed := agent != nil && agent.TenantID != nil
var agentID datastore.AgentID
if agent != nil {
agentID = agent.ID
}
c.status.Store(Status{
Agent: agent,
Connected: connected,
Claimed: claimed,
Thumbprint: thumbprint,
ServerURL: cl.ServerURL(),
ClaimURL: fmt.Sprintf(c.claimURL, thumbprint),
AgentURL: fmt.Sprintf(c.agentURL, agentID),
AgentVersion: c.agentVersion,
})
if err := c.startServer(ctx); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Controller) startServer(ctx context.Context) error {
server := c.getServer()
if server != nil {
return nil
}
server = &http.Server{
Addr: c.addr,
Handler: &Handler{
status: c.status,
},
}
go func() {
defer c.setServer(nil)
if err := server.ListenAndServe(); err != nil {
logger.Error(ctx, "could not start server", logger.E(errors.WithStack(err)))
}
}()
c.setServer(server)
return nil
}
func (c *Controller) setServer(s *http.Server) {
c.server.Store(s)
}
func (c *Controller) getServer() *http.Server {
server, ok := c.server.Load().(*http.Server)
if !ok {
return nil
}
return server
}
func NewController(addr string, claimURL string, agentURL string, agentVersion string) *Controller {
return &Controller{
addr: addr,
claimURL: claimURL,
agentURL: agentURL,
agentVersion: agentVersion,
status: &atomic.Value{},
server: &atomic.Value{},
}
}
var _ agent.Controller = &Controller{}

View File

@ -0,0 +1,74 @@
package status
import (
"embed"
"html/template"
"io/fs"
"net/http"
"sync"
"sync/atomic"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
//go:embed templates/*.gotpl
var templates embed.FS
//go:embed public/*
var public embed.FS
type Handler struct {
status *atomic.Value
public http.Handler
templates *template.Template
init sync.Once
initErr error
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.init.Do(func() {
root, err := fs.Sub(public, "public")
if err != nil {
h.initErr = errors.WithStack(err)
return
}
h.public = http.FileServer(http.FS(root))
tmpl, err := template.ParseFS(templates, "templates/*.gotpl")
if err != nil {
h.initErr = errors.WithStack(err)
return
}
h.templates = tmpl
})
if h.initErr != nil {
logger.Error(r.Context(), "could not initialize handler", logger.E(h.initErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
switch r.URL.Path {
case "/":
h.serveIndex(w, r)
default:
h.public.ServeHTTP(w, r)
}
}
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
data := h.status.Load()
if err := h.templates.ExecuteTemplate(w, "index.html.gotpl", data); err != nil {
logger.Error(r.Context(), "could not render template", logger.E(errors.WithStack(err)))
return
}
}
var _ http.Handler = &Handler{}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="logo.png">
<title>Status | Emissary Agent</title>
<link rel="stylesheet" href="bulma-0.9.4.min.css">
<style>
body {
background-color: #f7f7f7f7;
}
.logo {
left: 50%;
position: absolute;
margin-left: -40px;
width: 100px;
margin-top: -120px
}
.card {
position:relative;
padding-top: 70px;
margin-top: 70px;
}
#qrcode {
display: flex;
flex-direction: row;
justify-content: center;
}
</style>
{{if or .Connected ( not .Claimed ) }}
<script type="text/javascript" src="qrcode.min.js"></script>
{{ end }}
</head>
<body>
<section class="section">
<div class="container">
<div class="column">
<div class="has-text-centered">
<h1 class="title is-size-1 ">Emissary</h1>
<h2 class="subtitle is-size-4">Agent Status</h2>
</div>
<div class="box card">
<img class="logo" src="logo.png" />
<div class="overflow:hidden">
<div class="level is-mobile" style="margin-top:-50px">
<div class="level-left">
<div class="level-item is-size-4-tablet is-size-7-mobile">
<strong class="mr-2">Connected:</strong>{{if .Connected }}<span class="has-text-success">✔</span>{{ else }}<span class="has-text-danger">✕</span>{{ end }}
</div>
</div>
<div class="level-right">
<div class="level-item is-size-4-tablet is-size-7-mobile">
<strong class="mr-2">Claimed:</strong>{{if .Claimed }}<span class="has-text-success">✔</span>{{ else }}<span class="has-text-warning">✕</span>{{ end }}
</div>
</div>
</div>
{{ if and .Connected ( not .Claimed ) }}
<h3 class="is-size-3 mt-4">Claim your agent</h3>
<p class="has-text-centered">
You can claim your agent by clicking the following link:<br />
<a class="button is-link is-medium mt-3" href="{{ .ClaimURL }}" target="_blank" rel="nofollow">Claim me</a><br />
</p>
<p class="has-text-centered mt-3">
You can also scan the following QRCode:
<div id="qrcode" class="mt-3" data-claim-url="{{ .ClaimURL }}"></div>
<script type="text/javascript">
(function() {
const qrCodeElement = document.getElementById("qrcode");
const claimUrl = qrCodeElement.dataset.claimUrl;
new QRCode(qrCodeElement, claimUrl);
}())
</script>
</p>
{{ end }}
{{ if and .Connected .Claimed }}
<h3 class="is-size-3 mt-4">Manage your agent</h3>
<p class="has-text-centered">
You can manage your agent by clicking the following link:<br />
<a class="button is-link is-medium mt-3" href="{{ .AgentURL }}" target="_blank" rel="nofollow">Manage me</a><br />
</p>
<p class="has-text-centered mt-3">
You can also scan the following QRCode:
<div id="qrcode" class="mt-3" data-agent-url="{{ .AgentURL }}"></div>
<script type="text/javascript">
(function() {
const qrCodeElement = document.getElementById("qrcode");
const agentUrl = qrCodeElement.dataset.agentUrl;
new QRCode(qrCodeElement, agentUrl);
}())
</script>
</p>
{{ end }}
<h3 class="is-size-3 mt-4">Informations</h3>
<div class="table-container">
<table class="table is-fullwidth">
<thead>
<tr>
<th>Attribute</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Thumbprint</td>
<td><code>{{ .Thumbprint }}</code></td>
</tr>
<tr>
<td>Agent ID</td>
<td><code>{{ if .Agent }}{{ .Agent.ID }}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Agent Label</td>
<td><code>{{ with .Agent }}{{ if .Label }}{{ .Label }}{{ else }}empty{{end}}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Last server contact</td>
<td><code>{{ if .Agent }}{{ .Agent.ContactedAt }}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Server URL</td>
<td><code>{{ .ServerURL }}</code></td>
</tr>
<tr>
<td>Claim URL</td>
<td><code>{{ .ClaimURL }}</code></td>
</tr>
<tr>
<td>Agent URL</td>
<td><code>{{ if .Agent }}{{ .AgentURL }}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Agent version</td>
<td><code>{{ .AgentVersion }}</code></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>

View File

@ -1,6 +1,7 @@
package agent
import (
"encoding/json"
"fmt"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
@ -29,4 +30,18 @@ func (u *User) Agent() *datastore.Agent {
return u.agent
}
func (u *User) MarshalJSON() ([]byte, error) {
type user struct {
Subject string `json:"subject"`
Tenant string `json:"tenant"`
}
jsonUser := user{
Subject: u.Subject(),
Tenant: string(u.Tenant()),
}
return json.Marshal(jsonUser)
}
var _ auth.User = &User{}

View File

@ -64,16 +64,16 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
}
if user == nil {
isUnauthorized, isUnauthenticated, isUnknown := checkErrors(errs)
hasUnauthorized, hasUnauthenticated, hasUnknown := checkErrors(errs)
switch {
case isUnauthorized && !isUnknown:
case hasUnauthorized && !hasUnknown:
api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil)
return
case isUnauthenticated && !isUnknown:
api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil)
case hasUnauthenticated && !hasUnknown:
api.ErrorResponse(w, http.StatusUnauthorized, api.ErrCodeUnauthorized, nil)
return
case isUnknown:
case hasUnknown:
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
default:
@ -101,10 +101,8 @@ func checkErrors(errs []error) (isUnauthorized bool, isUnauthenticated bool, isU
switch {
case errors.Is(e, ErrUnauthorized):
isUnauthorized = true
continue
case errors.Is(e, ErrUnauthenticated):
isUnauthenticated = true
continue
default:
isUnknown = true
}

View File

@ -1,6 +1,8 @@
package user
import (
"encoding/json"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
)
@ -39,4 +41,20 @@ func (u *User) Role() Role {
return u.role
}
func (u *User) MarshalJSON() ([]byte, error) {
type user struct {
Subject string `json:"subject"`
Tenant string `json:"tenant"`
Role string `json:"role"`
}
jsonUser := user{
Subject: u.Subject(),
Tenant: string(u.Tenant()),
Role: string(u.Role()),
}
return json.Marshal(jsonUser)
}
var _ auth.User = &User{}

View File

@ -10,6 +10,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/persistence"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/proxy"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/spec"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/status"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/buildinfo"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/shell"
@ -94,6 +95,15 @@ func RunCommand() *cli.Command {
))
}
if ctrlConf.Status.Enabled {
controllers = append(controllers, status.NewController(
string(ctrlConf.Status.Address),
string(ctrlConf.Status.ClaimURL),
string(ctrlConf.Status.AgentURL),
string(ctx.String("projectVersion")),
))
}
key, err := jwk.LoadOrGenerate(string(conf.Agent.PrivateKeyPath), jwk.DefaultKeySize)
if err != nil {
return errors.WithStack(err)

View File

@ -24,6 +24,7 @@ type ControllersConfig struct {
App AppControllerConfig `yaml:"app"`
SysUpgrade SysUpgradeControllerConfig `yaml:"sysupgrade"`
MDNS MDNSControllerConfig `yaml:"mdns"`
Status StatusControllerConfig `yaml:"status"`
}
type PersistenceControllerConfig struct {
@ -60,6 +61,13 @@ type MDNSControllerConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
}
type StatusControllerConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
Address InterpolatedString `yaml:"address"`
ClaimURL InterpolatedString `yaml:"claimURL"`
AgentURL InterpolatedString `yaml:"agentURL"`
}
func NewDefaultAgentConfig() AgentConfig {
return AgentConfig{
ServerURL: "http://127.0.0.1:3000",
@ -94,6 +102,12 @@ func NewDefaultAgentConfig() AgentConfig {
MDNS: MDNSControllerConfig{
Enabled: true,
},
Status: StatusControllerConfig{
Enabled: true,
Address: ":42521",
ClaimURL: "http://localhost:3001/claim/%s",
AgentURL: "http://localhost:3001/agents/%v",
},
},
Collectors: []ShellCollectorConfig{
{

View File

@ -183,7 +183,7 @@ func assertMatchingAgent() assertAgent {
}
agent := u.Agent()
if agent != nil && agent.ID == agentID {
if agent != nil && agent.ID == agentID && agent.Status == datastore.AgentStatusAccepted {
return true
}

View File

@ -0,0 +1,35 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) getSession(w http.ResponseWriter, r *http.Request) {
user, ok := assertRequestUser(w, r)
if !ok {
return
}
ctx := r.Context()
tenant, err := m.tenantRepo.Get(ctx, user.Tenant())
if err != nil && !errors.Is(err, datastore.ErrNotFound) {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve user tenant", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
}
api.DataResponse(w, http.StatusOK, struct {
User auth.User `json:"user"`
Tenant *datastore.Tenant `json:"tenant"`
}{
User: user,
Tenant: tenant,
})
}

View File

@ -23,6 +23,8 @@ func (m *Mount) Mount(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(m.authenticators...))
r.Get("/session", m.getSession)
r.Route("/agents", func(r chi.Router) {
r.With(assertUserWithWriteAccess).Post("/claim", m.claimAgent)

View File

@ -50,8 +50,8 @@ func (m *Mount) registerAgent(w http.ResponseWriter, r *http.Request) {
}
if !validSignature {
logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
logger.Warn(ctx, "invalid thumbprint signature", logger.F("signature", registerAgentReq.Signature))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil)
return
}
@ -109,29 +109,39 @@ func (m *Mount) registerAgent(w http.ResponseWriter, r *http.Request) {
return
}
validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err))
if agent.Status != datastore.AgentStatusForgotten {
validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
return
}
if !validSignature {
logger.Error(ctx, "invalid signature")
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
}
if !validSignature {
logger.Error(ctx, "invalid signature")
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil)
updates := []datastore.AgentUpdateOptionFunc{
datastore.WithAgentUpdateKeySet(keySet),
datastore.WithAgentUpdateMetadata(metadata),
datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint),
}
return
if agent.Status == datastore.AgentStatusForgotten {
updates = append(updates, datastore.WithAgentUpdateStatus(datastore.AgentStatusPending))
}
agent, err = m.agentRepo.Update(
ctx,
agents[0].ID,
datastore.WithAgentUpdateKeySet(keySet),
datastore.WithAgentUpdateMetadata(metadata),
datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint),
updates...,
)
if err != nil {
err = errors.WithStack(err)

View File

@ -1,3 +1,5 @@
PRAGMA foreign_keys = 0;
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
@ -49,4 +51,6 @@ CREATE TABLE specs
INSERT INTO specs SELECT id, agent_id, name, revision, data, created_at, updated_at, 0 FROM _specs;
DROP TABLE _specs;
DROP TABLE _specs;
PRAGMA foreign_keys = 1;

View File

@ -62,6 +62,16 @@ agent:
- sh
- -c
- source /etc/openwrt_release && echo "$DISTRIB_ID-$DISTRIB_RELEASE-$DISTRIB_REVISION"
# Status controller configuration
status:
enabled: true
# Status page listening address
address: :42521
# Agent claim URL template (see Emissary HQ)
claimURL: http://127.0.0.1:3001/claim/%v
# Agent URL template (see Emissary HQ)
agentURL: http://127.0.0.1:3001/agents/%v
# Collectors configuration
collectors:

View File

@ -1,5 +1,6 @@
**/*.go
internal/**/*.json
**/*.gotpl
modd.conf
tmp/config.yml
.env {

View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/pkg/errors"
@ -18,6 +19,10 @@ type Client struct {
serverURL string
}
func (c *Client) ServerURL() string {
return c.serverURL
}
func (c *Client) apiGet(ctx context.Context, path string, result any, funcs ...OptionFunc) error {
if err := c.apiDo(ctx, http.MethodGet, path, nil, result, funcs...); err != nil {
return errors.WithStack(err)
@ -91,12 +96,15 @@ func (c *Client) apiDo(ctx context.Context, method string, path string, payload
defer res.Body.Close()
decoder := json.NewDecoder(res.Body)
if err := decoder.Decode(&response); err != nil {
data, err := io.ReadAll(res.Body)
if err != nil {
return errors.WithStack(err)
}
if err := json.Unmarshal(data, &response); err != nil {
return errors.Wrapf(err, "could not parse json: got '%s'", data)
}
return nil
}