Compare commits

...

379 Commits

Author SHA1 Message Date
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
331 changed files with 26745 additions and 4115 deletions
+29
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/
+1
View File
@@ -1,2 +1,3 @@
*
!dist
!build
+284
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
+3 -2
View File
@@ -2,7 +2,7 @@
Thanks for opening an issue on Portainer !
Do you need help or have a question? Come chat with us on gitter: https://gitter.im/portainer/Lobby.
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
@@ -38,6 +38,7 @@ Any other info e.g. Why do you consider this to be a bug? What did you expect to
* Portainer version:
* Target Docker version (the host/cluster you manage):
* Target Swarm version (if applicable):
* Platform (windows/linux):
* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`):
* Target Swarm version (if applicable):
* Browser:
+2
View File
@@ -2,3 +2,5 @@ node_modules
bower_components
dist
portainer-checksum.txt
api/cmd/portainer/portainer*
.tmp
+46
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/
+24 -2
View File
@@ -6,8 +6,29 @@ Some basic conventions for contributing to this project.
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.
* Non-trivial changes should be discussed in an issue first
* Develop in a topic branch, not master
* 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
@@ -47,6 +68,7 @@ Must be one of the following:
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
+15 -15
View File
@@ -1,22 +1,22 @@
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.
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)
+29 -57
View File
@@ -1,81 +1,53 @@
# Portainer
The easiest way to manage Docker.
<p align="center">
<img title="portainer" src='https://portainer.io/images/logo_alt.png' />
</p>
[![Microbadger version](https://images.microbadger.com/badges/version/portainer/portainer.svg)](https://microbadger.com/images/portainer/portainer "Latest version on Docker Hub")
[![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 lightweight management UI which allows you to **easily** manage your Docker host or Swarm cluster.
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
# Usage
**_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).
It's really simple to deploy it using Docker:
**_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*.
```shell
$ docker run -d -p 9000:9000 portainer/portainer -H tcp://<DOCKER_HOST>:<DOCKER_PORT>
```
## Demo
Just point it at your targeted Docker host and then access Portainer by hitting [http://localhost:9000](http://localhost:9000) with a web browser.
<img src="https://portainer.io/images/screenshots/portainer.gif" width="77%"/>
If your target is a Docker Swarm cluster or a Docker cluster using *swarm mode*, just add the flag `--swarm`:
You can try out the public demo instance: http://demo.portainer.io/ (login with the username **admin** and the password **tryportainer**).
```shell
$ docker run -d -p 9000:9000 portainer/portainer -H tcp://<SWARM_HOST>:<SWARM_PORT> --swarm
```
Please note that the public demo cluster is **reset every 15min**.
If you don't specify any target, its default behaviour is to use a bind mount on the Docker socket so you can easily deploy it to manage your local Docker host:
## Getting started
```shell
$ docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock portainer/portainer
```
* [Deploy Portainer](https://portainer.readthedocs.io/en/latest/deployment.html)
* [Documentation](https://portainer.readthedocs.io)
Have a look at our [documentation](http://portainer.readthedocs.io/en/stable/deployment.html) for more deployment options.
## Getting help
# Configuration
* 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
Portainer is easy to tune using CLI flags.
## Reporting bugs and contributing
## Hiding specific containers
* 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!
Portainer allows you to hide container with a specific label by using the `-l` flag.
## Limitations
For example, take a container started with the label `owner=acme`:
```shell
$ docker run -d --label owner=acme nginx
```
**_Portainer_** has full support for the following Docker versions:
Simply add the `-l owner=acme` option on the CLI when starting Portainer:
```shell
$ docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock portainer/portainer -l owner=acme
```
## Use your own templates
Portainer allows you to rapidly deploy containers using `App Templates`.
By default [Portainer templates](https://raw.githubusercontent.com/portainer/templates/master/templates.json) will be used but you can also define your own templates.
Add the `--templates` flag and specify the external location of your templates when starting Portainer:
```shell
$ docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock portainer/portainer --templates http://my-host.my-domain/templates.json
```
For more information about hosting your own template definitions and the format, see the [templates documentation](http://portainer.readthedocs.io/en/stable/templates.html).
Check our [documentation](http://portainer.readthedocs.io/en/stable/configuration.html) for more configuration options.
# FAQ
Be sure to check our [FAQ](http://portainer.readthedocs.io/en/stable/faq.html) if you are missing some information.
# Limitations
Portainer has full support for the following Docker versions:
* Docker 1.10 to Docker 1.12 (including `swarm-mode`)
* Docker 1.10 to the latest version
* Docker Swarm >= 1.2.3
Partial support for the following Docker versions (some features may not be available):
-60
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
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
}
+61
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
})
}
+154
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)
}
+97
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
}
+39
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
})
}
+103
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
}
+25
View File
@@ -0,0 +1,25 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateSettingsToVersion3() 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
}
+65
View File
@@ -0,0 +1,65 @@
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 == 0 {
err := m.updateAdminUserToDBVersion1()
if err != nil {
return err
}
}
// Portainer 1.12.x
if m.CurrentDBVersion == 1 {
err := m.updateResourceControlsToDBVersion2()
if err != nil {
return err
}
err = m.updateEndpointsToDBVersion2()
if err != nil {
return err
}
}
// Portainer 1.13.x
if m.CurrentDBVersion == 2 {
err := m.updateSettingsToVersion3()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err
}
return nil
}
+114
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
})
}
+148
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
})
}
+61
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
})
}
+217
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
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
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
})
}
+58
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
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(defaultNoAuth).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
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"
)
+17
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"
)
+10 -16
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
}
+253
View File
@@ -0,0 +1,253 @@
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,
TLS: *flags.TLSVerify,
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 on %s", *flags.Addr)
err = server.Start()
if err != nil {
log.Fatal(err)
}
}
+174
View File
@@ -0,0 +1,174 @@
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
}
)
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 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.TLS != updated.TLS ||
(updated.TLS && original.TLSCACertPath != updated.TLSCACertPath) ||
(updated.TLS && original.TLSCertPath != updated.TLSCertPath) ||
(updated.TLS && original.TLSKeyPath != updated.TLSKeyPath) {
endpoint = original
endpoint.URL = updated.URL
if updated.TLS {
endpoint.TLS = true
endpoint.TLSCACertPath = updated.TLSCACertPath
endpoint.TLSCertPath = updated.TLSCertPath
endpoint.TLSKeyPath = updated.TLSKeyPath
} else {
endpoint.TLS = false
endpoint.TLSCACertPath = ""
endpoint.TLSCertPath = ""
endpoint.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 []portainer.Endpoint
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
}
sync := job.prepareSyncData(storedEndpoints, fileEndpoints)
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
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
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))
}
+34
View File
@@ -0,0 +1,34 @@
package crypto
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
)
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
func CreateTLSConfiguration(caCertPath, certPath, keyPath string, skipTLSVerify bool) (*tls.Config, error) {
config := &tls.Config{}
if certPath != "" && keyPath != "" {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
config.Certificates = []tls.Certificate{cert}
}
if caCertPath != "" {
caCert, err := ioutil.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
config.RootCAs = caCertPool
}
config.InsecureSkipVerify = skipTLSVerify
return config, nil
}
-48
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)
})
}
+87
View File
@@ -0,0 +1,87 @@
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("Admin user already initialized")
)
// 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) }
-24
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
}
}
+141
View File
@@ -0,0 +1,141 @@
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 containing the TLS files for an endpoint.
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
}
// 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
}
-78
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
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
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)
}
+94
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)
}
+89
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)
}
}
+360
View File
@@ -0,0 +1,360 @@
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
}
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:"-"`
}
)
// 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,
TLS: req.TLS,
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))
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSCACertPath = caCertPath
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.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.TLS = true
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSCACertPath = caCertPath
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSKeyPath = keyPath
} else {
endpoint.TLS = false
endpoint.TLSCACertPath = ""
endpoint.TLSCertPath = ""
endpoint.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.TLS {
err = handler.FileService.DeleteTLSFiles(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
+61
View File
@@ -0,0 +1,61 @@
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,
},
}
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)
}
+85
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)
}
}
+314
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
}
}
+258
View File
@@ -0,0 +1,258 @@
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
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
}
}
+169
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
}
}
+38
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
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)
}
+242
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
}
}
+73
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)
}
+69
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
}
}
+451
View File
@@ -0,0 +1,451 @@
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 {
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
}
user, err := handler.UserService.UserByUsername("admin")
if err == portainer.ErrUserNotFound {
user := &portainer.User{
Username: "admin",
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 if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if user != nil {
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
}
_, 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)
}
@@ -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.TLS {
tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
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
+21
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
}
+108
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
}
+90
View File
@@ -0,0 +1,90 @@
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
}
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
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.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
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
}
+112
View File
@@ -0,0 +1,112 @@
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
}
+69
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.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)
}
+95
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
}
+46
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
}
+64
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)
}
+40
View File
@@ -0,0 +1,40 @@
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)
}
}
if _, err := io.Copy(w, res.Body); err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
}
}
+283
View File
@@ -0,0 +1,283 @@
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
if strings.HasPrefix(path, "/containers") {
return p.proxyContainerRequest(request)
} else if strings.HasPrefix(path, "/services") {
return p.proxyServiceRequest(request)
} else if strings.HasPrefix(path, "/volumes") {
return p.proxyVolumeRequest(request)
} else if strings.HasPrefix(path, "/swarm") {
return p.proxySwarmRequest(request)
}
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) proxySwarmRequest(request *http.Request) (*http.Response, error) {
return p.administratorOperation(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
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
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)
}
+123
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
}
+176
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
}
+50
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
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
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
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
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.TLSCACertPath, "", "", settings.TLSConfig.TLSSkipVerify)
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
}
-47
View File
@@ -1,47 +0,0 @@
package main // import "github.com/portainer/portainer"
import (
"gopkg.in/alecthomas/kingpin.v2"
)
// main is the entry point of the program
func main() {
kingpin.Version("1.9.2")
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/portainer/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)
}
+399
View File
@@ -0,0 +1,399 @@
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"`
TLS bool `json:"TLS"`
TLSCACertPath string `json:"TLSCACert,omitempty"`
TLSCertPath string `json:"TLSCert,omitempty"`
TLSKeyPath string `json:"TLSKey,omitempty"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
}
// 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: OwnerID field is deprecated in DBVersion == 2
OwnerID UserID `json:"OwnerId"`
// Deprecated: AccessLevel field is deprecated in DBVersion == 2
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
}
// 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)
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.0"
// DBVersion is the version number of the Portainer database.
DBVersion = 3
// 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
)
-18
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)
}
-27
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
}
+2471
View File
File diff suppressed because it is too large Load Diff
-27
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)
}
-47
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)
}
}
}
+772 -190
View File
@@ -1,191 +1,773 @@
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',
'service',
'services',
'createService',
'stats',
'swarm',
'network',
'networks',
'createNetwork',
'task',
'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',
'serviceLogs',
'containers',
'createContainer',
'createNetwork',
'createRegistry',
'createSecret',
'createService',
'createVolume',
'docker',
'endpoint',
'endpointAccess',
'endpointInit',
'endpoints',
'events',
'image',
'images',
'main',
'network',
'networks',
'node',
'registries',
'registry',
'registryAccess',
'secrets',
'secret',
'service',
'services',
'settings',
'settingsAuthentication',
'sidebar',
'stats',
'swarm',
'task',
'team',
'teams',
'templates',
'user',
'users',
'userSettings',
'volume',
'volumes'])
.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.network', {
url: "/network",
templateUrl: 'app/components/createNetwork/createnetwork.html',
controller: 'CreateNetworkController'
})
.state('actions.create.service', {
url: "/service",
templateUrl: 'app/components/createService/createservice.html',
controller: 'CreateServiceController'
})
.state('actions.create.volume', {
url: "/volume",
templateUrl: 'app/components/createVolume/createvolume.html',
controller: 'CreateVolumeController'
})
.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('services', {
url: '/services/',
templateUrl: 'app/components/services/services.html',
controller: 'ServicesController'
})
.state('service', {
url: '^/service/:id/',
templateUrl: 'app/components/service/service.html',
controller: 'ServiceController'
})
.state('task', {
url: '^/task/:id',
templateUrl: 'app/components/task/task.html',
controller: 'TaskController'
})
.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.9.2');
.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/stats/stats.html',
controller: 'StatsController'
},
'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('docker', {
url: '/docker/',
views: {
'content@': {
templateUrl: 'app/components/docker/docker.html',
controller: 'DockerController'
},
'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('endpointInit', {
url: '/init/endpoint',
views: {
'content@': {
templateUrl: 'app/components/endpointInit/endpointInit.html',
controller: 'EndpointInitController'
}
}
})
.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'
}
}
});
}])
.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);
+101
View File
@@ -0,0 +1,101 @@
<div class="page-wrapper">
<!-- login box -->
<div class="container simple-box">
<div class="col-md-6 col-md-offset-3 col-sm-6 col-sm-offset-3">
<!-- login box logo -->
<div class="row">
<img ng-if="logo" ng-src="{{ logo }}" class="simple-box-logo">
<img ng-if="!logo" src="images/logo_alt.png" class="simple-box-logo" alt="Portainer">
</div>
<!-- !login box logo -->
<!-- init password panel -->
<div class="panel panel-default" ng-if="initPassword">
<div class="panel-body">
<!-- init password form -->
<form class="login-form form-horizontal" enctype="multipart/form-data" method="POST">
<!-- comment -->
<div class="input-group">
<p style="margin: 5px;">
Please specify a password for the <b>admin</b> user account.
</p>
</div>
<!-- !comment input -->
<!-- comment -->
<div class="input-group">
<p style="margin: 5px;">
<i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[initPasswordData.password.length >= 8]" aria-hidden="true"></i>
Your password must be at least 8 characters long
</p>
</div>
<!-- !comment input -->
<!-- password input -->
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
<input id="admin_password" type="password" class="form-control" name="password" ng-model="initPasswordData.password" autofocus>
</div>
<!-- !password input -->
<!-- comment -->
<div class="input-group">
<p style="margin: 5px;">
<i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[initPasswordData.password !== '' && initPasswordData.password === initPasswordData.password_confirmation]" aria-hidden="true"></i>
Confirm your password
</p>
</div>
<!-- !comment input -->
<!-- password confirmation input -->
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
<input id="password_confirmation" type="password" class="form-control" name="password" ng-model="initPasswordData.password_confirmation">
</div>
<!-- !password confirmation input -->
<!-- validate button -->
<div class="form-group">
<div class="col-sm-12 controls">
<p class="pull-left text-danger" ng-if="initPasswordData.error" style="margin: 5px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> Unable to create default user
</p>
<button type="submit" class="btn btn-primary pull-right" ng-disabled="initPasswordData.password.length < 8 || initPasswordData.password !== initPasswordData.password_confirmation" ng-click="createAdminUser()"><i class="fa fa-key" aria-hidden="true"></i> Validate</button>
</div>
</div>
<!-- !validate button -->
</form>
<!-- !init password form -->
</div>
</div>
<!-- !init password panel -->
<!-- login panel -->
<div class="panel panel-default" ng-if="!initPassword">
<div class="panel-body">
<!-- login form -->
<form class="login-form form-horizontal" enctype="multipart/form-data" method="POST">
<!-- 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="authData.username" placeholder="Username">
</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="authData.password" autofocus>
</div>
<!-- !password input -->
<!-- login button -->
<div class="form-group">
<div class="col-sm-12 controls">
<p class="pull-left text-danger" ng-if="authData.error" style="margin: 5px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ authData.error }}
</p>
<button type="submit" class="btn btn-primary pull-right" ng-click="authenticateUser()"><i class="fa fa-sign-in" aria-hidden="true"></i> Login</button>
</div>
</div>
<!-- !login button -->
</form>
<!-- !login form -->
</div>
</div>
<!-- !login panel -->
</div>
</div>
<!-- !login box -->
</div>
+113
View File
@@ -0,0 +1,113 @@
angular.module('auth', [])
.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications',
function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Authentication, Users, EndpointService, StateManager, EndpointProvider, Notifications) {
$scope.authData = {
username: 'admin',
password: '',
error: ''
};
$scope.initPasswordData = {
password: '',
password_confirmation: '',
error: false
};
$scope.logo = StateManager.getState().application.logo;
if (!$scope.applicationState.application.authentication) {
EndpointService.endpoints()
.then(function success(data) {
if (data.length > 0) {
endpointID = EndpointProvider.endpointID();
if (!endpointID) {
endpointID = data[0].Id;
EndpointProvider.setEndpointID(endpointID);
}
StateManager.updateEndpointState(true)
.then(function success() {
$state.go('dashboard');
}, function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
});
}
else {
$state.go('endpointInit');
}
}, function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
});
} else {
Users.checkAdminUser({}, function () {},
function (e) {
if (e.status === 404) {
$scope.initPassword = true;
} else {
Notifications.error('Failure', e, 'Unable to verify administrator account existence');
}
});
}
if ($stateParams.logout) {
Authentication.logout();
}
if ($stateParams.error) {
$scope.authData.error = $stateParams.error;
Authentication.logout();
}
if (Authentication.isAuthenticated()) {
$state.go('dashboard');
}
$scope.createAdminUser = function() {
var password = $sanitize($scope.initPasswordData.password);
Users.initAdminUser({password: password}, function (d) {
$scope.initPassword = false;
$timeout(function() {
var element = $window.document.getElementById('password');
if(element) {
element.focus();
}
});
}, function (e) {
$scope.initPassword.error = true;
});
};
$scope.authenticateUser = function() {
$scope.authenticationError = false;
var username = $sanitize($scope.authData.username);
var password = $sanitize($scope.authData.password);
Authentication.login(username, password)
.then(function success(data) {
return EndpointService.endpoints();
})
.then(function success(data) {
var userDetails = Authentication.getUserDetails();
if (data.length > 0) {
endpointID = EndpointProvider.endpointID();
if (!endpointID) {
endpointID = data[0].Id;
EndpointProvider.setEndpointID(endpointID);
}
StateManager.updateEndpointState(true)
.then(function success() {
$state.go('dashboard');
}, function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
});
}
else if (data.length === 0 && userDetails.role === 1) {
$state.go('endpointInit');
} else if (data.length === 0 && userDetails.role === 2) {
Authentication.logout();
$scope.authData.error = 'User not allowed. Please contact your administrator.';
}
})
.catch(function error(err) {
$scope.authData.error = 'Authentication error';
});
};
}]);
+153 -29
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()"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Recreate</button>
<button class="btn btn-primary" ng-click="duplicate()"><i class="fa fa-files-o space-right" aria-hidden="true"></i>Duplicate/Edit</button>
</div>
</rd-widget-body>
</rd-widget>
@@ -33,6 +35,10 @@
<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>
@@ -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>
+206 -48
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();
}]);
@@ -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>
@@ -1,9 +1,10 @@
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;
@@ -16,45 +17,64 @@ function ($scope, $stateParams, Settings, Container, Exec, $timeout, Messages) {
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,23 +83,10 @@ 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();
@@ -87,19 +94,23 @@ function ($scope, $stateParams, Settings, Container, Exec, $timeout, Messages) {
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;
$scope.state.connected = false;
};
};
}
@@ -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);
});
@@ -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>
+54 -22
View File
@@ -3,6 +3,7 @@
<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>
@@ -11,21 +12,28 @@
<rd-widget>
<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 btn-responsive" ng-click="startAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-play btn-ico" aria-hidden="true"></i>Start</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="stopAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-stop btn-ico" aria-hidden="true"></i>Stop</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="killAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-bomb btn-ico" 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 btn-ico" aria-hidden="true"></i>Restart</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="pauseAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-pause btn-ico" aria-hidden="true"></i>Pause</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="unpauseAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-play btn-ico" aria-hidden="true"></i>Resume</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash btn-ico" aria-hidden="true"></i>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 btn-responsive" 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,7 +45,9 @@
<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
@@ -66,7 +76,7 @@
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="swarm && !swarm_mode">
<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,35 +85,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 && !swarm_mode"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername}}</a></td>
<td ng-if="!swarm || swarm_mode"><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 && !swarm_mode">{{ 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">
<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: 40}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: 40}}</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="8" class="text-center text-muted">No containers available.</td>
<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>
+150 -69
View File
@@ -1,39 +1,52 @@
angular.module('containers', [])
.controller('ContainersController', ['$scope', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config',
function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) {
.controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'SystemService', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider',
function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, SystemService, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) {
$scope.state = {};
$scope.state.displayAll = Settings.displayAll;
$scope.state.pagination_count = Pagination.getPaginationCount('containers');
$scope.state.displayAll = true;
$scope.state.displayIP = false;
$scope.sortType = 'State';
$scope.sortReverse = false;
$scope.state.selectedItemCount = 0;
$scope.swarm_mode = false;
$scope.containers = [];
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.PublicURL = EndpointProvider.endpointPublicURL();
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('containers', $scope.state.pagination_count);
};
$scope.cleanAssociatedVolumes = false;
var update = function (data) {
$('#loadContainersSpinner').show();
$scope.state.selectedItemCount = 0;
Container.query(data, function (d) {
var containers = d;
if ($scope.containersToHideLabels) {
containers = ContainerHelper.hideContainers(d, $scope.containersToHideLabels);
}
$scope.containers = containers.map(function (container) {
var model = new ContainerViewModel(container);
model.Status = $filter('containerstatus')(model.Status);
EntityListService.rememberPreviousSelection($scope.containers, model, function onSelect(model){
$scope.selectItem(model);
});
if (model.IP) {
$scope.state.displayIP = true;
}
if ($scope.swarm && !$scope.swarm_mode) {
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM') {
model.hostIP = $scope.swarm_hosts[_.split(container.Names[0], '/')[1]];
}
return model;
});
updateSelectionFlags();
$('#loadContainersSpinner').hide();
}, function (e) {
$('#loadContainersSpinner').hide();
Notifications.error('Failure', e, 'Unable to retrieve containers');
$scope.containers = [];
});
};
@@ -44,7 +57,7 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config)
counter = counter - 1;
if (counter === 0) {
$('#loadContainersSpinner').hide();
update({all: Settings.displayAll ? 1 : 0});
update({all: $scope.state.displayAll ? 1 : 0});
}
};
angular.forEach(items, function (c) {
@@ -52,33 +65,44 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config)
counter = counter + 1;
if (action === Container.start) {
action({id: c.Id}, {}, function (d) {
Messages.send("Container " + msg, c.Id);
Notifications.success('Container ' + msg, c.Id);
complete();
}, function (e) {
Messages.error("Failure", e, "Unable to start container");
Notifications.error('Failure', e, 'Unable to start container');
complete();
});
}
else if (action === Container.remove) {
ContainerService.remove(c, $scope.cleanAssociatedVolumes)
.then(function success() {
Notifications.success('Container successfully removed');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove container');
})
.finally(function final() {
complete();
});
}
else if (action === Container.pause) {
action({id: c.Id}, function (d) {
if (d.message) {
Messages.send("Error", d.message);
}
else {
Messages.send("Container " + msg, c.Id);
Notifications.success('Container is already paused', c.Id);
} else {
Notifications.success('Container ' + msg, c.Id);
}
complete();
}, function (e) {
Messages.error("Failure", e, 'Unable to remove container');
Notifications.error('Failure', e, 'Unable to pause container');
complete();
});
}
else {
action({id: c.Id}, function (d) {
Messages.send("Container " + msg, c.Id);
Notifications.success('Container ' + msg, c.Id);
complete();
}, function (e) {
Messages.error("Failure", e, 'An error occured');
Notifications.error('Failure', e, 'An error occured');
complete();
});
@@ -90,46 +114,105 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config)
}
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredContainers, function (container) {
if (container.Checked !== allSelected) {
container.Checked = allSelected;
toggleItemSelection(container);
}
});
updateSelectionFlags();
};
$scope.selectItem = function (item) {
toggleItemSelection(item);
updateSelectionFlags();
};
$scope.toggleGetAll = function () {
update({all: $scope.state.displayAll ? 1 : 0});
};
$scope.startAction = function () {
batch($scope.containers, Container.start, 'Started');
};
$scope.stopAction = function () {
batch($scope.containers, Container.stop, 'Stopped');
};
$scope.restartAction = function () {
batch($scope.containers, Container.restart, 'Restarted');
};
$scope.killAction = function () {
batch($scope.containers, Container.kill, 'Killed');
};
$scope.pauseAction = function () {
batch($scope.containers, Container.pause, 'Paused');
};
$scope.unpauseAction = function () {
batch($scope.containers, Container.unpause, 'Unpaused');
};
$scope.removeAction = function () {
batch($scope.containers, Container.remove, 'Removed');
};
$scope.confirmRemoveAction = function () {
var isOneContainerRunning = false;
angular.forEach($scope.containers, function (c) {
if (c.Checked && c.State === 'running') {
isOneContainerRunning = true;
return;
}
});
var title = 'You are about to remove one or more container.';
if (isOneContainerRunning) {
title = 'You are about to remove one or more running containers.';
}
ModalService.confirmContainerDeletion(
title,
function (result) {
if(!result) { return; }
$scope.cleanAssociatedVolumes = false;
if (result[0]) {
$scope.cleanAssociatedVolumes = true;
}
$scope.removeAction();
}
);
};
function toggleItemSelection(item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
}
$scope.toggleGetAll = function () {
Settings.displayAll = $scope.state.displayAll;
update({all: Settings.displayAll ? 1 : 0});
};
function updateSelectionFlags() {
$scope.state.noStoppedItemsSelected = true;
$scope.state.noRunningItemsSelected = true;
$scope.state.noPausedItemsSelected = true;
$scope.containers.forEach(function(container) {
if(!container.Checked) {
return;
}
$scope.startAction = function () {
batch($scope.containers, Container.start, "Started");
};
$scope.stopAction = function () {
batch($scope.containers, Container.stop, "Stopped");
};
$scope.restartAction = function () {
batch($scope.containers, Container.restart, "Restarted");
};
$scope.killAction = function () {
batch($scope.containers, Container.kill, "Killed");
};
$scope.pauseAction = function () {
batch($scope.containers, Container.pause, "Paused");
};
$scope.unpauseAction = function () {
batch($scope.containers, Container.unpause, "Unpaused");
};
$scope.removeAction = function () {
batch($scope.containers, Container.remove, "Removed");
};
if(container.Status === 'paused') {
$scope.state.noPausedItemsSelected = false;
} else if(container.Status === 'stopped' ||
container.Status === 'created') {
$scope.state.noStoppedItemsSelected = false;
} else if(container.Status === 'running') {
$scope.state.noRunningItemsSelected = false;
}
} );
}
function retrieveSwarmHostsInfo(data) {
var swarm_hosts = {};
@@ -146,21 +229,19 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config)
return swarm_hosts;
}
$scope.swarm = false;
Config.$promise.then(function (c) {
$scope.containersToHideLabels = c.hiddenLabels;
$scope.swarm = c.swarm;
if (c.swarm) {
Info.get({}, function (d) {
if (!_.startsWith(d.ServerVersion, 'swarm')) {
$scope.swarm_mode = true;
} else {
$scope.swarm_hosts = retrieveSwarmHostsInfo(d);
}
update({all: Settings.displayAll ? 1 : 0});
});
} else {
update({all: Settings.displayAll ? 1 : 0});
}
});
function initView() {
var provider = $scope.applicationState.endpoint.mode.provider;
$q.when(provider !== 'DOCKER_SWARM' || SystemService.info())
.then(function success(data) {
if (provider === 'DOCKER_SWARM') {
$scope.swarm_hosts = retrieveSwarmHostsInfo(data);
}
update({all: $scope.state.displayAll ? 1 : 0});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve cluster information');
});
}
initView();
}]);
@@ -1,36 +1,50 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createContainer', [])
.controller('CreateContainerController', ['$scope', '$state', 'Config', 'Info', 'Container', 'Image', 'Volume', 'Network', 'Messages',
function ($scope, $state, Config, Info, Container, Image, Volume, Network, Messages) {
$scope.state = {
alwaysPull: true
};
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) {
$scope.formValues = {
alwaysPull: true,
Console: 'none',
Volumes: [],
AvailableRegistries: [],
Registry: ''
NetworkContainer: '',
Labels: [],
ExtraHosts: [],
IPv4: '',
IPv6: '',
AccessControlData: new AccessControlFormData()
};
$scope.imageConfig = {};
$scope.state = {
formValidationError: ''
};
$scope.config = {
Image: '',
Env: [],
Cmd: '',
ExposedPorts: {},
HostConfig: {
RestartPolicy: {
Name: 'no'
},
PortBindings: [],
PublishAllPorts: false,
Binds: [],
NetworkMode: 'bridge',
Privileged: false
}
Privileged: false,
ExtraHosts: [],
Devices:[]
},
NetworkingConfig: {
EndpointsConfig: {}
},
Labels: {}
};
$scope.addVolume = function() {
$scope.formValues.Volumes.push({ name: '', containerPath: '' });
$scope.formValues.Volumes.push({ name: '', containerPath: '', readOnly: false, type: 'volume' });
};
$scope.removeVolume = function(index) {
@@ -53,102 +67,36 @@ function ($scope, $state, Config, Info, Container, Image, Volume, Network, Messa
$scope.config.HostConfig.PortBindings.splice(index, 1);
};
Config.$promise.then(function (c) {
var swarm = c.swarm;
Info.get({}, function(info) {
if (swarm && !_.startsWith(info.ServerVersion, 'swarm')) {
$scope.swarm_mode = true;
}
});
$scope.addLabel = function() {
$scope.formValues.Labels.push({ name: '', value: ''});
};
$scope.formValues.AvailableRegistries = c.registries;
$scope.removeLabel = function(index) {
$scope.formValues.Labels.splice(index, 1);
};
Volume.query({}, function (d) {
$scope.availableVolumes = d.Volumes;
}, function (e) {
Messages.error("Failure", e, "Unable to retrieve volumes");
});
$scope.addExtraHost = function() {
$scope.formValues.ExtraHosts.push({ value: '' });
};
Network.query({}, function (d) {
var networks = d;
if (swarm) {
networks = d.filter(function (network) {
if (network.Scope === 'global') {
return network;
}
});
$scope.globalNetworkCount = networks.length;
networks.push({Name: "bridge"});
networks.push({Name: "host"});
networks.push({Name: "none"});
}
$scope.availableNetworks = networks;
}, function (e) {
Messages.error("Failure", e, "Unable to retrieve networks");
});
});
$scope.removeExtraHost = function(index) {
$scope.formValues.ExtraHosts.splice(index, 1);
};
// TODO: centralize, already present in templatesController
function createContainer(config) {
Container.create(config, function (d) {
if (d.message) {
$('#createContainerSpinner').hide();
Messages.error('Error', {}, d.message);
} else {
Container.start({id: d.Id}, {}, function (cd) {
if (cd.message) {
$('#createContainerSpinner').hide();
Messages.error('Error', {}, cd.message);
} else {
$('#createContainerSpinner').hide();
Messages.send('Container Started', d.Id);
$state.go('containers', {}, {reload: true});
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error("Failure", e, 'Unable to start container');
});
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error("Failure", e, 'Unable to create container');
});
}
$scope.addDevice = function() {
$scope.config.HostConfig.Devices.push({ pathOnHost: '', pathInContainer: '' });
};
// TODO: centralize, already present in templatesController
function pullImageAndCreateContainer(config) {
Image.create($scope.imageConfig, function (data) {
var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error');
if (err) {
var detail = data[data.length - 1];
$('#createContainerSpinner').hide();
Messages.error('Error', {}, detail.error);
} else {
createContainer(config);
}
}, function (e) {
$('#createContainerSpinner').hide();
Messages.error('Failure', e, 'Unable to pull image');
});
}
$scope.removeDevice = function(index) {
$scope.config.HostConfig.Devices.splice(index, 1);
};
function createImageConfig(imageName, registry) {
var imageNameAndTag = imageName.split(':');
var image = imageNameAndTag[0];
if (registry) {
image = registry + '/' + imageNameAndTag[0];
}
var imageConfig = {
fromImage: image,
tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest'
};
return imageConfig;
}
$scope.fromContainerMultipleNetworks = false;
function prepareImageConfig(config) {
var image = _.toLower(config.Image);
var image = config.Image;
var registry = $scope.formValues.Registry;
var imageConfig = createImageConfig(image, registry);
var imageConfig = ImageHelper.createImageConfigForContainer(image, registry.URL);
config.Image = imageConfig.fromImage + ':' + imageConfig.tag;
$scope.imageConfig = imageConfig;
}
@@ -157,7 +105,7 @@ function ($scope, $state, Config, Info, Container, Image, Volume, Network, Messa
var bindings = {};
config.HostConfig.PortBindings.forEach(function (portBinding) {
if (portBinding.containerPort) {
var key = portBinding.containerPort + "/" + portBinding.protocol;
var key = portBinding.containerPort + '/' + portBinding.protocol;
var binding = {};
if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) {
var hostAndPort = portBinding.hostPort.split(':');
@@ -193,7 +141,7 @@ function ($scope, $state, Config, Info, Container, Image, Volume, Network, Messa
var env = [];
config.Env.forEach(function (v) {
if (v.name && v.value) {
env.push(v.name + "=" + v.value);
env.push(v.name + '=' + v.value);
}
});
config.Env = env;
@@ -219,24 +167,381 @@ function ($scope, $state, Config, Info, Container, Image, Volume, Network, Messa
config.Volumes = volumes;
}
function prepareNetworkConfig(config) {
var mode = config.HostConfig.NetworkMode;
var container = $scope.formValues.NetworkContainer;
var containerName = container;
if (container && typeof container === 'object') {
containerName = $filter('trimcontainername')(container.Names[0]);
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM') {
containerName = $filter('swarmcontainername')(container);
}
}
var networkMode = mode;
if (containerName) {
networkMode += ':' + containerName;
config.Hostname = '';
}
config.HostConfig.NetworkMode = networkMode;
config.NetworkingConfig.EndpointsConfig[networkMode] = {
IPAMConfig: {
IPv4Address: $scope.formValues.IPv4,
IPv6Address: $scope.formValues.IPv6
}
};
$scope.formValues.ExtraHosts.forEach(function (v) {
if (v.value) {
config.HostConfig.ExtraHosts.push(v.value);
}
});
}
function prepareLabels(config) {
var labels = {};
$scope.formValues.Labels.forEach(function (label) {
if (label.name && label.value) {
labels[label.name] = label.value;
}
});
config.Labels = labels;
}
function prepareDevices(config) {
var path = [];
config.HostConfig.Devices.forEach(function (p) {
if (p.pathOnHost) {
if(p.pathInContainer === '') {
p.pathInContainer = p.pathOnHost;
}
path.push({PathOnHost:p.pathOnHost,PathInContainer:p.pathInContainer,CgroupPermissions:'rwm'});
}
});
config.HostConfig.Devices = path;
}
function prepareConfiguration() {
var config = angular.copy($scope.config);
config.Cmd = ContainerHelper.commandStringToArray(config.Cmd);
prepareNetworkConfig(config);
prepareImageConfig(config);
preparePortBindings(config);
prepareConsole(config);
prepareEnvironmentVariables(config);
prepareVolumes(config);
prepareLabels(config);
prepareDevices(config);
return config;
}
$scope.create = function () {
var config = prepareConfiguration();
$('#createContainerSpinner').show();
if ($scope.state.alwaysPull) {
pullImageAndCreateContainer(config);
function confirmCreateContainer() {
var deferred = $q.defer();
Container.query({ all: 1, filters: {name: ['^/' + $scope.config.name + '$'] }}).$promise
.then(function success(data) {
var existingContainer = data[0];
if (existingContainer) {
ModalService.confirm({
title: 'Are you sure ?',
message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?',
buttons: {
confirm: {
label: 'Replace',
className: 'btn-danger'
}
},
callback: function onConfirm(confirmed) {
if(!confirmed) { deferred.resolve(false); }
else {
// Remove old container
ContainerService.remove(existingContainer, true)
.then(function success(data) {
Notifications.success('Container Removed', existingContainer.Id);
deferred.resolve(true);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to remove container', err: err });
});
}
}
});
} else {
deferred.resolve(true);
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers');
return undefined;
});
return deferred.promise;
}
function loadFromContainerCmd(d) {
if ($scope.config.Cmd) {
$scope.config.Cmd = ContainerHelper.commandArrayToString($scope.config.Cmd);
} else {
createContainer(config);
$scope.config.Cmd = '';
}
}
function loadFromContainerPortBindings(d) {
var bindings = [];
for (var p in $scope.config.HostConfig.PortBindings) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) {
var hostPort = '';
if ($scope.config.HostConfig.PortBindings[p][0].HostIp) {
hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':';
}
hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort;
var b = {
'hostPort': hostPort,
'containerPort': p.split('/')[0],
'protocol': p.split('/')[1]
};
bindings.push(b);
}
}
$scope.config.HostConfig.PortBindings = bindings;
}
function loadFromContainerVolumes(d) {
for (var v in d.Mounts) {
if ({}.hasOwnProperty.call(d.Mounts, v)) {
var mount = d.Mounts[v];
var volume = {
'type': mount.Type,
'name': mount.Name || mount.Source,
'containerPath': mount.Destination,
'readOnly': mount.RW === false
};
$scope.formValues.Volumes.push(volume);
}
}
}
function loadFromContainerNetworkConfig(d) {
$scope.config.NetworkingConfig = {
EndpointsConfig: {}
};
var networkMode = d.HostConfig.NetworkMode;
if (networkMode === 'default') {
$scope.config.HostConfig.NetworkMode = 'bridge';
if (!_.find($scope.availableNetworks, {'Name': 'bridge'})) {
$scope.config.HostConfig.NetworkMode = 'nat';
}
}
if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) {
var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1];
$scope.config.HostConfig.NetworkMode = 'container';
for (var c in $scope.runningContainers) {
if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) {
$scope.formValues.NetworkContainer = $scope.runningContainers[c];
}
}
}
$scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2;
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) {
$scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address;
}
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) {
$scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address;
}
}
}
$scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode];
// ExtraHosts
for (var h in $scope.config.HostConfig.ExtraHosts) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) {
$scope.formValues.ExtraHosts.push({'value': $scope.config.HostConfig.ExtraHosts[h]});
$scope.config.HostConfig.ExtraHosts = [];
}
}
}
function loadFromContainerEnvrionmentVariables(d) {
var envArr = [];
for (var e in $scope.config.Env) {
if ({}.hasOwnProperty.call($scope.config.Env, e)) {
var arr = $scope.config.Env[e].split(/\=(.+)/);
envArr.push({'name': arr[0], 'value': arr[1]});
}
}
$scope.config.Env = envArr;
}
function loadFromContainerLabels(d) {
for (var l in $scope.config.Labels) {
if ({}.hasOwnProperty.call($scope.config.Labels, l)) {
$scope.formValues.Labels.push({ name: l, value: $scope.config.Labels[l]});
}
}
}
function loadFromContainerConsole(d) {
if ($scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'both';
} else if (!$scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'tty';
} else if ($scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'interactive';
} else if (!$scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'none';
}
}
function loadFromContainerDevices(d) {
var path = [];
for (var dev in $scope.config.HostConfig.Devices) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) {
var device = $scope.config.HostConfig.Devices[dev];
path.push({'pathOnHost': device.PathOnHost, 'pathInContainer': device.PathInContainer});
}
}
$scope.config.HostConfig.Devices = path;
}
function loadFromContainerImageConfig(d) {
// If no registry found, we let default DockerHub and let full image path
var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image);
RegistryService.retrieveRegistryFromRepository($scope.config.Image)
.then(function success(data) {
if (data) {
$scope.config.Image = imageInfo.image;
$scope.formValues.Registry = data;
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrive registry');
});
}
function loadFromContainerSpec() {
// Get container
Container.get({ id: $stateParams.from }).$promise
.then(function success(d) {
var fromContainer = new ContainerDetailsViewModel(d);
if (!fromContainer.ResourceControl) {
$scope.formValues.AccessControlData.AccessControlEnabled = false;
}
$scope.fromContainer = fromContainer;
$scope.config = ContainerHelper.configFromContainer(fromContainer.Model);
loadFromContainerCmd(d);
loadFromContainerPortBindings(d);
loadFromContainerVolumes(d);
loadFromContainerNetworkConfig(d);
loadFromContainerEnvrionmentVariables(d);
loadFromContainerLabels(d);
loadFromContainerConsole(d);
loadFromContainerDevices(d);
loadFromContainerImageConfig(d);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container');
});
}
function initView() {
Volume.query({}, function (d) {
$scope.availableVolumes = d.Volumes;
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve volumes');
});
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;
networks.push({ Name: 'container' });
$scope.availableNetworks = networks;
if (_.find(networks, {'Name': 'nat'})) {
$scope.config.HostConfig.NetworkMode = 'nat';
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
Container.query({}, function (d) {
var containers = d;
$scope.runningContainers = containers;
if ($stateParams.from !== '') {
loadFromContainerSpec();
} else {
$scope.fromContainer = {};
$scope.formValues.Registry = {};
}
}, function(e) {
Notifications.error('Failure', e, 'Unable to retrieve running containers');
});
}
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
$scope.state.formValidationError = error;
return false;
}
return true;
}
$scope.create = function () {
confirmCreateContainer()
.then(function success(confirm) {
if (!confirm) {
return false;
}
$('#createContainerSpinner').show();
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
var config = prepareConfiguration();
createContainer(config, accessControlData);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create container');
});
};
function createContainer(config, accessControlData) {
$q.when(!$scope.formValues.alwaysPull || ImageService.pullImage($scope.config.Image, $scope.formValues.Registry, true))
.finally(function final() {
ContainerService.createAndStartContainer(config)
.then(function success(data) {
var containerIdentifier = data.Id;
var userId = Authentication.getUserDetails().ID;
return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, []);
})
.then(function success() {
Notifications.success('Container successfully created');
$state.go('containers', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create container');
})
.finally(function final() {
$('#createContainerSpinner').hide();
});
});
}
initView();
}]);
@@ -1,7 +1,7 @@
<rd-header>
<rd-header-title title="Create container"></rd-header-title>
<rd-header-content>
Containers > Add container
<a ui-sref="containers">Containers</a> &gt; Add container
</rd-header-content>
</rd-header>
@@ -18,79 +18,105 @@
</div>
</div>
<!-- !name-input -->
<!-- image-and-registry-inputs -->
<div class="form-group">
<label for="container_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-7">
<input type="text" class="form-control" ng-model="config.Image" id="container_image" placeholder="e.g. ubuntu:trusty">
</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="formValues.Registry" id="image_registry" placeholder="leave empty to use DockerHub">
</div>
<div class="col-sm-offset-1 col-sm-11">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="state.alwaysPull"> Always pull image before creating
</label>
</div>
</div>
<div class="col-sm-12 form-section-title">
Image configuration
</div>
<!-- !image-and-registry-inputs -->
<!-- restart-policy -->
<!-- image-and-registry -->
<div class="form-group">
<label class="col-sm-1 control-label text-left">Restart policy</label>
<div class="col-sm-11">
<label class="radio-inline">
<input type="radio" name="container_restart_policy" ng-model="config.HostConfig.RestartPolicy.Name" value="no">
Never
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry"></por-image-registry>
</div>
<!-- !image-and-registry -->
<!-- always-pull -->
<div class="form-group">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Always pull the image
<portainer-tooltip position="bottom" message="When enabled, Portainer will automatically try to pull the specified image before creating the container."></portainer-tooltip>
</label>
<label class="radio-inline">
<input type="radio" name="container_restart_policy" ng-model="config.HostConfig.RestartPolicy.Name" value="always">
Always
</label>
<label class="radio-inline">
<input type="radio" name="container_restart_policy" ng-model="config.HostConfig.RestartPolicy.Name" value="on-failure">
<span class="radio-value">On failure</span>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.alwaysPull"><i></i>
</label>
</div>
</div>
<!-- !restart-policy -->
<!-- !always-pull -->
<div class="col-sm-12 form-section-title">
Ports configuration
</div>
<!-- publish-exposed-ports -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Publish all exposed ports
<portainer-tooltip position="bottom" message="When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="config.HostConfig.PublishAllPorts"><i></i>
</label>
</div>
</div>
<!-- !publish-exposed-ports -->
<!-- port-mapping -->
<div class="form-group">
<label for="container_ports" class="col-sm-1 control-label text-left">Port mapping</label>
<div class="col-sm-11">
<span class="label label-default clickable" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map port
<div class="col-sm-12">
<label class="control-label text-left">Port mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPortBinding()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
</span>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="portBinding in config.HostConfig.PortBindings" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)">
</div>
<div class="input-group col-sm-5 input-group-sm">
<!-- !host-port -->
<span style="margin: 0 10px 0 10px;">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80">
</div>
<div class="input-group col-sm-1 input-group-sm">
<select class="selectpicker form-control" ng-model="portBinding.protocol">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !protocol-actions -->
</div>
</div>
<!-- !port-mapping-input-list -->
</div>
<!-- !port-mapping -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="applicationState.application.authentication && fromContainer"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="create()">Start container</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="containers">Cancel</a>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
<span ng-if="fromContainerMultipleNetworks" style="margin-left: 10px">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span class="small text-muted" style="margin-left: 5px;">This container is connected to multiple networks, only one network will be kept at creation time.</span>
</span>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
@@ -100,12 +126,16 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-cog" title="Advanced container settings"></rd-widget-header>
<rd-widget-body>
<ul class="nav nav-tabs">
<li class="active clickable"><a data-target="#command" data-toggle="tab">Command</a></li>
<li class="clickable"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
<li class="clickable"><a data-target="#network" data-toggle="tab">Network</a></li>
<li class="clickable"><a data-target="#security" data-toggle="tab">Security/Host</a></li>
<ul class="nav nav-pills nav-justified">
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command</a></li>
<li class="interactive"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
<li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li>
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#restart-policy" data-toggle="tab">Restart policy</a></li>
<li class="interactive"><a data-target="#runtime" data-toggle="tab">Runtime</a></li>
</ul>
<!-- tab-content -->
<div class="tab-content">
@@ -114,7 +144,7 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- command-input -->
<div class="form-group">
<label for="container_command" class="col-sm-1 control-label text-left">Command</label>
<label for="container_command" class="col-sm-2 col-lg-1 control-label text-left">Command</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Cmd" id="container_command" placeholder="e.g. /usr/bin/nginx -t -c /mynginx.conf">
</div>
@@ -122,7 +152,7 @@
<!-- !command-input -->
<!-- entrypoint-input -->
<div class="form-group">
<label for="container_entrypoint" class="col-sm-1 control-label text-left">Entry Point</label>
<label for="container_entrypoint" class="col-sm-2 col-lg-1 control-label text-left">Entry Point</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Entrypoint" id="container_entrypoint" placeholder="e.g. /bin/sh -c">
</div>
@@ -130,7 +160,7 @@
<!-- !entrypoint-input -->
<!-- workdir-user-input -->
<div class="form-group">
<label for="container_workingdir" class="col-sm-1 control-label text-left">Working Dir</label>
<label for="container_workingdir" class="col-sm-2 col-lg-1 control-label text-left">Working Dir</label>
<div class="col-sm-4">
<input type="text" class="form-control" ng-model="config.WorkingDir" id="container_workingdir" placeholder="e.g. /myapp">
</div>
@@ -142,8 +172,8 @@
<!-- !workdir-user-input -->
<!-- console -->
<div class="form-group">
<label for="container_console" class="col-sm-1 control-label text-left">Console</label>
<div class="col-sm-11">
<label for="container_console" class="col-sm-2 col-lg-1 control-label text-left">Console</label>
<div class="col-sm-10 col-lg-11">
<div class="col-sm-4">
<label class="radio-inline">
<input type="radio" name="container_console" ng-model="formValues.Console" value="both">
@@ -157,7 +187,7 @@
</label>
</div>
</div>
<div class="col-sm-offset-1 col-sm-11">
<div class="col-sm-offset-2 col-sm-10 col-lg-offset-1 col-lg-11">
<div class="col-sm-4">
<label class="radio-inline">
<input type="radio" name="container_console" ng-model="formValues.Console" value="tty">
@@ -173,35 +203,6 @@
</div>
</div>
<!-- !console -->
<!-- environment-variables -->
<div class="form-group">
<label for="container_env" class="col-sm-1 control-label text-left">Environment variables</label>
<div class="col-sm-11">
<span class="label label-default clickable" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in config.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<!-- !environment-variables -->
</form>
</div>
<!-- !tab-command -->
@@ -210,39 +211,64 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- volumes -->
<div class="form-group">
<label for="container_volumes" class="col-sm-1 control-label text-left">Volumes</label>
<div class="col-sm-11">
<span class="label label-default clickable" ng-click="addVolume()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> volume
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Volume mapping</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addVolume()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional volume
</span>
</div>
<!-- volumes-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div ng-repeat="volume in formValues.Volumes" style="margin-top: 2px;">
<div class="input-group col-sm-1 input-group-sm">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="volume.readOnly"> Read-only
</label>
<div class="form-inline" style="margin-top: 10px;">
<div ng-repeat="volume in formValues.Volumes">
<!-- volume-line1 -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<!-- container-path -->
<div class="input-group input-group-sm col-sm-6">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container">
</div>
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon"><input type="checkbox" ng-model="volume.isPath" ng-click="resetVolumePath($index)">Path</span>
<select class="selectpicker form-control" ng-model="volume.name" ng-if="!volume.isPath">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
</select>
<input ng-if="volume.isPath" type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeVolume($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
<!-- !container-path -->
<!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
<!-- !volume-type -->
</div>
<!-- !volume-line1 -->
<!-- volume-line2 -->
<div class="col-sm-12 form-inline" style="margin-top: 5px;">
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
<!-- volume -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
<span class="input-group-addon">volume</span>
<select class="form-control" ng-model="volume.name">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
</select>
</div>
<!-- !volume -->
<!-- bind -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host">
</div>
<!-- !bind -->
<!-- read-only -->
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="false">Writable</label>
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="true">Read-only</label>
</div>
</div>
<!-- !read-only -->
</div>
<!-- !volume-line2 -->
</div>
</div>
<!-- !volumes-input-list -->
@@ -254,25 +280,38 @@
<!-- tab-network -->
<div class="tab-pane" id="network">
<form class="form-horizontal" style="margin-top: 15px;">
<div class="form-group" ng-if="globalNetworkCount === 0 && !swarm_mode">
<div class="form-group" ng-if="globalNetworkCount === 0 && applicationState.endpoint.mode.provider !== 'DOCKER_SWARM_MODE'">
<div class="col-sm-12">
<span class="small text-muted">You don't have any shared network. Head over the <a ui-sref="networks">networks view</a> to create one.</span>
</div>
</div>
<!-- network-input -->
<div class="form-group">
<label for="container_network" class="col-sm-1 control-label text-left">Network</label>
<label for="container_network" class="col-sm-2 col-lg-1 control-label text-left">Network</label>
<div class="col-sm-9">
<select class="selectpicker form-control" ng-model="config.HostConfig.NetworkMode">
<select class="form-control" ng-model="config.HostConfig.NetworkMode" id="container_network">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
</select>
</div>
</div>
<!-- !network-input -->
<!-- container-name-input -->
<div class="form-group" ng-if="config.HostConfig.NetworkMode == 'container'">
<label for="container_network_container" class="col-sm-2 col-lg-1 control-label text-left">Container</label>
<div class="col-sm-9">
<select ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'" ng-options="container|containername for container in runningContainers" class="form-control" ng-model="formValues.NetworkContainer">
<option selected disabled hidden value="">Select a container</option>
</select>
<select ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'" ng-options="container|swarmcontainername for container in runningContainers" class="form-control" ng-model="formValues.NetworkContainer">
<option selected disabled hidden value="">Select a container</option>
</select>
</div>
</div>
<!-- !container-name-input -->
<!-- hostname-input -->
<div class="form-group">
<label for="container_hostname" class="col-sm-1 control-label text-left">Hostname</label>
<label for="container_hostname" class="col-sm-2 col-lg-1 control-label text-left">Hostname</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Hostname" id="container_hostname" placeholder="e.g. web01">
</div>
@@ -280,44 +319,197 @@
<!-- !hostname-input -->
<!-- domainname-input -->
<div class="form-group">
<label for="container_domainname" class="col-sm-1 control-label text-left">Domain Name</label>
<label for="container_domainname" class="col-sm-2 col-lg-1 control-label text-left">Domain Name</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Domainname" id="container_domainname" placeholder="e.g. example.com">
</div>
</div>
<!-- !domainname -->
<!-- ipv4-input -->
<div class="form-group">
<label for="container_ipv4" class="col-sm-2 col-lg-1 control-label text-left">IPv4 Address</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="formValues.IPv4" id="container_ipv4" placeholder="e.g. 172.20.0.7">
</div>
</div>
<!-- !ipv4-input -->
<!-- ipv6-input -->
<div class="form-group">
<label for="container_ipv6" class="col-sm-2 col-lg-1 control-label text-left">IPv6 Address</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="formValues.IPv6" id="container_ipv6" placeholder="e.g. a:b:c:d::1234">
</div>
</div>
<!-- !ipv6-input -->
<!-- extra-hosts-variables -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Hosts file entries</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addExtraHost()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add additional entry
</span>
</div>
<!-- extra-hosts-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in formValues.ExtraHosts" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. host:IP">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeExtraHost($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !extra-hosts-input-list -->
</div>
<!-- !extra-hosts-variables -->
</form>
</div>
<!-- !tab-network -->
<!-- tab-security -->
<div class="tab-pane" id="security">
<!-- tab-labels -->
<div class="tab-pane" id="labels">
<form class="form-horizontal" style="margin-top: 15px;">
<!-- labels -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
</div>
<!-- !labels-->
</form>
</div>
<!-- !tab-labels -->
<!-- tab-env -->
<div class="tab-pane" id="env">
<form class="form-horizontal" style="margin-top: 15px;">
<!-- environment-variables -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in config.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<!-- !environment-variables -->
</form>
</div>
<!-- !tab-labels -->
<!-- tab-restart-policy -->
<div class="tab-pane" id="restart-policy">
<form class="form-horizontal" style="margin-top: 15px;">
<!-- privileged-mode -->
<div class="form-group">
<div class="col-sm-12">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="config.HostConfig.Privileged"> Privileged mode
<label class="control-label text-left">
Restart policy
</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'no'">
Never
</label>
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'always'">
Always
</label>
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'on-failure'">
On failure
</label>
<label class="btn btn-primary" ng-model="config.HostConfig.RestartPolicy.Name" uib-btn-radio="'unless-stopped'">
Unless stopped
</label>
</div>
</div>
</div>
<!-- !privileged-mode -->
</form>
</div>
<!-- !tab-security -->
<!-- !tab-restart-policy -->
<!-- tab-runtime -->
<div class="tab-pane" id="runtime">
<form class="form-horizontal" style="margin-top: 15px;">
<!-- privileged-mode -->
<div class="form-group">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Privileged mode
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="config.HostConfig.Privileged"><i></i>
</label>
</div>
</div>
<!-- !privileged-mode -->
</form>
<form class="form-horizontal" style="margin-top: 15px;">
<!-- devices -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Devices</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addDevice()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add device
</span>
</div>
<!-- devices-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="device in config.HostConfig.Devices" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="device.pathOnHost" placeholder="e.g. /dev/tty0">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="device.pathInContainer" placeholder="e.g. /dev/tty0">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeDevice($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !devices-input-list -->
</div>
<!-- !devices-->
</form>
</div>
<!-- !tab-runtime -->
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12" style="text-align: center;">
<div>
<i id="createContainerSpinner" class="fa fa-cog fa-3x fa-spin" style="margin-bottom: 5px; display: none;"></i>
</div>
<button type="button" class="btn btn-default btn-lg" ng-click="create()">Create</button>
<a type="button" class="btn btn-default btn-lg" ui-sref="containers">Cancel</a>
</div>
</div>
@@ -1,10 +1,11 @@
angular.module('createNetwork', [])
.controller('CreateNetworkController', ['$scope', '$state', 'Messages', 'Network',
function ($scope, $state, Messages, Network) {
.controller('CreateNetworkController', ['$scope', '$state', 'Notifications', 'Network', 'LabelHelper',
function ($scope, $state, Notifications, Network, LabelHelper) {
$scope.formValues = {
DriverOptions: [],
Subnet: '',
Gateway: ''
Gateway: '',
Labels: []
};
$scope.config = {
@@ -16,7 +17,8 @@ function ($scope, $state, Messages, Network) {
IPAM: {
Driver: 'default',
Config: []
}
},
Labels: {}
};
$scope.addDriverOption = function() {
@@ -27,20 +29,28 @@ function ($scope, $state, Messages, Network) {
$scope.formValues.DriverOptions.splice(index, 1);
};
$scope.addLabel = function() {
$scope.formValues.Labels.push({ key: '', value: ''});
};
$scope.removeLabel = function(index) {
$scope.formValues.Labels.splice(index, 1);
};
function createNetwork(config) {
$('#createNetworkSpinner').show();
Network.create(config, function (d) {
if (d.message) {
$('#createNetworkSpinner').hide();
Messages.error('Unable to create network', {}, d.message);
Notifications.error('Unable to create network', {}, d.message);
} else {
Messages.send("Network created", d.Id);
Notifications.success('Network created', d.Id);
$('#createNetworkSpinner').hide();
$state.go('networks', {}, {reload: true});
}
}, function (e) {
$('#createNetworkSpinner').hide();
Messages.error("Failure", e, 'Unable to create network');
Notifications.error('Failure', e, 'Unable to create network');
});
}
@@ -63,10 +73,15 @@ function ($scope, $state, Messages, Network) {
config.Options = options;
}
function prepareLabelsConfig(config) {
config.Labels = LabelHelper.fromKeyValueToLabelHash($scope.formValues.Labels);
}
function prepareConfiguration() {
var config = angular.copy($scope.config);
prepareIPAMConfiguration(config);
prepareDriverOptions(config);
prepareLabelsConfig(config);
return config;
}
+72 -32
View File
@@ -1,7 +1,7 @@
<rd-header>
<rd-header-title title="Create network"></rd-header-title>
<rd-header-content>
Networks > Add network
<a ui-sref="networks">Networks</a> &gt; Add network
</rd-header-content>
</rd-header>
@@ -18,36 +18,45 @@
</div>
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title">
Network configuration
</div>
<!-- subnet-gateway-inputs -->
<div class="form-group">
<label for="network_subnet" class="col-sm-1 control-label text-left">Subnet</label>
<div class="col-sm-5">
<label for="network_subnet" class="col-sm-2 col-lg-1 control-label text-left">Subnet</label>
<div class="col-sm-4 col-lg-5">
<input type="text" class="form-control" ng-model="formValues.Subnet" id="network_subnet" placeholder="e.g. 172.20.0.0/16">
</div>
<label for="network_gateway" class="col-sm-1 control-label text-left">Gateway</label>
<div class="col-sm-5">
<label for="network_gateway" class="col-sm-2 col-lg-1 control-label text-left">Gateway</label>
<div class="col-sm-4 col-lg-5">
<input type="text" class="form-control" ng-model="formValues.Gateway" id="network_gateway" placeholder="e.g. 172.20.10.11">
</div>
</div>
<!-- !subnet-gateway-inputs -->
<div class="col-sm-12 form-section-title">
Driver configuration
</div>
<!-- driver-input -->
<div class="form-group">
<label for="network_driver" class="col-sm-1 control-label text-left">Driver</label>
<div class="col-sm-11">
<label for="network_driver" class="col-sm-2 col-lg-1 control-label text-left">Driver</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="config.Driver" id="network_driver" placeholder="e.g. driverName">
</div>
</div>
<!-- !driver-input -->
<!-- driver-options -->
<div class="form-group">
<label for="network_driveropts" class="col-sm-1 control-label text-left">Driver options</label>
<div class="col-sm-11">
<span class="label label-default clickable" ng-click="addDriverOption()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> driver option
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">
Driver options
<portainer-tooltip position="bottom" message="Driver options are specific to the selected driver. Please refer to the selected driver documentation."></portainer-tooltip>
</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addDriverOption()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add driver option
</span>
</div>
<!-- driver-options-input-list -->
<div class="col-sm-offset-1 col-sm-11 form-inline" style="margin-top: 10px;">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="option in formValues.DriverOptions" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
@@ -56,40 +65,71 @@
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. true">
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-click="removeDriverOption($index)">
<i class="fa fa-minus" aria-hidden="true"></i>
</button>
</span>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeDriverOption($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !driver-options-input-list -->
</div>
<!-- !driver-options -->
<div class="col-sm-12 form-section-title">
Advanced configuration
</div>
<!-- labels -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
</div>
<!-- !labels-->
<!-- internal -->
<div class="form-group">
<div class="col-sm-12">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="config.Internal"> Restrict external access to the network
</label>
</div>
<label for="ownership" class="control-label text-left">
Restrict external access to the network
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="config.Internal"><i></i>
</label>
</div>
</div>
<!-- !internal -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Name" ng-click="create()">Create network</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="networks">Cancel</a>
<i id="createNetworkSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12" style="text-align: center;">
<div>
<i id="createNetworkSpinner" class="fa fa-cog fa-3x fa-spin" style="margin-bottom: 5px; display: none;"></i>
</div>
<button type="button" class="btn btn-default btn-lg" ng-disabled="!config.Name" ng-click="create()">Create</button>
<a type="button" class="btn btn-default btn-lg" ui-sref="networks">Cancel</a>
</div>
</div>
@@ -0,0 +1,49 @@
angular.module('createRegistry', [])
.controller('CreateRegistryController', ['$scope', '$state', 'RegistryService', 'Notifications',
function ($scope, $state, RegistryService, Notifications) {
$scope.state = {
RegistryType: 'quay'
};
$scope.formValues = {
Name: 'Quay',
URL: 'quay.io',
Authentication: true,
Username: '',
Password: ''
};
$scope.selectQuayRegistry = function() {
$scope.formValues.Name = 'Quay';
$scope.formValues.URL = 'quay.io';
$scope.formValues.Authentication = true;
};
$scope.selectCustomRegistry = function() {
$scope.formValues.Name = '';
$scope.formValues.URL = '';
$scope.formValues.Authentication = false;
};
$scope.addRegistry = function() {
$('#createRegistrySpinner').show();
var registryName = $scope.formValues.Name;
var registryURL = $scope.formValues.URL.replace(/^https?\:\/\//i, '');
var authentication = $scope.formValues.Authentication;
var username = $scope.formValues.Username;
var password = $scope.formValues.Password;
RegistryService.createRegistry(registryName, registryURL, authentication, username, password)
.then(function success(data) {
Notifications.success('Registry successfully created');
$state.go('registries');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create registry');
})
.finally(function final() {
$('#createRegistrySpinner').hide();
});
};
}]);
@@ -0,0 +1,117 @@
<rd-header>
<rd-header-title title="Create registry">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="display:none"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="registries">Registries</a> &gt; Add registry
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
Registry provider
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div ng-click="selectQuayRegistry()">
<input type="radio" id="registry_quay" ng-model="state.RegistryType" value="quay">
<label for="registry_quay">
<div class="boxselector_header">
<i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i>
Quay.io
</div>
<p>Quay container registry</p>
</label>
</div>
<div ng-click="selectCustomRegistry()">
<input type="radio" id="registry_custom" ng-model="state.RegistryType" value="custom">
<label for="registry_custom">
<div class="boxselector_header">
<i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i>
Custom registry
</div>
<p>Define your own registry</p>
</label>
</div>
</div>
</div>
<div class="col-sm-12 form-section-title" ng-if="state.RegistryType === 'custom'">
Important notice
</div>
<div class="form-group" ng-if="state.RegistryType === 'custom'">
<span class="col-sm-12 text-muted small">
Docker requires you to connect to a <a href="https://docs.docker.com/registry/deploying/#running-a-domain-registry" target="_blank">secure registry</a>.
You can find more information about how to connect to an insecure registry <a href="https://docs.docker.com/registry/insecure/" target="_blank">in the Docker documentation</a>.
</span>
</div>
<div class="col-sm-12 form-section-title">
Registry details
</div>
<!-- name-input -->
<div class="form-group" ng-if="state.RegistryType === 'custom'">
<label for="registry_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="registry_name" ng-model="formValues.Name" placeholder="e.g. my-registry">
</div>
</div>
<!-- !name-input -->
<!-- registry-url-input -->
<div class="form-group" ng-if="state.RegistryType === 'custom'">
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
Registry URL
<portainer-tooltip position="bottom" message="URL or IP address of a Docker registry. Any protocol will be stripped."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="registry_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:5000 or myregistry.domain.tld">
</div>
</div>
<!-- !registry-url-input -->
<!-- authentication-checkbox -->
<div class="form-group" ng-if="state.RegistryType === 'custom'">
<div class="col-sm-12">
<label for="registry_auth" class="control-label text-left">
Authentication
<portainer-tooltip position="bottom" message="Enable this option if you need to specify credentials to connect to this registry."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.Authentication"><i></i>
</label>
</div>
</div>
<!-- !authentication-checkbox -->
<!-- authentication-credentials -->
<div ng-if="formValues.Authentication || state.RegistryType === 'quay'">
<!-- credentials-user -->
<div class="form-group">
<label for="credentials_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="credentials_username" ng-model="formValues.Username">
</div>
</div>
<!-- !credentials-user -->
<!-- credentials-password -->
<div class="form-group">
<label for="credentials_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="credentials_password" ng-model="formValues.Password">
</div>
</div>
<!-- !credentials-password -->
</div>
<!-- !authentication-credentials -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name || !formValues.URL || (formValues.Authentication && (!formValues.Username || !formValues.Password))" ng-click="addRegistry()">Add registry</button>
<i id="createRegistrySpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

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