Compare commits

...

15 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
eee7e60a86 Merge pull request 'Resources segregation by tenant' (#20) from tenant into master
Some checks failed
arcad/emissary/pipeline/head There was a failure building this commit
Reviewed-on: #20
2024-02-29 15:33:29 +01:00
954597d241 feat: tenants querying
All checks were successful
arcad/emissary/pipeline/pr-master This commit looks good
2024-02-27 17:01:24 +01:00
e0cde4519f doc: update documentation with latest changes 2024-02-27 16:24:40 +01:00
8438c4bc1a feat: add delete tenant command 2024-02-27 15:30:21 +01:00
df1a586d38 doc: add quickstart to readme 2024-02-27 15:15:25 +01:00
c851a1f51b feat(client): tenant management commands 2024-02-27 14:14:30 +01:00
15a0bf6ecc feat: refactor api mount 2024-02-27 09:56:15 +01:00
ca4211daef feat: resources segregation by tenant
All checks were successful
arcad/emissary/pipeline/head This commit looks good
arcad/emissary/pipeline/pr-master This commit looks good
2024-02-26 18:20:40 +01:00
118 changed files with 3988 additions and 1405 deletions

2
.gitignore vendored
View File

@ -10,6 +10,8 @@ dist/
/apps /apps
/server-key.json /server-key.json
/.emissary-token /.emissary-token
/.emissary-admin-token
/.emissary-tenant
/out /out
.mktools/ .mktools/
/CHANGELOG.md /CHANGELOG.md

View File

@ -15,6 +15,12 @@ OPENWRT_DEVICE ?= 192.168.1.1
watch: deps ## Watching updated files - live reload watch: deps ## Watching updated files - live reload
( set -o allexport && source .env && set +o allexport && go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest ) ( set -o allexport && source .env && set +o allexport && go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest )
clean:
rm -f .emissary-*
rm -f emissary.sqlite*
rm -f server-key.json
rm -f agent-key.json
.PHONY: test .PHONY: test
test: test-go ## Executing tests test: test-go ## Executing tests
@ -122,16 +128,25 @@ gitea-release: .mktools tools/gitea-release/bin/gitea-release.sh goreleaser chan
GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \ GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \
tools/gitea-release/bin/gitea-release.sh tools/gitea-release/bin/gitea-release.sh
.emissary-token: .emissary-tenant: .emissary-admin-token
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token" $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client tenant create --token-file .emissary-admin-token --tenant-label Dev -f json | jq -r '.[0].id' > .emissary-tenant"
.emissary-admin-token:
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role admin --output .emissary-admin-token"
.emissary-token: .emissary-tenant
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token --tenant $(shell cat .emissary-tenant)"
AGENT_ID ?= 1 AGENT_ID ?= 1
load-sample-specs: claim-agent: .emissary-token
cat misc/spec-samples/app.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent claim --agent-thumbprint $(shell go run ./cmd/agent agent show-thumbprint)"
cat misc/spec-samples/proxy.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com
cat misc/spec-samples/mdns.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com load-sample-specs: .emissary-token
cat misc/spec-samples/uci.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com cat misc/spec-samples/app.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com"
cat misc/spec-samples/proxy.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com"
cat misc/spec-samples/mdns.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com"
cat misc/spec-samples/uci.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com"
version: .mktools version: .mktools
@echo $(MKT_PROJECT_VERSION) @echo $(MKT_PROJECT_VERSION)

View File

@ -6,6 +6,40 @@ Control plane for "edge" (and OpenWRT-based) devices.
> ⚠ Emissary is currently in a very alpha stage ! Expect breaking changes... > ⚠ Emissary is currently in a very alpha stage ! Expect breaking changes...
## Quickstart
**Dependencies**
- [Go >= 1.21](https://go.dev/)
- `GNU Make`
```shell
# Start server and a local agent
make watch
# In an other terminal
# Create an admin token
make .emissary-admin-token
# Create a new tenant
make .emissary-tenant
# Create a new writer token for this tenant
make .emissary-token
# Claim the agent for your newly created tenant
make claim-agent
# Query your agents
./bin/server client agent query
# Load sample specs for your agent
make load-sample-specs
## Optional: reset your workspace
make clean
```
## Install ## Install
### Manually ### Manually

View File

@ -5,7 +5,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/command" "forge.cadoles.com/Cadoles/emissary/internal/command"
"forge.cadoles.com/Cadoles/emissary/internal/command/agent" "forge.cadoles.com/Cadoles/emissary/internal/command/agent"
"forge.cadoles.com/Cadoles/emissary/internal/command/api" "forge.cadoles.com/Cadoles/emissary/internal/command/client"
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/format" _ "forge.cadoles.com/Cadoles/emissary/internal/imports/format"
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/spec" _ "forge.cadoles.com/Cadoles/emissary/internal/imports/spec"
@ -20,5 +20,5 @@ var (
) )
func main() { func main() {
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), api.Root()) command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), client.Root())
} }

View File

@ -4,7 +4,7 @@ import (
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/command" "forge.cadoles.com/Cadoles/emissary/internal/command"
"forge.cadoles.com/Cadoles/emissary/internal/command/api" "forge.cadoles.com/Cadoles/emissary/internal/command/client"
"forge.cadoles.com/Cadoles/emissary/internal/command/server" "forge.cadoles.com/Cadoles/emissary/internal/command/server"
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/format" _ "forge.cadoles.com/Cadoles/emissary/internal/imports/format"
@ -21,5 +21,5 @@ var (
) )
func main() { func main() {
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), api.Root()) command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), client.Root())
} }

View File

@ -1,6 +1,7 @@
# Documentation # Documentation
- (FR) - [Introduction](./fr/introduction.md) - (FR) - [Vue d'ensemble](./others/fr/overview.md)
- (FR) - [Authentification et autorisation](./others/fr/auth.md)
## Tutorials ## Tutorials
@ -9,15 +10,13 @@
- (FR) - [Déployer une configuration UCI personnalisée sur un agent](./tutorials/fr/deploy-uci-configuration.md) - (FR) - [Déployer une configuration UCI personnalisée sur un agent](./tutorials/fr/deploy-uci-configuration.md)
- (FR) - [Démarrer un agent avec Docker](./tutorials/fr/docker-agent.md) - (FR) - [Démarrer un agent avec Docker](./tutorials/fr/docker-agent.md)
## References
### Specifications ### Specifications
- [Schéma `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json) - [Schema `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json)
- [Schéma `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json) - [Schema `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json)
- [Schéma `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json) - [Schema `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json)
- [Schéma `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json) - [Schema `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json)
- [Schéma `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json) - [Schema `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json)
### Configuration ### Configuration

27
doc/others/fr/auth.md Normal file
View File

@ -0,0 +1,27 @@
# Authentification et autorisation
## Authentification
Emissary utilise des [**JSON Web Token**](https://fr.wikipedia.org/wiki/JSON_Web_Token) (JWT) afin d'authentifier les appels à son API REST.
L'implémentation est compatible avec tout serveur d'authentification exposant une URL proposant un [**JSON Web Key Set**](https://www.ory.sh/docs/hydra/jwks#the-role-of-well-knownjwksjson).
La plupart des serveurs OpenID Connect exposent un point d'entrée du type [`/.well-known/jwks.json`](https://www.ory.sh/docs/hydra/jwks#the-role-of-well-knownjwksjson) remplissant ce rôle.
Emissary est également en capacité à fonctionner en mode autonome en générant des JWTs signés par une clé privée locale.
## Ségrégation des ressources
Emissary suit une stratégie ["multitenant"](https://fr.wikipedia.org/wiki/Multitenant) de séparer les ressources par organisation.
Un utilisateur est obligatoirement associé à un `tenant`` et ne peut opérer que sur les ressources associées à celui ci.
## Autorisation
Au sein d'un `tenant`, un utilisateur peut avoir un des rôles suivants:
- `writer` - Autorisé à visualiser et modifier les ressources;
- `reader` - Autorisé à visualiser les ressources.
Un rôle spécial `admin` permet la création et la suppression de `tenants`.

View File

@ -1,4 +1,4 @@
# Introduction # Vue d'ensemble
"Emissary" est un programme entrant dans la catégorie des outils de gestion et déploiement de configuration. "Emissary" est un programme entrant dans la catégorie des outils de gestion et déploiement de configuration.

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -80,13 +80,13 @@ Via la spécification [`uci.emissary.cadoles.com`](../../../internal/spec/uci/sc
AGENT_THUMBPRINT="<empreinte agent>" AGENT_THUMBPRINT="<empreinte agent>"
# Récupérer l'identifiant de l'agent # Récupérer l'identifiant de l'agent
AGENT_ID=$(emissary api agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id') AGENT_ID=$(emissary client agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id')
``` ```
2. Assigner la spécification à l'agent UCI: 2. Assigner la spécification à l'agent UCI:
```bash ```bash
cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com cat my-uci-spec.json | emissary client agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
``` ```
**Bravo, vous avez déployé des spécifications UCI sur votre agent !** **Bravo, vous avez déployé des spécifications UCI sur votre agent !**
@ -112,7 +112,7 @@ En intervenant directement sur notre spécification, il est possible de modifier
2. Mettre à jour la configuration de l'agent: 2. Mettre à jour la configuration de l'agent:
```bash ```bash
cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com cat my-uci-spec.json | emissary client agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
``` ```
3. Sur l'agent, après quelques secondes (par défaut, la fréquence de mise à jour est de 1 fois par minute) l'agent devrait avoir son `hostname` mis à jour: 3. Sur l'agent, après quelques secondes (par défaut, la fréquence de mise à jour est de 1 fois par minute) l'agent devrait avoir son `hostname` mis à jour:

View File

@ -80,15 +80,31 @@
5. Créer un jeton d'administration: 5. Créer un jeton d'administration:
```shell ```shell
sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role writer --subject $(whoami) sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role admin -o "$HOME/.config/emissary/admin-token"
``` ```
> **Note** Le jeton sera stocké dans le répertoire `$HOME/.config/emissary`. > **Note** Le jeton sera stocké dans le répertoire `$HOME/.config/emissary`.
6. Vérifier l'authentification sur l'API: 6. Créer un nouveau `tenant`:
```shell ```shell
emissary api agent query sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml client tenant create --tenant-label "My Tenant" -o wide --token-file "$HOME/.config/emissary/admin-token"
```
Noter la valeur de l'UUID (de la forme `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) affiché dans la colonne `ID`. Il sera identifié comme `$EMISSARY_TENANT` dans les étapes suivantes.
7. Créer un jeton d'authentification pour ce nouveau tenant:
```shell
sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role writer --tenant $EMISSARY_TENANT
```
> **Note** Le jeton sera stocké dans le fichier `$HOME/.config/emissary/auth-token`. Il sera le jeton utilisé par défaut par le CLI Emissary.
8. Vérifier l'authentification sur l'API:
```shell
emissary client agent query
``` ```
Une réponse équivalente à la suivante devrait s'afficher: Une réponse équivalente à la suivante devrait s'afficher:
@ -128,10 +144,18 @@
Thu May 25 18:48:51 2023 daemon.info emissary[2202]: 2023-05-25 18:48:51.680 [INFO] <./internal/agent/controller/openwrt/sysupgrade_controller.go:36> (*SysUpgradeController).Reconcile could not find sysupgrade spec, doing nothing {"controller": "sysupgrade-controller"} Thu May 25 18:48:51 2023 daemon.info emissary[2202]: 2023-05-25 18:48:51.680 [INFO] <./internal/agent/controller/openwrt/sysupgrade_controller.go:36> (*SysUpgradeController).Reconcile could not find sysupgrade spec, doing nothing {"controller": "sysupgrade-controller"}
``` ```
3. Sur le serveur, vérifier que l'agent a pu s'enregistrer: 2. Récupérer le `thumbprint` de votre agent:
```
emissary agent show-thumbprint
```
Noter la valeur de la chaîne de caractères affichée. Elle sera identifiée comme `$AGENT_THUMBPRINT` dans les étapes suivantes.
3. Sur le serveur, "réclamer" votre agent:
```shell ```shell
emissary api agent query emissary client agent claim --agent-thumbprint $AGENT_THUMBPRINT
``` ```
Un message de ce type devrait s'afficher: Un message de ce type devrait s'afficher:
@ -144,12 +168,12 @@
+----+-------+-----------------------------------+--------+-----------------------------------+-----------------------------------+ +----+-------+-----------------------------------+--------+-----------------------------------+-----------------------------------+
``` ```
Noter l'identifiant associé à l'agent. Noter la valeur de l'identifiant affiché dans la colonne `ID`. Il sera identifié comme `$AGENT_ID` dans les étapes suivantes.
4. Mettre à jour le statut de l'agent afin qu'il soit en capacité à récupérer ses spécifications: 4. Mettre à jour le statut de l'agent afin qu'il soit en capacité à récupérer ses spécifications:
``` ```
emissary api agent update --agent-id <agent_id> --status 1 emissary client agent update --agent-id $AGENT_ID --status 1
``` ```
**Bravo, vous avez appairé votre premier agent et son serveur Emissary !** **Bravo, vous avez appairé votre premier agent et son serveur Emissary !**

6
go.mod
View File

@ -28,7 +28,7 @@ require (
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/qri-io/jsonschema v0.2.1 github.com/qri-io/jsonschema v0.2.1
github.com/urfave/cli/v2 v2.26.0 github.com/urfave/cli/v2 v2.26.0
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.21.0 modernc.org/sqlite v1.21.0
) )
@ -78,7 +78,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@ -123,4 +123,4 @@ require (
) )
// replace forge.cadoles.com/arcad/edge => ../edge // replace forge.cadoles.com/arcad/edge => ../edge
replace github.com/allegro/bigcache/v3 v3.1.0 => github.com/Bornholm/bigcache v0.0.0-20231201111725-1ddf51584cad // replace gitlab.com/wpetit/goweb => ../goweb

4
go.sum
View File

@ -702,6 +702,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@ -1317,6 +1319,8 @@ github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxt
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 h1:0V95X1cBpdj5zyOe6oGtn/BQHlRpV8WlL3eTs3jaxiA= gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 h1:0V95X1cBpdj5zyOe6oGtn/BQHlRpV8WlL3eTs3jaxiA=
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07/go.mod h1:Nfr7aZPiSN6biFumhiHbh9k8A3rKQRzR+o0bVtv78UY= gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07/go.mod h1:Nfr7aZPiSN6biFumhiHbh9k8A3rKQRzR+o0bVtv78UY=
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88 h1:dsyRrmhp7fl/YaY1YIzz7lm9qfIFI5KpKNbXwuhTULA=
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88/go.mod h1:bg+TN16Rq2ygLQbB4VDSHQFNouAEzcy3AAutStehllA=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=

View File

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

View File

@ -10,7 +10,8 @@ import (
type contextKey string type contextKey string
const ( const (
contextKeyClient contextKey = "client" contextKeyClient contextKey = "client"
contextKeyThumbprint contextKey = "thumbprint"
) )
func withClient(ctx context.Context, client *client.Client) context.Context { func withClient(ctx context.Context, client *client.Client) context.Context {
@ -25,3 +26,16 @@ func Client(ctx context.Context) *client.Client {
return 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

@ -11,6 +11,7 @@ import (
"github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt" "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
) )
const DefaultAcceptableSkew = 5 * time.Minute const DefaultAcceptableSkew = 5 * time.Minute
@ -34,17 +35,19 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
token, err := jwt.Parse([]byte(rawToken), jwt.WithVerify(false)) token, err := jwt.Parse([]byte(rawToken), jwt.WithVerify(false))
if err != nil { if err != nil {
return nil, errors.WithStack(err) logger.Debug(ctx, "could not parse jwt token", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
rawThumbprint, exists := token.Get(keyThumbprint) rawThumbprint, exists := token.Get(keyThumbprint)
if !exists { if !exists {
return nil, errors.Errorf("could not find '%s' claim", keyThumbprint) return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
thumbrint, ok := rawThumbprint.(string) thumbrint, ok := rawThumbprint.(string)
if !ok { if !ok {
return nil, errors.Errorf("unexpected '%s' claim value: '%v'", keyThumbprint, rawThumbprint) logger.Debug(ctx, "unexpected claim value", logger.F("claim", rawThumbprint), logger.F("value", rawThumbprint))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
agents, _, err := a.repo.Query( agents, _, err := a.repo.Query(
@ -57,7 +60,8 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
} }
if len(agents) != 1 { if len(agents) != 1 {
return nil, errors.Errorf("unexpected number of found agents: '%d'", len(agents)) logger.Debug(ctx, "unexpected number of found agents", logger.F("total", len(agents)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
agent, err := a.repo.Get( agent, err := a.repo.Get(
@ -75,14 +79,15 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
jwt.WithAcceptableSkew(a.acceptableSkew), jwt.WithAcceptableSkew(a.acceptableSkew),
) )
if err != nil { if err != nil {
return nil, errors.WithStack(err) logger.Error(ctx, "could not parse jwt", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
contactedAt := time.Now() contactedAt := time.Now().UTC()
agent, err = a.repo.Update(ctx, agent.ID, datastore.WithAgentUpdateContactedAt(contactedAt)) agent, err = a.repo.Update(ctx, agent.ID, datastore.WithAgentUpdateContactedAt(contactedAt))
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
user := &User{ user := &User{

View File

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"encoding/json"
"fmt" "fmt"
"forge.cadoles.com/Cadoles/emissary/internal/auth" "forge.cadoles.com/Cadoles/emissary/internal/auth"
@ -16,8 +17,31 @@ func (u *User) Subject() string {
return fmt.Sprintf("agent-%d", u.agent.ID) return fmt.Sprintf("agent-%d", u.agent.ID)
} }
// Subject implements auth.User
func (u *User) Tenant() datastore.TenantID {
if u.agent.TenantID == nil {
return ""
}
return *u.agent.TenantID
}
func (u *User) Agent() *datastore.Agent { func (u *User) Agent() *datastore.Agent {
return u.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{} var _ auth.User = &User{}

8
internal/auth/error.go Normal file
View File

@ -0,0 +1,8 @@
package auth
import "github.com/pkg/errors"
var (
ErrUnauthenticated = errors.New("unauthenticated")
ErrUnauthorized = errors.New(("unauthorized"))
)

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api" "gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
@ -29,10 +30,9 @@ func CtxUser(ctx context.Context) (User, error) {
return user, nil return user, nil
} }
var ErrUnauthenticated = errors.New("unauthenticated")
type User interface { type User interface {
Subject() string Subject() string
Tenant() datastore.TenantID
} }
type Authenticator interface { type Authenticator interface {
@ -49,11 +49,12 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
err error err error
) )
var errs []error
for _, auth := range authenticators { for _, auth := range authenticators {
user, err = auth.Authenticate(ctx, r) user, err = auth.Authenticate(ctx, r)
if err != nil { if err != nil {
logger.Debug(ctx, "could not authenticate request", logger.E(errors.WithStack(err))) errs = append(errs, errors.WithStack(err))
continue continue
} }
@ -63,9 +64,22 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
} }
if user == nil { if user == nil {
api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil) hasUnauthorized, hasUnauthenticated, hasUnknown := checkErrors(errs)
return switch {
case hasUnauthorized && !hasUnknown:
api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil)
return
case hasUnauthenticated && !hasUnknown:
api.ErrorResponse(w, http.StatusUnauthorized, api.ErrCodeUnauthorized, nil)
return
case hasUnknown:
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
default:
api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil)
return
}
} }
ctx = logger.With(ctx, logger.F("user", user.Subject())) ctx = logger.With(ctx, logger.F("user", user.Subject()))
@ -77,3 +91,22 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
return http.HandlerFunc(fn) return http.HandlerFunc(fn)
} }
} }
func checkErrors(errs []error) (isUnauthorized bool, isUnauthenticated bool, isUnknown bool) {
isUnauthenticated = false
isUnauthorized = false
isUnknown = false
for _, e := range errs {
switch {
case errors.Is(e, ErrUnauthorized):
isUnauthorized = true
case errors.Is(e, ErrUnauthenticated):
isUnauthenticated = true
default:
isUnknown = true
}
}
return
}

View File

@ -1,32 +0,0 @@
package thirdparty
import "forge.cadoles.com/Cadoles/emissary/internal/auth"
type Role string
const (
RoleWriter Role = "writer"
RoleReader Role = "reader"
)
func isValidRole(r string) bool {
rr := Role(r)
return rr == RoleWriter || rr == RoleReader
}
type User struct {
subject string
role Role
}
// Subject implements auth.User
func (u *User) Subject() string {
return u.subject
}
func (u *User) Role() Role {
return u.role
}
var _ auth.User = &User{}

View File

@ -1,4 +1,4 @@
package thirdparty package user
import ( import (
"context" "context"
@ -7,21 +7,25 @@ import (
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/auth" "forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk" "forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/lestrrat-go/jwx/v2/jwt" "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
) )
const DefaultAcceptableSkew = 5 * time.Minute const DefaultAcceptableSkew = 5 * time.Minute
type ( type (
GetKeySet func(context.Context) (jwk.Set, error) GetKeySet func(context.Context) (jwk.Set, error)
GetTokenRole func(context.Context, jwt.Token) (string, error) GetTokenRole func(context.Context, jwt.Token) (string, error)
GetTokenTenant func(context.Context, jwt.Token) (string, error)
) )
type Authenticator struct { type Authenticator struct {
getKeySet GetKeySet getKeySet GetKeySet
getTokenRole GetTokenRole getTokenRole GetTokenRole
getTokenTenant GetTokenTenant
acceptableSkew time.Duration acceptableSkew time.Duration
} }
@ -44,29 +48,45 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
token, err := parseToken(ctx, keys, rawToken, a.acceptableSkew) token, err := parseToken(ctx, keys, rawToken, a.acceptableSkew)
if err != nil { if err != nil {
return nil, errors.WithStack(err) logger.Debug(ctx, "could not parse jwt token", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
rawRole, err := a.getTokenRole(ctx, token) rawRole, err := a.getTokenRole(ctx, token)
if err != nil { if err != nil {
return nil, errors.WithStack(err) logger.Debug(ctx, "could not retrieve token role", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
if !isValidRole(rawRole) { if !isValidRole(rawRole) {
return nil, errors.Errorf("invalid role '%s'", rawRole) return nil, errors.WithStack(auth.ErrUnauthorized)
}
rawTenantID, err := a.getTokenTenant(ctx, token)
if err != nil {
logger.Debug(ctx, "could not retrieve token tenant", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
}
tenantID, err := datastore.ParseTenantID(rawTenantID)
if err != nil {
logger.Debug(ctx, "could not retrieve token tenant", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
user := &User{ user := &User{
subject: token.Subject(), subject: token.Subject(),
role: Role(rawRole), role: Role(rawRole),
tenantID: tenantID,
} }
return user, nil return user, nil
} }
func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, acceptableSkew time.Duration) *Authenticator { func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, getTokenTenant GetTokenTenant, acceptableSkew time.Duration) *Authenticator {
return &Authenticator{ return &Authenticator{
getTokenRole: getTokenRole, getTokenRole: getTokenRole,
getTokenTenant: getTokenTenant,
getKeySet: getKeySet, getKeySet: getKeySet,
acceptableSkew: acceptableSkew, acceptableSkew: acceptableSkew,
} }

View File

@ -1,9 +1,10 @@
package thirdparty package user
import ( import (
"context" "context"
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk" "forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jws"
@ -26,9 +27,12 @@ func parseToken(ctx context.Context, keys jwk.Set, rawToken string, acceptableSk
return token, nil return token, nil
} }
const DefaultRoleKey string = "role" const (
DefaultRoleKey string = "role"
DefaultTenantKey string = "tenant"
)
func GenerateToken(ctx context.Context, key jwk.Key, subject string, role Role) (string, error) { func GenerateToken(ctx context.Context, key jwk.Key, tenant datastore.TenantID, subject string, role Role) (string, error) {
token := jwt.New() token := jwt.New()
if err := token.Set(jwt.SubjectKey, subject); err != nil { if err := token.Set(jwt.SubjectKey, subject); err != nil {
@ -39,6 +43,10 @@ func GenerateToken(ctx context.Context, key jwk.Key, subject string, role Role)
return "", errors.WithStack(err) return "", errors.WithStack(err)
} }
if err := token.Set(DefaultTenantKey, tenant); err != nil {
return "", errors.WithStack(err)
}
now := time.Now().UTC() now := time.Now().UTC()
if err := token.Set(jwt.NotBeforeKey, now); err != nil { if err := token.Set(jwt.NotBeforeKey, now); err != nil {

View File

@ -0,0 +1,60 @@
package user
import (
"encoding/json"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
)
type Role string
const (
RoleWriter Role = "writer"
RoleReader Role = "reader"
RoleAdmin Role = "admin"
)
func isValidRole(r string) bool {
rr := Role(r)
return rr == RoleWriter || rr == RoleReader || rr == RoleAdmin
}
type User struct {
subject string
tenantID datastore.TenantID
role Role
}
// Subject implements auth.User
func (u *User) Subject() string {
return u.subject
}
// Tenant implements auth.User
func (u *User) Tenant() datastore.TenantID {
return u.tenantID
}
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/persistence"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/proxy" "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/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"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/buildinfo" "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/buildinfo"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/shell" "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) key, err := jwk.LoadOrGenerate(string(conf.Agent.PrivateKeyPath), jwk.DefaultKeySize)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
@ -104,6 +114,10 @@ func RunCommand() *cli.Command {
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.SetLevel(logger.LevelInfo)
logger.Info(ctx.Context, "agent thumbprint", logger.F("thumbprint", thumbprint))
logger.SetLevel(logger.Level(conf.Logger.Level))
collectors := createShellCollectors(&conf.Agent) collectors := createShellCollectors(&conf.Agent)
collectors = append(collectors, buildinfo.NewCollector()) collectors = append(collectors, buildinfo.NewCollector())

View File

@ -1,16 +0,0 @@
package api
import (
"forge.cadoles.com/Cadoles/emissary/internal/command/api/agent"
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "api",
Usage: "API related commands",
Subcommands: []*cli.Command{
agent.Root(),
},
}
}

View File

@ -0,0 +1,51 @@
package agent
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func ClaimCommand() *cli.Command {
return &cli.Command{
Name: "claim",
Usage: "Claim agent",
Flags: clientFlag.ComposeFlags(
&cli.StringFlag{
Name: "agent-thumbprint",
Value: "",
Required: true,
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
agentThumbprint := ctx.String("agent-thumbprint")
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.ClaimAgent(ctx.Context, agentThumbprint)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := agentHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -3,12 +3,12 @@ package agent
import ( import (
"os" "os"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func CountCommand() *cli.Command { func CountCommand() *cli.Command {

View File

@ -3,14 +3,14 @@ package agent
import ( import (
"os" "os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func DeleteCommand() *cli.Command { func DeleteCommand() *cli.Command {

View File

@ -3,7 +3,7 @@ package flag
import ( import (
"errors" "errors"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )

View File

@ -3,13 +3,13 @@ package agent
import ( import (
"os" "os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func GetCommand() *cli.Command { func GetCommand() *cli.Command {

View File

@ -1,6 +1,9 @@
package agent package agent
import "forge.cadoles.com/Cadoles/emissary/internal/format" import (
"gitlab.com/wpetit/goweb/cli/format"
"gitlab.com/wpetit/goweb/cli/format/table"
)
func agentHints(outputMode format.OutputMode) format.Hints { func agentHints(outputMode format.OutputMode) format.Hints {
return format.Hints{ return format.Hints{
@ -10,8 +13,8 @@ func agentHints(outputMode format.OutputMode) format.Hints {
format.NewProp("Label", "Label"), format.NewProp("Label", "Label"),
format.NewProp("Thumbprint", "Thumbprint"), format.NewProp("Thumbprint", "Thumbprint"),
format.NewProp("Status", "Status"), format.NewProp("Status", "Status"),
format.NewProp("ContactedAt", "ContactedAt"), format.NewProp("ContactedAt", "ContactedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt"), format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
}, },
} }
} }

View File

@ -3,13 +3,13 @@ package agent
import ( import (
"os" "os"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func QueryCommand() *cli.Command { func QueryCommand() *cli.Command {

View File

@ -1,7 +1,7 @@
package agent package agent
import ( import (
"forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/spec" "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/spec"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -15,6 +15,7 @@ func Root() *cli.Command {
UpdateCommand(), UpdateCommand(),
GetCommand(), GetCommand(),
DeleteCommand(), DeleteCommand(),
ClaimCommand(),
spec.Root(), spec.Root(),
}, },
} }

View File

@ -3,14 +3,14 @@ package spec
import ( import (
"os" "os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func DeleteCommand() *cli.Command { func DeleteCommand() *cli.Command {

View File

@ -3,13 +3,13 @@ package spec
import ( import (
"os" "os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func GetCommand() *cli.Command { func GetCommand() *cli.Command {

View File

@ -4,15 +4,15 @@ import (
"encoding/json" "encoding/json"
"os" "os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
jsonpatch "github.com/evanphx/json-patch/v5" jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func UpdateCommand() *cli.Command { func UpdateCommand() *cli.Command {

View File

@ -3,13 +3,13 @@ package agent
import ( import (
"os" "os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func UpdateCommand() *cli.Command { func UpdateCommand() *cli.Command {

View File

@ -5,10 +5,10 @@ import (
"os" "os"
"strings" "strings"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/format/table"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
"gitlab.com/wpetit/goweb/cli/format/table"
) )
const ( const (

View File

@ -0,0 +1,18 @@
package client
import (
"forge.cadoles.com/Cadoles/emissary/internal/command/client/agent"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant"
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "client",
Usage: "API client related commands",
Subcommands: []*cli.Command{
agent.Root(),
tenant.Root(),
},
}
}

View File

@ -0,0 +1,51 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func CreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Create tenant",
Flags: clientFlag.ComposeFlags(
&cli.StringFlag{
Name: "tenant-label",
Usage: "Set `TENANT_LABEL` to targeted tenant",
Value: "",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantLabel := ctx.String("tenant-label")
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.CreateTenant(ctx.Context, tenantLabel)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,56 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func DeleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete tenant",
Flags: tenantFlag.WithTenantFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantID, err := tenantFlag.AssertTenantID(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
tenantID, err = client.DeleteTenant(ctx.Context, tenantID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, struct {
ID datastore.TenantID `json:"id"`
}{
ID: tenantID,
}); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,37 @@
package flag
import (
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func WithTenantFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := clientFlag.ComposeFlags(
&cli.StringFlag{
Name: "tenant-id",
Usage: "use `TENANT_ID` as targeted tenant",
Value: "",
},
)
flags = append(flags, baseFlags...)
return flags
}
func AssertTenantID(ctx *cli.Context) (datastore.TenantID, error) {
rawTenantID := ctx.String("tenant-id")
if rawTenantID == "" {
return "", errors.New("flag 'tenant-id' is required")
}
tenantID, err := datastore.ParseTenantID(rawTenantID)
if err != nil {
return "", errors.WithStack(err)
}
return tenantID, nil
}

View File

@ -0,0 +1,49 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func GetCommand() *cli.Command {
return &cli.Command{
Name: "get",
Usage: "Get tenant",
Flags: tenantFlag.WithTenantFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantID, err := tenantFlag.AssertTenantID(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.GetTenant(ctx.Context, tenantID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,18 @@
package tenant
import (
"gitlab.com/wpetit/goweb/cli/format"
"gitlab.com/wpetit/goweb/cli/format/table"
)
func tenantHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("ID", "ID", table.WithCompactModeMaxColumnWidth(8)),
format.NewProp("Label", "Label"),
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
},
}
}

View File

@ -0,0 +1,63 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func QueryCommand() *cli.Command {
return &cli.Command{
Name: "query",
Usage: "Query tenants",
Flags: clientFlag.ComposeFlags(
&cli.Int64SliceFlag{
Name: "ids",
Usage: "use `IDS` as query filter",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
options := make([]client.QueryTenantsOptionFunc, 0)
rawIDs := ctx.StringSlice("ids")
if rawIDs != nil {
tenantIDs := func(ids []string) []datastore.TenantID {
tenantIDs := make([]datastore.TenantID, len(ids))
for i, id := range ids {
tenantIDs[i] = datastore.TenantID(id)
}
return tenantIDs
}(rawIDs)
options = append(options, client.WithQueryTenantsID(tenantIDs...))
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
tenants, _, err := client.QueryTenants(ctx.Context, options...)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(tenants)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,19 @@
package tenant
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "tenant",
Usage: "Tenants related commands",
Subcommands: []*cli.Command{
CreateCommand(),
GetCommand(),
UpdateCommand(),
DeleteCommand(),
QueryCommand(),
},
}
}

View File

@ -0,0 +1,62 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func UpdateCommand() *cli.Command {
return &cli.Command{
Name: "update",
Usage: "Update tenant",
Flags: tenantFlag.WithTenantFlags(
&cli.StringFlag{
Name: "tenant-label",
Usage: "Set `TENANT_LABEL` to targeted tenant",
Value: "",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantID, err := tenantFlag.AssertTenantID(ctx)
if err != nil {
return errors.WithStack(err)
}
options := make([]client.UpdateTenantOptionFunc, 0)
label := ctx.String("tenant-label")
if label != "" {
options = append(options, client.WithTenantLabel(label))
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.UpdateTenant(ctx.Context, tenantID, options...)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -5,11 +5,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" "forge.cadoles.com/Cadoles/emissary/internal/auth/user"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/common" "forge.cadoles.com/Cadoles/emissary/internal/command/common"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk" "forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/lithammer/shortuuid/v4" "github.com/google/uuid"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -21,13 +22,18 @@ func CreateTokenCommand() *cli.Command {
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "role", Name: "role",
Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []thirdparty.Role{thirdparty.RoleReader, thirdparty.RoleWriter}), Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []user.Role{user.RoleReader, user.RoleWriter, user.RoleAdmin}),
Value: string(thirdparty.RoleReader), Value: string(user.RoleReader),
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "subject", Name: "subject",
Usage: "associate `SUBJECT` to the token", Usage: "associate `SUBJECT` to the token",
Value: fmt.Sprintf("user-%s", shortuuid.New()), Value: fmt.Sprintf("user-%s", uuid.New().String()),
},
&cli.StringFlag{
Name: "tenant",
Usage: "associate `TENANT` to the token",
Value: "00000000-0000-0000-0000-000000000000",
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "output", Name: "output",
@ -44,6 +50,7 @@ func CreateTokenCommand() *cli.Command {
} }
subject := ctx.String("subject") subject := ctx.String("subject")
tenant := ctx.String("tenant")
role := ctx.String("role") role := ctx.String("role")
output := ctx.String("output") output := ctx.String("output")
@ -57,7 +64,7 @@ func CreateTokenCommand() *cli.Command {
return errors.WithStack(err) return errors.WithStack(err)
} }
token, err := thirdparty.GenerateToken(ctx.Context, key, subject, thirdparty.Role(role)) token, err := user.GenerateToken(ctx.Context, key, datastore.TenantID(tenant), subject, user.Role(role))
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }

View File

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

View File

@ -3,7 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" "forge.cadoles.com/Cadoles/emissary/internal/auth/user"
) )
type ServerConfig struct { type ServerConfig struct {
@ -23,9 +23,10 @@ func NewDefaultServerConfig() ServerConfig {
} }
type AuthConfig struct { type AuthConfig struct {
Local *LocalAuthConfig `yaml:"local"` Local *LocalAuthConfig `yaml:"local"`
Remote *RemoteAuthConfig `yaml:"remote"` Remote *RemoteAuthConfig `yaml:"remote"`
RoleExtractionRules []string `yaml:"roleExtractionRules"` RoleExtractionRules []string `yaml:"roleExtractionRules"`
TenantExtractionRules []string `yaml:"tenantExtractionRules"`
} }
func NewDefaultAuthConfig() AuthConfig { func NewDefaultAuthConfig() AuthConfig {
@ -35,7 +36,10 @@ func NewDefaultAuthConfig() AuthConfig {
}, },
Remote: nil, Remote: nil,
RoleExtractionRules: []string{ RoleExtractionRules: []string{
fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", thirdparty.DefaultRoleKey, thirdparty.DefaultRoleKey), fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", user.DefaultRoleKey, user.DefaultRoleKey),
},
TenantExtractionRules: []string{
fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", user.DefaultTenantKey, user.DefaultTenantKey),
}, },
} }
} }

View File

@ -29,6 +29,7 @@ type Agent struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
ContactedAt *time.Time `json:"contactedAt,omitempty"` ContactedAt *time.Time `json:"contactedAt,omitempty"`
TenantID *TenantID `json:"tenantId"`
} }
type SerializableKeySet struct { type SerializableKeySet struct {

View File

@ -9,6 +9,10 @@ import (
type AgentRepository interface { type AgentRepository interface {
Create(ctx context.Context, thumbprint string, keySet jwk.Set, metadata map[string]any) (*Agent, error) Create(ctx context.Context, thumbprint string, keySet jwk.Set, metadata map[string]any) (*Agent, error)
Attach(ctx context.Context, tenantID TenantID, agentID AgentID) (*Agent, error)
Detach(ctx context.Context, agentID AgentID) (*Agent, error)
Get(ctx context.Context, id AgentID) (*Agent, error) Get(ctx context.Context, id AgentID) (*Agent, error)
Update(ctx context.Context, id AgentID, updates ...AgentUpdateOptionFunc) (*Agent, error) Update(ctx context.Context, id AgentID, updates ...AgentUpdateOptionFunc) (*Agent, error)
Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error) Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error)
@ -25,6 +29,7 @@ type AgentQueryOptions struct {
Limit *int Limit *int
Offset *int Offset *int
IDs []AgentID IDs []AgentID
TenantIDs []TenantID
Thumbprints []string Thumbprints []string
Metadata *map[string]any Metadata *map[string]any
Statuses []AgentStatus Statuses []AgentStatus
@ -54,6 +59,12 @@ func WithAgentQueryID(ids ...AgentID) AgentQueryOptionFunc {
} }
} }
func WithAgentQueryTenantID(ids ...TenantID) AgentQueryOptionFunc {
return func(opts *AgentQueryOptions) {
opts.TenantIDs = ids
}
}
func WithAgentQueryStatus(statuses ...AgentStatus) AgentQueryOptionFunc { func WithAgentQueryStatus(statuses ...AgentStatus) AgentQueryOptionFunc {
return func(opts *AgentQueryOptions) { return func(opts *AgentQueryOptions) {
opts.Statuses = statuses opts.Statuses = statuses
@ -75,6 +86,13 @@ type AgentUpdateOptions struct {
Metadata *map[string]any Metadata *map[string]any
KeySet *jwk.Set KeySet *jwk.Set
Thumbprint *string Thumbprint *string
TenantID *TenantID
}
func WithAgentUpdateTenant(id TenantID) AgentUpdateOptionFunc {
return func(opts *AgentUpdateOptions) {
opts.TenantID = &id
}
} }
func WithAgentUpdateStatus(status AgentStatus) AgentUpdateOptionFunc { func WithAgentUpdateStatus(status AgentStatus) AgentUpdateOptionFunc {

View File

@ -6,4 +6,5 @@ var (
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
ErrAlreadyExist = errors.New("already exist") ErrAlreadyExist = errors.New("already exist")
ErrUnexpectedRevision = errors.New("unexpected revision") ErrUnexpectedRevision = errors.New("unexpected revision")
ErrAlreadyAttached = errors.New("already attached")
) )

View File

@ -15,6 +15,8 @@ type Spec struct {
Revision int `json:"revision"` Revision int `json:"revision"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
TenantID TenantID `json:"tenantId"`
AgentID AgentID `json:"agentId"`
} }
func (s *Spec) SpecName() spec.Name { func (s *Spec) SpecName() spec.Name {

View File

@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
@ -16,8 +15,116 @@ import (
) )
type AgentRepository struct { type AgentRepository struct {
db *sql.DB repository
sqliteBusyRetryMaxAttempts int }
// Attach implements datastore.AgentRepository.
func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantID, agentID datastore.AgentID) (*datastore.Agent, error) {
var agent datastore.Agent
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT count(id), tenant_id FROM agents WHERE id = $1`
row := tx.QueryRowContext(ctx, query, agentID)
var (
count int
attachedTenantID *datastore.TenantID
)
if err := row.Scan(&count, &attachedTenantID); err != nil {
return errors.WithStack(err)
}
if count == 0 {
return errors.WithStack(datastore.ErrNotFound)
}
if attachedTenantID != nil {
return errors.WithStack(datastore.ErrAlreadyAttached)
}
now := time.Now().UTC()
query = `
UPDATE agents SET tenant_id = $1, updated_at = $2 WHERE id = $3
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
`
row = tx.QueryRowContext(
ctx, query,
tenantID,
now,
agentID,
)
metadata := JSONMap{}
var rawKeySet []byte
err := row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
if err != nil {
return errors.WithStack(err)
}
agent.Metadata = metadata
keySet, err := jwk.Parse(rawKeySet)
if err != nil {
return errors.WithStack(err)
}
agent.KeySet = &datastore.SerializableKeySet{keySet}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &agent, nil
}
// Detach implements datastore.AgentRepository.
func (r *AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) {
var agent datastore.Agent
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
now := time.Now().UTC()
query := `
UPDATE agents SET tenant_id = null, updated_at = $1 WHERE id = $2
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
`
row := tx.QueryRowContext(
ctx, query,
now,
agentID,
)
metadata := JSONMap{}
var rawKeySet []byte
err := row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
if err != nil {
return errors.WithStack(err)
}
agent.Metadata = metadata
keySet, err := jwk.Parse(rawKeySet)
if err != nil {
return errors.WithStack(err)
}
agent.KeySet = &datastore.SerializableKeySet{keySet}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &agent, nil
} }
// DeleteSpec implements datastore.AgentRepository. // DeleteSpec implements datastore.AgentRepository.
@ -123,8 +230,8 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
now := time.Now().UTC() now := time.Now().UTC()
query := ` query := `
INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at) INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at, tenant_id)
VALUES($1, $2, $3, $4, $5, $5) VALUES($1, $2, $3, $4, $5, $5, ( SELECT tenant_id FROM agents WHERE id = $1 ))
ON CONFLICT (agent_id, name) DO UPDATE SET ON CONFLICT (agent_id, name) DO UPDATE SET
data = $4, updated_at = $5, revision = specs.revision + 1 data = $4, updated_at = $5, revision = specs.revision + 1
WHERE revision = $3 WHERE revision = $3
@ -170,7 +277,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
count := 0 count := 0
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at FROM agents` query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at, tenant_id FROM agents`
limit := 10 limit := 10
if options.Limit != nil { if options.Limit != nil {
@ -193,6 +300,13 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
args = append(args, newArgs...) args = append(args, newArgs...)
} }
if options.TenantIDs != nil && len(options.TenantIDs) > 0 {
filter, newArgs, newParamIndex := inFilter("tenant_id", paramIndex, options.TenantIDs)
filters += filter
paramIndex = newParamIndex
args = append(args, newArgs...)
}
if options.Thumbprints != nil && len(options.Thumbprints) > 0 { if options.Thumbprints != nil && len(options.Thumbprints) > 0 {
if filters != "" { if filters != "" {
filters += " AND " filters += " AND "
@ -240,7 +354,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
metadata := JSONMap{} metadata := JSONMap{}
contactedAt := sql.NullTime{} contactedAt := sql.NullTime{}
if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -293,7 +407,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
query = ` query = `
INSERT INTO agents (thumbprint, keyset, metadata, status, created_at, updated_at) INSERT INTO agents (thumbprint, keyset, metadata, status, created_at, updated_at)
VALUES($1, $2, $3, $4, $5, $5) VALUES($1, $2, $3, $4, $5, $5)
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at" RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
` `
rawKeySet, err := json.Marshal(keySet) rawKeySet, err := json.Marshal(keySet)
@ -308,7 +422,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
metadata := JSONMap{} metadata := JSONMap{}
err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt) err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -363,7 +477,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := ` query := `
SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at" SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id"
FROM agents FROM agents
WHERE id = $1 WHERE id = $1
` `
@ -374,7 +488,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas
contactedAt := sql.NullTime{} contactedAt := sql.NullTime{}
var rawKeySet []byte var rawKeySet []byte
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return datastore.ErrNotFound return datastore.ErrNotFound
} }
@ -476,7 +590,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
query += ` query += `
WHERE id = $1 WHERE id = $1
RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at" RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id"
` `
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args)) logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
@ -487,7 +601,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
contactedAt := sql.NullTime{} contactedAt := sql.NullTime{}
var rawKeySet []byte var rawKeySet []byte
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return datastore.ErrNotFound return datastore.ErrNotFound
} }
@ -536,109 +650,8 @@ func (r *AgentRepository) agentExists(ctx context.Context, tx *sql.Tx, agentID d
return true, nil return true, nil
} }
func (r *AgentRepository) withTxRetry(ctx context.Context, fn func(*sql.Tx) error) error {
attempts := 0
max := r.sqliteBusyRetryMaxAttempts
ctx = logger.With(ctx, logger.F("max", max))
var err error
for {
ctx = logger.With(ctx)
if attempts >= max {
logger.Debug(ctx, "transaction retrying failed", logger.F("attempts", attempts))
return errors.Wrapf(err, "transaction failed after %d attempts", max)
}
err = r.withTx(ctx, fn)
if err != nil {
if !strings.Contains(err.Error(), "(5) (SQLITE_BUSY)") {
return errors.WithStack(err)
}
err = errors.WithStack(err)
logger.Warn(ctx, "database is busy", logger.E(err))
wait := time.Duration(8<<(attempts+1)) * time.Millisecond
logger.Debug(
ctx, "database is busy, waiting before retrying transaction",
logger.F("wait", wait.String()),
logger.F("attempts", attempts),
)
timer := time.NewTimer(wait)
select {
case <-timer.C:
attempts++
continue
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
}
return nil
}
}
func (r *AgentRepository) withTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := tx.Rollback(); err != nil {
if errors.Is(err, sql.ErrTxDone) {
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not rollback transaction", logger.CapturedE(err))
}
}()
if err := fn(tx); err != nil {
return errors.WithStack(err)
}
if err := tx.Commit(); err != nil {
return errors.WithStack(err)
}
return nil
}
func NewAgentRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *AgentRepository { func NewAgentRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *AgentRepository {
return &AgentRepository{db, sqliteBusyRetryMaxAttempts} return &AgentRepository{repository{db, sqliteBusyRetryMaxAttempts}}
} }
var _ datastore.AgentRepository = &AgentRepository{} var _ datastore.AgentRepository = &AgentRepository{}
func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) {
args := make([]any, 0, len(items))
filter := fmt.Sprintf("%s in (", column)
for idx, item := range items {
if idx != 0 {
filter += ","
}
filter += fmt.Sprintf("$%d", paramIndex)
paramIndex++
args = append(args, item)
}
filter += ")"
return filter, args, paramIndex
}

View File

@ -0,0 +1,97 @@
package sqlite
import (
"context"
"database/sql"
"strings"
"time"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type repository struct {
db *sql.DB
sqliteBusyRetryMaxAttempts int
}
func (r *repository) withTxRetry(ctx context.Context, fn func(*sql.Tx) error) error {
attempts := 0
max := r.sqliteBusyRetryMaxAttempts
ctx = logger.With(ctx, logger.F("max", max))
var err error
for {
ctx = logger.With(ctx)
if attempts >= max {
logger.Debug(ctx, "transaction retrying failed", logger.F("attempts", attempts))
return errors.Wrapf(err, "transaction failed after %d attempts", max)
}
err = r.withTx(ctx, fn)
if err != nil {
if !strings.Contains(err.Error(), "(5) (SQLITE_BUSY)") {
return errors.WithStack(err)
}
err = errors.WithStack(err)
logger.Warn(ctx, "database is busy", logger.E(err))
wait := time.Duration(8<<(attempts+1)) * time.Millisecond
logger.Debug(
ctx, "database is busy, waiting before retrying transaction",
logger.F("wait", wait.String()),
logger.F("attempts", attempts),
)
timer := time.NewTimer(wait)
select {
case <-timer.C:
attempts++
continue
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
}
return nil
}
}
func (r *repository) withTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := tx.Rollback(); err != nil {
if errors.Is(err, sql.ErrTxDone) {
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not rollback transaction", logger.CapturedE(err))
}
}()
if err := fn(tx); err != nil {
return errors.WithStack(err)
}
if err := tx.Commit(); err != nil {
return errors.WithStack(err)
}
return nil
}

View File

@ -0,0 +1,23 @@
package sqlite
import "fmt"
func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) {
args := make([]any, 0, len(items))
filter := fmt.Sprintf("%s in (", column)
for idx, item := range items {
if idx != 0 {
filter += ","
}
filter += fmt.Sprintf("$%d", paramIndex)
paramIndex++
args = append(args, item)
}
filter += ")"
return filter, args, paramIndex
}

View File

@ -0,0 +1,284 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type TenantRepository struct {
repository
}
// Query implements datastore.TenantRepository.
func (r *TenantRepository) Query(ctx context.Context, opts ...datastore.TenantQueryOptionFunc) ([]*datastore.Tenant, int, error) {
options := &datastore.TenantQueryOptions{}
for _, fn := range opts {
fn(options)
}
tenants := make([]*datastore.Tenant, 0)
count := 0
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT id, label, created_at, updated_at FROM tenants`
limit := 10
if options.Limit != nil {
limit = *options.Limit
}
offset := 0
if options.Offset != nil {
offset = *options.Offset
}
filters := ""
paramIndex := 3
args := []any{offset, limit}
if options.IDs != nil && len(options.IDs) > 0 {
filter, newArgs, newParamIndex := inFilter("id", paramIndex, options.IDs)
filters += filter
paramIndex = newParamIndex
args = append(args, newArgs...)
}
if filters != "" {
filters = ` WHERE ` + filters
}
query += filters + ` LIMIT $2 OFFSET $1`
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not close rows", logger.CapturedE(err))
}
}()
for rows.Next() {
tenant := &datastore.Tenant{}
if err := rows.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
return errors.WithStack(err)
}
tenants = append(tenants, tenant)
}
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM tenants `+filters, args...)
if err := row.Scan(&count); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, 0, errors.WithStack(err)
}
return tenants, count, nil
}
// Create implements datastore.TenantRepository.
func (r *TenantRepository) Create(ctx context.Context, label string) (*datastore.Tenant, error) {
var tenant datastore.Tenant
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
now := time.Now().UTC()
query := `
INSERT INTO tenants (id, label, created_at, updated_at)
VALUES($1, $2, $3, $3)
RETURNING "id", "label", "created_at", "updated_at"
`
tenantID := datastore.NewTenantID()
row := tx.QueryRowContext(
ctx, query,
tenantID, label, now,
)
if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &tenant, nil
}
// Delete implements datastore.TenantRepository.
func (r *TenantRepository) Delete(ctx context.Context, id datastore.TenantID) error {
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
if exists, err := r.tenantExists(ctx, tx, id); !exists {
return errors.WithStack(err)
}
query := `DELETE FROM tenants WHERE id = $1`
_, err := tx.ExecContext(ctx, query, id)
if err != nil {
return errors.WithStack(err)
}
query = `DELETE FROM agents WHERE tenant_id = $1`
_, err = tx.ExecContext(ctx, query, id)
if err != nil {
return errors.WithStack(err)
}
query = `DELETE FROM specs WHERE tenant_id = $1`
_, err = tx.ExecContext(ctx, query, id)
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
// Get implements datastore.TenantRepository.
func (r *TenantRepository) Get(ctx context.Context, id datastore.TenantID) (*datastore.Tenant, error) {
var tenant datastore.Tenant
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `
SELECT "id", "label", "created_at", "updated_at"
FROM tenants
WHERE id = $1
`
row := tx.QueryRowContext(ctx, query, id)
if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrNotFound)
}
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &tenant, nil
}
// Update implements datastore.TenantRepository.
func (r *TenantRepository) Update(ctx context.Context, id datastore.TenantID, updates ...datastore.TenantUpdateOptionFunc) (*datastore.Tenant, error) {
options := &datastore.TenantUpdateOptions{}
for _, fn := range updates {
fn(options)
}
var tenant datastore.Tenant
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
if exists, err := r.tenantExists(ctx, tx, id); !exists {
return errors.WithStack(err)
}
query := `
UPDATE tenants SET updated_at = $1
`
args := []any{id}
index := 2
if options.Label != nil {
query += fmt.Sprintf(`, label = $%d`, index)
args = append(args, *options.Label)
index++
}
updated := options.Label != nil
if updated {
now := time.Now().UTC()
query += fmt.Sprintf(`, updated_at = $%d`, index)
args = append(args, now)
index++
}
query += `
WHERE id = $1
RETURNING "id", "label", "created_at", "updated_at"
`
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
row := tx.QueryRowContext(ctx, query, args...)
if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrNotFound)
}
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &tenant, nil
}
func (r *TenantRepository) tenantExists(ctx context.Context, tx *sql.Tx, tenantID datastore.TenantID) (bool, error) {
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM tenants WHERE id = $1`, tenantID)
var count int
if err := row.Scan(&count); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, errors.WithStack(datastore.ErrNotFound)
}
return false, errors.WithStack(err)
}
if count == 0 {
return false, errors.WithStack(datastore.ErrNotFound)
}
return true, nil
}
func NewTenantRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *TenantRepository {
return &TenantRepository{
repository: repository{db, sqliteBusyRetryMaxAttempts},
}
}
var _ datastore.TenantRepository = &TenantRepository{}

View File

@ -0,0 +1,46 @@
package sqlite
import (
"database/sql"
"fmt"
"os"
"testing"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite"
"forge.cadoles.com/Cadoles/emissary/internal/migrate"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "modernc.org/sqlite"
)
func TestSQLiteTeantRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
file := "testdata/tenant_repository_test.sqlite"
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%+v", errors.WithStack(err))
}
dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
migr, err := migrate.New("../../../migrations", "sqlite", "sqlite://"+dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if err := migr.Up(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
repo := NewTenantRepository(db, 5)
testsuite.TestTenantRepository(t, repo)
}

View File

@ -0,0 +1,32 @@
package datastore
import (
"time"
"github.com/google/uuid"
"github.com/pkg/errors"
)
const DefaultTenantID TenantID = "00000000-0000-0000-0000-000000000000"
type TenantID string
func NewTenantID() TenantID {
return TenantID(uuid.New().String())
}
func ParseTenantID(raw string) (TenantID, error) {
uuid, err := uuid.Parse(raw)
if err != nil {
return "", errors.WithStack(err)
}
return TenantID(uuid.String()), nil
}
type Tenant struct {
ID TenantID `json:"id"`
Label string `json:"label"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@ -0,0 +1,50 @@
package datastore
import "context"
type TenantRepository interface {
Create(ctx context.Context, label string) (*Tenant, error)
Get(ctx context.Context, id TenantID) (*Tenant, error)
Update(ctx context.Context, id TenantID, updates ...TenantUpdateOptionFunc) (*Tenant, error)
Delete(ctx context.Context, id TenantID) error
Query(ctx context.Context, opts ...TenantQueryOptionFunc) ([]*Tenant, int, error)
}
type TenantUpdateOptionFunc func(*TenantUpdateOptions)
type TenantUpdateOptions struct {
Label *string
}
func WithTenantUpdateLabel(label string) TenantUpdateOptionFunc {
return func(opts *TenantUpdateOptions) {
opts.Label = &label
}
}
type TenantQueryOptionFunc func(*TenantQueryOptions)
type TenantQueryOptions struct {
Limit *int
Offset *int
IDs []TenantID
}
func WithTenantQueryLimit(limit int) TenantQueryOptionFunc {
return func(opts *TenantQueryOptions) {
opts.Limit = &limit
}
}
func WithTenantQueryOffset(offset int) TenantQueryOptionFunc {
return func(opts *TenantQueryOptions) {
opts.Offset = &offset
}
}
func WithTenantQueryID(ids ...TenantID) TenantQueryOptionFunc {
return func(opts *TenantQueryOptions) {
opts.IDs = ids
}
}

View File

@ -0,0 +1,14 @@
package testsuite
import (
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
)
func TestTenantRepository(t *testing.T, repo datastore.TenantRepository) {
t.Run("Cases", func(t *testing.T) {
t.Parallel()
runTenantRepositoryTests(t, repo)
})
}

View File

@ -0,0 +1,109 @@
package testsuite
import (
"context"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
)
type tenantRepositoryTestCase struct {
Name string
Skip bool
Run func(ctx context.Context, repo datastore.TenantRepository) error
}
var tenantRepositoryTestCases = []tenantRepositoryTestCase{
{
Name: "Create a new tenant",
Run: func(ctx context.Context, repo datastore.TenantRepository) error {
label := "Foo"
tenant, err := repo.Create(ctx, "Foo")
if err != nil {
return errors.WithStack(err)
}
if tenant.CreatedAt.IsZero() {
return errors.Errorf("tenant.CreatedAt should not be zero time")
}
if tenant.UpdatedAt.IsZero() {
return errors.Errorf("tenant.UpdatedAt should not be zero time")
}
if e, g := label, tenant.Label; e != g {
return errors.Errorf("tenant.Label: expected '%v', got '%v'", e, g)
}
if tenant.ID == "" {
return errors.Errorf("tenant.ID should not be empty")
}
if _, err := datastore.ParseTenantID(string(tenant.ID)); err != nil {
return errors.Wrapf(err, "tenant.ID should be valid")
}
return nil
},
},
{
Name: "Try to update an unexistant tenant",
Run: func(ctx context.Context, repo datastore.TenantRepository) error {
unexistantTenantID := datastore.TenantID("00000000-0000-0000-0000-000000000000")
tenant, err := repo.Update(ctx, unexistantTenantID)
if err == nil {
return errors.New("error should not be nil")
}
if !errors.Is(err, datastore.ErrNotFound) {
return errors.Errorf("error should be datastore.ErrNotFound, got '%+v'", err)
}
if tenant != nil {
return errors.New("tenant should be nil")
}
return nil
},
},
{
Name: "Try to delete spec of an unexistant agent",
Run: func(ctx context.Context, repo datastore.TenantRepository) error {
unexistantTenantID := datastore.TenantID("00000000-0000-0000-0000-000000000000")
err := repo.Delete(ctx, unexistantTenantID)
if err == nil {
return errors.New("error should not be nil")
}
if !errors.Is(err, datastore.ErrNotFound) {
return errors.Errorf("error should be datastore.ErrNotFound, got '%+v'", err)
}
return nil
},
},
}
func runTenantRepositoryTests(t *testing.T, repo datastore.TenantRepository) {
for _, tc := range tenantRepositoryTestCases {
func(tc tenantRepositoryTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
if tc.Skip {
t.SkipNow()
return
}
ctx := context.Background()
if err := tc.Run(ctx, repo); err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
})
}(tc)
}
}

View File

@ -1,38 +0,0 @@
package json
import (
"encoding/json"
"io"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
)
const Format format.Format = "json"
func init() {
format.Register(Format, NewWriter())
}
type Writer struct{}
// Format implements format.Writer.
func (*Writer) Write(writer io.Writer, hints format.Hints, data ...any) error {
encoder := json.NewEncoder(writer)
if hints.OutputMode == format.OutputModeWide {
encoder.SetIndent("", " ")
}
if err := encoder.Encode(data); err != nil {
return errors.WithStack(err)
}
return nil
}
func NewWriter() *Writer {
return &Writer{}
}
var _ format.Writer = &Writer{}

View File

@ -1,18 +0,0 @@
package format
type Prop struct {
name string
label string
}
func (p *Prop) Name() string {
return p.name
}
func (p *Prop) Label() string {
return p.label
}
func NewProp(name, label string) Prop {
return Prop{name, label}
}

View File

@ -1,46 +0,0 @@
package format
import (
"io"
"github.com/pkg/errors"
)
type Format string
type Registry map[Format]Writer
var defaultRegistry = Registry{}
var ErrUnknownFormat = errors.New("unknown format")
func Write(format Format, writer io.Writer, hints Hints, data ...any) error {
formatWriter, exists := defaultRegistry[format]
if !exists {
return errors.WithStack(ErrUnknownFormat)
}
if hints.OutputMode == "" {
hints.OutputMode = OutputModeCompact
}
if err := formatWriter.Write(writer, hints, data...); err != nil {
return errors.WithStack(err)
}
return nil
}
func Available() []Format {
formats := make([]Format, 0, len(defaultRegistry))
for f := range defaultRegistry {
formats = append(formats, f)
}
return formats
}
func Register(format Format, writer Writer) {
defaultRegistry[format] = writer
}

View File

@ -1,49 +0,0 @@
package table
import (
"encoding/json"
"fmt"
"reflect"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
)
func getProps(d any) []format.Prop {
props := make([]format.Prop, 0)
v := reflect.Indirect(reflect.ValueOf(d))
typeOf := v.Type()
for i := 0; i < v.NumField(); i++ {
name := typeOf.Field(i).Name
props = append(props, format.NewProp(name, name))
}
return props
}
func getFieldValue(obj any, name string) string {
v := reflect.Indirect(reflect.ValueOf(obj))
fieldValue := v.FieldByName(name)
switch fieldValue.Kind() {
case reflect.Map:
fallthrough
case reflect.Struct:
fallthrough
case reflect.Slice:
fallthrough
case reflect.Interface:
json, err := json.Marshal(fieldValue.Interface())
if err != nil {
panic(errors.WithStack(err))
}
return string(json)
default:
return fmt.Sprintf("%v", fieldValue.Interface())
}
}

View File

@ -1,75 +0,0 @@
package table
import (
"io"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/jedib0t/go-pretty/v6/table"
)
const Format format.Format = "table"
const DefaultCompactModeMaxColumnWidth = 30
func init() {
format.Register(Format, NewWriter(DefaultCompactModeMaxColumnWidth))
}
type Writer struct {
compactModeMaxColumnWidth int
}
// Write implements format.Writer.
func (w *Writer) Write(writer io.Writer, hints format.Hints, data ...any) error {
t := table.NewWriter()
t.SetOutputMirror(writer)
var props []format.Prop
if hints.Props != nil {
props = hints.Props
} else {
if len(data) > 0 {
props = getProps(data[0])
} else {
props = make([]format.Prop, 0)
}
}
labels := table.Row{}
for _, p := range props {
labels = append(labels, p.Label())
}
t.AppendHeader(labels)
isCompactMode := hints.OutputMode == format.OutputModeCompact
for _, d := range data {
row := table.Row{}
for _, p := range props {
value := getFieldValue(d, p.Name())
if isCompactMode && len(value) > w.compactModeMaxColumnWidth {
value = value[:w.compactModeMaxColumnWidth] + "..."
}
row = append(row, value)
}
t.AppendRow(row)
}
t.Render()
return nil
}
func NewWriter(compactModeMaxColumnWidth int) *Writer {
return &Writer{compactModeMaxColumnWidth}
}
var _ format.Writer = &Writer{}

View File

@ -1,86 +0,0 @@
package table
import (
"bytes"
"strings"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
)
type dummyItem struct {
MyString string
MyInt int
MySub subItem
}
type subItem struct {
MyBool bool
}
var dummyItems = []any{
dummyItem{
MyString: "Foo",
MyInt: 1,
MySub: subItem{
MyBool: false,
},
},
dummyItem{
MyString: "Bar",
MyInt: 0,
MySub: subItem{
MyBool: true,
},
},
}
func TestWriterNoHints(t *testing.T) {
var buf bytes.Buffer
writer := NewWriter(DefaultCompactModeMaxColumnWidth)
if err := writer.Write(&buf, format.Hints{}, dummyItems...); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
expected := `+----------+-------+------------------+
| MYSTRING | MYINT | MYSUB |
+----------+-------+------------------+
| Foo | 1 | {"MyBool":false} |
| Bar | 0 | {"MyBool":true} |
+----------+-------+------------------+`
if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g {
t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g)
}
}
func TestWriterWithPropHints(t *testing.T) {
var buf bytes.Buffer
writer := NewWriter(DefaultCompactModeMaxColumnWidth)
hints := format.Hints{
Props: []format.Prop{
format.NewProp("MyString", "MyString"),
format.NewProp("MyInt", "MyInt"),
},
}
if err := writer.Write(&buf, hints, dummyItems...); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
expected := `+----------+-------+
| MYSTRING | MYINT |
+----------+-------+
| Foo | 1 |
| Bar | 0 |
+----------+-------+`
if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g {
t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g)
}
}

View File

@ -1,19 +0,0 @@
package format
import "io"
type OutputMode string
const (
OutputModeWide OutputMode = "wide"
OutputModeCompact OutputMode = "compact"
)
type Hints struct {
Props []Prop
OutputMode OutputMode
}
type Writer interface {
Write(writer io.Writer, hints Hints, data ...any) error
}

View File

@ -1,6 +1,6 @@
package format package format
import ( import (
_ "forge.cadoles.com/Cadoles/emissary/internal/format/json" _ "gitlab.com/wpetit/goweb/cli/format/json"
_ "forge.cadoles.com/Cadoles/emissary/internal/format/table" _ "gitlab.com/wpetit/goweb/cli/format/table"
) )

View File

@ -1,420 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnknownError api.ErrorCode = "unknown-error"
ErrCodeNotFound api.ErrorCode = "not-found"
ErrCodeInvalidSignature api.ErrorCode = "invalid-signature"
ErrCodeConflict api.ErrorCode = "conflict"
)
type registerAgentRequest struct {
KeySet json.RawMessage `json:"keySet" validate:"required"`
Metadata []metadata.Tuple `json:"metadata" validate:"required"`
Thumbprint string `json:"thumbprint" validate:"required"`
Signature string `json:"signature" validate:"required"`
}
func (s *Server) registerAgent(w http.ResponseWriter, r *http.Request) {
registerAgentReq := &registerAgentRequest{}
if ok := api.Bind(w, r, registerAgentReq); !ok {
return
}
ctx := r.Context()
keySet, err := jwk.Parse(registerAgentReq.KeySet)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not parse key set", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint))
// Validate that the existing signature validates the request
validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if !validSignature {
logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
metadata := metadata.FromSorted(registerAgentReq.Metadata)
agent, err := s.agentRepo.Create(
ctx,
registerAgentReq.Thumbprint,
keySet,
metadata,
)
if err != nil {
if !errors.Is(err, datastore.ErrAlreadyExist) {
err = errors.WithStack(err)
logger.Error(ctx, "could not create agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
agents, _, err := s.agentRepo.Query(
ctx,
datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint),
datastore.WithAgentQueryLimit(1),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if len(agents) == 0 {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil)
return
}
agentID := agents[0].ID
agent, err = s.agentRepo.Get(ctx, agentID)
if err != nil {
err = errors.WithStack(err)
logger.Error(
ctx, "could not retrieve agent",
logger.CapturedE(err), logger.F("agentID", agentID),
)
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
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))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
agent, err = s.agentRepo.Update(
ctx, agents[0].ID,
datastore.WithAgentUpdateKeySet(keySet),
datastore.WithAgentUpdateMetadata(metadata),
datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
}
api.DataResponse(w, http.StatusCreated, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}
type updateAgentRequest struct {
Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"`
Label *string `json:"label" validate:"omitempty"`
}
func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
updateAgentReq := &updateAgentRequest{}
if ok := api.Bind(w, r, updateAgentReq); !ok {
return
}
options := make([]datastore.AgentUpdateOptionFunc, 0)
if updateAgentReq.Status != nil {
options = append(options, datastore.WithAgentUpdateStatus(*updateAgentReq.Status))
}
if updateAgentReq.Label != nil {
options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label))
}
agent, err := s.agentRepo.Update(
ctx,
datastore.AgentID(agentID),
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}
func (s *Server) queryAgents(w http.ResponseWriter, r *http.Request) {
limit, ok := getIntQueryParam(w, r, "limit", 10)
if !ok {
return
}
offset, ok := getIntQueryParam(w, r, "offset", 0)
if !ok {
return
}
options := []datastore.AgentQueryOptionFunc{
datastore.WithAgentQueryLimit(int(limit)),
datastore.WithAgentQueryOffset(int(offset)),
}
ids, ok := getIntSliceValues(w, r, "ids", nil)
if !ok {
return
}
if ids != nil {
agentIDs := func(ids []int64) []datastore.AgentID {
agentIDs := make([]datastore.AgentID, 0, len(ids))
for _, id := range ids {
agentIDs = append(agentIDs, datastore.AgentID(id))
}
return agentIDs
}(ids)
options = append(options, datastore.WithAgentQueryID(agentIDs...))
}
thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil)
if !ok {
return
}
if thumbprints != nil {
options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...))
}
statuses, ok := getIntSliceValues(w, r, "statuses", nil)
if !ok {
return
}
if statuses != nil {
agentStatuses := func(statuses []int64) []datastore.AgentStatus {
agentStatuses := make([]datastore.AgentStatus, 0, len(statuses))
for _, status := range statuses {
agentStatuses = append(agentStatuses, datastore.AgentStatus(status))
}
return agentStatuses
}(statuses)
options = append(options, datastore.WithAgentQueryStatus(agentStatuses...))
}
ctx := r.Context()
agents, total, err := s.agentRepo.Query(
ctx,
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not list agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agents []*datastore.Agent `json:"agents"`
Total int `json:"total"`
}{
Agents: agents,
Total: total,
})
}
func (s *Server) deleteAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
err := s.agentRepo.Delete(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
AgentID datastore.AgentID `json:"agentId"`
}{
AgentID: datastore.AgentID(agentID),
})
}
func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
agent, err := s.agentRepo.Get(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not get agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}
func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool) {
rawAgentID := chi.URLParam(r, "agentID")
agentID, err := strconv.ParseInt(rawAgentID, 10, 64)
if err != nil {
logger.Error(r.Context(), "could not parse agent id", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.AgentID(agentID), true
}
func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return value, true
}
return defaultValue, true
}
func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
values := strings.Split(rawValue, ",")
return values, true
}
return defaultValue, true
}
func getIntSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []int64) ([]int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
rawValues := strings.Split(rawValue, ",")
values := make([]int64, 0, len(rawValues))
for _, rv := range rawValues {
value, err := strconv.ParseInt(rv, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return nil, false
}
values = append(values, value)
}
return values, true
}
return defaultValue, true
}

View File

@ -0,0 +1,251 @@
package api
import (
"context"
"fmt"
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/auth/agent"
"forge.cadoles.com/Cadoles/emissary/internal/auth/user"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
var ErrCodeForbidden api.ErrorCode = "forbidden"
func assertQueryAccess(h http.Handler) http.Handler {
return assertAuthz(
h,
assertOneOfRoles(user.RoleReader, user.RoleWriter, user.RoleAdmin),
nil,
)
}
func assertUserWithWriteAccess(h http.Handler) http.Handler {
return assertAuthz(
h,
assertOneOfRoles(user.RoleWriter, user.RoleAdmin),
nil,
)
}
func assertAgentOrUserWithWriteAccess(h http.Handler) http.Handler {
return assertAuthz(
h,
assertOneOfRoles(user.RoleWriter, user.RoleAdmin),
assertMatchingAgent(),
)
}
func assertAgentOrUserWithReadAccess(h http.Handler) http.Handler {
return assertAuthz(
h,
assertOneOfRoles(user.RoleReader, user.RoleWriter, user.RoleAdmin),
assertMatchingAgent(),
)
}
func assertAdminAccess(h http.Handler) http.Handler {
return assertAuthz(
h,
assertOneOfRoles(user.RoleAdmin),
nil,
)
}
func assertAdminOrTenantReadAccess(h http.Handler) http.Handler {
return assertAuthz(
h,
assertOneOfUser(
assertOneOfRoles(user.RoleAdmin),
assertAllOfUser(
assertOneOfRoles(user.RoleReader, user.RoleWriter),
assertSameTenant(),
),
),
nil,
)
}
func assertAdminOrTenantWriteAccess(h http.Handler) http.Handler {
return assertAuthz(
h,
assertOneOfUser(
assertOneOfRoles(user.RoleAdmin),
assertAllOfUser(
assertOneOfRoles(user.RoleWriter),
assertSameTenant(),
),
),
nil,
)
}
func assertAuthz(h http.Handler, assertUser assertUser, assertAgent assertAgent) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
reqUser, ok := assertRequestUser(w, r)
if !ok {
return
}
switch u := reqUser.(type) {
case *user.User:
if assertUser != nil {
if ok := assertUser(w, r, u); ok {
h.ServeHTTP(w, r)
return
}
}
case *agent.User:
if assertAgent != nil {
if ok := assertAgent(w, r, u); ok {
h.ServeHTTP(w, r)
return
}
}
default:
logUnexpectedUserType(r.Context(), reqUser)
}
forbidden(w, r)
}
return http.HandlerFunc(fn)
}
type assertUser func(w http.ResponseWriter, r *http.Request, u *user.User) bool
type assertAgent func(w http.ResponseWriter, r *http.Request, u *agent.User) bool
func assertAllOfUser(funcs ...assertUser) assertUser {
return func(w http.ResponseWriter, r *http.Request, u *user.User) bool {
for _, fn := range funcs {
if ok := fn(w, r, u); !ok {
return false
}
}
return true
}
}
func assertOneOfUser(funcs ...assertUser) assertUser {
return func(w http.ResponseWriter, r *http.Request, u *user.User) bool {
for _, fn := range funcs {
if ok := fn(w, r, u); ok {
return true
}
}
return false
}
}
func assertSameTenant() assertUser {
return func(w http.ResponseWriter, r *http.Request, u *user.User) bool {
tenantID, ok := getTenantID(w, r)
if !ok {
return false
}
if u.Tenant() == tenantID {
return true
}
return false
}
}
func assertOneOfRoles(roles ...user.Role) assertUser {
return func(w http.ResponseWriter, r *http.Request, u *user.User) bool {
role := u.Role()
for _, rr := range roles {
if rr == role {
return true
}
}
return false
}
}
func assertMatchingAgent() assertAgent {
return func(w http.ResponseWriter, r *http.Request, u *agent.User) bool {
agentID, ok := getAgentID(w, r)
if !ok {
return false
}
agent := u.Agent()
if agent != nil && agent.ID == agentID && agent.Status == datastore.AgentStatusAccepted {
return true
}
return false
}
}
func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool) {
ctx := r.Context()
user, err := auth.CtxUser(ctx)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve user", logger.CapturedE(err))
forbidden(w, r)
return nil, false
}
if user == nil || user.Tenant() == "" {
forbidden(w, r)
return nil, false
}
return user, true
}
func (m *Mount) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentID datastore.AgentID) bool {
ctx := r.Context()
user, ok := assertRequestUser(w, r)
if !ok {
return false
}
agent, err := m.agentRepo.Get(ctx, agentID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not get agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
}
if agent.TenantID != nil && *agent.TenantID == user.Tenant() {
return true
}
api.ErrorResponse(w, http.StatusForbidden, ErrCodeForbidden, nil)
return false
}
func forbidden(w http.ResponseWriter, r *http.Request) {
logger.Warn(r.Context(), "forbidden", logger.F("path", r.URL.Path))
api.ErrorResponse(w, http.StatusForbidden, ErrCodeForbidden, nil)
}
func logUnexpectedUserType(ctx context.Context, user auth.User) {
logger.Warn(
ctx, "unexpected user type",
logger.F("subject", user.Subject()),
logger.F("type", fmt.Sprintf("%T", user)),
)
}

View File

@ -0,0 +1,77 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type claimAgentRequest struct {
Thumbprint string `json:"thumbprint" validate:"required"`
}
func (m *Mount) claimAgent(w http.ResponseWriter, r *http.Request) {
user, ok := assertRequestUser(w, r)
if !ok {
return
}
ctx := r.Context()
claimAgentReq := &claimAgentRequest{}
if ok := api.Bind(w, r, claimAgentReq); !ok {
return
}
results, _, err := m.agentRepo.Query(
ctx,
datastore.WithAgentQueryThumbprints(claimAgentReq.Thumbprint),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not query agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if len(results) == 0 {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
if len(results) > 1 {
logger.Error(ctx, "multiple results for agent thumbprint", logger.F("agentThumbprint", claimAgentReq.Thumbprint))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeMultipleResults, nil)
return
}
agent := results[0]
if agent.TenantID != nil {
logger.Error(ctx, "agent already claimed", logger.F("agentThumbprint", claimAgentReq.Thumbprint), logger.F("agentID", agent.ID), logger.F("tenantID", agent.TenantID))
api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyClaimed, nil)
return
}
agent, err = m.agentRepo.Attach(ctx, user.Tenant(), agent.ID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not attach agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,38 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type CreateTenantRequest struct {
Label string `json:"label" validate:"required"`
}
func (m *Mount) createTenant(w http.ResponseWriter, r *http.Request) {
createTenantReq := &CreateTenantRequest{}
if ok := api.Bind(w, r, createTenantReq); !ok {
return
}
ctx := r.Context()
tenant, err := m.tenantRepo.Create(ctx, createTenantReq.Label)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not create tenant", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Tenant *datastore.Tenant `json:"tenant"`
}{
Tenant: tenant,
})
}

View File

@ -0,0 +1,47 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) deleteAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
if ok := m.assertTenantOwns(w, r, agentID); !ok {
return
}
ctx := r.Context()
err := m.agentRepo.Delete(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
AgentID datastore.AgentID `json:"agentId"`
}{
AgentID: datastore.AgentID(agentID),
})
}

View File

@ -0,0 +1,43 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) deleteTenant(w http.ResponseWriter, r *http.Request) {
tenantID, ok := getTenantID(w, r)
if !ok {
return
}
ctx := r.Context()
err := m.tenantRepo.Delete(
ctx,
tenantID,
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete tenant", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
TenantID datastore.TenantID `json:"tenantId"`
}{
TenantID: tenantID,
})
}

View File

@ -0,0 +1,47 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) getAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
if ok := m.assertTenantOwns(w, r, agentID); !ok {
return
}
ctx := r.Context()
agent, err := m.agentRepo.Get(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not get agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

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

@ -0,0 +1,85 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
specs, err := m.agentRepo.GetSpecs(ctx, agentID)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not list specs", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Specs []*datastore.Spec `json:"specs"`
}{
Specs: specs,
})
}
type deleteSpecRequest struct {
Name string `json:"name"`
}
func (m *Mount) deleteSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
deleteSpecReq := &deleteSpecRequest{}
if ok := api.Bind(w, r, deleteSpecReq); !ok {
return
}
ctx := r.Context()
err := m.agentRepo.DeleteSpec(
ctx,
agentID,
deleteSpecReq.Name,
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Name string `json:"name"`
}{
Name: deleteSpecReq.Name,
})
}

View File

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

View File

@ -0,0 +1,123 @@
package api
import (
"net/http"
"strconv"
"strings"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnknownError api.ErrorCode = "unknown-error"
ErrCodeNotFound api.ErrorCode = "not-found"
ErrCodeInvalidSignature api.ErrorCode = "invalid-signature"
ErrCodeConflict api.ErrorCode = "conflict"
ErrCodeMultipleResults api.ErrorCode = "multiple-results"
ErrCodeAlreadyClaimed api.ErrorCode = "already-claimed"
)
func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool) {
rawAgentID := chi.URLParam(r, "agentID")
agentID, err := strconv.ParseInt(rawAgentID, 10, 64)
if err != nil {
logger.Error(r.Context(), "could not parse agent id", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.AgentID(agentID), true
}
func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) {
rawSpecID := chi.URLParam(r, "specID")
specID, err := strconv.ParseInt(rawSpecID, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.SpecID(specID), true
}
func getTenantID(w http.ResponseWriter, r *http.Request) (datastore.TenantID, bool) {
rawTenantID := chi.URLParam(r, "tenantID")
tenantID, err := datastore.ParseTenantID(rawTenantID)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse tenant id", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return "", false
}
return tenantID, true
}
func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return value, true
}
return defaultValue, true
}
func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
values := strings.Split(rawValue, ",")
return values, true
}
return defaultValue, true
}
func getIntSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []int64) ([]int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
rawValues := strings.Split(rawValue, ",")
values := make([]int64, 0, len(rawValues))
for _, rv := range rawValues {
value, err := strconv.ParseInt(rv, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return nil, false
}
values = append(values, value)
}
return values, true
}
return defaultValue, true
}

View File

@ -0,0 +1,58 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/go-chi/chi/v5"
"gitlab.com/wpetit/goweb/api"
)
type Mount struct {
agentRepo datastore.AgentRepository
tenantRepo datastore.TenantRepository
authenticators []auth.Authenticator
}
func (m *Mount) Mount(r chi.Router) {
r.NotFound(m.notFound)
r.Post("/register", m.registerAgent)
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)
r.With(assertQueryAccess).Get("/", m.queryAgents)
r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}", m.getAgent)
r.With(assertAgentOrUserWithWriteAccess).Put("/{agentID}", m.updateAgent)
r.With(assertUserWithWriteAccess).Delete("/{agentID}", m.deleteAgent)
r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}/specs", m.getAgentSpecs)
r.With(assertUserWithWriteAccess).Post("/{agentID}/specs", m.updateSpec)
r.With(assertUserWithWriteAccess).Delete("/{agentID}/specs", m.deleteSpec)
})
r.Route("/tenants", func(r chi.Router) {
r.With(assertQueryAccess).Get("/", m.queryTenants)
r.With(assertAdminAccess).Post("/", m.createTenant)
r.With(assertAdminOrTenantReadAccess).Get("/{tenantID}", m.getTenant)
r.With(assertAdminOrTenantWriteAccess).Put("/{tenantID}", m.updateTenant)
r.With(assertAdminAccess).Delete("/{tenantID}", m.deleteTenant)
})
})
}
func (m *Mount) notFound(w http.ResponseWriter, r *http.Request) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
}
func NewMount(agentRepo datastore.AgentRepository, tenantRepo datastore.TenantRepository, authenticators ...auth.Authenticator) *Mount {
return &Mount{agentRepo, tenantRepo, authenticators}
}

View File

@ -0,0 +1,112 @@
package api
import (
"net/http"
userAuth "forge.cadoles.com/Cadoles/emissary/internal/auth/user"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) queryAgents(w http.ResponseWriter, r *http.Request) {
baseUser, ok := assertRequestUser(w, r)
if !ok {
return
}
ctx := r.Context()
user, ok := baseUser.(*userAuth.User)
if !ok {
logger.Error(ctx, "unexpected user type", logger.F("user", baseUser))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
limit, ok := getIntQueryParam(w, r, "limit", 10)
if !ok {
return
}
offset, ok := getIntQueryParam(w, r, "offset", 0)
if !ok {
return
}
options := []datastore.AgentQueryOptionFunc{
datastore.WithAgentQueryLimit(int(limit)),
datastore.WithAgentQueryOffset(int(offset)),
}
if user.Role() != userAuth.RoleAdmin {
options = append(options, datastore.WithAgentQueryTenantID(user.Tenant()))
}
ids, ok := getIntSliceValues(w, r, "ids", nil)
if !ok {
return
}
if ids != nil {
agentIDs := func(ids []int64) []datastore.AgentID {
agentIDs := make([]datastore.AgentID, 0, len(ids))
for _, id := range ids {
agentIDs = append(agentIDs, datastore.AgentID(id))
}
return agentIDs
}(ids)
options = append(options, datastore.WithAgentQueryID(agentIDs...))
}
thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil)
if !ok {
return
}
if thumbprints != nil {
options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...))
}
statuses, ok := getIntSliceValues(w, r, "statuses", nil)
if !ok {
return
}
if statuses != nil {
agentStatuses := func(statuses []int64) []datastore.AgentStatus {
agentStatuses := make([]datastore.AgentStatus, 0, len(statuses))
for _, status := range statuses {
agentStatuses = append(agentStatuses, datastore.AgentStatus(status))
}
return agentStatuses
}(statuses)
options = append(options, datastore.WithAgentQueryStatus(agentStatuses...))
}
agents, total, err := m.agentRepo.Query(
ctx,
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not list agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agents []*datastore.Agent `json:"agents"`
Total int `json:"total"`
}{
Agents: agents,
Total: total,
})
}

View File

@ -0,0 +1,82 @@
package api
import (
"net/http"
userAuth "forge.cadoles.com/Cadoles/emissary/internal/auth/user"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) queryTenants(w http.ResponseWriter, r *http.Request) {
baseUser, ok := assertRequestUser(w, r)
if !ok {
return
}
ctx := r.Context()
user, ok := baseUser.(*userAuth.User)
if !ok {
logger.Error(ctx, "unexpected user type", logger.F("user", baseUser))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
limit, ok := getIntQueryParam(w, r, "limit", 10)
if !ok {
return
}
offset, ok := getIntQueryParam(w, r, "offset", 0)
if !ok {
return
}
options := []datastore.TenantQueryOptionFunc{
datastore.WithTenantQueryLimit(int(limit)),
datastore.WithTenantQueryOffset(int(offset)),
}
ids, ok := getStringSliceValues(w, r, "ids", nil)
if !ok {
return
}
tenantIDs := make([]datastore.TenantID, 0)
if user.Role() != userAuth.RoleAdmin {
tenantIDs = append(tenantIDs, user.Tenant())
}
for _, id := range ids {
tenantIDs = append(tenantIDs, datastore.TenantID(id))
}
if len(tenantIDs) > 0 {
options = append(options, datastore.WithTenantQueryID(tenantIDs...))
}
tenants, total, err := m.tenantRepo.Query(
ctx,
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not list tenants", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Tenants []*datastore.Tenant `json:"tenants"`
Total int `json:"total"`
}{
Tenants: tenants,
Total: total,
})
}

View File

@ -0,0 +1,161 @@
package api
import (
"encoding/json"
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type registerAgentRequest struct {
KeySet json.RawMessage `json:"keySet" validate:"required"`
Metadata []metadata.Tuple `json:"metadata" validate:"required"`
Thumbprint string `json:"thumbprint" validate:"required"`
Signature string `json:"signature" validate:"required"`
}
func (m *Mount) registerAgent(w http.ResponseWriter, r *http.Request) {
registerAgentReq := &registerAgentRequest{}
if ok := api.Bind(w, r, registerAgentReq); !ok {
return
}
ctx := r.Context()
keySet, err := jwk.Parse(registerAgentReq.KeySet)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not parse key set", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint))
// Validate that the existing signature validates the request
validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if !validSignature {
logger.Warn(ctx, "invalid thumbprint signature", logger.F("signature", registerAgentReq.Signature))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil)
return
}
metadata := metadata.FromSorted(registerAgentReq.Metadata)
agent, err := m.agentRepo.Create(
ctx,
registerAgentReq.Thumbprint,
keySet,
metadata,
)
if err != nil {
if !errors.Is(err, datastore.ErrAlreadyExist) {
err = errors.WithStack(err)
logger.Error(ctx, "could not create agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
agents, _, err := m.agentRepo.Query(
ctx,
datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint),
datastore.WithAgentQueryLimit(1),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if len(agents) == 0 {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil)
return
}
agentID := agents[0].ID
agent, err = m.agentRepo.Get(ctx, agentID)
if err != nil {
err = errors.WithStack(err)
logger.Error(
ctx, "could not retrieve agent",
logger.CapturedE(err), logger.F("agentID", agentID),
)
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
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)
return
}
if !validSignature {
logger.Error(ctx, "invalid signature")
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
}
updates := []datastore.AgentUpdateOptionFunc{
datastore.WithAgentUpdateKeySet(keySet),
datastore.WithAgentUpdateMetadata(metadata),
datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint),
}
if agent.Status == datastore.AgentStatusForgotten {
updates = append(updates, datastore.WithAgentUpdateStatus(datastore.AgentStatusPending))
}
agent, err = m.agentRepo.Update(
ctx,
agents[0].ID,
updates...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
}
api.DataResponse(w, http.StatusCreated, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,58 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type releaseAgentRequest struct {
AgentID int64 `json:"agentId" validate:"required"`
}
func (m *Mount) releaseAgent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
releaseAgentReq := &releaseAgentRequest{}
if ok := api.Bind(w, r, releaseAgentReq); !ok {
return
}
agentID := datastore.AgentID(releaseAgentReq.AgentID)
if ok := m.assertTenantOwns(w, r, agentID); !ok {
return
}
agent, err := m.agentRepo.Get(ctx, agentID)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
agent, err = m.agentRepo.Detach(ctx, agent.ID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not detach agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,64 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type updateAgentRequest struct {
Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"`
Label *string `json:"label" validate:"omitempty"`
}
func (m *Mount) updateAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
updateAgentReq := &updateAgentRequest{}
if ok := api.Bind(w, r, updateAgentReq); !ok {
return
}
options := make([]datastore.AgentUpdateOptionFunc, 0)
if updateAgentReq.Status != nil {
options = append(options, datastore.WithAgentUpdateStatus(*updateAgentReq.Status))
}
if updateAgentReq.Label != nil {
options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label))
}
agent, err := m.agentRepo.Update(
ctx,
datastore.AgentID(agentID),
options...,
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,86 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision"
)
type updateSpecRequest struct {
spec.RawSpec
}
func (m *Mount) updateSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
updateSpecReq := &updateSpecRequest{}
if ok := api.Bind(w, r, updateSpecReq); !ok {
return
}
if err := spec.Validate(ctx, updateSpecReq); err != nil {
data := struct {
Message string `json:"message"`
}{}
var validationErr *spec.ValidationError
if errors.As(err, &validationErr) {
data.Message = validationErr.Error()
}
err = errors.WithStack(err)
logger.Error(ctx, "could not validate spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data)
return
}
spec, err := m.agentRepo.UpdateSpec(
ctx,
datastore.AgentID(agentID),
string(updateSpecReq.SpecName()),
updateSpecReq.SpecRevision(),
updateSpecReq.SpecData(),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
if errors.Is(err, datastore.ErrUnexpectedRevision) {
api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not update spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Spec *datastore.Spec `json:"spec"`
}{
Spec: spec,
})
}

View File

@ -0,0 +1,59 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type UpdateTenantRequest struct {
Label *string `json:"label" validate:"omitempty"`
}
func (m *Mount) updateTenant(w http.ResponseWriter, r *http.Request) {
tenantID, ok := getTenantID(w, r)
if !ok {
return
}
ctx := r.Context()
updateTenantReq := &UpdateTenantRequest{}
if ok := api.Bind(w, r, updateTenantReq); !ok {
return
}
options := make([]datastore.TenantUpdateOptionFunc, 0)
if updateTenantReq.Label != nil {
options = append(options, datastore.WithTenantUpdateLabel(*updateTenantReq.Label))
}
tenant, err := m.tenantRepo.Update(
ctx,
tenantID,
options...,
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not update tenant", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Tenant *datastore.Tenant `json:"tenant"`
}{
Tenant: tenant,
})
}

Some files were not shown because too many files have changed in this diff Show More