Compare commits

...

468 Commits

Author SHA1 Message Date
Anthony Lapenna
1bccd521f8 Merge branch 'release/1.14.1' 2017-09-20 15:41:06 +02:00
Anthony Lapenna
5e2b3c1d07 chore(version): bump version number 2017-09-20 15:41:01 +02:00
Anthony Lapenna
210bdc8022 refactor(vendor): fix path to min CSS file for rzslider 2017-09-20 14:38:16 +02:00
Thomas Krzero
3cb96235b7 #516 feat(services) - add the ability to manage cpu/mem limits 2017-09-20 08:32:19 +02:00
Anthony Lapenna
d695657711 feat(sidebar): rename Docker to Engine (#1212) 2017-09-20 08:23:36 +02:00
Anthony Lapenna
5131c4c10b feat(notifications): do not display invalid JWT token notifications (#1209) 2017-09-19 20:59:28 +02:00
Anthony Lapenna
912ebf4672 feat(api): filter tasks based on service UAC (#1207) 2017-09-19 20:23:48 +02:00
Anthony Lapenna
dd0fc6fab8 feat(swarm): restrict access to the node details view to administrators only (#1204) 2017-09-19 18:41:03 +02:00
Anthony Lapenna
910136ee9b feat(containers): store show all filter value in a cookie (#1203) 2017-09-19 18:24:41 +02:00
Anthony Lapenna
61f652da04 feat(secrets): add UAC (#1200) 2017-09-19 17:10:15 +02:00
Anthony Lapenna
a2b4cd8050 feat(networks): add UAC (#1196) 2017-09-19 16:58:30 +02:00
Anthony Lapenna
774738110b feat(auth): add an auto-focus directive and remove username placeholder 2017-09-17 17:07:19 +02:00
Anthony Lapenna
851a1ac64c feat(sidebar): restrict access to Events for administrators only (#1193) 2017-09-15 09:57:04 +02:00
Anthony Lapenna
d653391cdd feat(api): write Docker response code when using local proxy (#1192) 2017-09-14 11:09:36 +02:00
Anthony Lapenna
f96b70841f feat(swarm-visualizer): add a platform icon next to node name (#1191) 2017-09-14 10:22:27 +02:00
Anthony Lapenna
8d4807c9e7 feat(api): TLS endpoint creation and init overhaul (#1173) 2017-09-14 08:08:37 +02:00
Anthony Lapenna
87825f7ebb feat(swarm-visualizer): add the swarm-visualizer view (#1190) 2017-09-14 08:04:59 +02:00
Anthony Lapenna
be4f3ec81d fix(admin-init): do not redirect to endpoint-init if at least one endpoint is defined 2017-09-11 10:36:18 +02:00
Adrian Kirchner
56604a5445 fix(cli): fix wrong default value for --no-analytics (#1185) 2017-09-10 10:00:48 +02:00
Anthony Lapenna
c0d282e85b feat(container-stats): overhaul (#1183) 2017-09-09 18:49:21 +02:00
Liam Cottam
b9b32f0526 feat(network-creation): network dropdown for drivers (#1016) (#1062) 2017-09-06 15:11:38 +02:00
Anthony Lapenna
be4beacdf7 feat(container-creation): display a warning message when editing a container with an unknow registry (#1143) 2017-09-05 16:42:20 +02:00
Sylvain MOUQUET
bf6b398a27 feat(containers): add a button to display the full name of containers (#1164) 2017-09-05 10:10:16 +02:00
Anthony Lapenna
9a0f0a9701 feat(favicon): fix favicon display (#1177) 2017-09-05 09:57:49 +02:00
Anthony Lapenna
ef8edfb67b feat(api): display version in startup logs (#1175) 2017-09-04 19:04:30 +02:00
Anthony Lapenna
0e8da2db18 docs(swagger): update UserAdminInitRequest definition 2017-08-29 09:11:19 +02:00
Anthony Lapenna
e65d132b3d feat(init-admin): allow to specify a username for the initial admin account (#1160) 2017-08-28 20:59:13 +02:00
Anthony Lapenna
13b2fcffd2 docs(templates): add deprecation notice for old volume format 2017-08-28 20:57:41 +02:00
Adam Snodgrass
c1e486bf43 feat(templates): add support for bind mounts in volumes
* #777 feat(templates): add support for binding to host path

* #777 feat(templates): add link to templates documentation

* refactor(templates): update warning style to match theme

* fix(templates): remove trailing comma

* refactor(templates): use bind instead of self declaration

* feat(templates): support readonly property in template volumes

* #777 refactor(templates): remove deprecation notice

* #777 refactor(templates): remove deprecated condition from template
2017-08-28 20:53:36 +02:00
Anthony Lapenna
8c68e92e74 feat(images): use containers instead of /system/df to check unused images (#1150) 2017-08-24 07:53:34 +02:00
Anthony Lapenna
a6ef27164c feat(container-details): prevent re-creation, edition & duplication for service task (#1149) 2017-08-23 10:06:18 +02:00
Anthony Lapenna
d50a650686 feat(dashboard): remove driver information in volumes (#1148) 2017-08-23 09:51:42 +02:00
Anthony Lapenna
35dd3916dd fix(authentication): do not use $sanitize with LDAP authentication (#1136) 2017-08-22 16:36:12 +02:00
Anthony Lapenna
1a28e1091c docs(api): update swagger.yml (#1130) 2017-08-16 10:15:58 +02:00
Anthony Lapenna
124458c3d6 Merge tag '1.14.0' into develop
Release 1.14.0
2017-08-13 20:17:35 +02:00
Anthony Lapenna
8e2dbd1775 Merge branch 'release/1.14.0' 2017-08-13 20:17:30 +02:00
Anthony Lapenna
27188f4dff chore(version): bump version number 2017-08-13 20:17:23 +02:00
Anthony Lapenna
ef13f6fb3b feat(sidebar): do not display services and secrets when managing a worker node (#1114) 2017-08-13 16:55:02 +02:00
Anthony Lapenna
92391254bc feat(api): introduces swagger.yml (#1112) 2017-08-13 16:45:55 +02:00
Anthony Lapenna
d3e87b2435 style(settings): fix typo 2017-08-13 15:04:24 +02:00
Anthony Lapenna
e5666dfdf2 feat(vic): fix multiple issues when managing a VIC engine (#1069) 2017-08-13 13:31:50 +02:00
Anthony Lapenna
e96e615761 feat(container-details): add the ability to specify if image should be pulled when re-creating a container 2017-08-13 12:55:52 +02:00
Thomas Krzero
c85aa0739d feat(container-details): add the ability to re-create, duplicate and edit a container (#855) 2017-08-13 12:17:41 +02:00
Anthony Lapenna
d814f3aaa4 fix(networks): review how networks are loaded for usage in multiple views (#1104) 2017-08-11 09:46:55 +02:00
Anthony Lapenna
3d5f9a76e4 fix(team-details): fix an issue when sorting columns (#1106) 2017-08-10 15:25:53 +02:00
Anthony Lapenna
d27528a771 feat(authentication): add LDAP authentication support (#1093) 2017-08-10 10:35:23 +02:00
Anthony Lapenna
04ea81e7cd feat(service): support the Order field for Update Configuration (#1101) 2017-08-09 15:30:50 +02:00
Anthony Lapenna
d7769dec33 fix(images): fix the way the registry and image name are extracted fr… (#1099)
* fix(images): fix the way the registry and image name are extracted from a repository
2017-08-09 10:40:46 +02:00
Liam Cottam
12adeadc94 fix(container-details): connected network section disappearing (#1092) 2017-08-06 10:42:38 +02:00
Anthony Lapenna
b5429f7504 docs(README): add code climate badge 2017-08-04 08:09:29 +02:00
Liam Cottam
cf5c3ee536 fix(container-console): fix an issue with scrollbar (#932) (#1086) 2017-08-04 08:02:26 +02:00
tfenster
86c450bd91 feat(templates): Use container name as hostname (#1084) 2017-08-04 07:54:03 +02:00
Anthony Lapenna
0d6ab099ac feat(templates): update LinuxServer.io templates feed URL (#1089) 2017-08-01 11:24:44 +02:00
Anthony Lapenna
5110f83fae fix(rest): fix an issue with rest factories using $http (#1077) 2017-07-27 10:46:29 +02:00
Anthony Lapenna
252e05e963 fix(container-details): add missing Created field from ContainerDetailsViewModel (#1075) 2017-07-26 17:12:02 +02:00
Dan Hlavenka
635ecdef72 style(sidebar): crop logo.png to fit in sidebar without scaling (#1072) 2017-07-26 07:52:44 +02:00
Anthony Lapenna
b08d2b07bc feat(volume-creation): add plugin support (#1044)
* feat(volume-creation): add plugin support

* feat(plugins): only use systemInfo to retrieve plugins when API version < 1.25

* refactor(createVolume): remove unused dependencies
2017-07-25 16:21:32 +02:00
Anthony Lapenna
3919ad3ccf fix(images): show image usage only if endpoint API version >= 1.25 (#1067) 2017-07-24 19:11:12 +02:00
Konstantin Azizov
aca4f5c286 fix(containers): Fix available buttons for created container (#1065) 2017-07-24 16:39:04 +02:00
Anthony Lapenna
387b4c66d9 fix(containers): fix an issue when only containers without ports are running (#1068) 2017-07-24 16:29:28 +02:00
Anthony Lapenna
7c40d2caa9 fix(services): use secrets with services only if endpoint API version >= 1.25 2017-07-24 11:59:09 +02:00
Anthony Lapenna
02203e7ce5 refactor(api): relocate /docker API endpoint under /endpoints (#1053) 2017-07-20 16:22:27 +02:00
Anthony Lapenna
53583741ba fix(UAC): fix the ability to update the ownership of a resource from public to another type (#1054) 2017-07-20 15:48:05 +02:00
1138-4EB
12eb9671de style(volumes): replace label 'Dangling' with 'Unused' (#1052) 2017-07-20 08:47:11 +02:00
Anthony Lapenna
29d66bfd97 fix(containers): add support for the 'dead' status (#1048) 2017-07-19 16:34:11 +02:00
Anthony Lapenna
57fde5ae7c feat(Dockerfile): use portainer/base image (#1045) 2017-07-18 12:17:31 +02:00
Anthony Lapenna
471f902171 Merge tag '1.13.6' into develop
Release 1.13.6
2017-07-17 16:00:47 +02:00
Anthony Lapenna
2e2aba1bbb Merge branch 'release/1.13.6' 2017-07-17 16:00:40 +02:00
Anthony Lapenna
f2347b2f77 chore(version): bump version number 2017-07-17 15:59:43 +02:00
Anthony Lapenna
a39645a297 fix(images): fix the system/df call to display unused images (#1037) 2017-07-17 15:58:53 +02:00
Anthony Lapenna
806a0b92a0 Merge tag '1.13.5' into develop
Release 1.13.5
2017-07-13 18:08:50 +02:00
Anthony Lapenna
a438357b45 Merge branch 'release/1.13.5' 2017-07-13 18:08:46 +02:00
Anthony Lapenna
206eb0513d chore(version): bump version number 2017-07-13 18:08:39 +02:00
Anthony Lapenna
5ad6837547 feat(container-console): improve container console UX (#1031) 2017-07-13 18:04:58 +02:00
Anthony Lapenna
272a040c91 feat(volumes): add a label in front of dangling volumes (#1025) 2017-07-13 13:50:59 +02:00
Anthony Lapenna
c04b9e5340 feat(volumes): new truncate method for volume paths (#1028) 2017-07-13 13:50:42 +02:00
Anthony Lapenna
3f085a977c fix(UAC): allow a team member to delete a resource control (#1030) 2017-07-13 09:12:06 +02:00
Anthony Lapenna
a1dd12a947 feat(sidebar): sort available endpoints alphabetically (#1027) 2017-07-12 20:52:07 +02:00
Anthony Lapenna
a7df43bd45 feat(container-details): show container ID (#1026) 2017-07-12 19:37:34 +02:00
Anthony Lapenna
5d749c2ebf feat(auth): use the same error message on invalid authentication (#1024) 2017-07-12 17:22:14 +02:00
Anthony Lapenna
536ca15e90 fix(swarm): fix multiple Swarm related issues (#1022)
* fix(containers): fix an issue where the containers would not be displayed

* fix(images): image usage filtering is not compliant with docker/swarm

* fix(volume-creation): do not load volume driver with docker/swarm
2017-07-12 16:11:11 +02:00
Anthony Lapenna
703e423e04 fix(external-endpoints): prevent the creation of an invalid file endpoint (#1021) 2017-07-12 15:15:42 +02:00
Anthony Lapenna
780fec8e36 fix(access): fix an issue where an access would disappear (#1018) 2017-07-12 14:13:51 +02:00
1138-4EB
0a436600f4 feat(build-system): dynamic vendoring (#994) 2017-07-12 11:28:51 +02:00
Anthony Lapenna
32c2ce90e2 feat(build-system): automatically remove binary build container 2017-07-12 10:13:00 +02:00
Anthony Lapenna
a864641692 refactor(UAC): refactor common views to components (#1013) 2017-07-12 09:51:51 +02:00
Anthony Lapenna
344eee098d chore(deps): update xtermjs version (#1012) 2017-07-11 16:52:39 +02:00
Konstantin Azizov
bc4b0a0b35 feat(images): display unused images tags (#1009) 2017-07-11 09:56:28 +02:00
1138-4EB
b23943e30b refactor(build-system): reduce gruntfile verbosity, drop grunt-if, allow custom build (#939) 2017-07-11 09:30:25 +02:00
Glowbal
25ed6a71fb feat(services): add support for placement preferences (#1003) 2017-07-10 09:33:09 +02:00
Konstantin Azizov
8dc6d05ed6 feat(console): allow the user to specify a command in the console section (#259) (#1007) 2017-07-10 09:10:10 +02:00
Konstantin Azizov
fe5a993fc9 feat(volumes): view dangling volumes (#993) 2017-07-09 18:49:36 +02:00
Thomas Krzero
6df5eb3787 feat(service-details) - add service logs (#671) 2017-07-08 11:34:21 +02:00
Konstantin Azizov
bc3d5e97ea chore(build-system): update run-dev to mount assets (#997) 2017-07-08 10:42:41 +02:00
Glowbal
9909b6d481 feat(backend): make swarm api endpoint admin user protected (#991) 2017-07-08 10:34:04 +02:00
Glowbal
90a32d1b67 refactor(html): fix html tags and escape special characters (#987) 2017-07-08 10:23:00 +02:00
Konstantin Azizov
472834ac42 feat(containers): add buttons disabling based on cluster selection (#985) 2017-07-08 10:07:08 +02:00
Anthony Lapenna
b3f4c6f751 refactor(image-details): place imageLayer model under models/docker 2017-07-08 09:22:39 +02:00
Anthony Lapenna
317303fc43 feat(image-details): image layer enhancements 2017-07-08 09:21:30 +02:00
Gábor Kovács
b6b579d55d feat(image-details): simple image history (#425) 2017-07-08 08:59:32 +02:00
Anthony Lapenna
6d6f4f092d fix(secrets): fix an issue when removing a secret that is in use (#984) 2017-07-07 15:45:31 +02:00
Anthony Lapenna
7473681c5b fix(container-details): fix the ability to commit a container (#983) 2017-07-05 19:06:28 +02:00
Konstantin Azizov
54c8872d25 feat(container-console): add ability to specify the user (#976) 2017-07-05 07:16:57 +02:00
Konstantin Azizov
c5ce45f588 chore(build-system): replace Recess with PostCSS (#975) 2017-07-04 14:30:22 +02:00
Anthony Lapenna
07a0c4dfe3 feat(endpoints): update information message (#974) 2017-07-03 08:36:18 +02:00
Anthony Lapenna
80bb94e745 docs(README): update README 2017-06-30 14:52:04 +02:00
Anthony Lapenna
6c89412f39 Merge tag '1.13.4' into develop
Release 1.13.4
2017-06-29 16:37:32 +02:00
Anthony Lapenna
034e29cd74 Merge branch 'release/1.13.4' 2017-06-29 16:37:28 +02:00
Anthony Lapenna
0e0764eff8 chore(version): bump version number 2017-06-29 16:37:22 +02:00
Anthony Lapenna
e47db0b8c9 feat(volumes): display mount point for each volume (#967) 2017-06-29 16:14:17 +02:00
Anthony Lapenna
6d401dcd59 fix(templates): fix the ability to pull an image within an offline environment (#961) 2017-06-29 16:05:39 +02:00
Anthony Lapenna
6609c2e928 style(container-details): review responsiveness for the join network section 2017-06-29 16:04:49 +02:00
Adam Snodgrass
a161d25d48 feat(container-details): add section to join networks (#927) 2017-06-29 15:49:35 +02:00
Anthony Lapenna
4adedf9436 fix(service-details): fix an issue where secret target would be overwritten (#964) 2017-06-29 08:37:05 +02:00
Anthony Lapenna
1168e94534 fix(service-creation): fix an issue when selecting a volume from available volumes (#963) 2017-06-29 07:41:37 +02:00
Anthony Lapenna
b57bfe3eee Create CODE_OF_CONDUCT.md (#946) 2017-06-22 05:11:40 +02:00
Anthony Lapenna
3592e88e4f Merge tag '1.13.3' into develop
Release 1.13.3
2017-06-20 13:21:16 +02:00
Anthony Lapenna
219cde4733 Merge branch 'release/1.13.3' 2017-06-20 13:21:12 +02:00
Anthony Lapenna
c82cd50d87 chore(version): bump version number 2017-06-20 13:21:06 +02:00
Anthony Lapenna
dae4893fe1 feat(endpoint): remove the active endpoint edition restriction (#941) 2017-06-20 13:18:08 +02:00
Anthony Lapenna
1e686f0428 feat(state): persist application state in localstorage instead of ses… (#940) 2017-06-20 13:07:24 +02:00
Anthony Lapenna
08c5a5a4f6 feat(registries): add registry management (#930) 2017-06-20 13:00:32 +02:00
eliat123
9360f24d89 feat(service-details): add quick navigation menu anchors (#875) 2017-06-20 12:54:27 +02:00
Anthony Lapenna
d0477b216f Merge branch 'develop' of github.com:portainer/portainer into develop 2017-06-17 17:05:52 +02:00
Anthony Lapenna
a812f4729c docs(README): update links to portainer.io 2017-06-17 17:05:34 +02:00
Anthony Lapenna
db324998e3 fix(templates): display templates without platform (#937) 2017-06-17 16:50:35 +02:00
Gabriel Lewertowski
4ec65a80df fix(user-creation): sanitize username and password (#934) 2017-06-17 15:25:23 +02:00
Anthony Lapenna
f2b9700345 chore(codeclimate): update mass_threshold for the duplication engine 2017-06-17 15:20:19 +02:00
Anthony Lapenna
d8f8ab785c fix(service-details): fix the ability to sort tasks (#931) 2017-06-15 22:52:49 +02:00
Anthony Lapenna
b316efe80b Merge tag '1.13.2' into develop
Release 1.13.2
2017-06-05 08:42:20 +02:00
Anthony Lapenna
14a4587f5e Merge branch 'release/1.13.2' 2017-06-05 08:42:15 +02:00
Anthony Lapenna
afd99d2d68 chore(version): bump version number 2017-06-05 08:42:08 +02:00
Anthony Lapenna
7bba1c9c5e style(settings): fix a small display issue in the hidden containers table 2017-06-05 08:40:42 +02:00
Anthony Lapenna
fd79afb429 style(sidebar): moved Secrets section under the Volumes section 2017-06-05 08:17:56 +02:00
Anthony Lapenna
d5f00597a5 fix(container-creation): ignore error when pulling an image (#914) 2017-06-05 07:55:18 +02:00
Fish2
1c4ccfe294 feat(assets): lossless compression of images saved 14KB (#915) 2017-06-05 07:47:55 +02:00
Anthony Lapenna
f48423d5aa docs(README): update documentation badge 2017-06-03 16:52:33 +02:00
Anthony Lapenna
5d98d9b54b feat(settings): prevent the creation of empty filters 2017-06-01 10:30:22 +02:00
Anthony Lapenna
132dd4acc4 fix(container-details): fix an issue when renaming a container (#908) 2017-06-01 10:23:59 +02:00
Anthony Lapenna
c7e306841a feat(settings): add settings management (#906) 2017-06-01 10:14:55 +02:00
Anthony Lapenna
5e74a3993b fix(api): add restrictions for the files served by the API (#903) 2017-05-29 22:10:36 +02:00
Anthony Lapenna
5bf10b89b1 docs(README): add Slack badge 2017-05-28 18:08:52 +02:00
Anthony Lapenna
bde9dd8b88 feat(templates): add support for a restart_policy field (#898) 2017-05-27 10:11:42 +02:00
Anthony Lapenna
42d28db47a feat(secrets): add secret management (#894) 2017-05-27 09:23:49 +02:00
Anthony Lapenna
128601bb58 Merge tag '1.13.1' into develop
Release 1.13.1
2017-05-25 12:20:56 +02:00
Anthony Lapenna
86addbdc9a Merge branch 'release/1.13.1' 2017-05-25 12:20:52 +02:00
Anthony Lapenna
de9be4bbe0 chore(version): bump version number 2017-05-25 12:20:43 +02:00
Anthony Lapenna
49b79aadfd docs(README): add codefresh badge 2017-05-25 12:17:51 +02:00
Renno Reinurm
6dab3eddea feat(task-details): show state message 2017-05-25 12:16:14 +02:00
Thomas Krzero
949f14b119 fix(service-creation) - issue with bind mount (#882) 2017-05-25 11:13:29 +02:00
Anthony Lapenna
de2818de4c chore(codefresh): add codefresh.yml (#887) 2017-05-25 11:08:26 +02:00
Anthony Lapenna
0f3fcb2917 fix(templates): fix an issue with the maximum number of templates displayed (#883) 2017-05-24 14:38:53 +02:00
Anthony Lapenna
3356fd9815 Merge tag '1.13.0' into develop
Release 1.13.0
2017-05-23 21:14:11 +02:00
Anthony Lapenna
7bef930d0c Merge branch 'release/1.13.0' 2017-05-23 21:14:03 +02:00
Anthony Lapenna
db1a754b39 chore(version): bump version number 2017-05-23 21:13:55 +02:00
Anthony Lapenna
9b9b2731ba refactor(api): fix lint issues 2017-05-23 21:01:19 +02:00
Anthony Lapenna
5523fc9023 feat(global): introduce user teams and new UAC system (#868) 2017-05-23 20:56:10 +02:00
Anthony Lapenna
a380fd9adc fix(image-details): fix invalid CMD with images using HEALTHCHECK (#879) 2017-05-23 20:43:58 +02:00
Anthony Lapenna
d3ecf1d7a8 fix(image-details): fix the ability to pull an image from a tag (#878) 2017-05-23 20:25:56 +02:00
Anthony Lapenna
6834c20b5d docs(README): update README 2017-05-23 17:54:14 +02:00
Anthony Lapenna
b9035659d2 chore(build-system): update Gruntfile tasks 2017-05-23 15:33:40 +02:00
Anthony Lapenna
5b47427484 fix(build-system): fix broken tasks 2017-05-20 11:25:47 +02:00
Anthony Lapenna
6e95e1279a chore(build-system): add support for linux 386 architecture (#871) 2017-05-20 10:27:55 +02:00
Anthony Lapenna
a2e781fb3f chore(build-system): add support for ppc64le architecture (#870) 2017-05-20 10:02:18 +02:00
Anthony Lapenna
69c7f116b1 fix(app): fix missing '=' char in state definitions 2017-05-19 17:51:01 +02:00
Anthony Lapenna
2ef1c90248 feat(app): disable Angular debug information on release (#867) 2017-05-19 17:48:03 +02:00
Anthony Lapenna
782df54570 fix(service-details): add missing Arguments field (#864) 2017-05-18 23:32:04 +02:00
Anthony Lapenna
0ba6645df0 fix(container-details): fix an issue with duplicate env var (#863) 2017-05-18 23:17:39 +02:00
Anthony Lapenna
0579251c70 feat(templates): new templates capabilities (#862) 2017-05-18 23:00:08 +02:00
Alex Seymour
c3363604ac feat(templates): Support interactive templates (#819) 2017-05-18 22:49:55 +02:00
Anthony Lapenna
09aa67ba61 chore(github): update ISSUE_TEMPLATE.md 2017-05-05 06:29:26 +02:00
Glowbal
4ff7ee4e60 fix(services): Empty environment variables are not maintained (#836) 2017-05-05 06:25:48 +02:00
Anthony Lapenna
5b81b35bf8 chore(gruntfile): use eslint instead of jshint 2017-05-04 10:17:55 +02:00
Glowbal
df3a529f0a feat(services): ability to publish ports using host mode (#838) 2017-05-04 09:43:20 +02:00
Glowbal
43e1f25f89 feat(service-creation): add placement constraints (#837) 2017-05-04 08:57:08 +02:00
Thomas Krzero
7c6c9284f2 feat(endpoints) - Access exposed containers on endpoint public URL (#826) 2017-05-01 11:19:43 +01:00
Thomas Krzero
3d8eec2557 feat(containers) - clean non-persistent volumes when removing a container (#824) 2017-05-01 11:18:06 +01:00
Thomas Krzero
5a07638f4d fix(container) - correct since date for created containers (#822) 2017-04-27 19:40:37 +01:00
Anthony Lapenna
87250d13d7 chore(project): update codeclimate configuration 2017-04-27 18:11:48 +02:00
Anthony Lapenna
90d13684e5 chore(project): add eslint and codeclimate configuration files 2017-04-27 18:09:40 +02:00
GP8x
25206e71cf feat(container-creation): add support for ip assignments (#812) 2017-04-25 21:32:27 +01:00
030
6fa6dde637 feat(backend): native SSL support 2017-04-25 10:51:22 +01:00
Thomas Krzero
e70817f776 feat(containers): show health status of containers (#622) 2017-04-25 10:09:06 +01:00
Thomas Krzero
ca5c606dfc fix(services): replicas count misunderstanding (#806) 2017-04-25 09:37:38 +01:00
Thomas Krzero
ac872b577a feat(containers) - Add the ability to force remove a container with confirmation (#814) 2017-04-25 09:20:57 +01:00
Anthony Lapenna
2761959f93 feat(templates): add support for the note field (#805) 2017-04-18 17:16:00 +01:00
Anthony Lapenna
7bf708faab Merge branch 'develop' of github.com:portainer/portainer into develop 2017-04-16 11:16:05 +02:00
Anthony Lapenna
c526209925 chore(gruntfile): remove --templates flag in run-dev task 2017-04-16 11:15:56 +02:00
Hilscher
8215cf7857 feat(container-creation): add support for devices (#729) 2017-04-16 08:57:47 +01:00
dedalusj
5745606fe7 feat(cli): Allow setting admin password from CLI (#752) 2017-04-16 08:54:51 +01:00
Anthony Lapenna
f15cf3e8be feat(notifications): replace gritter with toastr (#793) 2017-04-12 20:47:22 +01:00
Anthony Lapenna
8e8b0578b2 docs(README): add docker pulls badge 2017-04-10 19:01:15 +02:00
Anthony Lapenna
abc929824c fix(endpoints): add the ability to update TLS for an existing endpoint (#784) 2017-04-09 19:38:41 +01:00
Anthony Lapenna
44e48423ed fix(endpoint-init): fix an issue when connecting to a remote TLS endpoint (#783) 2017-04-08 19:38:19 +01:00
Anthony Lapenna
3883cc8b67 Merge tag '1.12.4' into develop
Release 1.12.4
2017-04-06 10:37:37 +02:00
Anthony Lapenna
8e6272920b Merge branch 'release/1.12.4' 2017-04-06 10:37:32 +02:00
Anthony Lapenna
0cde215259 chore(version): bump version number 2017-04-06 10:37:26 +02:00
Anthony Lapenna
3fc54c095e fix(service-details): fix an update issue when no ports are defined (#765) 2017-04-06 09:35:01 +01:00
Anthony Lapenna
80a0a15490 fix(service-details): display spinner when updating the service (#764) 2017-04-06 09:34:49 +01:00
Anthony Lapenna
af49c78498 Merge tag '1.12.3' into develop
Release 1.12.3
2017-04-05 10:15:14 +02:00
Anthony Lapenna
4839c5f313 Merge branch 'release/1.12.3' 2017-04-05 10:15:08 +02:00
Anthony Lapenna
e9c6feb3c4 chore(version): bump version number 2017-04-05 10:15:03 +02:00
Anthony Lapenna
b8803f380b feat(templates): LinuxServer.io templates integration (#761) 2017-04-05 10:13:32 +02:00
Anthony Lapenna
16166c3367 fix(network-creation): fix internal network switch (#760) 2017-04-05 10:04:29 +02:00
Anthony Lapenna
db4b153ce1 fix(service-creation): fix invalid mount specs (#757) 2017-04-04 09:16:13 +02:00
Anthony Lapenna
50305e0eee feat(volume-creation): retrieve available drivers from the engine (#751) 2017-04-01 12:18:46 +02:00
Thomas Krzero
53f31ba3b8 feat(templates): add the ability to connect a template to swarm attachable networks (#642) 2017-03-31 22:12:58 +02:00
Anthony Lapenna
ffca440135 fix(services): let Docker automatically assign port when PublishedPort is not defined (#747) 2017-03-30 12:00:16 +02:00
Thomas Krzero
9fda8f9c92 fix(services) - Fix exposed ports (#746) 2017-03-30 11:39:37 +02:00
Anthony Lapenna
a48503d821 feat(services): add a confirmation modal before deleting one or multiple services (#742) 2017-03-30 11:22:59 +02:00
Anthony Lapenna
f9c1941384 chore(api): update comment 2017-03-30 11:17:54 +02:00
Anthony Lapenna
9520380388 style(services): update empty service list text alignment (#744) 2017-03-29 18:54:27 +02:00
Anthony Lapenna
a88d02b0b4 style(templates): update ownership buttons style 2017-03-29 18:47:43 +02:00
Adrian Dimitrov
0a8501fcbb fix(containers): fix an issue with hidden labels (#740) 2017-03-29 17:47:56 +02:00
Anthony Lapenna
c9d50641c8 Merge tag '1.12.2' into develop
Release 1.12.2
2017-03-28 15:18:40 +02:00
Anthony Lapenna
9e06cfbdf0 Merge branch 'release/1.12.2' 2017-03-28 15:18:33 +02:00
Anthony Lapenna
135a92feb4 chore(version): bump version number 2017-03-28 15:18:29 +02:00
Anthony Lapenna
cd4b5e0c80 docs(README): update supported versions 2017-03-28 15:17:49 +02:00
Anthony Lapenna
3cd0506810 feat(build): update build script 2017-03-28 15:16:42 +02:00
Thomas Krzero
ffa2cf62f5 feat(services) - add exposed ports (#690) 2017-03-28 15:12:54 +02:00
Anthony Lapenna
0e439d7ae6 fix(Dockerfiles): use a volume to store data (#731) 2017-03-28 15:07:42 +02:00
Anthony Lapenna
a99c6c4cbe fix(backend): use a thread-safe implementation of map for proxies (#728) 2017-03-28 14:28:17 +02:00
Anthony Lapenna
9e818c2882 fix(authentication): remove any user credentials if not allowed on any endpoint (#719) 2017-03-27 15:24:35 +02:00
Anthony Lapenna
c243a02e7a feat(UX): UX/responsiveness enhancements 2017-03-27 14:44:39 +02:00
Anthony Lapenna
967286f45d docs(contributing): update contribution guidelines 2017-03-24 12:22:58 +01:00
dantheman0207
8e794be13f feat(containers): truncate long names & ids in the containers view (#699) 2017-03-22 08:13:59 +01:00
Glowbal
a8f70d7f59 feat(service-details): add ability to edit service details (#453) 2017-03-20 21:28:09 +01:00
Anthony Lapenna
ab91ffe12c style(containers): use the same action sequence for container-details and containers (#707) 2017-03-20 17:39:53 +01:00
Anthony Lapenna
24b51a7e87 refactor(image): refactor the code used in image and image details controller (#705) 2017-03-20 12:01:35 +01:00
Gábor Kovács
c2e63070e6 feat(image-details): add the ability to pull/update a tag (#421) 2017-03-20 11:45:04 +01:00
AHumanPerson
b6627098c2 docs(README): update demo username (#703) 2017-03-19 21:24:09 +01:00
Anthony Lapenna
097955e587 fix(templates): fix an issue where container links would fail (#701) 2017-03-19 19:07:22 +01:00
Anthony Lapenna
497a8392f6 fix(sidebar): fix a display issue on low resolution (#697) 2017-03-18 13:08:39 +01:00
Anthony Lapenna
dcce211676 fix(api): allow empty array when removing accesses to an endpoint (#692) 2017-03-17 11:52:17 +01:00
Anthony Lapenna
631b29eddc fix(jshint): fix lint issues 2017-03-16 11:32:07 +01:00
Anthony Lapenna
9f12cbd43d fix(services): fix an issue with the sorting link for the ownership column (#682) 2017-03-16 11:24:47 +01:00
Anthony Lapenna
b24825d453 feat(backend): check for the full database path to verify its existence (#681) 2017-03-16 11:23:01 +01:00
Anthony Lapenna
3861e964f4 fix(dockerfile): fix an issue with the data directory in Windows images 2017-03-14 18:28:21 +01:00
Anthony Lapenna
ca4428cff2 feat(build): update build script 2017-03-13 10:23:49 +01:00
Anthony Lapenna
6b09c4f9b7 Merge tag '1.12.1' into develop
Release 1.12.1
2017-03-13 10:12:55 +01:00
Anthony Lapenna
5b2d5e17ab Merge branch 'release/1.12.1' 2017-03-13 10:12:49 +01:00
Anthony Lapenna
be2acdbdfb chore(version): bump version number 2017-03-13 10:12:42 +01:00
Anthony Lapenna
723bf3874f fix(templates): fix an issue where the image would not be pulled correctly (#664) 2017-03-13 10:09:34 +01:00
Anthony Lapenna
ebc378230f Merge tag '1.12.0' into develop
Release 1.12.0
2017-03-12 22:33:40 +01:00
Anthony Lapenna
7bef9c0708 Merge branch 'release/1.12.0' 2017-03-12 22:33:34 +01:00
Anthony Lapenna
1294ebaa8c chore(version): bump version number 2017-03-12 22:33:26 +01:00
Anthony Lapenna
f40baa1287 feat(build): update build script 2017-03-12 22:30:50 +01:00
Richard Goater
35e2cecee1 feat(services): display clearer information about services 2017-03-12 18:24:41 +01:00
Anthony Lapenna
22c02a8fe9 fix(swarm): fix an issue when trying to access node view (#650) 2017-03-12 18:01:52 +01:00
Michael Friis
08868eb3e0 refactor(endpoint-init): update information warning for the local endpoint management 2017-03-12 17:43:33 +01:00
Damian
8a827950d8 Ability to select all endpoints via a checkbox (#607) 2017-03-12 17:39:27 +01:00
Anthony Lapenna
d724f75016 fix(app): use lodash startsWith method instead of ECMAScript 2015 one (#648) 2017-03-12 17:36:24 +01:00
Anthony Lapenna
80d50378c5 feat(uac): add multi user management and UAC (#647) 2017-03-12 17:24:15 +01:00
WTFKr0
f28f223624 #643 feat(templates): add privileged flag to templates (#644) 2017-03-10 15:43:57 +01:00
Anthony Lapenna
082cf5772b merge remote branch 'develop' into develop 2017-03-03 13:07:16 +01:00
Anthony Lapenna
44ceae40b5 merge branch 'release-1.11.4' into develop 2017-03-03 12:54:22 +01:00
Anthony Lapenna
b72cce810e Merge branch 'release/1.11.4' 2017-03-03 12:48:12 +01:00
Anthony Lapenna
ccaabf3b6b chore(version): bump version number 2017-03-03 12:36:24 +01:00
Anthony Lapenna
2232adbd8b merge branch 'feat484-external-endpoints' into release-1.11.4 2017-03-03 12:35:54 +01:00
WTFKr0
cff999d7bb refactor(global): change file format (dos2unix) (#620) 2017-02-25 12:21:55 +01:00
Anthony Lapenna
ec0cc84c7c refactor(lint): fix lint issue 2017-02-16 11:23:43 +13:00
Romain
64ef74321a feat(image): add the ability to force remove an image (#497) (#562) 2017-02-16 11:14:56 +13:00
Romain
6f53d1a35a feat (container): remember selection when refreshing a list view (#151) (#567) 2017-02-16 11:08:18 +13:00
Renato Silva
f1c458b147 feat(container-creation): add the ability to add entries in the container host file 2017-02-16 10:48:40 +13:00
Anthony Lapenna
38244312c5 fix(stats): fix a small issue within statsController 2017-02-14 17:10:08 +13:00
Anthony Lapenna
52ab0bd50d feat(UX): automatically change the state to dashboard when switching endpoint (#602) 2017-02-14 16:22:24 +13:00
Anthony Lapenna
73082f1674 feat(cli): add a --no-analytics flag to disable google analytics (#601) 2017-02-14 12:37:37 +13:00
Anthony Lapenna
66c574f74d feat(project): add google analytics in app (#599) 2017-02-14 11:39:26 +13:00
Anthony Lapenna
85a07237b1 feat(swarm): display the IP address of each node when API Version >= … (#595) 2017-02-13 22:39:02 +13:00
Anthony Lapenna
781dad3e17 feat(templates): add the ability to update the volume configuration (#590) 2017-02-13 18:16:14 +13:00
Romain
c5552d1b8e feat (container): add publish all ports option (#558) (#566) 2017-02-12 12:23:13 +13:00
Anthony Lapenna
e0b94e4ff7 feat(templates): add support for the network field (#583) 2017-02-11 09:32:34 +13:00
Anthony Lapenna
3089268d88 fix(container-creation): split the container command to a token array (#586) 2017-02-10 18:21:07 +13:00
Anthony Lapenna
d9624053d2 feat(templates): add support for the command field (#585) 2017-02-10 18:11:00 +13:00
Anthony Lapenna
9ebe2d96dd chore(jshint): update jshint library and configuration (#581) 2017-02-10 14:34:56 +13:00
Anthony Lapenna
2f3475b96a refactor(templates): refactor controller code and create required services (#580) 2017-02-10 14:11:36 +13:00
Samuel Tschiedel
06a484880b fix(index): fix a typo on the login page (#579) 2017-02-10 09:32:34 +13:00
Anthony Lapenna
a78758123b style(cli): update error message 2017-02-07 16:27:40 +13:00
Anthony Lapenna
f129bf3e97 refactor(api): refactor 2017-02-07 16:26:12 +13:00
Anthony Lapenna
dc78ec5135 feat(endpoints): add the ability to define endpoints from an external source 2017-02-06 18:29:34 +13:00
Anthony Lapenna
10f7744a62 feat(authentication): add a --no-auth flag to disable authentication (#553) 2017-02-01 22:13:48 +13:00
Anthony Lapenna
0f81ad5654 feat(global): add a --no-auth flag to disable authentication 2017-02-01 22:10:07 +13:00
Anthony Lapenna
779fcf8e7f refactor(readme): remove useless version badge 2017-02-01 15:42:15 +13:00
Anthony Lapenna
7c2b186a61 refactor(assets): remove useless .jshintrc file 2017-02-01 15:40:49 +13:00
Anthony Lapenna
fe0bf77bbb refactor(global): service separation #552 2017-02-01 12:26:29 +13:00
Anthony Lapenna
0abe8883d1 chore(dockerfiles): update data directory for windows Dockerfiles 2017-02-01 11:35:25 +13:00
Anthony Lapenna
84f2c2d735 Merge tag '1.11.3' into develop
Release 1.11.3
2017-02-01 11:02:15 +13:00
Anthony Lapenna
5d63c90203 Merge branch 'release/1.11.3' 2017-02-01 11:02:10 +13:00
Anthony Lapenna
a97e7bbaae chore(version): bump version number 2017-02-01 11:02:05 +13:00
Anthony Lapenna
f3cfb0a940 fix(cli): revert data/certs directories defaults to c:\data and c:\certs (#551) 2017-02-01 08:56:07 +13:00
Anthony Lapenna
b1ca43934f Merge tag '1.11.2' into develop
Release 1.11.2
2017-01-26 17:44:00 +13:00
Anthony Lapenna
7afeb8a80d Merge branch 'release/1.11.2' 2017-01-26 17:43:53 +13:00
Anthony Lapenna
f8ced03792 chore(version): bump version number 2017-01-26 17:43:47 +13:00
Jisu Park
1fdf56372b feat(containers): support container already pause message (#480) 2017-01-26 12:11:38 +13:00
Anthony Lapenna
835b273700 feat(api): force no-cache on HTML files 2017-01-26 11:45:03 +13:00
Anthony Lapenna
fcc9203416 feat(node): add pagination to associated tasks 2017-01-26 10:35:05 +13:00
Anthony Lapenna
e25c5a014c feat(swarm): set default sorting for Swarm nodes by role 2017-01-26 10:34:10 +13:00
Glowbal
fa9ba303aa #414 feat(node-details): add ability to view and edit Swarm mode nodes (#417) 2017-01-26 10:12:04 +13:00
morph027
e6dee37af0 style(swarm): update node status filter for swarm mode nodes 2017-01-26 09:54:08 +13:00
Anthony Lapenna
d03e992b4f feat(api): replace all calls to http.Error with custom Error writer 2017-01-24 16:35:48 +13:00
Anthony Lapenna
1a868be6ea fix(swarm): fix sorting issue with node table (#538) 2017-01-24 14:45:38 +13:00
Anthony Lapenna
e2fc8af87a feat(ux): add the ability to change the number of paginated items on all entity tables (#537) 2017-01-24 14:28:40 +13:00
Anthony Lapenna
70933d1056 style(sidebar): add active class on Docker section (#534) 2017-01-24 09:39:13 +13:00
Anthony Lapenna
7e0b0a05de feat(authentication): clean the state and the browser local storage on logout 2017-01-23 17:04:34 +13:00
Anthony Lapenna
980f65a08a feat(api): initializes the endpoint with an empty slice instead of a pointer 2017-01-23 16:29:49 +13:00
Anthony Lapenna
8cf6d34362 style(container-creation): remove useless labels section (#532) 2017-01-23 16:10:12 +13:00
Anthony Lapenna
70f139514f fix(network-details): add a fallback for listing containers when APIV… (#531) 2017-01-23 16:06:51 +13:00
Anthony Lapenna
fa4ec04c47 feat(state): introduce endpoint state (#529) 2017-01-23 12:14:34 +13:00
Anthony Lapenna
7ebe4af77d fix(images): fix an issue when deleting images with multiple tags (#526) 2017-01-22 14:42:12 +13:00
lpfeup
579241db92 #503 fix(container-stats): fix container stats timer not being properly canceled. (#504) 2017-01-21 18:04:28 +13:00
lpfeup
7d78871eee #446 fix(container-stats): fix issue in stats view with empty network data (#502) 2017-01-21 18:01:32 +13:00
Anthony Lapenna
3a6e9d2fbe fix(api): fix an issue introduced by the latest version of package gorilla/mux (#520) 2017-01-21 11:17:51 +13:00
Anthony Lapenna
e4d98082dc fix(api): disable data directory creation (#495)
* fix(api): disable data directory creation

* feat(dockerhub): update volume instruction value for Windows Dockerfiles
2017-01-14 14:22:39 +13:00
Kilhog
cd26051144 #476 fix(UX): Rename 'local' endpoint doesn't overwrite "unix://" (#477)
* #476 fix(UX): Rename 'local' endpoint doesn't overwrite "unix://"

* #477 fix(PR): Rename 'TYPE' in 'type'
2017-01-12 18:44:53 +13:00
Anthony Lapenna
27e584fc14 fix(api): check if admin user already exists when calling the /users/admin/init endpoint (#494) 2017-01-12 18:17:28 +13:00
Anthony Lapenna
2bdc9322de style(containers): update header text for published ports (#483) 2017-01-09 21:50:19 +13:00
Anthony Lapenna
35d5d75966 fix(api): update default value for data directory and TLS certs on Windows (#482) 2017-01-09 21:24:17 +13:00
Anthony Lapenna
2610e3d02a Merge tag '1.11.1' into develop
Release 1.11.1
2017-01-05 10:42:50 +13:00
Anthony Lapenna
d579f62fa7 Merge branch 'release/1.11.1' 2017-01-05 10:42:46 +13:00
Anthony Lapenna
d1b9820a29 chore(version): bump version number 2017-01-05 10:42:38 +13:00
Wouter Oet
13943c3d8b #372 feat(UX): Implement select all functionality (#437) 2017-01-05 09:15:41 +13:00
Anthony Lapenna
d8b800ddbc feat(api): create platform dependant default values for CLI flags (#458) 2017-01-04 19:50:25 +13:00
Matthew Strickland
59f1a2f673 feat(templates): display container restart policy in container dashboard (#434) (#435) 2017-01-04 19:49:04 +13:00
Anthony Lapenna
9ee652c818 fix(api): creates the data directory if not exist (#452) 2017-01-03 08:32:53 +13:00
Anthony Lapenna
816c1ea448 chore(build-system): fix release tasks 2017-01-03 07:47:12 +13:00
Albert Domenech
0bacaef71a feat(images): initial aarch64/arm64 support (#447) 2017-01-03 07:42:21 +13:00
Anthony Lapenna
2ef821f118 style(service-details): update style for update failure action field (#443) 2016-12-31 13:32:20 +13:00
Anthony Lapenna
487cb4e755 Merge branch 'develop' of github.com:portainer/portainer into develop 2016-12-31 13:27:51 +13:00
Anthony Lapenna
06d3debf38 chore(build-system): fix grunt lint task 2016-12-31 13:27:35 +13:00
Anthony Lapenna
907f83aaff fix(global): remove automatic lowercase processing on image names (#442) 2016-12-31 13:25:42 +13:00
Gábor Kovács
4b747a78cd style(sidebar): Highlight active page in sidebar (#420)
* Issue #331

* New line
2016-12-31 13:12:51 +13:00
Anthony Lapenna
d6f3dd8cda style(endpoint-initialization): update requirement message for local endpoint init (#424) 2016-12-31 13:00:30 +13:00
Anthony Lapenna
51632e367c fix(service-details): allow to specify the 0 value for replicas (#441) 2016-12-31 12:59:20 +13:00
Anthony Lapenna
6e98237419 feat(api): introduce cache busting mechanism (#439) 2016-12-31 12:20:38 +13:00
Anthony Lapenna
ecc8857a32 fix(global): strip leading '/' in front of endpoints (#438) 2016-12-31 10:30:22 +13:00
Anthony Lapenna
7d05e81c37 chore(github): update ISSUE_TEMPLATE.md 2016-12-27 08:54:39 +13:00
Anthony Lapenna
6ce3fe7a9e Merge tag '1.11.0' into develop
Release 1.11.0
2016-12-26 13:30:20 +13:00
Anthony Lapenna
9443284f52 Merge branch 'release/1.11.0' 2016-12-26 13:30:15 +13:00
Anthony Lapenna
4d6dadd17c chore(version): bump version number 2016-12-26 13:30:06 +13:00
Anthony Lapenna
d54d30a7be feat(global): multi endpoint management (#407) 2016-12-26 09:34:02 +13:00
Glowbal
a08ea134fc feat(container-creation): add ability to specify labels in the container creation view (#412) 2016-12-26 09:33:14 +13:00
Glowbal
c9ba16ef10 feat(network-creation): add labels on network create (#408) 2016-12-26 09:32:17 +13:00
Glowbal
986171ecfe feat(service): Add editable service update configuration (#346)
* #304 Add editable service update configuration

* fix unable to use 0 for update-delay

* apply margin top to center help text
2016-12-26 09:31:22 +13:00
Glowbal
712b4528c0 feat(network-details): add list of containers in network (#302)
- shows all containers currently connected to the network
- abillity to disconect a container from the network
- fix error when a container is not connected to any networks
2016-12-26 09:28:54 +13:00
Anthony Lapenna
03456ddcf8 feat(containers): add the ability to filter by state (#410) 2016-12-25 22:43:53 +13:00
Anthony Lapenna
ce32ed5b98 fix(service-creation): fix the command specification and add the ability to specify an entrypoint (#409) 2016-12-25 22:14:26 +13:00
Paul Kling
edeed41797 #186 feat(container): bind the enter key when renaming container (#385) 2016-12-25 08:53:57 +13:00
David Eisner
419727e1eb feat(api): Connect to docker behind a name based virtual host proxy (#379)
This involves copying and modifying go's httputil.NewSingleHostReverseProxy, which is documented to (perhaps surprisingly) leave the Host header untouched. Instead, we set the Host header to the target host for the connection for the benefit of name based virtual host proxies that make use of this. The value it would otherwise have in this app, typically 'localhost:8000', is strange and unlikely to be any use.

See golang/go#7618 and golang/go#10342
2016-12-24 17:49:29 +13:00
Anthony Lapenna
9165b5b215 fix(dashboard): add missing dependency to Messages service (#402) 2016-12-21 11:24:34 +13:00
Anthony Lapenna
0a38bba874 refactor(api): API overhaul (#392) 2016-12-18 18:21:29 +13:00
Anthony Lapenna
d9f6124609 refactor(global): remove useless code related to CSRF (#387) 2016-12-16 14:00:57 +13:00
Anthony Lapenna
5b16deb73e fix(templates): fix an issue with template creation introduced with #384 2016-12-16 13:39:24 +13:00
Anthony Lapenna
4e77c72fa2 feat(global): add authentication support with single admin account 2016-12-15 16:33:47 +13:00
Anthony Lapenna
1e5207517d fix(container-creation): do not stop container creation if unable to pull image 2016-12-15 14:30:35 +13:00
Anthony Lapenna
2a28921984 docs(README): update readthedocs badge to point at stable version 2016-12-14 09:46:01 +13:00
Anthony Lapenna
b5bf7cdead feat(templates): add support for the template registry field 2016-12-14 09:33:24 +13:00
Paul Kling
8869a2c79c feat(templates): automatically scroll up to the app template form after selecting a template 2016-12-14 09:25:23 +13:00
Anthony Lapenna
99d49a1f87 chore(project): update contribution guidelines 2016-12-02 19:19:24 +13:00
Anthony Lapenna
a53c0f08a3 Merge tag '1.10.2' into develop
Release 1.10.2
2016-11-26 00:51:01 +13:00
Anthony Lapenna
0e40bb13fc Merge branch 'release/1.10.2' 2016-11-26 00:50:55 +13:00
Anthony Lapenna
db46087799 chore(version): bump version number 2016-11-26 00:50:50 +13:00
Anthony Lapenna
367a275672 fix(service-details): fix an issue with the '=' separator in env variable values (#370) 2016-11-25 20:48:12 +09:00
Glowbal
b3a641e15a feat(service-creation): add support for container labels (#365) 2016-11-25 15:21:06 +09:00
Glowbal
868b400af3 fix(volumes): fix loading text displayed when no volumes present
Volumes is undefined when no volumes are present. The loading text will remain until volumes is defined.
2016-11-25 15:16:28 +09:00
Rob McFadzean
8fcae6810e fix(templates): fixes an issue regarding template selection when paged 2016-11-22 09:21:36 +09:00
Anthony Lapenna
913c580340 feat(UX): add pagination for all object lists (#352) 2016-11-17 21:50:46 +09:00
Anthony Lapenna
13a8b11d3d Merge tag '1.10.1' into develop
Release 1.10.1
2016-11-16 23:17:51 +13:00
Anthony Lapenna
5af99c6fe3 Merge branch 'release/1.10.1' 2016-11-16 23:17:46 +13:00
Anthony Lapenna
2d35ac8f82 chore(version): bump version number 2016-11-16 23:17:39 +13:00
Anthony Lapenna
3db487f386 fix(service-details): fix a sorting issue when ordering by last update (#350) 2016-11-16 19:16:50 +09:00
Rob Brazier
643769d4a6 feat(container-creation): add the ability to use container as a network 2016-11-16 10:52:05 +09:00
Anthony Lapenna
2c49d3b5d9 docs(README): add a donate badge 2016-11-12 12:51:06 +13:00
Anthony Lapenna
714f515f0b chore(build-system): fix build script 2016-11-11 15:50:59 +13:00
Anthony Lapenna
672479bf4f Merge tag '1.10.0' into develop
Release 1.10.0
2016-11-11 15:29:25 +13:00
Anthony Lapenna
8c3f7b3ec2 Merge branch 'release/1.10.0' 2016-11-11 15:29:16 +13:00
Anthony Lapenna
3aa0f4d263 chore(version): bump version number 2016-11-11 15:29:02 +13:00
Anthony Lapenna
2f35f04207 fix(service-details): fix an issue when trying to update a global service (#343) 2016-11-11 11:26:19 +09:00
Anthony Lapenna
3b3b23142c chore(build-system): add a release for macos task (#342) 2016-11-11 11:17:38 +09:00
Anthony Lapenna
9bd88fd10d style(service-details): fix wrong display for some fields (#340) 2016-11-10 13:01:03 +09:00
Glowbal
3092d0b7eb chore(grunt): adda run local swarm grunt task 2016-11-10 11:42:07 +09:00
Glowbal
d924d340d7 feat(service-details): add the ability to edit the labels associated to a service 2016-11-10 11:38:49 +09:00
Glowbal
c1ffd02491 fix(container-details): fix an issue with the leave network action 2016-11-10 11:25:31 +09:00
Glowbal
8e9dd8c2df #304 feat(service-details): add the ability to update a service env vars and image 2016-11-09 13:23:56 +13:00
Glowbal
1bfd6bbe95 #280 feat(service-creation): add labels to service creation (#306) 2016-11-07 17:57:33 +13:00
Glowbal
715638e368 feat(container-details): show list of joined networks (#303)
- Add overview of joined networks in container view
- Add option ot leave a joined network
2016-11-07 17:36:00 +13:00
jjlorenzo
08c868bc1c Restore the ability to customize the logo image. (#327) 2016-11-07 17:14:58 +13:00
Anthony Lapenna
9f46b12625 fix(containers): fix an issue with container IP in overlay network (#324) 2016-11-07 17:13:57 +13:00
Anthony Lapenna
6fc25691bd feat(backend): add a simple log message to indicate portainer startup (#320) 2016-11-04 16:52:02 +13:00
Anthony Lapenna
c1713e0d01 docs(readme): update Portainer description with Windows support 2016-11-04 10:48:36 +13:00
Glowbal
8187f17d33 fix(service-details): show labels in service view 2016-11-03 17:14:07 +13:00
Anthony Lapenna
f0e194f63b Disable CSRF protection (#313) 2016-11-03 15:56:10 +13:00
Glowbal
eabf1f10e4 feat(navigation): add clickable url in breadcrumbs 2016-11-02 18:14:52 +13:00
Stefan Scherer
c913d858ee Add Linux ARM support (#299)
Signed-off-by: Stefan Scherer <scherer_stefan@icloud.com>
2016-11-01 09:07:32 +13:00
Anthony Lapenna
17f35ef705 fix(container-creation): fix default network on Windows platform (#298) 2016-10-29 17:49:21 +13:00
Anthony Lapenna
0bdbb4a75d feat(container-stats): make process list sortable 2016-10-29 17:39:15 +13:00
Stefan Scherer
f9327b3337 Use microsoft base images (#296)
Signed-off-by: Stefan Scherer <scherer_stefan@icloud.com>
2016-10-29 16:38:32 +13:00
Anthony Lapenna
bf6c9c8b3b refactor(style): refactor multiple similar css classes 2016-10-27 21:33:39 +13:00
Anthony Lapenna
45015a573b feat(container-creation): add the unless stopped container restart policy (#294) 2016-10-27 20:05:37 +13:00
Anthony Lapenna
d4f0145161 feat(templates): allow to edit template port mapping (#293)
* feat(templates): allow to edit template port mapping

* refactor(templates): remove advanced template configuration feature
2016-10-27 19:55:44 +13:00
Anthony Lapenna
fa53339fea feat(docker): new docker view (#292) 2016-10-27 17:13:53 +13:00
Anthony Lapenna
e5396091a7 feat(console): automatically determine command presets based on container image OS (#284) 2016-10-26 16:29:29 +13:00
Anthony Lapenna
1ae18e1577 chore(grunt): fix an issue with the Docker image building process in grunt 2016-10-26 12:09:09 +13:00
Anthony Lapenna
b953850a1f chore(grunt): fix issue with grunt run-* tasks 2016-10-26 12:05:29 +13:00
Anthony Lapenna
d0954abe29 chore(docker): update build system with Docker for Windows support (#283) 2016-10-26 09:04:26 +13:00
Anthony Lapenna
c3cf5b5f9d feat(templates): advanced template creation (#277) 2016-10-20 16:43:09 +13:00
Anthony Lapenna
6589730acc refactor(css): remove useless css classes (#274) 2016-10-19 17:57:38 +13:00
Anthony Lapenna
442dcff0f1 chore(license): relicense to zlib license (#271) 2016-10-16 14:39:38 +13:00
Anthony Lapenna
8bac1955a8 Merge tag '1.9.3' into develop
Release 1.9.3
2016-10-09 10:50:52 +13:00
Anthony Lapenna
09a5534499 Merge branch 'release/1.9.3' 2016-10-09 10:50:46 +13:00
Anthony Lapenna
65c126f6a1 chore(version): bump version number 2016-10-09 10:50:32 +13:00
Anthony Lapenna
6adec680a4 style(templates): new effect on hover (#268)
* style(templates): new effect on hover

* feat(templates): display a loading message
2016-10-09 10:49:24 +13:00
Anthony Lapenna
b81d4fa7f2 feat(global): display a loading text in list views (#267) 2016-10-08 14:59:58 +13:00
Anthony Lapenna
d8f2e3da86 docs(readme): update README 2016-10-08 10:10:12 +13:00
Anthony Lapenna
b0c0512515 Merge tag '1.9.2' into develop
Release 1.9.2
2016-10-07 18:22:58 +13:00
Anthony Lapenna
bb9e044e89 Merge branch 'release/1.9.2' 2016-10-07 18:22:53 +13:00
Anthony Lapenna
520532cb9a chore(version): bump version number 2016-10-07 18:22:44 +13:00
Anthony Lapenna
44e09ecadf feat(container-creation): let Docker assign a port when host port is not specified (#264) 2016-10-07 18:08:07 +13:00
Anthony Lapenna
35ced4901a feat(global): display a message when no item available in a list view (#263) 2016-10-07 17:55:09 +13:00
Anthony Lapenna
134416c9a3 fix(container-console): use xterm.js v2 (#262) 2016-10-07 17:19:25 +13:00
Anthony Lapenna
8f7f4acc0d chore(build): add a build script to archive binary 2016-10-05 11:33:32 +13:00
Anthony Lapenna
fde0d3ea9f chore(github): add github issue template 2016-10-05 10:56:49 +13:00
Anthony Lapenna
477799af7e chore(project): update contribution guidelines 2016-10-05 10:44:29 +13:00
Anthony Lapenna
72570153a5 docs(badges): add the dockerhub version badge 2016-10-03 12:35:40 +13:00
Anthony Lapenna
9f335b692f Merge tag '1.9.1' into develop
Release 1.9.1
2016-10-02 16:26:25 +13:00
Anthony Lapenna
e88b22bd45 Merge branch 'release/1.9.1' 2016-10-02 16:26:19 +13:00
Anthony Lapenna
833053a2e1 chore(version): bump version number 2016-10-02 16:26:11 +13:00
Anthony Lapenna
64c52348f3 fix(lint): fix linting issue 2016-10-02 16:25:37 +13:00
Anthony Lapenna
c3b79e6cc2 chore(xterm): update xterm.js version to 1.1.3 (#254) 2016-10-02 16:19:11 +13:00
Anthony Lapenna
422a982d60 feat(templates): template configuration is now placed on top of template list (#253) 2016-10-02 16:11:20 +13:00
Anthony Lapenna
6e9fe26fde fix(templates): fix bad container display when swarm-mode enabled (#251) 2016-10-02 15:05:40 +13:00
Anthony Lapenna
6bfa3096dc feat(index): hide Events and Docker view when swarm-mode is enabled (#250) 2016-10-02 14:57:01 +13:00
Anthony Lapenna
7cd2da4c6e fix(console): fix issue with undefined socket (#248) 2016-10-01 21:44:23 +13:00
Anthony Lapenna
739a5ec299 fix(general): fix the size display using the filesize library (#246)
* fix(general): fix the size display using the filesize library

* refactor(humansize): use default value for filter
2016-10-01 21:38:20 +13:00
Anthony Lapenna
59e65222eb feat(swarm): support Swarm replica management (#245) 2016-10-01 17:50:46 +13:00
Anthony Lapenna
01d5d11c01 feat(events): support new events (#244) 2016-10-01 17:08:32 +13:00
Anthony Lapenna
29a59cab44 feat(containers): change exposed port format (#243) 2016-10-01 16:55:11 +13:00
Anthony Lapenna
be184c11a6 style(favicon): update favicon (#242) 2016-10-01 16:51:45 +13:00
Anthony Lapenna
d6ab97ad25 fix(containers): fix the ability to sort containers by status (#241) 2016-10-01 16:45:06 +13:00
Anthony Lapenna
6a0f76890e docs(README): update README 2016-09-30 18:51:55 +13:00
Anthony Lapenna
1946868248 docs(README): update links to readthedocs 2016-09-30 18:51:09 +13:00
Anthony Lapenna
84b02c711a docs(badge): add readthedocs badge 2016-09-30 18:47:36 +13:00
Anthony Lapenna
679a681749 Merge tag '1.9.0' into develop
Release 1.9.0
2016-09-24 22:33:37 +12:00
Anthony Lapenna
c35d1b14ec Merge branch 'release/1.9.0' 2016-09-24 22:33:30 +12:00
Anthony Lapenna
87df297a56 chore(version): bump version number 2016-09-24 22:33:23 +12:00
Anthony Lapenna
b8e420e0e8 docs(project): new documentation (#229) 2016-09-24 22:31:37 +12:00
Anthony Lapenna
f8c8668863 docs(contribution): add contributions rules 2016-09-24 17:30:08 +12:00
Anthony Lapenna
ced0746a81 Merge pull request #228 from portainer/chore218-portainer-org
chore(global): replace CloudInovasi with Portainer.io
2016-09-23 18:29:09 +12:00
Anthony Lapenna
39909d774f chore(global): replace CloudInovasi with Portainer.io 2016-09-23 18:28:20 +12:00
Anthony Lapenna
12e6e0557d Merge pull request #227 from cloud-inovasi/feat216-swarm-latest-support
feat(global): change the strategy used to determine if swarm mode is …
2016-09-23 18:02:48 +12:00
Anthony Lapenna
e27282de3c feat(global): change the strategy used to determine if swarm mode is used 2016-09-23 18:02:03 +12:00
Anthony Lapenna
fe63f9939a Merge pull request #226 from cloud-inovasi/style219-incoherent-container-icon
style(ui): use fa-server icon instead of fa-tasks for container entity
2016-09-23 17:20:52 +12:00
Anthony Lapenna
b623a5d452 style(ui): use fa-server icon instead of fa-tasks for container entity 2016-09-23 17:19:57 +12:00
Anthony Lapenna
d8113df979 Merge pull request #225 from cloud-inovasi/style220-github-icon
style(index): add a github icon next to the github link
2016-09-23 17:08:17 +12:00
Anthony Lapenna
b3ba36c02a style(index): add a github icon next to the github link 2016-09-23 17:07:48 +12:00
Anthony Lapenna
37863e3f74 feat(global): swarm mode support (#213)
feat(global): swarm mode support
2016-09-23 16:54:58 +12:00
Anthony Lapenna
da6f39b137 fix(lint): fix jshint issue 2016-09-14 17:50:54 +12:00
Anthony Lapenna
4fe63d7102 Merge pull request #211 from cloud-inovasi/feat200-network-view
feat(network): new network view
2016-09-14 17:49:24 +12:00
Anthony Lapenna
7c8881f37d feat(network): new network view 2016-09-14 17:48:20 +12:00
Anthony Lapenna
c20069fce0 Merge pull request #210 from cloud-inovasi/feat195-quick-network-creation-form
feat(networks): add a quick network creation form
2016-09-14 16:31:02 +12:00
Anthony Lapenna
2eb1c9e857 feat(networks): add a quick network creation form 2016-09-14 16:28:38 +12:00
Anthony Lapenna
48e1fe769e Merge pull request #209 from cloud-inovasi/style199-action-icons
style(actions): add icons for every actions
2016-09-14 15:32:31 +12:00
Anthony Lapenna
2b8bc82d4e style(actions): add icons for every actions 2016-09-14 15:30:52 +12:00
Anthony Lapenna
8f33151647 Merge tag '1.8.1' into develop
Release 1.8.1
2016-09-07 18:31:48 +12:00
Anthony Lapenna
8e743a8d32 Merge branch 'release/1.8.1' 2016-09-07 18:31:44 +12:00
Anthony Lapenna
9f22e01d3b chore(version): bump version number 2016-09-07 18:31:32 +12:00
Anthony Lapenna
502c8718c5 Merge pull request #206 from cloud-inovasi/feat196-disable-create-button
feat(network-creation): disable create button while network name is e…
2016-09-07 18:28:57 +12:00
Anthony Lapenna
220faa52e7 feat(network-creation): disable create button while network name is empty 2016-09-07 18:28:14 +12:00
Anthony Lapenna
857c93bff9 Merge pull request #205 from cloud-inovasi/fix185-volume-deletion-error
fix(volumes): display an error message when trying to delete a bound …
2016-09-07 18:23:11 +12:00
372 changed files with 30000 additions and 4168 deletions

29
.codeclimate.yml Normal file
View File

@@ -0,0 +1,29 @@
---
engines:
gofmt:
enabled: true
golint:
enabled: true
govet:
enabled: true
csslint:
enabled: true
duplication:
enabled: true
config:
languages:
javascript:
mass_threshold: 80
eslint:
enabled: true
config:
config: .eslintrc.yml
fixme:
enabled: true
ratings:
paths:
- "**.css"
- "**.js"
- "**.go"
exclude_paths:
- test/

View File

@@ -1,2 +1,3 @@
*
!dist
!build

284
.eslintrc.yml Normal file
View File

@@ -0,0 +1,284 @@
env:
browser: true
jquery: true
# globals:
# angular: true
# $: true
# _: true
# moment: true
# filesize: true
# splitargs: true
extends:
- 'eslint:recommended'
# http://eslint.org/docs/rules/
rules:
# Possible Errors
no-await-in-loop: off
no-cond-assign: error
no-console: off
no-constant-condition: error
no-control-regex: error
no-debugger: error
no-dupe-args: error
no-dupe-keys: error
no-duplicate-case: error
no-empty-character-class: error
no-empty: error
no-ex-assign: error
no-extra-boolean-cast: error
no-extra-parens: off
no-extra-semi: error
no-func-assign: error
no-inner-declarations:
- error
- functions
no-invalid-regexp: error
no-irregular-whitespace: error
no-negated-in-lhs: error
no-obj-calls: error
no-prototype-builtins: off
no-regex-spaces: error
no-sparse-arrays: error
no-template-curly-in-string: off
no-unexpected-multiline: error
no-unreachable: error
no-unsafe-finally: off
no-unsafe-negation: off
use-isnan: error
valid-jsdoc: off
valid-typeof: error
# Best Practices
accessor-pairs: error
array-callback-return: off
block-scoped-var: off
class-methods-use-this: off
complexity:
- error
- 6
consistent-return: off
curly: off
default-case: off
dot-location: off
dot-notation: off
eqeqeq: error
guard-for-in: error
no-alert: error
no-caller: error
no-case-declarations: error
no-div-regex: error
no-else-return: off
no-empty-function: off
no-empty-pattern: error
no-eq-null: error
no-eval: error
no-extend-native: error
no-extra-bind: error
no-extra-label: off
no-fallthrough: error
no-floating-decimal: off
no-global-assign: off
no-implicit-coercion: off
no-implied-eval: error
no-invalid-this: off
no-iterator: error
no-labels:
- error
- allowLoop: true
allowSwitch: true
no-lone-blocks: error
no-loop-func: error
no-magic-number: off
no-multi-spaces: off
no-multi-str: off
no-native-reassign: error
no-new-func: error
no-new-wrappers: error
no-new: error
no-octal-escape: error
no-octal: error
no-param-reassign: off
no-proto: error
no-redeclare: error
no-restricted-properties: off
no-return-assign: error
no-return-await: off
no-script-url: error
no-self-assign: off
no-self-compare: error
no-sequences: off
no-throw-literal: off
no-unmodified-loop-condition: off
no-unused-expressions: error
no-unused-labels: off
no-useless-call: error
no-useless-concat: error
no-useless-escape: off
no-useless-return: off
no-void: error
no-warning-comments: off
no-with: error
prefer-promise-reject-errors: off
radix: error
require-await: off
vars-on-top: off
wrap-iife: error
yoda: off
# Strict
strict: off
# Variables
init-declarations: off
no-catch-shadow: error
no-delete-var: error
no-label-var: error
no-restricted-globals: off
no-shadow-restricted-names: error
no-shadow: off
no-undef-init: error
no-undef: off
no-undefined: off
no-unused-vars: off
no-use-before-define: off
# Node.js and CommonJS
callback-return: error
global-require: error
handle-callback-err: error
no-mixed-requires: off
no-new-require: off
no-path-concat: error
no-process-env: off
no-process-exit: error
no-restricted-modules: off
no-sync: off
# Stylistic Issues
array-bracket-spacing: off
block-spacing: off
brace-style: off
camelcase: off
capitalized-comments: off
comma-dangle:
- error
- never
comma-spacing: off
comma-style: off
computed-property-spacing: off
consistent-this: off
eol-last: off
func-call-spacing: off
func-name-matching: off
func-names: off
func-style: off
id-length: off
id-match: off
indent: off
jsx-quotes: off
key-spacing: off
keyword-spacing: off
line-comment-position: off
linebreak-style:
- error
- unix
lines-around-comment: off
lines-around-directive: off
max-depth: off
max-len: off
max-nested-callbacks: off
max-params: off
max-statements-per-line: off
max-statements:
- error
- 30
multiline-ternary: off
new-cap: off
new-parens: off
newline-after-var: off
newline-before-return: off
newline-per-chained-call: off
no-array-constructor: off
no-bitwise: off
no-continue: off
no-inline-comments: off
no-lonely-if: off
no-mixed-operators: off
no-mixed-spaces-and-tabs: off
no-multi-assign: off
no-multiple-empty-lines: off
no-negated-condition: off
no-nested-ternary: off
no-new-object: off
no-plusplus: off
no-restricted-syntax: off
no-spaced-func: off
no-tabs: off
no-ternary: off
no-trailing-spaces: off
no-underscore-dangle: off
no-unneeded-ternary: off
object-curly-newline: off
object-curly-spacing: off
object-property-newline: off
one-var-declaration-per-line: off
one-var: off
operator-assignment: off
operator-linebreak: off
padded-blocks: off
quote-props: off
quotes:
- error
- single
require-jsdoc: off
semi-spacing: off
semi:
- error
- always
sort-keys: off
sort-vars: off
space-before-blocks: off
space-before-function-paren: off
space-in-parens: off
space-infix-ops: off
space-unary-ops: off
spaced-comment: off
template-tag-spacing: off
unicode-bom: off
wrap-regex: off
# ECMAScript 6
arrow-body-style: off
arrow-parens: off
arrow-spacing: off
constructor-super: off
generator-star-spacing: off
no-class-assign: off
no-confusing-arrow: off
no-const-assign: off
no-dupe-class-members: off
no-duplicate-imports: off
no-new-symbol: off
no-restricted-imports: off
no-this-before-super: off
no-useless-computed-key: off
no-useless-constructor: off
no-useless-rename: off
no-var: off
object-shorthand: off
prefer-arrow-callback: off
prefer-const: off
prefer-destructuring: off
prefer-numeric-literals: off
prefer-rest-params: off
prefer-reflect: off
prefer-spread: off
prefer-template: off
require-yield: off
rest-spread-spacing: off
sort-imports: off
symbol-description: off
template-curly-spacing: off
yield-star-spacing: off

44
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,44 @@
<!--
Thanks for opening an issue on Portainer !
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
If you are reporting a new issue, make sure that we do not have any duplicates
already open. You can ensure this by searching the issue list for this
repository. If there is a duplicate, please close your issue and add a comment
to the existing issue instead.
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
If you suspect your issue is a bug, please edit your issue description to
include the BUG REPORT INFORMATION shown below.
---------------------------------------------------
BUG REPORT INFORMATION
---------------------------------------------------
You do NOT have to include this information if this is a FEATURE REQUEST
-->
**Description**
<!--
Briefly describe the problem you are having in a few paragraphs.
-->
**Steps to reproduce the issue:**
1.
2.
3.
Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead?
**Technical details:**
* Portainer version:
* Target Docker version (the host/cluster you manage):
* Platform (windows/linux):
* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`):
* Target Swarm version (if applicable):
* Browser:

8
.gitignore vendored
View File

@@ -1,10 +1,6 @@
logs/*
!.gitkeep
*.esproj/*
node_modules
bower_components
.idea
*.iml
dist
dist/*
portainer-checksum.txt
api/cmd/portainer/portainer*
.tmp

46
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

79
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,79 @@
# Contributing Guidelines
Some basic conventions for contributing to this project.
### General
Please make sure that there aren't existing pull requests attempting to address the issue mentioned. Likewise, please check for issues related to update, as someone else may be working on the issue in a branch or fork.
* Please open a discussion in a new issue / existing issue to talk about the changes you'd like to bring
* Develop in a topic branch, not master/develop
When creating a new branch, prefix it with the *type* of the change (see section **Commit Message Format** below), the associated opened issue number, a dash and some text describing the issue (using dash as a separator).
For example, if you work on a bugfix for the issue #361, you could name the branch `fix361-template-selection`.
### Issues open to contribution
Want to contribute but don't know where to start?
Some of the open issues are labeled with prefix `exp/`, this is used to mark them as available for contributors to work on. All of these have an attributed difficulty level:
* **beginner**: a task that should be accessible with users not familiar with the codebase
* **intermediate**: a task that require some understanding of the project codebase or some experience in
either AngularJS or Golang
* **advanced**: a task that require a deep understanding of the project codebase
You can have a use Github filters to list these issues:
* beginner labeled issues: https://github.com/portainer/portainer/labels/exp%2Fbeginner
* intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate
* advanced labeled issues: https://github.com/portainer/portainer/labels/exp%2Fadvanced
### Linting
Please check your code using `grunt lint` before submitting your pull requests.
### Commit Message Format
Each commit message should include a **type**, a **scope** and a **subject**:
```
<type>(<scope>): <subject>
```
Lines should not exceed 100 characters. This allows the message to be easier to read on github as well as in various git tools and produces a nice, neat commit log ie:
```
#271 feat(containers): add exposed ports in the containers view
#270 fix(templates): fix a display issue in the templates view
#269 style(dashboard): update dashboard with new layout
```
#### Type
Must be one of the following:
* **feat**: A new feature
* **fix**: A bug fix
* **docs**: Documentation only changes
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
semi-colons, etc)
* **refactor**: A code change that neither fixes a bug or adds a feature
* **test**: Adding missing tests
* **chore**: Changes to the build process or auxiliary tools and libraries such as documentation
generation
#### Scope
The scope could be anything specifying place of the commit change. For example `networks`,
`containers`, `images` etc...
You can use the **area** label tag associated on the issue here (for `area/containers` use `containers` as a scope...)
#### Subject
The subject contains succinct description of the change:
* use the imperative, present tense: "change" not "changed" nor "changes"
* don't capitalize first letter
* no dot (.) at the end

34
LICENSE
View File

@@ -1,24 +1,24 @@
Portainer: Copyright (c) 2016 CloudInovasi
Portainer: Copyright (c) 2016 Portainer.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (anthonylapenna at cloudinovasi dot id)
Portainer contains code which was originally under this license:
UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1 +0,0 @@
web: portainer -p ":$PORT" -e "$DOCKER_ENDPOINT"

180
README.md
View File

@@ -1,165 +1,55 @@
# Portainer
[![Microbadger](https://images.microbadger.com/badges/image/cloudinovasi/portainer.svg)](http://microbadger.com/images/cloudinovasi/portainer "Image size")
<p align="center">
<img title="portainer" src='https://portainer.io/images/logo_alt.png' />
</p>
[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/)
[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size")
[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable)
[![Codefresh build status]( https://g.codefresh.io/api/badges/build?repoOwner=portainer&repoName=portainer&branch=develop&pipelineName=portainer-ci&accountName=deviantony&type=cf-1)]( https://g.codefresh.io/repositories/portainer/portainer/builds?filter=trigger:build;branch:develop;service:5922a08a3a1aab000116fcc6~portainer-ci)
[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer)
[![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/)
[![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)
Portainer is a web interface for the Docker remote API.
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
![Dashboard](/dashboard.png)
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container).
## Supported Docker versions
**_Portainer_** allows you to manage your Docker containers, images, volumes, networks and more ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*.
The following Docker versions are supported:
## Demo
* full support for Docker 1.10, 1.11 and 1.12
* partial support for Docker 1.9 (some features won't be available)
<img src="https://portainer.io/images/screenshots/portainer.gif" width="77%"/>
## Run
You can try out the public demo instance: http://demo.portainer.io/ (login with the username **admin** and the password **tryportainer**).
### Quickstart
Please note that the public demo cluster is **reset every 15min**.
1. Run: `docker run -d -p 9000:9000 --privileged -v /var/run/docker.sock:/var/run/docker.sock cloudinovasi/portainer`
## Getting started
2. Open your browser to `http://<dockerd host ip>:9000`
* [Deploy Portainer](https://portainer.readthedocs.io/en/latest/deployment.html)
* [Documentation](https://portainer.readthedocs.io)
Bind mounting the Unix socket into the Portainer container is much more secure than exposing your docker daemon over TCP.
## Getting help
The `--privileged` flag is required for hosts using SELinux.
* Issues: https://github.com/portainer/portainer/issues
* FAQ: https://portainer.readthedocs.io/en/latest/faq.html
* Slack (chat): https://portainer.io/slack/
* Gitter (chat): https://gitter.im/portainer/Lobby
### Specify socket to connect to Docker daemon
## Reporting bugs and contributing
By default Portainer connects to the Docker daemon with`/var/run/docker.sock`. For this to work you need to bind mount the unix socket into the container with `-v /var/run/docker.sock:/var/run/docker.sock`.
* Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
* Want to help us build **_portainer_**? Follow our [contribution guidelines](https://portainer.readthedocs.io/en/latest/contribute.html) to build it locally and make a pull request. We need all the help we can get!
You can use the `--host`, `-H` flags to change this socket:
## Limitations
```
# Connect to a tcp socket:
$ docker run -d -p 9000:9000 cloudinovasi/portainer -H tcp://127.0.0.1:2375
```
**_Portainer_** has full support for the following Docker versions:
```
# Connect to another unix socket:
$ docker run -d -p 9000:9000 cloudinovasi/portainer -H unix:///path/to/docker.sock
```
* Docker 1.10 to the latest version
* Docker Swarm >= 1.2.3
### Swarm support
Partial support for the following Docker versions (some features may not be available):
**Supported Swarm version: 1.2.3**
You can access a specific view for you Swarm cluster by defining the `--swarm` flag:
```
# Connect to a tcp socket and enable Swarm:
$ docker run -d -p 9000:9000 cloudinovasi/portainer -H tcp://<SWARM_HOST>:<SWARM_PORT> --swarm
```
*NOTE*: Due to Swarm not exposing information in a machine readable way, the app is bound to a specific version of Swarm at the moment.
### Change address/port Portainer is served on
Portainer listens on port 9000 by default. If you run Portainer inside a container then you can bind the container's internal port to any external address and port:
```
# Expose Portainer on 10.20.30.1:80
$ docker run -d -p 10.20.30.1:80:9000 --privileged -v /var/run/docker.sock:/var/run/docker.sock cloudinovasi/portainer
```
### Access a Docker engine protected via TLS
Ensure that you have access to the CA, the cert and the public key used to access your Docker engine.
These files will need to be named `ca.pem`, `cert.pem` and `key.pem` respectively. Store them somewhere on your disk and mount a volume containing these files inside the UI container:
```
$ docker run -d -p 9000:9000 cloudinovasi/portainer -v /path/to/certs:/certs -H https://my-docker-host.domain:2376 --tlsverify
```
You can also use the `--tlscacert`, `--tlscert` and `--tlskey` flags if you want to change the default path to the CA, certificate and key file respectively:
```
$ docker run -d -p 9000:9000 cloudinovasi/portainer -v /path/to/certs:/certs -H https://my-docker-host.domain:2376 --tlsverify --tlscacert /certs/myCa.pem --tlscert /certs/myCert.pem --tlskey /certs/myKey.pem
```
*Note*: Replace `/path/to/certs` to the path to the certificate files on your disk.
### Use your own logo
You can use the `--logo` flag to specify an URL to your own logo.
For example, using the Docker logo:
```
$ docker run -d -p 9000:9000 --privileged -v /var/run/docker.sock:/var/run/docker.sock cloudinovasi/portainer --logo "https://www.docker.com/sites/all/themes/docker/assets/images/brand-full.svg"
```
The custom logo will replace the Portainer logo in the UI.
### Hide containers with specific labels
You can hide specific containers in the containers view by using the `--hide-label` or `-l` options and specifying a label.
For example, take a container started with the label `owner=acme`:
```
$ docker run -d --label owner=acme nginx
```
You can hide it in the view by starting the ui with:
```
$ docker run -d -p 9000:9000 --privileged -v /var/run/docker.sock:/var/run/docker.sock cloudinovasi/portainer -l owner=acme
```
### Reverse proxy configuration
Has been tested with Nginx 1.11.
Use the following configuration to host the UI at `myhost.mydomain.com/portainer`:
```nginx
upstream portainer {
server ADDRESS:PORT;
}
server {
listen 80;
location /portainer/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://portainer/;
}
location /portainer/ws/ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_pass http://portainer/ws/;
}
}
```
Replace `ADDRESS:PORT` with the Portainer container details.
### Host your own apps
You can specify an URL to your own templates (**Apps**) definitions using the `--templates` or `-t` flags.
By default, CloudInovasi templates will be used (https://raw.githubusercontent.com/cloud-inovasi/ui-templates/master/templates.json).
For more information about hosting your own template definition and the format, see: https://github.com/cloud-inovasi/ui-templates
### Available options
The following options are available for the `portainer` binary:
* `--host`, `-H`: Docker daemon endpoint (default: `"unix:///var/run/docker.sock"`)
* `--bind`, `-p`: Address and port to serve Portainer (default: `":9000"`)
* `--data`, `-d`: Path to the data folder (default: `"."`)
* `--assets`, `-a`: Path to the assets (default: `"."`)
* `--swarm`, `-s`: Swarm cluster support (default: `false`)
* `--tlsverify`: TLS support (default: `false`)
* `--tlscacert`: Path to the CA (default `/certs/ca.pem`)
* `--tlscert`: Path to the TLS certificate file (default `/certs/cert.pem`)
* `--tlskey`: Path to the TLS key (default `/certs/key.pem`)
* `--hide-label`, `-l`: Hide containers with a specific label in the UI
* `--logo`: URL to a picture to be displayed as a logo in the UI
* `--templates`, `-t`: URL to templates (apps) definitions
* Docker 1.9

View File

@@ -1,60 +0,0 @@
package main
import (
"crypto/tls"
"log"
"net/http"
"net/url"
)
type (
api struct {
endpoint *url.URL
bindAddress string
assetPath string
dataPath string
tlsConfig *tls.Config
templatesURL string
}
apiConfig struct {
Endpoint string
BindAddress string
AssetPath string
DataPath string
SwarmSupport bool
TLSEnabled bool
TLSCACertPath string
TLSCertPath string
TLSKeyPath string
TemplatesURL string
}
)
func (a *api) run(settings *Settings) {
handler := a.newHandler(settings)
if err := http.ListenAndServe(a.bindAddress, handler); err != nil {
log.Fatal(err)
}
}
func newAPI(apiConfig apiConfig) *api {
endpointURL, err := url.Parse(apiConfig.Endpoint)
if err != nil {
log.Fatal(err)
}
var tlsConfig *tls.Config
if apiConfig.TLSEnabled {
tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath)
}
return &api{
endpoint: endpointURL,
bindAddress: apiConfig.BindAddress,
assetPath: apiConfig.AssetPath,
dataPath: apiConfig.DataPath,
tlsConfig: tlsConfig,
templatesURL: apiConfig.TemplatesURL,
}
}

144
api/bolt/datastore.go Normal file
View File

@@ -0,0 +1,144 @@
package bolt
import (
"log"
"os"
"time"
"github.com/boltdb/bolt"
"github.com/portainer/portainer"
)
// Store defines the implementation of portainer.DataStore using
// BoltDB as the storage system.
type Store struct {
// Path where is stored the BoltDB database.
Path string
// Services
UserService *UserService
TeamService *TeamService
TeamMembershipService *TeamMembershipService
EndpointService *EndpointService
ResourceControlService *ResourceControlService
VersionService *VersionService
SettingsService *SettingsService
RegistryService *RegistryService
DockerHubService *DockerHubService
db *bolt.DB
checkForDataMigration bool
}
const (
databaseFileName = "portainer.db"
versionBucketName = "version"
userBucketName = "users"
teamBucketName = "teams"
teamMembershipBucketName = "team_membership"
endpointBucketName = "endpoints"
resourceControlBucketName = "resource_control"
settingsBucketName = "settings"
registryBucketName = "registries"
dockerhubBucketName = "dockerhub"
)
// NewStore initializes a new Store and the associated services
func NewStore(storePath string) (*Store, error) {
store := &Store{
Path: storePath,
UserService: &UserService{},
TeamService: &TeamService{},
TeamMembershipService: &TeamMembershipService{},
EndpointService: &EndpointService{},
ResourceControlService: &ResourceControlService{},
VersionService: &VersionService{},
SettingsService: &SettingsService{},
RegistryService: &RegistryService{},
DockerHubService: &DockerHubService{},
}
store.UserService.store = store
store.TeamService.store = store
store.TeamMembershipService.store = store
store.EndpointService.store = store
store.ResourceControlService.store = store
store.VersionService.store = store
store.SettingsService.store = store
store.RegistryService.store = store
store.DockerHubService.store = store
_, err := os.Stat(storePath + "/" + databaseFileName)
if err != nil && os.IsNotExist(err) {
store.checkForDataMigration = false
} else if err != nil {
return nil, err
} else {
store.checkForDataMigration = true
}
return store, nil
}
// Open opens and initializes the BoltDB database.
func (store *Store) Open() error {
path := store.Path + "/" + databaseFileName
db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return err
}
store.db = db
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName}
return db.Update(func(tx *bolt.Tx) error {
for _, bucket := range bucketsToCreate {
_, err := tx.CreateBucketIfNotExists([]byte(bucket))
if err != nil {
return err
}
}
return nil
})
}
// Close closes the BoltDB database.
func (store *Store) Close() error {
if store.db != nil {
return store.db.Close()
}
return nil
}
// MigrateData automatically migrate the data based on the DBVersion.
func (store *Store) MigrateData() error {
if !store.checkForDataMigration {
err := store.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err
}
return nil
}
version, err := store.VersionService.DBVersion()
if err == portainer.ErrDBVersionNotFound {
version = 0
} else if err != nil {
return err
}
if version < portainer.DBVersion {
log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion)
migrator := NewMigrator(store, version)
err = migrator.Migrate()
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,61 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// DockerHubService represents a service for managing registries.
type DockerHubService struct {
store *Store
}
const (
dbDockerHubKey = "DOCKERHUB"
)
// DockerHub returns the DockerHub object.
func (service *DockerHubService) DockerHub() (*portainer.DockerHub, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(dockerhubBucketName))
value := bucket.Get([]byte(dbDockerHubKey))
if value == nil {
return portainer.ErrDockerHubNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var dockerhub portainer.DockerHub
err = internal.UnmarshalDockerHub(data, &dockerhub)
if err != nil {
return nil, err
}
return &dockerhub, nil
}
// StoreDockerHub persists a DockerHub object.
func (service *DockerHubService) StoreDockerHub(dockerhub *portainer.DockerHub) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(dockerhubBucketName))
data, err := internal.MarshalDockerHub(dockerhub)
if err != nil {
return err
}
err = bucket.Put([]byte(dbDockerHubKey), data)
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,154 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// EndpointService represents a service for managing endpoints.
type EndpointService struct {
store *Store
}
// Endpoint returns an endpoint by ID.
func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrEndpointNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var endpoint portainer.Endpoint
err = internal.UnmarshalEndpoint(data, &endpoint)
if err != nil {
return nil, err
}
return &endpoint, nil
}
// Endpoints return an array containing all the endpoints.
func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var endpoint portainer.Endpoint
err := internal.UnmarshalEndpoint(v, &endpoint)
if err != nil {
return err
}
endpoints = append(endpoints, endpoint)
}
return nil
})
if err != nil {
return nil, err
}
return endpoints, nil
}
// Synchronize creates, updates and deletes endpoints inside a single transaction.
func (service *EndpointService) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
for _, endpoint := range toCreate {
err := storeNewEndpoint(endpoint, bucket)
if err != nil {
return err
}
}
for _, endpoint := range toUpdate {
err := marshalAndStoreEndpoint(endpoint, bucket)
if err != nil {
return err
}
}
for _, endpoint := range toDelete {
err := bucket.Delete(internal.Itob(int(endpoint.ID)))
if err != nil {
return err
}
}
return nil
})
}
// CreateEndpoint assign an ID to a new endpoint and saves it.
func (service *EndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
err := storeNewEndpoint(endpoint, bucket)
if err != nil {
return err
}
return nil
})
}
// UpdateEndpoint updates an endpoint.
func (service *EndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error {
data, err := internal.MarshalEndpoint(endpoint)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteEndpoint deletes an endpoint.
func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}
func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error {
data, err := internal.MarshalEndpoint(endpoint)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(endpoint.ID)), data)
if err != nil {
return err
}
return nil
}
func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error {
id, _ := bucket.NextSequence()
endpoint.ID = portainer.EndpointID(id)
return marshalAndStoreEndpoint(endpoint, bucket)
}

View File

@@ -0,0 +1,97 @@
package internal
import (
"github.com/portainer/portainer"
"encoding/binary"
"encoding/json"
)
// MarshalUser encodes a user to binary format.
func MarshalUser(user *portainer.User) ([]byte, error) {
return json.Marshal(user)
}
// UnmarshalUser decodes a user from a binary data.
func UnmarshalUser(data []byte, user *portainer.User) error {
return json.Unmarshal(data, user)
}
// MarshalTeam encodes a team to binary format.
func MarshalTeam(team *portainer.Team) ([]byte, error) {
return json.Marshal(team)
}
// UnmarshalTeam decodes a team from a binary data.
func UnmarshalTeam(data []byte, team *portainer.Team) error {
return json.Unmarshal(data, team)
}
// MarshalTeamMembership encodes a team membership to binary format.
func MarshalTeamMembership(membership *portainer.TeamMembership) ([]byte, error) {
return json.Marshal(membership)
}
// UnmarshalTeamMembership decodes a team membership from a binary data.
func UnmarshalTeamMembership(data []byte, membership *portainer.TeamMembership) error {
return json.Unmarshal(data, membership)
}
// MarshalEndpoint encodes an endpoint to binary format.
func MarshalEndpoint(endpoint *portainer.Endpoint) ([]byte, error) {
return json.Marshal(endpoint)
}
// UnmarshalEndpoint decodes an endpoint from a binary data.
func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint)
}
// MarshalRegistry encodes a registry to binary format.
func MarshalRegistry(registry *portainer.Registry) ([]byte, error) {
return json.Marshal(registry)
}
// UnmarshalRegistry decodes a registry from a binary data.
func UnmarshalRegistry(data []byte, registry *portainer.Registry) error {
return json.Unmarshal(data, registry)
}
// MarshalResourceControl encodes a resource control object to binary format.
func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) {
return json.Marshal(rc)
}
// UnmarshalResourceControl decodes a resource control object from a binary data.
func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error {
return json.Unmarshal(data, rc)
}
// MarshalSettings encodes a settings object to binary format.
func MarshalSettings(settings *portainer.Settings) ([]byte, error) {
return json.Marshal(settings)
}
// UnmarshalSettings decodes a settings object from a binary data.
func UnmarshalSettings(data []byte, settings *portainer.Settings) error {
return json.Unmarshal(data, settings)
}
// MarshalDockerHub encodes a Dockerhub object to binary format.
func MarshalDockerHub(settings *portainer.DockerHub) ([]byte, error) {
return json.Marshal(settings)
}
// UnmarshalDockerHub decodes a Dockerhub object from a binary data.
func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error {
return json.Unmarshal(data, settings)
}
// Itob returns an 8-byte big endian representation of v.
// This function is typically used for encoding integer IDs to byte slices
// so that they can be used as BoltDB keys.
func Itob(v int) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(v))
return b
}

View File

@@ -0,0 +1,39 @@
package bolt
import (
"github.com/boltdb/bolt"
"github.com/portainer/portainer"
)
func (m *Migrator) updateAdminUserToDBVersion1() error {
u, err := m.UserService.UserByUsername("admin")
if err == nil {
admin := &portainer.User{
Username: "admin",
Password: u.Password,
Role: portainer.AdministratorRole,
}
err = m.UserService.CreateUser(admin)
if err != nil {
return err
}
err = m.removeLegacyAdminUser()
if err != nil {
return err
}
} else if err != nil && err != portainer.ErrUserNotFound {
return err
}
return nil
}
func (m *Migrator) removeLegacyAdminUser() error {
return m.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
err := bucket.Delete([]byte("admin"))
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,103 @@
package bolt
import (
"github.com/boltdb/bolt"
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
)
func (m *Migrator) updateResourceControlsToDBVersion2() error {
legacyResourceControls, err := m.retrieveLegacyResourceControls()
if err != nil {
return err
}
for _, resourceControl := range legacyResourceControls {
resourceControl.SubResourceIDs = []string{}
resourceControl.TeamAccesses = []portainer.TeamResourceAccess{}
owner, err := m.UserService.User(resourceControl.OwnerID)
if err != nil {
return err
}
if owner.Role == portainer.AdministratorRole {
resourceControl.AdministratorsOnly = true
resourceControl.UserAccesses = []portainer.UserResourceAccess{}
} else {
resourceControl.AdministratorsOnly = false
userAccess := portainer.UserResourceAccess{
UserID: resourceControl.OwnerID,
AccessLevel: portainer.ReadWriteAccessLevel,
}
resourceControl.UserAccesses = []portainer.UserResourceAccess{userAccess}
}
err = m.ResourceControlService.CreateResourceControl(&resourceControl)
if err != nil {
return err
}
}
return nil
}
func (m *Migrator) updateEndpointsToDBVersion2() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.AuthorizedTeams = []portainer.TeamID{}
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl, error) {
legacyResourceControls := make([]portainer.ResourceControl, 0)
err := m.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte("containerResourceControl"))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var resourceControl portainer.ResourceControl
err := internal.UnmarshalResourceControl(v, &resourceControl)
if err != nil {
return err
}
resourceControl.Type = portainer.ContainerResourceControl
legacyResourceControls = append(legacyResourceControls, resourceControl)
}
bucket = tx.Bucket([]byte("serviceResourceControl"))
cursor = bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var resourceControl portainer.ResourceControl
err := internal.UnmarshalResourceControl(v, &resourceControl)
if err != nil {
return err
}
resourceControl.Type = portainer.ServiceResourceControl
legacyResourceControls = append(legacyResourceControls, resourceControl)
}
bucket = tx.Bucket([]byte("volumeResourceControl"))
cursor = bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var resourceControl portainer.ResourceControl
err := internal.UnmarshalResourceControl(v, &resourceControl)
if err != nil {
return err
}
resourceControl.Type = portainer.VolumeResourceControl
legacyResourceControls = append(legacyResourceControls, resourceControl)
}
return nil
})
return legacyResourceControls, err
}

View File

@@ -0,0 +1,25 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateSettingsToDBVersion3() error {
legacySettings, err := m.SettingsService.Settings()
if err != nil {
return err
}
legacySettings.AuthenticationMethod = portainer.AuthenticationInternal
legacySettings.LDAPSettings = portainer.LDAPSettings{
TLSConfig: portainer.TLSConfiguration{},
SearchSettings: []portainer.LDAPSearchSettings{
portainer.LDAPSearchSettings{},
},
}
err = m.SettingsService.StoreSettings(legacySettings)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,27 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToDBVersion4() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.TLSConfig = portainer.TLSConfiguration{}
if endpoint.TLS {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = false
endpoint.TLSConfig.TLSCACertPath = endpoint.TLSCACertPath
endpoint.TLSConfig.TLSCertPath = endpoint.TLSCertPath
endpoint.TLSConfig.TLSKeyPath = endpoint.TLSKeyPath
}
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}

73
api/bolt/migrator.go Normal file
View File

@@ -0,0 +1,73 @@
package bolt
import "github.com/portainer/portainer"
// Migrator defines a service to migrate data after a Portainer version update.
type Migrator struct {
UserService *UserService
EndpointService *EndpointService
ResourceControlService *ResourceControlService
SettingsService *SettingsService
VersionService *VersionService
CurrentDBVersion int
store *Store
}
// NewMigrator creates a new Migrator.
func NewMigrator(store *Store, version int) *Migrator {
return &Migrator{
UserService: store.UserService,
EndpointService: store.EndpointService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
VersionService: store.VersionService,
CurrentDBVersion: version,
store: store,
}
}
// Migrate checks the database version and migrate the existing data to the most recent data model.
func (m *Migrator) Migrate() error {
// Portainer < 1.12
if m.CurrentDBVersion < 1 {
err := m.updateAdminUserToDBVersion1()
if err != nil {
return err
}
}
// Portainer 1.12.x
if m.CurrentDBVersion < 2 {
err := m.updateResourceControlsToDBVersion2()
if err != nil {
return err
}
err = m.updateEndpointsToDBVersion2()
if err != nil {
return err
}
}
// Portainer 1.13.x
if m.CurrentDBVersion < 3 {
err := m.updateSettingsToDBVersion3()
if err != nil {
return err
}
}
// Portainer 1.14.0
if m.CurrentDBVersion < 4 {
err := m.updateEndpointsToDBVersion4()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,114 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// RegistryService represents a service for managing registries.
type RegistryService struct {
store *Store
}
// Registry returns an registry by ID.
func (service *RegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(registryBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrRegistryNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var registry portainer.Registry
err = internal.UnmarshalRegistry(data, &registry)
if err != nil {
return nil, err
}
return &registry, nil
}
// Registries returns an array containing all the registries.
func (service *RegistryService) Registries() ([]portainer.Registry, error) {
var registries = make([]portainer.Registry, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(registryBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var registry portainer.Registry
err := internal.UnmarshalRegistry(v, &registry)
if err != nil {
return err
}
registries = append(registries, registry)
}
return nil
})
if err != nil {
return nil, err
}
return registries, nil
}
// CreateRegistry creates a new registry.
func (service *RegistryService) CreateRegistry(registry *portainer.Registry) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(registryBucketName))
id, _ := bucket.NextSequence()
registry.ID = portainer.RegistryID(id)
data, err := internal.MarshalRegistry(registry)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(registry.ID)), data)
if err != nil {
return err
}
return nil
})
}
// UpdateRegistry updates an registry.
func (service *RegistryService) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error {
data, err := internal.MarshalRegistry(registry)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(registryBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteRegistry deletes an registry.
func (service *RegistryService) DeleteRegistry(ID portainer.RegistryID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(registryBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,148 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// ResourceControlService represents a service for managing resource controls.
type ResourceControlService struct {
store *Store
}
// ResourceControl returns a ResourceControl object by ID
func (service *ResourceControlService) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(resourceControlBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrResourceControlNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var resourceControl portainer.ResourceControl
err = internal.UnmarshalResourceControl(data, &resourceControl)
if err != nil {
return nil, err
}
return &resourceControl, nil
}
// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal
// to the main ResourceID or in SubResourceIDs
func (service *ResourceControlService) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) {
var resourceControl *portainer.ResourceControl
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(resourceControlBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var rc portainer.ResourceControl
err := internal.UnmarshalResourceControl(v, &rc)
if err != nil {
return err
}
if rc.ResourceID == resourceID {
resourceControl = &rc
}
for _, subResourceID := range rc.SubResourceIDs {
if subResourceID == resourceID {
resourceControl = &rc
}
}
}
if resourceControl == nil {
return portainer.ErrResourceControlNotFound
}
return nil
})
if err != nil {
return nil, err
}
return resourceControl, nil
}
// ResourceControls returns all the ResourceControl objects
func (service *ResourceControlService) ResourceControls() ([]portainer.ResourceControl, error) {
var rcs = make([]portainer.ResourceControl, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(resourceControlBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var resourceControl portainer.ResourceControl
err := internal.UnmarshalResourceControl(v, &resourceControl)
if err != nil {
return err
}
rcs = append(rcs, resourceControl)
}
return nil
})
if err != nil {
return nil, err
}
return rcs, nil
}
// CreateResourceControl creates a new ResourceControl object
func (service *ResourceControlService) CreateResourceControl(resourceControl *portainer.ResourceControl) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(resourceControlBucketName))
id, _ := bucket.NextSequence()
resourceControl.ID = portainer.ResourceControlID(id)
data, err := internal.MarshalResourceControl(resourceControl)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(resourceControl.ID)), data)
if err != nil {
return err
}
return nil
})
}
// UpdateResourceControl saves a ResourceControl object.
func (service *ResourceControlService) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error {
data, err := internal.MarshalResourceControl(resourceControl)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(resourceControlBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteResourceControl deletes a ResourceControl object by ID
func (service *ResourceControlService) DeleteResourceControl(ID portainer.ResourceControlID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(resourceControlBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,61 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// SettingsService represents a service to manage application settings.
type SettingsService struct {
store *Store
}
const (
dbSettingsKey = "SETTINGS"
)
// Settings retrieve the settings object.
func (service *SettingsService) Settings() (*portainer.Settings, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(settingsBucketName))
value := bucket.Get([]byte(dbSettingsKey))
if value == nil {
return portainer.ErrSettingsNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var settings portainer.Settings
err = internal.UnmarshalSettings(data, &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// StoreSettings persists a Settings object.
func (service *SettingsService) StoreSettings(settings *portainer.Settings) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(settingsBucketName))
data, err := internal.MarshalSettings(settings)
if err != nil {
return err
}
err = bucket.Put([]byte(dbSettingsKey), data)
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,217 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// TeamMembershipService represents a service for managing TeamMembership objects.
type TeamMembershipService struct {
store *Store
}
// TeamMembership returns a TeamMembership object by ID
func (service *TeamMembershipService) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrTeamMembershipNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var membership portainer.TeamMembership
err = internal.UnmarshalTeamMembership(data, &membership)
if err != nil {
return nil, err
}
return &membership, nil
}
// TeamMemberships return an array containing all the TeamMembership objects.
func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembership, error) {
var memberships = make([]portainer.TeamMembership, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var membership portainer.TeamMembership
err := internal.UnmarshalTeamMembership(v, &membership)
if err != nil {
return err
}
memberships = append(memberships, membership)
}
return nil
})
if err != nil {
return nil, err
}
return memberships, nil
}
// TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present.
func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) {
var memberships = make([]portainer.TeamMembership, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var membership portainer.TeamMembership
err := internal.UnmarshalTeamMembership(v, &membership)
if err != nil {
return err
}
if membership.UserID == userID {
memberships = append(memberships, membership)
}
}
return nil
})
if err != nil {
return nil, err
}
return memberships, nil
}
// TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present.
func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) {
var memberships = make([]portainer.TeamMembership, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var membership portainer.TeamMembership
err := internal.UnmarshalTeamMembership(v, &membership)
if err != nil {
return err
}
if membership.TeamID == teamID {
memberships = append(memberships, membership)
}
}
return nil
})
if err != nil {
return nil, err
}
return memberships, nil
}
// UpdateTeamMembership saves a TeamMembership object.
func (service *TeamMembershipService) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error {
data, err := internal.MarshalTeamMembership(membership)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// CreateTeamMembership creates a new TeamMembership object.
func (service *TeamMembershipService) CreateTeamMembership(membership *portainer.TeamMembership) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
id, _ := bucket.NextSequence()
membership.ID = portainer.TeamMembershipID(id)
data, err := internal.MarshalTeamMembership(membership)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(membership.ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteTeamMembership deletes a TeamMembership object.
func (service *TeamMembershipService) DeleteTeamMembership(ID portainer.TeamMembershipID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}
// DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID.
func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portainer.UserID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var membership portainer.TeamMembership
err := internal.UnmarshalTeamMembership(v, &membership)
if err != nil {
return err
}
if membership.UserID == userID {
err := bucket.Delete(internal.Itob(int(membership.ID)))
if err != nil {
return err
}
}
}
return nil
})
}
// DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID.
func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamMembershipBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var membership portainer.TeamMembership
err := internal.UnmarshalTeamMembership(v, &membership)
if err != nil {
return err
}
if membership.TeamID == teamID {
err := bucket.Delete(internal.Itob(int(membership.ID)))
if err != nil {
return err
}
}
}
return nil
})
}

144
api/bolt/team_service.go Normal file
View File

@@ -0,0 +1,144 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// TeamService represents a service for managing teams.
type TeamService struct {
store *Store
}
// Team returns a Team by ID
func (service *TeamService) Team(ID portainer.TeamID) (*portainer.Team, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrTeamNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var team portainer.Team
err = internal.UnmarshalTeam(data, &team)
if err != nil {
return nil, err
}
return &team, nil
}
// TeamByName returns a team by name.
func (service *TeamService) TeamByName(name string) (*portainer.Team, error) {
var team *portainer.Team
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var t portainer.Team
err := internal.UnmarshalTeam(v, &t)
if err != nil {
return err
}
if t.Name == name {
team = &t
}
}
if team == nil {
return portainer.ErrTeamNotFound
}
return nil
})
if err != nil {
return nil, err
}
return team, nil
}
// Teams return an array containing all the teams.
func (service *TeamService) Teams() ([]portainer.Team, error) {
var teams = make([]portainer.Team, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var team portainer.Team
err := internal.UnmarshalTeam(v, &team)
if err != nil {
return err
}
teams = append(teams, team)
}
return nil
})
if err != nil {
return nil, err
}
return teams, nil
}
// UpdateTeam saves a Team.
func (service *TeamService) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error {
data, err := internal.MarshalTeam(team)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// CreateTeam creates a new Team.
func (service *TeamService) CreateTeam(team *portainer.Team) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamBucketName))
id, _ := bucket.NextSequence()
team.ID = portainer.TeamID(id)
data, err := internal.MarshalTeam(team)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(team.ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteTeam deletes a Team.
func (service *TeamService) DeleteTeam(ID portainer.TeamID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(teamBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}

170
api/bolt/user_service.go Normal file
View File

@@ -0,0 +1,170 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// UserService represents a service for managing users.
type UserService struct {
store *Store
}
// User returns a user by ID
func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrUserNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var user portainer.User
err = internal.UnmarshalUser(data, &user)
if err != nil {
return nil, err
}
return &user, nil
}
// UserByUsername returns a user by username.
func (service *UserService) UserByUsername(username string) (*portainer.User, error) {
var user *portainer.User
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var u portainer.User
err := internal.UnmarshalUser(v, &u)
if err != nil {
return err
}
if u.Username == username {
user = &u
}
}
if user == nil {
return portainer.ErrUserNotFound
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
// Users return an array containing all the users.
func (service *UserService) Users() ([]portainer.User, error) {
var users = make([]portainer.User, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var user portainer.User
err := internal.UnmarshalUser(v, &user)
if err != nil {
return err
}
users = append(users, user)
}
return nil
})
if err != nil {
return nil, err
}
return users, nil
}
// UsersByRole return an array containing all the users with the specified role.
func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
var users = make([]portainer.User, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var user portainer.User
err := internal.UnmarshalUser(v, &user)
if err != nil {
return err
}
if user.Role == role {
users = append(users, user)
}
}
return nil
})
if err != nil {
return nil, err
}
return users, nil
}
// UpdateUser saves a user.
func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error {
data, err := internal.MarshalUser(user)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// CreateUser creates a new user.
func (service *UserService) CreateUser(user *portainer.User) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
id, _ := bucket.NextSequence()
user.ID = portainer.UserID(id)
data, err := internal.MarshalUser(user)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(user.ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteUser deletes a user.
func (service *UserService) DeleteUser(ID portainer.UserID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(userBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,58 @@
package bolt
import (
"strconv"
"github.com/portainer/portainer"
"github.com/boltdb/bolt"
)
// VersionService represents a service to manage stored versions.
type VersionService struct {
store *Store
}
const (
dBVersionKey = "DB_VERSION"
)
// DBVersion retrieves the stored database version.
func (service *VersionService) DBVersion() (int, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(versionBucketName))
value := bucket.Get([]byte(dBVersionKey))
if value == nil {
return portainer.ErrDBVersionNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return 0, err
}
dbVersion, err := strconv.Atoi(string(data))
if err != nil {
return 0, err
}
return dbVersion, nil
}
// StoreDBVersion store the database version.
func (service *VersionService) StoreDBVersion(version int) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(versionBucketName))
data := []byte(strconv.Itoa(version))
err := bucket.Put([]byte(dBVersionKey), data)
if err != nil {
return err
}
return nil
})
}

140
api/cli/cli.go Normal file
View File

@@ -0,0 +1,140 @@
package cli
import (
"log"
"time"
"github.com/portainer/portainer"
"os"
"strings"
"gopkg.in/alecthomas/kingpin.v2"
)
// Service implements the CLIService interface
type Service struct{}
const (
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password")
)
// ParseFlags parse the CLI flags and return a portainer.Flags struct
func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
kingpin.Version(version)
flags := &portainer.CLIFlags{
Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(),
ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(),
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(),
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
// Deprecated flags
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(),
}
kingpin.Parse()
return flags, nil
}
// ValidateFlags validates the values of the flags.
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
if *flags.Endpoint != "" && *flags.ExternalEndpoints != "" {
return errEndpointExcludeExternal
}
err := validateEndpoint(*flags.Endpoint)
if err != nil {
return err
}
err = validateExternalEndpoints(*flags.ExternalEndpoints)
if err != nil {
return err
}
err = validateSyncInterval(*flags.SyncInterval)
if err != nil {
return err
}
if *flags.NoAuth && (*flags.AdminPassword != "") {
return errNoAuthExcludeAdminPassword
}
displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels)
return nil
}
func validateEndpoint(endpoint string) error {
if endpoint != "" {
if !strings.HasPrefix(endpoint, "unix://") && !strings.HasPrefix(endpoint, "tcp://") {
return errInvalidEndpointProtocol
}
if strings.HasPrefix(endpoint, "unix://") {
socketPath := strings.TrimPrefix(endpoint, "unix://")
if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) {
return errSocketNotFound
}
return err
}
}
}
return nil
}
func validateExternalEndpoints(externalEndpoints string) error {
if externalEndpoints != "" {
if _, err := os.Stat(externalEndpoints); err != nil {
if os.IsNotExist(err) {
return errEndpointsFileNotFound
}
return err
}
}
return nil
}
func validateSyncInterval(syncInterval string) error {
if syncInterval != defaultSyncInterval {
_, err := time.ParseDuration(syncInterval)
if err != nil {
return errInvalidSyncInterval
}
}
return nil
}
func displayDeprecationWarnings(templates, logo string, labels []portainer.Pair) {
if templates != "" {
log.Println("Warning: the --templates / -t flag is deprecated and will be removed in future versions.")
}
if logo != "" {
log.Println("Warning: the --logo flag is deprecated and will be removed in future versions.")
}
if labels != nil {
log.Println("Warning: the --hide-label / -l flag is deprecated and will be removed in future versions.")
}
}

19
api/cli/defaults.go Normal file
View File

@@ -0,0 +1,19 @@
// +build !windows
package cli
const (
defaultBindAddress = ":9000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "."
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultSSL = "false"
defaultSSLCertPath = "/certs/portainer.crt"
defaultSSLKeyPath = "/certs/portainer.key"
defaultSyncInterval = "60s"
)

View File

@@ -0,0 +1,17 @@
package cli
const (
defaultBindAddress = ":9000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "."
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
defaultSSL = "false"
defaultSSLCertPath = "C:\\certs\\portainer.crt"
defaultSSLKeyPath = "C:\\certs\\portainer.key"
defaultSyncInterval = "60s"
)

View File

@@ -1,46 +1,40 @@
package main
package cli
import (
"github.com/portainer/portainer"
"fmt"
"gopkg.in/alecthomas/kingpin.v2"
"strings"
)
// pair defines a key/value pair
type pair struct {
Name string `json:"name"`
Value string `json:"value"`
}
type pairList []portainer.Pair
// pairList defines an array of Label
type pairList []pair
// Set implementation for Labels
// Set implementation for a list of portainer.Pair
func (l *pairList) Set(value string) error {
parts := strings.SplitN(value, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("expected NAME=VALUE got '%s'", value)
}
p := new(pair)
p := new(portainer.Pair)
p.Name = parts[0]
p.Value = parts[1]
*l = append(*l, *p)
return nil
}
// String implementation for Labels
// String implementation for a list of pair
func (l *pairList) String() string {
return ""
}
// IsCumulative implementation for Labels
// IsCumulative implementation for a list of pair
func (l *pairList) IsCumulative() bool {
return true
}
// LabelParser defines a custom parser for Labels flags
func pairs(s kingpin.Settings) (target *[]pair) {
target = new([]pair)
func pairs(s kingpin.Settings) (target *[]portainer.Pair) {
target = new([]portainer.Pair)
s.SetValue((*pairList)(target))
return
}

256
api/cmd/portainer/main.go Normal file
View File

@@ -0,0 +1,256 @@
package main // import "github.com/portainer/portainer"
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt"
"github.com/portainer/portainer/cli"
"github.com/portainer/portainer/cron"
"github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/file"
"github.com/portainer/portainer/http"
"github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap"
"log"
)
func initCLI() *portainer.CLIFlags {
var cli portainer.CLIService = &cli.Service{}
flags, err := cli.ParseFlags(portainer.APIVersion)
if err != nil {
log.Fatal(err)
}
err = cli.ValidateFlags(flags)
if err != nil {
log.Fatal(err)
}
return flags
}
func initFileService(dataStorePath string) portainer.FileService {
fileService, err := file.NewService(dataStorePath, "")
if err != nil {
log.Fatal(err)
}
return fileService
}
func initStore(dataStorePath string) *bolt.Store {
store, err := bolt.NewStore(dataStorePath)
if err != nil {
log.Fatal(err)
}
err = store.Open()
if err != nil {
log.Fatal(err)
}
err = store.MigrateData()
if err != nil {
log.Fatal(err)
}
return store
}
func initJWTService(authenticationEnabled bool) portainer.JWTService {
if authenticationEnabled {
jwtService, err := jwt.NewService()
if err != nil {
log.Fatal(err)
}
return jwtService
}
return nil
}
func initCryptoService() portainer.CryptoService {
return &crypto.Service{}
}
func initLDAPService() portainer.LDAPService {
return &ldap.Service{}
}
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
authorizeEndpointMgmt := true
if externalEnpointFile != "" {
authorizeEndpointMgmt = false
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
endpointWatcher := cron.NewWatcher(endpointService, syncInterval)
err := endpointWatcher.WatchEndpointFile(externalEnpointFile)
if err != nil {
log.Fatal(err)
}
}
return authorizeEndpointMgmt
}
func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Status {
return &portainer.Status{
Analytics: !*flags.NoAnalytics,
Authentication: !*flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
Version: portainer.APIVersion,
}
}
func initDockerHub(dockerHubService portainer.DockerHubService) error {
_, err := dockerHubService.DockerHub()
if err == portainer.ErrDockerHubNotFound {
dockerhub := &portainer.DockerHub{
Authentication: false,
Username: "",
Password: "",
}
return dockerHubService.StoreDockerHub(dockerhub)
} else if err != nil {
return err
}
return nil
}
func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error {
_, err := settingsService.Settings()
if err == portainer.ErrSettingsNotFound {
settings := &portainer.Settings{
LogoURL: *flags.Logo,
DisplayExternalContributors: true,
AuthenticationMethod: portainer.AuthenticationInternal,
LDAPSettings: portainer.LDAPSettings{
TLSConfig: portainer.TLSConfiguration{},
SearchSettings: []portainer.LDAPSearchSettings{
portainer.LDAPSearchSettings{},
},
},
}
if *flags.Templates != "" {
settings.TemplatesURL = *flags.Templates
} else {
settings.TemplatesURL = portainer.DefaultTemplatesURL
}
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
} else {
settings.BlackListedLabels = make([]portainer.Pair, 0)
}
return settingsService.StoreSettings(settings)
} else if err != nil {
return err
}
return nil
}
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
endpoints, err := endpointService.Endpoints()
if err != nil {
log.Fatal(err)
}
return &endpoints[0]
}
func main() {
flags := initCLI()
fileService := initFileService(*flags.Data)
store := initStore(*flags.Data)
defer store.Close()
jwtService := initJWTService(!*flags.NoAuth)
cryptoService := initCryptoService()
ldapService := initLDAPService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags)
if err != nil {
log.Fatal(err)
}
err = initDockerHub(store.DockerHubService)
if err != nil {
log.Fatal(err)
}
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
if *flags.Endpoint != "" {
var endpoints []portainer.Endpoint
endpoints, err := store.EndpointService.Endpoints()
if err != nil {
log.Fatal(err)
}
if len(endpoints) == 0 {
endpoint := &portainer.Endpoint{
Name: "primary",
URL: *flags.Endpoint,
TLSConfig: portainer.TLSConfiguration{
TLS: *flags.TLSVerify,
TLSSkipVerify: false,
TLSCACertPath: *flags.TLSCacert,
TLSCertPath: *flags.TLSCert,
TLSKeyPath: *flags.TLSKey,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = store.EndpointService.CreateEndpoint(endpoint)
if err != nil {
log.Fatal(err)
}
} else {
log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.")
}
}
if *flags.AdminPassword != "" {
log.Printf("Creating admin user with password hash %s", *flags.AdminPassword)
user := &portainer.User{
Username: "admin",
Role: portainer.AdministratorRole,
Password: *flags.AdminPassword,
}
err := store.UserService.CreateUser(user)
if err != nil {
log.Fatal(err)
}
}
var server portainer.Server = &http.Server{
Status: applicationStatus,
BindAddress: *flags.Addr,
AssetsPath: *flags.Assets,
AuthDisabled: *flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
UserService: store.UserService,
TeamService: store.TeamService,
TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
RegistryService: store.RegistryService,
DockerHubService: store.DockerHubService,
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
LDAPService: ldapService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
}
log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr)
err = server.Start()
if err != nil {
log.Fatal(err)
}
}

211
api/cron/endpoint_sync.go Normal file
View File

@@ -0,0 +1,211 @@
package cron
import (
"encoding/json"
"io/ioutil"
"log"
"os"
"strings"
"github.com/portainer/portainer"
)
type (
endpointSyncJob struct {
logger *log.Logger
endpointService portainer.EndpointService
endpointFilePath string
}
synchronization struct {
endpointsToCreate []*portainer.Endpoint
endpointsToUpdate []*portainer.Endpoint
endpointsToDelete []*portainer.Endpoint
}
fileEndpoint struct {
Name string `json:"Name"`
URL string `json:"URL"`
TLS bool `json:"TLS,omitempty"`
TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"`
TLSCACert string `json:"TLSCACert,omitempty"`
TLSCert string `json:"TLSCert,omitempty"`
TLSKey string `json:"TLSKey,omitempty"`
}
)
const (
// ErrEmptyEndpointArray is an error raised when the external endpoint source array is empty.
ErrEmptyEndpointArray = portainer.Error("External endpoint source is empty")
)
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
return endpointSyncJob{
logger: log.New(os.Stderr, "", log.LstdFlags),
endpointService: endpointService,
endpointFilePath: endpointFilePath,
}
}
func endpointSyncError(err error, logger *log.Logger) bool {
if err != nil {
logger.Printf("Endpoint synchronization error: %s", err)
return true
}
return false
}
func isValidEndpoint(endpoint *portainer.Endpoint) bool {
if endpoint.Name != "" && endpoint.URL != "" {
if !strings.HasPrefix(endpoint.URL, "unix://") && !strings.HasPrefix(endpoint.URL, "tcp://") {
return false
}
return true
}
return false
}
func convertFileEndpoints(fileEndpoints []fileEndpoint) []portainer.Endpoint {
convertedEndpoints := make([]portainer.Endpoint, 0)
for _, e := range fileEndpoints {
endpoint := portainer.Endpoint{
Name: e.Name,
URL: e.URL,
TLSConfig: portainer.TLSConfiguration{},
}
if e.TLS {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = e.TLSSkipVerify
endpoint.TLSConfig.TLSCACertPath = e.TLSCACert
endpoint.TLSConfig.TLSCertPath = e.TLSCert
endpoint.TLSConfig.TLSKeyPath = e.TLSKey
}
convertedEndpoints = append(convertedEndpoints, endpoint)
}
return convertedEndpoints
}
func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint) int {
for idx, v := range endpoints {
if endpoint.Name == v.Name && isValidEndpoint(&v) {
return idx
}
}
return -1
}
func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint {
var endpoint *portainer.Endpoint
if original.URL != updated.URL || original.TLSConfig.TLS != updated.TLSConfig.TLS ||
(updated.TLSConfig.TLS && original.TLSConfig.TLSSkipVerify != updated.TLSConfig.TLSSkipVerify) ||
(updated.TLSConfig.TLS && original.TLSConfig.TLSCACertPath != updated.TLSConfig.TLSCACertPath) ||
(updated.TLSConfig.TLS && original.TLSConfig.TLSCertPath != updated.TLSConfig.TLSCertPath) ||
(updated.TLSConfig.TLS && original.TLSConfig.TLSKeyPath != updated.TLSConfig.TLSKeyPath) {
endpoint = original
endpoint.URL = updated.URL
if updated.TLSConfig.TLS {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = updated.TLSConfig.TLSSkipVerify
endpoint.TLSConfig.TLSCACertPath = updated.TLSConfig.TLSCACertPath
endpoint.TLSConfig.TLSCertPath = updated.TLSConfig.TLSCertPath
endpoint.TLSConfig.TLSKeyPath = updated.TLSConfig.TLSKeyPath
} else {
endpoint.TLSConfig.TLS = false
endpoint.TLSConfig.TLSSkipVerify = false
endpoint.TLSConfig.TLSCACertPath = ""
endpoint.TLSConfig.TLSCertPath = ""
endpoint.TLSConfig.TLSKeyPath = ""
}
}
return endpoint
}
func (sync synchronization) requireSync() bool {
if len(sync.endpointsToCreate) != 0 || len(sync.endpointsToUpdate) != 0 || len(sync.endpointsToDelete) != 0 {
return true
}
return false
}
// TMP: endpointSyncJob method to access logger, should be generic
func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization {
endpointsToCreate := make([]*portainer.Endpoint, 0)
endpointsToUpdate := make([]*portainer.Endpoint, 0)
endpointsToDelete := make([]*portainer.Endpoint, 0)
for idx := range storedEndpoints {
fidx := endpointExists(&storedEndpoints[idx], fileEndpoints)
if fidx != -1 {
endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx])
if endpoint != nil {
job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
endpointsToUpdate = append(endpointsToUpdate, endpoint)
} else {
job.logger.Printf("No change detected for a stored endpoint. [name: %v] [url: %v]\n", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
}
} else {
job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx])
}
}
for idx, endpoint := range fileEndpoints {
if !isValidEndpoint(&endpoint) {
job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
continue
}
sidx := endpointExists(&fileEndpoints[idx], storedEndpoints)
if sidx == -1 {
job.logger.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL)
endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx])
}
}
return &synchronization{
endpointsToCreate: endpointsToCreate,
endpointsToUpdate: endpointsToUpdate,
endpointsToDelete: endpointsToDelete,
}
}
func (job endpointSyncJob) Sync() error {
data, err := ioutil.ReadFile(job.endpointFilePath)
if endpointSyncError(err, job.logger) {
return err
}
var fileEndpoints []fileEndpoint
err = json.Unmarshal(data, &fileEndpoints)
if endpointSyncError(err, job.logger) {
return err
}
if len(fileEndpoints) == 0 {
return ErrEmptyEndpointArray
}
storedEndpoints, err := job.endpointService.Endpoints()
if endpointSyncError(err, job.logger) {
return err
}
convertedFileEndpoints := convertFileEndpoints(fileEndpoints)
sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints)
if sync.requireSync() {
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
if endpointSyncError(err, job.logger) {
return err
}
job.logger.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
}
return nil
}
func (job endpointSyncJob) Run() {
job.logger.Println("Endpoint synchronization job started.")
err := job.Sync()
endpointSyncError(err, job.logger)
}

40
api/cron/watcher.go Normal file
View File

@@ -0,0 +1,40 @@
package cron
import (
"github.com/portainer/portainer"
"github.com/robfig/cron"
)
// Watcher represents a service for managing crons.
type Watcher struct {
Cron *cron.Cron
EndpointService portainer.EndpointService
syncInterval string
}
// NewWatcher initializes a new service.
func NewWatcher(endpointService portainer.EndpointService, syncInterval string) *Watcher {
return &Watcher{
Cron: cron.New(),
EndpointService: endpointService,
syncInterval: syncInterval,
}
}
// WatchEndpointFile starts a cron job to synchronize the endpoints from a file
func (watcher *Watcher) WatchEndpointFile(endpointFilePath string) error {
job := newEndpointSyncJob(endpointFilePath, watcher.EndpointService)
err := job.Sync()
if err != nil {
return err
}
err = watcher.Cron.AddJob("@every "+watcher.syncInterval, job)
if err != nil {
return err
}
watcher.Cron.Start()
return nil
}

22
api/crypto/crypto.go Normal file
View File

@@ -0,0 +1,22 @@
package crypto
import (
"golang.org/x/crypto/bcrypt"
)
// Service represents a service for encrypting/hashing data.
type Service struct{}
// Hash hashes a string using the bcrypt algorithm
func (*Service) Hash(data string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
if err != nil {
return "", nil
}
return string(hash), nil
}
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.
func (*Service) CompareHashAndData(hash string, data string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data))
}

41
api/crypto/tls.go Normal file
View File

@@ -0,0 +1,41 @@
package crypto
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"github.com/portainer/portainer"
)
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
TLSConfig := &tls.Config{}
if config.TLS {
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
if err != nil {
return nil, err
}
TLSConfig.Certificates = []tls.Certificate{cert}
}
if !config.TLSSkipVerify {
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
TLSConfig.RootCAs = caCertPool
}
TLSConfig.InsecureSkipVerify = config.TLSSkipVerify
}
return TLSConfig, nil
}

View File

@@ -1,48 +0,0 @@
package main
import (
"github.com/gorilla/csrf"
"github.com/gorilla/securecookie"
"io/ioutil"
"log"
"net/http"
)
const keyFile = "authKey.dat"
// newAuthKey reuses an existing CSRF authkey if present or generates a new one
func newAuthKey(path string) []byte {
var authKey []byte
authKeyPath := path + "/" + keyFile
data, err := ioutil.ReadFile(authKeyPath)
if err != nil {
log.Print("Unable to find an existing CSRF auth key. Generating a new key.")
authKey = securecookie.GenerateRandomKey(32)
err := ioutil.WriteFile(authKeyPath, authKey, 0644)
if err != nil {
log.Fatal("Unable to persist CSRF auth key.")
log.Fatal(err)
}
} else {
authKey = data
}
return authKey
}
// newCSRF initializes a new CSRF handler
func newCSRFHandler(keyPath string) func(h http.Handler) http.Handler {
authKey := newAuthKey(keyPath)
return csrf.Protect(
authKey,
csrf.HttpOnly(false),
csrf.Secure(false),
)
}
// newCSRFWrapper wraps a http.Handler to add the CSRF token
func newCSRFWrapper(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
h.ServeHTTP(w, r)
})
}

89
api/errors.go Normal file
View File

@@ -0,0 +1,89 @@
package portainer
// General errors.
const (
ErrUnauthorized = Error("Unauthorized")
ErrResourceAccessDenied = Error("Access denied to resource")
ErrResourceNotFound = Error("Unable to find resource")
ErrUnsupportedDockerAPI = Error("Unsupported Docker API response")
ErrMissingSecurityContext = Error("Unable to find security details in request context")
)
// User errors.
const (
ErrUserNotFound = Error("User not found")
ErrUserAlreadyExists = Error("User already exists")
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed")
ErrAdminAlreadyInitialized = Error("An administrator user already exists")
ErrCannotRemoveAdmin = Error("Cannot remove the default administrator account")
ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator")
)
// Team errors.
const (
ErrTeamNotFound = Error("Team not found")
ErrTeamAlreadyExists = Error("Team already exists")
)
// TeamMembership errors.
const (
ErrTeamMembershipNotFound = Error("Team membership not found")
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.")
)
// ResourceControl errors.
const (
ErrResourceControlNotFound = Error("Resource control not found")
ErrResourceControlAlreadyExists = Error("A resource control is already applied on this resource")
ErrInvalidResourceControlType = Error("Unsupported resource control type")
)
// Endpoint errors.
const (
ErrEndpointNotFound = Error("Endpoint not found")
ErrEndpointAccessDenied = Error("Access denied to endpoint")
)
// Registry errors.
const (
ErrRegistryNotFound = Error("Registry not found")
ErrRegistryAlreadyExists = Error("A registry is already defined for this URL")
)
// Version errors.
const (
ErrDBVersionNotFound = Error("DB version not found")
)
// Settings errors.
const (
ErrSettingsNotFound = Error("Settings not found")
)
// DockerHub errors.
const (
ErrDockerHubNotFound = Error("Dockerhub not found")
)
// Crypto errors.
const (
ErrCryptoHashFailure = Error("Unable to hash data")
)
// JWT errors.
const (
ErrSecretGeneration = Error("Unable to generate secret key")
ErrInvalidJWTToken = Error("Invalid JWT token")
ErrMissingContextData = Error("Unable to find JWT data in request context")
)
// File errors.
const (
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
)
// Error represents an application error.
type Error string
// Error returns the error message.
func (e Error) Error() string { return string(e) }

View File

@@ -1,24 +0,0 @@
package main
import (
"golang.org/x/net/websocket"
"log"
)
// execContainer is used to create a websocket communication with an exec instance
func (a *api) execContainer(ws *websocket.Conn) {
qry := ws.Request().URL.Query()
execID := qry.Get("id")
var host string
if a.endpoint.Scheme == "tcp" {
host = a.endpoint.Host
} else if a.endpoint.Scheme == "unix" {
host = a.endpoint.Path
}
if err := hijack(host, a.endpoint.Scheme, "POST", "/exec/"+execID+"/start", a.tlsConfig, true, ws, ws, ws, nil, nil); err != nil {
log.Fatalf("error during hijack: %s", err)
return
}
}

164
api/file/file.go Normal file
View File

@@ -0,0 +1,164 @@
package file
import (
"github.com/portainer/portainer"
"io"
"os"
"path"
)
const (
// TLSStorePath represents the subfolder where TLS files are stored in the file store folder.
TLSStorePath = "tls"
// LDAPStorePath represents the subfolder where LDAP TLS files are stored in the TLSStorePath.
LDAPStorePath = "ldap"
// TLSCACertFile represents the name on disk for a TLS CA file.
TLSCACertFile = "ca.pem"
// TLSCertFile represents the name on disk for a TLS certificate file.
TLSCertFile = "cert.pem"
// TLSKeyFile represents the name on disk for a TLS key file.
TLSKeyFile = "key.pem"
)
// Service represents a service for managing files and directories.
type Service struct {
dataStorePath string
fileStorePath string
}
// NewService initializes a new service. It creates a data directory and a directory to store files
// inside this directory if they don't exist.
func NewService(dataStorePath, fileStorePath string) (*Service, error) {
service := &Service{
dataStorePath: dataStorePath,
fileStorePath: path.Join(dataStorePath, fileStorePath),
}
// Checking if a mount directory exists is broken with Go on Windows.
// This will need to be reviewed after the issue has been fixed in Go.
// See: https://github.com/portainer/portainer/issues/474
// err := createDirectoryIfNotExist(dataStorePath, 0755)
// if err != nil {
// return nil, err
// }
err := service.createDirectoryInStoreIfNotExist(TLSStorePath)
if err != nil {
return nil, err
}
return service, nil
}
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
storePath := path.Join(TLSStorePath, folder)
err := service.createDirectoryInStoreIfNotExist(storePath)
if err != nil {
return err
}
var fileName string
switch fileType {
case portainer.TLSFileCA:
fileName = TLSCACertFile
case portainer.TLSFileCert:
fileName = TLSCertFile
case portainer.TLSFileKey:
fileName = TLSKeyFile
default:
return portainer.ErrUndefinedTLSFileType
}
tlsFilePath := path.Join(storePath, fileName)
err = service.createFileInStore(tlsFilePath, r)
if err != nil {
return err
}
return nil
}
// GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint.
func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSFileType) (string, error) {
var fileName string
switch fileType {
case portainer.TLSFileCA:
fileName = TLSCACertFile
case portainer.TLSFileCert:
fileName = TLSCertFile
case portainer.TLSFileKey:
fileName = TLSKeyFile
default:
return "", portainer.ErrUndefinedTLSFileType
}
return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil
}
// DeleteTLSFiles deletes a folder in the TLS store path.
func (service *Service) DeleteTLSFiles(folder string) error {
storePath := path.Join(service.fileStorePath, TLSStorePath, folder)
err := os.RemoveAll(storePath)
if err != nil {
return err
}
return nil
}
// DeleteTLSFile deletes a specific TLS file from a folder.
func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileType) error {
var fileName string
switch fileType {
case portainer.TLSFileCA:
fileName = TLSCACertFile
case portainer.TLSFileCert:
fileName = TLSCertFile
case portainer.TLSFileKey:
fileName = TLSKeyFile
default:
return portainer.ErrUndefinedTLSFileType
}
filePath := path.Join(service.fileStorePath, TLSStorePath, folder, fileName)
err := os.Remove(filePath)
if err != nil {
return err
}
return nil
}
// createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system.
func (service *Service) createDirectoryInStoreIfNotExist(name string) error {
path := path.Join(service.fileStorePath, name)
return createDirectoryIfNotExist(path, 0700)
}
// createDirectoryIfNotExist creates a directory if it doesn't exists on the file system.
func createDirectoryIfNotExist(path string, mode uint32) error {
_, err := os.Stat(path)
if os.IsNotExist(err) {
err = os.Mkdir(path, os.FileMode(mode))
if err != nil {
return err
}
} else if err != nil {
return err
}
return nil
}
// createFile creates a new file in the file store with the content from r.
func (service *Service) createFileInStore(filePath string, r io.Reader) error {
path := path.Join(service.fileStorePath, filePath)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, r)
if err != nil {
return err
}
return nil
}

View File

@@ -1,78 +0,0 @@
package main
import (
"golang.org/x/net/websocket"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
)
// newHandler creates a new http.Handler with CSRF protection
func (a *api) newHandler(settings *Settings) http.Handler {
var (
mux = http.NewServeMux()
fileHandler = http.FileServer(http.Dir(a.assetPath))
)
handler := a.newAPIHandler()
CSRFHandler := newCSRFHandler(a.dataPath)
mux.Handle("/", fileHandler)
mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler))
mux.Handle("/ws/exec", websocket.Handler(a.execContainer))
mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) {
settingsHandler(w, r, settings)
})
mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) {
templatesHandler(w, r, a.templatesURL)
})
return CSRFHandler(newCSRFWrapper(mux))
}
// newAPIHandler initializes a new http.Handler based on the URL scheme
func (a *api) newAPIHandler() http.Handler {
var handler http.Handler
var endpoint = *a.endpoint
if endpoint.Scheme == "tcp" {
if a.tlsConfig != nil {
handler = a.newTCPHandlerWithTLS(&endpoint)
} else {
handler = a.newTCPHandler(&endpoint)
}
} else if endpoint.Scheme == "unix" {
socketPath := endpoint.Path
if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) {
log.Fatalf("Unix socket %s does not exist", socketPath)
}
log.Fatal(err)
}
handler = a.newUnixHandler(socketPath)
} else {
log.Fatalf("Bad Docker enpoint: %v. Only unix:// and tcp:// are supported.", &endpoint)
}
return handler
}
// newUnixHandler initializes a new UnixHandler
func (a *api) newUnixHandler(e string) http.Handler {
return &unixHandler{e}
}
// newTCPHandler initializes a HTTP reverse proxy
func (a *api) newTCPHandler(u *url.URL) http.Handler {
u.Scheme = "http"
return httputil.NewSingleHostReverseProxy(u)
}
// newTCPHandlerWithL initializes a HTTPS reverse proxy with a TLS configuration
func (a *api) newTCPHandlerWithTLS(u *url.URL) http.Handler {
u.Scheme = "https"
proxy := httputil.NewSingleHostReverseProxy(u)
proxy.Transport = &http.Transport{
TLSClientConfig: a.tlsConfig,
}
return proxy
}

22
api/http/error/error.go Normal file
View File

@@ -0,0 +1,22 @@
package error
import (
"encoding/json"
"log"
"net/http"
)
// errorResponse is a generic response for sending a error.
type errorResponse struct {
Err string `json:"err,omitempty"`
}
// WriteErrorResponse writes an error message to the response and logger.
func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.Logger) {
if logger != nil {
logger.Printf("http error: %s (code=%d)", err, code)
}
w.WriteHeader(code)
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
}

126
api/http/handler/auth.go Normal file
View File

@@ -0,0 +1,126 @@
package handler
import (
"github.com/portainer/portainer"
"encoding/json"
"log"
"net/http"
"os"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
)
// AuthHandler represents an HTTP API handler for managing authentication.
type AuthHandler struct {
*mux.Router
Logger *log.Logger
authDisabled bool
UserService portainer.UserService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
SettingsService portainer.SettingsService
}
const (
// ErrInvalidCredentialsFormat is an error raised when credentials format is not valid
ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format")
// ErrInvalidCredentials is an error raised when credentials for a user are invalid
ErrInvalidCredentials = portainer.Error("Invalid credentials")
// ErrAuthDisabled is an error raised when trying to access the authentication endpoints
// when the server has been started with the --no-auth flag
ErrAuthDisabled = portainer.Error("Authentication is disabled")
)
// NewAuthHandler returns a new instance of AuthHandler.
func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler {
h := &AuthHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
authDisabled: authDisabled,
}
h.Handle("/auth",
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost)
return h
}
type (
postAuthRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
}
postAuthResponse struct {
JWT string `json:"jwt"`
}
)
func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) {
if handler.authDisabled {
httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
var req postAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger)
return
}
var username = req.Username
var password = req.Password
u, err := handler.UserService.UserByUsername(username)
if err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusBadRequest, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 {
err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
} else {
err = handler.CryptoService.CompareHashAndData(u.Password, password)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger)
return
}
}
tokenData := &portainer.TokenData{
ID: u.ID,
Username: u.Username,
Role: u.Role,
}
token, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger)
}

View File

@@ -0,0 +1,94 @@
package handler
import (
"strconv"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// DockerHandler represents an HTTP API handler for proxying requests to the Docker API.
type DockerHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
// NewDockerHandler returns a new instance of DockerHandler.
func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler {
h := &DockerHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.PathPrefix("/{id}/docker").Handler(
bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI)))
return h
}
func (handler *DockerHandler) checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
memberships, _ := handler.TeamMembershipService.TeamMembershipsByUserID(userID)
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
for _, membership := range memberships {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
}
func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
parsedID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(parsedID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole && !handler.checkEndpointAccessControl(endpoint, tokenData.ID) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
var proxy http.Handler
proxy = handler.ProxyManager.GetProxy(string(endpointID))
if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
}
http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r)
}

View File

@@ -0,0 +1,89 @@
package handler
import (
"encoding/json"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// DockerHubHandler represents an HTTP API handler for managing DockerHub.
type DockerHubHandler struct {
*mux.Router
Logger *log.Logger
DockerHubService portainer.DockerHubService
}
// NewDockerHubHandler returns a new instance of NewDockerHubHandler.
func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler {
h := &DockerHubHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/dockerhub",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet)
h.Handle("/dockerhub",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut)
return h
}
type (
putDockerHubRequest struct {
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
)
// handleGetDockerHub handles GET requests on /dockerhub
func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) {
dockerhub, err := handler.DockerHubService.DockerHub()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, dockerhub, handler.Logger)
return
}
// handlePutDockerHub handles PUT requests on /dockerhub
func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *http.Request) {
var req putDockerHubRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
dockerhub := &portainer.DockerHub{
Authentication: false,
Username: "",
Password: "",
}
if req.Authentication {
dockerhub.Authentication = true
dockerhub.Username = req.Username
dockerhub.Password = req.Password
}
err = handler.DockerHubService.StoreDockerHub(dockerhub)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}

View File

@@ -0,0 +1,389 @@
package handler
import (
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
)
// EndpointHandler represents an HTTP API handler for managing Docker endpoints.
type EndpointHandler struct {
*mux.Router
Logger *log.Logger
authorizeEndpointManagement bool
EndpointService portainer.EndpointService
FileService portainer.FileService
ProxyManager *proxy.Manager
}
const (
// ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints
// when the server has been started with the --external-endpoints flag
ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled")
)
// NewEndpointHandler returns a new instance of EndpointHandler.
func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *EndpointHandler {
h := &EndpointHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
authorizeEndpointManagement: authorizeEndpointManagement,
}
h.Handle("/endpoints",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost)
h.Handle("/endpoints",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}/access",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete)
return h
}
type (
postEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
PublicURL string `valid:"-"`
TLS bool
TLSSkipVerify bool
TLSSkipClientVerify bool
}
postEndpointsResponse struct {
ID int `json:"Id"`
}
putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putEndpointsRequest struct {
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
TLS bool `valid:"-"`
TLSSkipVerify bool `valid:"-"`
TLSSkipClientVerify bool `valid:"-"`
}
)
// handleGetEndpoints handles GET requests on /endpoints
func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, filteredEndpoints, handler.Logger)
}
// handlePostEndpoints handles POST requests on /endpoints
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
var req postEndpointsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpoint := &portainer.Endpoint{
Name: req.Name,
URL: req.URL,
PublicURL: req.PublicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: req.TLS,
TLSSkipVerify: req.TLSSkipVerify,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.TLS {
folder := strconv.Itoa(int(endpoint.ID))
if !req.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
}
if !req.TLSSkipClientVerify {
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
}
// handleGetEndpoint handles GET requests on /endpoints/:id
func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, endpoint, handler.Logger)
}
// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access
func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointAccessRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.AuthorizedUsers != nil {
authorizedUserIDs := []portainer.UserID{}
for _, value := range req.AuthorizedUsers {
authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value))
}
endpoint.AuthorizedUsers = authorizedUserIDs
}
if req.AuthorizedTeams != nil {
authorizedTeamIDs := []portainer.TeamID{}
for _, value := range req.AuthorizedTeams {
authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value))
}
endpoint.AuthorizedTeams = authorizedTeamIDs
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handlePutEndpoint handles PUT requests on /endpoints/:id
func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
vars := mux.Vars(r)
id := vars["id"]
endpointID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointsRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.Name != "" {
endpoint.Name = req.Name
}
if req.URL != "" {
endpoint.URL = req.URL
}
if req.PublicURL != "" {
endpoint.PublicURL = req.PublicURL
}
folder := strconv.Itoa(int(endpoint.ID))
if req.TLS {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = req.TLSSkipVerify
if !req.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
} else {
endpoint.TLSConfig.TLSCACertPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA)
}
if !req.TLSSkipClientVerify {
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
} else {
endpoint.TLSConfig.TLSCertPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSKeyPath = ""
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey)
}
} else {
endpoint.TLSConfig.TLS = false
endpoint.TLSConfig.TLSSkipVerify = true
endpoint.TLSConfig.TLSCACertPath = ""
endpoint.TLSConfig.TLSCertPath = ""
endpoint.TLSConfig.TLSKeyPath = ""
err = handler.FileService.DeleteTLSFiles(folder)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
_, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleDeleteEndpoint handles DELETE requests on /endpoints/:id
func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger)
return
}
vars := mux.Vars(r)
id := vars["id"]
endpointID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
handler.ProxyManager.DeleteProxy(string(endpointID))
err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if endpoint.TLSConfig.TLS {
err = handler.FileService.DeleteTLSFiles(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}

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

@@ -0,0 +1,62 @@
package handler
import (
"os"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"log"
"net/http"
"path"
"strings"
)
// FileHandler represents an HTTP API handler for managing static files.
type FileHandler struct {
http.Handler
Logger *log.Logger
allowedDirectories map[string]bool
}
// NewFileHandler returns a new instance of FileHandler.
func NewFileHandler(assetPath string) *FileHandler {
h := &FileHandler{
Handler: http.FileServer(http.Dir(assetPath)),
Logger: log.New(os.Stderr, "", log.LstdFlags),
allowedDirectories: map[string]bool{
"/": true,
"/css": true,
"/js": true,
"/images": true,
"/fonts": true,
"/ico": true,
},
}
return h
}
func isHTML(acceptContent []string) bool {
for _, accept := range acceptContent {
if strings.Contains(accept, "text/html") {
return true
}
}
return false
}
func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
requestDirectory := path.Dir(r.URL.Path)
if !handler.allowedDirectories[requestDirectory] {
httperror.WriteErrorResponse(w, portainer.ErrResourceNotFound, http.StatusNotFound, handler.Logger)
return
}
if !isHTML(r.Header["Accept"]) {
w.Header().Set("Cache-Control", "max-age=31536000")
} else {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
handler.Handler.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,85 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"strings"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
)
// Handler is a collection of all the service handlers.
type Handler struct {
AuthHandler *AuthHandler
UserHandler *UserHandler
TeamHandler *TeamHandler
TeamMembershipHandler *TeamMembershipHandler
EndpointHandler *EndpointHandler
RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler
ResourceHandler *ResourceHandler
StatusHandler *StatusHandler
SettingsHandler *SettingsHandler
TemplatesHandler *TemplatesHandler
DockerHandler *DockerHandler
WebSocketHandler *WebSocketHandler
UploadHandler *UploadHandler
FileHandler *FileHandler
}
const (
// ErrInvalidJSON defines an error raised the app is unable to parse request data
ErrInvalidJSON = portainer.Error("Invalid JSON")
// ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid
ErrInvalidRequestFormat = portainer.Error("Invalid request data format")
// ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid
ErrInvalidQueryFormat = portainer.Error("Invalid query format")
)
// ServeHTTP delegates a request to the appropriate subhandler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/api/auth"):
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
if strings.Contains(r.URL.Path, "/docker") {
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
} else {
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/registries"):
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/resource_controls"):
http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/settings"):
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/status"):
http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/templates"):
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/upload"):
http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/users"):
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/teams"):
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/websocket"):
http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/"):
h.FileHandler.ServeHTTP(w, r)
}
}
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
if err := json.NewEncoder(w).Encode(v); err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)
}
}

View File

@@ -0,0 +1,314 @@
package handler
import (
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
)
// RegistryHandler represents an HTTP API handler for managing Docker registries.
type RegistryHandler struct {
*mux.Router
Logger *log.Logger
RegistryService portainer.RegistryService
}
// NewRegistryHandler returns a new instance of RegistryHandler.
func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler {
h := &RegistryHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/registries",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostRegistries))).Methods(http.MethodPost)
h.Handle("/registries",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetRegistries))).Methods(http.MethodGet)
h.Handle("/registries/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetRegistry))).Methods(http.MethodGet)
h.Handle("/registries/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistry))).Methods(http.MethodPut)
h.Handle("/registries/{id}/access",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistryAccess))).Methods(http.MethodPut)
h.Handle("/registries/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteRegistry))).Methods(http.MethodDelete)
return h
}
type (
postRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
postRegistriesResponse struct {
ID int `json:"Id"`
}
putRegistryAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
)
// handleGetRegistries handles GET requests on /registries
func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
registries, err := handler.RegistryService.Registries()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, filteredRegistries, handler.Logger)
}
// handlePostRegistries handles POST requests on /registries
func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *http.Request) {
var req postRegistriesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
registries, err := handler.RegistryService.Registries()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, r := range registries {
if r.URL == req.URL {
httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
registry := &portainer.Registry{
Name: req.Name,
URL: req.URL,
Authentication: req.Authentication,
Username: req.Username,
Password: req.Password,
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = handler.RegistryService.CreateRegistry(registry)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger)
}
// handleGetRegistry handles GET requests on /registries/:id
func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
registryID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
if err == portainer.ErrRegistryNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, registry, handler.Logger)
}
// handlePutRegistryAccess handles PUT requests on /registries/:id/access
func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
registryID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putRegistryAccessRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
if err == portainer.ErrRegistryNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.AuthorizedUsers != nil {
authorizedUserIDs := []portainer.UserID{}
for _, value := range req.AuthorizedUsers {
authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value))
}
registry.AuthorizedUsers = authorizedUserIDs
}
if req.AuthorizedTeams != nil {
authorizedTeamIDs := []portainer.TeamID{}
for _, value := range req.AuthorizedTeams {
authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value))
}
registry.AuthorizedTeams = authorizedTeamIDs
}
err = handler.RegistryService.UpdateRegistry(registry.ID, registry)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handlePutRegistry handles PUT requests on /registries/:id
func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
registryID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putRegistriesRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
if err == portainer.ErrRegistryNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
registries, err := handler.RegistryService.Registries()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, r := range registries {
if r.URL == req.URL && r.ID != registry.ID {
httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
if req.Name != "" {
registry.Name = req.Name
}
if req.URL != "" {
registry.URL = req.URL
}
if req.Authentication {
registry.Authentication = true
registry.Username = req.Username
registry.Password = req.Password
} else {
registry.Authentication = false
registry.Username = ""
registry.Password = ""
}
err = handler.RegistryService.UpdateRegistry(registry.ID, registry)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleDeleteRegistry handles DELETE requests on /registries/:id
func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
registryID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.RegistryService.Registry(portainer.RegistryID(registryID))
if err == portainer.ErrRegistryNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@@ -0,0 +1,262 @@
package handler
import (
"encoding/json"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// ResourceHandler represents an HTTP API handler for managing resource controls.
type ResourceHandler struct {
*mux.Router
Logger *log.Logger
ResourceControlService portainer.ResourceControlService
}
// NewResourceHandler returns a new instance of ResourceHandler.
func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler {
h := &ResourceHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/resource_controls",
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostResources))).Methods(http.MethodPost)
h.Handle("/resource_controls/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutResources))).Methods(http.MethodPut)
h.Handle("/resource_controls/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteResources))).Methods(http.MethodDelete)
return h
}
type (
postResourcesRequest struct {
ResourceID string `valid:"required"`
Type string `valid:"required"`
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
SubResourceIDs []string `valid:"-"`
}
putResourcesRequest struct {
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
}
)
// handlePostResources handles POST requests on /resources
func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) {
var req postResourcesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
var resourceControlType portainer.ResourceControlType
switch req.Type {
case "container":
resourceControlType = portainer.ContainerResourceControl
case "service":
resourceControlType = portainer.ServiceResourceControl
case "volume":
resourceControlType = portainer.VolumeResourceControl
case "network":
resourceControlType = portainer.NetworkResourceControl
case "secret":
resourceControlType = portainer.SecretResourceControl
default:
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
return
}
if len(req.Users) == 0 && len(req.Teams) == 0 && !req.AdministratorsOnly {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
rc, err := handler.ResourceControlService.ResourceControlByResourceID(req.ResourceID)
if err != nil && err != portainer.ErrResourceControlNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if rc != nil {
httperror.WriteErrorResponse(w, portainer.ErrResourceControlAlreadyExists, http.StatusConflict, handler.Logger)
return
}
var userAccesses = make([]portainer.UserResourceAccess, 0)
for _, v := range req.Users {
userAccess := portainer.UserResourceAccess{
UserID: portainer.UserID(v),
AccessLevel: portainer.ReadWriteAccessLevel,
}
userAccesses = append(userAccesses, userAccess)
}
var teamAccesses = make([]portainer.TeamResourceAccess, 0)
for _, v := range req.Teams {
teamAccess := portainer.TeamResourceAccess{
TeamID: portainer.TeamID(v),
AccessLevel: portainer.ReadWriteAccessLevel,
}
teamAccesses = append(teamAccesses, teamAccess)
}
resourceControl := portainer.ResourceControl{
ResourceID: req.ResourceID,
SubResourceIDs: req.SubResourceIDs,
Type: resourceControlType,
AdministratorsOnly: req.AdministratorsOnly,
UserAccesses: userAccesses,
TeamAccesses: teamAccesses,
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
err = handler.ResourceControlService.CreateResourceControl(&resourceControl)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
return
}
// handlePutResources handles PUT requests on /resources/:id
func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
resourceControlID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putResourcesRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID))
if err == portainer.ErrResourceControlNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
resourceControl.AdministratorsOnly = req.AdministratorsOnly
var userAccesses = make([]portainer.UserResourceAccess, 0)
for _, v := range req.Users {
userAccess := portainer.UserResourceAccess{
UserID: portainer.UserID(v),
AccessLevel: portainer.ReadWriteAccessLevel,
}
userAccesses = append(userAccesses, userAccess)
}
resourceControl.UserAccesses = userAccesses
var teamAccesses = make([]portainer.TeamResourceAccess, 0)
for _, v := range req.Teams {
teamAccess := portainer.TeamResourceAccess{
TeamID: portainer.TeamID(v),
AccessLevel: portainer.ReadWriteAccessLevel,
}
teamAccesses = append(teamAccesses, teamAccess)
}
resourceControl.TeamAccesses = teamAccesses
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleDeleteResources handles DELETE requests on /resources/:id
func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
resourceControlID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID))
if err == portainer.ErrResourceControlNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@@ -0,0 +1,169 @@
package handler
import (
"encoding/json"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
"github.com/portainer/portainer/file"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// SettingsHandler represents an HTTP API handler for managing Settings.
type SettingsHandler struct {
*mux.Router
Logger *log.Logger
SettingsService portainer.SettingsService
LDAPService portainer.LDAPService
FileService portainer.FileService
}
// NewSettingsHandler returns a new instance of OldSettingsHandler.
func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler {
h := &SettingsHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/settings",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet)
h.Handle("/settings",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut)
h.Handle("/settings/public",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet)
h.Handle("/settings/authentication/checkLDAP",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut)
return h
}
type (
publicSettingsResponse struct {
LogoURL string `json:"LogoURL"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
}
putSettingsRequest struct {
TemplatesURL string `valid:"required"`
LogoURL string `valid:""`
BlackListedLabels []portainer.Pair `valid:""`
DisplayExternalContributors bool `valid:""`
AuthenticationMethod int `valid:"required"`
LDAPSettings portainer.LDAPSettings `valid:""`
}
putSettingsLDAPCheckRequest struct {
LDAPSettings portainer.LDAPSettings `valid:""`
}
)
// handleGetSettings handles GET requests on /settings
func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, settings, handler.Logger)
return
}
// handleGetPublicSettings handles GET requests on /settings/public
func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) {
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
publicSettings := &publicSettingsResponse{
LogoURL: settings.LogoURL,
DisplayExternalContributors: settings.DisplayExternalContributors,
AuthenticationMethod: settings.AuthenticationMethod,
}
encodeJSON(w, publicSettings, handler.Logger)
return
}
// handlePutSettings handles PUT requests on /settings
func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) {
var req putSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
settings := &portainer.Settings{
TemplatesURL: req.TemplatesURL,
LogoURL: req.LogoURL,
BlackListedLabels: req.BlackListedLabels,
DisplayExternalContributors: req.DisplayExternalContributors,
LDAPSettings: req.LDAPSettings,
}
if req.AuthenticationMethod == 1 {
settings.AuthenticationMethod = portainer.AuthenticationInternal
} else if req.AuthenticationMethod == 2 {
settings.AuthenticationMethod = portainer.AuthenticationLDAP
} else {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
} else {
settings.LDAPSettings.TLSConfig.TLSCACertPath = ""
err := handler.FileService.DeleteTLSFiles(file.LDAPStorePath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}
err = handler.SettingsService.StoreSettings(settings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}
// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check
func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) {
var req putSettingsLDAPCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
}
err = handler.LDAPService.TestConnectivity(&req.LDAPSettings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@@ -0,0 +1,38 @@
package handler
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// StatusHandler represents an HTTP API handler for managing Status.
type StatusHandler struct {
*mux.Router
Logger *log.Logger
Status *portainer.Status
}
// NewStatusHandler returns a new instance of StatusHandler.
func NewStatusHandler(bouncer *security.RequestBouncer, status *portainer.Status) *StatusHandler {
h := &StatusHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
Status: status,
}
h.Handle("/status",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetStatus))).Methods(http.MethodGet)
return h
}
// handleGetStatus handles GET requests on /status
func (handler *StatusHandler) handleGetStatus(w http.ResponseWriter, r *http.Request) {
encodeJSON(w, handler.Status, handler.Logger)
return
}

262
api/http/handler/team.go Normal file
View File

@@ -0,0 +1,262 @@
package handler
import (
"strconv"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"encoding/json"
"log"
"net/http"
"os"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
)
// TeamHandler represents an HTTP API handler for managing teams.
type TeamHandler struct {
*mux.Router
Logger *log.Logger
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
ResourceControlService portainer.ResourceControlService
}
// NewTeamHandler returns a new instance of TeamHandler.
func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler {
h := &TeamHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/teams",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost)
h.Handle("/teams",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutTeam))).Methods(http.MethodPut)
h.Handle("/teams/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteTeam))).Methods(http.MethodDelete)
h.Handle("/teams/{id}/memberships",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet)
return h
}
type (
postTeamsRequest struct {
Name string `valid:"required"`
}
postTeamsResponse struct {
ID int `json:"Id"`
}
putTeamRequest struct {
Name string `valid:"-"`
}
)
// handlePostTeams handles POST requests on /teams
func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) {
var req postTeamsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
team, err := handler.TeamService.TeamByName(req.Name)
if err != nil && err != portainer.ErrTeamNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if team != nil {
httperror.WriteErrorResponse(w, portainer.ErrTeamAlreadyExists, http.StatusConflict, handler.Logger)
return
}
team = &portainer.Team{
Name: req.Name,
}
err = handler.TeamService.CreateTeam(team)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger)
}
// handleGetTeams handles GET requests on /teams
func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
teams, err := handler.TeamService.Teams()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredTeams := security.FilterUserTeams(teams, securityContext)
encodeJSON(w, filteredTeams, handler.Logger)
}
// handleGetTeam handles GET requests on /teams/:id
func (handler *TeamHandler) handleGetTeam(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
tid, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
teamID := portainer.TeamID(tid)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedTeamManagement(teamID, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
team, err := handler.TeamService.Team(teamID)
if err == portainer.ErrTeamNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &team, handler.Logger)
}
// handlePutTeam handles PUT requests on /teams/:id
func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
teamID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putTeamRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
team, err := handler.TeamService.Team(portainer.TeamID(teamID))
if err == portainer.ErrTeamNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.Name != "" {
team.Name = req.Name
}
err = handler.TeamService.UpdateTeam(team.ID, team)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleDeleteTeam handles DELETE requests on /teams/:id
func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
teamID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.TeamService.Team(portainer.TeamID(teamID))
if err == portainer.ErrTeamNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleGetMemberships handles GET requests on /teams/:id/memberships
func (handler *TeamHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
tid, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
teamID := portainer.TeamID(tid)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedTeamManagement(teamID, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(teamID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, memberships, handler.Logger)
}

View File

@@ -0,0 +1,242 @@
package handler
import (
"strconv"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"encoding/json"
"log"
"net/http"
"os"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
)
// TeamMembershipHandler represents an HTTP API handler for managing teams.
type TeamMembershipHandler struct {
*mux.Router
Logger *log.Logger
TeamMembershipService portainer.TeamMembershipService
ResourceControlService portainer.ResourceControlService
}
// NewTeamMembershipHandler returns a new instance of TeamMembershipHandler.
func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipHandler {
h := &TeamMembershipHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/team_memberships",
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostTeamMemberships))).Methods(http.MethodPost)
h.Handle("/team_memberships",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeamsMemberships))).Methods(http.MethodGet)
h.Handle("/team_memberships/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutTeamMembership))).Methods(http.MethodPut)
h.Handle("/team_memberships/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteTeamMembership))).Methods(http.MethodDelete)
return h
}
type (
postTeamMembershipsRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
postTeamMembershipsResponse struct {
ID int `json:"Id"`
}
putTeamMembershipRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
)
// handlePostTeamMemberships handles POST requests on /team_memberships
func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req postTeamMembershipsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
userID := portainer.UserID(req.UserID)
teamID := portainer.TeamID(req.TeamID)
role := portainer.MembershipRole(req.Role)
if !security.AuthorizedTeamManagement(teamID, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if len(memberships) > 0 {
for _, membership := range memberships {
if membership.UserID == userID && membership.TeamID == teamID {
httperror.WriteErrorResponse(w, portainer.ErrTeamMembershipAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
}
membership := &portainer.TeamMembership{
UserID: userID,
TeamID: teamID,
Role: role,
}
err = handler.TeamMembershipService.CreateTeamMembership(membership)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger)
}
// handleGetTeamsMemberships handles GET requests on /team_memberships
func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
memberships, err := handler.TeamMembershipService.TeamMemberships()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, memberships, handler.Logger)
}
// handlePutTeamMembership handles PUT requests on /team_memberships/:id
func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
membershipID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putTeamMembershipRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
userID := portainer.UserID(req.UserID)
teamID := portainer.TeamID(req.TeamID)
role := portainer.MembershipRole(req.Role)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedTeamManagement(teamID, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID))
if err == portainer.ErrTeamMembershipNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if securityContext.IsTeamLeader && membership.Role != role {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
membership.UserID = userID
membership.TeamID = teamID
membership.Role = role
err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id
func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
membershipID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID))
if err == portainer.ErrTeamMembershipNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@@ -0,0 +1,73 @@
package handler
import (
"io/ioutil"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
)
// TemplatesHandler represents an HTTP API handler for managing templates.
type TemplatesHandler struct {
*mux.Router
Logger *log.Logger
SettingsService portainer.SettingsService
}
const (
containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json"
)
// NewTemplatesHandler returns a new instance of TemplatesHandler.
func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler {
h := &TemplatesHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/templates",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet)
return h
}
// handleGetTemplates handles GET requests on /templates?key=<key>
func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
key := r.FormValue("key")
if key == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
var templatesURL string
if key == "containers" {
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
templatesURL = settings.TemplatesURL
} else if key == "linuxserver.io" {
templatesURL = containerTemplatesURLLinuxServerIo
} else {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
resp, err := http.Get(templatesURL)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}

View File

@@ -0,0 +1,69 @@
package handler
import (
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// UploadHandler represents an HTTP API handler for managing file uploads.
type UploadHandler struct {
*mux.Router
Logger *log.Logger
FileService portainer.FileService
}
// NewUploadHandler returns a new instance of UploadHandler.
func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler {
h := &UploadHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost)
return h
}
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder
func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
certificate := vars["certificate"]
folder := r.FormValue("folder")
if folder == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
file, _, err := r.FormFile("file")
defer file.Close()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var fileType portainer.TLSFileType
switch certificate {
case "ca":
fileType = portainer.TLSFileCA
case "cert":
fileType = portainer.TLSFileCert
case "key":
fileType = portainer.TLSFileKey
default:
httperror.WriteErrorResponse(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.FileService.StoreTLSFile(folder, fileType, file)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

468
api/http/handler/user.go Normal file
View File

@@ -0,0 +1,468 @@
package handler
import (
"strconv"
"strings"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"encoding/json"
"log"
"net/http"
"os"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
)
// UserHandler represents an HTTP API handler for managing users.
type UserHandler struct {
*mux.Router
Logger *log.Logger
UserService portainer.UserService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
ResourceControlService portainer.ResourceControlService
CryptoService portainer.CryptoService
SettingsService portainer.SettingsService
}
// NewUserHandler returns a new instance of UserHandler.
func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler {
h := &UserHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/users",
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost)
h.Handle("/users",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet)
h.Handle("/users/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet)
h.Handle("/users/{id}",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut)
h.Handle("/users/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete)
h.Handle("/users/{id}/memberships",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet)
h.Handle("/users/{id}/passwd",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost)
h.Handle("/users/admin/check",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet)
h.Handle("/users/admin/init",
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost)
return h
}
type (
postUsersRequest struct {
Username string `valid:"required"`
Password string `valid:""`
Role int `valid:"required"`
}
postUsersResponse struct {
ID int `json:"Id"`
}
postUserPasswdRequest struct {
Password string `valid:"required"`
}
postUserPasswdResponse struct {
Valid bool `json:"valid"`
}
putUserRequest struct {
Password string `valid:"-"`
Role int `valid:"-"`
}
postAdminInitRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
}
)
// handlePostUsers handles POST requests on /users
func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) {
var req postUsersRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
if securityContext.IsTeamLeader && req.Role == 1 {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
if strings.ContainsAny(req.Username, " ") {
httperror.WriteErrorResponse(w, portainer.ErrInvalidUsername, http.StatusBadRequest, handler.Logger)
return
}
user, err := handler.UserService.UserByUsername(req.Username)
if err != nil && err != portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if user != nil {
httperror.WriteErrorResponse(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger)
return
}
var role portainer.UserRole
if req.Role == 1 {
role = portainer.AdministratorRole
} else {
role = portainer.StandardUserRole
}
user = &portainer.User{
Username: req.Username,
Role: role,
}
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
return
}
}
err = handler.UserService.CreateUser(user)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger)
}
// handleGetUsers handles GET requests on /users
func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
users, err := handler.UserService.Users()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredUsers := security.FilterUsers(users, securityContext)
for i := range filteredUsers {
filteredUsers[i].Password = ""
}
encodeJSON(w, filteredUsers, handler.Logger)
}
// handlePostUserPasswd handles POST requests on /users/:id/passwd
func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req postUserPasswdRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
var password = req.Password
u, err := handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
valid := true
err = handler.CryptoService.CompareHashAndData(u.Password, password)
if err != nil {
valid = false
}
encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger)
}
// handleGetUser handles GET requests on /users/:id
func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
user, err := handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
user.Password = ""
encodeJSON(w, &user, handler.Logger)
}
// handlePutUser handles PUT requests on /users/:id
func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
return
}
var req putUserRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if req.Password == "" && req.Role == 0 {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
user, err := handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.Password != "" {
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
return
}
}
if req.Role != 0 {
if tokenData.Role != portainer.AdministratorRole {
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
return
}
if req.Role == 1 {
user.Role = portainer.AdministratorRole
} else {
user.Role = portainer.StandardUserRole
}
}
err = handler.UserService.UpdateUser(user.ID, user)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleGetAdminCheck handles GET requests on /users/admin/check
func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) {
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if len(users) == 0 {
httperror.WriteErrorResponse(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger)
return
}
}
// handlePostAdminInit handles POST requests on /users/admin/init
func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) {
var req postAdminInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if len(users) == 0 {
user := &portainer.User{
Username: req.Username,
Role: portainer.AdministratorRole,
}
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
return
}
err = handler.UserService.CreateUser(user)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
} else {
httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger)
return
}
}
// handleDeleteUser handles DELETE requests on /users/:id
func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
if userID == 1 {
httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveAdmin, http.StatusForbidden, handler.Logger)
return
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.ID == portainer.UserID(userID) {
httperror.WriteErrorResponse(w, portainer.ErrAdminCannotRemoveSelf, http.StatusForbidden, handler.Logger)
return
}
_, err = handler.UserService.User(portainer.UserID(userID))
if err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.UserService.DeleteUser(portainer.UserID(userID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleGetMemberships handles GET requests on /users/:id/memberships
func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
userID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger)
return
}
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, memberships, handler.Logger)
}

View File

@@ -1,4 +1,4 @@
package main
package handler
import (
"bytes"
@@ -6,12 +6,85 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"golang.org/x/net/websocket"
)
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
type WebSocketHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
}
// NewWebSocketHandler returns a new instance of WebSocketHandler.
func NewWebSocketHandler() *WebSocketHandler {
h := &WebSocketHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec))
return h
}
func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
qry := ws.Request().URL.Query()
execID := qry.Get("id")
edpID := qry.Get("endpointId")
parsedID, err := strconv.Atoi(edpID)
if err != nil {
log.Printf("Unable to parse endpoint ID: %s", err)
return
}
endpointID := portainer.EndpointID(parsedID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err != nil {
log.Printf("Unable to retrieve endpoint: %s", err)
return
}
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
log.Printf("Unable to parse endpoint URL: %s", err)
return
}
var host string
if endpointURL.Scheme == "tcp" {
host = endpointURL.Host
} else if endpointURL.Scheme == "unix" {
host = endpointURL.Path
}
// Should not be managed here
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
log.Fatalf("Unable to create TLS configuration: %s", err)
return
}
}
if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil {
log.Fatalf("error during hijack: %s", err)
return
}
}
type execConfig struct {
Tty bool
Detach bool

View File

@@ -0,0 +1,21 @@
package proxy
import "github.com/portainer/portainer"
func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool {
for _, authorizedUserAccess := range resourceControl.UserAccesses {
if userID == authorizedUserAccess.UserID {
return true
}
}
for _, authorizedTeamAccess := range resourceControl.TeamAccesses {
for _, userTeamID := range userTeamIDs {
if userTeamID == authorizedTeamAccess.TeamID {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,108 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier
ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found")
containerIdentifier = "Id"
containerLabelForServiceIdentifier = "com.docker.swarm.service.id"
)
// containerListOperation extracts the response as a JSON object, loop through the containers array
// decorate and/or filter the containers based on resource controls before rewriting the response
func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ContainerList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls,
executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
if executor.labelBlackList != nil {
responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList)
if err != nil {
return err
}
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// containerInspectOperation extracts the response as a JSON object, verify that the user
// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated container.
func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ContainerInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[containerIdentifier] == nil {
return ErrDockerContainerIdentifierNotFound
}
containerID := responseObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
}
return rewriteResponse(response, responseObject, http.StatusOK)
}
// extractContainerLabelsFromContainerInspectObject retrieve the Labels of the container if present.
// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
func extractContainerLabelsFromContainerInspectObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Config.Labels
containerConfigObject := extractJSONField(responseObject, "Config")
if containerConfigObject != nil {
containerLabelsObject := extractJSONField(containerConfigObject, "Labels")
return containerLabelsObject
}
return nil
}
// extractContainerLabelsFromContainerListObject retrieve the Labels of the container if present.
// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func extractContainerLabelsFromContainerListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
containerLabelsObject := extractJSONField(responseObject, "Labels")
return containerLabelsObject
}

138
api/http/proxy/decorator.go Normal file
View File

@@ -0,0 +1,138 @@
package proxy
import "github.com/portainer/portainer"
// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control.
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
if resourceControl != nil {
volumeObject = decorateObject(volumeObject, resourceControl)
}
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
}
return decoratedVolumeData, nil
}
// decorateContainerList loops through all containers and will decorate any container with an existing resource control.
// Check is based on the container ID and optional Swarm service ID.
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
if resourceControl != nil {
containerObject = decorateObject(containerObject, resourceControl)
}
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl != nil {
containerObject = decorateObject(containerObject, resourceControl)
}
}
decoratedContainerData = append(decoratedContainerData, containerObject)
}
return decoratedContainerData, nil
}
// decorateServiceList loops through all services and will decorate any service with an existing resource control.
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl != nil {
serviceObject = decorateObject(serviceObject, resourceControl)
}
decoratedServiceData = append(decoratedServiceData, serviceObject)
}
return decoratedServiceData, nil
}
// decorateNetworkList loops through all networks and will decorate any network with an existing resource control.
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, resourceControls)
if resourceControl != nil {
networkObject = decorateObject(networkObject, resourceControl)
}
decoratedNetworkData = append(decoratedNetworkData, networkObject)
}
return decoratedNetworkData, nil
}
// decorateSecretList loops through all secrets and will decorate any secret with an existing resource control.
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
if resourceControl != nil {
secretObject = decorateObject(secretObject, resourceControl)
}
decoratedSecretData = append(decoratedSecretData, secretObject)
}
return decoratedSecretData, nil
}
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControl
object["Portainer"] = metadata
return object
}

58
api/http/proxy/factory.go Normal file
View File

@@ -0,0 +1,58 @@
package proxy
import (
"net/http"
"net/http/httputil"
"net/url"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
)
// proxyFactory is a factory to create reverse proxies to Docker endpoints
type proxyFactory struct {
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
}
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
u.Scheme = "http"
return factory.createReverseProxy(u)
}
func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
u.Scheme = "https"
proxy := factory.createReverseProxy(u)
config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
return nil, err
}
proxy.Transport.(*proxyTransport).dockerTransport.TLSClientConfig = config
return proxy, nil
}
func (factory *proxyFactory) newSocketProxy(path string) http.Handler {
proxy := &socketProxy{}
transport := &proxyTransport{
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
dockerTransport: newSocketTransport(path),
}
proxy.Transport = transport
return proxy
}
func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy {
proxy := newSingleHostReverseProxyWithHostHeader(u)
transport := &proxyTransport{
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
dockerTransport: newHTTPTransport(),
}
proxy.Transport = transport
return proxy
}

185
api/http/proxy/filter.go Normal file
View File

@@ -0,0 +1,185 @@
package proxy
import "github.com/portainer/portainer"
// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with
// any resource control giving access to the user (these volumes will be decorated).
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
if resourceControl == nil {
filteredVolumeData = append(filteredVolumeData, volumeObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
volumeObject = decorateObject(volumeObject, resourceControl)
filteredVolumeData = append(filteredVolumeData, volumeObject)
}
}
return filteredVolumeData, nil
}
// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with
// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated).
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
if resourceControl == nil {
// check if container is part of a Swarm service
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if serviceResourceControl == nil {
filteredContainerData = append(filteredContainerData, containerObject)
} else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) {
containerObject = decorateObject(containerObject, serviceResourceControl)
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
containerObject = decorateObject(containerObject, resourceControl)
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
// any labels in the labels black list.
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil {
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
// filterServiceList loops through all services, filters services without any resource control (public resources) or with
// any resource control giving access to the user (these services will be decorated).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl == nil {
filteredServiceData = append(filteredServiceData, serviceObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
serviceObject = decorateObject(serviceObject, resourceControl)
filteredServiceData = append(filteredServiceData, serviceObject)
}
}
return filteredServiceData, nil
}
// filterNetworkList loops through all networks, filters networks without any resource control (public resources) or with
// any resource control giving access to the user (these networks will be decorated).
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func filterNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, resourceControls)
if resourceControl == nil {
filteredNetworkData = append(filteredNetworkData, networkObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
networkObject = decorateObject(networkObject, resourceControl)
filteredNetworkData = append(filteredNetworkData, networkObject)
}
}
return filteredNetworkData, nil
}
// filterSecretList loops through all secrets, filters secrets without any resource control (public resources) or with
// any resource control giving access to the user (these secrets will be decorated).
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func filterSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
if resourceControl == nil {
filteredSecretData = append(filteredSecretData, secretObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
secretObject = decorateObject(secretObject, resourceControl)
filteredSecretData = append(filteredSecretData, secretObject)
}
}
return filteredSecretData, nil
}
// filterTaskList loops through all tasks, filters tasks without any resource control (public resources) or with
// any resource control giving access to the user based on the associated service identifier.
// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
func filterTaskList(taskData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredTaskData := make([]interface{}, 0)
for _, task := range taskData {
taskObject := task.(map[string]interface{})
if taskObject[taskServiceIdentifier] == nil {
return nil, ErrDockerTaskServiceIdentifierNotFound
}
serviceID := taskObject[taskServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl == nil || (resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl)) {
filteredTaskData = append(filteredTaskData, taskObject)
}
}
return filteredTaskData, nil
}

69
api/http/proxy/manager.go Normal file
View File

@@ -0,0 +1,69 @@
package proxy
import (
"net/http"
"net/url"
"github.com/orcaman/concurrent-map"
"github.com/portainer/portainer"
)
// Manager represents a service used to manage Docker proxies.
type Manager struct {
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
}
// NewManager initializes a new proxy Service
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager {
return &Manager{
proxies: cmap.New(),
proxyFactory: &proxyFactory{
ResourceControlService: resourceControlService,
TeamMembershipService: teamMembershipService,
SettingsService: settingsService,
},
}
}
// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
var proxy http.Handler
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
if endpointURL.Scheme == "tcp" {
if endpoint.TLSConfig.TLS {
proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint)
if err != nil {
return nil, err
}
} else {
proxy = manager.proxyFactory.newHTTPProxy(endpointURL)
}
} else {
// Assume unix:// scheme
proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path)
}
manager.proxies.Set(string(endpoint.ID), proxy)
return proxy, nil
}
// GetProxy returns the proxy associated to a key
func (manager *Manager) GetProxy(key string) http.Handler {
proxy, ok := manager.proxies.Get(key)
if !ok {
return nil
}
return proxy.(http.Handler)
}
// DeleteProxy deletes the proxy associated to a key
func (manager *Manager) DeleteProxy(key string) {
manager.proxies.Remove(key)
}

View File

@@ -0,0 +1,66 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier
ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found")
networkIdentifier = "Id"
)
// networkListOperation extracts the response as a JSON object, loop through the networks array
// decorate and/or filter the networks based on resource controls before rewriting the response
func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// NetworkList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterNetworkList(responseArray, executor.operationContext.resourceControls,
executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// networkInspectOperation extracts the response as a JSON object, verify that the user
// has access to the network based on resource control and either rewrite an access denied response
// or a decorated network.
func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// NetworkInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[networkIdentifier] == nil {
return ErrDockerNetworkIdentifierNotFound
}
networkID := responseObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
return rewriteResponse(response, responseObject, http.StatusOK)
}

View File

@@ -0,0 +1,95 @@
package proxy
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"strconv"
"github.com/portainer/portainer"
)
const (
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
ErrEmptyResponseBody = portainer.Error("Empty response body")
)
func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} {
object := jsonObject[key]
if object != nil {
return object.(map[string]interface{})
}
return nil
}
func getResponseAsJSONOBject(response *http.Response) (map[string]interface{}, error) {
responseData, err := getResponseBodyAsGenericJSON(response)
if err != nil {
return nil, err
}
responseObject := responseData.(map[string]interface{})
return responseObject, nil
}
func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
responseData, err := getResponseBodyAsGenericJSON(response)
if err != nil {
return nil, err
}
responseObject := responseData.([]interface{})
return responseObject, nil
}
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
var data interface{}
if response.Body != nil {
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
err = response.Body.Close()
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &data)
if err != nil {
return nil, err
}
return data, nil
}
return nil, ErrEmptyResponseBody
}
func writeAccessDeniedResponse() (*http.Response, error) {
response := &http.Response{}
err := rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden)
return response, err
}
func rewriteAccessDeniedResponse(response *http.Response) error {
return rewriteResponse(response, portainer.ErrResourceAccessDenied, http.StatusForbidden)
}
func rewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error {
jsonData, err := json.Marshal(newResponseData)
if err != nil {
return err
}
body := ioutil.NopCloser(bytes.NewReader(jsonData))
response.StatusCode = statusCode
response.Body = body
response.ContentLength = int64(len(jsonData))
if response.Header == nil {
response.Header = make(http.Header)
}
response.Header.Set("Content-Length", strconv.Itoa(len(jsonData)))
return nil
}

View File

@@ -0,0 +1,46 @@
package proxy
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
req.Host = req.URL.Host
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}
return &httputil.ReverseProxy{Director: director}
}
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
// included here for use in NewSingleHostReverseProxyWithHostHeader
// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}

67
api/http/proxy/secrets.go Normal file
View File

@@ -0,0 +1,67 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerSecretIdentifierNotFound defines an error raised when Portainer is unable to find a secret identifier
ErrDockerSecretIdentifierNotFound = portainer.Error("Docker secret identifier not found")
secretIdentifier = "ID"
)
// secretListOperation extracts the response as a JSON object, loop through the secrets array
// decorate and/or filter the secrets based on resource controls before rewriting the response
func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// SecretList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/SecretList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterSecretList(responseArray, executor.operationContext.resourceControls,
executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// secretInspectOperation extracts the response as a JSON object, verify that the user
// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated secret.
func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// SecretInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[secretIdentifier] == nil {
return ErrDockerSecretIdentifierNotFound
}
secretID := responseObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
return rewriteResponse(response, responseObject, http.StatusOK)
}

64
api/http/proxy/service.go Normal file
View File

@@ -0,0 +1,64 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
serviceIdentifier = "ID"
)
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response
// or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[serviceIdentifier] == nil {
return ErrDockerServiceIdentifierNotFound
}
serviceID := responseObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
return rewriteResponse(response, responseObject, http.StatusOK)
}

43
api/http/proxy/socket.go Normal file
View File

@@ -0,0 +1,43 @@
package proxy
// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket
import (
"io"
"net/http"
httperror "github.com/portainer/portainer/http/error"
)
type socketProxy struct {
Transport *proxyTransport
}
func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Force URL/domain to http/unixsocket to be able to
// use http.Transport RoundTrip to do the requests via the socket
r.URL.Scheme = "http"
r.URL.Host = "unixsocket"
res, err := proxy.Transport.proxyDockerRequest(r)
if err != nil {
code := http.StatusInternalServerError
if res != nil && res.StatusCode != 0 {
code = res.StatusCode
}
httperror.WriteErrorResponse(w, err, code, nil)
return
}
defer res.Body.Close()
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
if _, err := io.Copy(w, res.Body); err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
}
}

36
api/http/proxy/tasks.go Normal file
View File

@@ -0,0 +1,36 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task
ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found")
taskServiceIdentifier = "ServiceID"
)
// taskListOperation extracts the response as a JSON object, loop through the tasks array
// and filter the tasks based on resource controls before rewriting the response
func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// TaskList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/TaskList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if !executor.operationContext.isAdmin {
responseArray, err = filterTaskList(responseArray, executor.operationContext.resourceControls,
executor.operationContext.userID, executor.operationContext.userTeamIDs)
if err != nil {
return err
}
}
return rewriteResponse(response, responseArray, http.StatusOK)
}

349
api/http/proxy/transport.go Normal file
View File

@@ -0,0 +1,349 @@
package proxy
import (
"net"
"net/http"
"path"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
)
type (
proxyTransport struct {
dockerTransport *http.Transport
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
}
restrictedOperationContext struct {
isAdmin bool
userID portainer.UserID
userTeamIDs []portainer.TeamID
resourceControls []portainer.ResourceControl
}
operationExecutor struct {
operationContext *restrictedOperationContext
labelBlackList []portainer.Pair
}
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
)
func newSocketTransport(socketPath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
}
func newHTTPTransport() *http.Transport {
return &http.Transport{}
}
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return p.proxyDockerRequest(request)
}
func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) {
return p.dockerTransport.RoundTrip(request)
}
func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) {
path := request.URL.Path
switch {
case strings.HasPrefix(path, "/containers"):
return p.proxyContainerRequest(request)
case strings.HasPrefix(path, "/services"):
return p.proxyServiceRequest(request)
case strings.HasPrefix(path, "/volumes"):
return p.proxyVolumeRequest(request)
case strings.HasPrefix(path, "/networks"):
return p.proxyNetworkRequest(request)
case strings.HasPrefix(path, "/secrets"):
return p.proxySecretRequest(request)
case strings.HasPrefix(path, "/swarm"):
return p.proxySwarmRequest(request)
case strings.HasPrefix(path, "/nodes"):
return p.proxyNodeRequest(request)
case strings.HasPrefix(path, "/tasks"):
return p.proxyTaskRequest(request)
default:
return p.executeDockerRequest(request)
}
}
func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/containers/create":
return p.executeDockerRequest(request)
case "/containers/prune":
return p.administratorOperation(request)
case "/containers/json":
return p.rewriteOperationWithLabelFiltering(request, containerListOperation)
default:
// This section assumes /containers/**
if match, _ := path.Match("/containers/*/*", requestPath); match {
// Handle /containers/{id}/{action} requests
containerID := path.Base(path.Dir(requestPath))
action := path.Base(requestPath)
if action == "json" {
return p.rewriteOperation(request, containerInspectOperation)
}
return p.restrictedOperation(request, containerID)
} else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests
containerID := path.Base(requestPath)
return p.restrictedOperation(request, containerID)
}
return p.executeDockerRequest(request)
}
}
func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/services/create":
return p.executeDockerRequest(request)
case "/services":
return p.rewriteOperation(request, serviceListOperation)
default:
// This section assumes /services/**
if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath))
return p.restrictedOperation(request, serviceID)
} else if match, _ := path.Match("/services/*", requestPath); match {
// Handle /services/{id} requests
serviceID := path.Base(requestPath)
if request.Method == http.MethodGet {
return p.rewriteOperation(request, serviceInspectOperation)
}
return p.restrictedOperation(request, serviceID)
}
return p.executeDockerRequest(request)
}
}
func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/volumes/create":
return p.executeDockerRequest(request)
case "/volumes/prune":
return p.administratorOperation(request)
case "/volumes":
return p.rewriteOperation(request, volumeListOperation)
default:
// assume /volumes/{name}
if request.Method == http.MethodGet {
return p.rewriteOperation(request, volumeInspectOperation)
}
volumeID := path.Base(requestPath)
return p.restrictedOperation(request, volumeID)
}
}
func (p *proxyTransport) proxyNetworkRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/networks/create":
return p.executeDockerRequest(request)
case "/networks":
return p.rewriteOperation(request, networkListOperation)
default:
// assume /networks/{id}
if request.Method == http.MethodGet {
return p.rewriteOperation(request, networkInspectOperation)
}
networkID := path.Base(requestPath)
return p.restrictedOperation(request, networkID)
}
}
func (p *proxyTransport) proxySecretRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/secrets/create":
return p.executeDockerRequest(request)
case "/secrets":
return p.rewriteOperation(request, secretListOperation)
default:
// assume /secrets/{id}
if request.Method == http.MethodGet {
return p.rewriteOperation(request, secretInspectOperation)
}
secretID := path.Base(requestPath)
return p.restrictedOperation(request, secretID)
}
}
func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response, error) {
requestPath := request.URL.Path
// assume /nodes/{id}
if path.Base(requestPath) != "nodes" {
return p.administratorOperation(request)
}
return p.executeDockerRequest(request)
}
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
return p.administratorOperation(request)
}
func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/tasks":
return p.rewriteOperation(request, taskListOperation)
default:
// assume /tasks/{id}
return p.executeDockerRequest(request)
}
}
// restrictedOperation ensures that the current user has the required authorizations
// before executing the original request.
func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
if tokenData.Role != portainer.AdministratorRole {
teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range teamMemberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
resourceControls, err := p.ResourceControlService.ResourceControls()
if err != nil {
return nil, err
}
resourceControl := getResourceControlByResourceID(resourceID, resourceControls)
if resourceControl != nil && !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
return writeAccessDeniedResponse()
}
}
return p.executeDockerRequest(request)
}
// rewriteOperation will create a new operation context with data that will be used
// to decorate the original request's response as well as retrieve all the black listed labels
// to filter the resources.
func (p *proxyTransport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
operationContext, err := p.createOperationContext(request)
if err != nil {
return nil, err
}
settings, err := p.SettingsService.Settings()
if err != nil {
return nil, err
}
executor := &operationExecutor{
operationContext: operationContext,
labelBlackList: settings.BlackListedLabels,
}
return p.executeRequestAndRewriteResponse(request, operation, executor)
}
// rewriteOperation will create a new operation context with data that will be used
// to decorate the original request's response.
func (p *proxyTransport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
operationContext, err := p.createOperationContext(request)
if err != nil {
return nil, err
}
executor := &operationExecutor{
operationContext: operationContext,
}
return p.executeRequestAndRewriteResponse(request, operation, executor)
}
func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) {
response, err := p.executeDockerRequest(request)
if err != nil {
return response, err
}
err = operation(request, response, executor)
return response, err
}
// administratorOperation ensures that the user has administrator privileges
// before executing the original request.
func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
if tokenData.Role != portainer.AdministratorRole {
return writeAccessDeniedResponse()
}
return p.executeDockerRequest(request)
}
func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedOperationContext, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
resourceControls, err := p.ResourceControlService.ResourceControls()
if err != nil {
return nil, err
}
operationContext := &restrictedOperationContext{
isAdmin: true,
userID: tokenData.ID,
resourceControls: resourceControls,
}
if tokenData.Role != portainer.AdministratorRole {
operationContext.isAdmin = false
teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range teamMemberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
operationContext.userTeamIDs = userTeamIDs
}
return operationContext, nil
}

32
api/http/proxy/utils.go Normal file
View File

@@ -0,0 +1,32 @@
package proxy
import "github.com/portainer/portainer"
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
for _, resourceControl := range resourceControls {
if resourceID == resourceControl.ResourceID {
return &resourceControl
}
for _, subResourceID := range resourceControl.SubResourceIDs {
if resourceID == subResourceID {
return &resourceControl
}
}
}
return nil
}
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
for key, value := range containerLabels {
labelName := key
labelValue := value.(string)
for _, blackListedLabel := range labelBlackList {
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
return true
}
}
}
return false
}

73
api/http/proxy/volumes.go Normal file
View File

@@ -0,0 +1,73 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier
ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found")
volumeIdentifier = "Name"
)
// volumeListOperation extracts the response as a JSON object, loop through the volume array
// decorate and/or filter the volumes based on resource controls before rewriting the response
func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// VolumeList response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
// The "Volumes" field contains the list of volumes as an array of JSON objects
// Response schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
if responseObject["Volumes"] != nil {
volumeData := responseObject["Volumes"].([]interface{})
if executor.operationContext.isAdmin {
volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls)
} else {
volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
// Overwrite the original volume list
responseObject["Volumes"] = volumeData
}
return rewriteResponse(response, responseObject, http.StatusOK)
}
// volumeInspectOperation extracts the response as a JSON object, verify that the user
// has access to the volume based on resource control and either rewrite an access denied response
// or a decorated volume.
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[volumeIdentifier] == nil {
return ErrDockerVolumeIdentifierNotFound
}
volumeID := responseObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
return rewriteResponse(response, responseObject, http.StatusOK)
}

View File

@@ -0,0 +1,123 @@
package security
import "github.com/portainer/portainer"
// AuthorizedResourceControlDeletion ensure that the user can delete a resource control object.
// A non-administrator user cannot delete a resource control where:
// * the AdministratorsOnly flag is set
// * he is not one of the users in the user accesses
// * he is not a member of any team within the team accesses
func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
if context.IsAdmin {
return true
}
if resourceControl.AdministratorsOnly {
return false
}
userAccessesCount := len(resourceControl.UserAccesses)
teamAccessesCount := len(resourceControl.TeamAccesses)
if teamAccessesCount > 0 {
for _, access := range resourceControl.TeamAccesses {
for _, membership := range context.UserMemberships {
if membership.TeamID == access.TeamID {
return true
}
}
}
}
if userAccessesCount > 0 {
for _, access := range resourceControl.UserAccesses {
if access.UserID == context.UserID {
return true
}
}
}
return false
}
// AuthorizedResourceControlUpdate ensure that the user can update a resource control object.
// It reuses the creation restrictions and adds extra checks.
// A non-administrator user cannot update a resource control where:
// * he wants to put one or more user in the user accesses
func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
userAccessesCount := len(resourceControl.UserAccesses)
if !context.IsAdmin && userAccessesCount > 0 {
return false
}
return AuthorizedResourceControlCreation(resourceControl, context)
}
// AuthorizedResourceControlCreation ensure that the user can create a resource control object.
// A non-administrator user cannot create a resource control where:
// * the AdministratorsOnly flag is set
// * he wants to add more than one user in the user accesses
// * he wants to add a team he is not a member of
func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
if context.IsAdmin {
return true
}
if resourceControl.AdministratorsOnly {
return false
}
userAccessesCount := len(resourceControl.UserAccesses)
teamAccessesCount := len(resourceControl.TeamAccesses)
if userAccessesCount > 1 || (userAccessesCount == 1 && teamAccessesCount == 1) {
return false
}
if userAccessesCount == 1 {
access := resourceControl.UserAccesses[0]
if access.UserID == context.UserID {
return true
}
}
if teamAccessesCount > 0 {
for _, access := range resourceControl.TeamAccesses {
isMember := false
for _, membership := range context.UserMemberships {
if membership.TeamID == access.TeamID {
isMember = true
}
}
if !isMember {
return false
}
}
}
return true
}
// AuthorizedTeamManagement ensure that access to the management of the specified team is granted.
// It will check if the user is either administrator or leader of that team.
func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedRequestContext) bool {
if context.IsAdmin {
return true
}
for _, membership := range context.UserMemberships {
if membership.TeamID == teamID && membership.Role == portainer.TeamLeader {
return true
}
}
return false
}
// AuthorizedUserManagement ensure that access to the management of the specified user is granted.
// It will check if the user is either administrator or the owner of the user account.
func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedRequestContext) bool {
if context.IsAdmin || context.UserID == userID {
return true
}
return false
}

View File

@@ -0,0 +1,176 @@
package security
import (
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"net/http"
"strings"
)
type (
// RequestBouncer represents an entity that manages API request accesses
RequestBouncer struct {
jwtService portainer.JWTService
teamMembershipService portainer.TeamMembershipService
authDisabled bool
}
// RestrictedRequestContext is a data structure containing information
// used in RestrictedAccess
RestrictedRequestContext struct {
IsAdmin bool
IsTeamLeader bool
UserID portainer.UserID
UserMemberships []portainer.TeamMembership
}
)
// NewRequestBouncer initializes a new RequestBouncer
func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
return &RequestBouncer{
jwtService: jwtService,
teamMembershipService: teamMembershipService,
authDisabled: authDisabled,
}
}
// PublicAccess defines a security check for public endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
h = mwSecureHeaders(h)
return h
}
// AuthenticatedAccess defines a security check for private endpoints.
// Authentication is required to access these endpoints.
func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
h = bouncer.mwCheckAuthentication(h)
h = mwSecureHeaders(h)
return h
}
// RestrictedAccess defines a security check for restricted endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to authorize/filter access to resources.
func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler {
h = bouncer.mwUpgradeToRestrictedRequest(h)
h = bouncer.AuthenticatedAccess(h)
return h
}
// AdministratorAccess defines a chain of middleware for restricted endpoints.
// Authentication as well as administrator role are required to access these endpoints.
func (bouncer *RequestBouncer) AdministratorAccess(h http.Handler) http.Handler {
h = mwCheckAdministratorRole(h)
h = bouncer.AuthenticatedAccess(h)
return h
}
// mwSecureHeaders provides secure headers middleware for handlers.
func mwSecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Content-Type-Options", "nosniff")
w.Header().Add("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
// mwUpgradeToRestrictedRequest will enhance the current request with
// a new RestrictedRequestContext object.
func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenData, err := RetrieveTokenData(r)
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
return
}
ctx := storeRestrictedRequestContext(r, requestContext)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// mwCheckAdministratorRole check the role of the user associated to the request
func mwCheckAdministratorRole(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenData, err := RetrieveTokenData(r)
if err != nil || tokenData.Role != portainer.AdministratorRole {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
next.ServeHTTP(w, r)
})
}
// mwCheckAuthentication provides Authentication middleware for handlers
func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenData *portainer.TokenData
if !bouncer.authDisabled {
var token string
// Get token from the Authorization header
tokens, ok := r.Header["Authorization"]
if ok && len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
}
if token == "" {
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil)
return
}
var err error
tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil)
return
}
} else {
tokenData = &portainer.TokenData{
Role: portainer.AdministratorRole,
}
}
ctx := storeTokenData(r, tokenData)
next.ServeHTTP(w, r.WithContext(ctx))
return
})
}
func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) {
requestContext := &RestrictedRequestContext{
IsAdmin: true,
UserID: userID,
}
if userRole != portainer.AdministratorRole {
requestContext.IsAdmin = false
memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(userID)
if err != nil {
return nil, err
}
isTeamLeader := false
for _, membership := range memberships {
if membership.Role == portainer.TeamLeader {
isTeamLeader = true
}
}
requestContext.IsTeamLeader = isTeamLeader
requestContext.UserMemberships = memberships
}
return requestContext, nil
}

View File

@@ -0,0 +1,50 @@
package security
import (
"context"
"net/http"
"github.com/portainer/portainer"
)
type (
contextKey int
)
const (
contextAuthenticationKey contextKey = iota
contextRestrictedRequest
)
// storeTokenData stores a TokenData object inside the request context and returns the enhanced context.
func storeTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context {
return context.WithValue(request.Context(), contextAuthenticationKey, tokenData)
}
// RetrieveTokenData returns the TokenData object stored in the request context.
func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) {
contextData := request.Context().Value(contextAuthenticationKey)
if contextData == nil {
return nil, portainer.ErrMissingContextData
}
tokenData := contextData.(*portainer.TokenData)
return tokenData, nil
}
// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context
// and returns the enhanced context.
func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context {
return context.WithValue(request.Context(), contextRestrictedRequest, requestContext)
}
// RetrieveRestrictedRequestContext returns the RestrictedRequestContext object stored in the request context.
func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequestContext, error) {
contextData := request.Context().Value(contextRestrictedRequest)
if contextData == nil {
return nil, portainer.ErrMissingSecurityContext
}
requestContext := contextData.(*RestrictedRequestContext)
return requestContext, nil
}

129
api/http/security/filter.go Normal file
View File

@@ -0,0 +1,129 @@
package security
import "github.com/portainer/portainer"
// FilterUserTeams filters teams based on user role.
// non-administrator users only have access to team they are member of.
func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team {
filteredTeams := teams
if !context.IsAdmin {
filteredTeams = make([]portainer.Team, 0)
for _, membership := range context.UserMemberships {
for _, team := range teams {
if team.ID == membership.TeamID {
filteredTeams = append(filteredTeams, team)
break
}
}
}
}
return filteredTeams
}
// FilterLeaderTeams filters teams based on user role.
// Team leaders only have access to team they lead.
func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team {
filteredTeams := teams
if context.IsTeamLeader {
filteredTeams = make([]portainer.Team, 0)
for _, membership := range context.UserMemberships {
for _, team := range teams {
if team.ID == membership.TeamID && membership.Role == portainer.TeamLeader {
filteredTeams = append(filteredTeams, team)
break
}
}
}
}
return filteredTeams
}
// FilterUsers filters users based on user role.
// Non-administrator users only have access to non-administrator users.
func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []portainer.User {
filteredUsers := users
if !context.IsAdmin {
filteredUsers = make([]portainer.User, 0)
for _, user := range users {
if user.Role != portainer.AdministratorRole {
filteredUsers = append(filteredUsers, user)
}
}
}
return filteredUsers
}
// FilterRegistries filters registries based on user role and team memberships.
// Non administrator users only have access to authorized endpoints.
func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) {
filteredRegistries := registries
if !context.IsAdmin {
filteredRegistries = make([]portainer.Registry, 0)
for _, registry := range registries {
if isRegistryAccessAuthorized(&registry, context.UserID, context.UserMemberships) {
filteredRegistries = append(filteredRegistries, registry)
}
}
}
return filteredRegistries, nil
}
// FilterEndpoints filters endpoints based on user role and team memberships.
// Non administrator users only have access to authorized endpoints.
func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
filteredEndpoints := endpoints
if !context.IsAdmin {
filteredEndpoints = make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if isEndpointAccessAuthorized(&endpoint, context.UserID, context.UserMemberships) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
}
return filteredEndpoints, nil
}
func isRegistryAccessAuthorized(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range registry.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range registry.AuthorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
}
func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
}

109
api/http/server.go Normal file
View File

@@ -0,0 +1,109 @@
package http
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/handler"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
"net/http"
)
// Server implements the portainer.Server interface
type Server struct {
BindAddress string
AssetsPath string
AuthDisabled bool
EndpointManagement bool
Status *portainer.Status
UserService portainer.UserService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
EndpointService portainer.EndpointService
ResourceControlService portainer.ResourceControlService
SettingsService portainer.SettingsService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
FileService portainer.FileService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
LDAPService portainer.LDAPService
Handler *handler.Handler
SSL bool
SSLCert string
SSLKey string
}
// Start starts the HTTP server
func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
authHandler.LDAPService = server.LDAPService
authHandler.SettingsService = server.SettingsService
var userHandler = handler.NewUserHandler(requestBouncer)
userHandler.UserService = server.UserService
userHandler.TeamService = server.TeamService
userHandler.TeamMembershipService = server.TeamMembershipService
userHandler.CryptoService = server.CryptoService
userHandler.ResourceControlService = server.ResourceControlService
userHandler.SettingsService = server.SettingsService
var teamHandler = handler.NewTeamHandler(requestBouncer)
teamHandler.TeamService = server.TeamService
teamHandler.TeamMembershipService = server.TeamMembershipService
var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer)
teamMembershipHandler.TeamMembershipService = server.TeamMembershipService
var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status)
var settingsHandler = handler.NewSettingsHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService
settingsHandler.LDAPService = server.LDAPService
settingsHandler.FileService = server.FileService
var templatesHandler = handler.NewTemplatesHandler(requestBouncer)
templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer)
dockerHandler.EndpointService = server.EndpointService
dockerHandler.TeamMembershipService = server.TeamMembershipService
dockerHandler.ProxyManager = proxyManager
var websocketHandler = handler.NewWebSocketHandler()
websocketHandler.EndpointService = server.EndpointService
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
endpointHandler.EndpointService = server.EndpointService
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager
var registryHandler = handler.NewRegistryHandler(requestBouncer)
registryHandler.RegistryService = server.RegistryService
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
dockerHubHandler.DockerHubService = server.DockerHubService
var resourceHandler = handler.NewResourceHandler(requestBouncer)
resourceHandler.ResourceControlService = server.ResourceControlService
var uploadHandler = handler.NewUploadHandler(requestBouncer)
uploadHandler.FileService = server.FileService
var fileHandler = handler.NewFileHandler(server.AssetsPath)
server.Handler = &handler.Handler{
AuthHandler: authHandler,
UserHandler: userHandler,
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,
EndpointHandler: endpointHandler,
RegistryHandler: registryHandler,
DockerHubHandler: dockerHubHandler,
ResourceHandler: resourceHandler,
SettingsHandler: settingsHandler,
StatusHandler: statusHandler,
TemplatesHandler: templatesHandler,
DockerHandler: dockerHandler,
WebSocketHandler: websocketHandler,
FileHandler: fileHandler,
UploadHandler: uploadHandler,
}
if server.SSL {
return http.ListenAndServeTLS(server.BindAddress, server.SSLCert, server.SSLKey, server.Handler)
}
return http.ListenAndServe(server.BindAddress, server.Handler)
}

79
api/jwt/jwt.go Normal file
View File

@@ -0,0 +1,79 @@
package jwt
import (
"github.com/portainer/portainer"
"fmt"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gorilla/securecookie"
)
// Service represents a service for managing JWT tokens.
type Service struct {
secret []byte
}
type claims struct {
UserID int `json:"id"`
Username string `json:"username"`
Role int `json:"role"`
jwt.StandardClaims
}
// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens.
func NewService() (*Service, error) {
secret := securecookie.GenerateRandomKey(32)
if secret == nil {
return nil, portainer.ErrSecretGeneration
}
service := &Service{
secret,
}
return service, nil
}
// GenerateToken generates a new JWT token.
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
expireToken := time.Now().Add(time.Hour * 8).Unix()
cl := claims{
int(data.ID),
data.Username,
int(data.Role),
jwt.StandardClaims{
ExpiresAt: expireToken,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
signedToken, err := token.SignedString(service.secret)
if err != nil {
return "", err
}
return signedToken, nil
}
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) {
parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
return nil, msg
}
return service.secret, nil
})
if err == nil && parsedToken != nil {
if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid {
tokenData := &portainer.TokenData{
ID: portainer.UserID(cl.UserID),
Username: cl.Username,
Role: portainer.UserRole(cl.Role),
}
return tokenData, nil
}
}
return nil, portainer.ErrInvalidJWTToken
}

123
api/ldap/ldap.go Normal file
View File

@@ -0,0 +1,123 @@
package ldap
import (
"fmt"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"gopkg.in/ldap.v2"
)
const (
// ErrUserNotFound defines an error raised when the user is not found via LDAP search
// or that too many entries (> 1) are returned.
ErrUserNotFound = portainer.Error("User not found or too many entries returned")
)
// Service represents a service used to authenticate users against a LDAP/AD.
type Service struct{}
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
var userDN string
found := false
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username),
[]string{"dn"},
nil,
)
// Deliberately skip errors on the search request so that we can jump to other search settings
// if any issue arise with the current one.
sr, _ := conn.Search(searchRequest)
if len(sr.Entries) == 1 {
found = true
userDN = sr.Entries[0].DN
break
}
}
if !found {
return "", ErrUserNotFound
}
return userDN, nil
}
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
if settings.TLSConfig.TLS || settings.StartTLS {
config, err := crypto.CreateTLSConfiguration(&settings.TLSConfig)
if err != nil {
return nil, err
}
config.ServerName = strings.Split(settings.URL, ":")[0]
if settings.TLSConfig.TLS {
return ldap.DialTLS("tcp", settings.URL, config)
}
conn, err := ldap.Dial("tcp", settings.URL)
if err != nil {
return nil, err
}
err = conn.StartTLS(config)
if err != nil {
return nil, err
}
return conn, nil
}
return ldap.Dial("tcp", settings.URL)
}
// AuthenticateUser is used to authenticate a user against a LDAP/AD.
func (*Service) AuthenticateUser(username, password string, settings *portainer.LDAPSettings) error {
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
userDN, err := searchUser(username, connection, settings.SearchSettings)
if err != nil {
return err
}
err = connection.Bind(userDN, password)
if err != nil {
return err
}
return nil
}
// TestConnectivity is used to test a connection against the LDAP server using the credentials
// specified in the LDAPSettings.
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
return nil
}

View File

@@ -1,47 +0,0 @@
package main // import "github.com/cloudinovasi/portainer"
import (
"gopkg.in/alecthomas/kingpin.v2"
)
// main is the entry point of the program
func main() {
kingpin.Version("1.8.0")
var (
endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String()
addr = kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String()
assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String()
data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String()
tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool()
tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String()
tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String()
tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String()
swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool()
labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l'))
logo = kingpin.Flag("logo", "URL for the logo displayed in the UI").String()
templates = kingpin.Flag("templates", "URL to the templates (apps) definitions").Default("https://raw.githubusercontent.com/cloud-inovasi/ui-templates/master/templates.json").Short('t').String()
)
kingpin.Parse()
apiConfig := apiConfig{
Endpoint: *endpoint,
BindAddress: *addr,
AssetPath: *assets,
DataPath: *data,
SwarmSupport: *swarm,
TLSEnabled: *tlsverify,
TLSCACertPath: *tlscacert,
TLSCertPath: *tlscert,
TLSKeyPath: *tlskey,
TemplatesURL: *templates,
}
settings := &Settings{
Swarm: *swarm,
HiddenLabels: *labels,
Logo: *logo,
}
api := newAPI(apiConfig)
api.run(settings)
}

406
api/portainer.go Normal file
View File

@@ -0,0 +1,406 @@
package portainer
import "io"
type (
// Pair defines a key/value string pair
Pair struct {
Name string `json:"name"`
Value string `json:"value"`
}
// CLIFlags represents the available flags on the CLI.
CLIFlags struct {
Addr *string
Assets *string
Data *string
ExternalEndpoints *string
SyncInterval *string
Endpoint *string
NoAuth *bool
NoAnalytics *bool
TLSVerify *bool
TLSCacert *string
TLSCert *string
TLSKey *string
SSL *bool
SSLCert *string
SSLKey *string
AdminPassword *string
// Deprecated fields
Logo *string
Templates *string
Labels *[]Pair
}
// Status represents the application status.
Status struct {
Authentication bool `json:"Authentication"`
EndpointManagement bool `json:"EndpointManagement"`
Analytics bool `json:"Analytics"`
Version string `json:"Version"`
}
// LDAPSettings represents the settings used to connect to a LDAP server.
LDAPSettings struct {
ReaderDN string `json:"ReaderDN"`
Password string `json:"Password"`
URL string `json:"URL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
StartTLS bool `json:"StartTLS"`
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
}
// TLSConfiguration represents a TLS configuration.
TLSConfiguration struct {
TLS bool `json:"TLS"`
TLSSkipVerify bool `json:"TLSSkipVerify"`
TLSCACertPath string `json:"TLSCACert,omitempty"`
TLSCertPath string `json:"TLSCert,omitempty"`
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// LDAPSearchSettings represents settings used to search for users in a LDAP server.
LDAPSearchSettings struct {
BaseDN string `json:"BaseDN"`
Filter string `json:"Filter"`
UserNameAttribute string `json:"UserNameAttribute"`
}
// Settings represents the application settings.
Settings struct {
TemplatesURL string `json:"TemplatesURL"`
LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
LDAPSettings LDAPSettings `json:"LDAPSettings"`
}
// User represents a user account.
User struct {
ID UserID `json:"Id"`
Username string `json:"Username"`
Password string `json:"Password,omitempty"`
Role UserRole `json:"Role"`
}
// UserID represents a user identifier
UserID int
// UserRole represents the role of a user. It can be either an administrator
// or a regular user
UserRole int
// AuthenticationMethod represents the authentication method used to authenticate a user.
AuthenticationMethod int
// Team represents a list of user accounts.
Team struct {
ID TeamID `json:"Id"`
Name string `json:"Name"`
}
// TeamID represents a team identifier
TeamID int
// TeamMembership represents a membership association between a user and a team
TeamMembership struct {
ID TeamMembershipID `json:"Id"`
UserID UserID `json:"UserID"`
TeamID TeamID `json:"TeamID"`
Role MembershipRole `json:"Role"`
}
// TeamMembershipID represents a team membership identifier
TeamMembershipID int
// MembershipRole represents the role of a user within a team
MembershipRole int
// TokenData represents the data embedded in a JWT token.
TokenData struct {
ID UserID
Username string
Role UserRole
}
// RegistryID represents a registry identifier.
RegistryID int
// Registry represents a Docker registry with all the info required
// to connect to it.
Registry struct {
ID RegistryID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
Authentication bool `json:"Authentication"`
Username string `json:"Username"`
Password string `json:"Password"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
}
// DockerHub represents all the required information to connect and use the
// Docker Hub.
DockerHub struct {
Authentication bool `json:"Authentication"`
Username string `json:"Username"`
Password string `json:"Password"`
}
// EndpointID represents an endpoint identifier.
EndpointID int
// Endpoint represents a Docker endpoint with all the info required
// to connect to it.
Endpoint struct {
ID EndpointID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
// Deprecated fields
// Deprecated in DBVersion == 4
TLS bool `json:"TLS,omitempty"`
TLSCACertPath string `json:"TLSCACert,omitempty"`
TLSCertPath string `json:"TLSCert,omitempty"`
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// ResourceControlID represents a resource control identifier.
ResourceControlID int
// ResourceControl represent a reference to a Docker resource with specific access controls
ResourceControl struct {
ID ResourceControlID `json:"Id"`
ResourceID string `json:"ResourceId"`
SubResourceIDs []string `json:"SubResourceIds"`
Type ResourceControlType `json:"Type"`
AdministratorsOnly bool `json:"AdministratorsOnly"`
UserAccesses []UserResourceAccess `json:"UserAccesses"`
TeamAccesses []TeamResourceAccess `json:"TeamAccesses"`
// Deprecated fields
// Deprecated in DBVersion == 2
OwnerID UserID `json:"OwnerId,omitempty"`
AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"`
}
// ResourceControlType represents the type of resource associated to the resource control (volume, container, service).
ResourceControlType int
// UserResourceAccess represents the level of control on a resource for a specific user.
UserResourceAccess struct {
UserID UserID `json:"UserId"`
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
}
// TeamResourceAccess represents the level of control on a resource for a specific team.
TeamResourceAccess struct {
TeamID TeamID `json:"TeamId"`
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
}
// ResourceAccessLevel represents the level of control associated to a resource.
ResourceAccessLevel int
// TLSFileType represents a type of TLS file required to connect to a Docker endpoint.
// It can be either a TLS CA file, a TLS certificate file or a TLS key file.
TLSFileType int
// CLIService represents a service for managing CLI.
CLIService interface {
ParseFlags(version string) (*CLIFlags, error)
ValidateFlags(flags *CLIFlags) error
}
// DataStore defines the interface to manage the data.
DataStore interface {
Open() error
Close() error
MigrateData() error
}
// Server defines the interface to serve the API.
Server interface {
Start() error
}
// UserService represents a service for managing user data.
UserService interface {
User(ID UserID) (*User, error)
UserByUsername(username string) (*User, error)
Users() ([]User, error)
UsersByRole(role UserRole) ([]User, error)
CreateUser(user *User) error
UpdateUser(ID UserID, user *User) error
DeleteUser(ID UserID) error
}
// TeamService represents a service for managing user data.
TeamService interface {
Team(ID TeamID) (*Team, error)
TeamByName(name string) (*Team, error)
Teams() ([]Team, error)
CreateTeam(team *Team) error
UpdateTeam(ID TeamID, team *Team) error
DeleteTeam(ID TeamID) error
}
// TeamMembershipService represents a service for managing team membership data.
TeamMembershipService interface {
TeamMembership(ID TeamMembershipID) (*TeamMembership, error)
TeamMemberships() ([]TeamMembership, error)
TeamMembershipsByUserID(userID UserID) ([]TeamMembership, error)
TeamMembershipsByTeamID(teamID TeamID) ([]TeamMembership, error)
CreateTeamMembership(membership *TeamMembership) error
UpdateTeamMembership(ID TeamMembershipID, membership *TeamMembership) error
DeleteTeamMembership(ID TeamMembershipID) error
DeleteTeamMembershipByUserID(userID UserID) error
DeleteTeamMembershipByTeamID(teamID TeamID) error
}
// EndpointService represents a service for managing endpoint data.
EndpointService interface {
Endpoint(ID EndpointID) (*Endpoint, error)
Endpoints() ([]Endpoint, error)
CreateEndpoint(endpoint *Endpoint) error
UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
DeleteEndpoint(ID EndpointID) error
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
}
// RegistryService represents a service for managing registry data.
RegistryService interface {
Registry(ID RegistryID) (*Registry, error)
Registries() ([]Registry, error)
CreateRegistry(registry *Registry) error
UpdateRegistry(ID RegistryID, registry *Registry) error
DeleteRegistry(ID RegistryID) error
}
// DockerHubService represents a service for managing the DockerHub object.
DockerHubService interface {
DockerHub() (*DockerHub, error)
StoreDockerHub(registry *DockerHub) error
}
// SettingsService represents a service for managing application settings.
SettingsService interface {
Settings() (*Settings, error)
StoreSettings(settings *Settings) error
}
// VersionService represents a service for managing version data.
VersionService interface {
DBVersion() (int, error)
StoreDBVersion(version int) error
}
// ResourceControlService represents a service for managing resource control data.
ResourceControlService interface {
ResourceControl(ID ResourceControlID) (*ResourceControl, error)
ResourceControlByResourceID(resourceID string) (*ResourceControl, error)
ResourceControls() ([]ResourceControl, error)
CreateResourceControl(rc *ResourceControl) error
UpdateResourceControl(ID ResourceControlID, resourceControl *ResourceControl) error
DeleteResourceControl(ID ResourceControlID) error
}
// CryptoService represents a service for encrypting/hashing data.
CryptoService interface {
Hash(data string) (string, error)
CompareHashAndData(hash string, data string) error
}
// JWTService represents a service for managing JWT tokens.
JWTService interface {
GenerateToken(data *TokenData) (string, error)
ParseAndVerifyToken(token string) (*TokenData, error)
}
// FileService represents a service for managing files.
FileService interface {
StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error
GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
DeleteTLSFile(folder string, fileType TLSFileType) error
DeleteTLSFiles(folder string) error
}
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
EndpointWatcher interface {
WatchEndpointFile(endpointFilePath string) error
}
// LDAPService represents a service used to authenticate users against a LDAP/AD.
LDAPService interface {
AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error
}
)
const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.14.1"
// DBVersion is the version number of the Portainer database.
DBVersion = 4
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
)
const (
// TLSFileCA represents a TLS CA certificate file.
TLSFileCA TLSFileType = iota
// TLSFileCert represents a TLS certificate file.
TLSFileCert
// TLSFileKey represents a TLS key file.
TLSFileKey
)
const (
_ MembershipRole = iota
// TeamLeader represents a leader role inside a team
TeamLeader
// TeamMember represents a member role inside a team
TeamMember
)
const (
_ UserRole = iota
// AdministratorRole represents an administrator user role
AdministratorRole
// StandardUserRole represents a regular user role
StandardUserRole
)
const (
_ AuthenticationMethod = iota
// AuthenticationInternal represents the internal authentication method (authentication against Portainer API)
AuthenticationInternal
// AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
AuthenticationLDAP
)
const (
_ ResourceAccessLevel = iota
// ReadWriteAccessLevel represents an access level with read-write permissions on a resource
ReadWriteAccessLevel
)
const (
_ ResourceControlType = iota
// ContainerResourceControl represents a resource control associated to a Docker container
ContainerResourceControl
// ServiceResourceControl represents a resource control associated to a Docker service
ServiceResourceControl
// VolumeResourceControl represents a resource control associated to a Docker volume
VolumeResourceControl
// NetworkResourceControl represents a resource control associated to a Docker network
NetworkResourceControl
// SecretResourceControl represents a resource control associated to a Docker secret
SecretResourceControl
)

View File

@@ -1,18 +0,0 @@
package main
import (
"encoding/json"
"net/http"
)
// Settings defines the settings available under the /settings endpoint
type Settings struct {
Swarm bool `json:"swarm"`
HiddenLabels pairList `json:"hiddenLabels"`
Logo string `json:"logo"`
}
// settingsHandler defines a handler function used to encode the configuration in JSON
func settingsHandler(w http.ResponseWriter, r *http.Request, s *Settings) {
json.NewEncoder(w).Encode(*s)
}

View File

@@ -1,27 +0,0 @@
package main
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"log"
)
// newTLSConfig initializes a tls.Config using a CA certificate, a certificate and a key
func newTLSConfig(caCertPath, certPath, keyPath string) *tls.Config {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
log.Fatal(err)
}
caCert, err := ioutil.ReadFile(caCertPath)
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
}
return tlsConfig
}

2575
api/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
)
// templatesHandler defines a handler function used to retrieve the templates from a URL and put them in the response
func templatesHandler(w http.ResponseWriter, r *http.Request, templatesURL string) {
resp, err := http.Get(templatesURL)
if err != nil {
http.Error(w, fmt.Sprintf("Error making request to %s: %s", templatesURL, err.Error()), http.StatusInternalServerError)
log.Print(err)
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Error reading body from templates URL", http.StatusInternalServerError)
log.Print(err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}

View File

@@ -1,47 +0,0 @@
package main
import (
"io"
"log"
"net"
"net/http"
"net/http/httputil"
)
// unixHandler defines a handler holding the path to a socket under UNIX
type unixHandler struct {
path string
}
// ServeHTTP implementation for unixHandler
func (h *unixHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := net.Dial("unix", h.path)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Println(err)
return
}
c := httputil.NewClientConn(conn, nil)
defer c.Close()
res, err := c.Do(r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Println(err)
return
}
defer res.Body.Close()
copyHeader(w.Header(), res.Header)
if _, err := io.Copy(w, res.Body); err != nil {
log.Println(err)
}
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}

View File

@@ -1,167 +1,808 @@
angular.module('portainer', [
'portainer.templates',
'ui.bootstrap',
'ui.router',
'ui.select',
'ngCookies',
'ngSanitize',
'portainer.services',
'portainer.helpers',
'portainer.filters',
'dashboard',
'container',
'containerConsole',
'containerLogs',
'containers',
'createContainer',
'docker',
'events',
'images',
'image',
'stats',
'swarm',
'network',
'networks',
'createNetwork',
'templates',
'volumes',
'createVolume'])
.config(['$stateProvider', '$urlRouterProvider', '$httpProvider', function ($stateProvider, $urlRouterProvider, $httpProvider) {
'use strict';
$httpProvider.defaults.xsrfCookieName = 'csrfToken';
$httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token';
$urlRouterProvider.otherwise('/');
angular.module('portainer.filters', []);
angular.module('portainer.rest', ['ngResource']);
angular.module('portainer.services', []);
angular.module('portainer.helpers', []);
angular.module('portainer', [
'ui.bootstrap',
'ui.router',
'isteven-multi-select',
'ngCookies',
'ngSanitize',
'ngFileUpload',
'angularUtils.directives.dirPagination',
'LocalStorageModule',
'angular-jwt',
'angular-google-analytics',
'portainer.templates',
'portainer.filters',
'portainer.rest',
'portainer.helpers',
'portainer.services',
'auth',
'dashboard',
'container',
'containerConsole',
'containerLogs',
'containerStats',
'serviceLogs',
'containers',
'createContainer',
'createNetwork',
'createRegistry',
'createSecret',
'createService',
'createVolume',
'engine',
'endpoint',
'endpointAccess',
'endpoints',
'events',
'image',
'images',
'initAdmin',
'initEndpoint',
'main',
'network',
'networks',
'node',
'registries',
'registry',
'registryAccess',
'secrets',
'secret',
'service',
'services',
'settings',
'settingsAuthentication',
'sidebar',
'swarm',
'swarmVisualizer',
'task',
'team',
'teams',
'templates',
'user',
'users',
'userSettings',
'volume',
'volumes',
'rzModule'])
.config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) {
'use strict';
var environment = '@@ENVIRONMENT';
if (environment === 'production') {
$compileProvider.debugInfoEnabled(false);
}
localStorageServiceProvider
.setPrefix('portainer');
jwtOptionsProvider.config({
tokenGetter: ['LocalStorage', function(LocalStorage) {
return LocalStorage.getJWT();
}],
unauthenticatedRedirector: ['$state', function($state) {
$state.go('auth', {error: 'Your session has expired'});
}]
});
$httpProvider.interceptors.push('jwtInterceptor');
AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
AnalyticsProvider.startOffline(true);
$urlRouterProvider.otherwise('/auth');
toastr.options.timeOut = 3000;
$uibTooltipProvider.setTriggers({
'mouseenter': 'mouseleave',
'click': 'click',
'focus': 'blur',
'outsideClick': 'outsideClick'
});
$stateProvider
.state('index', {
url: '/',
templateUrl: 'app/components/dashboard/dashboard.html',
controller: 'DashboardController'
})
.state('containers', {
url: '/containers/',
templateUrl: 'app/components/containers/containers.html',
controller: 'ContainersController'
})
.state('container', {
url: "^/containers/:id",
templateUrl: 'app/components/container/container.html',
controller: 'ContainerController'
})
.state('stats', {
url: "^/containers/:id/stats",
templateUrl: 'app/components/stats/stats.html',
controller: 'StatsController'
})
.state('logs', {
url: "^/containers/:id/logs",
templateUrl: 'app/components/containerLogs/containerlogs.html',
controller: 'ContainerLogsController'
})
.state('console', {
url: "^/containers/:id/console",
templateUrl: 'app/components/containerConsole/containerConsole.html',
controller: 'ContainerConsoleController'
})
.state('actions', {
abstract: true,
url: "/actions",
template: '<ui-view/>'
})
.state('actions.create', {
abstract: true,
url: "/create",
template: '<ui-view/>'
})
.state('actions.create.container', {
url: "/container",
templateUrl: 'app/components/createContainer/createcontainer.html',
controller: 'CreateContainerController'
})
.state('actions.create.volume', {
url: "/volume",
templateUrl: 'app/components/createVolume/createvolume.html',
controller: 'CreateVolumeController'
})
.state('actions.create.network', {
url: "/network",
templateUrl: 'app/components/createNetwork/createnetwork.html',
controller: 'CreateNetworkController'
})
.state('docker', {
url: '/docker/',
templateUrl: 'app/components/docker/docker.html',
controller: 'DockerController'
})
.state('events', {
url: '/events/',
templateUrl: 'app/components/events/events.html',
controller: 'EventsController'
})
.state('images', {
url: '/images/',
templateUrl: 'app/components/images/images.html',
controller: 'ImagesController'
})
.state('image', {
url: '^/images/:id/',
templateUrl: 'app/components/image/image.html',
controller: 'ImageController'
})
.state('networks', {
url: '/networks/',
templateUrl: 'app/components/networks/networks.html',
controller: 'NetworksController'
})
.state('network', {
url: '^/networks/:id/',
templateUrl: 'app/components/network/network.html',
controller: 'NetworkController'
})
.state('templates', {
url: '/templates/',
templateUrl: 'app/components/templates/templates.html',
controller: 'TemplatesController'
})
.state('volumes', {
url: '/volumes/',
templateUrl: 'app/components/volumes/volumes.html',
controller: 'VolumesController'
})
.state('swarm', {
url: '/swarm/',
templateUrl: 'app/components/swarm/swarm.html',
controller: 'SwarmController'
});
// The Docker API likes to return plaintext errors, this catches them and disp
$httpProvider.interceptors.push(function() {
return {
'response': function(response) {
if (typeof(response.data) === 'string' &&
(response.data.startsWith('Conflict.') || response.data.startsWith('conflict:'))) {
$.gritter.add({
title: 'Error',
text: $('<div>').text(response.data).html(),
time: 10000
});
}
var csrfToken = response.headers('X-Csrf-Token');
if (csrfToken) {
document.cookie = 'csrfToken=' + csrfToken;
}
return response;
}
};
});
}])
// This is your docker url that the api will use to make requests
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
.constant('DOCKER_ENDPOINT', 'dockerapi')
.constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is requred. If you have a port, prefix it with a ':' i.e. :4243
.constant('CONFIG_ENDPOINT', 'settings')
.constant('TEMPLATES_ENDPOINT', 'templates')
.constant('UI_VERSION', 'v1.8.0');
.state('root', {
abstract: true,
resolve: {
requiresLogin: ['StateManager', function (StateManager) {
var applicationState = StateManager.getState();
return applicationState.application.authentication;
}]
}
})
.state('auth', {
parent: 'root',
url: '/auth',
params: {
logout: false,
error: ''
},
views: {
'content@': {
templateUrl: 'app/components/auth/auth.html',
controller: 'AuthenticationController'
}
},
data: {
requiresLogin: false
}
})
.state('containers', {
parent: 'root',
url: '/containers/',
views: {
'content@': {
templateUrl: 'app/components/containers/containers.html',
controller: 'ContainersController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('container', {
url: '^/containers/:id',
views: {
'content@': {
templateUrl: 'app/components/container/container.html',
controller: 'ContainerController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('stats', {
url: '^/containers/:id/stats',
views: {
'content@': {
templateUrl: 'app/components/containerStats/containerStats.html',
controller: 'ContainerStatsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('containerlogs', {
url: '^/containers/:id/logs',
views: {
'content@': {
templateUrl: 'app/components/containerLogs/containerlogs.html',
controller: 'ContainerLogsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('servicelogs', {
url: '^/services/:id/logs',
views: {
'content@': {
templateUrl: 'app/components/serviceLogs/servicelogs.html',
controller: 'ServiceLogsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('console', {
url: '^/containers/:id/console',
views: {
'content@': {
templateUrl: 'app/components/containerConsole/containerConsole.html',
controller: 'ContainerConsoleController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('dashboard', {
parent: 'root',
url: '/dashboard',
views: {
'content@': {
templateUrl: 'app/components/dashboard/dashboard.html',
controller: 'DashboardController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions', {
abstract: true,
url: '/actions',
views: {
'content@': {
template: '<div ui-view="content@"></div>'
},
'sidebar@': {
template: '<div ui-view="sidebar@"></div>'
}
}
})
.state('actions.create', {
abstract: true,
url: '/create',
views: {
'content@': {
template: '<div ui-view="content@"></div>'
},
'sidebar@': {
template: '<div ui-view="sidebar@"></div>'
}
}
})
.state('actions.create.container', {
url: '/container/:from',
views: {
'content@': {
templateUrl: 'app/components/createContainer/createcontainer.html',
controller: 'CreateContainerController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.network', {
url: '/network',
views: {
'content@': {
templateUrl: 'app/components/createNetwork/createnetwork.html',
controller: 'CreateNetworkController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.registry', {
url: '/registry',
views: {
'content@': {
templateUrl: 'app/components/createRegistry/createregistry.html',
controller: 'CreateRegistryController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.secret', {
url: '/secret',
views: {
'content@': {
templateUrl: 'app/components/createSecret/createsecret.html',
controller: 'CreateSecretController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.service', {
url: '/service',
views: {
'content@': {
templateUrl: 'app/components/createService/createservice.html',
controller: 'CreateServiceController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.volume', {
url: '/volume',
views: {
'content@': {
templateUrl: 'app/components/createVolume/createvolume.html',
controller: 'CreateVolumeController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('init', {
abstract: true,
url: '/init',
views: {
'content@': {
template: '<div ui-view="content@"></div>'
}
}
})
.state('init.endpoint', {
url: '/endpoint',
views: {
'content@': {
templateUrl: 'app/components/initEndpoint/initEndpoint.html',
controller: 'InitEndpointController'
}
}
})
.state('init.admin', {
url: '/admin',
views: {
'content@': {
templateUrl: 'app/components/initAdmin/initAdmin.html',
controller: 'InitAdminController'
}
}
})
.state('engine', {
url: '/engine/',
views: {
'content@': {
templateUrl: 'app/components/engine/engine.html',
controller: 'EngineController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('endpoints', {
url: '/endpoints/',
views: {
'content@': {
templateUrl: 'app/components/endpoints/endpoints.html',
controller: 'EndpointsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('endpoint', {
url: '^/endpoints/:id',
views: {
'content@': {
templateUrl: 'app/components/endpoint/endpoint.html',
controller: 'EndpointController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('endpoint.access', {
url: '^/endpoints/:id/access',
views: {
'content@': {
templateUrl: 'app/components/endpointAccess/endpointAccess.html',
controller: 'EndpointAccessController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('events', {
url: '/events/',
views: {
'content@': {
templateUrl: 'app/components/events/events.html',
controller: 'EventsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('images', {
url: '/images/',
views: {
'content@': {
templateUrl: 'app/components/images/images.html',
controller: 'ImagesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('image', {
url: '^/images/:id/',
views: {
'content@': {
templateUrl: 'app/components/image/image.html',
controller: 'ImageController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('networks', {
url: '/networks/',
views: {
'content@': {
templateUrl: 'app/components/networks/networks.html',
controller: 'NetworksController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('network', {
url: '^/networks/:id/',
views: {
'content@': {
templateUrl: 'app/components/network/network.html',
controller: 'NetworkController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('node', {
url: '^/nodes/:id/',
views: {
'content@': {
templateUrl: 'app/components/node/node.html',
controller: 'NodeController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('registries', {
url: '/registries/',
views: {
'content@': {
templateUrl: 'app/components/registries/registries.html',
controller: 'RegistriesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('registry', {
url: '^/registries/:id',
views: {
'content@': {
templateUrl: 'app/components/registry/registry.html',
controller: 'RegistryController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('registry.access', {
url: '^/registries/:id/access',
views: {
'content@': {
templateUrl: 'app/components/registryAccess/registryAccess.html',
controller: 'RegistryAccessController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('secrets', {
url: '^/secrets/',
views: {
'content@': {
templateUrl: 'app/components/secrets/secrets.html',
controller: 'SecretsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('secret', {
url: '^/secret/:id/',
views: {
'content@': {
templateUrl: 'app/components/secret/secret.html',
controller: 'SecretController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('services', {
url: '/services/',
views: {
'content@': {
templateUrl: 'app/components/services/services.html',
controller: 'ServicesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('service', {
url: '^/service/:id/',
views: {
'content@': {
templateUrl: 'app/components/service/service.html',
controller: 'ServiceController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('settings', {
url: '/settings/',
views: {
'content@': {
templateUrl: 'app/components/settings/settings.html',
controller: 'SettingsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('settings_authentication', {
url: '^/settings/authentication',
views: {
'content@': {
templateUrl: 'app/components/settingsAuthentication/settingsAuthentication.html',
controller: 'SettingsAuthenticationController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('task', {
url: '^/task/:id',
views: {
'content@': {
templateUrl: 'app/components/task/task.html',
controller: 'TaskController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('templates', {
url: '/templates/',
params: {
key: 'containers',
hide_descriptions: false
},
views: {
'content@': {
templateUrl: 'app/components/templates/templates.html',
controller: 'TemplatesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('templates_linuxserver', {
url: '^/templates/linuxserver.io',
params: {
key: 'linuxserver.io',
hide_descriptions: true
},
views: {
'content@': {
templateUrl: 'app/components/templates/templates.html',
controller: 'TemplatesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('volumes', {
url: '/volumes/',
views: {
'content@': {
templateUrl: 'app/components/volumes/volumes.html',
controller: 'VolumesController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('volume', {
url: '^/volumes/:id',
views: {
'content@': {
templateUrl: 'app/components/volume/volume.html',
controller: 'VolumeController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('users', {
url: '/users/',
views: {
'content@': {
templateUrl: 'app/components/users/users.html',
controller: 'UsersController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('user', {
url: '^/users/:id',
views: {
'content@': {
templateUrl: 'app/components/user/user.html',
controller: 'UserController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('userSettings', {
url: '/userSettings/',
views: {
'content@': {
templateUrl: 'app/components/userSettings/userSettings.html',
controller: 'UserSettingsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('teams', {
url: '/teams/',
views: {
'content@': {
templateUrl: 'app/components/teams/teams.html',
controller: 'TeamsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('team', {
url: '^/teams/:id',
views: {
'content@': {
templateUrl: 'app/components/team/team.html',
controller: 'TeamController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('swarm', {
url: '/swarm',
views: {
'content@': {
templateUrl: 'app/components/swarm/swarm.html',
controller: 'SwarmController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('swarm.visualizer', {
url: '/visualizer',
views: {
'content@': {
templateUrl: 'app/components/swarmVisualizer/swarmVisualizer.html',
controller: 'SwarmVisualizerController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
;
}])
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics) {
EndpointProvider.initialize();
StateManager.initialize().then(function success(state) {
if (state.application.authentication) {
authManager.checkAuthOnRefresh();
authManager.redirectWhenUnauthenticated();
Authentication.init();
$rootScope.$on('tokenHasExpired', function($state) {
$state.go('auth', {error: 'Your session has expired'});
});
}
if (state.application.analytics) {
Analytics.offline(false);
Analytics.registerScriptTags();
Analytics.registerTrackers();
$rootScope.$on('$stateChangeSuccess', function (event, toState, toParams, fromState, fromParams) {
Analytics.trackPage(toState.url);
Analytics.pageView();
});
}
}, function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
});
$rootScope.$state = $state;
}])
// This is your docker url that the api will use to make requests
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
// .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243
.constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_USERS', 'api/users')
.constant('API_ENDPOINT_TEAMS', 'api/teams')
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
.constant('PAGINATION_MAX_ITEMS', 10);

View File

@@ -0,0 +1,47 @@
<div class="page-wrapper">
<!-- login box -->
<div class="container simple-box">
<div class="col-sm-6 col-sm-offset-3">
<!-- login box logo -->
<div class="row">
<img ng-if="!logo" src="images/logo_alt.png" class="simple-box-logo" alt="Portainer">
<img ng-if="logo" ng-src="{{ logo }}" class="simple-box-logo">
</div>
<!-- !login box logo -->
<!-- login panel -->
<div class="panel panel-default">
<div class="panel-body">
<!-- login form -->
<form class="simple-box-form form-horizontal">
<!-- username input -->
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-user" aria-hidden="true"></i></span>
<input id="username" type="text" class="form-control" name="username" ng-model="formValues.Username" auto-focus>
</div>
<!-- !username input -->
<!-- password input -->
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
<input id="password" type="password" class="form-control" name="password" ng-model="formValues.Password">
</div>
<!-- !password input -->
<!-- login button -->
<div class="form-group">
<div class="col-sm-12">
<button type="submit" class="btn btn-primary btn-sm pull-right" ng-click="authenticateUser()"><i class="fa fa-sign-in" aria-hidden="true"></i> Login</button>
<span class="pull-left" style="margin: 5px;" ng-if="state.AuthenticationError">
<i class="fa fa-exclamation-triangle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<span class="small text-danger">{{ state.AuthenticationError }}</span>
</span>
</div>
</div>
<!-- !login button -->
</form>
<!-- !login form -->
</div>
</div>
<!-- !login panel -->
</div>
</div>
<!-- !login box -->
</div>

View File

@@ -0,0 +1,110 @@
angular.module('auth', [])
.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'UserService', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'SettingsService',
function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Authentication, Users, UserService, EndpointService, StateManager, EndpointProvider, Notifications, SettingsService) {
$scope.logo = StateManager.getState().application.logo;
$scope.formValues = {
Username: '',
Password: ''
};
$scope.state = {
AuthenticationError: ''
};
function setActiveEndpointAndRedirectToDashboard(endpoint) {
var endpointID = EndpointProvider.endpointID();
if (!endpointID) {
EndpointProvider.setEndpointID(endpoint.Id);
}
StateManager.updateEndpointState(true)
.then(function success(data) {
$state.go('dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
});
}
function unauthenticatedFlow() {
EndpointService.endpoints()
.then(function success(data) {
var endpoints = data;
if (endpoints.length > 0) {
setActiveEndpointAndRedirectToDashboard(endpoints[0]);
} else {
$state.go('init.endpoint');
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
});
}
function authenticatedFlow() {
UserService.administratorExists()
.then(function success(exists) {
if (!exists) {
$state.go('init.admin');
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to verify administrator account existence');
});
}
$scope.authenticateUser = function() {
var username = $scope.formValues.Username;
var password = $scope.formValues.Password;
SettingsService.publicSettings()
.then(function success(data) {
var settings = data;
if (settings.AuthenticationMethod === 1) {
username = $sanitize(username);
password = $sanitize(password);
}
return Authentication.login(username, password);
})
.then(function success() {
return EndpointService.endpoints();
})
.then(function success(data) {
var endpoints = data;
var userDetails = Authentication.getUserDetails();
if (endpoints.length > 0) {
setActiveEndpointAndRedirectToDashboard(endpoints[0]);
} else if (endpoints.length === 0 && userDetails.role === 1) {
$state.go('init.endpoint');
} else if (endpoints.length === 0 && userDetails.role === 2) {
Authentication.logout();
$scope.state.AuthenticationError = 'User not allowed. Please contact your administrator.';
}
})
.catch(function error() {
$scope.state.AuthenticationError = 'Invalid credentials';
});
};
function initView() {
if ($stateParams.logout || $stateParams.error) {
Authentication.logout();
$scope.state.AuthenticationError = $stateParams.error;
return;
}
if (Authentication.isAuthenticated()) {
$state.go('dashboard');
}
var authenticationEnabled = $scope.applicationState.application.authentication;
if (!authenticationEnabled) {
unauthenticatedFlow();
} else {
authenticatedFlow();
}
}
initView();
}]);

View File

@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
Containers > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a>
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a>
</rd-header-content>
</rd-header>
@@ -13,13 +13,15 @@
<rd-widget-header icon="fa-cogs" title="Actions"></rd-widget-header>
<rd-widget-body classes="padding">
<div class="btn-group" role="group" aria-label="...">
<button class="btn btn-primary" ng-click="start()" ng-if="!container.State.Running"><i class="fa fa-play btn-ico" aria-hidden="true"></i>Start</button>
<button class="btn btn-danger" ng-click="stop()" ng-if="container.State.Running"><i class="fa fa-stop btn-ico" aria-hidden="true"></i>Stop</button>
<button class="btn btn-danger" ng-click="kill()" ng-if="container.State.Running"><i class="fa fa-bomb btn-ico" aria-hidden="true"></i>Kill</button>
<button class="btn btn-primary" ng-click="restart()" ng-if="container.State.Running"><i class="fa fa-refresh btn-ico" aria-hidden="true"></i>Restart</button>
<button class="btn btn-primary" ng-click="pause()" ng-if="container.State.Running && !container.State.Paused"><i class="fa fa-pause btn-ico" aria-hidden="true"></i>Pause</button>
<button class="btn btn-primary" ng-click="unpause()" ng-if="container.State.Paused"><i class="fa fa-play btn-ico" aria-hidden="true"></i>Resume</button>
<button class="btn btn-danger" ng-click="remove()" ng-disabled="container.State.Running"><i class="fa fa-trash btn-ico" aria-hidden="true"></i>Remove</button>
<button class="btn btn-success" ng-click="start()" ng-disabled="container.State.Running"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button class="btn btn-danger" ng-click="stop()" ng-disabled="!container.State.Running"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button class="btn btn-danger" ng-click="kill()" ng-disabled="!container.State.Running"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
<button class="btn btn-primary" ng-click="restart()" ng-disabled="!container.State.Running"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button class="btn btn-primary" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button class="btn btn-danger" ng-click="confirmRemove()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<button class="btn btn-danger" ng-click="recreate()" ng-if="!container.Config.Labels['com.docker.swarm.service.id']"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Recreate</button>
<button class="btn btn-primary" ng-click="duplicate()" ng-if="!container.Config.Labels['com.docker.swarm.service.id']"><i class="fa fa-files-o space-right" aria-hidden="true"></i>Duplicate/Edit</button>
</div>
</rd-widget-body>
</rd-widget>
@@ -29,10 +31,14 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Container status"></rd-widget-header>
<rd-widget-header icon="fa-server" title="Container status"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>ID</td>
<td>{{ container.Id }}</td>
</tr>
<tr>
<td>Name</td>
<td ng-if="!container.edit">
@@ -40,9 +46,11 @@
<a href="" data-toggle="tooltip" title="Edit container name" ng-click="container.edit = true;"><i class="fa fa-edit"></i></a>
</td>
<td ng-if="container.edit">
<input type="text" class="containerNameInput" ng-model="container.newContainerName">
<a href="" ng-click="container.edit = false;"><i class="fa fa-times"></i></a>
<a href="" ng-click="renameContainer()"><i class="fa fa-check-square-o"></i></a>
<form ng-submit="renameContainer()">
<input type="text" class="containerNameInput" ng-model="container.newContainerName">
<a href="" ng-click="container.edit = false;"><i class="fa fa-times"></i></a>
<a href="" ng-click="renameContainer()"><i class="fa fa-check-square-o"></i></a>
</form>
</td>
</tr>
<tr ng-if="container.NetworkSettings.IPAddress">
@@ -52,24 +60,29 @@
<tr>
<td>Status</td>
<td>
<i ng-class="{true: 'fa fa-heartbeat text-icon green-icon', false: 'fa fa-heartbeat text-icon red-icon'}[container.State.Running]"></i>
{{ container.State|getstatetext }} since {{ activityTime }}<span ng-if="!container.State.Running"> with exit code {{ container.State.ExitCode }}</span>
<i class="fa fa-heartbeat space-right green-icon" ng-if="container.State.Running"></i>
<i class="fa fa-heartbeat space-right red-icon" ng-if="!container.State.Running && container.State.Status !== 'created'"></i>
{{ container.State|getstatetext }} since {{ activityTime }}<span ng-if="!container.State.Running && container.State.Status !== 'created'"> with exit code {{ container.State.ExitCode }}</span>
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ container.Created|getisodate }}</td>
</tr>
<tr ng-if="container.State.Running">
<td>Start time</td>
<td>{{ container.State.StartedAt|getisodate }}</td>
</tr>
<tr ng-if="!container.State.Running">
<tr ng-if="!container.State.Running && container.State.Status !== 'created'">
<td>Finished</td>
<td>{{ container.State.FinishedAt|getisodate }}</td>
</tr>
<tr>
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a class="btn btn-outline-secondary" type="button" ui-sref="stats({id: container.Id})"><i class="fa fa-area-chart btn-ico" aria-hidden="true"></i>Stats</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="logs({id: container.Id})"><i class="fa fa-exclamation-circle btn-ico" aria-hidden="true"></i>Logs</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="console({id: container.Id})"><i class="fa fa-terminal btn-ico" aria-hidden="true"></i>Console</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="stats({id: container.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i>Stats</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="containerlogs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a>
</div>
</td>
</tr>
@@ -80,6 +93,44 @@
</div>
</div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="container && applicationState.application.authentication"
resource-id="container.Id"
resource-control="container.ResourceControl"
resource-type="'container'">
</por-access-control-panel>
<!-- !access-control-panel -->
<div ng-if="container.State.Health" class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-server" title="Container health"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Status</td>
<td>
<i ng-class="{'healthy': 'fa fa-heartbeat space-right green-icon', 'unhealthy': 'fa fa-heartbeat space-right red-icon', 'starting': 'fa fa-heartbeat space-right orange-icon'}[container.State.Health.Status]"></i>
{{ container.State.Health.Status }}
</td>
</tr>
<tr>
<td>Failure count</td>
<td>{{ container.State.Health.FailingStreak }}</td>
</tr>
<tr>
<td>Last output</td>
<td>{{ container.State.Health.Log[container.State.Health.Log.length - 1].Output }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
@@ -96,18 +147,11 @@
</div>
</div>
<!-- !tag-description -->
<!-- name-and-registry-inputs -->
<!-- image-and-registry -->
<div class="form-group">
<label for="image_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-7">
<input type="text" class="form-control" ng-model="config.Image" id="image_name" placeholder="e.g. myImage:myTag">
</div>
<label for="image_registry" class="col-sm-1 control-label text-left">Registry</label>
<div class="col-sm-3">
<input type="text" class="form-control" ng-model="config.Registry" id="image_registry" placeholder="optional">
</div>
<por-image-registry image="config.Image" registry="config.Registry"></por-image-registry>
</div>
<!-- !name-and-registry-inputs -->
<!-- !image-and-registry -->
<!-- tag-note -->
<div class="form-group">
<div class="col-sm-12">
@@ -117,7 +161,7 @@
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-default btn-sm" ng-disabled="!config.Image" ng-click="commit()">Create</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="commit()">Create</button>
<i id="createImageSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
@@ -130,7 +174,7 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Container details"></rd-widget-header>
<rd-widget-header icon="fa-server" title="Container details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
@@ -154,7 +198,7 @@
<td>ENV</td>
<td>
<table class="table table-bordered table-condensed">
<tr ng-repeat="var in container.Config.Env">
<tr ng-repeat="var in container.Config.Env track by $index">
<td>{{ var|key: '=' }}</td>
<td>{{ var|value: '=' }}</td>
</tr>
@@ -172,6 +216,23 @@
</table>
</td>
</tr>
<tr ng-if="container.HostConfig.RestartPolicy.Name !== 'no'">
<td>Restart policies</td>
<td>
<table class="table table-bordered table-condensed">
<tr>
<td class="col-md-3">Name</td>
<td>{{ container.HostConfig.RestartPolicy.Name }}</td>
</tr>
<tr>
<td class="col-md-3">MaximumRetryCount</td>
<td>
{{ container.HostConfig.RestartPolicy.MaximumRetryCount }}
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
@@ -202,3 +263,66 @@
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-sitemap" title="Connected networks">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<th>Network Name</th>
<th>IP Address</th>
<th>Gateway</th>
<th>MacAddress</th>
<th>Actions</th>
</thead>
<tbody>
<tr dir-paginate="(key, value) in container.NetworkSettings.Networks | itemsPerPage: state.pagination_count">
<td><a ui-sref="network({id: value.NetworkID})">{{ key }}</a></td>
<td>{{ value.IPAddress || '-' }}</td>
<td>{{ value.Gateway || '-' }}</td>
<td>{{ value.MacAddress || '-' }}</td>
<td>
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(container, value.NetworkID)"><i class="fa fa-trash space-right" aria-hidden="true"></i>Leave Network</button>
</td>
</tr>
<tr ng-if="(container.NetworkSettings.Networks | emptyobject)">
<td colspan="5" class="text-center text-muted">No networks connected.</td>
</tr>
</tbody>
</table>
<div class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
<hr />
<form class="form-horizontal">
<!-- network-input -->
<div class="row">
<label for="container_network" class="col-sm-3 col-lg-2 control-label text-left">Join a Network</label>
<div class="col-sm-5 col-lg-4">
<select class="form-control" ng-model="selectedNetwork" id="container_network">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Id">{{ net.Name }}</option>
</select>
</div>
<div class="col-sm-1">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!selectedNetwork" ng-click="containerJoinNetwork(container, selectedNetwork)">Join Network</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -1,33 +1,42 @@
angular.module('container', [])
.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ImageHelper', 'Messages',
function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ImageHelper, Messages) {
.controller('ContainerController', ['$q', '$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
$scope.activityTime = 0;
$scope.portBindings = [];
$scope.config = {
Image: '',
Registry: ''
};
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('container_networks');
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('container_networks', $scope.state.pagination_count);
};
var update = function () {
$('#loadingViewSpinner').show();
Container.get({id: $stateParams.id}, function (d) {
$scope.container = d;
var container = new ContainerDetailsViewModel(d);
$scope.container = container;
$scope.container.edit = false;
$scope.container.newContainerName = $filter('trimcontainername')(d.Name);
$scope.container.newContainerName = $filter('trimcontainername')(container.Name);
if (d.State.Running) {
$scope.activityTime = moment.duration(moment(d.State.StartedAt).utc().diff(moment().utc())).humanize();
if (container.State.Running) {
$scope.activityTime = moment.duration(moment(container.State.StartedAt).utc().diff(moment().utc())).humanize();
} else if (container.State.Status === 'created') {
$scope.activityTime = moment.duration(moment(container.Created).utc().diff(moment().utc())).humanize();
} else {
$scope.activityTime = moment.duration(moment().utc().diff(moment(d.State.FinishedAt).utc())).humanize();
$scope.activityTime = moment.duration(moment().utc().diff(moment(container.State.FinishedAt).utc())).humanize();
}
$scope.portBindings = [];
if (d.NetworkSettings.Ports) {
angular.forEach(Object.keys(d.NetworkSettings.Ports), function(portMapping) {
if (d.NetworkSettings.Ports[portMapping]) {
if (container.NetworkSettings.Ports) {
angular.forEach(Object.keys(container.NetworkSettings.Ports), function(portMapping) {
if (container.NetworkSettings.Ports[portMapping]) {
var mapping = {};
mapping.container = portMapping;
mapping.host = d.NetworkSettings.Ports[portMapping][0].HostIp + ':' + d.NetworkSettings.Ports[portMapping][0].HostPort;
mapping.host = container.NetworkSettings.Ports[portMapping][0].HostIp + ':' + container.NetworkSettings.Ports[portMapping][0].HostPort;
$scope.portBindings.push(mapping);
}
});
@@ -35,7 +44,7 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima
$('#loadingViewSpinner').hide();
}, function (e) {
$('#loadingViewSpinner').hide();
Messages.error("Failure", e, "Unable to retrieve container info");
Notifications.error('Failure', e, 'Unable to retrieve container info');
});
};
@@ -43,10 +52,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima
$('#loadingViewSpinner').show();
Container.start({id: $scope.container.Id}, {}, function (d) {
update();
Messages.send("Container started", $stateParams.id);
Notifications.success('Container started', $stateParams.id);
}, function (e) {
update();
Messages.error("Failure", e, "Unable to start container");
Notifications.error('Failure', e, 'Unable to start container');
});
};
@@ -54,10 +63,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima
$('#loadingViewSpinner').show();
Container.stop({id: $stateParams.id}, function (d) {
update();
Messages.send("Container stopped", $stateParams.id);
Notifications.success('Container stopped', $stateParams.id);
}, function (e) {
update();
Messages.error("Failure", e, "Unable to stop container");
Notifications.error('Failure', e, 'Unable to stop container');
});
};
@@ -65,26 +74,26 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima
$('#loadingViewSpinner').show();
Container.kill({id: $stateParams.id}, function (d) {
update();
Messages.send("Container killed", $stateParams.id);
Notifications.success('Container killed', $stateParams.id);
}, function (e) {
update();
Messages.error("Failure", e, "Unable to kill container");
Notifications.error('Failure', e, 'Unable to kill container');
});
};
$scope.commit = function () {
$('#createImageSpinner').show();
var image = _.toLower($scope.config.Image);
var registry = _.toLower($scope.config.Registry);
var imageConfig = ImageHelper.createImageConfig(image, registry);
var image = $scope.config.Image;
var registry = $scope.config.Registry;
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL);
ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
$('#createImageSpinner').hide();
update();
Messages.send("Container commited", $stateParams.id);
Notifications.success('Container commited', $stateParams.id);
}, function (e) {
$('#createImageSpinner').hide();
update();
Messages.error("Failure", e, "Unable to commit container");
Notifications.error('Failure', e, 'Unable to commit container');
});
};
@@ -92,10 +101,10 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima
$('#loadingViewSpinner').show();
Container.pause({id: $stateParams.id}, function (d) {
update();
Messages.send("Container paused", $stateParams.id);
Notifications.success('Container paused', $stateParams.id);
}, function (e) {
update();
Messages.error("Failure", e, "Unable to pause container");
Notifications.error('Failure', e, 'Unable to pause container');
});
};
@@ -103,27 +112,43 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima
$('#loadingViewSpinner').show();
Container.unpause({id: $stateParams.id}, function (d) {
update();
Messages.send("Container unpaused", $stateParams.id);
Notifications.success('Container unpaused', $stateParams.id);
}, function (e) {
update();
Messages.error("Failure", e, "Unable to unpause container");
Notifications.error('Failure', e, 'Unable to unpause container');
});
};
$scope.remove = function () {
$scope.confirmRemove = function () {
var title = 'You are about to remove a container.';
if ($scope.container.State.Running) {
title = 'You are about to remove a running container.';
}
ModalService.confirmContainerDeletion(
title,
function (result) {
if(!result) { return; }
var cleanAssociatedVolumes = false;
if (result[0]) {
cleanAssociatedVolumes = true;
}
$scope.remove(cleanAssociatedVolumes);
}
);
};
$scope.remove = function(cleanAssociatedVolumes) {
$('#loadingViewSpinner').show();
Container.remove({id: $stateParams.id}, function (d) {
if (d.message) {
$('#loadingViewSpinner').hide();
Messages.send("Error", d.message);
}
else {
$state.go('containers', {}, {reload: true});
Messages.send("Container removed", $stateParams.id);
}
}, function (e) {
update();
Messages.error("Failure", e, "Unable to remove container");
ContainerService.remove($scope.container, cleanAssociatedVolumes)
.then(function success() {
Notifications.success('Container successfully removed');
$state.go('containers', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove container');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
@@ -131,27 +156,160 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima
$('#loadingViewSpinner').show();
Container.restart({id: $stateParams.id}, function (d) {
update();
Messages.send("Container restarted", $stateParams.id);
Notifications.success('Container restarted', $stateParams.id);
}, function (e) {
update();
Messages.error("Failure", e, "Unable to restart container");
Notifications.error('Failure', e, 'Unable to restart container');
});
};
$scope.renameContainer = function () {
Container.rename({id: $stateParams.id, 'name': $scope.container.newContainerName}, function (d) {
var container = $scope.container;
Container.rename({id: $stateParams.id, 'name': container.newContainerName}, function (d) {
if (d.message) {
$scope.container.newContainerName = $scope.container.Name;
Messages.error("Unable to rename container", {}, d.message);
container.newContainerName = container.Name;
Notifications.error('Unable to rename container', {}, d.message);
} else {
$scope.container.Name = $scope.container.newContainerName;
Messages.send("Container successfully renamed", d.name);
container.Name = container.newContainerName;
Notifications.success('Container successfully renamed', container.Name);
}
}, function (e) {
Messages.error("Failure", e, 'Unable to rename container');
Notifications.error('Failure', e, 'Unable to rename container');
});
$scope.container.edit = false;
};
$scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) {
$('#loadingViewSpinner').show();
Network.disconnect({id: networkId}, { Container: $stateParams.id, Force: false }, function (d) {
if (container.message) {
$('#loadingViewSpinner').hide();
Notifications.error('Error', d, 'Unable to disconnect container from network');
} else {
$('#loadingViewSpinner').hide();
Notifications.success('Container left network', $stateParams.id);
$state.go('container', {id: $stateParams.id}, {reload: true});
}
}, function (e) {
$('#loadingViewSpinner').hide();
Notifications.error('Failure', e, 'Unable to disconnect container from network');
});
};
$scope.duplicate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
$state.go('actions.create.container', {from: $stateParams.id}, {reload: true});
});
};
$scope.confirmRemove = function () {
var title = 'You are about to remove a container.';
if ($scope.container.State.Running) {
title = 'You are about to remove a running container.';
}
ModalService.confirmContainerDeletion(
title,
function (result) {
if(!result) { return; }
var cleanAssociatedVolumes = false;
if (result[0]) {
cleanAssociatedVolumes = true;
}
$scope.remove(cleanAssociatedVolumes);
}
);
};
function recreateContainer(pullImage) {
$('#loadingViewSpinner').show();
var container = $scope.container;
var config = ContainerHelper.configFromContainer(container.Model);
ContainerService.remove(container, true)
.then(function success() {
return RegistryService.retrieveRegistryFromRepository(container.Config.Image);
})
.then(function success(data) {
return $q.when(!pullImage || ImageService.pullImage(container.Config.Image, data, true));
})
.then(function success() {
return ContainerService.createAndStartContainer(config);
})
.then(function success(data) {
if (!container.ResourceControl) {
return true;
} else {
var containerIdentifier = data.Id;
var resourceControl = container.ResourceControl;
var users = resourceControl.UserAccesses.map(function(u) {
return u.UserId;
});
var teams = resourceControl.TeamAccesses.map(function(t) {
return t.TeamId;
});
return ResourceControlService.createResourceControl(resourceControl.AdministratorsOnly,
users, teams, containerIdentifier, 'container', []);
}
})
.then(function success(data) {
Notifications.success('Container successfully re-created');
$state.go('containers', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to re-create container');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
$scope.recreate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
ModalService.confirmContainerRecreation(function (result) {
if(!result) { return; }
var pullImage = false;
if (result[0]) {
pullImage = true;
}
recreateContainer(pullImage);
});
});
};
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$('#joinNetworkSpinner').show();
Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) {
if (container.message) {
$('#joinNetworkSpinner').hide();
Notifications.error('Error', d, 'Unable to connect container to network');
} else {
$('#joinNetworkSpinner').hide();
Notifications.success('Container joined network', $stateParams.id);
$state.go('container', {id: $stateParams.id}, {reload: true});
}
}, function (e) {
$('#joinNetworkSpinner').hide();
Notifications.error('Failure', e, 'Unable to connect container to network');
});
};
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM'
)
.then(function success(data) {
var networks = data;
$scope.availableNetworks = networks;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
update();
}]);

View File

@@ -2,12 +2,12 @@
<rd-header-title title="Container console">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
Containers > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Console
<rd-header-content ng-if="state.loaded">
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> &gt; Console
</rd-header-content>
</rd-header>
<div class="row">
<div class="row" ng-if="state.loaded">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-terminal" title="Console">
@@ -17,18 +17,51 @@
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- command-list -->
<div class="form-group">
<div class="col-sm-3">
<select class="selectpicker form-control" ng-model="state.command">
<option value="bash">/bin/bash</option>
<option value="sh">/bin/sh</option>
</select>
<div ng-if="!state.connected">
<!-- command-list -->
<div class="form-group">
<label for="command" class="col-lg-1 text-left col-sm-2 control-label">Command</label>
<div class="col-lg-11 col-sm-10">
<div class="input-group" ng-if="!formValues.isCustomCommand">
<span class="input-group-addon">
<i class="fa fa-linux" aria-hidden="true" ng-if="imageOS == 'linux'"></i>
<i class="fa fa-windows" aria-hidden="true" ng-if="imageOS == 'windows'"></i>
</span>
<select class="form-control" ng-model="formValues.command" id="command">
<option value="bash" ng-if="imageOS == 'linux'">/bin/bash</option>
<option value="sh" ng-if="imageOS == 'linux'">/bin/sh</option>
<option value="powershell" ng-if="imageOS == 'windows'">powershell</option>
<option value="cmd.exe" ng-if="imageOS == 'windows'">cmd.exe</option>
</select>
</div>
<input class="form-control" ng-if="formValues.isCustomCommand" type="text" name="custom-command" ng-model="formValues.customCommand" placeholder="e.g. ps aux">
</div>
</div>
<!-- !command-list -->
<div class="form-group col-lg-12">
<label for="command" class="text-left control-label">Use custom command</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.isCustomCommand"><i></i>
</label>
</div>
<div class="col-sm-9 pull-left">
<button type="button" class="btn btn-primary" ng-click="connect()" ng-disabled="connected">Connect</button>
<button type="button" class="btn btn-default" ng-click="disconnect()" ng-disabled="!connected">Disconnect</button>
<div class="form-group">
<label for="username" class="col-lg-1 text-left col-sm-2 control-label">
User
<portainer-tooltip position="bottom" message="Format is one of: user, user:group, uid or uid:gid"></portainer-tooltip>
</label>
<div class="col-lg-11 col-sm-10">
<input class="form-control" type="text" name="username" ng-model="formValues.user" placeholder="root">
</div>
</div>
<div class="form-group">
<div class="col-lg-offset-1 col-sm-offset-2 col-lg-11 col-sm-10">
<button type="button" class="btn btn-primary" ng-click="connect()">Connect</button>
</div>
</div>
</div>
<div ng-if="state.connected">
<label>Exec into container as <code>{{ ::formValues.user || 'default user' }}</code> using command <code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code></label>
<button type="button" class="btn btn-default" ng-click="disconnect()">Disconnect</button>
</div>
</form>
</rd-widget-body>

View File

@@ -1,60 +1,80 @@
angular.module('containerConsole', [])
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Exec', '$timeout', 'Messages',
function ($scope, $stateParams, Settings, Container, Exec, $timeout, Messages) {
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService',
function ($scope, $stateParams, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) {
$scope.state = {};
$scope.state.command = "bash";
$scope.connected = false;
$scope.state.loaded = false;
$scope.state.connected = false;
$scope.formValues = {};
var socket, term;
// Ensure the socket is closed before leaving the view
$scope.$on('$stateChangeStart', function (event, next, current) {
if (socket !== null) {
if (socket && socket !== null) {
socket.close();
}
});
Container.get({id: $stateParams.id}, function(d) {
$scope.container = d;
if (d.message) {
Notifications.error('Error', d, 'Unable to retrieve container details');
$('#loadingViewSpinner').hide();
} else {
Image.get({id: d.Image}, function(imgData) {
$scope.imageOS = imgData.Os;
$scope.formValues.command = imgData.Os === 'windows' ? 'powershell' : 'bash';
$scope.state.loaded = true;
$('#loadingViewSpinner').hide();
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve image details');
$('#loadingViewSpinner').hide();
});
}
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve container details');
$('#loadingViewSpinner').hide();
});
$scope.connect = function() {
$('#loadConsoleSpinner').show();
var termWidth = Math.round($('#terminal-container').width() / 8.2);
var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39);
var termHeight = 30;
var command = $scope.formValues.isCustomCommand ?
$scope.formValues.customCommand : $scope.formValues.command;
var execConfig = {
id: $stateParams.id,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: $scope.state.command.replace(" ", ",").split(",")
User: $scope.formValues.user,
Cmd: ContainerHelper.commandStringToArray(command)
};
Container.exec(execConfig, function(d) {
if (d.message) {
$('#loadConsoleSpinner').hide();
Messages.error("Error", {}, d.message);
var execId;
ContainerService.createExec(execConfig)
.then(function success(data) {
execId = data.Id;
var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID();
if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://');
} else {
var execId = d.Id;
resizeTTY(execId, termHeight, termWidth);
var url = window.location.href.split('#')[0] + 'ws/exec?id=' + execId;
if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://');
} else {
url = url.replace('http://', 'ws://');
}
initTerm(url, termHeight, termWidth);
url = url.replace('http://', 'ws://');
}
}, function (e) {
initTerm(url, termHeight, termWidth);
return ExecService.resizeTTY(execId, termHeight, termWidth, 2000);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to exec into container');
})
.finally(function final() {
$('#loadConsoleSpinner').hide();
Messages.error("Failure", e, 'Unable to start an exec instance');
});
};
$scope.disconnect = function() {
$scope.connected = false;
$scope.state.connected = false;
if (socket !== null) {
socket.close();
}
@@ -63,47 +83,34 @@ function ($scope, $stateParams, Settings, Container, Exec, $timeout, Messages) {
}
};
function resizeTTY(execId, height, width) {
$timeout(function() {
Exec.resize({id: execId, height: height, width: width}, function (d) {
if (d.message) {
Messages.error('Error', {}, 'Unable to resize TTY');
}
}, function (e) {
Messages.error("Failure", {}, 'Unable to resize TTY');
});
}, 2000);
}
function initTerm(url, height, width) {
socket = new WebSocket(url);
$scope.connected = true;
$scope.state.connected = true;
socket.onopen = function(evt) {
$('#loadConsoleSpinner').hide();
term = new Terminal({
cols: width,
rows: height,
cursorBlink: true
});
term = new Terminal();
term.on('data', function (data) {
socket.send(data);
});
term.open(document.getElementById('terminal-container'));
term.open(document.getElementById('terminal-container'), true);
term.resize(width, height);
term.setOption('cursorBlink', true);
term.fit();
window.onresize = function() {
term.fit();
};
socket.onmessage = function (e) {
term.write(e.data);
};
socket.onerror = function (error) {
$scope.connected = false;
$scope.state.connected = false;
};
socket.onclose = function(evt) {
$scope.connected = false;
// term.write("Session terminated");
// term.destroy();
$scope.state.connected = false;
};
};
}

View File

@@ -14,7 +14,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
$('#loadingViewSpinner').hide();
}, function (e) {
$('#loadingViewSpinner').hide();
Messages.error("Failure", e, "Unable to retrieve container info");
Notifications.error('Failure', e, 'Unable to retrieve container info');
});
function getLogs() {
@@ -60,7 +60,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
getLogs();
var logIntervalId = window.setInterval(getLogs, 5000);
$scope.$on("$destroy", function () {
$scope.$on('$destroy', function () {
// clearing interval when view changes
clearInterval(logIntervalId);
});

View File

@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
Containers > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Logs
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> &gt; Logs
</rd-header-content>
</rd-header>
@@ -12,7 +12,7 @@
<rd-widget>
<rd-widget-body>
<div class="widget-icon grey pull-left">
<i class="fa fa-tasks"></i>
<i class="fa fa-server"></i>
</div>
<div class="title">{{ container.Name|trimcontainername }}</div>
<div class="comment">Name</div>

View File

@@ -0,0 +1,123 @@
<rd-header>
<rd-header-title title="Container statistics">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> &gt; Stats
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-md-12">
<rd-widget>
<rd-widget-header icon="fa-info-circle" title="About statistics">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the container <b>{{ container.Name|trimcontainername }}</b> as well as a list of the running processes
inside this container.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left">
Refresh rate
</label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-4 col-md-6 col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-area-chart" title="Memory usage"></rd-widget-header>
<rd-widget-body>
<div class="chart-container" style="position: relative;">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-4 col-md-6 col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-area-chart" title="CPU usage"></rd-widget-header>
<rd-widget-body>
<div class="chart-container" style="position: relative;">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-4 col-md-12 col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-area-chart" title="Network usage"></rd-widget-header>
<rd-widget-body>
<div class="chart-container" style="position: relative;">
<canvas id="networkChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-sm-12" ng-if="applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Processes">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table table-striped">
<thead>
<tr>
<th ng-repeat="title in processInfo.Titles">
<a ng-click="order(title)">
{{ title }}
<span ng-show="sortType == title && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == title && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="processDetails in state.filteredProcesses = (processInfo.Processes | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count)">
<td ng-repeat="procInfo in processDetails track by $index">{{ procInfo }}</td>
</tr>
<tr ng-if="!processInfo.Processes">
<td colspan="processInfo.Titles.length" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="state.filteredProcesses.length === 0">
<td colspan="processInfo.Titles.length" class="text-center text-muted">No processes available.</td>
</tr>
</tbody>
</table>
<div ng-if="processInfo.Processes" class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,159 @@
angular.module('containerStats', [])
.controller('ContainerStatsController', ['$q', '$scope', '$stateParams', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'Pagination',
function ($q, $scope, $stateParams, $document, $interval, ContainerService, ChartService, Notifications, Pagination) {
$scope.state = {
refreshRate: '5'
};
$scope.state.pagination_count = Pagination.getPaginationCount('stats_processes');
$scope.sortType = 'CMD';
$scope.sortReverse = false;
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('stats_processes', $scope.state.pagination_count);
};
$scope.$on('$destroy', function() {
stopRepeater();
});
function stopRepeater() {
var repeater = $scope.repeater;
if (angular.isDefined(repeater)) {
$interval.cancel(repeater);
repeater = null;
}
}
function updateNetworkChart(stats, chart) {
var rx = stats.Networks[0].rx_bytes;
var tx = stats.Networks[0].tx_bytes;
var label = moment(stats.Date).format('HH:mm:ss');
ChartService.UpdateNetworkChart(label, rx, tx, chart);
}
function updateMemoryChart(stats, chart) {
var label = moment(stats.Date).format('HH:mm:ss');
var value = stats.MemoryUsage;
ChartService.UpdateMemoryChart(label, value, chart);
}
function updateCPUChart(stats, chart) {
var label = moment(stats.Date).format('HH:mm:ss');
var value = calculateCPUPercentUnix(stats);
ChartService.UpdateCPUChart(label, value, chart);
}
function calculateCPUPercentUnix(stats) {
var cpuPercent = 0.0;
var cpuDelta = stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage;
var systemDelta = stats.CurrentCPUSystemUsage - stats.PreviousCPUSystemUsage;
if (systemDelta > 0.0 && cpuDelta > 0.0) {
cpuPercent = (cpuDelta / systemDelta) * stats.CPUCores * 100.0;
}
return cpuPercent;
}
$scope.changeUpdateRepeater = function() {
var networkChart = $scope.networkChart;
var cpuChart = $scope.cpuChart;
var memoryChart = $scope.memoryChart;
stopRepeater();
setUpdateRepeater(networkChart, cpuChart, memoryChart);
$('#refreshRateChange').show();
$('#refreshRateChange').fadeOut(1500);
};
function startChartUpdate(networkChart, cpuChart, memoryChart) {
$('#loadingViewSpinner').show();
$q.all({
stats: ContainerService.containerStats($stateParams.id),
top: ContainerService.containerTop($stateParams.id)
})
.then(function success(data) {
var stats = data.stats;
$scope.processInfo = data.top;
updateNetworkChart(stats, networkChart);
updateMemoryChart(stats, memoryChart);
updateCPUChart(stats, cpuChart);
setUpdateRepeater(networkChart, cpuChart, memoryChart);
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve container statistics');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
function setUpdateRepeater(networkChart, cpuChart, memoryChart) {
var refreshRate = $scope.state.refreshRate;
$scope.repeater = $interval(function() {
$q.all({
stats: ContainerService.containerStats($stateParams.id),
top: ContainerService.containerTop($stateParams.id)
})
.then(function success(data) {
var stats = data.stats;
$scope.processInfo = data.top;
updateNetworkChart(stats, networkChart);
updateMemoryChart(stats, memoryChart);
updateCPUChart(stats, cpuChart);
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve container statistics');
});
}, refreshRate * 1000);
}
function initCharts() {
var networkChartCtx = $('#networkChart');
var networkChart = ChartService.CreateNetworkChart(networkChartCtx);
$scope.networkChart = networkChart;
var cpuChartCtx = $('#cpuChart');
var cpuChart = ChartService.CreateCPUChart(cpuChartCtx);
$scope.cpuChart = cpuChart;
var memoryChartCtx = $('#memoryChart');
var memoryChart = ChartService.CreateMemoryChart(memoryChartCtx);
$scope.memoryChart = memoryChart;
startChartUpdate(networkChart, cpuChart, memoryChart);
}
function initView() {
$('#loadingViewSpinner').show();
ContainerService.container($stateParams.id)
.then(function success(data) {
$scope.container = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container information');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
$document.ready(function() {
initCharts();
});
}
initView();
}]);

View File

@@ -3,29 +3,37 @@
<a data-toggle="tooltip" title="Refresh" ui-sref="containers" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadContainersSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>Containers</rd-header-content>
</rd-header>
<div class="col-lg-12">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Containers">
<rd-widget-header icon="fa-server" title="Containers">
<div class="pull-right">
<i id="loadContainersSpinner" class="fa fa-cog fa-2x fa-spin" style="margin-top: 5px;"></i>
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12">
<div class="pull-left">
<div class="btn-group" role="group" aria-label="...">
<button type="button" class="btn btn-primary" ng-click="startAction()" ng-disabled="!state.selectedItemCount">Start</button>
<button type="button" class="btn btn-primary" ng-click="stopAction()" ng-disabled="!state.selectedItemCount">Stop</button>
<button type="button" class="btn btn-primary" ng-click="killAction()" ng-disabled="!state.selectedItemCount">Kill</button>
<button type="button" class="btn btn-primary" ng-click="restartAction()" ng-disabled="!state.selectedItemCount">Restart</button>
<button type="button" class="btn btn-primary" ng-click="pauseAction()" ng-disabled="!state.selectedItemCount">Pause</button>
<button type="button" class="btn btn-primary" ng-click="unpauseAction()" ng-disabled="!state.selectedItemCount">Unpause</button>
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount">Remove</button>
<button type="button" class="btn btn-success btn-responsive" ng-click="startAction()" ng-disabled="!state.selectedItemCount || state.noStoppedItemsSelected"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="stopAction()" ng-disabled="!state.selectedItemCount || state.noRunningItemsSelected"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="killAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="restartAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="pauseAction()" ng-disabled="!state.selectedItemCount || state.noRunningItemsSelected"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="unpauseAction()" ng-disabled="!state.selectedItemCount || state.noPausedItemsSelected"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="confirmRemoveAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
</div>
<a class="btn btn-default" type="button" ui-sref="actions.create.container">Add container</a>
<a class="btn btn-primary" type="button" ui-sref="actions.create.container"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add container</a>
</div>
<div class="pull-right">
<input type="checkbox" ng-model="state.displayAll" id="displayAll" ng-change="toggleGetAll()" style="margin-top: -2px; margin-right: 5px;"/><label for="displayAll">Show all containers</label>
@@ -37,12 +45,14 @@
<table class="table table-hover">
<thead>
<tr>
<th></th>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="containers" ng-click="order('Status')">
State
<span ng-show="sortType == 'State' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'State' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
@@ -51,6 +61,9 @@
<span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
<a data-toggle="tooltip" title="More" ng-click="truncateMore();" ng-show="showMore">
<i class="fa fa-plus-square" aria-hidden="true"></i>
</a>
</th>
<th>
<a ui-sref="containers" ng-click="order('Image')">
@@ -66,7 +79,7 @@
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="swarm">
<th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
<a ui-sref="containers" ng-click="order('Host')">
Host IP
<span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
@@ -75,32 +88,57 @@
</th>
<th>
<a ui-sref="containers" ng-click="order('Ports')">
Exposed Ports
Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ui-sref="containers" ng-click="order('ResourceControl.Ownership')">
Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="container in (state.filteredContainers = ( containers | filter:state.filter | orderBy:sortType:sortReverse))">
<tr dir-paginate="container in (state.filteredContainers = ( containers | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="container.Checked" ng-change="selectItem(container)"/></td>
<td><span class="label label-{{ container.Status|containerstatusbadge }}">{{ container.Status|containerstatus }}</span></td>
<td ng-if="swarm"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername}}</a></td>
<td ng-if="!swarm"><a ui-sref="container({id: container.Id})">{{ container|containername}}</a></td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image }}</a></td>
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
<td ng-if="swarm">{{ container.hostIP }}</td>
<td>
<a ng-if="container.Ports.length > 0" ng-repeat="p in container.Ports" class="image-tag" ng-href="http://{{p.host}}:{{p.public}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.private }}
<span ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) !== -1" class="label label-{{ container.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ container.Status }}</span>
<span ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) === -1" class="label label-{{ container.Status|containerstatusbadge }}">{{ container.Status }}</span>
</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>
<td>
<a ng-if="container.Ports.length > 0" ng-repeat="p in container.Ports" class="image-tag" ng-href="http://{{ PublicURL || p.host }}:{{p.public}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{p.public}}:{{ p.private }}
</a>
<span ng-if="container.Ports.length == 0" >-</span>
</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="container.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ container.ResourceControl.Ownership ? container.ResourceControl.Ownership : container.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!containers">
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="containers.length == 0">
<td colspan="9" class="text-center text-muted">No containers available.</td>
</tr>
</tbody>
</table>
<div ng-if="containers" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>

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