Compare commits

...

198 Commits

Author SHA1 Message Date
Anthony Lapenna f42733b74c Merge branch 'release/1.17.1' 2018-05-21 11:03:55 +02:00
Anthony Lapenna 19f9840c8c chore(version): bump version number 2018-05-21 11:03:48 +02:00
Anthony Lapenna fe7a88697b feat(service): automatically focus replica input after clicking on scale (#1916) 2018-05-21 10:59:02 +02:00
kirdia 19c3fa276b feat(log-viewer): Add the ability to specify displayed line count (#1914) 2018-05-21 10:51:56 +02:00
Anthony Lapenna 63d338c4da fix(api): refactor TLS support (#1909)
* refactor(api): refactor TLS support

* feat(api): migrate endpoint data

* refactor(api): remove unused code and rename functions

* refactor(app): remove console.log statement
2018-05-19 16:25:11 +02:00
Anthony Lapenna 5d3f438288 fix(tasks): fix an issue when filtering tasks (#1913) 2018-05-19 10:47:58 +02:00
Anthony Lapenna e7e7d73f20 docs(api): update swagger.yml 2018-05-18 10:58:16 +02:00
Anthony Lapenna 0ea91f7185 chore(codefresh): remove develop pipeline 2018-05-18 10:15:56 +02:00
Anthony Lapenna 034fde6d1a chore(codefresh): add branch pipeline 2018-05-18 10:07:25 +02:00
Anthony Lapenna 45f52657cf fix(websocket): feat(websocket): remove Origin header before handling request (#1901) 2018-05-16 09:13:46 +02:00
Anthony Lapenna 32800a843a feat(sidebar): update endpoint selection UX (#1902)
* style(sidebar): update selected endpoint name color

* feat(sidebar): sort groups/endpoints alphabetically
2018-05-16 08:49:14 +02:00
Anthony Lapenna 5df09923b6 feat(api): add debug statements in response handling 2018-05-15 19:13:27 +02:00
Anthony Lapenna 79f4c20c25 fix(endpoints): set TLSSkipVerify to false when TLS is not enabled during update (#1896) 2018-05-15 18:24:54 +02:00
Anthony Lapenna 2c0595f5ed feat(exec): relocate config.json to data folder and re-use existing content (#1898) 2018-05-15 14:12:49 +02:00
Anthony Lapenna a09af01e17 chore(build-system): update gruntfile 2018-05-14 21:41:24 +02:00
Anthony Lapenna be236f9d09 fix(api): fix default group for endpoint declared via -H 2018-05-14 21:40:50 +02:00
Anthony Lapenna 87fdd43afc Merge tag '1.17.0' into develop
Release 1.17.0
2018-05-10 17:22:26 +02:00
Anthony Lapenna 19bb83ba2a Merge branch 'release/1.17.0' 2018-05-10 17:22:20 +02:00
Anthony Lapenna f75c87315e chore(version): bump version number 2018-05-10 17:22:11 +02:00
Anthony Lapenna a0a667053e feat(tasks): change task name format in tasks datatable (#1884) 2018-05-10 17:17:53 +02:00
Miguel A. C b2b1c86067 fix(service-details): avoid sending unmodified service reservation, limits and update config (#1625) 2018-05-10 09:54:22 +02:00
Anthony Lapenna 74c92c4da8 Merge branch 'develop' of github.com:portainer/portainer into develop 2018-05-09 16:12:02 +02:00
Anthony Lapenna 7754933470 fix(api): fix a panic issue when retrieving Docker API response 2018-05-09 16:11:52 +02:00
Andrew Pearson 1c06bfd911 feat(container-details): update port mapping order (#1878)
Switching container port mapping around to match docker, correcting issue #1871
2018-05-09 10:26:47 +02:00
Anthony Lapenna 3b14e6b6b9 chore(codefresh): update codefresh pipelines (#1879) 2018-05-09 10:00:38 +02:00
Anthony Lapenna a83ea1554c chore(build-system): update docker binary version 2018-05-08 19:53:10 +02:00
Anthony Lapenna 4d79259748 feat(notifications): display image removal error 2018-05-08 08:20:27 +02:00
Anthony Lapenna cdb09a91a7 refactor(about): remove Swarm support 2018-05-08 08:20:04 +02:00
Konstantin Azizov 284f2b7752 feat(settings): allow hide container with label with no value (#1860) (#1872)
Also add ability to submit form by pressing "Enter" key

Fixes #1860
2018-05-08 07:46:07 +02:00
Konstantin Azizov 55a96767bb feat(security): add request rate limiter on authentication endpoint (#1866) 2018-05-07 20:01:39 +02:00
Anthony Lapenna 6360e6a20b fix(api): use the folder of the stackfile as working dir when deploying a stack (#1869) 2018-05-07 09:57:15 +02:00
Anthony Lapenna 2327d696e0 feat(agent): add agent support (#1828) 2018-05-06 09:15:57 +02:00
Anthony Lapenna 77a85bd385 fix(container-edit): fix an issue related to missing extra hosts in network config (#1862) 2018-05-04 09:59:51 +02:00
Anthony Lapenna e0cf088428 fix(log-viewer): strip headers in container logs when TTY is disabled (#1861) 2018-05-04 09:45:05 +02:00
Hans-Joachim Krauch 1e55ada6af feat(templates): allow to set hostname in container templates (#1833) 2018-05-02 20:41:46 +02:00
Anthony Lapenna e8744e8c0b chore(project): update issue templates 2018-05-02 17:01:05 +02:00
Anthony Lapenna 1162549209 feat(endpoint-groups): add endpoint-groups (#1837) 2018-04-26 18:08:46 +02:00
Anthony Lapenna 2ffcb946b1 fix(access-control): fix access control panel layout (#1844) 2018-04-25 22:13:06 +02:00
Anthony Lapenna 1d24a827de docs(api): update endpoint creation documentation (#1843) 2018-04-25 21:52:06 +02:00
Anthony Lapenna c705d27ac6 docs(api): update resource control creation docs (#1842) 2018-04-25 21:40:21 +02:00
Anthony Lapenna dea5038c93 chore(docker): upgrade Docker CLI version (#1841) 2018-04-25 21:29:23 +02:00
Herwono W. Wijaya f0317d6d87 fix(api): fix the ability to push images to private repositories 2018-04-25 16:58:08 +02:00
Guri afa3fd9a47 feat(app): remove charset from content-type of post/put/patch (#1791) 2018-04-25 16:00:29 +02:00
Anthony Lapenna fe74f36f62 fix(volume-creation): fix missing endpointProvider variable 2018-04-23 08:05:22 +02:00
Anthony Lapenna 05d6abf57b feat(api): ping the endpoint at creation time (#1817) 2018-04-16 13:19:24 +02:00
Hasnat 031b428e0c fix(external-endpoints): less verbose output (#1815) 2018-04-14 11:17:58 +02:00
Anthony Lapenna 23f4939ee7 docs(api): add missing supported resource control types (#1812) 2018-04-13 16:09:43 +02:00
Igor Karpovich 7690ef3c33 fix(api): add json content-type to all json API responses (#1809) 2018-04-13 16:01:02 +02:00
Anthony Lapenna 4f0e752d00 feat(api): remove any version api before proxying request (#1806) 2018-04-11 17:40:29 +02:00
Maximilian Pachl 2a9ba1f9a2 feat(swarm-visualizer): save settings to local storage (#1777) 2018-04-06 18:59:25 +10:00
Shahar Hadas 216d6c2b14 feat(container-console): add the ability to select ash (#1790)
Add /bin/ash as another dropbox option in addition to bash and sh
2018-04-06 18:43:08 +10:00
Rahul Ruikar dca1976252 feat(stack): Add the ability to scale services in stack-details (#1776) 2018-04-04 19:45:35 +10:00
Anthony Lapenna 1cfbec557c refactor(project): remove Swarm standalone support (#1720)
* refactor(project): remove Swarm standalone support

* fix(state): fix an issue with endpoint state not being registered
2018-04-04 10:31:04 +10:00
Lennart Nordgreen 517f983ec6 chore(disribution): update .spec files 2018-04-04 09:06:02 +10:00
Anthony Lapenna 0edcdbd612 Merge tag '1.16.5' into develop
Release 1.16.5
2018-04-02 07:44:33 +10:00
Anthony Lapenna a8ee774cf2 Merge branch 'release/1.16.5' 2018-04-02 07:44:28 +10:00
Anthony Lapenna 81ed0e4507 chore(version): bump version number 2018-04-02 07:44:19 +10:00
Anthony Lapenna 8d32703456 fix(service-details): prevent regular users from using bind mounts (#1778) 2018-03-29 18:41:47 +11:00
Anthony Lapenna eca39b11a8 chore(project): remove linting from contribution guidelines 2018-03-28 19:47:49 +11:00
Emanuele De Cupis b2b685ba6f style(datatables): prevent cell content to go to new line (#1770) 2018-03-28 08:11:17 +11:00
moncho 7e26d09881 feat(service-details): display stop grace period in a human-friendly format (#1773) 2018-03-28 08:05:01 +11:00
Rahul Ruikar 80a23b5351 feat(log-viewer): add the ability to display timestamps (#1697) 2018-03-25 10:36:13 +10:00
Anthony Lapenna 30dfd3d616 fix(api): manage registry authentication in the API (#1751) 2018-03-23 08:44:43 +10:00
Anthony Lapenna c267f8bf57 fix(stacks): fix an issue when deploying public stacks 2018-03-22 15:38:00 +10:00
Herwono W. Wijaya bca8936faa fix(templates): fix app templates stack deployment (#1747)
* fix(templates): fix app templates stack deployment

* fix(templates): stack deployment remove return statement and fix identation
2018-03-22 15:28:55 +10:00
Anthony Lapenna a72ffe4188 fix(extensions): use an empty object instead of a null value when registering extension (#1750) 2018-03-22 14:37:36 +10:00
Anthony Lapenna 27dcd708a6 fix(extensions): init endpoint extensions after admin user creation (#1733)
* fix(extensions): init endpoint extensions after admin user creation
2018-03-18 07:09:07 +10:00
Anthony Lapenna adf1ba7b47 feat(stack-creation): add the ability to specify git credentials (#1722)
* feat(stack-creation): add the ability to specify git credentials

* docs(api): update Swagger
2018-03-16 07:22:05 +10:00
Anthony Lapenna 50ece68f35 style(app): update icon style (#1727) 2018-03-14 15:32:14 +10:00
Paweł Kozioł 4e38e4ba33 feat(image-details): display image layer order and sort by it by default (#1715)
* feat(image-details): display image layer depth and sort by it by default (#1706)

* refactor(image-details): rename 'Depth' to 'Order' in image layers table

* refactor(image-details): sort image layers from the bottom to the top one
2018-03-14 10:27:06 +10:00
1138-4EB f0621cb09c chore(build-system): use regular vendor files, ignore (pre)minified (#1475) 2018-03-14 10:24:00 +10:00
Anthony Lapenna 9e47aedbe6 fix(api): ignore directory existence check and use os.MkdirAll (#1719) 2018-03-14 09:47:21 +10:00
Anthony Lapenna 706490db5e fix(api): use EntryPoint as a reference to overwrite stack Compose file (#1725) 2018-03-13 21:35:12 +10:00
Anthony Lapenna d34b1d5f9d fix(build-system): fix task order after fontawesome5 integration (#1724) 2018-03-13 21:09:02 +10:00
Herwono W. Wijaya 66f29dd103 style(app): upgrade to font awesome v5 2018-03-13 15:36:53 +10:00
Anthony Lapenna 96e77b3ada fix(api): fix a regression with the HTTP handler (#1718) 2018-03-13 09:06:38 +10:00
Anthony Lapenna 3d9a3f11e4 Merge tag '1.16.4' into develop
Release 1.16.4
2018-03-11 20:30:16 +10:00
Anthony Lapenna 9c277733d5 Merge branch 'release/1.16.4' 2018-03-11 20:30:12 +10:00
Anthony Lapenna ec2a9e149b chore(version): bump version number 2018-03-11 20:30:07 +10:00
Anthony Lapenna aa41fd02ef feat(log-viewer): use only one switch to manage collection/autoscroll (#1713)
* feat(log-viewer): use only one switch to manage collection/autoscroll

* feat(log-viewer): add the ability to clear selection

* style(log-viewer): update unselect button design
2018-03-11 20:29:13 +10:00
Anthony Lapenna 28c73323bf refactor(extensions): review bouncer settings for extensions endpoint (#1711) 2018-03-10 08:18:59 +10:00
Herwono W. Wijaya b389e3c65a fix(service-logs): fix services log view breadcrumb link (#1709) 2018-03-10 08:09:03 +10:00
Anthony Lapenna 02b3d54a75 fix(extensions): fix invalid storidge API URL (#1707) 2018-03-09 19:50:48 +10:00
Anthony Lapenna f1a21c07bd feat(storidge): add extension check on endpoint switch (#1693)
* feat(storidge): add extension check on endpoint switch

* feat(storidge): add extension check post login
2018-03-09 08:49:43 +10:00
Anthony Lapenna 403de0d319 chore(momentjs): upgrade momentjs version (#1701) 2018-03-08 11:42:50 +10:00
Anthony Lapenna a76ccff7c9 refactor(xterm): update xtermjs to latest version (#1692) 2018-03-06 17:40:02 +10:00
Anthony Lapenna 1ae9832980 Merge tag '1.16.3' into develop
Release 1.16.3
2018-03-03 09:20:05 +10:00
Anthony Lapenna 8a9619c7e8 Merge branch 'release/1.16.3' 2018-03-03 09:19:59 +10:00
Anthony Lapenna 9634cf1563 chore(version): bump version number 2018-03-03 09:19:54 +10:00
Mauro Cortellazzi 716cd033b2 feat(events): add missing events support (#1682) 2018-03-02 18:21:26 +10:00
Anthony Lapenna 28bca85e01 feat(registries): remove actual password from registry password input (#1687) 2018-03-02 18:16:33 +10:00
Anthony Lapenna 73e6498d2f refactor(swarm-visualizer): move task border logic to a filter (#1686) 2018-03-02 09:00:34 +10:00
Mauro Cortellazzi 1b8d5e89d1 feat(swarm-visualizer): swarm visualizer color by service (#1683) 2018-03-02 08:10:14 +10:00
Anthony Lapenna 76aeee7237 feat(templates): add support for the name property (#1680) 2018-02-28 08:59:31 +01:00
Anthony Lapenna b9a1c68ea0 feat(security): check user existence for each protected requests (#1679) 2018-02-28 08:09:51 +01:00
Anthony Lapenna b8f8df5f48 fix(endpoints-creation): remove endpoint if an error is raised during creation (#1678) 2018-02-28 07:52:40 +01:00
Anthony Lapenna 0c5152fb5f feat(log-viewer): introduce the log viewer component (#1666) 2018-02-28 07:19:28 +01:00
Anthony Lapenna 81de2a5afb feat(image-build): add the ability to build images (#1672) 2018-02-28 07:19:06 +01:00
Anthony Lapenna e065bd4a47 style(containers): update label color for unhealthy containers (#1677) 2018-02-28 05:54:13 +01:00
Anthony Lapenna 9b80b6adb2 refactor(code-editor): introduce code-editor component (#1674)
* refactor(code-editor): introduce code-editor component

* refactor(code-editor): add some extra validation
2018-02-27 08:19:21 +01:00
Anthony Lapenna eb43579378 feat(storidge): introduce endpoint extensions and proxy Storidge API (#1661) 2018-02-23 03:10:26 +01:00
Anthony Lapenna b5e256c967 fix(services): use the Public URL instead of a manager IP (#1665) 2018-02-21 10:55:51 +01:00
Boissier Florian ae5416583e style(containers): update quick actions tooltips messages (#1659) 2018-02-17 09:44:29 +01:00
Anthony Lapenna 5b9cb1a883 feat(api): use the stack ProjectPath as the working directory during deployment (#1648) 2018-02-09 10:55:51 +01:00
Anthony Lapenna b040b3ff8c Merge tag '1.16.2' into develop
Release 1.16.2
2018-02-08 09:27:27 +01:00
Anthony Lapenna 3ff49542f3 Merge branch 'release/1.16.2' 2018-02-08 09:27:20 +01:00
Anthony Lapenna 27dcfd043b chore(version): bump version number 2018-02-08 09:27:13 +01:00
Anthony Lapenna 1de0619fd5 fix(api): ignore Docker login errors during stack deployment (#1635) 2018-02-07 08:37:01 +01:00
Anthony Lapenna 1c67db0c70 feat(ux): enable auto-focus on search field (#1636) 2018-02-06 16:58:05 +01:00
Anthony Lapenna 7365e69c59 fix(config-creation): fix an issue setting config editor as read-only (#1634) 2018-02-06 14:23:08 +01:00
Anthony Lapenna 23a565243a Merge branch 'develop' of github.com:portainer/portainer into develop 2018-02-01 13:29:43 +01:00
Anthony Lapenna 27dceadba1 refactor(app): introduce new project structure for the frontend (#1623) 2018-02-01 13:27:52 +01:00
Anthony Lapenna 6f471cef34 Merge branch 'master' into develop 2018-01-31 21:35:20 +01:00
Ben Yanke e6422a6d75 style(container-details): fix a typo in container status 2018-01-31 20:28:36 +01:00
Anthony Lapenna 56cab429de Revert "feat(container-details): fix typo in container status" (#1619)
This reverts commit 5f742c2163.
2018-01-31 19:11:20 +01:00
Ben Yanke 5f742c2163 feat(container-details): fix typo in container status 2018-01-31 19:09:10 +01:00
Anthony Lapenna f31f29fa2f feat(volumes): check if volumes are used in service definitions (#1601) 2018-01-25 08:13:56 +01:00
Anthony Lapenna 672819f3af refactor(api): remove CLI deprecation related code (#1602) 2018-01-24 21:58:58 +01:00
Anthony Lapenna 0ff0c3ed0d Merge tag '1.16.1' into develop
Release 1.16.1
2018-01-23 16:53:03 +01:00
Anthony Lapenna 54750f002a Merge branch 'release/1.16.1' 2018-01-23 16:52:59 +01:00
Anthony Lapenna 4c2dfb3346 chore(version): bump version number 2018-01-23 16:52:54 +01:00
Miguel A. C 8ae3abf29e fix(service-details): avoid sending unmodified restart policy settings when updating a service (#1576) 2018-01-23 10:06:58 +01:00
Anthony Lapenna 362f036a68 fix(state): ensure API version >= 1.25 before extension check (#1594)
* fix(state): ensure API version >= 1.25 before extension check
2018-01-23 09:50:14 +01:00
Anthony Lapenna 0d0072a50e extension(storidge): support cluster shutdown (#1589) 2018-01-23 09:49:29 +01:00
Anthony Lapenna 173ea372c2 fix(extension): bypass the error returned by plugin service during ex… (#1586)
* fix(extension): bypass the error returned by plugin service during extension check

* feat(plugins): bypass the error returned by plugin service during plugin retrieval
2018-01-23 09:47:36 +01:00
Anthony Lapenna 8c75f705e2 chore(dependency): upgrade jquery version to latest (#1592) 2018-01-22 17:44:49 +01:00
Anthony Lapenna b1863430df revert: revert PR 1366 (#1588) 2018-01-22 10:06:47 +01:00
Anthony Lapenna c51db23c32 Merge tag '1.16.0' into develop
Release 1.16.0
2018-01-21 17:30:18 +01:00
Anthony Lapenna c40f120da2 Merge branch 'release/1.16.0' 2018-01-21 17:30:13 +01:00
Anthony Lapenna a7cb0ca823 chore(version): bump version number 2018-01-21 17:30:06 +01:00
Anthony Lapenna 7817d4bd0b extension(storidge): add Storidge extension (#1581) 2018-01-21 17:26:24 +01:00
Miguel A. C edadce359c feat(stack-details): add stack deploy prune option (#1567)
* feat(stack-details): add stack deploy prune option

* fix go fmt issues

* add changes proposed by reviewer

* refactor deployStack as suggested by codeclimate
2018-01-20 18:05:01 +01:00
Anthony Lapenna e1bf9599ef fix(stack-details): fix broken link for services published ports (#1578) 2018-01-20 11:31:26 +01:00
RobbyVoid c3ba9e6a53 feat(networks): Show untruncated network name as link title (#1574)
If the network name was truncated (40 characters) it should be visible as a mouse over title
2018-01-19 12:41:18 +01:00
Vincent Besançon 10174b98b9 refactor(api): Fixed typo in check health cli flag (#1570) 2018-01-17 16:34:15 +01:00
1138-4EB 6acfb580dc feat(cli): Add CLI flag for health-check (#1366) 2018-01-15 19:34:07 +01:00
Miguel A. C 340ec841fe feat(swarm-visualizer): add auto-refresh to the cluster visualizer (#1561) 2018-01-12 16:10:02 +01:00
Anthony Lapenna a515b96a46 fix(app): fix a Javascript error related to missing $state parameter (#1562) 2018-01-09 20:06:19 +01:00
Anthony Lapenna 46da85c8cf feat(services): bind enter key when scaling a service (#1560) 2018-01-09 10:59:33 +01:00
Anthony Lapenna f52ac8fb12 feat(UX): improve UX for service update (#1558) 2018-01-09 10:40:30 +01:00
Miguel A. C 0e28aebd65 feat(service): add force update in service list/detail (#1536) 2018-01-08 22:06:56 +01:00
Anthony Lapenna 35892525ff docs(api): document the stack management endpoint (#1557) 2018-01-08 18:27:45 +01:00
Anthony Lapenna d2f3309842 refactor(api): rename file package to filesystem (#1555) 2018-01-06 18:53:12 +01:00
Rahul Ruikar 03f6cc0acf feat(templates): add labels to container template (#1538) 2018-01-06 18:24:51 +01:00
cbrherms f8c7ee7ae6 feat(container-creation): add support for mac assignments (#1546)
* feat(container-creation): add support for mac assignments (#1524)

* refactor(container-creation): code relocation to relevant function

* style(container-creation): fix typo in environment variables function
2018-01-06 11:53:03 +01:00
Thomas Krzero 00daedca30 fix(service): check endpoint spec existence before update 2018-01-05 14:49:41 +01:00
Anthony Lapenna e2b8633aac fix(stack-details): fix an issue related to env vars (#1512) 2018-01-05 14:32:23 +01:00
Anthony Lapenna 50dbb572b1 fix(containers): update the persisted filters after refresh (#1553) 2018-01-05 14:31:20 +01:00
Anthony Lapenna 95b595d2a9 fix(UAC): fix an issue with network/volume ownership update (#1552) 2018-01-05 13:43:25 +01:00
Anthony Lapenna f57ce8b327 feat(containers): trim the @sha256 suffix in the image name (#1551) 2018-01-05 12:32:53 +01:00
Anthony Lapenna 5787df5599 refactor(stack): replace $stateParams usage with $transition$.params() 2018-01-04 21:53:10 +01:00
Anthony Lapenna 52ac9504c1 chore(codefresh): fix the build_frontend step (#1547) 2018-01-02 13:14:26 +01:00
Yassir Hannoun 1da64f2e75 * fix(containers): display a subset of the sha images name in the containers datatable
* Removed unnecessary filter

* refactor(common): improve trimshasum  filter

* refactor(common): improve trimshasum filter
2017-12-22 19:39:06 +01:00
Miguel A. C 8bf3f669d0 feat(service): add logging driver config in service create/update (#1516) 2017-12-22 10:05:31 +01:00
Anthony Lapenna eec10541b3 fix(users): fix invalid Authentication value (#1528) 2017-12-21 19:56:54 +01:00
Anthony Lapenna e0b09f20b0 fix(cache): add a cache validity mechanism (#1527) 2017-12-21 19:49:39 +01:00
Miguel A. C 8e40eb1844 feat(service): add hosts file entries in service create/update (#1511) 2017-12-21 09:53:34 +01:00
Anthony Lapenna c9e060d574 fix(container-logs): add missing dependency to Notifications (#1514) 2017-12-18 21:24:51 +01:00
Anthony Lapenna 9c9e16b2b2 fix(containers): fix the ability to stop/pause a healthy container (#1507) 2017-12-14 10:31:16 +01:00
Anthony Lapenna 35f7ce5f3d Merge tag '1.15.5' into develop
Release 1.15.5
2017-12-11 16:04:03 +01:00
Anthony Lapenna 45e7938c5c Merge branch 'release/1.15.5' 2017-12-11 16:03:58 +01:00
Anthony Lapenna fbd9139928 chore(version): bump version number 2017-12-11 16:03:53 +01:00
Anthony Lapenna d0da9860af style(datatables): use normal font weight for table headers (#1496) 2017-12-11 16:03:00 +01:00
Duvel 46d8dba137 style(networks): change the label of the add button (#1495) 2017-12-11 15:50:59 +01:00
Anthony Lapenna 3660f6eeb5 Merge tag '1.15.4' into develop
Release 1.15.4
2017-12-10 10:10:01 +01:00
Anthony Lapenna 39236ae84e Merge branch 'release/1.15.4' 2017-12-10 10:09:56 +01:00
Anthony Lapenna 7dcf5c2d0b chore(version): bump version number 2017-12-10 10:09:11 +01:00
Miguel A. C d0e147137d feat(service): add restart policy options in service create/details (#1479) 2017-12-07 21:05:45 +01:00
Anthony Lapenna bdb23a8dd2 feat(UX): replace tables with datatables (#1460) 2017-12-06 12:04:02 +01:00
1138-4EB 7922ecc4a1 chore(build-system): refactor gruntfile (#1447) 2017-12-05 21:26:45 +01:00
Miguel A. C 728ef35cc1 feat(service): change update delay format to a time string in service… (#1470) 2017-12-05 20:12:54 +01:00
Anthony Lapenna f3a23c7dd1 feat(container-details): display loading when using recreate (#1471) 2017-12-05 17:46:11 +01:00
Anthony Lapenna 283faca4f7 feat(dashboard): add a link to the visualizer (#1469) 2017-12-05 17:34:29 +01:00
Anthony Lapenna 2b2850d17a fix(stacks): fix an issue with stacks using docker in their name (#1468) 2017-12-05 14:56:40 +01:00
1138-4EB 997af882c4 chore(build-system): drop bower, use npm|yarn for frontend dependencies (#1416)
* chore(build-system): drop bower, use npm|yarn for frontend dependencies

* chore(build-sytem): for github dependencies, use semver format instead of tag/commit

* add yarn.lock
2017-12-05 09:52:38 +01:00
1138-4EB 75b3a78e2b refactor(services): Refactor chartService and pluginService (#1340) 2017-12-05 09:49:04 +01:00
Miguel A. C d8f6b14726 feat(authentication-settings): add default port when not set in url (#1456) 2017-12-04 19:41:59 +01:00
Miguel A. C 406757d751 feat(swarm-visualizer): add ram and cpu info to nodes & limits to tasks (#1458) 2017-12-04 18:01:07 +01:00
Miguel A. C f3b5f803f5 feat(tasks): add missing task states, set new default state color (#1459) 2017-12-04 17:58:46 +01:00
1138-4EB f1d9b72a06 docs(README): add ref to portainer-demo/play-with-docker (#1455) 2017-12-02 09:39:10 +01:00
doncicuto 9513da80f6 feat(node): add engine labels info in the swarm nodes view (#1451) 2017-12-01 09:26:03 +01:00
Anthony Lapenna ca036b56c1 feat(database-migration): enable donation header when upgrading Portainer (#1450) 2017-11-28 13:40:33 +01:00
Anthony Lapenna 27a388a030 Merge tag '1.15.3' into develop
Release 1.15.3
2017-11-26 10:08:08 +01:00
Anthony Lapenna 65cde27334 Merge branch 'release/1.15.3' 2017-11-26 10:08:04 +01:00
Anthony Lapenna 2275467bdc chore(version): bump version number 2017-11-26 10:07:59 +01:00
1138-4EB 688b15fb4b feat(about): add a new about view as well as a support header 2017-11-26 10:05:03 +01:00
Anthony Lapenna 3362ba0c8c fix(services): do not display exposed ports when published port is missing (#1440) 2017-11-25 10:30:40 +01:00
Anthony Lapenna 39cf4d75ff fix(container-creation): reset NetworkConfig when changing the network during container edition (#1431) 2017-11-23 16:02:40 +01:00
Duvel 13d8d38bf9 fix(service-details): fix an issue with invalid service restart policy (#1415) 2017-11-23 10:47:39 +01:00
1138-4EB e51246ee78 style(sidebar): prevent icon of active item moving on hover (#1422) 2017-11-23 09:59:29 +01:00
Anthony Lapenna 4ab580923f fix(templates): fix an issue preventing linuxserver.io templates to be displayed (#1426) 2017-11-22 22:16:53 +01:00
1138-4EB 547511c8aa feat(UX): change background color for selected items (#1414) 2017-11-20 17:46:01 +01:00
1138-4EB 8a101f67f6 style(container-details): change the grouping of buttons
* style(containers) make add container button responsive

* style(container) make action buttons responsive, group as in containers
2017-11-20 14:48:42 +01:00
Thomas Krzero 3ee2e20f8e feat(services): add the ability to specify a target for secrets (#1365) 2017-11-20 14:44:23 +01:00
Yassir Hannoun 6b9f3dad7a feat(UX): add an image autocomplete feature for services and containers (#1389) 2017-11-20 14:34:14 +01:00
1138-4EB a2d41e5316 feat(build-system): check that files listed in vendor.yml exist (#1398)
* chore(build-system) check that files listed in vendor.yml exist (#1410)

* fix(build-system) Chart.min.js duplicated in vendor.yml (#1410)
2017-11-20 10:09:11 +01:00
Thomas Kooi 3548f0db6f refactor(webapp): simplify isAdmin statement (#1388) 2017-11-14 08:54:35 +01:00
Anthony Lapenna 521cc3d6ab Merge tag '1.15.2' into develop
Release 1.15.2
2017-11-13 10:11:27 +01:00
550 changed files with 22094 additions and 7998 deletions
@@ -13,15 +13,21 @@ steps:
image: portainer/angular-builder:latest
working_directory: ${{build_backend}}
commands:
- npm install -g bower grunt grunt-cli && npm install
- bower install --allow-root
- grunt build-webapp
- yarn
- yarn grunt build-webapp
- mv api/cmd/portainer/portainer dist/
get_docker_version:
image: alpine
working_directory: ${{build_frontend}}
commands:
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
download_docker_binary:
image: busybox
working_directory: ${{build_frontend}}
commands:
- echo ${{DOCKER_VERSION}}
- wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz
- tar -xf /tmp/docker-binaries.tgz -C /tmp
- mv /tmp/docker/docker dist/
@@ -38,7 +44,3 @@ steps:
candidate: '${{build_image}}'
tag: '${{CF_BRANCH}}'
registry: dockerhub
when:
branch:
only:
- develop
+46
View File
@@ -0,0 +1,46 @@
version: '1.0'
steps:
build_backend:
image: portainer/golang-builder:ci
working_directory: ${{main_clone}}
commands:
- mkdir -p /go/src/github.com/${{CF_REPO_OWNER}}
- ln -s /codefresh/volume/${{CF_REPO_NAME}}/api /go/src/github.com/${{CF_REPO_OWNER}}/${{CF_REPO_NAME}}
- /build.sh api/cmd/portainer
build_frontend:
image: portainer/angular-builder:latest
working_directory: ${{build_backend}}
commands:
- yarn
- yarn grunt build-webapp
- mv api/cmd/portainer/portainer dist/
get_docker_version:
image: alpine
working_directory: ${{build_frontend}}
commands:
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
download_docker_binary:
image: busybox
working_directory: ${{build_frontend}}
commands:
- echo ${{DOCKER_VERSION}}
- wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz
- tar -xf /tmp/docker-binaries.tgz -C /tmp
- mv /tmp/docker/docker dist/
build_image:
type: build
working_directory: ${{download_docker_binary}}
dockerfile: ./build/linux/Dockerfile
image_name: portainer/portainer
tag: ${{CF_BRANCH}}
push_image:
type: push
candidate: '${{build_image}}'
tag: 'pr${{CF_PULL_REQUEST_NUMBER}}'
registry: dockerhub
+47
View File
@@ -0,0 +1,47 @@
---
name: Bug report
about: Create a bug report
---
<!--
Thanks for reporting a bug for Portainer !
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
Before opening a new issue, make sure that we do not have any duplicates
already open. You can ensure this by searching the issue list for this
repository. If there is a duplicate, please close your issue and add a comment
to the existing issue instead.
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
-->
**Bug description**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
Briefly describe what you were expecting.
**Steps to reproduce the issue:**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Technical details:**
* Portainer version:
* Docker version (managed by Portainer):
* Platform (windows/linux):
* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`):
* Browser:
**Additional context**
Add any other context about the problem here.
+15
View File
@@ -0,0 +1,15 @@
---
name: Question
about: Ask us a question about Portainer usage or deployment
---
<!--
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.
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
-->
**Question**:
How can I deploy Portainer on... ?
+31
View File
@@ -0,0 +1,31 @@
---
name: Feature request
about: Suggest a feature/enhancement that should be added in Portainer
---
<!--
Thanks for opening a feature request for Portainer !
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
Before opening a new issue, make sure that we do not have any duplicates
already open. You can ensure this by searching the issue list for this
repository. If there is a duplicate, please close your issue and add a comment
to the existing issue instead.
Also, be sure to check our FAQ and documentation first: https://portainer.readthedocs.io
-->
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
-3
View File
@@ -30,9 +30,6 @@ You can have a use Github filters to list these issues:
* intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate
* advanced labeled issues: https://github.com/portainer/portainer/labels/exp%2Fadvanced
### Linting
Please check your code using `grunt lint` before submitting your pull requests.
### Commit Message Format
+8
View File
@@ -26,6 +26,14 @@ You can try out the public demo instance: http://demo.portainer.io/ (login with
Please note that the public demo cluster is **reset every 15min**.
Alternatively, you can deploy a copy of the demo stack inside a [play-with-docker (PWD)](https://labs.play-with-docker.com) playground:
- Browse [PWD/?stack=portainer-demo/play-with-docker/docker-stack.yml](http://play-with-docker.com/?stack=https://raw.githubusercontent.com/portainer/portainer-demo/master/play-with-docker/docker-stack.yml)
- Sign in with your [Docker ID](https://docs.docker.com/docker-id)
- Follow [these](https://github.com/portainer/portainer-demo/blob/master/play-with-docker/docker-stack.yml#L5-L8) steps.
Unlike the public demo, the playground sessions are deleted after 4 hours. Apart from that, all the settings are same, including default credentials.
## Getting started
* [Deploy Portainer](https://portainer.readthedocs.io/en/latest/deployment.html)
+36
View File
@@ -0,0 +1,36 @@
package archive
import (
"archive/tar"
"bytes"
)
// TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content
// specified in fileContent. Returns the archive as a byte array.
func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) {
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
header := &tar.Header{
Name: fileName,
Mode: 0600,
Size: int64(len(fileContent)),
}
err := tarWriter.WriteHeader(header)
if err != nil {
return nil, err
}
_, err = tarWriter.Write(fileContent)
if err != nil {
return nil, err
}
err = tarWriter.Close()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
+27 -1
View File
@@ -20,6 +20,7 @@ type Store struct {
TeamService *TeamService
TeamMembershipService *TeamMembershipService
EndpointService *EndpointService
EndpointGroupService *EndpointGroupService
ResourceControlService *ResourceControlService
VersionService *VersionService
SettingsService *SettingsService
@@ -38,6 +39,7 @@ const (
teamBucketName = "teams"
teamMembershipBucketName = "team_membership"
endpointBucketName = "endpoints"
endpointGroupBucketName = "endpoint_groups"
resourceControlBucketName = "resource_control"
settingsBucketName = "settings"
registryBucketName = "registries"
@@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) {
TeamService: &TeamService{},
TeamMembershipService: &TeamMembershipService{},
EndpointService: &EndpointService{},
EndpointGroupService: &EndpointGroupService{},
ResourceControlService: &ResourceControlService{},
VersionService: &VersionService{},
SettingsService: &SettingsService{},
@@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) {
store.TeamService.store = store
store.TeamMembershipService.store = store
store.EndpointService.store = store
store.EndpointGroupService.store = store
store.ResourceControlService.store = store
store.VersionService.store = store
store.SettingsService.store = store
@@ -94,7 +98,7 @@ func (store *Store) Open() error {
store.db = db
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName, stackBucketName}
return db.Update(func(tx *bolt.Tx) error {
@@ -110,6 +114,28 @@ func (store *Store) Open() error {
})
}
// Init creates the default data set.
func (store *Store) Init() error {
groups, err := store.EndpointGroupService.EndpointGroups()
if err != nil {
return err
}
if len(groups) == 0 {
unassignedGroup := &portainer.EndpointGroup{
Name: "Unassigned",
Description: "Unassigned endpoints",
Labels: []portainer.Pair{},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup)
}
return nil
}
// Close closes the BoltDB database.
func (store *Store) Close() error {
if store.db != 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"
)
// EndpointGroupService represents a service for managing endpoint groups.
type EndpointGroupService struct {
store *Store
}
// EndpointGroup returns an endpoint group by ID.
func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrEndpointGroupNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var endpointGroup portainer.EndpointGroup
err = internal.UnmarshalEndpointGroup(data, &endpointGroup)
if err != nil {
return nil, err
}
return &endpointGroup, nil
}
// EndpointGroups return an array containing all the endpoint groups.
func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup, error) {
var endpointGroups = make([]portainer.EndpointGroup, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var endpointGroup portainer.EndpointGroup
err := internal.UnmarshalEndpointGroup(v, &endpointGroup)
if err != nil {
return err
}
endpointGroups = append(endpointGroups, endpointGroup)
}
return nil
})
if err != nil {
return nil, err
}
return endpointGroups, nil
}
// CreateEndpointGroup assign an ID to a new endpoint group and saves it.
func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
id, _ := bucket.NextSequence()
endpointGroup.ID = portainer.EndpointGroupID(id)
data, err := internal.MarshalEndpointGroup(endpointGroup)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(endpointGroup.ID)), data)
if err != nil {
return err
}
return nil
})
}
// UpdateEndpointGroup updates an endpoint group.
func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error {
data, err := internal.MarshalEndpointGroup(endpointGroup)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteEndpointGroup deletes an endpoint group.
func (service *EndpointGroupService) DeleteEndpointGroup(ID portainer.EndpointGroupID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}
+10
View File
@@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint)
}
// MarshalEndpointGroup encodes an endpoint group to binary format.
func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) {
return json.Marshal(group)
}
// UnmarshalEndpointGroup decodes an endpoint group from a binary data.
func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error {
return json.Unmarshal(data, group)
}
// MarshalStack encodes a stack to binary format.
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
return json.Marshal(stack)
+28
View File
@@ -0,0 +1,28 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion11() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
if endpoint.Type == portainer.AgentOnDockerEnvironment {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = true
} else {
if endpoint.TLSConfig.TLSSkipVerify && !endpoint.TLSConfig.TLS {
endpoint.TLSConfig.TLSSkipVerify = false
}
}
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package bolt
func (m *Migrator) updateSettingsToVersion7() error {
legacySettings, err := m.SettingsService.Settings()
if err != nil {
return err
}
legacySettings.DisplayDonationHeader = true
err = m.SettingsService.StoreSettings(legacySettings)
if err != nil {
return err
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion8() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.Extensions = []portainer.EndpointExtension{}
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion9() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.GroupID = portainer.EndpointGroupID(1)
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion10() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.Type = portainer.DockerEnvironment
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
+39
View File
@@ -81,6 +81,45 @@ func (m *Migrator) Migrate() error {
}
}
// https://github.com/portainer/portainer/issues/1449
if m.CurrentDBVersion < 7 {
err := m.updateSettingsToVersion7()
if err != nil {
return err
}
}
if m.CurrentDBVersion < 8 {
err := m.updateEndpointsToVersion8()
if err != nil {
return err
}
}
// https: //github.com/portainer/portainer/issues/1396
if m.CurrentDBVersion < 9 {
err := m.updateEndpointsToVersion9()
if err != nil {
return err
}
}
// https://github.com/portainer/portainer/issues/461
if m.CurrentDBVersion < 10 {
err := m.updateEndpointsToVersion10()
if err != nil {
return err
}
}
// https://github.com/portainer/portainer/issues/1906
if m.CurrentDBVersion < 11 {
err := m.updateEndpointsToVersion11()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err
+15 -30
View File
@@ -1,7 +1,6 @@
package cli
import (
"log"
"time"
"github.com/portainer/portainer"
@@ -31,27 +30,27 @@ 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(),
EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(),
ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(),
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).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(),
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").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(),
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()
@@ -70,11 +69,11 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
// ValidateFlags validates the values of the flags.
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
if *flags.Endpoint != "" && *flags.ExternalEndpoints != "" {
if *flags.EndpointURL != "" && *flags.ExternalEndpoints != "" {
return errEndpointExcludeExternal
}
err := validateEndpoint(*flags.Endpoint)
err := validateEndpointURL(*flags.EndpointURL)
if err != nil {
return err
}
@@ -97,19 +96,17 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
return errAdminPassExcludeAdminPassFile
}
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://") {
func validateEndpointURL(endpointURL string) error {
if endpointURL != "" {
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") {
return errInvalidEndpointProtocol
}
if strings.HasPrefix(endpoint, "unix://") {
socketPath := strings.TrimPrefix(endpoint, "unix://")
if strings.HasPrefix(endpointURL, "unix://") {
socketPath := strings.TrimPrefix(endpointURL, "unix://")
if _, err := os.Stat(socketPath); err != nil {
if os.IsNotExist(err) {
return errSocketNotFound
@@ -142,15 +139,3 @@ func validateSyncInterval(syncInterval string) error {
}
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.")
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ const (
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
+2 -1
View File
@@ -6,7 +6,8 @@ const (
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
+151 -33
View File
@@ -1,15 +1,18 @@
package main // import "github.com/portainer/portainer"
import (
"strings"
"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/exec"
"github.com/portainer/portainer/file"
"github.com/portainer/portainer/filesystem"
"github.com/portainer/portainer/git"
"github.com/portainer/portainer/http"
"github.com/portainer/portainer/http/client"
"github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap"
@@ -31,7 +34,7 @@ func initCLI() *portainer.CLIFlags {
}
func initFileService(dataStorePath string) portainer.FileService {
fileService, err := file.NewService(dataStorePath, "")
fileService, err := filesystem.NewService(dataStorePath, "")
if err != nil {
log.Fatal(err)
}
@@ -49,6 +52,11 @@ func initStore(dataStorePath string) *bolt.Store {
log.Fatal(err)
}
err = store.Init()
if err != nil {
log.Fatal(err)
}
err = store.MigrateData()
if err != nil {
log.Fatal(err)
@@ -56,8 +64,8 @@ func initStore(dataStorePath string) *bolt.Store {
return store
}
func initStackManager(assetsPath string) portainer.StackManager {
return exec.NewStackManager(assetsPath)
func initStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) {
return exec.NewStackManager(assetsPath, dataStorePath, signatureService, fileService)
}
func initJWTService(authenticationEnabled bool) portainer.JWTService {
@@ -71,6 +79,10 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService {
return nil
}
func initDigitalSignatureService() portainer.DigitalSignatureService {
return &crypto.ECDSAService{}
}
func initCryptoService() portainer.CryptoService {
return &crypto.Service{}
}
@@ -127,6 +139,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
if err == portainer.ErrSettingsNotFound {
settings := &portainer.Settings{
LogoURL: *flags.Logo,
DisplayDonationHeader: true,
DisplayExternalContributors: false,
AuthenticationMethod: portainer.AuthenticationInternal,
LDAPSettings: portainer.LDAPSettings{
@@ -167,6 +180,122 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService
return &endpoints[0]
}
func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
private, public, err := fileService.LoadKeyPair()
if err != nil {
return err
}
return signatureService.ParseKeyPair(private, public)
}
func generateAndStoreKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
private, public, err := signatureService.GenerateKeyPair()
if err != nil {
return err
}
privateHeader, publicHeader := signatureService.PEMHeaders()
return fileService.StoreKeyPair(private, public, privateHeader, publicHeader)
}
func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error {
existingKeyPair, err := fileService.KeyPairFilesExist()
if err != nil {
log.Fatal(err)
}
if existingKeyPair {
return loadAndParseKeyPair(fileService, signatureService)
}
return generateAndStoreKeyPair(fileService, signatureService)
}
func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService) error {
tlsConfiguration := portainer.TLSConfiguration{
TLS: *flags.TLS,
TLSSkipVerify: *flags.TLSSkipVerify,
}
if *flags.TLS {
tlsConfiguration.TLSCACertPath = *flags.TLSCacert
tlsConfiguration.TLSCertPath = *flags.TLSCert
tlsConfiguration.TLSKeyPath = *flags.TLSKey
} else if !*flags.TLS && *flags.TLSSkipVerify {
tlsConfiguration.TLS = true
}
endpoint := &portainer.Endpoint{
Name: "primary",
URL: *flags.EndpointURL,
GroupID: portainer.EndpointGroupID(1),
Type: portainer.DockerEnvironment,
TLSConfig: tlsConfiguration,
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
if strings.HasPrefix(endpoint.URL, "tcp://") {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(tlsConfiguration.TLSCACertPath, tlsConfiguration.TLSCertPath, tlsConfiguration.TLSKeyPath, tlsConfiguration.TLSSkipVerify)
if err != nil {
return err
}
agentOnDockerEnvironment, err := client.ExecutePingOperation(endpoint.URL, tlsConfig)
if err != nil {
return err
}
if agentOnDockerEnvironment {
endpoint.Type = portainer.AgentOnDockerEnvironment
}
}
return endpointService.CreateEndpoint(endpoint)
}
func createUnsecuredEndpoint(endpointURL string, endpointService portainer.EndpointService) error {
if strings.HasPrefix(endpointURL, "tcp://") {
_, err := client.ExecutePingOperation(endpointURL, nil)
if err != nil {
return err
}
}
endpoint := &portainer.Endpoint{
Name: "primary",
URL: endpointURL,
GroupID: portainer.EndpointGroupID(1),
Type: portainer.DockerEnvironment,
TLSConfig: portainer.TLSConfiguration{},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
return endpointService.CreateEndpoint(endpoint)
}
func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService) error {
if *flags.EndpointURL == "" {
return nil
}
endpoints, err := endpointService.Endpoints()
if err != nil {
return err
}
if len(endpoints) > 0 {
log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.")
return nil
}
if *flags.TLS || *flags.TLSSkipVerify {
return createTLSSecuredEndpoint(flags, endpointService)
}
return createUnsecuredEndpoint(*flags.EndpointURL, endpointService)
}
func main() {
flags := initCLI()
@@ -175,19 +304,29 @@ func main() {
store := initStore(*flags.Data)
defer store.Close()
stackManager := initStackManager(*flags.Assets)
jwtService := initJWTService(!*flags.NoAuth)
cryptoService := initCryptoService()
digitalSignatureService := initDigitalSignatureService()
ldapService := initLDAPService()
gitService := initGitService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags)
err := initKeyPair(fileService, digitalSignatureService)
if err != nil {
log.Fatal(err)
}
stackManager, err := initStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService)
if err != nil {
log.Fatal(err)
}
err = initSettings(store.SettingsService, flags)
if err != nil {
log.Fatal(err)
}
@@ -199,32 +338,9 @@ func main() {
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
if *flags.Endpoint != "" {
endpoints, err := store.EndpointService.Endpoints()
if err != nil {
log.Fatal(err)
}
if len(endpoints) == 0 {
endpoint := &portainer.Endpoint{
Name: "primary",
URL: *flags.Endpoint,
TLSConfig: portainer.TLSConfiguration{
TLS: *flags.TLSVerify,
TLSSkipVerify: false,
TLSCACertPath: *flags.TLSCacert,
TLSCertPath: *flags.TLSCert,
TLSKeyPath: *flags.TLSKey,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = store.EndpointService.CreateEndpoint(endpoint)
if err != nil {
log.Fatal(err)
}
} else {
log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.")
}
err = initEndpoint(flags, store.EndpointService)
if err != nil {
log.Fatal(err)
}
adminPasswordHash := ""
@@ -273,6 +389,7 @@ func main() {
TeamService: store.TeamService,
TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService,
EndpointGroupService: store.EndpointGroupService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
RegistryService: store.RegistryService,
@@ -284,6 +401,7 @@ func main() {
FileService: fileService,
LDAPService: ldapService,
GitService: gitService,
SignatureService: digitalSignatureService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
-2
View File
@@ -142,8 +142,6 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
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)
+125
View File
@@ -0,0 +1,125 @@
package crypto
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/md5"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"math/big"
)
const (
// PrivateKeyPemHeader represents the header that is appended to the PEM file when
// storing the private key.
PrivateKeyPemHeader = "EC PRIVATE KEY"
// PublicKeyPemHeader represents the header that is appended to the PEM file when
// storing the public key.
PublicKeyPemHeader = "ECDSA PUBLIC KEY"
)
// ECDSAService is a service used to create digital signatures when communicating with
// an agent based environment. It will automatically generates a key pair using ECDSA or
// can also reuse an existing ECDSA key pair.
type ECDSAService struct {
privateKey *ecdsa.PrivateKey
publicKey *ecdsa.PublicKey
encodedPubKey string
}
// EncodedPublicKey returns the encoded version of the public that can be used
// to be shared with other services. It's the hexadecimal encoding of the public key
// content.
func (service *ECDSAService) EncodedPublicKey() string {
return service.encodedPubKey
}
// PEMHeaders returns the ECDSA PEM headers.
func (service *ECDSAService) PEMHeaders() (string, string) {
return PrivateKeyPemHeader, PublicKeyPemHeader
}
// ParseKeyPair parses existing private/public key pair content and associate
// the parsed keys to the service.
func (service *ECDSAService) ParseKeyPair(private, public []byte) error {
privateKey, err := x509.ParseECPrivateKey(private)
if err != nil {
return err
}
service.privateKey = privateKey
encodedKey := hex.EncodeToString(public)
service.encodedPubKey = encodedKey
publicKey, err := x509.ParsePKIXPublicKey(public)
if err != nil {
return err
}
service.publicKey = publicKey.(*ecdsa.PublicKey)
return nil
}
// GenerateKeyPair will create a new key pair using ECDSA.
func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) {
pubkeyCurve := elliptic.P256()
privatekey, err := ecdsa.GenerateKey(pubkeyCurve, rand.Reader)
if err != nil {
return nil, nil, err
}
service.privateKey = privatekey
service.publicKey = &privatekey.PublicKey
private, err := x509.MarshalECPrivateKey(service.privateKey)
if err != nil {
return nil, nil, err
}
public, err := x509.MarshalPKIXPublicKey(service.publicKey)
if err != nil {
return nil, nil, err
}
encodedKey := hex.EncodeToString(public)
service.encodedPubKey = encodedKey
return private, public, nil
}
// Sign creates a signature from a message.
// It automatically hash the message using MD5 and creates a signature from
// that hash.
// It then encodes the generated signature in base64.
func (service *ECDSAService) Sign(message string) (string, error) {
digest := md5.New()
digest.Write([]byte(message))
hash := digest.Sum(nil)
r := big.NewInt(0)
s := big.NewInt(0)
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
if err != nil {
return "", err
}
keyBytes := service.privateKey.Params().BitSize / 8
rBytes := r.Bytes()
rBytesPadded := make([]byte, keyBytes)
copy(rBytesPadded[keyBytes-len(rBytes):], rBytes)
sBytes := s.Bytes()
sBytesPadded := make([]byte, keyBytes)
copy(sBytesPadded[keyBytes-len(sBytes):], sBytes)
signature := append(rBytesPadded, sBytesPadded...)
return base64.RawStdEncoding.EncodeToString(signature), nil
}
+35 -15
View File
@@ -4,36 +4,56 @@ import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"github.com/portainer/portainer"
)
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
TLSConfig := &tls.Config{}
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
// loaded from memory.
func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
config := &tls.Config{}
config.InsecureSkipVerify = skipServerVerification
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
if !skipClientVerification {
certificate, err := tls.X509KeyPair(cert, key)
if err != nil {
return nil, err
}
config.Certificates = []tls.Certificate{certificate}
}
if !skipServerVerification {
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
config.RootCAs = caCertPool
}
return config, nil
}
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
// loaded from disk.
func CreateTLSConfigurationFromDisk(caCertPath, certPath, keyPath string, skipServerVerification bool) (*tls.Config, error) {
config := &tls.Config{}
config.InsecureSkipVerify = skipServerVerification
if certPath != "" && keyPath != "" {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
TLSConfig.Certificates = []tls.Certificate{cert}
config.Certificates = []tls.Certificate{cert}
}
if !config.TLSSkipVerify {
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
if !skipServerVerification && caCertPath != "" {
caCert, err := ioutil.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
TLSConfig.RootCAs = caCertPool
config.RootCAs = caCertPool
}
TLSConfig.InsecureSkipVerify = config.TLSSkipVerify
return TLSConfig, nil
return config, nil
}
+13 -1
View File
@@ -28,7 +28,7 @@ const (
// TeamMembership errors.
const (
ErrTeamMembershipNotFound = Error("Team membership not found")
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.")
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team")
)
// ResourceControl errors.
@@ -44,6 +44,12 @@ const (
ErrEndpointAccessDenied = Error("Access denied to endpoint")
)
// Endpoint group errors.
const (
ErrEndpointGroupNotFound = Error("Endpoint group not found")
ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group")
)
// Registry errors.
const (
ErrRegistryNotFound = Error("Registry not found")
@@ -57,6 +63,12 @@ const (
ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository")
)
// Endpoint extensions error
const (
ErrEndpointExtensionNotSupported = Error("This extension is not supported")
ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint")
)
// Version errors.
const (
ErrDBVersionNotFound = Error("DB version not found")
+85 -26
View File
@@ -2,6 +2,7 @@ package exec
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path"
@@ -12,72 +13,85 @@ import (
// StackManager represents a service for managing stacks.
type StackManager struct {
binaryPath string
binaryPath string
dataPath string
signatureService portainer.DigitalSignatureService
fileService portainer.FileService
}
// NewStackManager initializes a new StackManager service.
func NewStackManager(binaryPath string) *StackManager {
return &StackManager{
binaryPath: binaryPath,
// It also updates the configuration of the Docker CLI binary.
func NewStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) {
manager := &StackManager{
binaryPath: binaryPath,
dataPath: dataPath,
signatureService: signatureService,
fileService: fileService,
}
err := manager.updateDockerCLIConfiguration(dataPath)
if err != nil {
return nil, err
}
return manager, nil
}
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
for _, registry := range registries {
if registry.Authentication {
registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL)
err := runCommandAndCaptureStdErr(command, registryArgs, nil)
if err != nil {
return err
}
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
}
}
if dockerhub.Authentication {
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
err := runCommandAndCaptureStdErr(command, dockerhubArgs, nil)
if err != nil {
return err
}
runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "")
}
return nil
}
// Logout executes the docker logout command.
func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
// Deploy executes the docker stack deploy command.
func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error {
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
if prune {
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
} else {
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
}
env := make([]string, 0)
for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env)
stackFolder := path.Dir(stackFilePath)
return runCommandAndCaptureStdErr(command, args, env, stackFolder)
}
// Remove executes the docker stack rm command.
func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
args = append(args, "stack", "rm", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
func runCommandAndCaptureStdErr(command string, args []string, env []string) error {
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
cmd.Dir = workingDir
if env != nil {
cmd.Env = os.Environ()
@@ -92,7 +106,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string) err
return nil
}
func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint) (string, []string) {
func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portainer.Endpoint) (string, []string) {
// Assume Linux as a default
command := path.Join(binaryPath, "docker")
@@ -101,6 +115,7 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
}
args := make([]string, 0)
args = append(args, "--config", dataPath)
args = append(args, "-H", endpoint.URL)
if endpoint.TLSConfig.TLS {
@@ -117,3 +132,47 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint
return command, args
}
func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error {
configFilePath := path.Join(dataPath, "config.json")
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
return err
}
signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
if config["HttpHeaders"] == nil {
config["HttpHeaders"] = make(map[string]interface{})
}
headersObject := config["HttpHeaders"].(map[string]interface{})
headersObject["X-PortainerAgent-ManagerOperation"] = "1"
headersObject["X-PortainerAgent-Signature"] = signature
headersObject["X-PortainerAgent-PublicKey"] = manager.signatureService.EncodedPublicKey()
err = manager.fileService.WriteJSONToFile(configFilePath, config)
if err != nil {
return err
}
return nil
}
func (manager *StackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) {
var config map[string]interface{}
raw, err := manager.fileService.GetFileContent(path)
if err != nil {
return make(map[string]interface{}), nil
}
err = json.Unmarshal([]byte(raw), &config)
if err != nil {
return nil, err
}
return config, nil
}
+125 -33
View File
@@ -1,7 +1,9 @@
package file
package filesystem
import (
"bytes"
"encoding/json"
"encoding/pem"
"io/ioutil"
"github.com/portainer/portainer"
@@ -26,6 +28,10 @@ const (
ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml"
// PrivateKeyFile represents the name on disk of the file containing the private key.
PrivateKeyFile = "portainer.key"
// PublicKeyFile represents the name on disk of the file containing the public key.
PublicKeyFile = "portainer.pub"
)
// Service represents a service for managing files and directories.
@@ -42,20 +48,17 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
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)
err := os.MkdirAll(dataStorePath, 0755)
if err != nil {
return nil, err
}
err = service.createDirectoryInStoreIfNotExist(ComposeStorePath)
err = service.createDirectoryInStore(TLSStorePath)
if err != nil {
return nil, err
}
err = service.createDirectoryInStore(ComposeStorePath)
if err != nil {
return nil, err
}
@@ -76,14 +79,14 @@ func (service *Service) GetStackProjectPath(stackIdentifier string) string {
// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileContent string) (string, error) {
func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) {
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
err := service.createDirectoryInStore(stackStorePath)
if err != nil {
return "", err
}
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
composeFilePath := path.Join(stackStorePath, fileName)
data := []byte(stackFileContent)
r := bytes.NewReader(data)
@@ -97,14 +100,14 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileConte
// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error) {
func (service *Service) StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) {
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
err := service.createDirectoryInStore(stackStorePath)
if err != nil {
return "", err
}
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
composeFilePath := path.Join(stackStorePath, fileName)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
@@ -117,7 +120,7 @@ func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Re
// 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)
err := service.createDirectoryInStore(storePath)
if err != nil {
return err
}
@@ -201,26 +204,75 @@ func (service *Service) GetFileContent(filePath string) (string, error) {
return string(content), 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 {
// WriteJSONToFile writes JSON to the specified file.
func (service *Service) WriteJSONToFile(path string, content interface{}) error {
jsonContent, err := json.Marshal(content)
if err != nil {
return err
}
return ioutil.WriteFile(path, jsonContent, 0644)
}
// KeyPairFilesExist checks for the existence of the key files.
func (service *Service) KeyPairFilesExist() (bool, error) {
privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile)
exists, err := fileExists(privateKeyPath)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile)
exists, err = fileExists(publicKeyPath)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
return true, nil
}
// StoreKeyPair store the specified keys content as PEM files on disk.
func (service *Service) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error {
err := service.createPEMFileInStore(private, privatePEMHeader, PrivateKeyFile)
if err != nil {
return err
}
err = service.createPEMFileInStore(public, publicPEMHeader, PublicKeyFile)
if err != nil {
return err
}
return nil
}
// LoadKeyPair retrieve the content of both key files on disk.
func (service *Service) LoadKeyPair() ([]byte, []byte, error) {
privateKey, err := service.getContentFromPEMFile(PrivateKeyFile)
if err != nil {
return nil, nil, err
}
publicKey, err := service.getContentFromPEMFile(PublicKeyFile)
if err != nil {
return nil, nil, err
}
return privateKey, publicKey, nil
}
// createDirectoryInStore creates a new directory in the file store
func (service *Service) createDirectoryInStore(name string) error {
path := path.Join(service.fileStorePath, name)
return os.MkdirAll(path, 0700)
}
// 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)
@@ -238,3 +290,43 @@ func (service *Service) createFileInStore(filePath string, r io.Reader) error {
return nil
}
func (service *Service) createPEMFileInStore(content []byte, fileType, filePath string) error {
path := path.Join(service.fileStorePath, filePath)
block := &pem.Block{Type: fileType, Bytes: content}
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer out.Close()
err = pem.Encode(out, block)
if err != nil {
return err
}
return nil
}
func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
path := path.Join(service.fileStorePath, filePath)
fileContent, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(fileContent)
return block.Bytes, nil
}
func fileExists(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
+19 -5
View File
@@ -1,6 +1,9 @@
package git
import (
"net/url"
"strings"
"gopkg.in/src-d/go-git.v4"
)
@@ -14,12 +17,23 @@ func NewService(dataStorePath string) (*Service, error) {
return service, nil
}
// CloneRepository clones a git repository using the specified URL in the specified
// ClonePublicRepository clones a public git repository using the specified URL in the specified
// destination folder.
func (service *Service) CloneRepository(url, destination string) error {
_, err := git.PlainClone(destination, false, &git.CloneOptions{
URL: url,
})
func (service *Service) ClonePublicRepository(repositoryURL, destination string) error {
return cloneRepository(repositoryURL, destination)
}
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
// destination folder. It will use the specified username and password for basic HTTP authentication.
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error {
credentials := username + ":" + url.PathEscape(password)
repositoryURL = strings.Replace(repositoryURL, "://", "://"+credentials+"@", 1)
return cloneRepository(repositoryURL, destination)
}
func cloneRepository(repositoryURL, destination string) error {
_, err := git.PlainClone(destination, false, &git.CloneOptions{
URL: repositoryURL,
})
return err
}
+46
View File
@@ -0,0 +1,46 @@
package client
import (
"crypto/tls"
"net/http"
"strings"
"time"
"github.com/portainer/portainer"
)
// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment
// using the specified host and optional TLS configuration.
func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) {
transport := &http.Transport{}
scheme := "http"
if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
scheme = "https"
}
client := &http.Client{
Timeout: time.Second * 3,
Transport: transport,
}
target := strings.Replace(host, "tcp://", scheme+"://", 1)
return pingOperation(client, target)
}
func pingOperation(client *http.Client, target string) (bool, error) {
pingOperationURL := target + "/_ping"
response, err := client.Get(pingOperationURL)
if err != nil {
return false, err
}
agentOnDockerEnvironment := false
if response.Header.Get(portainer.PortainerAgentHeader) != "" {
agentOnDockerEnvironment = true
}
return agentOnDockerEnvironment, nil
}
+1
View File
@@ -17,6 +17,7 @@ func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.
logger.Printf("http error: %s (code=%d)", err, code)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
}
+2 -2
View File
@@ -37,14 +37,14 @@ const (
)
// NewAuthHandler returns a new instance of AuthHandler.
func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler {
func NewAuthHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, 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)
rateLimiter.LimitAccess(bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))).Methods(http.MethodPost)
return h
}
+19 -21
View File
@@ -20,6 +20,7 @@ type DockerHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
@@ -35,24 +36,6 @@ func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler {
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"]
@@ -75,17 +58,32 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
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)
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole {
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
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)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
+2
View File
@@ -52,6 +52,8 @@ func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *ht
return
}
dockerhub.Password = ""
encodeJSON(w, dockerhub, handler.Logger)
return
}
+211 -59
View File
@@ -1,7 +1,12 @@
package handler
import (
"bytes"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/http/client"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
@@ -22,6 +27,7 @@ type EndpointHandler struct {
Logger *log.Logger
authorizeEndpointManagement bool
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
FileService portainer.FileService
ProxyManager *proxy.Manager
}
@@ -56,19 +62,6 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
}
type (
postEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
PublicURL string `valid:"-"`
TLS bool
TLSSkipVerify bool
TLSSkipClientVerify bool
}
postEndpointsResponse struct {
ID int `json:"Id"`
}
putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
@@ -78,10 +71,24 @@ type (
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
GroupID int `valid:"-"`
TLS bool `valid:"-"`
TLSSkipVerify bool `valid:"-"`
TLSSkipClientVerify bool `valid:"-"`
}
postEndpointPayload struct {
name string
url string
publicURL string
groupID int
useTLS bool
skipTLSServerVerification bool
skipTLSClientVerification bool
caCert []byte
cert []byte
key []byte
}
)
// handleGetEndpoints handles GET requests on /endpoints
@@ -98,7 +105,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
return
}
filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext)
groups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -107,6 +120,180 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
encodeJSON(w, filteredEndpoints, handler.Logger)
}
func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification)
if err != nil {
return nil, err
}
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig)
if err != nil {
return nil, err
}
endpointType := portainer.DockerEnvironment
if agentOnDockerEnvironment {
endpointType = portainer.AgentOnDockerEnvironment
}
endpoint := &portainer.Endpoint{
Name: payload.name,
URL: payload.url,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: payload.useTLS,
TLSSkipVerify: payload.skipTLSServerVerification,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
if err != nil {
return nil, err
}
folder := strconv.Itoa(int(endpoint.ID))
if !payload.skipTLSServerVerification {
r := bytes.NewReader(payload.caCert)
// TODO: review the API exposed by the FileService to store
// a file from a byte slice and return the path to the stored file instead
// of using multiple legacy calls (StoreTLSFile, GetPathForTLSFile) here.
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCA, r)
if err != nil {
handler.EndpointService.DeleteEndpoint(endpoint.ID)
return nil, err
}
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
}
if !payload.skipTLSClientVerification {
r := bytes.NewReader(payload.cert)
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCert, r)
if err != nil {
handler.EndpointService.DeleteEndpoint(endpoint.ID)
return nil, err
}
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
r = bytes.NewReader(payload.key)
err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileKey, r)
if err != nil {
handler.EndpointService.DeleteEndpoint(endpoint.ID)
return nil, err
}
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return nil, err
}
return endpoint, nil
}
func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
endpointType := portainer.DockerEnvironment
if !strings.HasPrefix(payload.url, "unix://") {
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil)
if err != nil {
return nil, err
}
if agentOnDockerEnvironment {
endpointType = portainer.AgentOnDockerEnvironment
}
}
endpoint := &portainer.Endpoint{
Name: payload.name,
URL: payload.url,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
err := handler.EndpointService.CreateEndpoint(endpoint)
if err != nil {
return nil, err
}
return endpoint, nil
}
func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) {
if payload.useTLS {
return handler.createTLSSecuredEndpoint(payload)
}
return handler.createUnsecuredEndpoint(payload)
}
func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) {
payload := &postEndpointPayload{}
payload.name = r.FormValue("Name")
payload.url = r.FormValue("URL")
payload.publicURL = r.FormValue("PublicURL")
if payload.name == "" || payload.url == "" {
return nil, ErrInvalidRequestFormat
}
rawGroupID := r.FormValue("GroupID")
if rawGroupID == "" {
payload.groupID = 1
} else {
groupID, err := strconv.Atoi(rawGroupID)
if err != nil {
return nil, err
}
payload.groupID = groupID
}
payload.useTLS = r.FormValue("TLS") == "true"
if payload.useTLS {
payload.skipTLSServerVerification = r.FormValue("TLSSkipVerify") == "true"
payload.skipTLSClientVerification = r.FormValue("TLSSkipClientVerify") == "true"
if !payload.skipTLSServerVerification {
caCert, err := getUploadedFileContent(r, "TLSCACertFile")
if err != nil {
return nil, err
}
payload.caCert = caCert
}
if !payload.skipTLSClientVerification {
cert, err := getUploadedFileContent(r, "TLSCertFile")
if err != nil {
return nil, err
}
payload.cert = cert
key, err := getUploadedFileContent(r, "TLSKeyFile")
if err != nil {
return nil, err
}
payload.key = key
}
}
return payload, nil
}
// handlePostEndpoints handles POST requests on /endpoints
func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
@@ -114,59 +301,19 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
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)
payload, err := convertPostEndpointRequestToPayload(r)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint := &portainer.Endpoint{
Name: req.Name,
URL: req.URL,
PublicURL: req.PublicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: req.TLS,
TLSSkipVerify: req.TLSSkipVerify,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
endpoint, err := handler.createEndpoint(payload)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.TLS {
folder := strconv.Itoa(int(endpoint.ID))
if !req.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSConfig.TLSCACertPath = caCertPath
}
if !req.TLSSkipClientVerify {
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSConfig.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSConfig.TLSKeyPath = keyPath
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
encodeJSON(w, &endpoint, handler.Logger)
}
// handleGetEndpoint handles GET requests on /endpoints/:id
@@ -296,6 +443,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
endpoint.PublicURL = req.PublicURL
}
if req.GroupID != 0 {
endpoint.GroupID = portainer.EndpointGroupID(req.GroupID)
}
folder := strconv.Itoa(int(endpoint.ID))
if req.TLS {
endpoint.TLSConfig.TLS = true
@@ -321,7 +472,7 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
}
} else {
endpoint.TLSConfig.TLS = false
endpoint.TLSConfig.TLSSkipVerify = true
endpoint.TLSConfig.TLSSkipVerify = false
endpoint.TLSConfig.TLSCACertPath = ""
endpoint.TLSConfig.TLSCertPath = ""
endpoint.TLSConfig.TLSKeyPath = ""
@@ -372,6 +523,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
}
handler.ProxyManager.DeleteProxy(string(endpointID))
handler.ProxyManager.DeleteExtensionProxies(string(endpointID))
err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID))
if err != nil {
+364
View File
@@ -0,0 +1,364 @@
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"
)
// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups.
type EndpointGroupHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
}
// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler.
func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler {
h := &EndpointGroupHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/endpoint_groups",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost)
h.Handle("/endpoint_groups",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}/access",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete)
return h
}
type (
postEndpointGroupsResponse struct {
ID int `json:"Id"`
}
postEndpointGroupsRequest struct {
Name string `valid:"required"`
Description string `valid:"-"`
Labels []portainer.Pair `valid:""`
AssociatedEndpoints []portainer.EndpointID `valid:""`
}
putEndpointGroupAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putEndpointGroupsRequest struct {
Name string `valid:"-"`
Description string `valid:"-"`
Labels []portainer.Pair `valid:""`
AssociatedEndpoints []portainer.EndpointID `valid:""`
}
)
// handleGetEndpointGroups handles GET requests on /endpoint_groups
func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpointGroups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, filteredEndpointGroups, handler.Logger)
}
// handlePostEndpointGroups handles POST requests on /endpoint_groups
func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) {
var req postEndpointGroupsRequest
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
}
endpointGroup := &portainer.EndpointGroup{
Name: req.Name,
Description: req.Description,
Labels: req.Labels,
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup)
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
}
for _, endpoint := range endpoints {
if endpoint.GroupID == portainer.EndpointGroupID(1) {
err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger)
}
// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, endpointGroup, handler.Logger)
}
// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access
func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointGroupAccessRequest
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
}
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrEndpointGroupNotFound {
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))
}
endpointGroup.AuthorizedUsers = authorizedUserIDs
}
if req.AuthorizedTeams != nil {
authorizedTeamIDs := []portainer.TeamID{}
for _, value := range req.AuthorizedTeams {
authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value))
}
endpointGroup.AuthorizedTeams = authorizedTeamIDs
}
err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointGroupsRequest
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
}
groupID := portainer.EndpointGroupID(endpointGroupID)
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID)
if err == portainer.ErrEndpointGroupNotFound {
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 != "" {
endpointGroup.Name = req.Name
}
if req.Description != "" {
endpointGroup.Description = req.Description
}
endpointGroup.Labels = req.Labels
err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
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
}
for _, endpoint := range endpoints {
err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
if endpoint.GroupID == groupID {
return handler.checkForGroupUnassignment(endpoint, associatedEndpoints)
} else if endpoint.GroupID == portainer.EndpointGroupID(1) {
return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints)
}
return nil
}
func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
return nil
}
}
endpoint.GroupID = portainer.EndpointGroupID(1)
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
endpoint.GroupID = groupID
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
}
return nil
}
// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
if endpointGroupID == 1 {
httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger)
return
}
groupID := portainer.EndpointGroupID(endpointGroupID)
_, err = handler.EndpointGroupService.EndpointGroup(groupID)
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID))
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
}
for _, endpoint := range endpoints {
if endpoint.GroupID == groupID {
endpoint.GroupID = portainer.EndpointGroupID(1)
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
}
+143
View File
@@ -0,0 +1,143 @@
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/proxy"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// ExtensionHandler represents an HTTP API handler for managing Settings.
type ExtensionHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
ProxyManager *proxy.Manager
}
// NewExtensionHandler returns a new instance of ExtensionHandler.
func NewExtensionHandler(bouncer *security.RequestBouncer) *ExtensionHandler {
h := &ExtensionHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/{endpointId}/extensions",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostExtensions))).Methods(http.MethodPost)
h.Handle("/{endpointId}/extensions/{extensionType}",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleDeleteExtensions))).Methods(http.MethodDelete)
return h
}
type (
postExtensionRequest struct {
Type int `valid:"required"`
URL string `valid:"required"`
}
)
func (handler *ExtensionHandler) handlePostExtensions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(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
}
var req postExtensionRequest
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
}
extensionType := portainer.EndpointExtensionType(req.Type)
var extension *portainer.EndpointExtension
for _, ext := range endpoint.Extensions {
if ext.Type == extensionType {
extension = &ext
}
}
if extension != nil {
extension.URL = req.URL
} else {
extension = &portainer.EndpointExtension{
Type: extensionType,
URL: req.URL,
}
endpoint.Extensions = append(endpoint.Extensions, *extension)
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, extension, handler.Logger)
}
func (handler *ExtensionHandler) handleDeleteExtensions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(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
}
extType, err := strconv.Atoi(vars["extensionType"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
extensionType := portainer.EndpointExtensionType(extType)
for idx, ext := range endpoint.Extensions {
if ext.Type == extensionType {
endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...)
}
}
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
+106
View File
@@ -0,0 +1,106 @@
package extensions
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"
)
// StoridgeHandler represents an HTTP API handler for proxying requests to the Docker API.
type StoridgeHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
// NewStoridgeHandler returns a new instance of StoridgeHandler.
func NewStoridgeHandler(bouncer *security.RequestBouncer) *StoridgeHandler {
h := &StoridgeHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.PathPrefix("/{id}/extensions/storidge").Handler(
bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToStoridgeAPI)))
return h
}
func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(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
}
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole {
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
}
var storidgeExtension *portainer.EndpointExtension
for _, extension := range endpoint.Extensions {
if extension.Type == portainer.StoridgeEndpointExtension {
storidgeExtension = &extension
}
}
if storidgeExtension == nil {
httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionNotSupported, http.StatusInternalServerError, handler.Logger)
return
}
proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension)
var proxy http.Handler
proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey)
if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r)
}
+32 -3
View File
@@ -2,12 +2,14 @@ package handler
import (
"encoding/json"
"io/ioutil"
"log"
"net/http"
"strings"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/handler/extensions"
)
// Handler is a collection of all the service handlers.
@@ -17,8 +19,11 @@ type Handler struct {
TeamHandler *TeamHandler
TeamMembershipHandler *TeamMembershipHandler
EndpointHandler *EndpointHandler
EndpointGroupHandler *EndpointGroupHandler
RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler
ExtensionHandler *ExtensionHandler
StoridgeHandler *extensions.StoridgeHandler
ResourceHandler *ResourceHandler
StackHandler *StackHandler
StatusHandler *StatusHandler
@@ -47,12 +52,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
if strings.Contains(r.URL.Path, "/docker") {
switch {
case strings.Contains(r.URL.Path, "/docker/"):
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
} else if strings.Contains(r.URL.Path, "/stacks") {
case strings.Contains(r.URL.Path, "/stacks"):
http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r)
} else {
case strings.Contains(r.URL.Path, "/extensions/storidge"):
http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r)
case strings.Contains(r.URL.Path, "/extensions"):
http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r)
default:
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/registries"):
@@ -82,7 +94,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)
}
}
// getUploadedFileContent retrieve the content of a file uploaded in the request.
// Uses requestParameter as the key to retrieve the file in the request payload.
func getUploadedFileContent(request *http.Request, requestParameter string) ([]byte, error) {
file, _, err := request.FormFile(requestParameter)
if err != nil {
return nil, err
}
defer file.Close()
fileContent, err := ioutil.ReadAll(file)
if err != nil {
return nil, err
}
return fileContent, nil
}
+6
View File
@@ -91,6 +91,10 @@ func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *ht
return
}
for i := range filteredRegistries {
filteredRegistries[i].Password = ""
}
encodeJSON(w, filteredRegistries, handler.Logger)
}
@@ -159,6 +163,8 @@ func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http
return
}
registry.Password = ""
encodeJSON(w, registry, handler.Logger)
}
+8 -4
View File
@@ -5,7 +5,7 @@ import (
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
"github.com/portainer/portainer/file"
"github.com/portainer/portainer/filesystem"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
@@ -46,6 +46,7 @@ func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler {
type (
publicSettingsResponse struct {
LogoURL string `json:"LogoURL"`
DisplayDonationHeader bool `json:"DisplayDonationHeader"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
@@ -56,6 +57,7 @@ type (
TemplatesURL string `valid:"required"`
LogoURL string `valid:""`
BlackListedLabels []portainer.Pair `valid:""`
DisplayDonationHeader bool `valid:""`
DisplayExternalContributors bool `valid:""`
AuthenticationMethod int `valid:"required"`
LDAPSettings portainer.LDAPSettings `valid:""`
@@ -90,6 +92,7 @@ func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r
publicSettings := &publicSettingsResponse{
LogoURL: settings.LogoURL,
DisplayDonationHeader: settings.DisplayDonationHeader,
DisplayExternalContributors: settings.DisplayExternalContributors,
AuthenticationMethod: settings.AuthenticationMethod,
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
@@ -118,6 +121,7 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
TemplatesURL: req.TemplatesURL,
LogoURL: req.LogoURL,
BlackListedLabels: req.BlackListedLabels,
DisplayDonationHeader: req.DisplayDonationHeader,
DisplayExternalContributors: req.DisplayExternalContributors,
LDAPSettings: req.LDAPSettings,
AllowBindMountsForRegularUsers: req.AllowBindMountsForRegularUsers,
@@ -134,11 +138,11 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
}
if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
} else {
settings.LDAPSettings.TLSConfig.TLSCACertPath = ""
err := handler.FileService.DeleteTLSFiles(file.LDAPStorePath)
err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
@@ -165,7 +169,7 @@ func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter
}
if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
}
+72 -36
View File
@@ -9,7 +9,7 @@ import (
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
"github.com/portainer/portainer/file"
"github.com/portainer/portainer/filesystem"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
@@ -37,6 +37,14 @@ type StackHandler struct {
StackManager portainer.StackManager
}
type stackDeploymentConfig struct {
endpoint *portainer.Endpoint
stack *portainer.Stack
prune bool
dockerhub *portainer.DockerHub
registries []portainer.Registry
}
// NewStackHandler returns a new instance of StackHandler.
func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler {
h := &StackHandler{
@@ -62,12 +70,15 @@ func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler {
type (
postStacksRequest struct {
Name string `valid:"required"`
SwarmID string `valid:"required"`
StackFileContent string `valid:""`
GitRepository string `valid:""`
PathInRepository string `valid:""`
Env []portainer.Pair `valid:""`
Name string `valid:"required"`
SwarmID string `valid:"required"`
StackFileContent string `valid:""`
RepositoryURL string `valid:""`
RepositoryAuthentication bool `valid:""`
RepositoryUsername string `valid:""`
RepositoryPassword string `valid:""`
ComposeFilePathInRepository string `valid:""`
Env []portainer.Pair `valid:""`
}
postStacksResponse struct {
ID string `json:"Id"`
@@ -78,6 +89,7 @@ type (
putStackRequest struct {
StackFileContent string `valid:"required"`
Env []portainer.Pair `valid:""`
Prune bool `valid:"-"`
}
)
@@ -166,11 +178,11 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter,
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: req.Env,
}
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent)
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, stackFileContent)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -207,7 +219,14 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter,
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
config := stackDeploymentConfig{
stack: stack,
endpoint: endpoint,
dockerhub: dockerhub,
registries: filteredRegistries,
prune: false,
}
err = handler.deployStack(&config)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -247,24 +266,20 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
}
stackName := req.Name
if stackName == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
swarmID := req.SwarmID
if swarmID == "" {
if stackName == "" || swarmID == "" || req.RepositoryURL == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if req.GitRepository == "" {
if req.RepositoryAuthentication && (req.RepositoryUsername == "" || req.RepositoryPassword == "") {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if req.PathInRepository == "" {
req.PathInRepository = file.ComposeFileDefaultName
if req.ComposeFilePathInRepository == "" {
req.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
}
stacks, err := handler.StackService.Stacks()
@@ -284,7 +299,7 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: req.PathInRepository,
EntryPoint: req.ComposeFilePathInRepository,
Env: req.Env,
}
@@ -298,7 +313,11 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
return
}
err = handler.GitService.CloneRepository(req.GitRepository, projectPath)
if req.RepositoryAuthentication {
err = handler.GitService.ClonePrivateRepositoryWithBasicAuth(req.RepositoryURL, projectPath, req.RepositoryUsername, req.RepositoryPassword)
} else {
err = handler.GitService.ClonePublicRepository(req.RepositoryURL, projectPath)
}
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -334,7 +353,14 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
config := stackDeploymentConfig{
stack: stack,
endpoint: endpoint,
dockerhub: dockerhub,
registries: filteredRegistries,
prune: false,
}
err = handler.deployStack(&config)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -404,11 +430,11 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: env,
}
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile)
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stack.EntryPoint, stackFile)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -445,7 +471,14 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
config := stackDeploymentConfig{
stack: stack,
endpoint: endpoint,
dockerhub: dockerhub,
registries: filteredRegistries,
prune: false,
}
err = handler.deployStack(&config)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -601,7 +634,7 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque
}
stack.Env = req.Env
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent)
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, req.StackFileContent)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -637,7 +670,14 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
config := stackDeploymentConfig{
stack: stack,
endpoint: endpoint,
dockerhub: dockerhub,
registries: filteredRegistries,
prune: req.Prune,
}
err = handler.deployStack(&config)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -732,22 +772,18 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re
}
}
func (handler *StackHandler) deployStack(endpoint *portainer.Endpoint, stack *portainer.Stack, dockerhub *portainer.DockerHub, registries []portainer.Registry) error {
func (handler *StackHandler) deployStack(config *stackDeploymentConfig) error {
handler.stackCreationMutex.Lock()
err := handler.StackManager.Login(dockerhub, registries, endpoint)
handler.StackManager.Login(config.dockerhub, config.registries, config.endpoint)
err := handler.StackManager.Deploy(config.stack, config.prune, config.endpoint)
if err != nil {
handler.stackCreationMutex.Unlock()
return err
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
handler.stackCreationMutex.Unlock()
return err
}
err = handler.StackManager.Logout(endpoint)
err = handler.StackManager.Logout(config.endpoint)
if err != nil {
handler.stackCreationMutex.Unlock()
return err
+216 -128
View File
@@ -1,11 +1,11 @@
package handler
import (
"bufio"
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -16,119 +16,136 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/koding/websocketproxy"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"golang.org/x/net/websocket"
httperror "github.com/portainer/portainer/http/error"
)
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
type WebSocketHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
}
type (
// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket.
WebSocketHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
SignatureService portainer.DigitalSignatureService
connectionUpgrader websocket.Upgrader
}
webSocketExecRequestParams struct {
execID string
nodeName string
endpoint *portainer.Endpoint
}
execStartOperationPayload struct {
Tty bool
Detach bool
}
)
// NewWebSocketHandler returns a new instance of WebSocketHandler.
func NewWebSocketHandler() *WebSocketHandler {
h := &WebSocketHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
connectionUpgrader: websocket.Upgrader{},
}
h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec))
h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet)
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)
// handleWebsocketExec handles GET requests on /websocket/exec?id=<execID>&endpointId=<endpointID>&nodeName=<nodeName>
// If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint.
// If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
// an ExecStart operation HTTP request will be created and hijacked.
func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) {
paramExecID := r.FormValue("id")
paramEndpointID := r.FormValue("endpointId")
if paramExecID == "" || paramEndpointID == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(parsedID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
endpointID, err := strconv.Atoi(paramEndpointID)
if err != nil {
log.Printf("Unable to retrieve endpoint: %s", err)
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointURL, err := url.Parse(endpoint.URL)
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err != nil {
log.Printf("Unable to parse endpoint URL: %s", err)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var host string
if endpointURL.Scheme == "tcp" {
host = endpointURL.Host
} else if endpointURL.Scheme == "unix" {
host = endpointURL.Path
params := &webSocketExecRequestParams{
endpoint: endpoint,
execID: paramExecID,
nodeName: r.FormValue("nodeName"),
}
// TODO: Should not be managed here
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
log.Fatalf("Unable to create TLS configuration: %s", err)
return
err = handler.handleRequest(w, r, params)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
r.Header.Del("Origin")
if params.nodeName != "" {
return handler.proxyWebsocketRequest(w, r, params)
}
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.execID)
}
func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
agentURL, err := url.Parse(params.endpoint.URL)
if err != nil {
return err
}
agentURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(agentURL)
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify {
agentURL.Scheme = "wss"
proxy.Dialer = &websocket.Dialer{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify,
},
}
}
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
signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
}
proxy.ServeHTTP(w, r)
return nil
}
type execConfig struct {
Tty bool
Detach bool
}
// hijack allows to upgrade an HTTP connection to a TCP connection
// It redirects IO streams for stdin, stdout and stderr to a websocket
func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error {
execConfig := &execConfig{
Tty: true,
Detach: false,
}
buf, err := json.Marshal(execConfig)
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
dial, err := createDial(endpoint)
if err != nil {
return fmt.Errorf("error marshaling exec config: %s", err)
}
rdr := bytes.NewReader(buf)
req, err := http.NewRequest(method, path, rdr)
if err != nil {
return fmt.Errorf("error during hijack request: %s", err)
}
req.Header.Set("User-Agent", "Docker-Client")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "tcp")
req.Host = addr
var (
dial net.Conn
dialErr error
)
if tlsConfig == nil {
dial, dialErr = net.Dial(scheme, addr)
} else {
dial, dialErr = tls.Dial(scheme, addr, tlsConfig)
}
if dialErr != nil {
return dialErr
return err
}
// When we set up a TCP connection for hijack, there could be long periods
@@ -140,57 +157,128 @@ func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerm
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
}
httpConn := httputil.NewClientConn(dial, nil)
defer httpConn.Close()
execStartRequest, err := createExecStartRequest(execID)
if err != nil {
return err
}
clientconn := httputil.NewClientConn(dial, nil)
defer clientconn.Close()
// Server hijacks the connection, error 'connection closed' expected
clientconn.Do(req)
rwc, br := clientconn.Hijack()
defer rwc.Close()
if started != nil {
started <- rwc
err = hijackRequest(websocketConn, httpConn, execStartRequest)
if err != nil {
return err
}
var receiveStdout chan error
if stdout != nil || stderr != nil {
go func() (err error) {
if setRawTerminal && stdout != nil {
_, err = io.Copy(stdout, br)
}
return err
}()
}
go func() error {
if in != nil {
io.Copy(rwc, in)
}
if conn, ok := rwc.(interface {
CloseWrite() error
}); ok {
if err := conn.CloseWrite(); err != nil {
}
}
return nil
}()
if stdout != nil || stderr != nil {
if err := <-receiveStdout; err != nil {
return err
}
}
go func() {
for {
fmt.Println(br)
}
}()
return nil
}
func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
url, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
var host string
if url.Scheme == "tcp" {
host = url.Host
} else if url.Scheme == "unix" {
host = url.Path
}
if endpoint.TLSConfig.TLS {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
return tls.Dial(url.Scheme, host, tlsConfig)
}
return net.Dial(url.Scheme, host)
}
func createExecStartRequest(execID string) (*http.Request, error) {
execStartOperationPayload := &execStartOperationPayload{
Tty: true,
Detach: false,
}
encodedBody := bytes.NewBuffer(nil)
err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload)
if err != nil {
return nil, err
}
request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "tcp")
return request, nil
}
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error {
// Server hijacks the connection, error 'connection closed' expected
resp, err := httpConn.Do(request)
if err != httputil.ErrPersistEOF {
if err != nil {
return err
}
if resp.StatusCode != http.StatusSwitchingProtocols {
resp.Body.Close()
return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode)
}
}
tcpConn, brw := httpConn.Hijack()
defer tcpConn.Close()
errorChan := make(chan error, 1)
go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan)
go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan)
err = <-errorChan
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
}
return nil
}
func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) {
for {
_, in, err := websocketConn.ReadMessage()
if err != nil {
errorChan <- err
break
}
_, err = tcpConn.Write(in)
if err != nil {
errorChan <- err
break
}
}
}
func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) {
for {
out := make([]byte, 1024)
_, err := br.Read(out)
if err != nil {
errorChan <- err
break
}
err = websocketConn.WriteMessage(websocket.TextMessage, out)
if err != nil {
errorChan <- err
break
}
}
}
+6 -3
View File
@@ -98,9 +98,12 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
}
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControl
object["Portainer"] = metadata
if object["Portainer"] == nil {
object["Portainer"] = make(map[string]interface{})
}
portainerMetadata := object["Portainer"].(map[string]interface{})
portainerMetadata["ResourceControl"] = resourceControl
return object
}
+56
View File
@@ -0,0 +1,56 @@
package proxy
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"github.com/portainer/portainer/archive"
)
type postDockerfileRequest struct {
Content string
}
// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request.
// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function
// will extract the file content from the request body, tar it, and rewrite the body.
// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted
// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and
// rewrite the body of the request.
// In any other case, it will leave the request unaltered.
func buildOperation(request *http.Request) error {
contentTypeHeader := request.Header.Get("Content-Type")
if contentTypeHeader != "" && !strings.Contains(contentTypeHeader, "application/json") {
return nil
}
var dockerfileContent []byte
if contentTypeHeader == "" {
body, err := ioutil.ReadAll(request.Body)
if err != nil {
return err
}
dockerfileContent = body
} else {
var req postDockerfileRequest
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
return err
}
dockerfileContent = []byte(req.Content)
}
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile")
if err != nil {
return err
}
request.Body = ioutil.NopCloser(bytes.NewReader(buffer))
request.ContentLength = int64(len(buffer))
request.Header.Set("Content-Type", "application/x-tar")
return nil
}
+2 -2
View File
@@ -14,7 +14,7 @@ const (
// configListOperation extracts the response as a JSON object, loop through the configs array
// decorate and/or filter the configs based on resource controls before rewriting the response
func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func configListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// ConfigList response is a JSON array
@@ -39,7 +39,7 @@ func configListOperation(request *http.Request, response *http.Response, executo
// configInspectOperation extracts the response as a JSON object, verify that the user
// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated config.
func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func configInspectOperation(response *http.Response, executor *operationExecutor) error {
// ConfigInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
responseObject, err := getResponseAsJSONOBject(response)
+2 -2
View File
@@ -16,7 +16,7 @@ const (
// 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 {
func containerListOperation(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
@@ -47,7 +47,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec
// 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 {
func containerInspectOperation(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)
+28 -12
View File
@@ -15,17 +15,21 @@ type proxyFactory struct {
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService
}
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
u.Scheme = "http"
return factory.createReverseProxy(u)
return newSingleHostReverseProxyWithHostHeader(u)
}
func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) {
u.Scheme = "https"
proxy := factory.createReverseProxy(u)
config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
proxy := factory.createDockerReverseProxy(u, enableSignature)
config, err := crypto.CreateTLSConfigurationFromDisk(tlsConfig.TLSCACertPath, tlsConfig.TLSCertPath, tlsConfig.TLSKeyPath, tlsConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
@@ -34,26 +38,42 @@ func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpo
return proxy, nil
}
func (factory *proxyFactory) newSocketProxy(path string) http.Handler {
func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool) http.Handler {
u.Scheme = "http"
return factory.createDockerReverseProxy(u, enableSignature)
}
func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
proxy := &socketProxy{}
transport := &proxyTransport{
enableSignature: false,
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
RegistryService: factory.RegistryService,
DockerHubService: factory.DockerHubService,
dockerTransport: newSocketTransport(path),
}
proxy.Transport = transport
return proxy
}
func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy {
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy {
proxy := newSingleHostReverseProxyWithHostHeader(u)
transport := &proxyTransport{
enableSignature: enableSignature,
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
dockerTransport: newHTTPTransport(),
RegistryService: factory.RegistryService,
DockerHubService: factory.DockerHubService,
dockerTransport: &http.Transport{},
}
if enableSignature {
transport.SignatureService = factory.SignatureService
}
proxy.Transport = transport
return proxy
}
@@ -65,7 +85,3 @@ func newSocketTransport(socketPath string) *http.Transport {
},
}
}
func newHTTPTransport() *http.Transport {
return &http.Transport{}
}
+67 -13
View File
@@ -3,25 +3,43 @@ package proxy
import (
"net/http"
"net/url"
"strings"
"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
}
type (
// Manager represents a service used to manage Docker proxies.
Manager struct {
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
extensionProxies cmap.ConcurrentMap
}
// ManagerParams represents the required parameters to create a new Manager instance.
ManagerParams struct {
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService
}
)
// NewManager initializes a new proxy Service
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager {
func NewManager(parameters *ManagerParams) *Manager {
return &Manager{
proxies: cmap.New(),
proxies: cmap.New(),
extensionProxies: cmap.New(),
proxyFactory: &proxyFactory{
ResourceControlService: resourceControlService,
TeamMembershipService: teamMembershipService,
SettingsService: settingsService,
ResourceControlService: parameters.ResourceControlService,
TeamMembershipService: parameters.TeamMembershipService,
SettingsService: parameters.SettingsService,
RegistryService: parameters.RegistryService,
DockerHubService: parameters.DockerHubService,
SignatureService: parameters.SignatureService,
},
}
}
@@ -36,18 +54,23 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
return nil, err
}
enableSignature := false
if endpoint.Type == portainer.AgentOnDockerEnvironment {
enableSignature = true
}
if endpointURL.Scheme == "tcp" {
if endpoint.TLSConfig.TLS {
proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint)
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature)
if err != nil {
return nil, err
}
} else {
proxy = manager.proxyFactory.newHTTPProxy(endpointURL)
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature)
}
} else {
// Assume unix:// scheme
proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path)
proxy = manager.proxyFactory.newDockerSocketProxy(endpointURL.Path)
}
manager.proxies.Set(string(endpoint.ID), proxy)
@@ -67,3 +90,34 @@ func (manager *Manager) GetProxy(key string) http.Handler {
func (manager *Manager) DeleteProxy(key string) {
manager.proxies.Remove(key)
}
// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies.
func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
extensionURL, err := url.Parse(extensionAPIURL)
if err != nil {
return nil, err
}
proxy := manager.proxyFactory.newExtensionHTTPPRoxy(extensionURL)
manager.extensionProxies.Set(key, proxy)
return proxy, nil
}
// GetExtensionProxy returns the extension proxy associated to a key
func (manager *Manager) GetExtensionProxy(key string) http.Handler {
proxy, ok := manager.extensionProxies.Get(key)
if !ok {
return nil
}
return proxy.(http.Handler)
}
// DeleteExtensionProxies deletes all the extension proxies associated to a key
func (manager *Manager) DeleteExtensionProxies(key string) {
for _, k := range manager.extensionProxies.Keys() {
if strings.Contains(k, key+"_") {
manager.extensionProxies.Remove(k)
}
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ const (
// networkListOperation extracts the response as a JSON object, loop through the networks array
// decorate and/or filter the networks based on resource controls before rewriting the response
func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func networkListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// NetworkList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
@@ -39,7 +39,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut
// networkInspectOperation extracts the response as a JSON object, verify that the user
// has access to the network based on resource control and either rewrite an access denied response
// or a decorated network.
func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func networkInspectOperation(response *http.Response, executor *operationExecutor) error {
// NetworkInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
responseObject, err := getResponseAsJSONOBject(response)
+37
View File
@@ -0,0 +1,37 @@
package proxy
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
)
func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader {
var authenticationHeader *registryAuthenticationHeader
if serverAddress == "" {
authenticationHeader = &registryAuthenticationHeader{
Username: accessContext.dockerHub.Username,
Password: accessContext.dockerHub.Password,
Serveraddress: "docker.io",
}
} else {
var matchingRegistry *portainer.Registry
for _, registry := range accessContext.registries {
if registry.URL == serverAddress &&
(accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(&registry, accessContext.userID, accessContext.teamMemberships))) {
matchingRegistry = &registry
break
}
}
if matchingRegistry != nil {
authenticationHeader = &registryAuthenticationHeader{
Username: matchingRegistry.Username,
Password: matchingRegistry.Password,
Serveraddress: matchingRegistry.URL,
}
}
}
return authenticationHeader
}
+16 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"io/ioutil"
"log"
"net/http"
"strconv"
@@ -13,6 +14,8 @@ import (
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")
// ErrInvalidResponseContent defines an error raised when Portainer excepts a JSON array and get something else.
ErrInvalidResponseContent = portainer.Error("Invalid Docker response")
)
func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} {
@@ -39,8 +42,19 @@ func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
return nil, err
}
responseObject := responseData.([]interface{})
return responseObject, nil
switch responseObject := responseData.(type) {
case []interface{}:
return responseObject, nil
case map[string]interface{}:
if responseObject["message"] != nil {
return nil, portainer.Error(responseObject["message"].(string))
}
log.Printf("Response: %+v\n", responseObject)
return nil, ErrInvalidResponseContent
default:
log.Printf("Response: %+v\n", responseObject)
return nil, ErrInvalidResponseContent
}
}
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
+2 -2
View File
@@ -14,7 +14,7 @@ const (
// secretListOperation extracts the response as a JSON object, loop through the secrets array
// decorate and/or filter the secrets based on resource controls before rewriting the response
func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func secretListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// SecretList response is a JSON array
@@ -39,7 +39,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo
// secretInspectOperation extracts the response as a JSON object, verify that the user
// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated secret.
func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func secretInspectOperation(response *http.Response, executor *operationExecutor) error {
// SecretInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
responseObject, err := getResponseAsJSONOBject(response)
+2 -2
View File
@@ -15,7 +15,7 @@ const (
// 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 {
func serviceListOperation(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
@@ -39,7 +39,7 @@ func serviceListOperation(request *http.Request, response *http.Response, execut
// 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 {
func serviceInspectOperation(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)
+1 -1
View File
@@ -15,7 +15,7 @@ const (
// taskListOperation extracts the response as a JSON object, loop through the tasks array
// and filter the tasks based on resource controls before rewriting the response
func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func taskListOperation(response *http.Response, executor *operationExecutor) error {
var err error
// TaskList response is a JSON array
+140 -5
View File
@@ -1,20 +1,29 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"net/http"
"path"
"regexp"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
)
var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`)
type (
proxyTransport struct {
dockerTransport *http.Transport
enableSignature bool
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SettingsService portainer.SettingsService
SignatureService portainer.DigitalSignatureService
}
restrictedOperationContext struct {
isAdmin bool
@@ -22,11 +31,24 @@ type (
userTeamIDs []portainer.TeamID
resourceControls []portainer.ResourceControl
}
registryAccessContext struct {
isAdmin bool
userID portainer.UserID
teamMemberships []portainer.TeamMembership
registries []portainer.Registry
dockerHub *portainer.DockerHub
}
registryAuthenticationHeader struct {
Username string `json:"username"`
Password string `json:"password"`
Serveraddress string `json:"serveraddress"`
}
operationExecutor struct {
operationContext *restrictedOperationContext
labelBlackList []portainer.Pair
}
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
restrictedOperationRequest func(*http.Response, *operationExecutor) error
operationRequest func(*http.Request) error
)
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
@@ -38,7 +60,18 @@ func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Resp
}
func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) {
path := request.URL.Path
path := apiVersionRe.ReplaceAllString(request.URL.Path, "")
request.URL.Path = path
if p.enableSignature {
signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, p.SignatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
}
switch {
case strings.HasPrefix(path, "/configs"):
@@ -59,6 +92,10 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
return p.proxyNodeRequest(request)
case strings.HasPrefix(path, "/tasks"):
return p.proxyTaskRequest(request)
case strings.HasPrefix(path, "/build"):
return p.proxyBuildRequest(request)
case strings.HasPrefix(path, "/images"):
return p.proxyImageRequest(request)
default:
return p.executeDockerRequest(request)
}
@@ -116,7 +153,7 @@ func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Res
func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/services/create":
return p.executeDockerRequest(request)
return p.replaceRegistryAuthenticationHeader(request)
case "/services":
return p.rewriteOperation(request, serviceListOperation)
@@ -228,6 +265,58 @@ func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response
}
}
func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
return p.interceptAndRewriteRequest(request, buildOperation)
}
func (p *proxyTransport) proxyImageRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/images/create":
return p.replaceRegistryAuthenticationHeader(request)
default:
if path.Base(requestPath) == "push" && request.Method == http.MethodPost {
return p.replaceRegistryAuthenticationHeader(request)
}
return p.executeDockerRequest(request)
}
}
func (p *proxyTransport) replaceRegistryAuthenticationHeader(request *http.Request) (*http.Response, error) {
accessContext, err := p.createRegistryAccessContext(request)
if err != nil {
return nil, err
}
originalHeader := request.Header.Get("X-Registry-Auth")
if originalHeader != "" {
decodedHeaderData, err := base64.StdEncoding.DecodeString(originalHeader)
if err != nil {
return nil, err
}
var originalHeaderData registryAuthenticationHeader
err = json.Unmarshal(decodedHeaderData, &originalHeaderData)
if err != nil {
return nil, err
}
authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext)
headerData, err := json.Marshal(authenticationHeader)
if err != nil {
return nil, err
}
header := base64.StdEncoding.EncodeToString(headerData)
request.Header.Set("X-Registry-Auth", header)
}
return p.executeDockerRequest(request)
}
// restrictedOperation ensures that the current user has the required authorizations
// before executing the original request.
func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) {
@@ -263,7 +352,7 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s
return p.executeDockerRequest(request)
}
// rewriteOperation will create a new operation context with data that will be used
// rewriteOperationWithLabelFiltering 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) {
@@ -300,13 +389,22 @@ func (p *proxyTransport) rewriteOperation(request *http.Request, operation restr
return p.executeRequestAndRewriteResponse(request, operation, executor)
}
func (p *proxyTransport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) {
err := operation(request)
if err != nil {
return nil, err
}
return p.executeDockerRequest(request)
}
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)
err = operation(response, executor)
return response, err
}
@@ -325,6 +423,43 @@ func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Re
return p.executeDockerRequest(request)
}
func (p *proxyTransport) createRegistryAccessContext(request *http.Request) (*registryAccessContext, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
accessContext := &registryAccessContext{
isAdmin: true,
userID: tokenData.ID,
}
hub, err := p.DockerHubService.DockerHub()
if err != nil {
return nil, err
}
accessContext.dockerHub = hub
registries, err := p.RegistryService.Registries()
if err != nil {
return nil, err
}
accessContext.registries = registries
if tokenData.Role != portainer.AdministratorRole {
accessContext.isAdmin = false
teamMemberships, err := p.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
accessContext.teamMemberships = teamMemberships
}
return accessContext, nil
}
func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedOperationContext, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
+2 -2
View File
@@ -15,7 +15,7 @@ const (
// 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 {
func volumeListOperation(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
@@ -48,7 +48,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
// volumeInspectOperation extracts the response as a JSON object, verify that the user
// has access to the volume based on any existing resource control and either rewrite an access denied response
// or a decorated volume.
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
func volumeInspectOperation(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)
+41
View File
@@ -121,3 +121,44 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
}
return false
}
// AuthorizedEndpointAccess ensure that the user can access the specified endpoint.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams of the endpoint and the associated group.
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
if !groupAccess {
return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams)
}
return true
}
// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
}
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
return authorizedAccess(userID, memberships, registry.AuthorizedUsers, registry.AuthorizedTeams)
}
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, authorizedUsers []portainer.UserID, authorizedTeams []portainer.TeamID) bool {
for _, authorizedUserID := range authorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range authorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
}
+12 -1
View File
@@ -12,6 +12,7 @@ type (
// RequestBouncer represents an entity that manages API request accesses
RequestBouncer struct {
jwtService portainer.JWTService
userService portainer.UserService
teamMembershipService portainer.TeamMembershipService
authDisabled bool
}
@@ -27,9 +28,10 @@ type (
)
// NewRequestBouncer initializes a new RequestBouncer
func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
func NewRequestBouncer(jwtService portainer.JWTService, userService portainer.UserService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
return &RequestBouncer{
jwtService: jwtService,
userService: userService,
teamMembershipService: teamMembershipService,
authDisabled: authDisabled,
}
@@ -136,6 +138,15 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han
httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil)
return
}
_, err = bouncer.userService.User(tokenData.ID)
if err != nil && err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
return
}
} else {
tokenData = &portainer.TokenData{
Role: portainer.AdministratorRole,
+24 -27
View File
@@ -69,7 +69,7 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
filteredRegistries = make([]portainer.Registry, 0)
for _, registry := range registries {
if isRegistryAccessAuthorized(&registry, context.UserID, context.UserMemberships) {
if AuthorizedRegistryAccess(&registry, context.UserID, context.UserMemberships) {
filteredRegistries = append(filteredRegistries, registry)
}
}
@@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
}
// 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) {
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, 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) {
endpointGroup := getAssociatedGroup(&endpoint, groups)
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
@@ -96,34 +98,29 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
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
// FilterEndpointGroups filters endpoint groups based on user role and team memberships.
// Non administrator users only have access to authorized endpoint groups.
func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) {
filteredEndpointGroups := endpointGroups
if !context.IsAdmin {
filteredEndpointGroups = make([]portainer.EndpointGroup, 0)
for _, group := range endpointGroups {
if AuthorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) {
filteredEndpointGroups = append(filteredEndpointGroups, group)
}
}
}
return false
return filteredEndpointGroups, nil
}
func isEndpointAccessAuthorized(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup {
for _, group := range groups {
if group.ID == endpoint.GroupID {
return &group
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package security
import (
"net/http"
"strings"
"time"
"github.com/g07cha/defender"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
)
// RateLimiter represents an entity that manages request rate limiting
type RateLimiter struct {
*defender.Defender
}
// NewRateLimiter initializes a new RateLimiter
func NewRateLimiter(maxRequests int, duration time.Duration, banDuration time.Duration) *RateLimiter {
messages := make(chan struct{})
limiter := defender.New(maxRequests, duration, banDuration)
go limiter.CleanupTask(messages)
return &RateLimiter{
limiter,
}
}
// LimitAccess wraps current request with check if remote address does not goes above the defined limits
func (limiter *RateLimiter) LimitAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := StripAddrPort(r.RemoteAddr)
if banned := limiter.Inc(ip); banned == true {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil)
return
}
next.ServeHTTP(w, r)
})
}
// StripAddrPort removes port from IP address
func StripAddrPort(addr string) string {
portIndex := strings.LastIndex(addr, ":")
if portIndex != -1 {
addr = addr[:portIndex]
}
return addr
}
+69
View File
@@ -0,0 +1,69 @@
package security
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLimitAccess(t *testing.T) {
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
t.Run("Request below the limit", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
rateLimiter := NewRateLimiter(10, 1*time.Second, 1*time.Hour)
handler := rateLimiter.LimitAccess(testHandler)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
})
t.Run("Request above the limit", func(t *testing.T) {
rateLimiter := NewRateLimiter(1, 1*time.Second, 1*time.Hour)
handler := rateLimiter.LimitAccess(testHandler)
ts := httptest.NewServer(handler)
defer ts.Close()
http.Get(ts.URL)
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
if status := resp.StatusCode; status != http.StatusForbidden {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusForbidden)
}
})
}
func TestStripAddrPort(t *testing.T) {
t.Run("IP with port", func(t *testing.T) {
result := StripAddrPort("127.0.0.1:1000")
if result != "127.0.0.1" {
t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result)
}
})
t.Run("IP without port", func(t *testing.T) {
result := StripAddrPort("127.0.0.1")
if result != "127.0.0.1" {
t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result)
}
})
t.Run("Local IP", func(t *testing.T) {
result := StripAddrPort("[::1]:1000")
if result != "[::1]" {
t.Errorf("Expected IP with address to be '[::1]', but it was %s instead", result)
}
})
}
+34 -3
View File
@@ -1,8 +1,11 @@
package http
import (
"time"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/handler"
"github.com/portainer/portainer/http/handler/extensions"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
@@ -21,6 +24,7 @@ type Server struct {
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
ResourceControlService portainer.ResourceControlService
SettingsService portainer.SettingsService
CryptoService portainer.CryptoService
@@ -32,6 +36,7 @@ type Server struct {
StackManager portainer.StackManager
LDAPService portainer.LDAPService
GitService portainer.GitService
SignatureService portainer.DigitalSignatureService
Handler *handler.Handler
SSL bool
SSLCert string
@@ -40,11 +45,20 @@ type Server struct {
// 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)
requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled)
proxyManagerParameters := &proxy.ManagerParams{
ResourceControlService: server.ResourceControlService,
TeamMembershipService: server.TeamMembershipService,
SettingsService: server.SettingsService,
RegistryService: server.RegistryService,
DockerHubService: server.DockerHubService,
SignatureService: server.SignatureService,
}
proxyManager := proxy.NewManager(proxyManagerParameters)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
var authHandler = handler.NewAuthHandler(requestBouncer, rateLimiter, server.AuthDisabled)
authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
@@ -71,14 +85,20 @@ func (server *Server) Start() error {
templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer)
dockerHandler.EndpointService = server.EndpointService
dockerHandler.EndpointGroupService = server.EndpointGroupService
dockerHandler.TeamMembershipService = server.TeamMembershipService
dockerHandler.ProxyManager = proxyManager
var websocketHandler = handler.NewWebSocketHandler()
websocketHandler.EndpointService = server.EndpointService
websocketHandler.SignatureService = server.SignatureService
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
endpointHandler.EndpointService = server.EndpointService
endpointHandler.EndpointGroupService = server.EndpointGroupService
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager
var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer)
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
endpointGroupHandler.EndpointService = server.EndpointService
var registryHandler = handler.NewRegistryHandler(requestBouncer)
registryHandler.RegistryService = server.RegistryService
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
@@ -96,6 +116,14 @@ func (server *Server) Start() error {
stackHandler.GitService = server.GitService
stackHandler.RegistryService = server.RegistryService
stackHandler.DockerHubService = server.DockerHubService
var extensionHandler = handler.NewExtensionHandler(requestBouncer)
extensionHandler.EndpointService = server.EndpointService
extensionHandler.ProxyManager = proxyManager
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
storidgeHandler.EndpointService = server.EndpointService
storidgeHandler.EndpointGroupService = server.EndpointGroupService
storidgeHandler.TeamMembershipService = server.TeamMembershipService
storidgeHandler.ProxyManager = proxyManager
server.Handler = &handler.Handler{
AuthHandler: authHandler,
@@ -103,6 +131,7 @@ func (server *Server) Start() error {
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,
EndpointHandler: endpointHandler,
EndpointGroupHandler: endpointGroupHandler,
RegistryHandler: registryHandler,
DockerHubHandler: dockerHubHandler,
ResourceHandler: resourceHandler,
@@ -114,6 +143,8 @@ func (server *Server) Start() error {
WebSocketHandler: websocketHandler,
FileHandler: fileHandler,
UploadHandler: uploadHandler,
ExtensionHandler: extensionHandler,
StoridgeHandler: storidgeHandler,
}
if server.SSL {
+1 -1
View File
@@ -55,7 +55,7 @@ func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearc
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
if settings.TLSConfig.TLS || settings.StartTLS {
config, err := crypto.CreateTLSConfiguration(&settings.TLSConfig)
config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig.TLSCACertPath, settings.TLSConfig.TLSCertPath, settings.TLSConfig.TLSKeyPath, settings.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
+104 -25
View File
@@ -12,26 +12,26 @@ type (
// CLIFlags represents the available flags on the CLI.
CLIFlags struct {
Addr *string
AdminPassword *string
AdminPasswordFile *string
Assets *string
Data *string
EndpointURL *string
ExternalEndpoints *string
SyncInterval *string
Endpoint *string
Labels *[]Pair
Logo *string
NoAuth *bool
NoAnalytics *bool
TLSVerify *bool
Templates *string
TLS *bool
TLSSkipVerify *bool
TLSCacert *string
TLSCert *string
TLSKey *string
SSL *bool
SSLCert *string
SSLKey *string
AdminPassword *string
AdminPasswordFile *string
// Deprecated fields
Logo *string
Templates *string
Labels *[]Pair
SyncInterval *string
}
// Status represents the application status.
@@ -73,6 +73,7 @@ type (
TemplatesURL string `json:"TemplatesURL"`
LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"`
DisplayDonationHeader bool `json:"DisplayDonationHeader"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
LDAPSettings LDAPSettings `json:"LDAPSettings"`
@@ -152,7 +153,7 @@ type (
URL string `json:"URL"`
Authentication bool `json:"Authentication"`
Username string `json:"Username"`
Password string `json:"Password"`
Password string `json:"Password,omitempty"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
}
@@ -162,22 +163,28 @@ type (
DockerHub struct {
Authentication bool `json:"Authentication"`
Username string `json:"Username"`
Password string `json:"Password"`
Password string `json:"Password,omitempty"`
}
// EndpointID represents an endpoint identifier.
EndpointID int
// EndpointType represents the type of an endpoint.
EndpointType int
// Endpoint represents a Docker endpoint with all the info required
// to connect to it.
Endpoint struct {
ID EndpointID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
ID EndpointID `json:"Id"`
Name string `json:"Name"`
Type EndpointType `json:"Type"`
URL string `json:"URL"`
GroupID EndpointGroupID `json:"GroupId"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
Extensions []EndpointExtension `json:"Extensions"`
// Deprecated fields
// Deprecated in DBVersion == 4
@@ -187,6 +194,29 @@ type (
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// EndpointGroupID represents an endpoint group identifier.
EndpointGroupID int
// EndpointGroup represents a group of endpoints.
EndpointGroup struct {
ID EndpointGroupID `json:"Id"`
Name string `json:"Name"`
Description string `json:"Description"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
Labels []Pair `json:"Labels"`
}
// EndpointExtension represents a extension associated to an endpoint.
EndpointExtension struct {
Type EndpointExtensionType `json:"Type"`
URL string `json:"URL"`
}
// EndpointExtensionType represents the type of an endpoint extension. Only
// one extension of each type can be associated to an endpoint.
EndpointExtensionType int
// ResourceControlID represents a resource control identifier.
ResourceControlID int
@@ -237,6 +267,7 @@ type (
// DataStore defines the interface to manage the data.
DataStore interface {
Open() error
Init() error
Close() error
MigrateData() error
}
@@ -290,6 +321,15 @@ type (
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
}
// EndpointGroupService represents a service for managing endpoint group data.
EndpointGroupService interface {
EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error)
EndpointGroups() ([]EndpointGroup, error)
CreateEndpointGroup(group *EndpointGroup) error
UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error
DeleteEndpointGroup(ID EndpointGroupID) error
}
// RegistryService represents a service for managing registry data.
RegistryService interface {
Registry(ID RegistryID) (*Registry, error)
@@ -343,6 +383,15 @@ type (
CompareHashAndData(hash string, data string) error
}
// DigitalSignatureService represents a service to manage digital signatures.
DigitalSignatureService interface {
ParseKeyPair(private, public []byte) error
GenerateKeyPair() ([]byte, []byte, error)
EncodedPublicKey() string
PEMHeaders() (string, string)
Sign(message string) (string, error)
}
// JWTService represents a service for managing JWT tokens.
JWTService interface {
GenerateToken(data *TokenData) (string, error)
@@ -358,13 +407,18 @@ type (
DeleteTLSFile(folder string, fileType TLSFileType) error
DeleteTLSFiles(folder string) error
GetStackProjectPath(stackIdentifier string) string
StoreStackFileFromString(stackIdentifier string, stackFileContent string) (string, error)
StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error)
StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error)
StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error)
KeyPairFilesExist() (bool, error)
StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error
LoadKeyPair() ([]byte, []byte, error)
WriteJSONToFile(path string, content interface{}) error
}
// GitService represents a service for managing Git.
GitService interface {
CloneRepository(url, destination string) error
ClonePublicRepository(repositoryURL, destination string) error
ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error
}
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
@@ -380,20 +434,31 @@ type (
// StackManager represents a service to manage stacks.
StackManager interface {
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) error
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint)
Logout(endpoint *Endpoint) error
Deploy(stack *Stack, endpoint *Endpoint) error
Deploy(stack *Stack, prune bool, endpoint *Endpoint) error
Remove(stack *Stack, endpoint *Endpoint) error
}
)
const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.15.2"
APIVersion = "1.17.1"
// DBVersion is the version number of the Portainer database.
DBVersion = 6
DBVersion = 11
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
// PortainerAgentHeader represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
PortainerAgentTargetHeader = "X-PortainerAgent-Target"
// PortainerAgentSignatureHeader represent the name of the header containing the digital signature
PortainerAgentSignatureHeader = "X-PortainerAgent-Signature"
// PortainerAgentPublicKeyHeader represent the name of the header containing the public key
PortainerAgentPublicKeyHeader = "X-PortainerAgent-PublicKey"
// PortainerAgentSignatureMessage represents the message used to create a digital signature
// to be used when communicating with an agent
PortainerAgentSignatureMessage = "Portainer-App"
)
const (
@@ -452,3 +517,17 @@ const (
// ConfigResourceControl represents a resource control associated to a Docker config
ConfigResourceControl
)
const (
_ EndpointExtensionType = iota
// StoridgeEndpointExtension represents the Storidge extension
StoridgeEndpointExtension
)
const (
_ EndpointType = iota
// DockerEnvironment represents an endpoint connected to a Docker environment
DockerEnvironment
// AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment
AgentOnDockerEnvironment
)
+464 -57
View File
@@ -56,7 +56,7 @@ info:
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
version: "1.15.2"
version: "1.17.1"
title: "Portainer API"
contact:
email: "info@portainer.io"
@@ -77,6 +77,8 @@ tags:
description: "Manage Portainer settings"
- name: "status"
description: "Information about the Portainer instance"
- name: "stacks"
description: "Manage Docker stacks"
- name: "users"
description: "Manage users"
- name: "teams"
@@ -222,21 +224,54 @@ paths:
**Access policy**: administrator
operationId: "EndpointCreate"
consumes:
- "application/json"
- "multipart/form-data"
produces:
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Endpoint details"
- name: "Name"
in: "formData"
type: "string"
description: "Name that will be used to identify this endpoint (example: my-endpoint)"
required: true
schema:
$ref: "#/definitions/EndpointCreateRequest"
- name: "URL"
in: "formData"
type: "string"
description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375)"
required: true
- name: "PublicURL"
in: "formData"
type: "string"
description: "URL or IP address where exposed containers will be reachable.\
\ Defaults to URL if not specified (example: docker.mydomain.tld:2375)"
- name: "GroupID"
in: "formData"
type: "string"
description: "Endpoint group identifier. If not specified will default to 1 (unassigned)."
- name: "TLS"
in: "formData"
type: "string"
description: "Require TLS to connect against this endpoint (example: true)"
- name: "TLSSkipVerify"
in: "formData"
type: "string"
description: "Skip server verification when using TLS" (example: false)
- name: "TLSCACertFile"
in: "formData"
type: "file"
description: "TLS CA certificate file"
- name: "TLSCertFile"
in: "formData"
type: "file"
description: "TLS client certificate file"
- name: "TLSKeyFile"
in: "formData"
type: "file"
description: "TLS client key file"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/EndpointCreateResponse"
$ref: "#/definitions/Endpoint"
400:
description: "Invalid request"
schema:
@@ -436,6 +471,285 @@ paths:
schema:
$ref: "#/definitions/GenericError"
/endpoints/{endpointId}/stacks:
get:
tags:
- "stacks"
summary: "List stacks"
description: |
List all stacks based on the current user authorizations.
Will return all stacks if using an administrator account otherwise it
will only return the list of stacks the user have access to.
**Access policy**: restricted
operationId: "StackList"
produces:
- "application/json"
parameters:
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/StackListResponse"
403:
description: "Unauthorized"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Access denied to resource"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
post:
tags:
- "stacks"
summary: "Deploy a new stack"
description: |
Deploy a new stack into a Docker environment specified via the endpoint identifier.
**Access policy**: restricted
operationId: "StackCreate"
consumes:
- "application/json"
produces:
- "application/json"
parameters:
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
- name: "method"
in: "query"
description: "Stack deployment method. Possible values: string or repository."
required: true
type: "string"
- in: "body"
name: "body"
description: "Stack details. Used when"
required: true
schema:
$ref: "#/definitions/StackCreateRequest"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/StackCreateResponse"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
404:
description: "Endpoint not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Endpoint not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/endpoints/{endpointId}/stacks/{id}:
get:
tags:
- "stacks"
summary: "Inspect a stack"
description: |
Retrieve details about a stack.
**Access policy**: restricted
operationId: "StackInspect"
produces:
- "application/json"
parameters:
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
- name: "id"
in: "path"
description: "Stack identifier"
required: true
type: "string"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/Stack"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
403:
description: "Unauthorized"
schema:
$ref: "#/definitions/GenericError"
404:
description: "Stack not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Stack not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
put:
tags:
- "stacks"
summary: "Update a stack"
description: |
Update a stack.
**Access policy**: restricted
operationId: "StackUpdate"
consumes:
- "application/json"
produces:
- "application/json"
parameters:
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
- name: "id"
in: "path"
description: "Stack identifier"
required: true
type: "string"
- in: "body"
name: "body"
description: "Stack details"
required: true
schema:
$ref: "#/definitions/StackUpdateRequest"
responses:
200:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
404:
description: "Stack not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Stack not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
delete:
tags:
- "stacks"
summary: "Remove a stack"
description: |
Remove a stack.
**Access policy**: restricted
operationId: "StackDelete"
parameters:
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
- name: "id"
in: "path"
description: "Stack identifier"
required: true
type: "string"
responses:
200:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
403:
description: "Unauthorized"
schema:
$ref: "#/definitions/GenericError"
404:
description: "Stack not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Stack not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/endpoints/{endpointId}/stacks/{id}/stackfile:
get:
tags:
- "stacks"
summary: "Retrieve the content of the Stack file for the specified stack"
description: |
Get Stack file content.
**Access policy**: restricted
operationId: "StackFileInspect"
produces:
- "application/json"
parameters:
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
- name: "id"
in: "path"
description: "Stack identifier"
required: true
type: "string"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/StackFileInspectResponse"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
403:
description: "Unauthorized"
schema:
$ref: "#/definitions/GenericError"
404:
description: "Stack not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Stack not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/registries:
get:
tags:
@@ -536,7 +850,7 @@ paths:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Endpoint not found"
err: "Registry not found"
500:
description: "Server error"
schema:
@@ -593,13 +907,6 @@ paths:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
503:
description: "Endpoint management disabled"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Endpoint management is disabled"
delete:
tags:
- "registries"
@@ -1869,7 +2176,7 @@ definitions:
description: "Is analytics enabled"
Version:
type: "string"
example: "1.15.2"
example: "1.17.1"
description: "Portainer API version"
PublicSettingsInspectResponse:
type: "object"
@@ -1880,6 +2187,10 @@ definitions:
description: "URL to a logo that will be displayed on the login page as well\
\ as on top of the sidebar. Will use default Portainer logo when value is\
\ empty string"
DisplayDonationHeader:
type: "boolean"
example: true
description: "Whether to display or not the donation message in the header."
DisplayExternalContributors:
type: "boolean"
example: false
@@ -1983,6 +2294,10 @@ definitions:
\ when querying containers"
items:
$ref: "#/definitions/Settings_BlackListedLabels"
DisplayDonationHeader:
type: "boolean"
example: true
description: "Whether to display or not the donation message in the header."
DisplayExternalContributors:
type: "boolean"
example: false
@@ -2062,10 +2377,22 @@ definitions:
type: "string"
example: "my-endpoint"
description: "Endpoint name"
Type:
type: "integer"
example: 1
description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment."
URL:
type: "string"
example: "docker.mydomain.tld:2375"
description: "URL or IP address of the Docker host associated to this endpoint"
PublicURL:
type: "string"
example: "docker.mydomain.tld:2375"
description: "URL or IP address where exposed containers will be reachable"
GroupID:
type: "integer"
example: 1
description: "Endpoint group identifier"
AuthorizedUsers:
type: "array"
description: "List of user identifiers authorized to connect to this endpoint"
@@ -2142,44 +2469,6 @@ definitions:
type: "string"
example: "hub_password"
description: "Password used to authenticate against the DockerHub"
EndpointCreateRequest:
type: "object"
required:
- "Name"
- "URL"
properties:
Name:
type: "string"
example: "my-endpoint"
description: "Name that will be used to identify this endpoint"
URL:
type: "string"
example: "docker.mydomain.tld:2375"
description: "URL or IP address of a Docker host"
PublicURL:
type: "string"
example: "docker.mydomain.tld:2375"
description: "URL or IP address where exposed containers will be reachable.\
\ Defaults to URL if not specified"
TLS:
type: "boolean"
example: true
description: "Require TLS to connect against this endpoint"
TLSSkipVerify:
type: "boolean"
example: false
description: "Skip server verification when using TLS"
TLSSkipClientVerify:
type: "boolean"
example: false
description: "Skip client verification when using TLS"
EndpointCreateResponse:
type: "object"
properties:
Id:
type: "integer"
example: 1
description: "Id of the endpoint"
EndpointListResponse:
type: "array"
items:
@@ -2321,12 +2610,13 @@ definitions:
ResourceID:
type: "string"
example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"
description: "Docker resource identifier on which access control will be applied"
description: "Docker resource identifier on which access control will be applied.\
\ In the case of a resource control applied to a stack, use the stack name as identifier"
Type:
type: "string"
example: "container"
description: "Type of Docker resource. Valid values are: container, volume\
\ or service"
\ service, secret, config or stack"
AdministratorsOnly:
type: "boolean"
example: true
@@ -2398,6 +2688,10 @@ definitions:
\ when querying containers"
items:
$ref: "#/definitions/Settings_BlackListedLabels"
DisplayDonationHeader:
type: "boolean"
example: true
description: "Whether to display or not the donation message in the header."
DisplayExternalContributors:
type: "boolean"
example: false
@@ -2600,3 +2894,116 @@ definitions:
type: "string"
example: "nginx:latest"
description: "The Docker image associated to the template"
StackCreateRequest:
type: "object"
required:
- "Name"
- "SwarmID"
properties:
Name:
type: "string"
example: "myStack"
description: "Name of the stack"
SwarmID:
type: "string"
example: "jpofkc0i9uo9wtx1zesuk649w"
description: "Cluster identifier of the Swarm cluster"
StackFileContent:
type: "string"
example: "version: 3\n services:\n web:\n image:nginx"
description: "Content of the Stack file. Required when using the 'string' deployment method."
RepositoryURL:
type: "string"
example: "https://github.com/openfaas/faas"
description: "URL of a Git repository hosting the Stack file. Required when using the 'repository' deployment method."
ComposeFilePathInRepository:
type: "string"
example: "docker-compose.yml"
description: "Path to the Stack file inside the Git repository. Required when using the 'repository' deployment method."
RepositoryAuthentication:
type: "boolean"
example: true
description: "Use basic authentication to clone the Git repository."
RepositoryUsername:
type: "string"
example: "myGitUsername"
description: "Username used in basic authentication. Required when RepositoryAuthentication is true."
RepositoryPassword:
type: "string"
example: "myGitPassword"
description: "Password used in basic authentication. Required when RepositoryAuthentication is true."
Env:
type: "array"
description: "A list of environment variables used during stack deployment"
items:
$ref: "#/definitions/Stack_Env"
Stack_Env:
properties:
name:
type: "string"
example: "MYSQL_ROOT_PASSWORD"
value:
type: "string"
example: "password"
StackCreateResponse:
type: "object"
properties:
Id:
type: "string"
example: "myStack_jpofkc0i9uo9wtx1zesuk649w"
description: "Id of the stack"
StackListResponse:
type: "array"
items:
$ref: "#/definitions/Stack"
Stack:
type: "object"
properties:
Id:
type: "string"
example: "myStack_jpofkc0i9uo9wtx1zesuk649w"
description: "Stack identifier"
Name:
type: "string"
example: "myStack"
description: "Stack name"
EntryPoint:
type: "string"
example: "docker-compose.yml"
description: "Path to the Stack file"
SwarmID:
type: "string"
example: "jpofkc0i9uo9wtx1zesuk649w"
description: "Cluster identifier of the Swarm cluster where the stack is deployed"
ProjectPath:
type: "string"
example: "/data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w"
description: "Path on disk to the repository hosting the Stack file"
Env:
type: "array"
description: "A list of environment variables used during stack deployment"
items:
$ref: "#/definitions/Stack_Env"
StackUpdateRequest:
type: "object"
properties:
StackFileContent:
type: "string"
example: "version: 3\n services:\n web:\n image:nginx"
description: "New content of the Stack file."
Env:
type: "array"
description: "A list of environment variables used during stack deployment"
items:
$ref: "#/definitions/Stack_Env"
Prune:
type: "boolean"
example: false
description: "Prune services that are no longer referenced"
StackFileInspectResponse:
type: "object"
properties:
StackFileContent:
type: "string"
example: "version: 3\n services:\n web:\n image:nginx"
description: "Content of the Stack file."
+9 -59
View File
@@ -5,69 +5,19 @@ angular.module('portainer', [
'ngCookies',
'ngSanitize',
'ngFileUpload',
'ngMessages',
'ngResource',
'angularUtils.directives.dirPagination',
'LocalStorageModule',
'angular-jwt',
'angular-google-analytics',
'angular-json-tree',
'angular-loading-bar',
'angular-clipboard',
'luegg.directives',
'portainer.templates',
'portainer.filters',
'portainer.rest',
'portainer.helpers',
'portainer.services',
'auth',
'dashboard',
'config',
'configs',
'container',
'containerConsole',
'containerLogs',
'containerStats',
'containerInspect',
'serviceLogs',
'containers',
'createConfig',
'createContainer',
'createNetwork',
'createRegistry',
'createSecret',
'createService',
'createVolume',
'createStack',
'engine',
'endpoint',
'endpointAccess',
'endpoints',
'events',
'image',
'images',
'initAdmin',
'initEndpoint',
'main',
'network',
'networks',
'node',
'registries',
'registry',
'registryAccess',
'secrets',
'secret',
'service',
'services',
'settings',
'settingsAuthentication',
'sidebar',
'stack',
'stacks',
'swarm',
'swarmVisualizer',
'task',
'team',
'teams',
'templates',
'user',
'users',
'userSettings',
'volume',
'volumes',
'portainer.app',
'portainer.agent',
'portainer.docker',
'extension.storidge',
'rzModule']);
+1
View File
@@ -0,0 +1 @@
angular.module('portainer.agent', []);
@@ -0,0 +1,7 @@
angular.module('portainer.agent').component('nodeSelector', {
templateUrl: 'app/agent/components/node-selector/nodeSelector.html',
controller: 'NodeSelectorController',
bindings: {
model: '='
}
});
@@ -0,0 +1,8 @@
<div class="form-group">
<label for="target_node" class="col-sm-1 control-label text-left">Node</label>
<div class="col-sm-11">
<select class="form-control"
ng-model="$ctrl.model" ng-options="agent.NodeName as agent.NodeName for agent in $ctrl.agents"
></select>
</div>
</div>
@@ -0,0 +1,18 @@
angular.module('portainer.agent')
.controller('NodeSelectorController', ['AgentService', 'Notifications', function (AgentService, Notifications) {
var ctrl = this;
this.$onInit = function() {
AgentService.agents()
.then(function success(data) {
ctrl.agents = data;
if (!ctrl.model) {
ctrl.model = data[0].NodeName;
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load agents');
});
};
}]);
+5
View File
@@ -0,0 +1,5 @@
function AgentViewModel(data) {
this.IPAddress = data.IPAddress;
this.NodeName = data.NodeName;
this.NodeRole = data.NodeRole;
}
+10
View File
@@ -0,0 +1,10 @@
angular.module('portainer.agent')
.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', {
endpointId: EndpointProvider.endpointID
},
{
query: {method: 'GET', isArray: true}
});
}]);
+24
View File
@@ -0,0 +1,24 @@
angular.module('portainer.agent')
.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) {
'use strict';
var service = {};
service.agents = function() {
var deferred = $q.defer();
Agent.query({}).$promise
.then(function success(data) {
var agents = data.map(function (item) {
return new AgentViewModel(item);
});
deferred.resolve(agents);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve agents', err: err });
});
return deferred.promise;
};
return service;
}]);
+9 -4
View File
@@ -1,5 +1,6 @@
angular.module('portainer')
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar) {
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper',
function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) {
'use strict';
EndpointProvider.initialize();
@@ -7,7 +8,7 @@ angular.module('portainer')
StateManager.initialize()
.then(function success(state) {
if (state.application.authentication) {
initAuthentication(authManager, Authentication, $rootScope);
initAuthentication(authManager, Authentication, $rootScope, $state);
}
if (state.application.analytics) {
initAnalytics(Analytics, $rootScope);
@@ -27,15 +28,19 @@ angular.module('portainer')
originalSet.apply(cfpLoadingBar, arguments);
}
};
$transitions.onBefore({ to: 'docker.**' }, function() {
HttpRequestHelper.resetAgentTargetQueue();
});
}]);
function initAuthentication(authManager, Authentication, $rootScope) {
function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
authManager.redirectWhenUnauthenticated();
Authentication.init();
$rootScope.$on('tokenHasExpired', function() {
$state.go('auth', {error: 'Your session has expired'});
$state.go('portainer.auth', {error: 'Your session has expired'});
});
}
-80
View File
@@ -1,80 +0,0 @@
<rd-header>
<rd-header-title title="Configs list">
<a data-toggle="tooltip" title="Refresh" ui-sref="configs" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Configs</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-file-code-o" title="Configs">
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.config"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add config</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('CreatedAt')">
Created at
<span ng-show="sortType == 'CreatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CreatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a 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>
</thead>
<tbody>
<tr dir-paginate="config in (state.filteredConfigs = ( configs | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: pagination_count))">
<td><input type="checkbox" ng-model="config.Checked" ng-change="selectItem(config)"/></td>
<td><a ui-sref="config({id: config.Id})">{{ config.Name }}</a></td>
<td>{{ config.CreatedAt | getisodate }}</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="config.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ config.ResourceControl.Ownership ? config.ResourceControl.Ownership : config.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!configs">
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="configs.length == 0">
<td colspan="4" class="text-center text-muted">No configs available.</td>
</tr>
</tbody>
</table>
<div ng-if="configs" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>
@@ -1,60 +0,0 @@
angular.module('configs', [])
.controller('ConfigsController', ['$scope', '$stateParams', '$state', 'ConfigService', 'Notifications', 'Pagination',
function ($scope, $stateParams, $state, ConfigService, Notifications, Pagination) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('configs');
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredConfigs, function (config) {
if (config.Checked !== allSelected) {
config.Checked = allSelected;
$scope.selectItem(config);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function () {
angular.forEach($scope.configs, function (config) {
if (config.Checked) {
ConfigService.remove(config.Id)
.then(function success() {
Notifications.success('Config deleted', config.Id);
var index = $scope.configs.indexOf(config);
$scope.configs.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove config');
});
}
});
};
function initView() {
ConfigService.configs()
.then(function success(data) {
$scope.configs = data;
})
.catch(function error(err) {
$scope.configs = [];
Notifications.error('Failure', err, 'Unable to retrieve configs');
});
}
initView();
}]);
@@ -1,70 +0,0 @@
angular.module('containerLogs', [])
.controller('ContainerLogsController', ['$scope', '$transition$', '$anchorScroll', 'ContainerLogs', 'Container',
function ($scope, $transition$, $anchorScroll, ContainerLogs, Container) {
$scope.state = {};
$scope.state.displayTimestampsOut = false;
$scope.state.displayTimestampsErr = false;
$scope.stdout = '';
$scope.stderr = '';
$scope.tailLines = 2000;
Container.get({id: $transition$.params().id}, function (d) {
$scope.container = d;
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve container info');
});
function getLogs() {
getLogsStdout();
getLogsStderr();
}
function getLogsStderr() {
ContainerLogs.get($transition$.params().id, {
stdout: 0,
stderr: 1,
timestamps: $scope.state.displayTimestampsErr,
tail: $scope.tailLines
}, function (data, status, headers, config) {
// Replace carriage returns with newlines to clean up output
data = data.replace(/[\r]/g, '\n');
// Strip 8 byte header from each line of output
data = data.substring(8);
data = data.replace(/\n(.{8})/g, '\n');
$scope.stderr = data;
});
}
function getLogsStdout() {
ContainerLogs.get($transition$.params().id, {
stdout: 1,
stderr: 0,
timestamps: $scope.state.displayTimestampsOut,
tail: $scope.tailLines
}, function (data, status, headers, config) {
// Replace carriage returns with newlines to clean up output
data = data.replace(/[\r]/g, '\n');
// Strip 8 byte header from each line of output
data = data.substring(8);
data = data.replace(/\n(.{8})/g, '\n');
$scope.stdout = data;
});
}
// initial call
getLogs();
var logIntervalId = window.setInterval(getLogs, 5000);
$scope.$on('$destroy', function () {
// clearing interval when view changes
clearInterval(logIntervalId);
});
$scope.toggleTimestampsOut = function () {
getLogsStdout();
};
$scope.toggleTimestampsErr = function () {
getLogsStderr();
};
}]);
@@ -1,54 +0,0 @@
<rd-header>
<rd-header-title title="Container logs"></rd-header-title>
<rd-header-content>
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> &gt; Logs
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<div class="widget-icon grey pull-left">
<i class="fa fa-server"></i>
</div>
<div class="title">{{ container.Name|trimcontainername }}</div>
<div class="comment">Name</div>
</rd-widget-body>
</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-info-circle" title="Stdout logs"></rd-widget-header>
<rd-widget-taskbar>
<input type="checkbox" ng-model="state.displayTimestampsOut" id="displayAllTsOut" ng-change="toggleTimestampsOut()"/>
<label for="displayAllTsOut">Display timestamps</label>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="panel-body">
<pre id="stdoutLog" class="pre-scrollable pre-x-scrollable">{{stdout}}</pre>
</div>
</rd-widget-body>
</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-exclamation-triangle" title="Stderr logs"></rd-widget-header>
<rd-widget-taskbar>
<input type="checkbox" ng-model="state.displayTimestampsErr" id="displayAllTsErr" ng-change="toggleTimestampsErr()"/>
<label for="displayAllTsErr">Display timestamps</label>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="panel-body">
<pre id="stderrLog" class="pre-scrollable pre-x-scrollable">{{stderr}}</pre>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
-151
View File
@@ -1,151 +0,0 @@
<rd-header>
<rd-header-title title="Container list">
<a data-toggle="tooltip" title="Refresh" ui-sref="containers" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Containers</rd-header-content>
</rd-header>
<div class="col-lg-12">
<rd-widget>
<rd-widget-header icon="fa-server" title="Containers">
<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-taskbar classes="col-lg-12">
<div class="pull-left">
<div class="btn-group" role="group" aria-label="...">
<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-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>
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Status')">
State
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Names')">
Name
<span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
<a data-toggle="tooltip" title="More" ng-click="truncateMore();" ng-show="showMore">
<i class="fa fa-plus-square" aria-hidden="true"></i>
</a>
</th>
<th>
<a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="state.displayIP">
<a ng-click="order('IP')">
IP Address
<span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
<a ng-click="order('Host')">
Host IP
<span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('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 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 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 ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) !== -1" class="label label-{{ container.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ container.Status }}</span>
<span ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) === -1" class="label label-{{ container.Status|containerstatusbadge }}">{{ container.Status }}</span>
</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td>
<td>{{ container.StackName ? container.StackName : '-' }}</td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>
<td>
<a ng-if="container.Ports.length > 0" ng-repeat="p in container.Ports" class="image-tag" ng-href="http://{{ PublicURL || p.host }}:{{p.public}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{p.public}}:{{ p.private }}
</a>
<span ng-if="container.Ports.length == 0" >-</span>
</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="container.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ container.ResourceControl.Ownership ? container.ResourceControl.Ownership : container.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!containers">
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="containers.length == 0">
<td colspan="9" class="text-center text-muted">No containers available.</td>
</tr>
</tbody>
</table>
<div ng-if="containers" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
@@ -1,249 +0,0 @@
angular.module('containers', [])
.controller('ContainersController', ['$q', '$scope', '$state', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'SystemService', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', 'LocalStorage',
function ($q, $scope, $state, $filter, Container, ContainerService, ContainerHelper, SystemService, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider, LocalStorage) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('containers');
$scope.state.displayAll = LocalStorage.getFilterContainerShowAll();
$scope.state.displayIP = false;
$scope.sortType = 'State';
$scope.sortReverse = false;
$scope.state.selectedItemCount = 0;
$scope.truncate_size = 40;
$scope.showMore = true;
$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) {
$scope.state.selectedItemCount = 0;
Container.query(data, function (d) {
var containers = d;
$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.applicationState.endpoint.mode.provider === 'DOCKER_SWARM') {
model.hostIP = $scope.swarm_hosts[_.split(container.Names[0], '/')[1]];
}
return model;
});
updateSelectionFlags();
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve containers');
$scope.containers = [];
});
};
var batch = function (items, action, msg) {
var counter = 0;
var complete = function () {
counter = counter - 1;
if (counter === 0) {
update({all: $scope.state.displayAll ? 1 : 0});
}
};
angular.forEach(items, function (c) {
if (c.Checked) {
counter = counter + 1;
if (action === Container.start) {
action({id: c.Id}, {}, function (d) {
Notifications.success('Container ' + msg, c.Id);
complete();
}, function (e) {
Notifications.error('Failure', e, 'Unable to start container');
complete();
});
}
else if (action === Container.remove) {
ContainerService.remove(c, $scope.cleanAssociatedVolumes)
.then(function success() {
var index = items.indexOf(c);
items.splice(index, 1);
Notifications.success('Container successfully removed');
complete();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove container');
complete();
});
}
else if (action === Container.pause) {
action({id: c.Id}, function (d) {
if (d.message) {
Notifications.success('Container is already paused', c.Id);
} else {
Notifications.success('Container ' + msg, c.Id);
}
complete();
}, function (e) {
Notifications.error('Failure', e, 'Unable to pause container');
complete();
});
}
else {
action({id: c.Id}, function (d) {
Notifications.success('Container ' + msg, c.Id);
complete();
}, function (e) {
Notifications.error('Failure', e, 'An error occured');
complete();
});
}
}
});
};
$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 () {
LocalStorage.storeFilterContainerShowAll($scope.state.displayAll);
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.truncateMore = function(size) {
$scope.truncate_size = 80;
$scope.showMore = false;
};
$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--;
}
}
function updateSelectionFlags() {
$scope.state.noStoppedItemsSelected = true;
$scope.state.noRunningItemsSelected = true;
$scope.state.noPausedItemsSelected = true;
$scope.containers.forEach(function(container) {
if(!container.Checked) {
return;
}
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 = {};
var systemStatus = data.SystemStatus;
var node_count = parseInt(systemStatus[3][1], 10);
var node_offset = 4;
for (i = 0; i < node_count; i++) {
var host = {};
host.name = _.trim(systemStatus[node_offset][0]);
host.ip = _.split(systemStatus[node_offset][1], ':')[0];
swarm_hosts[host.name] = host.ip;
node_offset += 9;
}
return swarm_hosts;
}
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,28 +0,0 @@
<form class="form-horizontal" style="margin-top: 15px;">
<div class="form-group">
<div class="col-sm-12 small text-muted">
Secrets will be available under <code>/run/secrets/$SECRET_NAME</code> in containers.
</div>
</div>
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Secrets</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addSecret()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add a secret
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="secret in formValues.Secrets" style="margin-top: 2px;">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">secret</span>
<select class="form-control" ng-model="secret.model" ng-options="secret.Name for secret in availableSecrets">
<option value="" selected="selected">Select a secret</option>
</select>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeSecret($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</form>
-159
View File
@@ -1,159 +0,0 @@
<rd-header>
<rd-header-title title="Endpoints">
<a data-toggle="tooltip" title="Refresh" ui-sref="endpoints" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Endpoint management</rd-header-content>
</rd-header>
<div class="row" ng-if="!applicationState.application.endpointManagement">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-exclamation-triangle" title="Endpoint management is not available">
</rd-widget-header>
<rd-widget-body>
<span class="small text-muted">Portainer has been started using the <code>--external-endpoints</code> flag.
Endpoint management via the UI is disabled.
<span ng-if="applicationState.application.authentication">You can still manage endpoint access.</span>
</span>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="applicationState.application.endpointManagement">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plus" title="Add a new endpoint">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="container_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="container_name" ng-model="formValues.Name" placeholder="e.g. docker-prod01">
</div>
</div>
<!-- !name-input -->
<!-- endpoint-url-input -->
<div class="form-group">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
Endpoint URL
<portainer-tooltip position="bottom" message="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="endpoint_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375">
</div>
</div>
<!-- !endpoint-url-input -->
<!-- endpoint-public-url-input -->
<div class="form-group">
<label for="endpoint_public_url" class="col-sm-3 col-lg-2 control-label text-left">
Public IP
<portainer-tooltip position="bottom" message="URL or IP address where exposed containers will be reachable. This field is optional and will default to the endpoint URL."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="endpoint_public_url" ng-model="formValues.PublicURL" placeholder="e.g. 10.0.0.10 or mydocker.mydomain.com">
</div>
</div>
<!-- !endpoint-public-url-input -->
<!-- endpoint-security -->
<por-endpoint-security form-data="formValues.SecurityFormData"></por-endpoint-security>
<!-- !endpoint-security -->
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Name || !formValues.URL || (formValues.TLS && ((formValues.TLSVerify && !formValues.TLSCACert) || (formValues.TLSClientCert && (!formValues.TLSCert || !formValues.TLSKey))))" ng-click="addEndpoint()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Add endpoint</span>
<span ng-show="state.actionInProgress">Creating endpoint...</span>
</button>
</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">
<rd-widget>
<rd-widget-header icon="fa-plug" title="Endpoints">
<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-taskbar classes="col-lg-12">
<div class="pull-left" ng-if="applicationState.application.endpointManagement">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th ng-if="applicationState.application.endpointManagement">
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="endpoints" ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="endpoints" ng-click="order('URL')">
URL
<span ng-show="sortType == 'URL' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'URL' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th></th>
</tr>
</thead>
<tbody>
<tr dir-paginate="endpoint in (state.filteredEndpoints = (endpoints | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td ng-if="applicationState.application.endpointManagement"><input type="checkbox" ng-model="endpoint.Checked" ng-change="selectItem(endpoint)" /></td>
<td><i class="fa fa-star" aria-hidden="true" ng-if="endpoint.Id === activeEndpoint.Id"></i> {{ endpoint.Name }}</td>
<td>{{ endpoint.URL | stripprotocol }}</td>
<td>
<span ng-if="applicationState.application.endpointManagement">
<a ui-sref="endpoint({id: endpoint.Id})"><i class="fa fa-pencil-square-o" aria-hidden="true"></i> Edit</a>
</span>
<span ng-if="applicationState.application.authentication">
<a ui-sref="endpoint.access({id: endpoint.Id})"><i class="fa fa-users" aria-hidden="true" style="margin-left: 7px;"></i> Manage access</a>
</span>
</td>
</tr>
<tr ng-if="!endpoints">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="endpoints.length == 0">
<td colspan="5" class="text-center text-muted">No endpoints available.</td>
</tr>
</tbody>
</table>
<div ng-if="endpoints" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -1,104 +0,0 @@
angular.module('endpoints', [])
.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination',
function ($scope, $state, $filter, EndpointService, EndpointProvider, Notifications, Pagination) {
$scope.state = {
uploadInProgress: false,
selectedItemCount: 0,
pagination_count: Pagination.getPaginationCount('endpoints'),
actionInProgress: false
};
$scope.sortType = 'Name';
$scope.sortReverse = true;
$scope.formValues = {
Name: '',
URL: '',
PublicURL: '',
SecurityFormData: new EndpointSecurityFormData()
};
$scope.order = function(sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('endpoints', $scope.state.pagination_count);
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredEndpoints, function (endpoint) {
if (endpoint.Checked !== allSelected) {
endpoint.Checked = allSelected;
$scope.selectItem(endpoint);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.addEndpoint = function() {
var name = $scope.formValues.Name;
var URL = $filter('stripprotocol')($scope.formValues.URL);
var PublicURL = $scope.formValues.PublicURL;
if (PublicURL === '') {
PublicURL = URL.split(':')[0];
}
var securityData = $scope.formValues.SecurityFormData;
var TLS = securityData.TLS;
var TLSMode = securityData.TLSMode;
var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only');
var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only');
var TLSCAFile = TLSSkipVerify ? null : securityData.TLSCACert;
var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert;
var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey;
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) {
Notifications.success('Endpoint created', name);
$state.reload();
}, function error(err) {
$scope.state.uploadInProgress = false;
$scope.state.actionInProgress = false;
Notifications.error('Failure', err, 'Unable to create endpoint');
}, function update(evt) {
if (evt.upload) {
$scope.state.uploadInProgress = evt.upload;
}
});
};
$scope.removeAction = function () {
angular.forEach($scope.endpoints, function (endpoint) {
if (endpoint.Checked) {
EndpointService.deleteEndpoint(endpoint.Id).then(function success(data) {
Notifications.success('Endpoint deleted', endpoint.Name);
var index = $scope.endpoints.indexOf(endpoint);
$scope.endpoints.splice(index, 1);
}, function error(err) {
Notifications.error('Failure', err, 'Unable to remove endpoint');
});
}
});
};
function fetchEndpoints() {
EndpointService.endpoints()
.then(function success(data) {
$scope.endpoints = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
$scope.endpoints = [];
});
}
fetchEndpoints();
}]);
-73
View File
@@ -1,73 +0,0 @@
<rd-header>
<rd-header-title title="Event list">
<a data-toggle="tooltip" title="Refresh" ui-sref="events" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Events</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-history" title="Events">
<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-taskbar classes="col-lg-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ui-sref="events" ng-click="order('Time')">
Date
<span ng-show="sortType == 'Time' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Time' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="events" ng-click="order('Type')">
Category
<span ng-show="sortType == 'Type' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Type' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="events" ng-click="order('Details')">
Details
<span ng-show="sortType == 'Details' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Details' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="event in (events | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count)">
<td>{{ event.Time|getisodatefromtimestamp }}</td>
<td>{{ event.Type }}</td>
<td>{{ event.Details }}</td>
</tr>
</tbody>
</table>
<div ng-if="events" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
-32
View File
@@ -1,32 +0,0 @@
angular.module('events', [])
.controller('EventsController', ['$scope', 'Notifications', 'SystemService', 'Pagination',
function ($scope, Notifications, SystemService, Pagination) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('events');
$scope.sortType = 'Time';
$scope.sortReverse = true;
$scope.order = function(sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('events', $scope.state.pagination_count);
};
function initView() {
var from = moment().subtract(24, 'hour').unix();
var to = moment().unix();
SystemService.events(from, to)
.then(function success(data) {
$scope.events = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load events');
});
}
initView();
}]);
-152
View File
@@ -1,152 +0,0 @@
<rd-header>
<rd-header-title title="Image list">
<a data-toggle="tooltip" title="Refresh" ui-sref="images" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Images</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-download" title="Pull image ">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- image-and-registry -->
<div class="form-group">
<por-image-registry image="formValues.Image" registry="formValues.Registry"></por-image-registry>
</div>
<!-- !image-and-registry -->
<!-- tag-note -->
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">Note: if you don't specify the tag in the image name, <span class="label label-default">latest</span> will be used.</span>
</div>
</div>
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Image" ng-click="pullImage()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress">Pull the image</span>
<span ng-show="state.actionInProgress">Download in progress...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</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-clone" title="Images">
<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-taskbar classes="col-lg-12">
<div class="pull-left">
<div class="btn-group">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" ng-disabled="!state.selectedItemCount" >
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="confirmRemovalAction(true)" ng-disabled="!state.selectedItemCount" >Force Remove</a></li>
</ul>
</div>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
<span class="btn-group btn-group-sm pull-right" style="margin-right: 20px;">
<label class="btn btn-primary" ng-model="state.containersCountFilter" uib-btn-radio="undefined">
All
</label>
<label class="btn btn-primary" ng-model="state.containersCountFilter" uib-btn-radio="'!' + 0">
Used
</label>
<label class="btn btn-primary" ng-model="state.containersCountFilter" uib-btn-radio="0">
Unused
</label>
</span>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="images" ng-click="order('Id')">
Id
<span ng-show="sortType == 'Id' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="images" ng-click="order('RepoTags')">
Tags
<span ng-show="sortType == 'RepoTags' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'RepoTags' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="images" ng-click="order('VirtualSize')">
Size
<span ng-show="sortType == 'VirtualSize' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'VirtualSize' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="images" ng-click="order('Created')">
Created
<span ng-show="sortType == 'Created' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Created' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="image in (state.filteredImages = (images | filter:{ ContainerCount: state.containersCountFilter } | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="image.Checked" ng-change="selectItem(image)" /></td>
<td>
<a class="monospaced" ui-sref="image({id: image.Id})">{{ image.Id|truncate:20}}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::image.ContainerCount === 0">Unused</span>
</td>
<td>
<span class="label label-primary image-tag" ng-repeat="tag in (image|repotags)">{{ tag }}</span>
</td>
<td>{{ image.VirtualSize|humansize }}</td>
<td>{{ image.Created|getisodatefromtimestamp }}</td>
</tr>
<tr ng-if="!images">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="state.filteredImages.length === 0">
<td colspan="5" class="text-center text-muted">No images available.</td>
</tr>
</tbody>
</table>
<div ng-if="images" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
-100
View File
@@ -1,100 +0,0 @@
angular.module('images', [])
.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'Pagination', 'ModalService',
function ($scope, $state, ImageService, Notifications, Pagination, ModalService) {
$scope.state = {
pagination_count: Pagination.getPaginationCount('images'),
actionInProgress: false,
selectedItemCount: 0
};
$scope.sortType = 'RepoTags';
$scope.sortReverse = true;
$scope.formValues = {
Image: '',
Registry: ''
};
$scope.order = function(sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('images', $scope.state.pagination_count);
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredImages, function (image) {
if (image.Checked !== allSelected) {
image.Checked = allSelected;
$scope.selectItem(image);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.pullImage = function() {
var image = $scope.formValues.Image;
var registry = $scope.formValues.Registry;
$scope.state.actionInProgress = true;
ImageService.pullImage(image, registry, false)
.then(function success(data) {
Notifications.success('Image successfully pulled', image);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to pull image');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
$scope.confirmRemovalAction = function (force) {
ModalService.confirmImageForceRemoval(function (confirmed) {
if(!confirmed) { return; }
$scope.removeAction(force);
});
};
$scope.removeAction = function (force) {
force = !!force;
angular.forEach($scope.images, function (i) {
if (i.Checked) {
ImageService.deleteImage(i.Id, force)
.then(function success(data) {
Notifications.success('Image deleted', i.Id);
var index = $scope.images.indexOf(i);
$scope.images.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
}
});
};
function fetchImages() {
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
ImageService.images(true)
.then(function success(data) {
$scope.images = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve images');
$scope.images = [];
});
}
fetchImages();
}]);
-132
View File
@@ -1,132 +0,0 @@
<rd-header>
<rd-header-title title="Network list">
<a data-toggle="tooltip" title="Refresh" ui-sref="networks" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Networks</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-sitemap" title="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-taskbar classes="col-lg-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.network"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add network</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Scope')">
Scope
<span ng-show="sortType == 'Scope' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Scope' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Driver')">
Driver
<span ng-show="sortType == 'Driver' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Driver' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('IPAM.Driver')">
IPAM Driver
<span ng-show="sortType == 'IPAM.Driver' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'IPAM.Driver' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('IPAM.Config[0].Subnet')">
IPAM Subnet
<span ng-show="sortType == 'IPAM.Config[0].Subnet' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'IPAM.Config[0].Subnet' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('IPAM.Config[0].Gateway')">
IPAM Gateway
<span ng-show="sortType == 'IPAM.Config[0].Gateway' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'IPAM.Config[0].Gateway' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a 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 dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td>
<td><a ui-sref="network({id: network.Id})">{{ network.Name | truncate:40 }}</a></td>
<td>{{ network.StackName ? network.StackName : '-' }}</td>
<td>{{ network.Scope }}</td>
<td>{{ network.Driver }}</td>
<td>{{ network.IPAM.Driver }}</td>
<td>{{ network.IPAM.Config[0].Subnet ? network.IPAM.Config[0].Subnet : '-' }}</td>
<td>{{ network.IPAM.Config[0].Gateway ? network.IPAM.Config[0].Gateway : '-' }}</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="network.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ network.ResourceControl.Ownership ? network.ResourceControl.Ownership : network.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!networks">
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="networks.length == 0">
<td colspan="9" class="text-center text-muted">No networks available.</td>
</tr>
</tbody>
</table>
<div ng-if="networks" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -1,67 +0,0 @@
angular.module('networks', [])
.controller('NetworksController', ['$scope', '$state', 'Network', 'NetworkService', 'Notifications', 'Pagination',
function ($scope, $state, Network, NetworkService, Notifications, Pagination) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('networks');
$scope.state.selectedItemCount = 0;
$scope.state.advancedSettings = false;
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('networks', $scope.state.pagination_count);
};
$scope.order = function(sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItems = function(allSelected) {
angular.forEach($scope.state.filteredNetworks, function (network) {
if (network.Checked !== allSelected) {
network.Checked = allSelected;
$scope.selectItem(network);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function () {
angular.forEach($scope.networks, function (network) {
if (network.Checked) {
Network.remove({id: network.Id}, function (d) {
if (d.message) {
Notifications.error('Error', d, 'Unable to remove network');
} else {
Notifications.success('Network removed', network.Id);
var index = $scope.networks.indexOf(network);
$scope.networks.splice(index, 1);
}
}, function (e) {
Notifications.error('Failure', e, 'Unable to remove network');
});
}
});
};
function initView() {
NetworkService.networks(true, true, true, true)
.then(function success(data) {
$scope.networks = data;
})
.catch(function error(err) {
$scope.networks = [];
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
}
initView();
}]);
-151
View File
@@ -1,151 +0,0 @@
<rd-header>
<rd-header-title title="Registries">
<a data-toggle="tooltip" title="Refresh" ui-sref="registries" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Registry management</rd-header-content>
</rd-header>
<div class="row" ng-if="dockerhub">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-database" title="DockerHub">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- note -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
The DockerHub registry can be used by any user. You can specify the credentials that will be used to push &amp; pull images here.
</span>
</div>
<!-- !note -->
<!-- authentication-checkbox -->
<div class="form-group">
<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 push/pull private images."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="dockerhub.Authentication"><i></i>
</label>
</div>
</div>
<!-- !authentication-checkbox -->
<!-- authentication-credentials -->
<div ng-if="dockerhub.Authentication">
<!-- credentials-user -->
<div class="form-group">
<label for="hub_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="hub_username" ng-model="dockerhub.Username">
</div>
</div>
<!-- !credentials-user -->
<!-- credentials-password -->
<div class="form-group">
<label for="hub_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="hub_password" ng-model="dockerhub.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="state.actionInProgress || dockerhub.Authentication && (!dockerhub.Username || !dockerhub.Password)" ng-click="updateDockerHub()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress">Update</span>
<span ng-show="state.actionInProgress">Updating DockerHub settings...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-database" title="Available registries">
<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-taskbar classes="col-lg-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.registry"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add registry</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="registries" ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="registries" ng-click="order('URL')">
URL
<span ng-show="sortType == 'URL' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'URL' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th></th>
</tr>
</thead>
<tbody>
<tr dir-paginate="registry in (state.filteredRegistries = (registries | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="registry.Checked" ng-change="selectItem(registry)" /></td>
<td>
<a ui-sref="registry({id: registry.Id})">{{ registry.Name }}</a>
<span ng-if="registry.Authentication" style="margin-left: 5px;">
<i class="fa fa-shield" aria-hidden="true" tooltip-placement="bottom" tooltip-class="portainer-tooltip" uib-tooltip="Authentication is enabled for this registry."></i>
</span>
</td>
<td>{{ registry.URL }}</td>
<td>
<span ng-if="applicationState.application.authentication">
<a ui-sref="registry.access({id: registry.Id})"><i class="fa fa-users" aria-hidden="true" style="margin-left: 7px;"></i> Manage access</a>
</span>
</td>
</tr>
<tr ng-if="!registries">
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="registries.length == 0">
<td colspan="4" class="text-center text-muted">No registries available.</td>
</tr>
</tbody>
</table>
<div ng-if="registries" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -1,97 +0,0 @@
angular.module('registries', [])
.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', 'Pagination',
function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications, Pagination) {
$scope.state = {
selectedItemCount: 0,
pagination_count: Pagination.getPaginationCount('registries'),
actionInProgress: false
};
$scope.sortType = 'Name';
$scope.sortReverse = true;
$scope.updateDockerHub = function() {
var dockerhub = $scope.dockerhub;
$scope.state.actionInProgress = true;
DockerHubService.update(dockerhub)
.then(function success(data) {
Notifications.success('DockerHub registry updated');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update DockerHub details');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
$scope.order = function(sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('endpoints', $scope.state.pagination_count);
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredRegistries, function (registry) {
if (registry.Checked !== allSelected) {
registry.Checked = allSelected;
$scope.selectItem(registry);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function() {
ModalService.confirmDeletion(
'Do you want to remove the selected registries?',
function onConfirm(confirmed) {
if(!confirmed) { return; }
removeRegistries();
}
);
};
function removeRegistries() {
var registries = $scope.registries;
angular.forEach(registries, function (registry) {
if (registry.Checked) {
RegistryService.deleteRegistry(registry.Id)
.then(function success(data) {
var index = registries.indexOf(registry);
registries.splice(index, 1);
Notifications.success('Registry deleted', registry.Name);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove registry');
});
}
});
}
function initView() {
$q.all({
registries: RegistryService.registries(),
dockerhub: DockerHubService.dockerhub()
})
.then(function success(data) {
$scope.registries = data.registries;
$scope.dockerhub = data.dockerhub;
})
.catch(function error(err) {
$scope.registries = [];
Notifications.error('Failure', err, 'Unable to retrieve registries');
});
}
initView();
}]);
-80
View File
@@ -1,80 +0,0 @@
<rd-header>
<rd-header-title title="Secrets list">
<a data-toggle="tooltip" title="Refresh" ui-sref="secrets" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Secrets</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-user-secret" title="Secrets">
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.secret"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add secret</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('CreatedAt')">
Created at
<span ng-show="sortType == 'CreatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CreatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a 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>
</thead>
<tbody>
<tr dir-paginate="secret in (state.filteredSecrets = ( secrets | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: pagination_count))">
<td><input type="checkbox" ng-model="secret.Checked" ng-change="selectItem(secret)"/></td>
<td><a ui-sref="secret({id: secret.Id})">{{ secret.Name }}</a></td>
<td>{{ secret.CreatedAt | getisodate }}</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="secret.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ secret.ResourceControl.Ownership ? secret.ResourceControl.Ownership : secret.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!secrets">
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="secrets.length == 0">
<td colspan="4" class="text-center text-muted">No secrets available.</td>
</tr>
</tbody>
</table>
<div ng-if="secrets" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -1,60 +0,0 @@
angular.module('secrets', [])
.controller('SecretsController', ['$scope', '$state', 'SecretService', 'Notifications', 'Pagination',
function ($scope, $state, SecretService, Notifications, Pagination) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('secrets');
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredSecrets, function (secret) {
if (secret.Checked !== allSelected) {
secret.Checked = allSelected;
$scope.selectItem(secret);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function () {
angular.forEach($scope.secrets, function (secret) {
if (secret.Checked) {
SecretService.remove(secret.Id)
.then(function success() {
Notifications.success('Secret deleted', secret.Id);
var index = $scope.secrets.indexOf(secret);
$scope.secrets.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove secret');
});
}
});
};
function initView() {
SecretService.secrets()
.then(function success(data) {
$scope.secrets = data;
})
.catch(function error(err) {
$scope.secrets = [];
Notifications.error('Failure', err, 'Unable to retrieve secrets');
});
}
initView();
}]);
@@ -1,71 +0,0 @@
<div ng-if="tasks.length > 0 && nodes" id="service-tasks">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Associated tasks">
<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-taskbar classes="col-lg-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>
<a ng-click="order('Status.State')">
Status
<span ng-show="sortType == 'Status.State' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status.State' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="service.Mode !== 'global'">
<a ng-click="order('Slot')">
Slot
<span ng-show="sortType == 'Slot' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Slot' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('NodeId')">
Node
<span ng-show="sortType == 'NodeId' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'NodeId' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Updated')">
Last update
<span ng-show="sortType == 'Updated' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Updated' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="task in (filteredTasks = ( tasks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><a ui-sref="task({ id: task.Id })" class="monospaced">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State|taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td ng-if="service.Mode !== 'global'">{{ task.Slot }}</td>
<td>{{ task.NodeId | tasknodename: nodes }}</td>
<td>{{ task.Updated | getisodate }}</td>
</tr>
</tbody>
</table>
<div ng-if="tasks" class="pagination-controls" >
<dir-pagination-controls></dir-pagination-controls>
</div>
</rd-widget-body>
</rd-widget>
</div>
@@ -1,78 +0,0 @@
angular.module('serviceLogs', [])
.controller('ServiceLogsController', ['$scope', '$transition$', '$anchorScroll', 'ServiceLogs', 'Service',
function ($scope, $transition$, $anchorScroll, ServiceLogs, Service) {
$scope.state = {};
$scope.state.displayTimestampsOut = false;
$scope.state.displayTimestampsErr = false;
$scope.stdout = '';
$scope.stderr = '';
$scope.tailLines = 2000;
function getLogs() {
getLogsStdout();
getLogsStderr();
}
function getLogsStderr() {
ServiceLogs.get($transition$.params().id, {
stdout: 0,
stderr: 1,
timestamps: $scope.state.displayTimestampsErr,
tail: $scope.tailLines
}, function (data, status, headers, config) {
// Replace carriage returns with newlines to clean up output
data = data.replace(/[\r]/g, '\n');
// Strip 8 byte header from each line of output
data = data.substring(8);
data = data.replace(/\n(.{8})/g, '\n');
$scope.stderr = data;
});
}
function getLogsStdout() {
ServiceLogs.get($transition$.params().id, {
stdout: 1,
stderr: 0,
timestamps: $scope.state.displayTimestampsOut,
tail: $scope.tailLines
}, function (data, status, headers, config) {
// Replace carriage returns with newlines to clean up output
data = data.replace(/[\r]/g, '\n');
// Strip 8 byte header from each line of output
data = data.substring(8);
data = data.replace(/\n(.{8})/g, '\n');
$scope.stdout = data;
});
}
function getService() {
Service.get({id: $transition$.params().id}, function (d) {
$scope.service = d;
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve service info');
});
}
function initView() {
getService();
getLogs();
var logIntervalId = window.setInterval(getLogs, 5000);
$scope.$on('$destroy', function () {
// clearing interval when view changes
clearInterval(logIntervalId);
});
$scope.toggleTimestampsOut = function () {
getLogsStdout();
};
$scope.toggleTimestampsErr = function () {
getLogsStderr();
};
}
initView();
}]);
@@ -1,54 +0,0 @@
<rd-header>
<rd-header-title title="Service logs"></rd-header-title>
<rd-header-content>
<a ui-sref="services">Services</a> > <a ui-sref="service({id: service.ID})">{{ service.Spec.Name }}</a> > Logs
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<div class="widget-icon grey pull-left">
<i class="fa fa-list-alt"></i>
</div>
<div class="title">{{ service.Spec.Name }}</div>
<div class="comment">Name</div>
</rd-widget-body>
</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-info-circle" title="Stdout logs"></rd-widget-header>
<rd-widget-taskbar>
<input type="checkbox" ng-model="state.displayTimestampsOut" id="displayAllTsOut" ng-change="toggleTimestampsOut()"/>
<label for="displayAllTsOut">Display timestamps</label>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="panel-body">
<pre id="stdoutLog" class="pre-scrollable pre-x-scrollable">{{stdout}}</pre>
</div>
</rd-widget-body>
</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-exclamation-triangle" title="Stderr logs"></rd-widget-header>
<rd-widget-taskbar>
<input type="checkbox" ng-model="state.displayTimestampsErr" id="displayAllTsErr" ng-change="toggleTimestampsErr()"/>
<label for="displayAllTsErr">Display timestamps</label>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="panel-body">
<pre id="stderrLog" class="pre-scrollable pre-x-scrollable">{{stderr}}</pre>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
-140
View File
@@ -1,140 +0,0 @@
<rd-header>
<rd-header-title title="Service list">
<a data-toggle="tooltip" title="Refresh" ui-sref="services" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Services</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Services">
<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-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.service"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add service</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<th></th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Mode')">
Scheduling mode
<span ng-show="sortType == 'Mode' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Mode' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('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>
<a ng-click="order('UpdatedAt')">
Updated at
<span ng-show="sortType == 'UpdatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'UpdatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a 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>
</thead>
<tbody>
<tr dir-paginate="service in (state.filteredServices = ( services | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="service.Checked" ng-change="selectItem(service)"/></td>
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.StackName ? service.StackName : '-' }}</td>
<td>{{ service.Image | hideshasum }}</td>
<td>
{{ service.Mode }}
<code data-toggle="tooltip" title="Replicas">{{ service.Running }}</code>
/
<code data-toggle="tooltip" title="Replicas">{{ service.Replicas }}</code>
<span ng-if="service.Mode === 'replicated' && !service.Scale">
<a class="interactive" ng-click="service.Scale = true; service.ReplicaCount = service.Replicas;"><i class="fa fa-arrows-v" aria-hidden="true"></i> Scale</a>
</span>
<span ng-if="service.Mode === 'replicated' && service.Scale">
<input class="input-sm" type="number" ng-model="service.Replicas" />
<a class="interactive" ng-click="service.Scale = false;"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="scaleService(service)"><i class="fa fa-check-square-o"></i></a>
</span>
</td>
<td>
<a ng-if="service.Ports && service.Ports.length > 0 && swarmManagerIP" ng-repeat="p in service.Ports" class="image-tag" ng-href="http://{{swarmManagerIP}}:{{p.PublishedPort}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.PublishedPort }}:{{ p.TargetPort }}
</a>
<span ng-if="!service.Ports || service.Ports.length === 0 || !swarmManagerIP" >-</span>
</td>
<td>
{{ service.UpdatedAt|getisodate }}
</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="service.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ service.ResourceControl.Ownership ? service.ResourceControl.Ownership : service.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!services">
<td colspan="7" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="services.length == 0">
<td colspan="7" class="text-center text-muted">No services available.</td>
</tr>
</tbody>
</table>
<div ng-if="services" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -1,109 +0,0 @@
angular.module('services', [])
.controller('ServicesController', ['$q', '$scope', '$transition$', '$state', 'Service', 'ServiceService', 'ServiceHelper', 'Notifications', 'Pagination', 'Task', 'Node', 'NodeHelper', 'ModalService', 'ResourceControlService',
function ($q, $scope, $transition$, $state, Service, ServiceService, ServiceHelper, Notifications, Pagination, Task, Node, NodeHelper, ModalService, ResourceControlService) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('services');
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('services', $scope.state.pagination_count);
};
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.scaleService = function scaleService(service) {
var config = ServiceHelper.serviceToConfig(service.Model);
config.Mode.Replicated.Replicas = service.Replicas;
Service.update({ id: service.Id, version: service.Version }, config, function (data) {
Notifications.success('Service successfully scaled', 'New replica count: ' + service.Replicas);
$state.reload();
}, function (e) {
service.Scale = false;
service.Replicas = service.ReplicaCount;
Notifications.error('Failure', e, 'Unable to scale service');
});
};
$scope.removeAction = function() {
ModalService.confirmDeletion(
'Do you want to remove the selected service(s)? All the containers associated to the selected service(s) will be removed too.',
function onConfirm(confirmed) {
if(!confirmed) { return; }
removeServices();
}
);
};
function removeServices() {
angular.forEach($scope.services, function (service) {
if (service.Checked) {
ServiceService.remove(service)
.then(function success(data) {
Notifications.success('Service successfully deleted');
var index = $scope.services.indexOf(service);
$scope.services.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove service');
});
}
});
}
function mapUsersToServices(users) {
angular.forEach($scope.services, function (service) {
if (service.Metadata) {
var serviceRC = service.Metadata.ResourceControl;
if (serviceRC && serviceRC.OwnerId !== $scope.user.ID) {
angular.forEach(users, function (user) {
if (serviceRC.OwnerId === user.Id) {
service.Owner = user.Username;
}
});
}
}
});
}
function initView() {
$q.all({
services: Service.query({}).$promise,
tasks: Task.query({filters: {'desired-state': ['running','accepted']}}).$promise,
nodes: Node.query({}).$promise
})
.then(function success(data) {
$scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes);
$scope.services = data.services.map(function (service) {
var runningTasks = data.tasks.filter(function (task) {
return task.ServiceID === service.ID && task.Status.State === 'running';
});
var allTasks = data.tasks.filter(function (task) {
return task.ServiceID === service.ID;
});
var taskNodes = data.nodes.filter(function (node) {
return node.Spec.Availability === 'active' && node.Status.State === 'ready';
});
return new ServiceViewModel(service, runningTasks, allTasks, taskNodes);
});
})
.catch(function error(err) {
$scope.services = [];
Notifications.error('Failure', err, 'Unable to retrieve services');
});
}
initView();
}]);
-90
View File
@@ -1,90 +0,0 @@
<!-- Sidebar -->
<div id="sidebar-wrapper">
<div class="sidebar-header">
<a ng-click="toggleSidebar()" class="interactive">
<img ng-if="logo" ng-src="{{ logo }}" class="img-responsive logo">
<img ng-if="!logo" src="images/logo.png" class="img-responsive logo" alt="Portainer">
<span class="menu-icon glyphicon glyphicon-transfer"></span>
</a>
</div>
<div class="sidebar-content">
<ul class="sidebar">
<li class="sidebar-title">
<span>Active endpoint</span>
</li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="endpoint.Name for endpoint in endpoints" ng-model="activeEndpoint" ng-change="switchEndpoint(activeEndpoint)">
</select>
</li>
<li class="sidebar-title"><span>Endpoint actions</span></li>
<li class="sidebar-list">
<a ui-sref="dashboard" ui-sref-active="active">Dashboard <span class="menu-icon fa fa-tachometer"></span></a>
</li>
<li class="sidebar-list">
<a ui-sref="templates" ui-sref-active="active">App Templates <span class="menu-icon fa fa-rocket"></span></a>
<div class="sidebar-sublist" ng-if="toggle && displayExternalContributors && ($state.current.name === 'templates' || $state.current.name === 'templates_linuxserver')">
<a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a>
</div>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a>
</li>
<li class="sidebar-list">
<a ui-sref="containers" ui-sref-active="active">Containers <span class="menu-icon fa fa-server"></span></a>
</li>
<li class="sidebar-list">
<a ui-sref="images" ui-sref-active="active">Images <span class="menu-icon fa fa-clone"></span></a>
</li>
<li class="sidebar-list">
<a ui-sref="networks" ui-sref-active="active">Networks <span class="menu-icon fa fa-sitemap"></span></a>
</li>
<li class="sidebar-list">
<a ui-sref="volumes" ui-sref-active="active">Volumes <span class="menu-icon fa fa-cubes"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.30 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="configs" ui-sref-active="active">Configs <span class="menu-icon fa fa-file-code-o"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="secrets" ui-sref-active="active">Secrets <span class="menu-icon fa fa-user-secret"></span></a>
</li>
<li class="sidebar-list" ng-if="(applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC') && isAdmin">
<a ui-sref="events" ui-sref-active="active">Events <span class="menu-icon fa fa-history"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || (applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER')">
<a ui-sref="swarm" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
<a ui-sref="engine" ui-sref-active="active">Engine <span class="menu-icon fa fa-th"></span></a>
</li>
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin || isTeamLeader">
<span>Portainer settings</span>
</li>
<li class="sidebar-list" ng-if="applicationState.application.authentication && (isAdmin || isTeamLeader)">
<a ui-sref="users" ui-sref-active="active">User management <span class="menu-icon fa fa-users"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'users' || $state.current.name === 'user' || $state.current.name === 'teams' || $state.current.name === 'team')">
<a ui-sref="teams" ui-sref-active="active">Teams</a>
</div>
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="endpoints" ui-sref-active="active">Endpoints <span class="menu-icon fa fa-plug"></span></a>
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="registries" ui-sref-active="active">Registries <span class="menu-icon fa fa-database"></span></a>
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="settings" ui-sref-active="active">Settings <span class="menu-icon fa fa-cogs"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'settings' || $state.current.name === 'settings_authentication') && applicationState.application.authentication && isAdmin">
<a ui-sref="settings_authentication" ui-sref-active="active">Authentication</a>
</div>
</li>
</ul>
<div class="sidebar-footer-content">
<img src="images/logo_small.png" class="img-responsive logo" alt="Portainer">
<span class="version">{{ uiVersion }}</span>
</div>
</div>
</div>
<!-- End Sidebar -->
@@ -1,71 +0,0 @@
angular.module('sidebar', [])
.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService',
function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService) {
$scope.uiVersion = StateManager.getState().application.version;
$scope.displayExternalContributors = StateManager.getState().application.displayExternalContributors;
$scope.logo = StateManager.getState().application.logo;
$scope.endpoints = [];
$scope.switchEndpoint = function(endpoint) {
var activeEndpointID = EndpointProvider.endpointID();
var activeEndpointPublicURL = EndpointProvider.endpointPublicURL();
EndpointProvider.setEndpointID(endpoint.Id);
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
StateManager.updateEndpointState(true)
.then(function success() {
$state.go('dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
EndpointProvider.setEndpointID(activeEndpointID);
EndpointProvider.setEndpointPublicURL(activeEndpointPublicURL);
StateManager.updateEndpointState(true)
.then(function success() {});
});
};
function setActiveEndpoint(endpoints) {
var activeEndpointID = EndpointProvider.endpointID();
angular.forEach(endpoints, function (endpoint) {
if (endpoint.Id === activeEndpointID) {
$scope.activeEndpoint = endpoint;
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
}
});
}
function checkPermissions(memberships) {
var isLeader = false;
angular.forEach(memberships, function(membership) {
if (membership.Role === 1) {
isLeader = true;
}
});
$scope.isTeamLeader = isLeader;
}
function initView() {
EndpointService.endpoints()
.then(function success(data) {
var endpoints = data;
$scope.endpoints = _.sortBy(endpoints, ['Name']);
setActiveEndpoint(endpoints);
if (StateManager.getState().application.authentication) {
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true: false;
$scope.isAdmin = isAdmin;
return $q.when(!isAdmin ? UserService.userMemberships(userDetails.ID) : []);
}
})
.then(function success(data) {
checkPermissions(data);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
});
}
initView();
}]);

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