Compare commits

...

169 Commits

Author SHA1 Message Date
Chaim Lev-Ari
b3ac1eb183 fix(edge/stacks): change text on temple method [EE-6596]
Some checks are pending
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
fix [EE-6596]
2024-01-24 12:36:30 +02:00
Chaim Lev-Ari
52db4cba0e fix(storybook): fix msw stories [EE-6503] (#10985) 2024-01-24 10:06:38 +02:00
Chaim Lev-Ari
079bade139 refactor(kube/app): use structuredClone to copy object [EE-6581] (#11004) 2024-01-24 09:31:33 +02:00
Ali
26e52a0f00 fix(pods): don't add labels to old pod that has none [EE-6587] (#11009) 2024-01-24 14:44:15 +13:00
Ali
3ccc764d40 fix(images): update up to date teaser wording [EE-6537] (#11008)
Co-authored-by: testa113 <testa113>
2024-01-24 14:22:15 +13:00
Dakota Walsh
dd068473d2 fix(console): minor typo in tooltip EE-1976 (#11007) 2024-01-24 12:02:56 +13:00
Dakota Walsh
fe47318e26 fix(terminal): display os specific copy/paste tooltip EE-1976 (#10835) 2024-01-24 09:45:40 +13:00
Dakota Walsh
fc7d9ca2cd fix(secrets): add CE teaser EE-6568 (#11001) 2024-01-24 09:44:50 +13:00
Ali
7bf346bd2d fix(app): no summary for existing pvc on edit [EE-6569] (#11003) 2024-01-24 08:09:59 +13:00
Chaim Lev-Ari
8f0f9d7aaa fix(ui): stub unused modules [EE-6583] (#11006) 2024-01-23 15:22:56 +02:00
Chaim Lev-Ari
69c06bc756 feat(ci): replace jest with vitest [EE-6504] (#10997) 2024-01-23 08:42:52 +02:00
Ali
4a19871fcc fix(app): fix capitalisation typos and match EE codebase [EE-6480] (#11002)
Co-authored-by: testa113 <testa113>
2024-01-23 16:28:00 +13:00
Ali
d5080b6884 fix(r2a): fix layout shifting from errors showing as undefined [EE-6570] (#11000) 2024-01-23 14:16:34 +13:00
Prabhat Khera
f7840e0407 fix(ui): mark resources system correctly [EE-6558] (#10996)
* fix(ui): mark resources system correctly [EE-6558]

* address review comments
2024-01-23 13:49:25 +13:00
andres-portainer
85ae705833 fix(gitops): add singleflight behavior to RedeployWhenChanged calls EE-6377 (#10734) 2024-01-22 19:41:48 -03:00
Ali
77c38306b2 fix(app): get min resource limits [EE-6567] (#10994)
Co-authored-by: testa113 <testa113>
2024-01-23 11:20:24 +13:00
Ali
b81babe682 fix(app): no summary for existing pvc [EE-6569] (#10995) 2024-01-23 11:19:52 +13:00
Ali
4c0049edbe fix(app): allow editing pod services [EE-6480] (#10875)
* fix(app): allow editing pod services [EE-6480]
* address review comment

---------

Co-authored-by: testa113 <testa113>
Co-authored-by: prabhat khera <prabhat.khera@portainer.io>
2024-01-23 10:10:16 +13:00
Oscar Zhou
7cba02226e fix(container): duplicate/edit button causes empty container screen [EE-6566] (#10982) 2024-01-22 10:28:16 +13:00
Ali
a15b7cf39a fix(app): fix namespace validation message for admin [EE-6561] (#10992)
Co-authored-by: testa113 <testa113>
2024-01-22 09:30:11 +13:00
Dakota Walsh
36ab4dfb1a Revert "fix(docs): add APIKey Digest example EE-6199 (#10980)" (#10981)
This reverts commit 7b6e106606.
2024-01-19 14:02:19 +13:00
Dakota Walsh
7b6e106606 fix(docs): add APIKey Digest example EE-6199 (#10980) 2024-01-19 13:16:56 +13:00
Ali
5f040bf788 fix(app): namespace selector fixes [EE-6561] (#10977) 2024-01-19 12:20:44 +13:00
Prabhat Khera
a4739f1701 fix messaging for resourse over commit (#10974) 2024-01-19 12:14:47 +13:00
Ali
59f642ea56 fix(app): persisted volume fixes [EE-6554] (#10975)
Co-authored-by: testa113 <testa113>
2024-01-19 12:14:19 +13:00
Oscar Zhou
fa63432695 fix(stack/template): web editor error shows for changing between same mustache templates [EE-6563] (#10976) 2024-01-19 09:28:09 +13:00
Dakota Walsh
1676fefd97 fix(backup): calculate file size correctly EE-6439 (#10919) 2024-01-18 09:00:01 +13:00
Prabhat Khera
bf66b6c5f3 fix(ui): reset auto-scaling formvalues if needed [EE-6544] (#10969) 2024-01-18 07:59:00 +13:00
Chaim Lev-Ari
115b01cee3 fix(docker): include healthy containers in running [EE-6264] (#10746) 2024-01-17 22:30:12 +07:00
Chaim Lev-Ari
a305fe9e4c feat(stacks): hide redeploy for orphaned stacks [EE-5784] (#10841) 2024-01-17 21:45:08 +07:00
Ali
a58b4f479b fix(app): remove duplicate validation messages [EE-5933] (#10967) 2024-01-17 16:30:30 +13:00
Prabhat Khera
93593e1379 fix(ui): update button disabled when manisfest reverted to the orignial content [EE-6544] (#10968) 2024-01-17 13:56:10 +13:00
Prabhat Khera
51ae2198f6 fix typo in app name (#10965) 2024-01-17 12:15:58 +13:00
Prabhat Khera
ccc97e6f78 fix(ui): app summary [EE-6515] (#10966) 2024-01-17 12:15:22 +13:00
Dakota Walsh
3f28d56bfc fix(teams): show add user notification EE-4899 (#10873) 2024-01-17 12:14:05 +13:00
Matt Hook
3103d498cf fix(docs): fix minor swagger issue and upgrade swag [EE-6548] 2024-01-17 11:27:57 +13:00
Oscar Zhou
47f29002f0 fix(edgestack): repull image not work in git autoupdate [EE-6430] (#10952) 2024-01-17 10:20:59 +13:00
Ali
787c7ec4cc fix(app): remove canUndo function from environment variables [EE-6232] (#10961)
Co-authored-by: testa113 <testa113>
2024-01-17 10:13:53 +13:00
Ali
a8e53a4510 fix(app): hide placement form section [EE-6386] (#10964)
Co-authored-by: testa113 <testa113>
2024-01-17 09:34:29 +13:00
Ali
752be47fcc fix(app): get utilization percentage in payload [EE-6387] (#10962)
Co-authored-by: testa113 <testa113>
2024-01-17 08:33:40 +13:00
Ali
95474b7dc5 fix(app): various persisted folder fixes [EE-6235] (#10963)
Co-authored-by: testa113 <testa113>
2024-01-17 08:31:22 +13:00
Prabhat Khera
7a04d1d4ea fix input cursor moving to the end on edit (#10959) 2024-01-16 16:03:01 +13:00
Prabhat Khera
211fff5ed4 update metrics help text (#10960) 2024-01-16 16:02:26 +13:00
Prabhat Khera
2f2cfad722 fix high contrast theme colors (#10872) 2024-01-16 14:32:24 +13:00
Prabhat Khera
380c16c8dd increase the font size for search panel (#10838) 2024-01-16 14:31:39 +13:00
Prabhat Khera
bbf1900677 Disable update application button on load of edit screen (#10957) 2024-01-16 09:31:44 +13:00
Ali
fcc5736d61 fix(app): use isAdmin check on CE [EE-6231] (#10956)
Co-authored-by: testa113 <testa113>
2024-01-15 15:34:21 +13:00
Ali
ae6333bf7c fix(app): remove duplicate values for multinode cluster [EE-6386] (#10947) 2024-01-15 14:34:54 +13:00
Ali
3a959208a8 fix(app): autoscaling min validation [EE-6387] (#10945) 2024-01-15 14:34:16 +13:00
Prabhat Khera
b3b7cfa77f fix(kube): patching stateful service [EE-6523] (#10948) 2024-01-15 13:30:45 +13:00
Ali
6d71a28584 fix(app): improve resource quota error handling [EE-5933] (#10951) 2024-01-15 13:29:35 +13:00
Dakota Walsh
488fcc7cc5 fix(docs): convert APIKey to string EE-6199 (#10943) 2024-01-15 11:59:39 +13:00
Ali
d750389c67 fix(app): fix exhaused error message [EE-6231] (#10949)
Co-authored-by: testa113 <testa113>
2024-01-15 11:03:38 +13:00
Ali
cb7efd8601 fix(app): fix wording and 2 key validation [EE-6233] (#10944)
Co-authored-by: testa113 <testa113>
2024-01-15 11:01:48 +13:00
Ali
55f66f161e fix(app): fix env var state and validation [EE-6232] (#10941)
Co-authored-by: testa113 <testa113>
2024-01-15 10:56:53 +13:00
Prabhat Khera
067a7d148f update endpoint angular state (#10950) 2024-01-12 16:44:49 +13:00
Prabhat Khera
cf88570c39 update validation for storage classes (#10940) 2024-01-12 09:40:55 +13:00
Prabhat Khera
0e6a175bf6 fix metrics text wordings (#10939) 2024-01-12 08:31:29 +13:00
Oscar Zhou
bb680ef20a fix(git): incorrect git commit url for bitbucket [EE-6446] (#10855) 2024-01-12 08:22:50 +13:00
Oscar Zhou
c6505a6647 fix(docker/container): show exit code in status column if needs [EE-5760] (#10916) 2024-01-12 08:21:38 +13:00
Ali
4e7d1c7088 refactor(app): migrate remaining form sections [EE-6231] (#10938) 2024-01-11 15:13:28 +13:00
Prabhat Khera
0b9cebc685 fix(caching): integrate with axios cache interceptor [EE-6505] (#10922)
* integrate with axios-cache-interceptor
* remove extra headers as not needed
2024-01-11 11:12:53 +13:00
Prabhat Khera
d0b9e3a732 fix(UI): app summary on forvalues update [EE-6515] (#10932)
* app summary on forvalues update

* comment added
2024-01-11 10:14:23 +13:00
Prabhat Khera
b7635feff0 fix rbac message when not enabled (#10933) 2024-01-11 08:28:01 +13:00
Matt Hook
7528cabf5a deep upgrade dependencies, follow-redirects, @babel/traverse, postcss (#10931) 2024-01-10 15:40:05 +13:00
Matt Hook
39eb37d5e5 upgrade circl => v1.3.7 (#10925) 2024-01-10 13:08:26 +13:00
Matt Hook
dbd2e609d7 fix(api-key): add password requirement to generate api key [EE-6140] (#10617) 2024-01-09 11:14:24 +13:00
Chaim Lev-Ari
236e669332 refactor(templates): migrate edit view to react [EE-6412] (#10774) 2024-01-08 14:32:32 +07:00
Chaim Lev-Ari
e142939929 fix(ui): apply controlled input to field [EE-6411] (#10738) 2024-01-08 12:11:31 +07:00
Prabhat Khera
98157350b6 fix(UI): add resourse quota warning is consumed 100% [EE-6508] (#10914)
* add resourse quota warning is consumed 100%

* address review comments
2024-01-08 13:49:57 +13:00
Prabhat Khera
317eec2790 allow kube app to scale 0 (#10909) 2024-01-08 08:31:31 +13:00
Prabhat Khera
7a1893f864 fix showing env var values (#10908) 2024-01-08 08:26:20 +13:00
Chaim Lev-Ari
c7125266f6 fix(registries): retag image [EE-6456] (#10836) 2024-01-05 18:02:09 -03:00
matias-portainer
69271c9d59 fix(docker/images): check for empty tags EE-6256 (#10531) 2024-01-05 17:33:42 -03:00
andres-portainer
717f0978d9 fix(tls): set the correct scheme for Docker clients EE-6514 (#10917) 2024-01-05 15:24:29 -03:00
Ali
abf517de28 refactor(app): migrate app summary section [EE-6239] (#10910) 2024-01-05 15:42:36 +13:00
matias-portainer
7a4314032a fix(docker/console): avoid resizing console when inactive EE-5370 (#10292) 2024-01-04 13:01:52 -03:00
andres-portainer
791c21f643 fix(swarm): retrieve the node names for the image list EE-6401 (#10879) 2024-01-04 10:28:24 -03:00
Chaim Lev-Ari
eb5975a400 docs(dashboard): update link for swarm node [EE-6318] (#10833)
Co-authored-by: holysoles <holysoles97@gmail.com>
2024-01-04 17:02:36 +07:00
Chaim Lev-Ari
400a80c07d chore(deps): upgrade to msw v2 [EE-6489] (#10911) 2024-01-04 16:57:21 +07:00
Matt Hook
ecd603db8c fix(docker-networks): use Network icon for networks [EE-6507] (#10913) 2024-01-04 18:54:04 +13:00
Chaim Lev-Ari
95358c204b chore(deps): upgrade docker-types [EE-6491] (#10905) 2024-01-03 16:55:45 +07:00
Ali
9fc7187e24 refactor(app): placement form section [EE-6386] (#10818)
Co-authored-by: testa113 <testa113>
2024-01-03 11:00:50 +13:00
Ali
2d77e71085 refactor(app): migrate-autoscaling [EE-6387] (#10709)
* refactor(app): migrate-autoscaling [EE-6387]
2024-01-03 10:42:39 +13:00
Ali
6da71661d5 refactor(app): migrate replicas form section [EE-6238] (#10705)
Co-authored-by: testa113 <testa113>
2024-01-03 10:27:38 +13:00
Ali
58da51f767 refactor(app): migrate deployment type section [EE-6237] (#10704)
Co-authored-by: testa113 <testa113>
2024-01-03 10:04:08 +13:00
Ali
947ba4940b refactor(app): migrate resource reservations [EE-6236] (#10695)
* refactor(app): migrate resource reservations [EE-6236]
2024-01-03 10:03:33 +13:00
Ali
e07ee05ee7 refactor(app): persisted folders form section [EE-6235] (#10693)
* refactor(app): persisted folder section [EE-6235]
2024-01-03 09:46:26 +13:00
Ali
7a2412b1be refactor(app): migrate configmap and secret form sections [EE-6233] (#10528)
* refactor(app): migrate configmap and secret form sections [EE-6233]
2024-01-03 09:07:11 +13:00
Matt Hook
391b85da41 fix(lib): update binaries and modules for CVEs [EE-6457] 2024-01-03 08:58:13 +13:00
Prabhat Khera
e412958dcc chore(build): exclude draft PRs [EE-5872] (#9987)
* exclude draft PRs
2024-01-03 08:25:35 +13:00
Ali
488393007f refactor(app): migrate env var form section [EE-6232] (#10499)
* refactor(app): migrate env var form section [EE-6232]

* allow undoing delete in inputlist
2024-01-03 08:17:54 +13:00
matias-portainer
6228314e3c fix(oauth): show asterisks placeholder in secret key input field EE-5664 (#10761) 2024-01-02 12:19:15 -03:00
Chaim Lev-Ari
ba19aab8dc refactor(registries): migrate repos table to react [EE-6451] (#10830) 2024-01-02 14:04:15 +07:00
Chaim Lev-Ari
3ae430bdd8 chore(build): remove eslint plugin [EE-6432] (#10773) 2024-01-02 13:42:48 +07:00
Chaim Lev-Ari
faa7180536 docs(api): default to pascal case for property name [EE-6471] (#10860) 2024-01-02 13:30:02 +07:00
Chaim Lev-Ari
a1519ba737 chore(deps): upgrade axios [EE-6488] (#10885)
Co-authored-by: Matt Hook <hookenz@gmail.com>
2024-01-02 13:26:54 +07:00
Chaim Lev-Ari
4c226d7a17 fix(templates): separate template views filters [EE-6397] (#10711) 2024-01-02 13:25:26 +07:00
Chaim Lev-Ari
82951093b5 chore(ci): run lint and test on all pkgs [EE-6201] (#10481) 2024-01-02 10:59:49 +07:00
Matt Hook
2e15cad048 fix(postcss): update postcss to 8.4.32 [EE-6490] 2023-12-29 06:39:53 +13:00
Matt Hook
27e997fe0d update go-get and x/crypto (#10893) 2023-12-28 07:54:41 +13:00
Matt Hook
6a4cfc8d7c chore(libs): update go libs and hide passwords/keys [EE-6496] (#10889) 2023-12-28 05:23:25 +13:00
Matt Hook
ebac0b9da2 upgrade golang and other dependant binaries (#10888) 2023-12-27 10:42:35 +13:00
andres-portainer
e3c5cd063b fix(chisel): fix a nil pointer dereference EE-6481 (#10871) 2023-12-22 11:36:01 -03:00
Chaim Lev-Ari
2b73116284 fix(templates): add host file entry [EE-6461] (#10839) 2023-12-21 15:56:02 +07:00
Prabhat Khera
d2ccb10972 add border to tooltip and modal in high contrast theme (#10834) 2023-12-20 08:55:00 +13:00
Prabhat Khera
6ede9f8cc3 disable html5 validation (#10844) 2023-12-20 08:54:00 +13:00
Prabhat Khera
6b07c874fc revert #10765 (#10870) 2023-12-19 14:19:24 +13:00
Ali
e84dd27e88 feat(cache): default to off [EE-6293] (#10867)
Co-authored-by: testa113 <testa113>
2023-12-19 12:13:44 +13:00
Matt Hook
5f1f797281 remove deprecated random seed and other minor staticcheck errors (#10851) 2023-12-18 11:48:41 +13:00
Ali
52fe09d0b1 fix(stacks): remove deployed version column [EE-6346] (#10859)
Co-authored-by: testa113 <testa113>
2023-12-18 11:39:38 +13:00
Matt Hook
e687cee608 ignore, remove or comment out unused code. Enable unused linter (#10743) 2023-12-18 10:28:15 +13:00
Matt Hook
8396ff068d enable gosimple linter (#10744) 2023-12-18 10:27:24 +13:00
Ali
d98fc1238e fix(git): stacks deployed version [EE-6346] (#10852)
Co-authored-by: testa113 <testa113>
2023-12-15 16:55:39 +13:00
Dakota Walsh
0ddf84638f fix(kubernetes): deprecate old configurations api EE-5571 (#10837)
* fix(kubernetes): deprecate old configurations api EE-5571

* fix doc variable type
2023-12-15 09:04:08 +13:00
Matt Hook
0b9407f0a6 close db before restore. fix log (#10826) 2023-12-14 12:01:05 +13:00
Oscar Zhou
e4d71d858d fix(setting/ssl): cert files are optional to upload [EE-6139] (#10776) 2023-12-13 23:20:19 +13:00
Chaim Lev-Ari
25741e8c4c feat(edge): sort waiting room table [EE-6259] (#10577) 2023-12-13 11:10:29 +02:00
Prabhat Khera
32d8dc311b fix cpu parsing logic (#10808) 2023-12-12 15:35:36 +13:00
Dakota Walsh
6ff6fd7f75 fix(swagger): custom template create docs EE-6428 (#10807) 2023-12-11 10:04:23 +13:00
Matt Hook
41b73fe2ae close the db before backup for windows shares and better error handling (#10810) 2023-12-08 15:24:23 +13:00
Prabhat Khera
fb3b00de41 fix(UI): remember backup settings tab selection [EE-6347] (#10765)
* remember backup settings tab selection

* address review comments
2023-12-08 15:17:27 +13:00
Prabhat Khera
0f9b91a15f disable create access btn if there is no team or user (#10766) 2023-12-08 14:19:43 +13:00
Dakota Walsh
79f3e1b04b fix(backup ui): minor typo on backup page EE-6348 (#10716) 2023-12-08 13:22:41 +13:00
matias-portainer
56022ab7b1 fix(stacks): allow editing custom templates before stack deployment EE-6380 (#10712) 2023-12-07 09:42:18 -03:00
Ali
4e8b371fb7 fix(gitops): clean trailing slash [EE-6346] (#10777)
Co-authored-by: testa113 <testa113>
2023-12-07 13:43:01 +13:00
Ali
a2d6d6002c fix(app): update sliders when limits are known [EE-5933] (#10768)
Co-authored-by: testa113 <testa113>
2023-12-07 12:11:44 +13:00
Chaim Lev-Ari
dabcf4f7db feat(custom-templates): migrate create view to react [EE-6400] (#10715) 2023-12-06 14:11:02 +01:00
Prabhat Khera
bd5ba7b5d0 fix(kube): configmaps and secrets from envFrom in the app detail screen [EE-6282] (#10742)
* fix configmaps and secrets from envFrom

* adress review comments
2023-12-06 16:02:26 +13:00
James Carppe
1d279428a7 Update bug template for 2.19.4 (#10763) 2023-12-06 12:05:53 +13:00
Chaim Lev-Ari
8ee0c0cf27 fix(images): sort by tags [EE-6410] (#10740) 2023-12-04 08:47:28 +02:00
Chaim Lev-Ari
2a18c9f215 fix(edge/templates): fix issues with git templates [EE-6357] (#10679) 2023-12-04 08:46:44 +02:00
Ali
974378c9b5 fix(gitops): correct commit hash link [EE-6346] (#10723) 2023-12-04 11:18:15 +13:00
Matt Hook
eb23818f83 fix(rollback): reimplement rollback feature [EE-6367] (#10721) 2023-12-04 09:12:41 +13:00
Ali
8f4d6e7e27 fix(app): shift external to the top [EE-6392] (#10719)
Co-authored-by: testa113 <testa113>
2023-12-04 07:43:53 +13:00
Oscar Zhou
5c7f6aab66 fix(docker/image): swarm image list issue [EE-6374] (#10710) 2023-12-01 16:49:31 +13:00
Chaim Lev-Ari
3cf36b0e93 fix(app/templates): show default url in settings [EE-6393] (#10706) 2023-11-30 07:18:15 +02:00
Chaim Lev-Ari
7a9436dad7 fix(edge/stacks): clear templates values on change method [EE-6390] (#10707) 2023-11-30 07:13:01 +02:00
cmeng
5c59c53e91 fix(password): force change password EE-6382 (#10708) 2023-11-30 17:46:57 +13:00
Ali
e3a995d515 fix(pvc): show access modes [EE-5581] (#10554)
Co-authored-by: testa113 <testa113>
2023-11-30 09:48:55 +13:00
Ali
87b486b798 fix(PVC): access modes match storage class capability [EE-5580] (#10550) 2023-11-30 09:48:14 +13:00
cmeng
92c18843b2 fix(wizard): count swarm agent as local environment EE-6215 (#10684) 2023-11-30 08:53:56 +13:00
Ali
450c167461 fix(cache): exclude reqs that accept yaml [EE-6381] (#10696)
Co-authored-by: testa113 <testa113>
2023-11-29 11:45:10 +13:00
Ali
bdcb003a32 fix(app): dont validate stack name [EE-6379] (#10701)
Co-authored-by: testa113 <testa113>
2023-11-29 11:44:33 +13:00
Chaim Lev-Ari
c40931b31c fix(templates): show type selector [EE-6370] (#10694) 2023-11-28 15:40:22 +02:00
Matt Hook
db46dc553f fix(backups): fix rollback feature [EE-6367] (#10691) 2023-11-28 11:12:17 +13:00
Chaim Lev-Ari
76bcdfa2b8 fix(edge/templates): fix issues [EE-6328] (#10656) 2023-11-27 09:56:15 +02:00
cmeng
140ac5d17c fix(logout): clean user data when logout EE-6368 (#10690) 2023-11-27 17:21:55 +13:00
Ali
2fe965942a fix(kubeClient): get standard client [EE-6376] (#10692)
Co-authored-by: testa113 <testa113>
2023-11-27 16:48:47 +13:00
cmeng
dc574af734 fix(container): runtime and resources issues EE-6306 (#10611) 2023-11-27 11:56:44 +13:00
Ali
1bcbfb8213 fix(cache): set as true for a new admin [EE-6293] (#10689)
Co-authored-by: testa113 <testa113>
2023-11-27 10:19:08 +13:00
Oscar Zhou
6bec4cdecc fix(edgestack): set retry deployment (#10676) 2023-11-24 13:45:10 +13:00
Ali
04c1c7d8fb fix(cache): cache fixes [EE-6293] (#10681)
* fix(cache): default cache to on for new users [EE-6293]

* clear cache to transition terminating namespace

* add rq requests back to the namespace view

---------

Co-authored-by: testa113 <testa113>
2023-11-24 11:27:52 +13:00
Ali
2f91315ac7 fix(namespace): handle undefined registry options [EE-6366] (#10683)
Co-authored-by: testa113 <testa113>
2023-11-24 10:58:32 +13:00
andres-portainer
a4b17d2548 fix(gitops): change the condition that checks if the environment is online EE-6321 (#10665) 2023-11-23 11:54:50 -03:00
Chaim Lev-Ari
26953d0b15 fix(templates): change default url [EE-6363] (#10680) 2023-11-23 03:41:22 +02:00
cmeng
13d1fc63ff fix(stack): sync ee code to ce EE-5960 (#10642) 2023-11-23 09:17:12 +13:00
Ali
a4926e5237 fix(namespace): create page layout [EE-6385] (#10675) 2023-11-22 15:37:11 +13:00
James Carppe
936a71ee00 Update bug template for 2.19.3 (#10674) 2023-11-22 14:25:22 +13:00
Ali
4096bb562d feat(cache): introduce cache option [EE-6293] (#10672)
Co-authored-by: testa113 <testa113>
2023-11-22 14:21:07 +13:00
cmeng
57ed6ae6a6 fix(edge-stack): parse docker compose multi lines json output EE-6317 (#10627) 2023-11-20 22:54:28 +13:00
Chaim Lev-Ari
ad5a17ac34 feat(edge/updates): schedule time changes [EE-5975] (#10458) 2023-11-20 10:24:54 +02:00
Chaim Lev-Ari
436da01bce feat(auth): save jwt in cookie [EE-5864] (#10527) 2023-11-20 09:35:03 +02:00
Ali
ecce501cf3 Revert "feat(cache): introduce cache option [EE-6293] (#10641)" (#10658)
This reverts commit 2c032f1739.
2023-11-20 15:08:19 +13:00
Ali
2c032f1739 feat(cache): introduce cache option [EE-6293] (#10641) 2023-11-20 10:22:48 +13:00
cmeng
fffc7b364e fix(container): Unable to create container with webhook EE-6313 (#10619) 2023-11-17 14:35:47 +13:00
andres-portainer
0b5b8971b1 fix(gitops): handle the local environment in isEnvironmentOnline() EE-6321 (#10631) 2023-11-16 09:40:42 -03:00
cmeng
be09c5e346 fix(volumes): Volumes browse button spacing issue EE-6323 (#10633) 2023-11-16 16:25:17 +13:00
cmeng
d089dfbca0 fix(container): fix various creating container issues EE-6287 (#10595)
* fix(container): show placeholder for image field EE-6287

* fix(container): correct query params for search button field EE-6287

* fix(container): use btoa to encode registry credential EE-6287

* fix(container): allow creating non-existing option EE-6287

* fix(ui/forms): typeahead component

* fix(container): select the default registry EE-6287

* fix(container): always enable deploy button when always pull is off EE-6287

* fix(container): reset command fields outside current event to avoid validation on broken values EE-6287

* fix(container): query registry with endpoint ID param EE-6287

---------

Co-authored-by: Chaim Lev-Ari <chaim.levi-ari@portainer.io>
2023-11-16 08:50:23 +13:00
600 changed files with 14391 additions and 10872 deletions

View File

@@ -23,7 +23,7 @@ parserOptions:
modules: true
rules:
no-console: warn
no-console: error
no-alert: error
no-control-regex: 'off'
no-empty: warn
@@ -116,10 +116,9 @@ overrides:
- files:
- app/**/*.test.*
extends:
- 'plugin:jest/recommended'
- 'plugin:jest/style'
- 'plugin:vitest/recommended'
env:
'jest/globals': true
'vitest/env': true
rules:
'react/jsx-no-constructed-context-values': off
- files:

View File

@@ -93,6 +93,8 @@ body:
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.19.4'
- '2.19.3'
- '2.19.2'
- '2.19.1'
- '2.19.0'

View File

@@ -13,11 +13,16 @@ on:
- 'feat/*'
- 'fix/*'
- 'refactor/*'
types:
- opened
- reopened
- synchronize
- ready_for_review
env:
DOCKER_HUB_REPO: portainerci/portainer
NODE_ENV: testing
GO_VERSION: 1.21.3
GO_VERSION: 1.21.5
NODE_VERSION: 18.x
jobs:
@@ -30,6 +35,7 @@ jobs:
- { platform: windows, arch: amd64, version: 1809 }
- { platform: windows, arch: amd64, version: ltsc2022 }
runs-on: arc-runner-set
if: github.event.pull_request.draft == false
steps:
- name: '[preparation] checkout the current branch'
uses: actions/checkout@v3.5.3
@@ -120,6 +126,7 @@ jobs:
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
build_manifests:
runs-on: arc-runner-set
if: github.event.pull_request.draft == false
needs: [build_images]
steps:
- name: '[preparation] docker login'

View File

@@ -11,20 +11,27 @@ on:
- master
- develop
- release/*
types:
- opened
- reopened
- synchronize
- ready_for_review
env:
GO_VERSION: 1.21.3
GO_VERSION: 1.21.5
NODE_VERSION: 18.x
jobs:
run-linters:
name: Run linters
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '18'
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- uses: actions/setup-go@v4
with:
@@ -44,6 +51,5 @@ jobs:
- name: GolangCI-Lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.54.1
working-directory: api
version: v1.55.2
args: --timeout=10m -c .golangci.yaml

View File

@@ -6,7 +6,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: 1.21.3
GO_VERSION: 1.21.5
jobs:
client-dependencies:
@@ -144,7 +144,7 @@ jobs:
image: portainerci/portainer:develop
sarif-file: image-docker-scout.json
dockerhub-user: ${{ secrets.DOCKER_HUB_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: upload Docker Scout image security scan result as artifact
uses: actions/upload-artifact@v3
@@ -197,7 +197,7 @@ jobs:
matrix.js.status == 'failure' ||
matrix.go.status == 'failure' ||
matrix.image-trivy.status == 'failure' ||
matrix.image-docker-scout.status == 'failure'
matrix.image-docker-scout.status == 'failure'
uses: slackapi/slack-github-action@v1.23.0
with:
payload: |

View File

@@ -23,7 +23,8 @@ jobs:
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
github.event.review.body == '/scan' &&
github.event.pull_request.draft == false
outputs:
jsdiff: ${{ steps.set-diff-matrix.outputs.js_diff_result }}
steps:
@@ -77,7 +78,8 @@ jobs:
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
github.event.review.body == '/scan' &&
github.event.pull_request.draft == false
outputs:
godiff: ${{ steps.set-diff-matrix.outputs.go_diff_result }}
steps:
@@ -139,7 +141,8 @@ jobs:
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
github.event.review.body == '/scan' &&
github.event.pull_request.draft == false
outputs:
imagediff-trivy: ${{ steps.set-diff-trivy-matrix.outputs.image_diff_trivy_result }}
imagediff-docker-scout: ${{ steps.set-diff-docker-scout-matrix.outputs.image_diff_docker_scout_result }}
@@ -268,7 +271,8 @@ jobs:
runs-on: ubuntu-latest
if: >-
github.event.pull_request &&
github.event.review.body == '/scan'
github.event.review.body == '/scan' &&
github.event.pull_request.draft == false
strategy:
matrix:
jsdiff: ${{fromJson(needs.client-dependencies.outputs.jsdiff)}}

View File

@@ -1,14 +1,22 @@
name: Test
on: push
env:
GO_VERSION: 1.21.3
GO_VERSION: 1.21.5
NODE_VERSION: 18.x
on:
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review
push:
jobs:
test-client:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v2
@@ -19,7 +27,7 @@ jobs:
- run: yarn --frozen-lockfile
- name: Run tests
run: make test-client ARGS="--maxWorkers=2"
run: make test-client ARGS="--maxWorkers=2 --minWorkers=1"
test-server:
strategy:
matrix:
@@ -29,6 +37,8 @@ jobs:
- { platform: windows, arch: amd64, version: 1809 }
- { platform: windows, arch: amd64, version: ltsc2022 }
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3

View File

@@ -6,14 +6,20 @@ on:
- master
- develop
- 'release/*'
types:
- opened
- reopened
- synchronize
- ready_for_review
env:
GO_VERSION: 1.21.3
GO_VERSION: 1.21.5
NODE_VERSION: 18.x
jobs:
openapi-spec:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v3

View File

@@ -4,10 +4,13 @@ linters:
# Enable these for now
enable:
- unused
- depguard
- gosimple
- govet
- errorlint
- exportloopref
linters-settings:
depguard:
rules:

View File

@@ -3,6 +3,7 @@ import { StorybookConfig } from '@storybook/react-webpack5';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack';
import postcss from 'postcss';
const config: StorybookConfig = {
stories: ['../app/**/*.stories.@(ts|tsx)'],
addons: [
@@ -87,9 +88,6 @@ const config: StorybookConfig = {
name: '@storybook/react-webpack5',
options: {},
},
docs: {
autodocs: true,
},
};
export default config;

View File

@@ -1,23 +1,26 @@
import '../app/assets/css';
import React from 'react';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswDecorator } from 'msw-storybook-addon';
import { handlers } from '@/setup-tests/server-handlers';
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../app/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from 'react-query';
// Initialize MSW
initMSW({
onUnhandledRequest: ({ method, url }) => {
if (url.pathname.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}.
initMSW(
{
onUnhandledRequest: ({ method, url }) => {
console.log(method, url);
if (url.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}.
This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories.
If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses
`);
}
}
},
},
});
handlers
);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
@@ -44,5 +47,6 @@ export const decorators = [
</UIRouter>
</QueryClientProvider>
),
mswDecorator,
];
export const loaders = [mswLoader];

View File

@@ -2,22 +2,22 @@
/* tslint:disable */
/**
* Mock Service Worker (0.36.3).
* Mock Service Worker (2.0.11).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929';
const bypassHeaderName = 'x-msw-bypass';
const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();
self.addEventListener('install', function () {
return self.skipWaiting();
self.skipWaiting();
});
self.addEventListener('activate', async function (event) {
return self.clients.claim();
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('message', async function (event) {
@@ -33,7 +33,9 @@ self.addEventListener('message', async function (event) {
return;
}
const allClients = await self.clients.matchAll();
const allClients = await self.clients.matchAll({
type: 'window',
});
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
@@ -83,165 +85,8 @@ self.addEventListener('message', async function (event) {
}
});
// Resolve the "main" client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (client.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll();
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event);
const response = await getResponse(event, client, requestId);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
(async function () {
const clonedResponse = response.clone();
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body: clonedResponse.body === null ? null : await clonedResponse.text(),
headers: serializeHeaders(clonedResponse.headers),
redirected: clonedResponse.redirected,
},
});
})();
}
return response;
}
async function getResponse(event, client, requestId) {
const { request } = event;
const requestClone = request.clone();
const getOriginalResponse = () => fetch(requestClone);
// Bypass mocking when the request client is not active.
if (!client) {
return getOriginalResponse();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return await getOriginalResponse();
}
// Bypass requests with the explicit bypass header
if (requestClone.headers.get(bypassHeaderName) === 'true') {
const cleanRequestHeaders = serializeHeaders(requestClone.headers);
// Remove the bypass header to comply with the CORS preflight check.
delete cleanRequestHeaders[bypassHeaderName];
const originalRequest = new Request(requestClone, {
headers: new Headers(cleanRequestHeaders),
});
return fetch(originalRequest);
}
// Send the request to the client-side MSW.
const reqHeaders = serializeHeaders(request.headers);
const body = await request.text();
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: reqHeaders,
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body,
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
});
switch (clientMessage.type) {
case 'MOCK_SUCCESS': {
return delayPromise(() => respondWithMock(clientMessage), clientMessage.payload.delay);
}
case 'MOCK_NOT_FOUND': {
return getOriginalResponse();
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.payload;
const networkError = new Error(message);
networkError.name = name;
// Rejecting a request Promise emulates a network error.
throw networkError;
}
case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body);
console.error(
`\
[MSW] Uncaught exception in the request handler for "%s %s":
${parsedBody.location}
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url
);
return respondWithMock(clientMessage);
}
}
return getOriginalResponse();
}
self.addEventListener('fetch', function (event) {
const { request } = event;
const accept = request.headers.get('accept') || '';
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return;
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
@@ -261,36 +106,149 @@ self.addEventListener('fetch', function (event) {
return;
}
const requestId = uuidv4();
return event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn('[MSW] Successfully emulated a network error for the "%s %s" request.', request.method, request.url);
return;
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`
);
})
);
// Generate unique request ID.
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId));
});
function serializeHeaders(headers) {
const reqHeaders = {};
headers.forEach((value, name) => {
reqHeaders[name] = reqHeaders[name] ? [].concat(reqHeaders[name]).concat(value) : value;
});
return reqHeaders;
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event);
const response = await getResponse(event, client, requestId);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
(async function () {
const responseClone = response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
[responseClone.body]
);
})();
}
return response;
}
function sendToClient(client, message) {
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (client?.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
async function getResponse(event, client, requestId) {
const { request } = event;
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone();
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries());
// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention'];
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
const mswIntention = request.headers.get('x-msw-intention');
if (['bypass', 'passthrough'].includes(mswIntention)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer();
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
},
},
[requestBuffer]
);
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data);
}
case 'MOCK_NOT_FOUND': {
return passthrough();
}
}
return passthrough();
}
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
@@ -302,27 +260,25 @@ function sendToClient(client, message) {
resolve(event.data);
};
client.postMessage(JSON.stringify(message), [channel.port2]);
client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
});
}
function delayPromise(cb, duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(cb()), duration);
});
}
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
function respondWithMock(clientMessage) {
return new Response(clientMessage.payload.body, {
...clientMessage.payload,
headers: clientMessage.payload.headers,
});
}
const mockedResponse = new Response(response.body, response);
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}

View File

@@ -7,9 +7,9 @@ ARCH=$(shell go env GOARCH)
# build target, can be one of "production", "testing", "development"
ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=latest
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.8.11
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM=go run gotest.tools/gotestsum@latest
# Don't change anything below this line unless you know what you're doing
@@ -68,7 +68,7 @@ test-client: ## Run client tests
yarn test $(ARGS)
test-server: ## Run server tests
cd api && $(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
##@ Dev
.PHONY: dev dev-client dev-server
@@ -92,7 +92,7 @@ format-client: ## Format client code
yarn format
format-server: ## Format server code
cd api && go fmt ./...
go fmt ./...
##@ Lint
.PHONY: lint lint-client lint-server
@@ -102,7 +102,7 @@ lint-client: ## Lint client code
yarn lint
lint-server: ## Lint server code
cd api && go vet ./...
golangci-lint run --timeout=10m -c .golangci.yaml
##@ Extension
@@ -114,7 +114,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 --markdownFiles ./
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml

View File

@@ -6,11 +6,11 @@ import (
// APIKeyService represents a service for managing API keys.
type APIKeyService interface {
HashRaw(rawKey string) []byte
HashRaw(rawKey string) string
GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error)
GetAPIKey(apiKeyID portainer.APIKeyID) (*portainer.APIKey, error)
GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey, error)
GetDigestUserAndKey(digest []byte) (portainer.User, portainer.APIKey, error)
GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error)
UpdateAPIKey(apiKey *portainer.APIKey) error
DeleteAPIKey(apiKeyID portainer.APIKeyID) error
InvalidateUserKeyCache(userId portainer.UserID) bool

View File

@@ -33,8 +33,8 @@ func NewAPIKeyCache(cacheSize int) *apiKeyCache {
// Get returns the user/key associated to an api-key's digest
// This is required because HTTP requests will contain the digest of the API key in header,
// the digest value must be mapped to a portainer user.
func (c *apiKeyCache) Get(digest []byte) (portainer.User, portainer.APIKey, bool) {
val, ok := c.cache.Get(string(digest))
func (c *apiKeyCache) Get(digest string) (portainer.User, portainer.APIKey, bool) {
val, ok := c.cache.Get(digest)
if !ok {
return portainer.User{}, portainer.APIKey{}, false
}
@@ -44,23 +44,23 @@ func (c *apiKeyCache) Get(digest []byte) (portainer.User, portainer.APIKey, bool
}
// Set persists a user/key entry to the cache
func (c *apiKeyCache) Set(digest []byte, user portainer.User, apiKey portainer.APIKey) {
c.cache.Add(string(digest), entry{
func (c *apiKeyCache) Set(digest string, user portainer.User, apiKey portainer.APIKey) {
c.cache.Add(digest, entry{
user: user,
apiKey: apiKey,
})
}
// Delete evicts a digest's user/key entry key from the cache
func (c *apiKeyCache) Delete(digest []byte) {
c.cache.Remove(string(digest))
func (c *apiKeyCache) Delete(digest string) {
c.cache.Remove(digest)
}
// InvalidateUserKeyCache loops through all the api-keys associated to a user and removes them from the cache
func (c *apiKeyCache) InvalidateUserKeyCache(userId portainer.UserID) bool {
present := false
for _, k := range c.cache.Keys() {
user, _, _ := c.Get([]byte(k.(string)))
user, _, _ := c.Get(k.(string))
if user.ID == userId {
present = c.cache.Remove(k)
}

View File

@@ -17,19 +17,19 @@ func Test_apiKeyCacheGet(t *testing.T) {
keyCache.cache.Add(string(""), entry{user: portainer.User{}, apiKey: portainer.APIKey{}})
tests := []struct {
digest []byte
digest string
found bool
}{
{
digest: []byte("foo"),
digest: "foo",
found: true,
},
{
digest: []byte(""),
digest: "",
found: true,
},
{
digest: []byte("bar"),
digest: "bar",
found: false,
},
}
@@ -48,11 +48,11 @@ func Test_apiKeyCacheSet(t *testing.T) {
keyCache := NewAPIKeyCache(10)
// pre-populate cache
keyCache.Set([]byte("bar"), portainer.User{ID: 2}, portainer.APIKey{})
keyCache.Set([]byte("foo"), portainer.User{ID: 1}, portainer.APIKey{})
keyCache.Set("bar", portainer.User{ID: 2}, portainer.APIKey{})
keyCache.Set("foo", portainer.User{ID: 1}, portainer.APIKey{})
// overwrite existing entry
keyCache.Set([]byte("foo"), portainer.User{ID: 3}, portainer.APIKey{})
keyCache.Set("foo", portainer.User{ID: 3}, portainer.APIKey{})
val, ok := keyCache.cache.Get(string("bar"))
is.True(ok)
@@ -74,14 +74,14 @@ func Test_apiKeyCacheDelete(t *testing.T) {
t.Run("Delete an existing entry", func(t *testing.T) {
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
keyCache.Delete([]byte("foo"))
keyCache.Delete("foo")
_, ok := keyCache.cache.Get(string("foo"))
is.False(ok)
})
t.Run("Delete a non-existing entry", func(t *testing.T) {
nonPanicFunc := func() { keyCache.Delete([]byte("non-existent-key")) }
nonPanicFunc := func() { keyCache.Delete("non-existent-key") }
is.NotPanics(nonPanicFunc)
})
}
@@ -131,16 +131,16 @@ func Test_apiKeyCacheLRU(t *testing.T) {
keyCache := NewAPIKeyCache(test.cacheLen)
for _, key := range test.key {
keyCache.Set([]byte(key), portainer.User{ID: 1}, portainer.APIKey{})
keyCache.Set(key, portainer.User{ID: 1}, portainer.APIKey{})
}
for _, key := range test.foundKeys {
_, _, found := keyCache.Get([]byte(key))
_, _, found := keyCache.Get(key)
is.True(found, "Key %s not found", key)
}
for _, key := range test.evictedKeys {
_, _, found := keyCache.Get([]byte(key))
_, _, found := keyCache.Get(key)
is.False(found, "key %s should have been evicted", key)
}
})

View File

@@ -32,9 +32,9 @@ func NewAPIKeyService(apiKeyRepository dataservices.APIKeyRepository, userReposi
}
// HashRaw computes a hash digest of provided raw API key.
func (a *apiKeyService) HashRaw(rawKey string) []byte {
func (a *apiKeyService) HashRaw(rawKey string) string {
hashDigest := sha256.Sum256([]byte(rawKey))
return hashDigest[:]
return base64.StdEncoding.EncodeToString(hashDigest[:])
}
// GenerateApiKey generates a raw API key for a user (for one-time display).
@@ -77,7 +77,7 @@ func (a *apiKeyService) GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey,
// GetDigestUserAndKey returns the user and api-key associated to a specified hash digest.
// A cache lookup is performed first; if the user/api-key is not found in the cache, respective database lookups are performed.
func (a *apiKeyService) GetDigestUserAndKey(digest []byte) (portainer.User, portainer.APIKey, error) {
func (a *apiKeyService) GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error) {
// get api key from cache if possible
cachedUser, cachedKey, ok := a.cache.Get(digest)
if ok {

View File

@@ -2,6 +2,7 @@ package apikey
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"testing"
@@ -68,7 +69,7 @@ func Test_GenerateApiKey(t *testing.T) {
generatedDigest := sha256.Sum256([]byte(rawKey))
is.Equal(apiKey.Digest, generatedDigest[:])
is.Equal(apiKey.Digest, base64.StdEncoding.EncodeToString(generatedDigest[:]))
})
}

View File

@@ -48,18 +48,6 @@ func TarGzDir(absolutePath string) (string, error) {
}
func addToArchive(tarWriter *tar.Writer, pathInArchive string, path string, info os.FileInfo) error {
header, err := tar.FileInfoHeader(info, info.Name())
if err != nil {
return err
}
header.Name = pathInArchive // use relative paths in archive
err = tarWriter.WriteHeader(header)
if err != nil {
return err
}
if info.IsDir() {
return nil
}
@@ -68,6 +56,26 @@ func addToArchive(tarWriter *tar.Writer, pathInArchive string, path string, info
if err != nil {
return err
}
stat, err := file.Stat()
if err != nil {
return err
}
header, err := tar.FileInfoHeader(stat, stat.Name())
if err != nil {
return err
}
header.Name = pathInArchive // use relative paths in archive
err = tarWriter.WriteHeader(header)
if err != nil {
return err
}
if stat.IsDir() {
return nil
}
_, err = io.Copy(tarWriter, file)
return err
}
@@ -98,7 +106,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
// skip, dir will be created with a file
case tar.TypeReg:
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
if err := os.MkdirAll(filepath.Dir(p), 0744); err != nil {
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
}
outFile, err := os.Create(p)

View File

@@ -17,7 +17,7 @@ import (
"github.com/rs/zerolog/log"
)
const rwxr__r__ os.FileMode = 0744
const rwxr__r__ os.FileMode = 0o744
var filesToBackup = []string{
"certs",

View File

@@ -21,6 +21,7 @@ const (
tunnelCleanupInterval = 10 * time.Second
requiredTimeout = 15 * time.Second
activeTimeout = 4*time.Minute + 30*time.Second
pingTimeout = 3 * time.Second
)
// Service represents a service to manage the state of multiple reverse tunnels.
@@ -59,14 +60,18 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
}
httpClient := &http.Client{
Timeout: 3 * time.Second,
Timeout: pingTimeout,
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return err
return nil
}
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done

View File

@@ -0,0 +1,39 @@
package chisel
import (
"net"
"net/http"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
)
func TestPingAgentPanic(t *testing.T) {
endpointID := portainer.EndpointID(1)
s := NewService(nil, nil, nil)
defer func() {
require.Nil(t, recover())
}()
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(pingTimeout + 1*time.Second)
})
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
require.NoError(t, err)
go func() {
require.NoError(t, http.Serve(ln, mux))
}()
s.getTunnelDetails(endpointID)
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
require.Error(t, s.pingAgent(endpointID))
}

View File

@@ -49,7 +49,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
Rollback: kingpin.Flag("rollback", "Rollback the database store to the previous version").Bool(),
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),

View File

@@ -9,7 +9,7 @@ import (
// Confirm starts a rollback db cli application
func Confirm(message string) (bool, error) {
fmt.Printf("%s [y/N]", message)
fmt.Printf("%s [y/N] ", message)
reader := bufio.NewReader(os.Stdin)

View File

@@ -3,11 +3,9 @@ package main
import (
"context"
"crypto/sha256"
"math/rand"
"os"
"path"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
@@ -200,7 +198,7 @@ func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
return apikey.NewAPIKeyService(datastore.APIKeyRepository(), datastore.User())
}
func initJWTService(userSessionTimeout string, dataStore dataservices.DataStore) (dataservices.JWTService, error) {
func initJWTService(userSessionTimeout string, dataStore dataservices.DataStore) (portainer.JWTService, error) {
if userSessionTimeout == "" {
userSessionTimeout = portainer.DefaultUserSessionTimeout
}
@@ -631,8 +629,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
func main() {
rand.Seed(time.Now().UnixNano())
configureLogger()
setLoggingMode("PRETTY")

View File

@@ -144,6 +144,8 @@ func (connection *DbConnection) Open() error {
// Close closes the BoltDB database.
// Safe to being called multiple times.
func (connection *DbConnection) Close() error {
log.Info().Msg("closing PortainerDB")
if connection.DB != nil {
return connection.DB.Close()
}

View File

@@ -1,7 +1,6 @@
package apikeyrepository
import (
"bytes"
"errors"
"fmt"
@@ -37,7 +36,7 @@ func NewService(connection portainer.Connection) (*Service, error) {
// GetAPIKeysByUserID returns a slice containing all the APIKeys a user has access to.
func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer.APIKey, error) {
var result = make([]portainer.APIKey, 0)
result := make([]portainer.APIKey, 0)
err := service.Connection.GetAll(
BucketName,
@@ -61,7 +60,7 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
// GetAPIKeyByDigest returns the API key for the associated digest.
// Note: there is a 1-to-1 mapping of api-key and digest
func (service *Service) GetAPIKeyByDigest(digest []byte) (*portainer.APIKey, error) {
func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) {
var k *portainer.APIKey
stop := fmt.Errorf("ok")
err := service.Connection.GetAll(
@@ -73,7 +72,7 @@ func (service *Service) GetAPIKeyByDigest(digest []byte) (*portainer.APIKey, err
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
}
if bytes.Equal(key.Digest, digest) {
if key.Digest == digest {
k = key
return nil, stop
}

View File

@@ -2,7 +2,6 @@ package dataservices
import (
"io"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
@@ -133,15 +132,6 @@ type (
HelmUserRepositoryByUserID(userID portainer.UserID) ([]portainer.HelmUserRepository, error)
}
// JWTService represents a service for managing JWT tokens
JWTService interface {
GenerateToken(data *portainer.TokenData) (string, error)
GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error)
GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error)
ParseAndVerifyToken(token string) (*portainer.TokenData, error)
SetUserSessionDuration(userSessionDuration time.Duration)
}
// RegistryService represents a service for managing registry data
RegistryService interface {
BaseCRUD[portainer.Registry, portainer.RegistryID]
@@ -162,7 +152,7 @@ type (
APIKeyRepository interface {
BaseCRUD[portainer.APIKey, portainer.APIKeyID]
GetAPIKeysByUserID(userID portainer.UserID) ([]portainer.APIKey, error)
GetAPIKeyByDigest(digest []byte) (*portainer.APIKey, error)
GetAPIKeyByDigest(digest string) (*portainer.APIKey, error)
}
// SettingsService represents a service for managing application settings

View File

@@ -4,186 +4,82 @@ import (
"fmt"
"os"
"path"
"time"
"github.com/portainer/portainer/api/database/models"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
var backupDefaults = struct {
backupDir string
commonDir string
}{
"backups",
"common",
func (store *Store) Backup() (string, error) {
if err := store.createBackupPath(); err != nil {
return "", err
}
backupFilename := store.backupFilename()
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
// Close the store before backing up
err := store.Close()
if err != nil {
return "", fmt.Errorf("failed to close store before backup: %w", err)
}
err = store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true)
if err != nil {
return "", fmt.Errorf("failed to create backup file: %w", err)
}
// reopen the store
_, err = store.Open()
if err != nil {
return "", fmt.Errorf("failed to reopen store after backup: %w", err)
}
return backupFilename, nil
}
//
// Backup Helpers
//
func (store *Store) Restore() error {
backupFilename := store.backupFilename()
return store.RestoreFromFile(backupFilename)
}
// createBackupFolders create initial folders for backups
func (store *Store) createBackupFolders() {
// create common dir
commonDir := store.commonBackupDir()
if exists, _ := store.fileService.FileExists(commonDir); !exists {
if err := os.MkdirAll(commonDir, 0700); err != nil {
log.Error().Err(err).Msg("error while creating common backup folder")
func (store *Store) RestoreFromFile(backupFilename string) error {
store.Close()
if err := store.fileService.Copy(backupFilename, store.connection.GetDatabaseFilePath(), true); err != nil {
return fmt.Errorf("unable to restore backup file %q. err: %w", backupFilename, err)
}
log.Info().Str("from", backupFilename).Str("to", store.connection.GetDatabaseFilePath()).Msgf("database restored")
_, err := store.Open()
if err != nil {
return fmt.Errorf("unable to determine version of restored portainer backup file: %w", err)
}
// determine the db version
version, err := store.VersionService.Version()
if err != nil {
return fmt.Errorf("unable to determine restored database version. err: %w", err)
}
editionLabel := portainer.SoftwareEdition(version.Edition).GetEditionLabel()
log.Info().Msgf("Restored database version: Portainer %s %s", editionLabel, version.SchemaVersion)
return nil
}
func (store *Store) createBackupPath() error {
backupDir := path.Join(store.connection.GetStorePath(), "backups")
if exists, _ := store.fileService.FileExists(backupDir); !exists {
if err := os.MkdirAll(backupDir, 0700); err != nil {
return fmt.Errorf("unable to create backup folder: %w", err)
}
}
return nil
}
func (store *Store) backupFilename() string {
return path.Join(store.connection.GetStorePath(), "backups", store.connection.GetDatabaseFileName()+".bak")
}
func (store *Store) databasePath() string {
return store.connection.GetDatabaseFilePath()
}
func (store *Store) commonBackupDir() string {
return path.Join(store.connection.GetStorePath(), backupDefaults.backupDir, backupDefaults.commonDir)
}
func (store *Store) copyDBFile(from string, to string) error {
log.Info().Str("from", from).Str("to", to).Msg("copying DB file")
err := store.fileService.Copy(from, to, true)
if err != nil {
log.Error().Err(err).Msg("failed")
}
return err
}
// BackupOptions provide a helper to inject backup options
type BackupOptions struct {
Version string
BackupDir string
BackupFileName string
BackupPath string
}
// getBackupRestoreOptions returns options to store db at common backup dir location; used by:
// - db backup prior to version upgrade
// - db rollback
func getBackupRestoreOptions(backupDir string) *BackupOptions {
return &BackupOptions{
BackupDir: backupDir, //connection.commonBackupDir(),
BackupFileName: beforePortainerVersionUpgradeBackup,
}
}
// Backup current database with default options
func (store *Store) Backup(version *models.Version) (string, error) {
if version == nil {
return store.backupWithOptions(nil)
}
return store.backupWithOptions(&BackupOptions{
Version: version.SchemaVersion,
})
}
func (store *Store) setupOptions(options *BackupOptions) *BackupOptions {
if options == nil {
options = &BackupOptions{}
}
if options.Version == "" {
v, err := store.VersionService.Version()
if err != nil {
options.Version = ""
}
options.Version = v.SchemaVersion
}
if options.BackupDir == "" {
options.BackupDir = store.commonBackupDir()
}
if options.BackupFileName == "" {
options.BackupFileName = fmt.Sprintf("%s.%s.%s", store.connection.GetDatabaseFileName(), options.Version, time.Now().Format("20060102150405"))
}
if options.BackupPath == "" {
options.BackupPath = path.Join(options.BackupDir, options.BackupFileName)
}
return options
}
// BackupWithOptions backup current database with options
func (store *Store) backupWithOptions(options *BackupOptions) (string, error) {
log.Info().Msg("creating DB backup")
store.createBackupFolders()
options = store.setupOptions(options)
dbPath := store.databasePath()
if err := store.Close(); err != nil {
return options.BackupPath, fmt.Errorf(
"error closing datastore before creating backup: %w",
err,
)
}
if err := store.copyDBFile(dbPath, options.BackupPath); err != nil {
return options.BackupPath, err
}
if _, err := store.Open(); err != nil {
return options.BackupPath, fmt.Errorf(
"error opening datastore after creating backup: %w",
err,
)
}
return options.BackupPath, nil
}
// RestoreWithOptions previously saved backup for the current Edition with options
// Restore strategies:
// - default: restore latest from current edition
// - restore a specific
func (store *Store) restoreWithOptions(options *BackupOptions) error {
options = store.setupOptions(options)
// Check if backup file exist before restoring
_, err := os.Stat(options.BackupPath)
if os.IsNotExist(err) {
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to restore does not exist %s")
return err
}
err = store.Close()
if err != nil {
log.Error().Err(err).Msg("error while closing store before restore")
return err
}
log.Info().Msg("restoring DB backup")
err = store.copyDBFile(options.BackupPath, store.databasePath())
if err != nil {
return err
}
_, err = store.Open()
return err
}
// RemoveWithOptions removes backup database based on supplied options
func (store *Store) removeWithOptions(options *BackupOptions) error {
log.Info().Msg("removing DB backup")
options = store.setupOptions(options)
_, err := os.Stat(options.BackupPath)
if os.IsNotExist(err) {
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to remove does not exist")
return err
}
log.Info().Str("path", options.BackupPath).Msg("removing DB file")
err = os.Remove(options.BackupPath)
if err != nil {
log.Error().Err(err).Msg("failed")
return err
}
return nil
}

View File

@@ -2,106 +2,79 @@ package datastore
import (
"fmt"
"os"
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
)
func TestCreateBackupFolders(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
connection := store.GetConnection()
backupPath := path.Join(connection.GetStorePath(), backupDefaults.backupDir)
if isFileExist(backupPath) {
t.Error("Expect backups folder to not exist")
}
store.createBackupFolders()
if !isFileExist(backupPath) {
t.Error("Expect backups folder to exist")
}
}
func TestStoreCreation(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
if store == nil {
t.Error("Expect to create a store")
t.Fatal("Expect to create a store")
}
if store.CheckCurrentEdition() != nil {
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if portainer.SoftwareEdition(v.Edition) != portainer.PortainerCE {
t.Error("Expect to get CE Edition")
}
if v.SchemaVersion != portainer.APIVersion {
t.Error("Expect to get APIVersion")
}
}
func TestBackup(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
connection := store.GetConnection()
t.Run("Backup should create default db backup", func(t *testing.T) {
backupFileName := store.backupFilename()
t.Run(fmt.Sprintf("Backup should create %s", backupFileName), func(t *testing.T) {
v := models.Version{
Edition: int(portainer.PortainerCE),
SchemaVersion: portainer.APIVersion,
}
store.VersionService.UpdateVersion(&v)
store.backupWithOptions(nil)
store.Backup()
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", fmt.Sprintf("portainer.edb.%s.*", portainer.APIVersion))
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
}
})
t.Run("BackupWithOption should create a name specific backup at common path", func(t *testing.T) {
store.backupWithOptions(&BackupOptions{
BackupFileName: beforePortainerVersionUpgradeBackup,
BackupDir: store.commonBackupDir(),
})
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", beforePortainerVersionUpgradeBackup)
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
}
})
}
func TestRemoveWithOptions(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
func TestRestore(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
t.Run("successfully removes file if existent", func(t *testing.T) {
store.createBackupFolders()
options := &BackupOptions{
BackupDir: store.commonBackupDir(),
BackupFileName: "test.txt",
}
t.Run("Basic Restore", func(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
filePath := path.Join(options.BackupDir, options.BackupFileName)
f, err := os.Create(filePath)
if err != nil {
t.Fatalf("file should be created; err=%s", err)
}
f.Close()
store.Backup()
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err = store.removeWithOptions(options)
if err != nil {
t.Errorf("RemoveWithOptions should successfully remove file; err=%v", err)
}
if isFileExist(f.Name()) {
t.Errorf("RemoveWithOptions should successfully remove file; file=%s", f.Name())
}
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
})
t.Run("fails to removes file if non-existent", func(t *testing.T) {
options := &BackupOptions{
BackupDir: store.commonBackupDir(),
BackupFileName: "test.txt",
}
t.Run("Basic Restore After Multiple Backups", func(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup()
updateVersion(store, "2.14")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err := store.removeWithOptions(options)
if err == nil {
t.Error("RemoveWithOptions should fail for non-existent file")
}
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
})
}

View File

@@ -31,8 +31,14 @@ func (store *Store) Open() (newStore bool, err error) {
}
if encryptionReq {
backupFilename, err := store.Backup()
if err != nil {
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
}
err = store.encryptDB()
if err != nil {
store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
return false, err
}
}

View File

@@ -0,0 +1,58 @@
package datastore
import (
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
// isFileExist is helper function to check for file existence
func isFileExist(path string) bool {
matches, err := filepath.Glob(path)
if err != nil {
return false
}
return len(matches) > 0
}
func updateVersion(store *Store, v string) {
version, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
version.SchemaVersion = v
err = store.VersionService.UpdateVersion(version)
if err != nil {
log.Fatal().Err(err).Msg("")
}
}
func updateEdition(store *Store, edition portainer.SoftwareEdition) {
version, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
version.Edition = int(edition)
err = store.VersionService.UpdateVersion(version)
if err != nil {
log.Fatal().Err(err).Msg("")
}
}
// testVersion is a helper which tests current store version against wanted version
func testVersion(store *Store, versionWant string, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
log.Fatal().Err(err).Msg("")
}
if v.SchemaVersion != versionWant {
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
}
}

View File

@@ -2,6 +2,7 @@ package datastore
import (
"fmt"
"os"
"runtime/debug"
portainer "github.com/portainer/portainer/api"
@@ -15,8 +16,6 @@ import (
"github.com/rs/zerolog/log"
)
const beforePortainerVersionUpgradeBackup = "portainer.db.bak"
func (store *Store) MigrateData() error {
updating, err := store.VersionService.IsUpdating()
if err != nil {
@@ -41,7 +40,7 @@ func (store *Store) MigrateData() error {
}
// before we alter anything in the DB, create a backup
backupPath, err := store.Backup(version)
_, err = store.Backup()
if err != nil {
return errors.Wrap(err, "while backing up database")
}
@@ -51,9 +50,9 @@ func (store *Store) MigrateData() error {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if restorErr != nil {
return errors.Wrap(restorErr, "failed to restore database")
restoreErr := store.Restore()
if restoreErr != nil {
return errors.Wrap(restoreErr, "failed to restore database")
}
log.Info().Msg("database restored to previous version")
@@ -117,6 +116,11 @@ func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version *models
return err
}
// Special test code to simulate a failure (used by migrate_data_test.go). Do not remove...
if os.Getenv("PORTAINER_TEST_MIGRATE_FAIL") == "FAIL" {
panic("test migration failure")
}
err = store.VersionService.StoreIsUpdating(false)
if err != nil {
return errors.Wrap(err, "failed to update the store")
@@ -135,9 +139,7 @@ func (store *Store) connectionRollback(force bool) error {
}
}
options := getBackupRestoreOptions(store.commonBackupDir())
err := store.restoreWithOptions(options)
err := store.Restore()
if err != nil {
return err
}

View File

@@ -2,34 +2,25 @@ package datastore
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/google/go-cmp/cmp"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
// testVersion is a helper which tests current store version against wanted version
func testVersion(store *Store, versionWant string, t *testing.T) {
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Expect store version to be %s but was %s with error: %s", versionWant, v.SchemaVersion, err)
}
if v.SchemaVersion != versionWant {
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
}
}
func TestMigrateData(t *testing.T) {
snapshotTests := []struct {
tests := []struct {
testName string
srcPath string
wantPath string
@@ -42,7 +33,7 @@ func TestMigrateData(t *testing.T) {
overrideInstanceId: true,
},
}
for _, test := range snapshotTests {
for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
err := migrateDBTestHelper(t, test.srcPath, test.wantPath, test.overrideInstanceId)
if err != nil {
@@ -55,147 +46,133 @@ func TestMigrateData(t *testing.T) {
})
}
// t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) {
// newStore, store, teardown := MustNewTestStore(t, true, false)
// defer teardown()
t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) {
newStore, store := MustNewTestStore(t, true, false)
if !newStore {
t.Error("Expect a new DB")
}
// if !newStore {
// t.Error("Expect a new DB")
// }
testVersion(store, portainer.APIVersion, t)
store.Close()
// testVersion(store, portainer.APIVersion, t)
// store.Close()
newStore, _ = store.Open()
if newStore {
t.Error("Expect store to NOT be new DB")
}
})
// newStore, _ = store.Open()
// if newStore {
// t.Error("Expect store to NOT be new DB")
// }
// })
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "1.0", Edition: int(portainer.PortainerCE)})
store.MigrateData()
// tests := []struct {
// version string
// expectedVersion string
// }{
// {version: "1.24.1", expectedVersion: portainer.APIVersion},
// {version: "2.0.0", expectedVersion: portainer.APIVersion},
// }
// for _, tc := range tests {
// _, store, teardown := MustNewTestStore(t, true, true)
// defer teardown()
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
t.Errorf("Expect backup file to be created %s", backupfilename)
}
})
// // Setup data
// v := models.Version{SchemaVersion: tc.version}
// store.VersionService.UpdateVersion(&v)
t.Run("MigrateData should recover and restore backup during migration critical failure", func(t *testing.T) {
os.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL")
// // Required roles by migrations 22.2
// store.RoleService.Create(&portainer.Role{ID: 1})
// store.RoleService.Create(&portainer.Role{ID: 2})
// store.RoleService.Create(&portainer.Role{ID: 3})
// store.RoleService.Create(&portainer.Role{ID: 4})
version := "2.15"
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
store.MigrateData()
// t.Run(fmt.Sprintf("MigrateData for version %s", tc.version), func(t *testing.T) {
// store.MigrateData()
// testVersion(store, tc.expectedVersion, t)
// })
store.Open()
testVersion(store, version, t)
})
// t.Run(fmt.Sprintf("Restoring DB after migrateData for version %s", tc.version), func(t *testing.T) {
// store.Rollback(true)
// store.Open()
// testVersion(store, tc.version, t)
// })
// }
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.StoreIsUpdating(true)
store.MigrateData()
// t.Run("Error in MigrateData should restore backup before MigrateData", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); exists {
t.Errorf("Backup file should not exist for dirty database")
}
})
// v := models.Version{SchemaVersion: "1.24.1"}
// store.VersionService.UpdateVersion(&v)
t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
// store.MigrateData()
// Set migrator the count to match our migrations array (simulate no changes).
// Should not create a backup
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Unable to read version from db: %s", err)
t.FailNow()
}
// testVersion(store, v.SchemaVersion, t)
// })
migratorParams := store.newMigratorParameters(v)
m := migrator.NewMigrator(migratorParams)
latestMigrations := m.LatestMigrations()
// t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
v.MigratorCount = len(latestMigrations.MigrationFuncs)
store.VersionService.UpdateVersion(v)
}
// v := models.Version{SchemaVersion: "0.0.0"}
// store.VersionService.UpdateVersion(&v)
store.MigrateData()
// store.MigrateData()
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); exists {
t.Errorf("Backup file should not exist for dirty database")
}
})
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
t.Run("MigrateData should create backup on startup if portainer version matches db and migrationFuncs counts differ", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
// if !isFileExist(options.BackupPath) {
// t.Errorf("Backup file should exist; file=%s", options.BackupPath)
// }
// })
// Set migrator count very large to simulate changes
// Should not create a backup
v, err := store.VersionService.Version()
if err != nil {
t.Errorf("Unable to read version from db: %s", err)
t.FailNow()
}
// t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
v.MigratorCount = 1000
store.VersionService.UpdateVersion(v)
store.MigrateData()
// store.VersionService.StoreIsUpdating(true)
// store.MigrateData()
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
// if isFileExist(options.BackupPath) {
// t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
// }
// })
// t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
// _, store, teardown := MustNewTestStore(t, false, true)
// defer teardown()
// store.MigrateData()
// options := store.setupOptions(getBackupRestoreOptions(store.commonBackupDir()))
// if isFileExist(options.BackupPath) {
// t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
// }
// })
}
func Test_getBackupRestoreOptions(t *testing.T) {
_, store := MustNewTestStore(t, false, true)
options := getBackupRestoreOptions(store.commonBackupDir())
wantDir := store.commonBackupDir()
if !strings.HasSuffix(options.BackupDir, wantDir) {
log.Fatal().Str("got", options.BackupDir).Str("want", wantDir).Msg("incorrect backup dir")
}
wantFilename := "portainer.db.bak"
if options.BackupFileName != wantFilename {
log.Fatal().Str("got", options.BackupFileName).Str("want", wantFilename).Msg("incorrect backup file")
}
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
// only create a backup when the version changes.
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
t.Errorf("DB backup should exist and there should be no error")
}
})
}
func TestRollback(t *testing.T) {
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := models.Version{SchemaVersion: "2.4.0"}
_, store := MustNewTestStore(t, true, false)
version := "2.11"
err := store.VersionService.UpdateVersion(&version)
if err != nil {
t.Errorf("Failed updating version: %v", err)
v := models.Version{
SchemaVersion: version,
}
_, err = store.backupWithOptions(getBackupRestoreOptions(store.commonBackupDir()))
_, store := MustNewTestStore(t, false, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
if err != nil {
log.Fatal().Err(err).Msg("")
}
// Change the current version
version2 := models.Version{SchemaVersion: "2.6.0"}
err = store.VersionService.UpdateVersion(&version2)
v.SchemaVersion = "2.14"
// Change the current edition
err = store.VersionService.UpdateVersion(&v)
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -207,26 +184,45 @@ func TestRollback(t *testing.T) {
return
}
_, err = store.Open()
store.Open()
testVersion(store, version, t)
})
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := "2.15"
v := models.Version{
SchemaVersion: version,
Edition: int(portainer.PortainerCE),
}
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
if err != nil {
t.Logf("Open failed: %s", err)
log.Fatal().Err(err).Msg("")
}
v.SchemaVersion = "2.14"
// Change the current edition
err = store.VersionService.UpdateVersion(&v)
if err != nil {
log.Fatal().Err(err).Msg("")
}
err = store.Rollback(true)
if err != nil {
t.Logf("Rollback failed: %s", err)
t.Fail()
return
}
testVersion(store, version.SchemaVersion, t)
store.Open()
testVersion(store, version, t)
})
}
// isFileExist is helper function to check for file existence
func isFileExist(path string) bool {
matches, err := filepath.Glob(path)
if err != nil {
return false
}
return len(matches) > 0
}
// migrateDBTestHelper loads a json representation of a bolt database from srcPath,
// parses it into a database, runs a migration on that database, and then
// compares it with an expected output database.

View File

@@ -228,7 +228,6 @@ func (m *Migrator) initMigrations() {
m.migrateDockerDesktopExtensionSetting,
m.updateEdgeStackStatusForDB100,
)
m.addMigrations("2.20",
m.updateAppTemplatesVersionForDB110,
)

View File

@@ -669,6 +669,7 @@
"snapshots": [
{
"Docker": {
"ContainerCount": 0,
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
@@ -903,6 +904,7 @@
"color": ""
},
"TokenIssueAt": 0,
"UseCache": false,
"Username": "admin"
},
{
@@ -932,6 +934,7 @@
"color": ""
},
"TokenIssueAt": 0,
"UseCache": false,
"Username": "prabhat"
}
],

View File

@@ -1,18 +1,24 @@
package client
import (
"bytes"
"errors"
"fmt"
"io"
"maps"
"net/http"
"strings"
"time"
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
var errUnsupportedEnvironmentType = errors.New("Environment not supported")
var errUnsupportedEnvironmentType = errors.New("environment not supported")
const (
defaultDockerRequestTimeout = 60 * time.Second
@@ -42,9 +48,16 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
case portainer.AzureEnvironment:
return nil, errUnsupportedEnvironmentType
case portainer.AgentOnDockerEnvironment:
return createAgentClient(endpoint, factory.signatureService, nodeName, timeout)
return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout)
case portainer.EdgeAgentOnDockerEnvironment:
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout)
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, err
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout)
}
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
@@ -87,7 +100,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
)
}
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string, timeout *time.Duration) (*client.Client, error) {
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint, timeout)
if err != nil {
return nil, err
@@ -107,51 +120,73 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
headers[portainer.PortainerAgentTargetHeader] = nodeName
}
tunnel, err := reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, err
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
return client.NewClientWithOpts(
opts := []client.Opt{
client.WithHost(endpointURL),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)
}
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
opts = append(opts, client.WithScheme("https"))
}
return client.NewClientWithOpts(opts...)
}
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint, timeout)
type NodeNameTransport struct {
*http.Transport
nodeNames map[string]string
}
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.Transport.RoundTrip(req)
if err != nil ||
resp.StatusCode != http.StatusOK ||
resp.ContentLength == 0 ||
!strings.HasSuffix(req.URL.Path, "/images/json") {
return resp, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
resp.Body.Close()
return resp, err
}
signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
var rs []struct {
types.ImageSummary
Portainer struct {
Agent struct {
NodeName string
}
}
}
headers := map[string]string{
portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(),
portainer.PortainerAgentSignatureHeader: signature,
if err = json.Unmarshal(body, &rs); err != nil {
return resp, nil
}
if nodeName != "" {
headers[portainer.PortainerAgentTargetHeader] = nodeName
t.nodeNames = make(map[string]string)
for _, r := range rs {
t.nodeNames[r.ID] = r.Portainer.Agent.NodeName
}
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)
return resp, err
}
func (t *NodeNameTransport) NodeNames() map[string]string {
return maps.Clone(t.nodeNames)
}
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
transport := &http.Transport{}
transport := &NodeNameTransport{
Transport: &http.Transport{},
}
if endpoint.TLSConfig.TLS {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)

View File

@@ -201,9 +201,12 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
}
}
if strings.Contains(container.Status, "(healthy)") {
if container.State == "healthy" {
runningContainers++
healthyContainers++
} else if strings.Contains(container.Status, "(unhealthy)") {
}
if container.State == "unhealthy" {
unhealthyContainers++
}
@@ -222,6 +225,7 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
snapshot.GpuUseAll = gpuUseAll
snapshot.GpuUseList = gpuUseList
snapshot.ContainerCount = len(containers)
snapshot.RunningContainerCount = runningContainers
snapshot.StoppedContainerCount = stoppedContainers
snapshot.HealthyContainerCount = healthyContainers

View File

@@ -51,6 +51,10 @@ type (
// Used only for EE
// EnvVars is a list of environment variables to inject into the stack
EnvVars []portainer.Pair
// Used only for EE async edge agent
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
}
// RegistryCredentials holds the credentials for a Docker registry.

View File

@@ -10,8 +10,8 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/portainer/portainer/pkg/testhelpers"
"github.com/rs/zerolog/log"
)

View File

@@ -173,7 +173,7 @@ func (service *Service) GetStackProjectPathByVersion(stackIdentifier string, ver
}
if commitHash != "" {
versionStr = fmt.Sprintf("%s", commitHash)
versionStr = commitHash
}
return JoinPaths(service.wrapFileStore(ComposeStorePath), stackIdentifier, versionStr)
}

62
api/http/csrf/csrf.go Normal file
View File

@@ -0,0 +1,62 @@
package csrf
import (
"crypto/rand"
"fmt"
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
gorillacsrf "github.com/gorilla/csrf"
"github.com/portainer/portainer/api/http/security"
"github.com/urfave/negroni"
)
func WithProtect(handler http.Handler) (http.Handler, error) {
handler = withSendCSRFToken(handler)
token := make([]byte, 32)
_, err := rand.Read(token)
if err != nil {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler)
return withSkipCSRF(handler), nil
}
func withSendCSRFToken(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := negroni.NewResponseWriter(w)
sw.Before(func(sw negroni.ResponseWriter) {
statusCode := sw.Status()
if statusCode >= 200 && statusCode < 300 {
csrfToken := gorillacsrf.Token(r)
sw.Header().Set("X-CSRF-Token", csrfToken)
}
})
handler.ServeHTTP(sw, r)
})
}
func withSkipCSRF(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
skip, err := security.ShouldSkipCSRFCheck(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
return
}
if skip {
r = gorillacsrf.UnsafeSkipCheck(r)
}
handler.ServeHTTP(w, r)
})
}

View File

@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -25,7 +26,7 @@ type authenticatePayload struct {
type authenticateResponse struct {
// JWT token used to authenticate against the API
JWT string `json:"jwt" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE"`
JWT string `json:"jwt" example:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB"`
}
func (payload *authenticatePayload) Validate(r *http.Request) error {
@@ -142,12 +143,15 @@ func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User,
}
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
token, err := handler.JWTService.GenerateToken(tokenData)
token, expirationTime, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {
return httperror.InternalServerError("Unable to generate JWT token", err)
}
security.AddAuthCookie(w, token, expirationTime)
return response.JSON(w, &authenticateResponse{JWT: token})
}
func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settings *portainer.LDAPSettings) error {
@@ -196,7 +200,7 @@ func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settin
func teamExists(teamName string, ldapGroups []string) bool {
for _, group := range ldapGroups {
if strings.ToLower(group) == strings.ToLower(teamName) {
if strings.EqualFold(group, teamName) {
return true
}
}

View File

@@ -18,7 +18,7 @@ type Handler struct {
*mux.Router
DataStore dataservices.DataStore
CryptoService portainer.CryptoService
JWTService dataservices.JWTService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
ProxyManager *proxy.Manager

View File

@@ -3,6 +3,7 @@ package auth
import (
"net/http"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/logoutcontext"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -18,12 +19,14 @@ import (
// @failure 500 "Server error"
// @router /auth/logout [post]
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData := handler.bouncer.JWTAuthLookup(r)
tokenData, _ := handler.bouncer.CookieAuthLookup(r)
if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
logoutcontext.Cancel(tokenData.Token)
}
security.RemoveAuthCookie(w)
return response.Empty(w)
}

View File

@@ -152,7 +152,7 @@ func isValidNote(note string) bool {
// @success 200 {object} portainer.CustomTemplate
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /custom_templates/string [post]
// @router /custom_templates/create/string [post]
func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*portainer.CustomTemplate, error) {
var payload customTemplateFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)

View File

@@ -2,7 +2,6 @@ package customtemplates
import (
"bytes"
"fmt"
"io"
"io/fs"
"net/http"
@@ -18,6 +17,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/segmentio/encoding/json"
@@ -76,7 +76,7 @@ func singleAPIRequest(h *Handler, jwt string, is *assert.Assertions, expect stri
}
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBuffer([]byte("{}")))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -132,8 +132,8 @@ func Test_customTemplateGitFetch(t *testing.T) {
h := NewHandler(requestBouncer, store, fileService, gitService)
// generate two standard users' tokens
jwt1, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role})
jwt2, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role})
jwt1, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role})
jwt2, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role})
t.Run("can return the expected file content by a single call from one user", func(t *testing.T) {
singleAPIRequest(h, jwt1, is, "abcdefg")

View File

@@ -211,10 +211,12 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.GitConfig = gitConfig
} else {
templateFolder := strconv.Itoa(customTemplateID)
_, err = handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
if err != nil {
return httperror.InternalServerError("Unable to persist updated custom template file on disk", err)
}
customTemplate.ProjectPath = projectPath
}
err = handler.DataStore.CustomTemplate().Update(customTemplate.ID, customTemplate)

View File

@@ -2,13 +2,16 @@ package images
import (
"net/http"
"strings"
"github.com/docker/docker/api/types"
"github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/handler/docker/utils"
"github.com/portainer/portainer/api/internal/set"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
)
type ImageResponse struct {
@@ -47,6 +50,12 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}
// Extract the node name from the custom transport
nodeNames := make(map[string]string)
if t, ok := cli.HTTPClient().Transport.(*client.NodeNameTransport); ok {
nodeNames = t.NodeNames()
}
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
if err != nil {
return httperror.BadRequest("Invalid query parameter: withUsage", err)
@@ -66,12 +75,19 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
imagesList := make([]ImageResponse, len(images))
for i, image := range images {
if (image.RepoTags == nil || len(image.RepoTags) == 0) && (image.RepoDigests != nil && len(image.RepoDigests) > 0) {
for _, repoDigest := range image.RepoDigests {
image.RepoTags = append(image.RepoTags, repoDigest[0:strings.Index(repoDigest, "@")]+":<none>")
}
}
imagesList[i] = ImageResponse{
Created: image.Created,
ID: image.ID,
Size: image.Size,
Tags: image.RepoTags,
Used: imageUsageSet.Contains(image.ID),
Created: image.Created,
NodeName: nodeNames[image.ID],
ID: image.ID,
Size: image.Size,
Tags: image.RepoTags,
Used: imageUsageSet.Contains(image.ID),
}
}

View File

@@ -2,7 +2,6 @@ package edgestacks
import (
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -190,26 +189,3 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
return newRelatedEnvironmentIDs, endpointsToAdd, nil
}
func newStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIds []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
newStatus := make(map[portainer.EndpointID]portainer.EdgeStackStatus)
for _, endpointID := range relatedEnvironmentIds {
newEnvStatus := portainer.EdgeStackStatus{}
oldEnvStatus, ok := oldStatus[endpointID]
if ok {
newEnvStatus = oldEnvStatus
}
newEnvStatus.Status = []portainer.EdgeStackDeploymentStatus{
{
Time: time.Now().Unix(),
Type: portainer.EdgeStackStatusPending,
},
}
newStatus[endpointID] = newEnvStatus
}
return newStatus
}

View File

@@ -1,12 +1,10 @@
package edgestacks
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
@@ -26,8 +24,6 @@ type Handler struct {
KubernetesDeployer portainer.KubernetesDeployer
}
const contextKey = "edgeStack_item"
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
h := &Handler{
@@ -62,35 +58,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
return h
}
func (handler *Handler) convertAndStoreKubeManifestIfNeeded(stackFolder string, projectPath, composePath string, relatedEndpointIds []portainer.EndpointID) (manifestPath string, err error) {
hasKubeEndpoint, err := hasKubeEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
if err != nil {
return "", fmt.Errorf("unable to check if edge stack has kube environments: %w", err)
}
if !hasKubeEndpoint {
return "", nil
}
composeConfig, err := handler.FileService.GetFileContent(projectPath, composePath)
if err != nil {
return "", fmt.Errorf("unable to retrieve Compose file from disk: %w", err)
}
kompose, err := handler.KubernetesDeployer.ConvertCompose(composeConfig)
if err != nil {
return "", fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err)
}
komposeFileName := filesystem.ManifestFileDefaultName
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, komposeFileName, kompose)
if err != nil {
return "", fmt.Errorf("failed to store kube manifest file: %w", err)
}
return komposeFileName, nil
}
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
httpErr := httperror.InternalServerError(msg, err)

View File

@@ -19,6 +19,8 @@ package endpoints
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /endpoints/{id}/docker/v2/browse/put [post]
//
//lint:ignore U1000 Ignore unused code, for documentation purposes
func _fileBrowseFileUploadV2() {
// dummy function to make swag pick up the above docs for the following REST call
// POST request on /browse/put?volumeID=:id

View File

@@ -2,7 +2,6 @@ package endpoints
import (
"net/http"
"sort"
"strconv"
portainer "github.com/portainer/portainer/api"
@@ -30,7 +29,7 @@ const (
// @produce json
// @param start query int false "Start searching from"
// @param limit query int false "Limit results to this value"
// @param sort query int false "Sort results by this value"
// @param sort query sortKey false "Sort results by this value" Enum("Name", "Group", "Status", "LastCheckIn", "EdgeID")
// @param order query int false "Order sorted results by desc/asc" Enum("asc", "desc")
// @param search query string false "Search query"
// @param groupIds query []int false "List environments(endpoints) of these groups"
@@ -98,7 +97,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
return httperror.InternalServerError("Unable to filter endpoints", err)
}
sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc")
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
filteredEndpointCount := len(filteredEndpoints)
@@ -147,46 +146,6 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
return endpoints[start:end]
}
func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, sortField string, isSortDesc bool) {
switch sortField {
case "Name":
if isSortDesc {
sort.Stable(sort.Reverse(EndpointsByName(endpoints)))
} else {
sort.Stable(EndpointsByName(endpoints))
}
case "Group":
endpointGroupNames := make(map[portainer.EndpointGroupID]string, 0)
for _, group := range endpointGroups {
endpointGroupNames[group.ID] = group.Name
}
endpointsByGroup := EndpointsByGroup{
endpointGroupNames: endpointGroupNames,
endpoints: endpoints,
}
if isSortDesc {
sort.Stable(sort.Reverse(endpointsByGroup))
} else {
sort.Stable(endpointsByGroup)
}
case "Status":
if isSortDesc {
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i].Status > endpoints[j].Status
})
} else {
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i].Status < endpoints[j].Status
})
}
}
}
func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup {
var endpointGroup portainer.EndpointGroup
for _, group := range groups {

View File

@@ -211,7 +211,7 @@ func buildEndpointListRequest(query string) *http.Request {
restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
req = req.WithContext(restrictedCtx)
req.Header.Add("Authorization", "Bearer dummytoken")
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
return req
}

View File

@@ -1,46 +1,94 @@
package endpoints
import (
"strings"
"slices"
"github.com/fvbommel/sortorder"
portainer "github.com/portainer/portainer/api"
)
type EndpointsByName []portainer.Endpoint
type comp[T any] func(a, b T) int
func (e EndpointsByName) Len() int {
return len(e)
func stringComp(a, b string) int {
if sortorder.NaturalLess(a, b) {
return -1
} else if sortorder.NaturalLess(b, a) {
return 1
} else {
return 0
}
}
func (e EndpointsByName) Swap(i, j int) {
e[i], e[j] = e[j], e[i]
}
func (e EndpointsByName) Less(i, j int) bool {
return sortorder.NaturalLess(strings.ToLower(e[i].Name), strings.ToLower(e[j].Name))
}
type EndpointsByGroup struct {
endpointGroupNames map[portainer.EndpointGroupID]string
endpoints []portainer.Endpoint
}
func (e EndpointsByGroup) Len() int {
return len(e.endpoints)
}
func (e EndpointsByGroup) Swap(i, j int) {
e.endpoints[i], e.endpoints[j] = e.endpoints[j], e.endpoints[i]
}
func (e EndpointsByGroup) Less(i, j int) bool {
if e.endpoints[i].GroupID == e.endpoints[j].GroupID {
return false
func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool) {
if sortField == "" {
return
}
groupA := e.endpointGroupNames[e.endpoints[i].GroupID]
groupB := e.endpointGroupNames[e.endpoints[j].GroupID]
var less comp[portainer.Endpoint]
switch sortField {
case sortKeyName:
less = func(a, b portainer.Endpoint) int {
return stringComp(a.Name, b.Name)
}
case sortKeyGroup:
environmentGroupNames := make(map[portainer.EndpointGroupID]string, 0)
for _, group := range environmentGroups {
environmentGroupNames[group.ID] = group.Name
}
// set the "unassigned" group name to be empty string
environmentGroupNames[1] = ""
less = func(a, b portainer.Endpoint) int {
aGroup := environmentGroupNames[a.GroupID]
bGroup := environmentGroupNames[b.GroupID]
return stringComp(aGroup, bGroup)
}
case sortKeyStatus:
less = func(a, b portainer.Endpoint) int {
return int(a.Status - b.Status)
}
case sortKeyLastCheckInDate:
less = func(a, b portainer.Endpoint) int {
return int(a.LastCheckInDate - b.LastCheckInDate)
}
case sortKeyEdgeID:
less = func(a, b portainer.Endpoint) int {
return stringComp(a.EdgeID, b.EdgeID)
}
}
slices.SortStableFunc(environments, func(a, b portainer.Endpoint) int {
mul := 1
if isSortDesc {
mul = -1
}
return less(a, b) * mul
})
return sortorder.NaturalLess(strings.ToLower(groupA), strings.ToLower(groupB))
}
type sortKey string
const (
sortKeyName sortKey = "Name"
sortKeyGroup sortKey = "Group"
sortKeyStatus sortKey = "Status"
sortKeyLastCheckInDate sortKey = "LastCheckIn"
sortKeyEdgeID sortKey = "EdgeID"
)
func getSortKey(sortField string) sortKey {
fieldAsSortKey := sortKey(sortField)
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID}, fieldAsSortKey) {
return fieldAsSortKey
}
return ""
}

View File

@@ -0,0 +1,168 @@
package endpoints
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/slices"
"github.com/stretchr/testify/assert"
)
func TestSortEndpointsByField(t *testing.T) {
environments := []portainer.Endpoint{
{ID: 0, Name: "Environment 1", GroupID: 1, Status: 1, LastCheckInDate: 3, EdgeID: "edge32"},
{ID: 1, Name: "Environment 2", GroupID: 2, Status: 2, LastCheckInDate: 6, EdgeID: "edge57"},
{ID: 2, Name: "Environment 3", GroupID: 1, Status: 3, LastCheckInDate: 2, EdgeID: "test87"},
{ID: 3, Name: "Environment 4", GroupID: 2, Status: 4, LastCheckInDate: 1, EdgeID: "abc123"},
}
environmentGroups := []portainer.EndpointGroup{
{ID: 1, Name: "Group 1"},
{ID: 2, Name: "Group 2"},
}
tests := []struct {
name string
sortField sortKey
isSortDesc bool
expected []portainer.EndpointID
}{
{
name: "sort without value",
sortField: "",
expected: []portainer.EndpointID{
environments[0].ID,
environments[1].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by name ascending",
sortField: "Name",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[0].ID,
environments[1].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by name descending",
sortField: "Name",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[3].ID,
environments[2].ID,
environments[1].ID,
environments[0].ID,
},
},
{
name: "sort by group name ascending",
sortField: "Group",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[0].ID,
environments[2].ID,
environments[1].ID,
environments[3].ID,
},
},
{
name: "sort by group name descending",
sortField: "Group",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[1].ID,
environments[3].ID,
environments[0].ID,
environments[2].ID,
},
},
{
name: "sort by status ascending",
sortField: "Status",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[0].ID,
environments[1].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by status descending",
sortField: "Status",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[3].ID,
environments[2].ID,
environments[1].ID,
environments[0].ID,
},
},
{
name: "sort by last check-in ascending",
sortField: "LastCheckIn",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[3].ID,
environments[2].ID,
environments[0].ID,
environments[1].ID,
},
},
{
name: "sort by last check-in descending",
sortField: "LastCheckIn",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[1].ID,
environments[0].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by edge ID ascending",
sortField: "EdgeID",
expected: []portainer.EndpointID{
environments[3].ID,
environments[0].ID,
environments[1].ID,
environments[2].ID,
},
},
{
name: "sort by edge ID descending",
sortField: "EdgeID",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[2].ID,
environments[1].ID,
environments[0].ID,
environments[3].ID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
sortEnvironmentsByField(environments, environmentGroups, "Name", false) // reset to default sort order
sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc)
is.Equal(tt.expected, getEndpointIDs(environments))
})
}
}
func getEndpointIDs(environments []portainer.Endpoint) []portainer.EndpointID {
return slices.Map(environments, func(environment portainer.Endpoint) portainer.EndpointID {
return environment.ID
})
}

View File

@@ -20,14 +20,14 @@ type Handler struct {
*mux.Router
requestBouncer security.BouncerService
dataStore dataservices.DataStore
jwtService dataservices.JWTService
jwtService portainer.JWTService
kubeClusterAccessService kubernetes.KubeClusterAccessService
kubernetesDeployer portainer.KubernetesDeployer
helmPackageManager libhelm.HelmPackageManager
}
// NewHandler creates a handler to manage endpoint group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
@@ -93,7 +93,7 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
bearerToken, err := handler.jwtService.GenerateToken(tokenData)
bearerToken, _, err := handler.jwtService.GenerateToken(tokenData)
if err != nil {
return nil, httperror.Unauthorized("Unauthorized", err)
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/stretchr/testify/assert"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
)
@@ -48,7 +49,7 @@ func Test_helmDelete(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/1/kubernetes/helm/%s", options.Name), nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
req.Header.Add("Authorization", "Bearer dummytoken")
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)

View File

@@ -11,6 +11,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/exec/exectest"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
@@ -52,7 +53,7 @@ func Test_helmInstall(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/1/kubernetes/helm", bytes.NewBuffer(optdata))
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
req.Header.Add("Authorization", "Bearer dummytoken")
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)

View File

@@ -10,6 +10,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/exec/exectest"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
@@ -48,7 +49,7 @@ func Test_helmList(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
req.Header.Add("Authorization", "Bearer dummytoken")
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)

View File

@@ -8,6 +8,22 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id getKubernetesConfigMapsAndSecrets
// @summary Get ConfigMaps and Secrets
// @description Get all ConfigMaps and Secrets for a given namespace
// @description **Access policy**: authenticated
// @tags kubernetes
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param id path int true "Environment (Endpoint) identifier"
// @param namespace path string true "Namespace name"
// @success 200 {array} []kubernetes.K8sConfigMapOrSecret "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /kubernetes/{id}/namespaces/{namespace}/configuration [get]
func (handler *Handler) getKubernetesConfigMapsAndSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {

View File

@@ -26,12 +26,12 @@ type Handler struct {
authorizationService *authorization.Service
DataStore dataservices.DataStore
KubernetesClientFactory *cli.ClientFactory
JwtService dataservices.JWTService
JwtService portainer.JWTService
kubeClusterAccessService kubernetes.KubeClusterAccessService
}
// NewHandler creates a handler to process pre-proxied requests to external APIs.
func NewHandler(bouncer security.BouncerService, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler {
func NewHandler(bouncer security.BouncerService, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler {
h := &Handler{
Router: mux.NewRouter(),
authorizationService: authorizationService,
@@ -107,6 +107,7 @@ func kubeOnlyMiddleware(next http.Handler) http.Handler {
return
}
rw.Header().Set(portainer.PortainerCacheHeader, "true")
next.ServeHTTP(rw, request)
})
}
@@ -120,7 +121,12 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr
return nil, httperror.BadRequest("Invalid environment identifier route variable", err)
}
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), r.Header.Get("Authorization"))
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", err)
}
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
if !ok {
return nil, httperror.InternalServerError("Failed to lookup KubeClient", nil)
}
@@ -141,8 +147,13 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err)
}
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), r.Header.Get("Authorization"))
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
if ok {
next.ServeHTTP(w, r)
return
@@ -164,12 +175,6 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
// Generate a proxied kubeconfig, then create a kubeclient using it.
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err)
return
}
bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to create JWT token", err)
@@ -208,7 +213,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), r.Header.Get("Authorization"), kubeCli)
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Username, kubeCli)
next.ServeHTTP(w, r)
})
}

View File

@@ -2,7 +2,6 @@ package kubernetes
import (
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/middlewares"
@@ -178,14 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
)
}
cli, ok := handler.KubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
currentControllers, err := cli.GetIngressControllers()

View File

@@ -84,7 +84,6 @@ func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Re
// @accept json
// @produce json
// @param id path int true "Environment (Endpoint) identifier"
// @param namespace path string true "Namespace"
// @param body body models.K8sNamespaceDetails true "Namespace configuration details"
// @success 200 {string} string "Success"
// @failure 400 "Invalid request"

View File

@@ -23,7 +23,7 @@ type Handler struct {
*mux.Router
DataStore dataservices.DataStore
FileService portainer.FileService
JWTService dataservices.JWTService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
SnapshotService portainer.SnapshotService
demoService *demo.Service

View File

@@ -41,9 +41,6 @@ func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error
if govalidator.IsNull(payload.StackFileContent) {
return errors.New("Invalid stack file content")
}
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil
}

View File

@@ -1,7 +1,6 @@
package system
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +12,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/segmentio/encoding/json"
@@ -43,12 +43,12 @@ func Test_getSystemVersion(t *testing.T) {
h := NewHandler(requestBouncer, &portainer.Status{}, &demo.Service{}, store, nil)
// generate standard and admin user tokens
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
t.Run("Display Edition", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/system/version", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)

View File

@@ -13,6 +13,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/segmentio/encoding/json"
@@ -39,7 +40,7 @@ func Test_teamList(t *testing.T) {
h.DataStore = store
// generate admin user tokens
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
// Case 1: the team is given the endpoint access directly
// create teams
@@ -77,11 +78,11 @@ func Test_teamList(t *testing.T) {
err = store.Endpoint().Create(endpointWithTeamAccessPolicy)
is.NoError(err, "error creating endpoint")
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role})
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role})
t.Run("admin user can successfully list all teams", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/teams", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -102,7 +103,7 @@ func Test_teamList(t *testing.T) {
params := url.Values{}
params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID))
req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -124,7 +125,7 @@ func Test_teamList(t *testing.T) {
t.Run("standard user only can list team where he belongs to", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/teams", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -168,7 +169,7 @@ func Test_teamList(t *testing.T) {
params := url.Values{}
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID))
req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)

View File

@@ -20,7 +20,6 @@ var (
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
errCryptoHashFailure = errors.New("Unable to hash data")
errWrongPassword = errors.New("Wrong password")
)
func hideFields(user *portainer.User) {
@@ -67,6 +66,9 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
adminRouter.Handle("/users", httperror.LoggerHandler(h.userCreate)).Methods(http.MethodPost)
restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
restrictedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete)
@@ -75,6 +77,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete)
restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut)
publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet)
publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost)

View File

@@ -15,18 +15,22 @@ import (
)
type userAccessTokenCreatePayload struct {
Password string `validate:"required" example:"password" json:"password"`
Description string `validate:"required" example:"github-api-key" json:"description"`
}
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Password) {
return errors.New("invalid password: cannot be empty")
}
if govalidator.IsNull(payload.Description) {
return errors.New("invalid description. cannot be empty")
return errors.New("invalid description: cannot be empty")
}
if govalidator.HasWhitespaceOnly(payload.Description) {
return errors.New("invalid description. cannot contain only whitespaces")
return errors.New("invalid description: cannot contain only whitespaces")
}
if govalidator.MinStringLength(payload.Description, "128") {
return errors.New("invalid description. cannot be longer than 128 characters")
return errors.New("invalid description: cannot be longer than 128 characters")
}
return nil
}
@@ -55,9 +59,9 @@ type accessTokenResponse struct {
// @failure 500 "Server error"
// @router /users/{id}/tokens [post]
func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
// specifically require JWT auth for this endpoint since API-Key based auth is not supported
if jwt := handler.bouncer.JWTAuthLookup(r); jwt == nil {
return httperror.Unauthorized("Auth not supported", errors.New("JWT Authentication required"))
// specifically require Cookie auth for this endpoint since API-Key based auth is not supported
if jwt, _ := handler.bouncer.CookieAuthLookup(r); jwt == nil {
return httperror.Unauthorized("Auth not supported", errors.New("Cookie Authentication required"))
}
var payload userAccessTokenCreatePayload
@@ -82,7 +86,12 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
if err != nil {
return httperror.BadRequest("Unable to find a user", err)
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
}
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
}
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)

View File

@@ -2,7 +2,6 @@ package users
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +12,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/segmentio/encoding/json"
@@ -25,7 +25,7 @@ func Test_userCreateAccessToken(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
// create admin and standard user(s)
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
adminUser := &portainer.User{ID: 1, Password: "password", Username: "admin", Role: portainer.AdministratorRole}
err := store.User().Create(adminUser)
is.NoError(err, "error creating admin user")
@@ -43,18 +43,19 @@ func Test_userCreateAccessToken(t *testing.T) {
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
h.DataStore = store
h.CryptoService = testhelpers.NewCryptoService()
// generate standard and admin user tokens
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
t.Run("standard user successfully generates API key", func(t *testing.T) {
data := userAccessTokenCreatePayload{Description: "test-token"}
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token"}
payload, err := json.Marshal(data)
is.NoError(err)
req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -72,12 +73,12 @@ func Test_userCreateAccessToken(t *testing.T) {
})
t.Run("admin cannot generate API key for standard user", func(t *testing.T) {
data := userAccessTokenCreatePayload{Description: "test-token-admin"}
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-admin"}
payload, err := json.Marshal(data)
is.NoError(err)
req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -92,7 +93,7 @@ func Test_userCreateAccessToken(t *testing.T) {
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key")
is.NoError(err)
data := userAccessTokenCreatePayload{Description: "test-token-fails"}
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-fails"}
payload, err := json.Marshal(data)
is.NoError(err)
@@ -106,7 +107,7 @@ func Test_userCreateAccessToken(t *testing.T) {
body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error")
is.Equal(`{"message":"Auth not supported","details":"JWT Authentication required"}`, string(body))
is.Equal(`{"message":"Auth not supported","details":"Cookie Authentication required"}`, string(body))
})
}
@@ -118,23 +119,23 @@ func Test_userAccessTokenCreatePayload(t *testing.T) {
shouldFail bool
}{
{
payload: userAccessTokenCreatePayload{Description: "test-token"},
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token"},
shouldFail: false,
},
{
payload: userAccessTokenCreatePayload{Description: ""},
payload: userAccessTokenCreatePayload{Password: "password", Description: ""},
shouldFail: true,
},
{
payload: userAccessTokenCreatePayload{Description: "test token"},
payload: userAccessTokenCreatePayload{Password: "password", Description: "test token"},
shouldFail: false,
},
{
payload: userAccessTokenCreatePayload{Description: "test-token "},
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token "},
shouldFail: false,
},
{
payload: userAccessTokenCreatePayload{Description: `
payload: userAccessTokenCreatePayload{Password: "password", Description: `
this string is longer than 128 characters and hence this will fail.
this string is longer than 128 characters and hence this will fail.
this string is longer than 128 characters and hence this will fail.

View File

@@ -64,5 +64,5 @@ func (handler *Handler) userGetAccessTokens(w http.ResponseWriter, r *http.Reque
// hideAPIKeyFields remove the digest from the API key (it is not needed in the response)
func hideAPIKeyFields(apiKey *portainer.APIKey) {
apiKey.Digest = nil
apiKey.Digest = ""
}

View File

@@ -1,7 +1,6 @@
package users
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -12,6 +11,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/segmentio/encoding/json"
@@ -44,15 +44,15 @@ func Test_userGetAccessTokens(t *testing.T) {
h.DataStore = store
// generate standard and admin user tokens
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
t.Run("standard user can successfully retrieve API key", func(t *testing.T) {
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-get-token")
is.NoError(err)
req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -68,7 +68,7 @@ func Test_userGetAccessTokens(t *testing.T) {
is.Len(resp, 1)
if len(resp) == 1 {
is.Nil(resp[0].Digest)
is.Equal(resp[0].Digest, "")
is.Equal(apiKey.ID, resp[0].ID)
is.Equal(apiKey.UserID, resp[0].UserID)
is.Equal(apiKey.Prefix, resp[0].Prefix)
@@ -81,7 +81,7 @@ func Test_userGetAccessTokens(t *testing.T) {
is.NoError(err)
req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -129,10 +129,10 @@ func Test_hideAPIKeyFields(t *testing.T) {
UserID: 2,
Prefix: "abc",
Description: "test",
Digest: nil,
Digest: "",
}
hideAPIKeyFields(apiKey)
is.Nil(apiKey.Digest, "digest should be cleared when hiding api key fields")
is.Equal(apiKey.Digest, "", "digest should be cleared when hiding api key fields")
}

View File

@@ -0,0 +1,58 @@
package users
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type CurrentUserInspectResponse struct {
*portainer.User
ForceChangePassword bool `json:"forceChangePassword"`
}
// @id CurrentUserInspect
// @summary Inspect the current user user
// @description Retrieve details about the current user.
// @description User passwords are filtered out, and should never be accessible.
// @description **Access policy**: authenticated
// @tags users
// @security ApiKeyAuth
// @security jwt
// @produce json
// @success 200 {object} portainer.User "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "User not found"
// @failure 500 "Server error"
// @router /users/me [get]
func (handler *Handler) userInspectMe(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
user, err := handler.DataStore.User().Read(securityContext.UserID)
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
}
hideFields(user)
return response.JSON(
w,
&CurrentUserInspectResponse{
User: user,
ForceChangePassword: tokenData.ForceChangePassword,
},
)
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/segmentio/encoding/json"
@@ -43,7 +44,7 @@ func Test_userList(t *testing.T) {
h.DataStore = store
// generate admin user tokens
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
// Case 1: the user is given the endpoint access directly
userWithEndpointAccess := &portainer.User{ID: 2, Username: "standard-user-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
@@ -67,11 +68,11 @@ func Test_userList(t *testing.T) {
err = store.Endpoint().Create(endpointWithUserAccessPolicy)
is.NoError(err, "error creating endpoint")
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role})
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role})
t.Run("admin user can successfully list all users", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -92,7 +93,7 @@ func Test_userList(t *testing.T) {
params := url.Values{}
params.Add("environmentId", fmt.Sprintf("%d", endpointWithUserAccessPolicy.ID))
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -114,7 +115,7 @@ func Test_userList(t *testing.T) {
t.Run("standard user cannot list users", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -146,7 +147,7 @@ func Test_userList(t *testing.T) {
params := url.Values{}
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithUser.ID))
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -198,7 +199,7 @@ func Test_userList(t *testing.T) {
params := url.Values{}
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID))
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -249,7 +250,7 @@ func Test_userList(t *testing.T) {
params := url.Values{}
params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID))
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)

View File

@@ -11,6 +11,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/stretchr/testify/assert"
)
@@ -41,15 +42,16 @@ func Test_userRemoveAccessToken(t *testing.T) {
h.DataStore = store
// generate standard and admin user tokens
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
t.Run("standard user can successfully delete API key", func(t *testing.T) {
is := assert.New(t)
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-delete-token")
is.NoError(err)
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
testhelpers.AddTestSecurityCookie(req, jwt)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -63,11 +65,12 @@ func Test_userRemoveAccessToken(t *testing.T) {
})
t.Run("admin can delete a standard user API Key", func(t *testing.T) {
is := assert.New(t)
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-admin-delete-token")
is.NoError(err)
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
testhelpers.AddTestSecurityCookie(req, adminJWT)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -81,6 +84,7 @@ func Test_userRemoveAccessToken(t *testing.T) {
})
t.Run("user can delete API Key using api-key auth", func(t *testing.T) {
is := assert.New(t)
rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-api-key-auth-deletion")
is.NoError(err)

View File

@@ -24,6 +24,7 @@ type userUpdatePayload struct {
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
NewPassword string `validate:"required" example:"asfj2emv"`
UseCache *bool `validate:"required" example:"true"`
Theme *themePayload
// User role (1 for administrator account and 2 for regular account)
@@ -147,6 +148,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
}
}
if payload.UseCache != nil {
user.UseCache = *payload.UseCache
}
if payload.Role != 0 {
user.Role = portainer.UserRole(payload.Role)
user.TokenIssueAt = time.Now().Unix()

View File

@@ -13,7 +13,15 @@ import (
"github.com/gorilla/mux"
)
const contextEndpoint = "endpoint"
// Note: context keys must be distinct types to prevent collisions. They are NOT key/value map's internally
// See: https://go.dev/blog/context#TOC_3.2.
// This avoids staticcheck error:
// SA1029: should not use built-in type string as key for value; define your own type to avoid collisions (staticcheck)
// https://stackoverflow.com/questions/40891345/fix-should-not-use-basic-type-string-as-key-in-context-withvalue-golint
type key int
const contextEndpoint key = 0
func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {

View File

@@ -2,10 +2,13 @@ package azure
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*
@@ -23,6 +26,12 @@ func (transport *Transport) proxyContainerGroupRequest(request *http.Request) (*
}
func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", err)
}
//add a lock before processing existence check
transport.mutex.Lock()
defer transport.mutex.Unlock()
@@ -32,7 +41,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
Method: http.MethodGet,
URL: request.URL,
Header: http.Header{
"Authorization": []string{request.Header.Get("Authorization")},
"Authorization": []string{fmt.Sprintf("Bearer %s", tokenData.Token)},
},
}

View File

@@ -57,5 +57,11 @@ func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Respons
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
return transport.baseTransport.RoundTrip(request)
response, err := transport.baseTransport.RoundTrip(request)
if err != nil {
return response, err
}
response.Header.Set(portainer.PortainerCacheHeader, "true")
return response, err
}

View File

@@ -26,13 +26,13 @@ type (
AuthorizedEndpointOperation(*http.Request, *portainer.Endpoint) error
AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
JWTAuthLookup(*http.Request) *portainer.TokenData
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
}
// RequestBouncer represents an entity that manages API request accesses
RequestBouncer struct {
dataStore dataservices.DataStore
jwtService dataservices.JWTService
jwtService portainer.JWTService
apiKeyService apikey.APIKeyService
}
@@ -46,13 +46,14 @@ type (
}
// tokenLookup looks up a token in the request
tokenLookup func(*http.Request) *portainer.TokenData
tokenLookup func(*http.Request) (*portainer.TokenData, error)
)
const apiKeyHeader = "X-API-KEY"
const jwtTokenHeader = "Authorization"
// NewRequestBouncer initializes a new RequestBouncer
func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer {
func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer {
return &RequestBouncer{
dataStore: dataStore,
jwtService: jwtService,
@@ -188,8 +189,9 @@ func (bouncer *RequestBouncer) TrustedEdgeEnvironmentAccess(tx dataservices.Data
// - authenticating the request with a valid token
func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler {
h = bouncer.mwAuthenticateFirst([]tokenLookup{
bouncer.JWTAuthLookup,
bouncer.apiKeyLookup,
bouncer.CookieAuthLookup,
bouncer.JWTAuthLookup,
}, h)
h = mwSecureHeaders(h)
return h
@@ -276,24 +278,26 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
var token *portainer.TokenData
for _, lookup := range tokenLookups {
token = lookup(r)
resultToken, err := lookup(r)
if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Invalid API key", httperrors.ErrUnauthorized)
return
}
if token != nil {
if resultToken != nil {
token = resultToken
break
}
}
if token == nil {
httperror.WriteError(w, http.StatusUnauthorized, "A valid authorisation token is missing", httperrors.ErrUnauthorized)
httperror.WriteError(w, http.StatusUnauthorized, "A valid authorization token is missing", httperrors.ErrUnauthorized)
return
}
_, err := bouncer.dataStore.User().Read(token.ID)
if err != nil && bouncer.dataStore.IsErrObjectNotFound(err) {
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
return
} else if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err)
user, _ := bouncer.dataStore.User().Read(token.ID)
if user == nil {
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
return
}
@@ -303,21 +307,39 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
}
// JWTAuthLookup looks up a valid bearer in the request.
func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenData {
func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) {
// get token from the Authorization header or query parameter
token, err := extractBearerToken(r)
token, err := extractKeyFromCookie(r)
if err != nil {
return nil
return nil, nil
}
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil
return nil, ErrInvalidKey
}
return tokenData
return tokenData, nil
}
// JWTAuthLookup looks up a valid bearer in the request.
func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) {
// get token from the Authorization header or query parameter
token, ok := extractBearerToken(r)
if !ok {
return nil, nil
}
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil, ErrInvalidKey
}
return tokenData, nil
}
var ErrInvalidKey = errors.New("Invalid API key")
// apiKeyLookup looks up an verifies an api-key by:
// - computing the digest of the raw api-key
// - verifying it exists in cache/database
@@ -325,17 +347,17 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenDa
// If the key is valid/verified, the last updated time of the key is updated.
// Successful verification of the key will return a TokenData object - since the downstream handlers
// utilise the token injected in the request context.
func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenData {
func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenData, error) {
rawAPIKey, ok := extractAPIKey(r)
if !ok {
return nil
return nil, nil
}
digest := bouncer.apiKeyService.HashRaw(rawAPIKey)
user, apiKey, err := bouncer.apiKeyService.GetDigestUserAndKey(digest)
if err != nil {
return nil
return nil, ErrInvalidKey
}
tokenData := &portainer.TokenData{
@@ -343,8 +365,8 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenDat
Username: user.Username,
Role: user.Role,
}
if _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
return nil
if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
return nil, ErrInvalidKey
}
if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds]
@@ -353,32 +375,74 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenDat
bouncer.apiKeyService.UpdateAPIKey(&apiKey)
}
return tokenData
return tokenData, nil
}
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
func extractBearerToken(r *http.Request) (string, error) {
// Optionally, token might be set via the "token" query parameter.
func extractBearerToken(r *http.Request) (string, bool) {
// Token might be set via the "token" query parameter.
// For example, in websocket requests
token := r.URL.Query().Get("token")
// For these cases, hide the token from the query
query := r.URL.Query()
token := query.Get("token")
if token != "" {
query.Del("token")
r.URL.RawQuery = query.Encode()
return token, true
}
tokens, ok := r.Header["Authorization"]
if ok && len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
tokens, ok := r.Header[jwtTokenHeader]
if !ok || len(tokens) == 0 {
return "", false
}
if token == "" {
return "", httperrors.ErrUnauthorized
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
return token, true
}
// AddAuthCookie adds the jwt token to the response cookie.
func AddAuthCookie(w http.ResponseWriter, token string, expirationTime time.Time) {
http.SetCookie(w, &http.Cookie{
Name: portainer.AuthCookieKey,
Value: token,
Path: "/",
Expires: expirationTime,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
// RemoveAuthCookie removes the jwt token from the response cookie.
func RemoveAuthCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: portainer.AuthCookieKey,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
HttpOnly: true,
MaxAge: -1,
SameSite: http.SameSiteStrictMode,
})
}
// extractKeyFromCookie extracts the jwt token from the cookie.
func extractKeyFromCookie(r *http.Request) (string, error) {
cookie, err := r.Cookie(portainer.AuthCookieKey)
if err != nil {
return "", err
}
return token, nil
return cookie.Value, nil
}
// extractAPIKey extracts the api key from the api key request header or query params.
func extractAPIKey(r *http.Request) (apikey string, ok bool) {
func extractAPIKey(r *http.Request) (string, bool) {
// extract the API key from the request header
apikey = r.Header.Get(apiKeyHeader)
if apikey != "" {
return apikey, true
apiKey := r.Header.Get(apiKeyHeader)
if apiKey != "" {
return apiKey, true
}
// extract the API key from query params.
@@ -448,3 +512,35 @@ func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Hand
next.ServeHTTP(w, r)
})
}
// ShouldSkipCSRFCheck checks if the CSRF check should be skipped
//
// It returns true if the request has no cookie token and has either (but not both):
// - an api key header
// - an auth header
// if it has both headers, an error is returned
//
// we allow CSRF check to be skipped for the following reasons:
// - public routes
// - kubectl - a bearer token is needed, and no csrf token can be sent
// - api token
func ShouldSkipCSRFCheck(r *http.Request) (bool, error) {
cookie, _ := r.Cookie(portainer.AuthCookieKey)
hasCookie := cookie != nil && cookie.Value != ""
if hasCookie {
return false, nil
}
apiKey := r.Header.Get(apiKeyHeader)
hasApiKey := apiKey != ""
authHeader := r.Header.Get(jwtTokenHeader)
hasAuthHeader := authHeader != ""
if hasApiKey && hasAuthHeader {
return false, errors.New("api key and auth header are not allowed at the same time")
}
return true, nil
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/stretchr/testify/assert"
@@ -21,21 +21,24 @@ var testHandler200 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusOK)
})
func tokenLookupSucceed(dataStore dataservices.DataStore, jwtService dataservices.JWTService) tokenLookup {
return func(r *http.Request) *portainer.TokenData {
func tokenLookupSucceed(dataStore dataservices.DataStore, jwtService portainer.JWTService) tokenLookup {
return func(r *http.Request) (*portainer.TokenData, error) {
uid := portainer.UserID(1)
dataStore.User().Create(&portainer.User{ID: uid})
jwtService.GenerateToken(&portainer.TokenData{ID: uid})
return &portainer.TokenData{ID: 1}
return &portainer.TokenData{ID: 1}, nil
}
}
func tokenLookupFail(r *http.Request) *portainer.TokenData {
return nil
func tokenLookupFail(r *http.Request) (*portainer.TokenData, error) {
return nil, ErrInvalidKey
}
func tokenLookupEmpty(r *http.Request) (*portainer.TokenData, error) {
return nil, nil
}
func Test_mwAuthenticateFirst(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -79,17 +82,28 @@ func Test_mwAuthenticateFirst(t *testing.T) {
wantStatusCode: http.StatusOK,
},
{
name: "mwAuthenticateFirst succeeds if last middleware successfully handles request",
name: "mwAuthenticateFirst fails if first middleware fails",
verificationMiddlwares: []tokenLookup{
tokenLookupFail,
tokenLookupSucceed(store, jwtService),
},
wantStatusCode: http.StatusOK,
wantStatusCode: http.StatusUnauthorized,
},
{
name: "mwAuthenticateFirst fails if first middleware has no token, but second middleware fails",
verificationMiddlwares: []tokenLookup{
tokenLookupEmpty,
tokenLookupFail,
tokenLookupSucceed(store, jwtService),
},
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
@@ -101,9 +115,46 @@ func Test_mwAuthenticateFirst(t *testing.T) {
}
}
func Test_extractBearerToken(t *testing.T) {
func Test_extractKeyFromCookie(t *testing.T) {
is := assert.New(t)
tt := []struct {
name string
token string
succeeds bool
}{
{
name: "missing cookie",
token: "",
succeeds: false,
},
{
name: "valid cookie",
token: "abc",
succeeds: true,
},
}
for _, test := range tt {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if test.token != "" {
testhelpers.AddTestSecurityCookie(req, test.token)
}
apiKey, err := extractKeyFromCookie(req)
is.Equal(test.token, apiKey)
if !test.succeeds {
is.Error(err, "Should return error")
is.ErrorIs(err, http.ErrNoCookie)
} else {
is.NoError(err)
}
}
}
func Test_extractBearerToken(t *testing.T) {
tt := []struct {
name string
requestHeader string
@@ -142,16 +193,14 @@ func Test_extractBearerToken(t *testing.T) {
}
for _, test := range tt {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(test.requestHeader, test.requestHeaderValue)
apiKey, err := extractBearerToken(req)
is.Equal(test.wantToken, apiKey)
if !test.succeeds {
is.Error(err, "Should return error")
is.ErrorIs(err, httperrors.ErrUnauthorized)
} else {
is.NoError(err)
}
t.Run(test.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(test.requestHeader, test.requestHeaderValue)
apiKey, ok := extractBearerToken(req)
is.Equal(test.wantToken, apiKey)
is.Equal(test.succeeds, ok)
})
}
}
@@ -274,16 +323,17 @@ func Test_apiKeyLookup(t *testing.T) {
t.Run("missing x-api-key header fails api-key lookup", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
// req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
token := bouncer.apiKeyLookup(req)
// testhelpers.AddTestSecurityCookie(req, jwt)
token, _ := bouncer.apiKeyLookup(req)
is.Nil(token)
})
t.Run("invalid x-api-key header fails api-key lookup", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", "random-failing-api-key")
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
is.Nil(token)
is.Error(err)
})
t.Run("valid x-api-key header succeeds api-key lookup", func(t *testing.T) {
@@ -293,7 +343,7 @@ func Test_apiKeyLookup(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", rawAPIKey)
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@@ -307,7 +357,7 @@ func Test_apiKeyLookup(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", rawAPIKey)
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@@ -321,7 +371,7 @@ func Test_apiKeyLookup(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", rawAPIKey)
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@@ -332,3 +382,68 @@ func Test_apiKeyLookup(t *testing.T) {
is.True(apiKeyUpdated.LastUsed > apiKey.LastUsed)
})
}
func Test_ShouldSkipCSRFCheck(t *testing.T) {
tt := []struct {
name string
cookieValue string
apiKey string
authHeader string
expectedResult bool
expectedError bool
}{
{
name: "Should return false when cookie is present",
cookieValue: "test-cookie",
},
{
name: "Should return true when cookie is not present",
cookieValue: "",
expectedResult: true,
},
{
name: "Should return true when api key is present",
cookieValue: "",
apiKey: "test-api-key",
expectedResult: true,
},
{
name: "Should return true when auth header is present",
cookieValue: "",
authHeader: "test-auth-header",
expectedResult: true,
},
{
name: "Should return false and error when both api key and auth header are present",
cookieValue: "",
apiKey: "test-api-key",
authHeader: "test-auth-header",
expectedError: true,
},
}
for _, test := range tt {
t.Run(test.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
if test.cookieValue != "" {
req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: test.cookieValue})
}
if test.apiKey != "" {
req.Header.Set(apiKeyHeader, test.apiKey)
}
if test.authHeader != "" {
req.Header.Set(jwtTokenHeader, test.authHeader)
}
result, err := ShouldSkipCSRFCheck(req)
is.Equal(test.expectedResult, result)
if test.expectedError {
is.Error(err)
} else {
is.NoError(err)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"time"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/apikey"
@@ -15,6 +16,7 @@ import (
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/csrf"
"github.com/portainer/portainer/api/http/handler"
"github.com/portainer/portainer/api/http/handler/auth"
"github.com/portainer/portainer/api/http/handler/backup"
@@ -91,7 +93,7 @@ type Server struct {
GitService portainer.GitService
OpenAMTService portainer.OpenAMTService
APIKeyService apikey.APIKeyService
JWTService dataservices.JWTService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
SwarmStackManager portainer.SwarmStackManager
@@ -342,6 +344,11 @@ func (server *Server) Start() error {
handler = middlewares.WithSlowRequestsLogger(handler)
handler, err := csrf.WithProtect(handler)
if err != nil {
return errors.Wrap(err, "failed to create CSRF middleware")
}
if server.HTTPEnabled {
go func() {
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")

View File

@@ -0,0 +1,16 @@
package testhelpers
// Service represents a service for encrypting/hashing data.
type cryptoService struct{}
func NewCryptoService() *cryptoService {
return &cryptoService{}
}
func (*cryptoService) Hash(data string) (string, error) {
return "", nil
}
func (*cryptoService) CompareHashAndData(hash string, data string) error {
return nil
}

View File

@@ -50,6 +50,14 @@ func (testRequestBouncer) TrustedEdgeEnvironmentAccess(tx dataservices.DataStore
return nil
}
func (testRequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenData {
return nil
func (testRequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) {
return nil, nil
}
// AddTestSecurityCookie adds a security cookie to the request
func AddTestSecurityCookie(r *http.Request, jwt string) {
r.AddCookie(&http.Cookie{
Name: portainer.AuthCookieKey,
Value: jwt,
})
}

View File

@@ -91,23 +91,15 @@ func getOrCreateKubeSecret(dataStore dataservices.DataStore) ([]byte, error) {
return kubeSecret, nil
}
func (service *Service) defaultExpireAt() int64 {
return time.Now().Add(service.userSessionTimeout).Unix()
func (service *Service) defaultExpireAt() time.Time {
return time.Now().Add(service.userSessionTimeout)
}
// GenerateToken generates a new JWT token.
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
return service.generateSignedToken(data, service.defaultExpireAt(), defaultScope)
}
// GenerateTokenForOAuth generates a new JWT token for OAuth login
// token expiry time response from OAuth provider is considered
func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
expireAt := service.defaultExpireAt()
if expiryTime != nil && !expiryTime.IsZero() {
expireAt = expiryTime.Unix()
}
return service.generateSignedToken(data, expireAt, defaultScope)
func (service *Service) GenerateToken(data *portainer.TokenData) (string, time.Time, error) {
expiryTime := service.defaultExpireAt()
token, err := service.generateSignedToken(data, expiryTime.Unix(), defaultScope)
return token, expiryTime, err
}
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
@@ -134,10 +126,11 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
}
return &portainer.TokenData{
ID: portainer.UserID(cl.UserID),
Username: cl.Username,
Role: portainer.UserRole(cl.Role),
Token: token,
ID: portainer.UserID(cl.UserID),
Username: cl.Username,
Role: portainer.UserRole(cl.Role),
Token: token,
ForceChangePassword: cl.ForceChangePassword,
}, nil
}
}

View File

@@ -102,8 +102,8 @@ func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*Kube
// GetProxyKubeClient retrieves a KubeClient from the cache. You should be
// calling SetProxyKubeClient before first. It is normally, called the
// kubernetes middleware.
func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (*KubeClient, bool) {
client, ok := factory.endpointProxyClients.Get(endpointID + "." + token)
func (factory *ClientFactory) GetProxyKubeClient(endpointID, userID string) (*KubeClient, bool) {
client, ok := factory.endpointProxyClients.Get(endpointID + "." + userID)
if !ok {
return nil, false
}
@@ -112,8 +112,8 @@ func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (*Kub
}
// SetProxyKubeClient stores a kubeclient in the cache.
func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli *KubeClient) {
factory.endpointProxyClients.Set(endpointID+"."+token, cli, 0)
func (factory *ClientFactory) SetProxyKubeClient(endpointID, userID string, cli *KubeClient) {
factory.endpointProxyClients.Set(endpointID+"."+userID, cli, 0)
}
// CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and
@@ -257,32 +257,6 @@ func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*re
return config, nil
}
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return kubernetes.NewForConfig(config)
}
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
config, err := factory.CreateConfig(endpoint)
if err != nil {

View File

@@ -24,7 +24,7 @@ func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
for _, item := range nodes.Items {
cpu := item.Status.Allocatable.Cpu().MilliValue()
memory := item.Status.Allocatable.Memory().Value()
memory := item.Status.Allocatable.Memory().Value() // bytes
nodesLimits[item.ObjectMeta.Name] = &portainer.K8sNodeLimits{
CPU: cpu,
@@ -57,7 +57,7 @@ func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitE
memory := int64(0)
for _, node := range nodes.Items {
limits.CPU += node.Status.Allocatable.Cpu().MilliValue()
memory += node.Status.Allocatable.Memory().Value()
memory += node.Status.Allocatable.Memory().Value() // bytes
}
limits.Memory = memory / 1000000 // B to MB

View File

@@ -147,11 +147,11 @@ func addResourceLabels(yamlDoc interface{}, appLabels map[string]string) {
}
for _, v := range m {
switch v.(type) {
switch v := v.(type) {
case map[string]interface{}:
addResourceLabels(v, appLabels)
case []interface{}:
for _, item := range v.([]interface{}) {
for _, item := range v {
addResourceLabels(item, appLabels)
}
}

View File

@@ -32,7 +32,7 @@ type (
// Authorizations represents a set of authorizations associated to a role
Authorizations map[Authorization]bool
//AutoUpdateSettings represents the git auto sync config for stack deployment
// AutoUpdateSettings represents the git auto sync config for stack deployment
AutoUpdateSettings struct {
// Auto update interval
Interval string `example:"1m30s"`
@@ -215,6 +215,7 @@ type (
Swarm bool `json:"Swarm"`
TotalCPU int `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
ContainerCount int `json:"ContainerCount"`
RunningContainerCount int `json:"RunningContainerCount"`
StoppedContainerCount int `json:"StoppedContainerCount"`
HealthyContainerCount int `json:"HealthyContainerCount"`
@@ -311,7 +312,7 @@ type (
ConfigHash string `json:"ConfigHash"`
}
//EdgeStack represents an edge stack
// EdgeStack represents an edge stack
EdgeStack struct {
// EdgeStack Identifier
ID EdgeStackID `json:"Id" example:"1"`
@@ -335,7 +336,7 @@ type (
EdgeStackDeploymentType int
//EdgeStackID represents an edge stack id
// EdgeStackID represents an edge stack id
EdgeStackID int
EdgeStackStatusDetails struct {
@@ -348,12 +349,14 @@ type (
ImagesPulled bool
}
//EdgeStackStatus represents an edge stack status
// EdgeStackStatus represents an edge stack status
EdgeStackStatus struct {
Status []EdgeStackDeploymentStatus
EndpointID EndpointID
// EE only feature
DeploymentInfo StackDeploymentInfo
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
// Deprecated
Details EdgeStackStatusDetails
@@ -372,7 +375,7 @@ type (
RollbackTo *int
}
//EdgeStackStatusType represents an edge stack status type
// EdgeStackStatusType represents an edge stack status type
EdgeStackStatusType int
PendingActionsID int
@@ -905,7 +908,7 @@ type (
Prefix string `json:"prefix"` // API key identifier (7 char prefix)
DateCreated int64 `json:"dateCreated"` // Unix timestamp (UTC) when the API key was created
LastUsed int64 `json:"lastUsed"` // Unix timestamp (UTC) when the API key was last used
Digest []byte `json:"digest,omitempty"` // Digest represents SHA256 hash of the raw API key
Digest string `json:"digest,omitempty"` // Digest represents SHA256 hash of the raw API key
}
// Schedule represents a scheduled job.
@@ -1314,9 +1317,10 @@ type (
Username string `json:"Username" example:"bob"`
Password string `json:"Password,omitempty" swaggerignore:"true"`
// User role (1 for administrator account and 2 for regular account)
Role UserRole `json:"Role" example:"1"`
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
ThemeSettings UserThemeSettings
Role UserRole `json:"Role" example:"1"`
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
ThemeSettings UserThemeSettings `json:"ThemeSettings"`
UseCache bool `json:"UseCache" example:"true"`
// Deprecated fields
@@ -1478,6 +1482,14 @@ type (
ExecuteDeviceAction(configuration OpenAMTConfiguration, deviceGUID string, action string) error
}
// JWTService represents a service for managing JWT tokens
JWTService interface {
GenerateToken(data *TokenData) (string, time.Time, error)
GenerateTokenForKubeconfig(data *TokenData) (string, error)
ParseAndVerifyToken(token string) (*TokenData, error)
SetUserSessionDuration(userSessionDuration time.Duration)
}
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
KubeClient interface {
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
@@ -1616,7 +1628,7 @@ const (
// DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3.0/templates.json"
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
// DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
@@ -1627,6 +1639,10 @@ const (
DefaultKubectlShellImage = "portainer/kubectl-shell"
// WebSocketKeepAlive web socket keep alive for edge environments
WebSocketKeepAlive = 1 * time.Hour
// AuthCookieName is the name of the cookie used to store the JWT token
AuthCookieKey = "portainer_api_key"
// PortainerCacheHeader is used to enabled FE caching for Kubernetes resources
PortainerCacheHeader = "X-Portainer-Cache"
)
// List of supported features
@@ -1644,7 +1660,7 @@ const (
AuthenticationInternal
// AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
AuthenticationLDAP
//AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server)
// AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server)
AuthenticationOAuth
)
@@ -1684,13 +1700,13 @@ const (
const (
// EdgeStackStatusPending represents a pending edge stack
EdgeStackStatusPending EdgeStackStatusType = iota
//EdgeStackStatusDeploymentReceived represents an edge environment which received the edge stack deployment
// EdgeStackStatusDeploymentReceived represents an edge environment which received the edge stack deployment
EdgeStackStatusDeploymentReceived
//EdgeStackStatusError represents an edge environment which failed to deploy its edge stack
// EdgeStackStatusError represents an edge environment which failed to deploy its edge stack
EdgeStackStatusError
//EdgeStackStatusAcknowledged represents an acknowledged edge stack
// EdgeStackStatusAcknowledged represents an acknowledged edge stack
EdgeStackStatusAcknowledged
//EdgeStackStatusRemoved represents a removed edge stack
// EdgeStackStatusRemoved represents a removed edge stack
EdgeStackStatusRemoved
// StatusRemoteUpdateSuccess represents a successfully updated edge stack
EdgeStackStatusRemoteUpdateSuccess

View File

@@ -3,6 +3,7 @@ package deployments
import (
"crypto/tls"
"fmt"
"strconv"
"time"
portainer "github.com/portainer/portainer/api"
@@ -16,6 +17,7 @@ import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
type StackAuthorMissingErr struct {
@@ -27,11 +29,11 @@ func (e *StackAuthorMissingErr) Error() string {
return fmt.Sprintf("stack's %v author %s is missing", e.stackID, e.authorName)
}
var singleflightGroup = &singleflight.Group{}
// RedeployWhenChanged pull and redeploy the stack when git repo changed
// Stack will always be redeployed if force deployment is set to true
func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) error {
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
stack, err := datastore.Stack().Read(stackID)
if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
@@ -39,6 +41,24 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
}
// Webhook
if stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != "" {
return redeployWhenChanged(stack, deployer, datastore, gitService)
}
// Polling
_, err, _ = singleflightGroup.Do(strconv.Itoa(int(stackID)), func() (any, error) {
return nil, redeployWhenChanged(stack, deployer, datastore, gitService)
})
return err
}
func redeployWhenChanged(stack *portainer.Stack, deployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) error {
stackID := stack.ID
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
if stack.GitConfig == nil {
return nil // do nothing if it isn't a git-based stack
}
@@ -171,6 +191,11 @@ func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, e
}
func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
if endpoint.Type != portainer.AgentOnDockerEnvironment &&
endpoint.Type != portainer.AgentOnKubernetesEnvironment {
return true
}
var err error
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {

View File

@@ -198,7 +198,6 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
Str("cmd", strings.Join(cmd, " ")).
Msg("running unpacker")
rand.Seed(time.Now().UnixNano())
unpackerContainer, err := cli.ContainerCreate(ctx, &container.Config{
Image: image,
Cmd: cmd,

View File

@@ -18,7 +18,7 @@ definitions:
properties:
jwt:
description: JWT token used to authenticate against the API
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
example: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB
type: string
type: object
auth.oauthPayload:
@@ -2524,7 +2524,7 @@ info:
Example:
```
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
Bearer abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyzAB
```
# Security

View File

@@ -1 +0,0 @@
export function loadProgressBar() {}

View File

@@ -27,10 +27,9 @@ export function mockT(i18nKey: string, args?: Record<string, string>) {
return key;
}
const i18next: Record<string, unknown> = jest.createMockFromModule('i18next');
i18next.t = mockT;
i18next.language = 'en';
i18next.changeLanguage = () => new Promise(() => {});
i18next.use = () => i18next;
export default i18next;
export default {
t: mockT,
language: 'en',
changeLanguage: () => new Promise(() => {}),
use: () => this,
};

View File

@@ -1,7 +1,7 @@
import $ from 'jquery';
/* @ngInject */
export function onStartupAngular($rootScope, $state, LocalStorage, cfpLoadingBar, $transitions, HttpRequestHelper) {
export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions, HttpRequestHelper) {
$rootScope.$state = $state;
// Workaround to prevent the loading bar from going backward
@@ -23,6 +23,7 @@ export function onStartupAngular($rootScope, $state, LocalStorage, cfpLoadingBar
if (type && hasNoContentType) {
jqXhr.setRequestHeader('Content-Type', 'application/json');
}
jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT());
const csrfCookie = window.cookieStore.get('_gorilla_csrf');
jqXhr.setRequestHeader('X-CSRF-Token', csrfCookie);
});
}

View File

@@ -566,6 +566,10 @@
--border-widget: var(--white-color);
--border-stepper-color: var(--ui-gray-warm-9);
--button-close-color: var(--white-color);
--button-opacity: 1;
--button-opacity-hover: 0.7;
--shadow-box-color: none;
--shadow-boxselector-color: none;

View File

@@ -238,6 +238,12 @@ textarea {
background: var(--text-input-textarea);
}
[theme='highcontrast'] input,
[theme='highcontrast'] select,
[theme='highcontrast'] textarea {
border: 1px solid var(--white-color);
}
.daterangepicker {
background-color: var(--bg-daterangepicker-color);
border: 1px solid var(--border-daterangepicker-color);
@@ -349,6 +355,26 @@ input:-webkit-autofill {
border-left: 8px solid var(--bg-tooltip-color);
}
[theme='highcontrast'] .tippy-box[data-placement^='top'] > .tippy-arrow:before {
border-top: 8px solid var(--white-color);
margin-bottom: -1px;
}
[theme='highcontrast'] .tippy-box[data-placement^='bottom'] > .tippy-arrow:before {
border-bottom: 8px solid var(--white-color);
margin-top: -1px;
}
[theme='highcontrast'] .tippy-box[data-placement^='right'] > .tippy-arrow:before {
border-right: 8px solid var(--white-color);
margin-left: -1px;
}
[theme='highcontrast'] .tippy-box[data-placement^='left'] > .tippy-arrow:before {
border-left: 8px solid var(--white-color);
margin-right: -1px;
}
/* Sidebar */
.sidebar .tippy-box {
font-size: 12px;

View File

@@ -1,23 +1,24 @@
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import { csrfInterceptor, csrfTokenReaderInterceptorAngular } from './portainer/services/csrf';
import { agentInterceptor } from './portainer/services/axios';
import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper';
/* @ngInject */
export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
if (process.env.NODE_ENV === 'testing') {
$compileProvider.debugInfoEnabled(false);
}
localStorageServiceProvider.setPrefix('portainer');
jwtOptionsProvider.config({
tokenGetter: /* @ngInject */ function tokenGetter(LocalStorage) {
return LocalStorage.getJWT();
// ask to clear cache on mutation
$httpProvider.interceptors.push(() => ({
request: (reqConfig) => {
dispatchCacheRefreshEventIfNeeded(reqConfig);
return reqConfig;
},
whiteListedDomains: ['localhost'],
});
}));
$httpProvider.interceptors.push('jwtInterceptor');
localStorageServiceProvider.setPrefix('portainer');
$httpProvider.defaults.headers.post['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
@@ -27,6 +28,11 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
request: agentInterceptor,
}));
$httpProvider.interceptors.push(() => ({
response: csrfTokenReaderInterceptorAngular,
request: csrfInterceptor,
}));
Terminal.applyAddon(fit);
$uibTooltipProvider.setTriggers({

View File

@@ -27,6 +27,3 @@ export const CONSOLE_COMMANDS_LABEL_PREFIX = 'io.portainer.commands.';
export const PREDEFINED_NETWORKS = ['host', 'bridge', 'ingress', 'nat', 'none'];
export const PORTAINER_FADEOUT = 1500;
export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
export const KUBE_TEMPLATE_NAME_VALIDATION_REGEX =
'^(([a-z0-9](?:(?:[-a-z0-9_.]){0,61}[a-z0-9])?))$'; // alphanumeric, lowercase, can contain dashes, dots and underscores, max 63 characters

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