├── .github
└── workflows
│ ├── clabot.yml
│ └── test-driver.yml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── Vagrantfile
├── examples
├── iis-test-classic.nomad
└── iis-test.nomad
├── go.mod
├── go.sum
├── iis
├── driver.go
├── handle.go
├── iis.go
├── iis_test.go
└── state.go
├── main.go
├── scripts
└── win_provision.ps1
└── test
├── test.pfx
├── testapppool.xml
├── testsite.xml
└── win_client.hcl
/.github/workflows/clabot.yml:
--------------------------------------------------------------------------------
1 | name: "CLA Signature Bot"
2 | on:
3 | issue_comment:
4 | types: [created]
5 | pull_request:
6 | types: [opened,closed,synchronize]
7 |
8 | jobs:
9 | call-clabot-workflow:
10 | uses: Roblox/cla-signature-bot/.github/workflows/clabot-workflow.yml@master
11 | with:
12 | whitelist: "shishir-a412ed,chuckyz,vulfox,cliffchapmanrbx"
13 | use-remote-repo: true
14 | remote-repo-name: "roblox/cla-bot-store"
15 | secrets: inherit
16 |
--------------------------------------------------------------------------------
/.github/workflows/test-driver.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | branches: [ master ]
6 | # Allows you to run this workflow manually from the Actions tab
7 | workflow_dispatch:
8 |
9 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
10 | jobs:
11 | build:
12 | runs-on: windows-2016
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Install Prerequisites (PFX, IIS)
16 | run: Import-PfxCertificate -FilePath .\test\test.pfx -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String 'Test123!' -AsPlainText -Force)
17 | shell: powershell
18 | - name: Run iis-driver integration tests
19 | run: go test ./iis/ -count=1 -v
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vagrant/
2 | *.exe
3 | *.log
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PLUGIN_BINARY=win_iis.exe
2 | export GO111MODULE=on
3 | export GOOS=windows
4 |
5 | ifeq ($(OS),Windows_NT)
6 | RMCMD = del /f
7 | else
8 | RMCMD = rm -f
9 | endif
10 |
11 | default: build
12 |
13 | .PHONY: clean
14 | clean:
15 | ${RMCMD} ${PLUGIN_BINARY}
16 | vagrant destroy -f
17 |
18 | build:
19 | go build -o ${PLUGIN_BINARY} .
20 |
21 | up:
22 | vagrant up
23 |
24 | converge: build up
25 | vagrant provision
26 |
27 | test: converge
28 | vagrant winrm -s cmd -c 'chdir C:\vagrant && go test ./iis/ -count=1 -v'
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Nomad IIS Driver
2 | [](https://github.com/Roblox/nomad-driver-iis/actions)
3 | [](https://github.com/Roblox/nomad-driver-iis/blob/master/LICENSE)
4 | [](https://github.com/Roblox/nomad-driver-iis/releases/tag/v0.1.0)
5 |
6 | A driver plugin for nomad to orchestrate windows IIS website tasks.
7 |
8 | A "Website" is a combination of an application pool and a site (app, vdir, etc.).
9 | Each allocation will create an application pool and site with the name being the allocation ID (guid).
10 |
11 | This driver is heavily tested on Windows 2016+ and does not guarantee compatibility with older versions of Windows/IIS. IIS version and Windows versions are locked together and can be seen [here](https://en.wikipedia.org/wiki/Internet_Information_Services). Each feature/config has a minimum IIS version associated so that one can dance around them to have nomad and the IIS driver work on an older machine. The easiest way to utilize unique features for a given IIS version should be to use the application pool and site config imports provided by the task config.
12 |
13 | ---
14 | ## Configuration
15 | ### **Driver Config**
16 |
17 | | Option | Type | Required | Default | Description |
18 | | :---: | :---: | :---: | :---: | :--- |
19 | | **enabled** | bool | no | true | Enable/Disable task driver. |
20 | | **stats_interval** | string | no | 1s | Interval for collecting `TaskStats` |
21 |
22 | ### **Task Config**
23 | | Option | Type | Required | Default | Min. IIS Version | Description |
24 | | :---: | :---: | :---: | :---: | :---: | :--- |
25 | | **path** | string | yes | nil | 6.0 | Path to IIS Compatible website directory. |
26 | | **apppool_config_path** | string | no | nil | 6.0 | Path to App Pool XML Configuration File. |
27 | | **site_config_path** | string | no | nil | 6.0 | Path to Site XML Configuration File. |
28 | | **apppool_identity** | string | no | ApplicationPoolIdentity | 6.0 | Application Pool Identity e.g. ('SpecificUser', 'ApplicationPoolIdentity', etc..) |
29 | | **bindings** | block list | no | nil | 7.0 | This is needed to tie IIS Bindings to Nomad's `resources`->`network` ports to IIS as well as specify IIS Binding specific settings |
30 | ### **Bindings Block Config**
31 | A `resource_port` OR a `port` must be provided. Due to a current limitation, we can not force at least 1 required option between multiple options. There are plans to revisit combining port options to improve UX.
32 |
33 | | Option | Type | Required | Default | Min. IIS Version | Description |
34 | | :---: | :---: | :---: | :---: | :---: | :--- |
35 | | **hostname** | string | no | nil | 7.0 | HostName attribute for a given binding. |
36 | | **ipaddress** | string | no | * | 7.0 | IPAddress attribute for a given binding. |
37 | | **port** | string | yes | 0 | 7.0 | Tie a `resources`->`network` port label to the binding. This allows us to use a dynamic port given by Nomad. |
38 | | **type** | string | yes | nil | 7.0 | Type is the binding's protocol e.g. ('http', 'https', etc..) |
39 | | **cert_hash** | string | no | nil | 7.0 | Hash of a cert that exists prior to nomad allocating an IIS website. This **must** be set for SSL bindings. |
40 | For more info on IIS Bindings, you can go [here](https://docs.microsoft.com/en-us/iis/configuration/system.applicationhost/sites/site/bindings/binding)
41 |
42 | ### **Environment Variables**
43 | Environment variables can be set like any other Nomad task via `env` or `template` stanzas. Environment variables are only supported for IIS 10.0+.
44 |
45 | ### Meta Environment Variables
46 | These meta env vars do not persist to the process/task. They are pulled from the env var list that is passed to the IIS application pool. These do not require a minimal IIS version as they are not used as env vars by IIS.
47 |
48 | - `NOMAD_APPPOOL_USERNAME`
49 | - Sets the UserName of an application pool and will override the Application Pool Identity to `SpecificUser`
50 | - `NOMAD_APPPOOL_PASSWORD`
51 | - Sets the Password of an application pool's specific user account
52 |
53 | ### Why meta env vars?
54 | Nomad currently doesn't have a clean way to use credentials to be used by the nomad job spec itself. To get around this and not provide the user/pass credentials in plain text on the nomad job spec is to have them passed by env vars and this allows users to utilize consul/vault via the `template` stanza to promote better security. Here is the respective Nomad [issue](https://github.com/hashicorp/nomad/issues/3854).
55 |
56 | ---
57 | ## Build & Test
58 | ### **Requirements**
59 |
60 | - [Nomad](https://www.nomadproject.io/downloads.html) >=v1.0
61 | - The driver will try to be backwards compatible with v0.11-v1.0 versions of Nomad, but tests will focus around v1.0 for ensuring stability with modern Nomad.
62 | - [Go](https://golang.org/doc/install) >=v1.15 (to build the provider plugin)
63 | - [Vagrant](https://www.vagrantup.com/downloads.html) >=v2.2
64 | - [VirtualBox](https://www.virtualbox.org/) v6.0 (or any version vagrant is compatible with)
65 |
66 | ### **Building the driver**
67 |
68 | ````
69 | $ mkdir -p $GOPATH/src/github.com/Roblox
70 | $ cd $GOPATH/src/github.com/Roblox
71 | $ git clone git@github.com:Roblox/nomad-driver-iis.git
72 | $ cd nomad-driver-iis
73 | $ make build (This will build your nomad-driver-iis executable)
74 | ````
75 |
76 | ### **Tests**
77 | ````
78 | $ make test
79 | ````
80 | This will run nomad-driver-iis tests in the provisioned vagrant VM.
81 |
82 | ### **Cleanup**
83 | ````
84 | make clean
85 | ````
86 | This will destroy your vagrant VM (along with all your changes) and remove the executable (win_iis.exe).
87 |
88 | ---
89 | ## Contributing
90 |
91 | Want to fix a bug, update documentation or add a feature?
92 | PR's are welcome!!
93 | Test your changes locally before contributing.
94 |
95 | The easiest way to test your changes is `make converge`.
96 | `make converge` will:
97 |
98 | 1) Build the executable (win_iis.exe)
99 | 2) Spin up a vagrant VM (`vagrant up`) if it's not already running.
100 | 3) Provision your changes into the VM (`vagrant provision`)
101 |
102 | Once you are in the VM:
103 |
104 | 1) nomad-driver-iis codebase (hostpath) is mounted at `C:\vagrant` in the VM.
105 | 2) Plugin (executable) is available at `C:\ProgramData\nomad\plugin`
106 | 3) Logs are available at `C:\ProgramData\nomad\logs`.
107 | 4) Tail on logs in powershell:
108 | ````
109 | $ Get-Content -path "C:\ProgramData\nomad\logs\nomad-output.log" -wait
110 | ````
111 | 5) Launch an example IIS website:
112 | ````
113 | $ nomad job run C:\vagrant\examples\iis-test.nomad
114 | ````
115 |
116 | ---
117 | ## License
118 |
119 | Copyright 2020 Roblox Corporation
120 |
121 | Licensed under the Apache License, Version 2.0 (the "License"). For more information read the [License](LICENSE).
122 |
--------------------------------------------------------------------------------
/Vagrantfile:
--------------------------------------------------------------------------------
1 | # Specify minimum Vagrant version and Vagrant API version
2 | Vagrant.require_version ">= 1.6.0"
3 | VAGRANTFILE_API_VERSION = "2"
4 |
5 | # Create boxes
6 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
7 | config.vm.define "nomad-dev-win" do |ncw|
8 | ncw.vm.hostname = "nomad-dev-win"
9 | ncw.vm.box = "tas50/windows_2016"
10 | ncw.vm.network "private_network", ip: "172.17.8.101"
11 | ncw.vm.provision "shell", path: "scripts/win_provision.ps1"
12 | ncw.vm.provider :virtualbox do |vb|
13 | vb.name = "nomad-dev-win"
14 | # The VM has a really bad time working off of 2GB of RAM, bump to 4GB
15 | vb.memory = 4096
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/examples/iis-test-classic.nomad:
--------------------------------------------------------------------------------
1 | job "iis-test-classic" {
2 | datacenters = ["dc1"]
3 | type = "service"
4 |
5 | group "iis-test" {
6 | count = 1
7 | restart {
8 | attempts = 10
9 | interval = "5m"
10 | delay = "25s"
11 | mode = "delay"
12 | }
13 | task "iis-test" {
14 | driver = "win_iis"
15 |
16 | config {
17 | path = "C:\\inetpub\\wwwroot"
18 | bindings {
19 | type = "http"
20 | port = "httplabel"
21 | }
22 | }
23 |
24 | template {
25 | data = < 0 {
112 | if h.taskConfig.Resources.Ports != nil {
113 | // parse group/shared resource ports. This is the preferred route for establishing network ports
114 | // here is the relevant PR for the docker driver that drove this change: https://github.com/hashicorp/nomad/pull/8623
115 |
116 | for _, binding := range driverConfig.Bindings {
117 | if port, ok := h.taskConfig.Resources.Ports.Get(binding.PortLabel); ok {
118 | binding.Port = port.Value
119 | iisBindings = append(iisBindings, binding)
120 | } else {
121 | errMsg := fmt.Sprintf("Port %s not found, check network stanza", binding.PortLabel)
122 | h.handleError(errMsg, errors.New(errMsg))
123 | return
124 | }
125 | }
126 | } else if len(h.taskConfig.Resources.NomadResources.Networks) > 0 {
127 | // parses a task's network stanza for dynamic/static ports
128 | // this is deprecated as of Nomad v1.0+, in time this should be removed
129 | // just like the docker driver, you can only work with one network stanza format over another
130 |
131 | for _, binding := range driverConfig.Bindings {
132 | foundPort := false
133 | for _, network := range h.taskConfig.Resources.NomadResources.Networks {
134 |
135 | for _, port := range network.ReservedPorts {
136 | binding.Port = port.Value
137 | iisBindings = append(iisBindings, binding)
138 | foundPort = true
139 | }
140 |
141 | for _, port := range network.DynamicPorts {
142 | binding.Port = port.Value
143 | iisBindings = append(iisBindings, binding)
144 | foundPort = true
145 | }
146 | }
147 | if !foundPort {
148 | errMsg := fmt.Sprintf("Port %s not found, check network stanza", binding.PortLabel)
149 | h.handleError(errMsg, errors.New(errMsg))
150 | return
151 | }
152 | }
153 | }
154 | }
155 |
156 | websiteConfig.Bindings = iisBindings
157 |
158 | if err := createWebsite(&websiteConfig); err != nil {
159 | errMsg := fmt.Sprintf("Error in creating website: %v", err)
160 | h.handleError(errMsg, err)
161 | return
162 | }
163 |
164 | if err := startWebsite(websiteConfig.Name); err != nil {
165 | errMsg := fmt.Sprintf("Error in starting website: %v", err)
166 | h.handleError(errMsg, err)
167 | return
168 | }
169 |
170 | h.websiteStarted = true
171 | }
172 |
173 | // handleError will log the error message (errMsg) and update the task handle with exit results.
174 | func (h *taskHandle) handleError(errMsg string, err error) {
175 | h.logger.Error(errMsg)
176 | h.exitResult.Err = err
177 | h.procState = drivers.TaskStateUnknown
178 | h.completedAt = time.Now()
179 | }
180 |
181 | func (h *taskHandle) Stats(ctx context.Context, interval time.Duration) (<-chan *drivers.TaskResourceUsage, error) {
182 | ch := make(chan *drivers.TaskResourceUsage)
183 | go h.handleStats(ch, ctx, interval)
184 |
185 | return ch, nil
186 | }
187 |
188 | func (h *taskHandle) handleStats(ch chan *drivers.TaskResourceUsage, ctx context.Context, interval time.Duration) {
189 | defer close(ch)
190 | timer := time.NewTimer(0)
191 | for {
192 | select {
193 | case <-ctx.Done():
194 | return
195 |
196 | case <-timer.C:
197 | timer.Reset(interval)
198 | }
199 |
200 | // Get IIS Worker Process stats if we can.
201 | stats, err := getWebsiteStats(h.taskConfig.AllocID)
202 | if err != nil {
203 | h.logger.Error("Failed to get iis worker process stats:", "error", err)
204 | return
205 | }
206 |
207 | select {
208 | case <-ctx.Done():
209 | return
210 | case ch <- h.getTaskResourceUsage(stats):
211 | }
212 | }
213 | }
214 |
215 | // Convert IIS WMI Tasks Info to driver TaskResourceUsage expected input
216 | func (h *taskHandle) getTaskResourceUsage(stats *wmiProcessStats) *drivers.TaskResourceUsage {
217 | totalPercent := h.totalCpuStats.Percent(float64(stats.KernelModeTime + stats.UserModeTime))
218 | cs := &drivers.CpuStats{
219 | SystemMode: h.systemCpuStats.Percent(float64(stats.KernelModeTime)),
220 | UserMode: h.userCpuStats.Percent(float64(stats.UserModeTime)),
221 | Percent: totalPercent,
222 | Measured: []string{"Percent", "System Mode", "User Mode"},
223 | TotalTicks: h.totalCpuStats.TicksConsumed(totalPercent),
224 | }
225 |
226 | ms := &drivers.MemoryStats{
227 | RSS: stats.WorkingSetPrivate,
228 | Measured: []string{"RSS"},
229 | }
230 |
231 | ts := time.Now().UTC().UnixNano()
232 | return &drivers.TaskResourceUsage{
233 | ResourceUsage: &drivers.ResourceUsage{
234 | CpuStats: cs,
235 | MemoryStats: ms,
236 | },
237 | Timestamp: ts,
238 | }
239 | }
240 |
241 | func (h *taskHandle) shutdown(timeout time.Duration) error {
242 | h.stateLock.Lock()
243 | defer h.stateLock.Unlock()
244 |
245 | if err := stopWebsite(h.taskConfig.AllocID); err != nil {
246 | return err
247 | }
248 |
249 | // Sleep for timeout duration to allow stopWebsite to finish gracefully.
250 | time.Sleep(timeout)
251 |
252 | return nil
253 | }
254 |
255 | func (h *taskHandle) cleanup() error {
256 | err := deleteWebsite(h.taskConfig.AllocID)
257 | if err != nil {
258 | return fmt.Errorf("Error in destroying website: %v", err)
259 | }
260 |
261 | return nil
262 | }
263 |
--------------------------------------------------------------------------------
/iis/iis.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2020 Roblox Corporation
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 | */
17 |
18 | package iis
19 |
20 | import (
21 | "bufio"
22 | "bytes"
23 | "encoding/xml"
24 | "fmt"
25 | "os/exec"
26 | "regexp"
27 | "strconv"
28 | "strings"
29 | "sync"
30 |
31 | wmi "github.com/StackExchange/wmi"
32 | )
33 |
34 | var mux sync.Mutex
35 |
36 | type WebsiteConfig struct {
37 | AppPoolIdentity iisAppPoolIdentity
38 | AppPoolConfigPath string
39 | Bindings []iisBinding
40 | Env map[string]string
41 | Name string
42 | Path string
43 | SiteConfigPath string
44 | }
45 |
46 | // Application Pool schema given from appcmd.exe
47 | type appCmdAppPool struct {
48 | Name string `xml:"APPPOOL.NAME,attr"`
49 | PipelineMode string `xml:"PipelineMode,attr"`
50 | RuntimeVersion string `xml:"RuntimeVersion,attr"`
51 | State string `xml:"state,attr"`
52 | Add appPoolAdd `xml:"add"`
53 | }
54 |
55 | // An Application Pool's inner schema to describe the ApplicationPool given from appcmd.exe
56 | type appPoolAdd struct {
57 | Name string `xml:"name,attr"`
58 | QueueLength int `xml:"queueLength,attr"`
59 | AutoStart bool `xml:"autoStart,attr"`
60 | ProcessModel appPoolProcessModel `xml:"processModel"`
61 | EnvironmentVariables appPoolEnvVars `xml:"environmentVariables"`
62 | }
63 |
64 | // An Application Pool's 'add' schema for Environment Variables
65 | type appPoolAddEnvVar struct {
66 | Name string `xml:"name,attr"`
67 | Value string `xml:"value,attr"`
68 | }
69 |
70 | // An Application Pool's inner schema for Environment Variables. We only care about 'add' vars.
71 | type appPoolEnvVars struct {
72 | Add []appPoolAddEnvVar `xml:"add"`
73 | }
74 |
75 | // An Application Pool's ProcessModel schema given from appcmd.exe
76 | type appPoolProcessModel struct {
77 | IdentityType string `xml:"identityType,attr"`
78 | Password string `xml:"password,attr"`
79 | Username string `xml:"userName,attr"`
80 | }
81 |
82 | // AppCmd message schema used for errors and messages
83 | type appCmdMessage struct {
84 | Message string `xml:"message,attr"`
85 | }
86 |
87 | // AppCmd schema for all results
88 | type appCmdResult struct {
89 | Apps []appCmdApp `xml:"APP"`
90 | AppPools []appCmdAppPool `xml:"APPPOOL"`
91 | Errors []appCmdMessage `xml:"ERROR"`
92 | Sites []appCmdSite `xml:"SITE"`
93 | Statuses []appCmdMessage `xml:"STATUS"`
94 | WorkerProcesses []appCmdWP `xml:"WP"`
95 | VDirs []appCmdVDir `xml:"VDIR"`
96 | XMLName xml.Name `xml:"appcmd"`
97 | }
98 |
99 | // Site schema given from appcmd.exe
100 | type appCmdSite struct {
101 | Bindings string `xml:"bindings,attr"`
102 | ID int `xml:"SISTE.ID,attr"`
103 | Name string `xml:"SITE.NAME,attr"`
104 | State string `xml:"state,attr"`
105 | Site site `xml:"site"`
106 | }
107 |
108 | // Application schema given from appcmd.exe
109 | type appCmdApp struct {
110 | Name string `xml:"APP.NAME,attr"`
111 | AppPoolName string `xml:"APPPOOL.NAME,attr"`
112 | SiteName string `xml:"SITE.NAME,attr"`
113 | Path string `xml:"path,attr"`
114 | Applications siteApplication `xml:"application"`
115 | }
116 |
117 | // Virtual Directory schema given from appcmd.exe
118 | type appCmdVDir struct {
119 | Name string `xml:"VDIR.NAME,attr"`
120 | AppName string `xml:"APP.NAME,attr"`
121 | PhysicalPath string `xml:"physicalPath,attr"`
122 | Path string `xml:"path,attr"`
123 | VDirs []siteVDir `xml:"virtualDirectory"`
124 | }
125 |
126 | // Nested Site schema given from appcmd.exe
127 | type site struct {
128 | Name string `xml:"name,attr"`
129 | ID string `xml:"id,attr"`
130 | Application siteApplication `xml:"application"`
131 | }
132 |
133 | // A Site's Application schema given from appcmd.exe
134 | type siteApplication struct {
135 | Path string `xml:"path,attr"`
136 | ApplicationPool string `xml:"applicationPool,attr"`
137 | VDirs []siteVDir `xml:"virtualDirectory"`
138 | }
139 |
140 | // A Site's Virtual Directory schema given from appcmd.exe
141 | type siteVDir struct {
142 | Path string `xml:"path,attr"`
143 | PhysicalPath string `xml:"physicalPath,attr"`
144 | }
145 |
146 | // Worker Process schema given from appcmd.exe
147 | type appCmdWP struct {
148 | AppPoolName string `xml:"APPPOOL.NAME,attr"`
149 | Name string `xml:"WP.NAME,attr"`
150 | }
151 |
152 | // IIS Identity used for an Application Pool
153 | type iisAppPoolIdentity struct {
154 | Identity string
155 | Password string
156 | Username string
157 | }
158 |
159 | // IIS Binding struct to match
160 | type iisBinding struct {
161 | CertHash string `codec:"cert_hash"`
162 | HostName string `codec:"hostname"`
163 | IPAddress string `codec:"ipaddress"`
164 | Port int
165 | PortLabel string `codec:"port"`
166 | Type string `codec:"type"`
167 | }
168 |
169 | // Stat fields that are unmarshalled from WMI
170 | type wmiProcessStats struct {
171 | KernelModeTime uint64
172 | UserModeTime uint64
173 | WorkingSetPrivate uint64
174 | }
175 |
176 | // A Version Struct to parse IIS Version strings for granular control with features.
177 | type iisVersion struct {
178 | Major int
179 | Minor int
180 | Build int
181 | Revision int
182 | }
183 |
184 | // Gets the exe version string of InetMgr.exe
185 | func getVersionStr() (string, error) {
186 | cmd := exec.Command("cmd", "/C", `wmic datafile where name='C:\\Windows\\System32\\inetsrv\\InetMgr.exe' get version`)
187 | if out, err := cmd.Output(); err != nil {
188 | return "", fmt.Errorf("Failed to determine version: %v", err)
189 | } else {
190 | if output := strings.Fields(string(out)); len(output) != 2 {
191 | return "", fmt.Errorf("Did not receive proper version formatting")
192 | } else {
193 | return output[1], nil
194 | }
195 | }
196 | }
197 |
198 | // Gets a version object of InetMgr.exe which parses major.minor.build.revision string
199 | func getVersion() (*iisVersion, error) {
200 | versionStr, err := getVersionStr()
201 | if err != nil {
202 | return nil, fmt.Errorf("Failed to get version string for iisVersion parsing: %v", err)
203 | }
204 |
205 | versionNumbers := strings.Split(versionStr, ".")
206 | if len(versionNumbers) != 4 {
207 | return nil, fmt.Errorf("Format of IIS version is improper. It must have \"major.minor.build.revision\" format")
208 | }
209 | version := &iisVersion{}
210 |
211 | major, err := strconv.Atoi(versionNumbers[0])
212 | if err != nil {
213 | return nil, fmt.Errorf("Failed to set Major version number: %v", err)
214 | }
215 | version.Major = major
216 |
217 | minor, err := strconv.Atoi(versionNumbers[1])
218 | if err != nil {
219 | return nil, fmt.Errorf("Failed to set Minor version number: %v", err)
220 | }
221 | version.Minor = minor
222 |
223 | build, err := strconv.Atoi(versionNumbers[2])
224 | if err != nil {
225 | return nil, fmt.Errorf("Failed to set Build version number: %v", err)
226 | }
227 | version.Build = build
228 |
229 | revision, err := strconv.Atoi(versionNumbers[3])
230 | if err != nil {
231 | return nil, fmt.Errorf("Failed to set Revision version number: %v", err)
232 | }
233 | version.Revision = revision
234 |
235 | return version, nil
236 | }
237 |
238 | // Returns if the IIS service is running in Windows Service Controller (SC)
239 | func isIISRunning() (bool, error) {
240 | cmd := exec.Command(`C:\Windows\System32\sc.exe`, "query", "w3svc")
241 | if out, err := cmd.CombinedOutput(); err != nil {
242 | return false, err
243 | } else {
244 | return regexp.MatchString(`STATE.*:.*4.*RUNNING`, string(out))
245 | }
246 | }
247 |
248 | // Removes all Application Pools and Sites from IIS
249 | func purgeIIS() error {
250 | if sites, err := getSites(); err != nil {
251 | return err
252 | } else {
253 | for _, site := range sites {
254 | if err = deleteSite(site.Name); err != nil {
255 | return err
256 | }
257 | }
258 | }
259 | if appPools, err := getAppPools(); err != nil {
260 | return err
261 | } else {
262 | for _, appPool := range appPools {
263 | if err = deleteAppPool(appPool.Name); err != nil {
264 | return err
265 | }
266 | }
267 | }
268 | return nil
269 | }
270 |
271 | // Starts the IIS service in Windows SC
272 | func startIIS() error {
273 | if isRunning, err := isIISRunning(); err != nil || isRunning {
274 | return err
275 | }
276 |
277 | cmd := exec.Command(`C:\Windows\System32\sc.exe`, "start", "w3svc")
278 | if _, err := cmd.Output(); err != nil {
279 | return err
280 | }
281 | return nil
282 | }
283 |
284 | // Stops the IIS service in Windows SC
285 | func stopIIS() error {
286 | if isRunning, err := isIISRunning(); err != nil || !isRunning {
287 | return err
288 | }
289 |
290 | cmd := exec.Command(`C:\Windows\System32\sc.exe`, "stop", "w3svc")
291 | if _, err := cmd.Output(); err != nil {
292 | return err
293 | }
294 | return nil
295 | }
296 |
297 | // Executes appcmd.exe with the given arguments and returns a structured result or error
298 | func executeAppCmd(arg ...string) (appCmdResult, error) {
299 | return executeAppCmdWithInput("", arg...)
300 | }
301 |
302 | // Executes appcmd.exe with the given arguments along with an xml path file for input and returns a structured result or error
303 | func executeAppCmdWithInput(importXmlPath string, arg ...string) (appCmdResult, error) {
304 | var result appCmdResult
305 | var cmd *exec.Cmd
306 |
307 | arg = append(arg, "/xml")
308 | if importXmlPath != "" {
309 | arg = append([]string{"/C", `C:\Windows\System32\inetsrv\APPCMD.exe`}, append(arg, fmt.Sprintf("/in<%s", importXmlPath))...)
310 | cmd = exec.Command("cmd", arg...)
311 | } else {
312 | cmd = exec.Command(`C:\Windows\System32\inetsrv\APPCMD.exe`, arg...)
313 | }
314 |
315 | if out, err := cmd.Output(); err != nil {
316 | // Attempt to parse output for verbose error messages in xml, otherwise return error code
317 | // If an appcmd xml is parsed successfully, then accept that as source of error truth
318 | if xmlErr := xml.Unmarshal(out, &result); xmlErr == nil {
319 | if len(result.Errors) != 0 {
320 | return result, fmt.Errorf(result.Errors[0].Message)
321 | }
322 |
323 | return result, nil
324 | }
325 | return result, err
326 | } else {
327 | xml.Unmarshal(out, &result)
328 | return result, nil
329 | }
330 | }
331 |
332 | // Applies the Application Pool identity user settings
333 | func applyAppPoolIdentity(appPoolName string, appPoolIdentity iisAppPoolIdentity) error {
334 | properties := []string{"set", "config", "/section:applicationPools"}
335 |
336 | if appPoolIdentity.Identity != "" {
337 | properties = append(properties, fmt.Sprintf("/[name='%s'].processModel.identityType:%s", appPoolName, appPoolIdentity.Identity))
338 | }
339 |
340 | if appPoolIdentity.Identity == "SpecificUser" && appPoolIdentity.Username != "" && appPoolIdentity.Password != "" {
341 | properties = append(properties, fmt.Sprintf("/[name='%s'].processModel.userName:%s", appPoolName, appPoolIdentity.Username))
342 | properties = append(properties, fmt.Sprintf("/[name='%s'].processModel.password:%s", appPoolName, appPoolIdentity.Password))
343 | }
344 |
345 | if _, err := executeAppCmd(properties...); err != nil {
346 | return fmt.Errorf("Failed to set Application Pool identity: %v", err)
347 | }
348 |
349 | return nil
350 | }
351 |
352 | // Creates environment variable xml nodes for IIS to ingest for each Application Pool for IIS 10+
353 | // Note: AppCmd will not let you set an environment variable when a key with the same name exists.
354 | // To get around this, we will remove any changed env vars that already exist within the application pool and re-add.
355 | func applyAppPoolEnvVars(appPoolName string, envVars map[string]string) error {
356 | if len(envVars) == 0 {
357 | return nil
358 | }
359 |
360 | if iisVersion, err := getVersion(); err != nil {
361 | return err
362 | } else if iisVersion.Major < 10 {
363 | // Default behavior for older versions of IIS does not accept env vars
364 | return nil
365 | }
366 |
367 | appPool, err := getAppPool(appPoolName, true)
368 | if err != nil || appPool == nil {
369 | return err
370 | }
371 |
372 | properties := []string{"set", "config", "-section:system.applicationHost/applicationPools"}
373 |
374 | for key, val := range envVars {
375 | key = strings.Trim(key, " ")
376 | if key == "" {
377 | continue
378 | }
379 | if keyExists, isSameValue := doesAppPoolEnvVarExistWithSameValue(appPool, key, val); keyExists {
380 | // Delete altered env vars so that they can updated for the Application Pool
381 | if isSameValue {
382 | continue
383 | } else {
384 | if err = deleteAppPoolEnvVar(appPoolName, key); err != nil {
385 | return fmt.Errorf("Failed to remove old environment variable entry from the Application Pool: %v", err)
386 | }
387 | }
388 | }
389 | properties = append(properties, fmt.Sprintf("/+[name='%s'].environmentVariables.[name='%s',value='%s']", appPoolName, key, val))
390 | }
391 |
392 | properties = append(properties, "/commit:appHost")
393 | if _, err := executeAppCmd(properties...); err != nil {
394 | return fmt.Errorf("Failed to set Application Pool environment variables: %v", err)
395 | }
396 |
397 | return nil
398 | }
399 |
400 | // Creates an Application Pool with the given name and applies an IIS exported Application Pool xml if a path is provided
401 | func createAppPool(appPoolName string, configPath string) error {
402 | if exists, err := doesAppPoolExist(appPoolName); err != nil || exists {
403 | return err
404 | }
405 |
406 | properties := []string{"add", "apppool", fmt.Sprintf("/name:%s", appPoolName)}
407 | if _, err := executeAppCmdWithInput(configPath, properties...); err != nil {
408 | return fmt.Errorf("Failed to create Application Pool: %v", err)
409 | }
410 |
411 | return nil
412 | }
413 |
414 | // Deletes an Application Pool with the given name
415 | func deleteAppPool(appPoolName string) error {
416 | if exists, err := doesAppPoolExist(appPoolName); err != nil || !exists {
417 | return err
418 | }
419 |
420 | if _, err := executeAppCmd("delete", "apppool", appPoolName); err != nil {
421 | return fmt.Errorf("Failed to delete Application Pool: %v", err)
422 | }
423 |
424 | return nil
425 | }
426 |
427 | // Deletes an environment variable based on a key for a given Application Pool
428 | func deleteAppPoolEnvVar(appPoolName string, key string) error {
429 | if exists, err := doesAppPoolExist(appPoolName); err != nil || !exists {
430 | return err
431 | }
432 |
433 | properties := []string{"set", "config", "-section:system.applicationHost/applicationPools"}
434 | properties = append(properties, fmt.Sprintf("/-[name='%s'].environmentVariables.[name='%s']", appPoolName, key))
435 | properties = append(properties, "/commit:appHost")
436 |
437 | if _, err := executeAppCmd(properties...); err != nil {
438 | return fmt.Errorf("Failed to delete Application Pool environment variable: %v", err)
439 | }
440 |
441 | return nil
442 | }
443 |
444 | // Returns if an Application Pool with the given name exists in IIS
445 | func doesAppPoolExist(appPoolName string) (bool, error) {
446 | if appPool, err := getAppPool(appPoolName, false); err != nil || appPool == nil {
447 | return false, err
448 | }
449 | return true, nil
450 | }
451 |
452 | // Determines if an environment variable exists and if the values match for an Application Pool
453 | func doesAppPoolEnvVarExistWithSameValue(appPool *appCmdAppPool, key string, val string) (bool, bool) {
454 | if appPool == nil {
455 | return false, false
456 | }
457 |
458 | for _, envVar := range appPool.Add.EnvironmentVariables.Add {
459 | if envVar.Name == key {
460 | return true, envVar.Value == val
461 | }
462 | }
463 |
464 | return false, false
465 | }
466 |
467 | // Returns an Application Pool with the given name
468 | func getAppPool(appPoolName string, allConfigs bool) (*appCmdAppPool, error) {
469 | args := []string{"list", "apppool", appPoolName}
470 | if allConfigs {
471 | args = append(args, "/config:*")
472 | }
473 |
474 | if result, err := executeAppCmd(args...); err != nil {
475 | return nil, fmt.Errorf("Failed to get Application Pool: %v", err)
476 | } else if len(result.AppPools) == 0 {
477 | return nil, nil
478 | } else {
479 | return &result.AppPools[0], nil
480 | }
481 | }
482 |
483 | // Returns all Application Pools that exist in IIS
484 | func getAppPools() ([]appCmdAppPool, error) {
485 | if result, err := executeAppCmd("list", "apppool"); err != nil {
486 | return nil, fmt.Errorf("Failed to get Application Pools: %v", err)
487 | } else {
488 | return result.AppPools, nil
489 | }
490 | }
491 |
492 | // Returns if an Application Pool with the given name is started in IIS
493 | func isAppPoolStarted(appPoolName string) (bool, error) {
494 | if appPool, err := getAppPool(appPoolName, false); err != nil || appPool == nil {
495 | return false, err
496 | } else {
497 | return strings.ToLower(appPool.State) == "started", nil
498 | }
499 | }
500 |
501 | // Starts an Application Pool with the given name in IIS
502 | func startAppPool(appPoolName string) error {
503 | if isStarted, err := isAppPoolStarted(appPoolName); err != nil || isStarted {
504 | return err
505 | }
506 |
507 | if _, err := executeAppCmd("start", "apppool", appPoolName); err != nil {
508 | return fmt.Errorf("Failed to start Application Pool: %v", err)
509 | }
510 |
511 | return nil
512 | }
513 |
514 | // Stops an Application Pool with the given name in IIS
515 | func stopAppPool(appPoolName string) error {
516 | if isStarted, err := isAppPoolStarted(appPoolName); err != nil || !isStarted {
517 | return err
518 | }
519 |
520 | if _, err := executeAppCmd("stop", "apppool", appPoolName); err != nil {
521 | return fmt.Errorf("Failed to stop Application Pool: %v", err)
522 | }
523 |
524 | return nil
525 | }
526 |
527 | // Applies the Site bindings
528 | func applySiteBindings(siteName string, bindings []iisBinding) error {
529 | site, err := getSite(siteName, false)
530 | if err != nil {
531 | return err
532 | }
533 |
534 | var addBindings []iisBinding
535 | currentBindings, err := site.getBindings()
536 | if err != nil {
537 | return err
538 | }
539 |
540 | properties := []string{"set", "site", siteName}
541 |
542 | // Compare current bindings with desired bindings
543 | // Remove any bindings that exist in both arrays from currentBindings. This allows us to determine which of the currentBindings are no longer needed.
544 | var exists bool
545 | for _, binding := range bindings {
546 | exists = false
547 | if binding.IPAddress == "" {
548 | binding.IPAddress = "*"
549 | }
550 |
551 | for cIndex, currentBinding := range currentBindings {
552 | if currentBinding.Type == binding.Type && currentBinding.IPAddress == binding.IPAddress && currentBinding.Port == binding.Port && currentBinding.HostName == binding.HostName {
553 | exists = true
554 | currentBindings[cIndex] = currentBindings[len(currentBindings)-1]
555 | currentBindings = currentBindings[:len(currentBindings)-1]
556 | break
557 | }
558 | }
559 |
560 | if !exists {
561 | addBindings = append(addBindings, binding)
562 |
563 | }
564 | }
565 |
566 | // Nothing is changed if there are no bindings to update
567 | if len(currentBindings) == 0 && len(addBindings) == 0 {
568 | return nil
569 | }
570 |
571 | // Remove any bindings that are not desired
572 | for _, binding := range currentBindings {
573 | if binding.Type == "https" {
574 | bindingInfo, err := getSSLCertBinding(binding.IPAddress, binding.Port)
575 |
576 | if len(bindingInfo) != 0 {
577 | if err = unbindSSLCert(binding.IPAddress, binding.Port); err != nil {
578 | return err
579 | }
580 | }
581 | }
582 |
583 | properties = append(properties, fmt.Sprintf("/-bindings.[protocol='%s',bindingInformation='%s:%d:%s']", binding.Type, binding.IPAddress, binding.Port, binding.HostName))
584 | }
585 |
586 | // Add bindings that are desired
587 | for _, binding := range addBindings {
588 | if binding.Type == "https" {
589 | if binding.CertHash == "" {
590 | return fmt.Errorf("HTTPS binding used, but no cert hash was supplied")
591 | }
592 |
593 | bindingInfo, err := getSSLCertBinding(binding.IPAddress, binding.Port)
594 |
595 | if len(bindingInfo) != 0 && bindingInfo["CertificateHash"] != binding.CertHash {
596 | if err = unbindSSLCert(binding.IPAddress, binding.Port); err != nil {
597 | return err
598 | }
599 | }
600 |
601 | if err = bindSSLCert(siteName, binding.IPAddress, binding.Port, binding.CertHash); err != nil {
602 | return err
603 | }
604 | }
605 |
606 | if binding.IPAddress == "" {
607 | binding.IPAddress = "*"
608 | }
609 |
610 | properties = append(properties, fmt.Sprintf("/+bindings.[protocol='%s',bindingInformation='%s:%d:%s']", binding.Type, binding.IPAddress, binding.Port, binding.HostName))
611 | }
612 |
613 | if _, err := executeAppCmd(properties...); err != nil {
614 | return fmt.Errorf("Failed to set Site bindings: %v", err)
615 | }
616 |
617 | return nil
618 | }
619 |
620 | // Creates a Site with the given name and applies an IIS exported Site xml if a path is provided
621 | func createSite(siteName string, configPath string) error {
622 | if exists, err := doesSiteExist(siteName); err != nil || exists {
623 | return err
624 | }
625 |
626 | properties := []string{"add", "site", fmt.Sprintf("/name:%s", siteName)}
627 | if _, err := executeAppCmdWithInput(configPath, properties...); err != nil {
628 | return fmt.Errorf("Failed to create Site: %v", err)
629 | }
630 |
631 | return nil
632 | }
633 |
634 | // Deletes a Site with the given name
635 | func deleteSite(siteName string) error {
636 | if exists, err := doesSiteExist(siteName); err != nil || !exists {
637 | return err
638 | }
639 |
640 | if _, err := executeAppCmd("delete", "site", siteName); err != nil {
641 | return fmt.Errorf("Failed to delete Site: %v", err)
642 | }
643 |
644 | return nil
645 | }
646 |
647 | // Returns if a Site with the given name exists in IIS
648 | func doesSiteExist(siteName string) (bool, error) {
649 | if site, err := getSite(siteName, false); err != nil || site == nil {
650 | return false, err
651 | }
652 |
653 | return true, nil
654 | }
655 |
656 | // Returns IISBindings by parsing a Site's bindings string
657 | func (site *appCmdSite) getBindings() ([]iisBinding, error) {
658 | var currentBindings []iisBinding
659 |
660 | if site.Bindings == "" {
661 | return currentBindings, nil
662 | }
663 |
664 | bindings := strings.Split(site.Bindings, ",")
665 |
666 | for _, binding := range bindings {
667 | var iisBinding iisBinding
668 | slashIndex := strings.Index(binding, "/")
669 | iisBinding.Type = binding[:slashIndex]
670 | bindingInfo := strings.Split(binding[slashIndex+1:], ":")
671 | iisBinding.IPAddress = bindingInfo[0]
672 | if port, err := strconv.Atoi(bindingInfo[1]); err != nil {
673 | return nil, fmt.Errorf("Failed to parse a binding's port")
674 | } else {
675 | iisBinding.Port = port
676 | }
677 | iisBinding.HostName = bindingInfo[2]
678 |
679 | currentBindings = append(currentBindings, iisBinding)
680 | }
681 |
682 | return currentBindings, nil
683 | }
684 |
685 | // Returns a Site with the given name
686 | func getSite(siteName string, allConfigs bool) (*appCmdSite, error) {
687 | args := []string{"list", "site", siteName}
688 | if allConfigs {
689 | args = append(args, "/config:*")
690 | }
691 |
692 | if result, err := executeAppCmd(args...); err != nil {
693 | return nil, fmt.Errorf("Failed to get Site: %v", err)
694 | } else if len(result.Sites) == 0 {
695 | return nil, nil
696 | } else {
697 | return &result.Sites[0], nil
698 | }
699 | }
700 |
701 | // Returns all Sites that exist in IIS
702 | func getSites() ([]appCmdSite, error) {
703 | if result, err := executeAppCmd("list", "site"); err != nil {
704 | return nil, fmt.Errorf("Failed to get Sites: %v", err)
705 | } else {
706 | return result.Sites, nil
707 | }
708 | }
709 |
710 | // Returns if a Site with the given name is started in IIS
711 | func isSiteStarted(siteName string) (bool, error) {
712 | if site, err := getSite(siteName, false); err != nil || site == nil {
713 | return false, err
714 | } else {
715 | return strings.ToLower(site.State) == "started", nil
716 | }
717 | }
718 |
719 | // Starts a Site with the given name in IIS
720 | func startSite(siteName string) error {
721 | if isRunning, err := isSiteStarted(siteName); err != nil || isRunning {
722 | return err
723 | }
724 |
725 | if _, err := executeAppCmd("start", "site", siteName); err != nil {
726 | return fmt.Errorf("Failed to start Site: %v", err)
727 | }
728 |
729 | return nil
730 | }
731 |
732 | // Stops a Site with the given name in IIS
733 | func stopSite(siteName string) error {
734 | if isRunning, err := isSiteStarted(siteName); err != nil || !isRunning {
735 | return err
736 | }
737 |
738 | if _, err := executeAppCmd("stop", "site", siteName); err != nil {
739 | return fmt.Errorf("Failed to stop Site: %v", err)
740 | }
741 |
742 | return nil
743 | }
744 |
745 | // Sets a Site's Application Pool to the names given
746 | func applySiteAppPool(siteName string, appPoolName string) error {
747 | if _, err := executeAppCmd("set", "app", fmt.Sprintf("%s/", siteName), fmt.Sprintf("/applicationPool:%s", appPoolName)); err != nil {
748 | return fmt.Errorf("Failed to set Site Application Pool: %v", err)
749 | }
750 |
751 | return nil
752 | }
753 |
754 | // Creates an Application with the given Site name and path
755 | func createApplication(siteName string, path string) error {
756 | if exists, err := doesApplicationExist(siteName, path); err != nil || exists {
757 | return err
758 | }
759 |
760 | properties := []string{"add", "app", fmt.Sprintf("/site.name:%s", siteName), fmt.Sprintf("/path:%s", path)}
761 | if _, err := executeAppCmd(properties...); err != nil {
762 | return fmt.Errorf("Failed to create Application: %v", err)
763 | }
764 |
765 | return nil
766 | }
767 |
768 | // Returns if an Application with the given Site name and path exists in IIS
769 | func doesApplicationExist(siteName string, path string) (bool, error) {
770 | if app, err := getApplication(siteName, path, false); err != nil || app == nil {
771 | return false, err
772 | }
773 |
774 | return true, nil
775 | }
776 |
777 | // Returns an Application with the given Site name and path
778 | func getApplication(siteName string, path string, allConfigs bool) (*appCmdApp, error) {
779 | args := []string{"list", "app", siteName + path}
780 | if allConfigs {
781 | args = append(args, "/config:*")
782 | }
783 |
784 | result, err := executeAppCmd(args...)
785 | if err != nil {
786 | return nil, fmt.Errorf("Failed to get Application: %v", err)
787 | } else if len(result.Apps) == 0 {
788 | return nil, nil
789 | }
790 | return &result.Apps[0], nil
791 | }
792 |
793 | // Returns a valid VirtualDir name for IIS/appcmd to ingest
794 | // A "/" must exist somewhere in the app name to append a vdir to it.
795 | // If none are provided, append "/" to the end of the app name as default.
796 | func getValidVDirAppName(appName string) string {
797 | if !strings.Contains(appName, "/") {
798 | return appName + "/"
799 | }
800 | return appName
801 | }
802 |
803 | // Creates a VirtualDir with the given Application Name and path
804 | func createVDir(appName string, path string) error {
805 | if exists, err := doesVDirExist(appName, path); err != nil || exists {
806 | return err
807 | }
808 |
809 | validAppName := getValidVDirAppName(appName)
810 |
811 | properties := []string{"add", "vdir", fmt.Sprintf("/app.name:%s", validAppName), fmt.Sprintf("/path:%s", path)}
812 | if _, err := executeAppCmd(properties...); err != nil {
813 | return fmt.Errorf("Failed to create Virtual Directory: %v", err)
814 | }
815 |
816 | return nil
817 | }
818 |
819 | // Returns if a VirtualDir with the given Application Name and path exists
820 | func doesVDirExist(appName string, path string) (bool, error) {
821 | if app, err := getVDir(appName, path, false); err != nil || app == nil {
822 | return false, err
823 | }
824 |
825 | return true, nil
826 | }
827 |
828 | // Returns a VirtualDir with the given Application Name and path
829 | func getVDir(appName string, path string, allConfigs bool) (*appCmdVDir, error) {
830 | args := []string{"list", "vdir", appName + path}
831 | if allConfigs {
832 | args = append(args, "/config:*")
833 | }
834 |
835 | result, err := executeAppCmd(args...)
836 | if err != nil {
837 | return nil, fmt.Errorf("Failed to get Virtual Directory: %v", err)
838 | } else if len(result.VDirs) == 0 {
839 | return nil, nil
840 | }
841 | return &result.VDirs[0], nil
842 | }
843 |
844 | // Sets a VirtualDir with the given Application Name and path to the provided physical path
845 | func setVDir(appName string, path string, physicalPath string) error {
846 | properties := []string{"set", "vdir", appName + path, fmt.Sprintf("-physicalPath:%s", physicalPath)}
847 | if _, err := executeAppCmd(properties...); err != nil {
848 | return fmt.Errorf("Failed to set Virtual Directory: %v", err)
849 | }
850 |
851 | return nil
852 | }
853 |
854 | // Creates an Application Pool and Site with the given configuration
855 | func createWebsite(websiteConfig *WebsiteConfig) error {
856 | mux.Lock()
857 | defer mux.Unlock()
858 |
859 | if err := createAppPool(websiteConfig.Name, websiteConfig.AppPoolConfigPath); err != nil {
860 | return err
861 | }
862 | if err := applyAppPoolIdentity(websiteConfig.Name, websiteConfig.AppPoolIdentity); err != nil {
863 | return err
864 | }
865 | if err := applyAppPoolEnvVars(websiteConfig.Name, websiteConfig.Env); err != nil {
866 | return err
867 | }
868 |
869 | // A "site" is made of Site -> Applications -> Virtual Dirs
870 | // The default "path" for a site is "/", this is the relative path that is presented to urls
871 | // By default, we bind the provided website config path (physicalPath) to the root virtual dir.
872 | if err := createSite(websiteConfig.Name, websiteConfig.SiteConfigPath); err != nil {
873 | return err
874 | }
875 | if err := createApplication(websiteConfig.Name, "/"); err != nil {
876 | return err
877 | }
878 | if err := createVDir(websiteConfig.Name, "/"); err != nil {
879 | return err
880 | }
881 | if err := setVDir(websiteConfig.Name, "/", websiteConfig.Path); err != nil {
882 | return err
883 | }
884 |
885 | if err := applySiteAppPool(websiteConfig.Name, websiteConfig.Name); err != nil {
886 | return err
887 | }
888 | return applySiteBindings(websiteConfig.Name, websiteConfig.Bindings)
889 | }
890 |
891 | // Deletes an Application Pool and Site with the given name
892 | func deleteWebsite(websiteName string) error {
893 | if err := deleteSite(websiteName); err != nil {
894 | return err
895 | }
896 | return deleteAppPool(websiteName)
897 | }
898 |
899 | // Returns if both Application Pool and Site exist with the given name
900 | func doesWebsiteExist(websiteName string) (bool, error) {
901 | if exists, err := doesAppPoolExist(websiteName); err != nil || !exists {
902 | return false, err
903 | }
904 | if exists, err := doesSiteExist(websiteName); err != nil || !exists {
905 | return false, err
906 | }
907 |
908 | return true, nil
909 | }
910 |
911 | // Returns the ProcessIds of a running Application Pool as string slice
912 | func getWebsiteProcessIdsStr(websiteName string) ([]string, error) {
913 | result, err := executeAppCmd("list", "wp", fmt.Sprintf("/apppool.name:%s", websiteName))
914 | if err != nil {
915 | return nil, fmt.Errorf("Failed to get Website Process Ids: %v", err)
916 | }
917 | var processIds []string
918 | for _, wp := range result.WorkerProcesses {
919 | processIds = append(processIds, wp.Name)
920 | }
921 |
922 | return processIds, nil
923 | }
924 |
925 | // Returns the ProcessIds of a running Application Pool
926 | func getWebsiteProcessIds(websiteName string) ([]int, error) {
927 | result, err := getWebsiteProcessIdsStr(websiteName)
928 | if err != nil {
929 | return nil, err
930 | }
931 |
932 | var processIds []int
933 | for _, id := range result {
934 | newProcessId, err := strconv.Atoi(id)
935 | if err != nil {
936 | return nil, fmt.Errorf("Failed to parse Website Process Ids: %v", err)
937 | }
938 | processIds = append(processIds, newProcessId)
939 | }
940 |
941 | return processIds, nil
942 | }
943 |
944 | // WMI Internal Type for gather WorkingSet Memory
945 | type win32PerfFormattedDataPerfProcProcess struct {
946 | WorkingSetPrivate uint64
947 | }
948 |
949 | // WMI Internal Type for gathering CPU usage of a process
950 | type win32Process struct {
951 | KernelModeTime uint64
952 | UserModeTime uint64
953 | }
954 |
955 | // Gets the WMI CPU and Memory stats of a given website
956 | func getWebsiteStats(websiteName string) (*wmiProcessStats, error) {
957 | // Get a list of process ids tied to the app pool
958 | processIds, err := getWebsiteProcessIdsStr(websiteName)
959 | if err != nil {
960 | return nil, err
961 | }
962 |
963 | stats := &wmiProcessStats{
964 | WorkingSetPrivate: 0,
965 | KernelModeTime: 0,
966 | UserModeTime: 0,
967 | }
968 |
969 | // No process ids means no stats.
970 | // IIS sites/app pools can be in a state without an actively running process id.
971 | if len(processIds) == 0 {
972 | return stats, nil
973 | }
974 |
975 | // Query WMI for cpu stats with the given process ids
976 | var win32Processes []win32Process
977 | query := wmi.CreateQuery(&win32Processes, fmt.Sprintf("WHERE ProcessID=%s", strings.Join(processIds, "OR ProcessID=")), "Win32_Process")
978 | if err := wmi.Query(query, &win32Processes); err != nil {
979 | return nil, err
980 | }
981 |
982 | // Sum up all cpu stats
983 | for _, process := range win32Processes {
984 | stats.KernelModeTime += process.KernelModeTime
985 | stats.UserModeTime += process.UserModeTime
986 | }
987 |
988 | var formattedProcess []win32PerfFormattedDataPerfProcProcess
989 |
990 | // Query WMI for memory stats with the given process ids
991 | // We are only using the WorkingSetPrivate for our memory to better align the Windows Task Manager and the RSS field nomad is expecting
992 | query = wmi.CreateQuery(&formattedProcess, fmt.Sprintf("WHERE IDProcess=%s", strings.Join(processIds, "OR IDProcess=")), "Win32_PerfFormattedData_PerfProc_Process")
993 | if err := wmi.Query(query, &formattedProcess); err != nil {
994 | return nil, err
995 | }
996 |
997 | // Sum up all memory stats
998 | for _, process := range formattedProcess {
999 | stats.WorkingSetPrivate += process.WorkingSetPrivate
1000 | }
1001 |
1002 | // Need to multiply cpu stats by one hundred to align with nomad method CpuStats.Percent's expected decimal placement
1003 | stats.KernelModeTime *= 100
1004 | stats.UserModeTime *= 100
1005 | return stats, nil
1006 | }
1007 |
1008 | func isWebsiteStarted(websiteName string) (bool, error) {
1009 | if isStarted, err := isAppPoolStarted(websiteName); err != nil || !isStarted {
1010 | return false, err
1011 | }
1012 | if isStarted, err := isSiteStarted(websiteName); err != nil || !isStarted {
1013 | return false, err
1014 | }
1015 |
1016 | return true, nil
1017 | }
1018 |
1019 | // Returns if the Application Pool has running processes or both Application Pool and Site are started with the given name
1020 | func isWebsiteRunning(websiteName string) (bool, error) {
1021 | processIds, err := getWebsiteProcessIdsStr(websiteName)
1022 | if err != nil {
1023 | return false, err
1024 | }
1025 | if len(processIds) != 0 {
1026 | return true, nil
1027 | }
1028 |
1029 | if isRunning, err := isWebsiteStarted(websiteName); err != nil || !isRunning {
1030 | return false, err
1031 | }
1032 |
1033 | return true, nil
1034 | }
1035 |
1036 | // Starts both Application Pool and Site with the given name
1037 | func startWebsite(websiteName string) error {
1038 | if err := startAppPool(websiteName); err != nil {
1039 | return err
1040 | }
1041 | return startSite(websiteName)
1042 | }
1043 |
1044 | // Stops both Application Pool and Site with the given name
1045 | func stopWebsite(websiteName string) error {
1046 | if err := stopSite(websiteName); err != nil {
1047 | return err
1048 | }
1049 | return stopAppPool(websiteName)
1050 | }
1051 |
1052 | func getNetshIP(ipAddress string) string {
1053 | if ipAddress != "" && ipAddress != "*" {
1054 | return ipAddress
1055 | } else {
1056 | return "0.0.0.0"
1057 | }
1058 | }
1059 |
1060 | // Binds an appid, ip address, and port to a hash of a pre-existing certificate in the cert store for https protocol IIS binding with netsh
1061 | func bindSSLCert(appID string, ipAddress string, port int, hash string) error {
1062 | if info, err := getSSLCertBinding(ipAddress, port); err != nil {
1063 | return err
1064 | } else if info["CertificateHash"] == hash {
1065 | return nil
1066 | }
1067 |
1068 | cmd := exec.Command(`C:\Windows\System32\netsh.exe`, "http", "add", "sslcert", fmt.Sprintf("ipport=%s:%d", getNetshIP(ipAddress), port), fmt.Sprintf("certhash=%s", hash), fmt.Sprintf("appid={%s}", appID))
1069 |
1070 | if err := cmd.Run(); err != nil {
1071 | return fmt.Errorf("Failed to install cert! %+v", err)
1072 | }
1073 |
1074 | return nil
1075 | }
1076 |
1077 | // Gets sslcert binding details from an ip address and port with netsh.
1078 | func getSSLCertBinding(ipAddress string, port int) (map[string]string, error) {
1079 |
1080 | cmd := exec.Command(`C:\Windows\System32\netsh.exe`, "http", "show", "sslcert", fmt.Sprintf("%s:%d", getNetshIP(ipAddress), port))
1081 |
1082 | if out, err := cmd.Output(); err != nil {
1083 | // Only ignore errors for not being able to find the file specified. SSLBinding doesn't exist in that case
1084 | if !strings.Contains(string(out), "The system cannot find the file specified") {
1085 | return nil, fmt.Errorf("Failed to read imported certificate! %+v", err)
1086 | }
1087 | return nil, nil
1088 | } else {
1089 | result := make(map[string]string)
1090 | count := 0
1091 | scanner := bufio.NewScanner(bytes.NewReader(out))
1092 | for scanner.Scan() {
1093 | count++
1094 | if count < 3 {
1095 | continue
1096 | }
1097 | line := scanner.Text()
1098 | if strings.Contains(line, ":") {
1099 | split := strings.Split(line, ":")
1100 |
1101 | space := regexp.MustCompile(`\s+`)
1102 | key := space.ReplaceAllString(split[0], "")
1103 | result[key] = strings.TrimSpace(split[1])
1104 | }
1105 | }
1106 |
1107 | return result, nil
1108 | }
1109 | }
1110 |
1111 | // Removes ip address and port sslcert binding with netsh
1112 | func unbindSSLCert(ipAddress string, port int) error {
1113 | if info, err := getSSLCertBinding(ipAddress, port); err != nil || len(info) == 0 {
1114 | return err
1115 | }
1116 |
1117 | cmd := exec.Command(`C:\Windows\System32\netsh.exe`, "http", "delete", "sslcert", fmt.Sprintf("ipport=%s:%d", getNetshIP(ipAddress), port))
1118 |
1119 | if err := cmd.Run(); err != nil {
1120 | return fmt.Errorf("Failed to uninstall cert! %+v", err)
1121 | }
1122 |
1123 | return nil
1124 | }
1125 |
--------------------------------------------------------------------------------
/iis/iis_test.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2020 Roblox Corporation
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 | */
17 |
18 | package iis
19 |
20 | // This is a test file for iis.go and IIS on a Windows Server
21 | // All tests are using to execute the iis function which directly communicate with various executables of Windows (sc, netsh, and appcmd)
22 | // These tests ensure the functionality of the code being used by the nomad handle/driver will properly change iis as needed
23 |
24 | import (
25 | "os"
26 | "os/exec"
27 | "path/filepath"
28 | "regexp"
29 | "testing"
30 | "time"
31 |
32 | "github.com/stretchr/testify/assert"
33 | )
34 |
35 | const (
36 | guid = "d42d7b18-691b-409a-94fd-4259a2b7e066"
37 | hash = "854d57551e79656159a0081054fbc08c6c648f86"
38 | )
39 |
40 | var (
41 | websiteConfig = WebsiteConfig{
42 | Name: guid,
43 | Path: "C:\\inetpub\\wwwroot",
44 | Bindings: []iisBinding{
45 | {Type: "http", Port: 8080},
46 | {Type: "https", Port: 8081, CertHash: hash},
47 | },
48 | Env: map[string]string{
49 | "EXAMPLE_ENV_VAR": "test123",
50 | "EXAMPLE_ENV_VAR_ALT": "test123",
51 | "": "INVALID",
52 | " ": "INVALID_SPACE",
53 | " FUN_SPACE ": "test456",
54 | },
55 | AppPoolIdentity: iisAppPoolIdentity{
56 | Identity: "SpecificUser",
57 | Username: "vagrant",
58 | Password: "vagrant",
59 | },
60 | }
61 | )
62 |
63 | // Test the fingerprinting ability of getVersion to ensure it is outputing the proper version format of IIS
64 | func TestIISVersion(t *testing.T) {
65 | version, err := getVersionStr()
66 | if err != nil {
67 | t.Fatal(err)
68 | }
69 |
70 | if match, err := regexp.MatchString(`^[0-9]*\.[0-9]*\.[0-9]*\.[[0-9]*$`, version); err != nil {
71 | t.Fatal(err)
72 | } else if !match {
73 | t.Fatal("Version returned does not match regex")
74 | }
75 | }
76 |
77 | // Test to ensure IIS functions for altering IIS's state works for other functional/integration tests
78 | func TestIISRunning(t *testing.T) {
79 | if err := stopIIS(); err != nil {
80 | t.Fatal(err)
81 | }
82 |
83 | // Wait for IIS running state to return as false, or timeout
84 | c1 := make(chan bool, 1)
85 | go func() {
86 | isRunning := true
87 |
88 | for isRunning {
89 | if running, err := isIISRunning(); err != nil {
90 | t.Fatal(err)
91 | } else {
92 | isRunning = running
93 | }
94 | time.Sleep(1 * time.Second)
95 | }
96 |
97 | c1 <- isRunning
98 | }()
99 |
100 | // Listen on our channel AND a timeout channel - which ever happens first.
101 | select {
102 | case isRunning := <-c1:
103 | if isRunning {
104 | t.Fatal("IIS did not stop!")
105 | }
106 | case <-time.After(10 * time.Second):
107 | t.Fatal("Timeout: IIS failed to stop in a reasonable time!")
108 | }
109 |
110 | if err := startIIS(); err != nil {
111 | t.Fatal("Error trying to start IIS!")
112 | }
113 |
114 | // Wait for IIS running state to return as true, or timeout
115 | go func() {
116 | isRunning := false
117 |
118 | for !isRunning {
119 | if running, err := isIISRunning(); err != nil {
120 | t.Fatal(err)
121 | } else {
122 | isRunning = running
123 | }
124 | time.Sleep(1 * time.Second)
125 | }
126 |
127 | c1 <- isRunning
128 | }()
129 |
130 | // Listen on our channel AND a timeout channel - which ever happens first.
131 | select {
132 | case isRunning := <-c1:
133 | if !isRunning {
134 | t.Fatal("IIS did not start!")
135 | }
136 | case <-time.After(10 * time.Second):
137 | t.Fatal("Timeout: IIS failed to start in a reasonable time!")
138 | }
139 | }
140 |
141 | // Test SSL Binding functionalility with netsh
142 | func TestSSLBinding(t *testing.T) {
143 | ipAddress := "0.0.0.0"
144 | port := 8081
145 | // Unbind for fresh start
146 | if err := unbindSSLCert(ipAddress, port); err != nil {
147 | t.Fatal(err)
148 | }
149 |
150 | // Check to see if unbinding the port was actually applied
151 | if bindingInfo, err := getSSLCertBinding(ipAddress, port); err != nil {
152 | t.Fatal(err)
153 | } else if bindingInfo != nil {
154 | t.Fatalf("SSL Cert binding exist after unbind!")
155 | }
156 |
157 | // Bind the ip:port
158 | if err := bindSSLCert(guid, ipAddress, port, hash); err != nil {
159 | t.Fatal(err)
160 | }
161 | // Bind idempotency test
162 | if err := bindSSLCert(guid, ipAddress, port, hash); err != nil {
163 | t.Fatal(err)
164 | }
165 |
166 | // Verify that an ssl binding exists for the ip and port
167 | if bindingInfo, err := getSSLCertBinding(ipAddress, port); err != nil {
168 | t.Fatal(err)
169 | } else if bindingInfo == nil {
170 | t.Fatal("SSL Cert binding doesn't exist after bind!")
171 | } else {
172 | assert.Equal(t, hash, bindingInfo["CertificateHash"], "Bound SSL Cert Hash doesn't match!")
173 | }
174 |
175 | // Unbind test
176 | if err := unbindSSLCert(ipAddress, port); err != nil {
177 | t.Fatal(err)
178 | }
179 | // Unbind idempotency test
180 | if err := unbindSSLCert(ipAddress, port); err != nil {
181 | t.Fatal(err)
182 | }
183 |
184 | // Verify ssl cert was unbound to the given ip:port
185 | if bindingInfo, err := getSSLCertBinding(ipAddress, port); err != nil {
186 | t.Fatal(err)
187 | } else if bindingInfo != nil {
188 | t.Fatal("SSL Cert binding exists after unbind!")
189 | }
190 | }
191 |
192 | // Helper function for verify iis bindings match
193 | func doBindingsMatchSite(t *testing.T, expected []iisBinding, siteName string) bool {
194 | site, err := getSite(guid, true)
195 | if err != nil {
196 | t.Fatal(err)
197 | } else if site == nil {
198 | t.Fatal("Site not found!")
199 | }
200 |
201 | actual, err := site.getBindings()
202 | if err != nil {
203 | t.Fatal(err)
204 | }
205 |
206 | if len(expected) != len(actual) {
207 | t.Logf("Expected %d bindings, but got %d", len(expected), len(actual))
208 | return false
209 | }
210 |
211 | for _, expectedBinding := range expected {
212 | exists := false
213 | if expectedBinding.IPAddress == "" {
214 | expectedBinding.IPAddress = "*"
215 | }
216 | for _, actualBinding := range actual {
217 | if expectedBinding.Type == actualBinding.Type &&
218 | expectedBinding.IPAddress == actualBinding.IPAddress &&
219 | expectedBinding.HostName == actualBinding.HostName &&
220 | expectedBinding.Port == actualBinding.Port {
221 | exists = true
222 | break
223 | }
224 | }
225 | if !exists {
226 | t.Logf("Doesn't Exist: %v", expectedBinding)
227 | return false
228 | }
229 | }
230 |
231 | return true
232 | }
233 |
234 | // Test various bindings that could be applied to a Site
235 | func TestSiteBinding(t *testing.T) {
236 | // Clean up pre-exisint IIS sites
237 | if err := purgeIIS(); err != nil {
238 | t.Fatal("Error purging: ", err)
239 | }
240 |
241 | // Create the test site in IIS
242 | if err := createSite(guid, ""); err != nil {
243 | t.Fatal(err)
244 | }
245 |
246 | // Test http and https bindings
247 | bindings := []iisBinding{
248 | {Type: "http", Port: 8080, IPAddress: "*"},
249 | {Type: "https", Port: 8081, IPAddress: "*", CertHash: hash},
250 | }
251 |
252 | // Apply the bindings
253 | if err := applySiteBindings(guid, bindings); err != nil {
254 | t.Fatal(err)
255 | }
256 |
257 | // Verify that expected and actual bindings match
258 | if !doBindingsMatchSite(t, bindings, guid) {
259 | t.Fatal("Expected and Actual bindings do not match!")
260 | }
261 |
262 | // Change up bindings so that 1 is overwritten and add a new one with specific ip and hostname
263 | bindings = []iisBinding{
264 | {Type: "http", Port: 8080, IPAddress: "*"},
265 | {Type: "http", Port: 8081, IPAddress: ""},
266 | {Type: "https", Port: 8082, IPAddress: "172.17.8.101", HostName: "test.com", CertHash: hash},
267 | }
268 |
269 | // Apply the new bindings
270 | if err := applySiteBindings(guid, bindings); err != nil {
271 | t.Fatal(err)
272 | }
273 |
274 | // Verify that the new binding match actual and expected
275 | if !doBindingsMatchSite(t, bindings, guid) {
276 | t.Fatal("Expected and Actual bindings do not match!")
277 | }
278 |
279 | // Test a scenario where bindings are not supplied (which should result in no bindings applied to site)
280 | bindings = []iisBinding{}
281 |
282 | // Apply the bindings
283 | if err := applySiteBindings(guid, bindings); err != nil {
284 | t.Fatal(err)
285 | }
286 |
287 | // Verify that bindings match
288 | if !doBindingsMatchSite(t, bindings, guid) {
289 | t.Fatal("Expected and Actual bindings do not match!")
290 | }
291 |
292 | // Cleanup site
293 | if err := deleteSite(guid); err != nil {
294 | t.Fatal(err)
295 | }
296 | }
297 |
298 | // Test a basic lifecycle of a website
299 | func TestWebsite(t *testing.T) {
300 | assert := assert.New(t)
301 |
302 | // Clean any pre-existing websites
303 | if err := purgeIIS(); err != nil {
304 | t.Fatal("Error purging: ", err)
305 | }
306 |
307 | // Create a website with the config and website name
308 | if err := createWebsite(&websiteConfig); err != nil {
309 | t.Fatal(err)
310 | }
311 |
312 | websiteConfig.Env["EXAMPLE_ENV_VAR_ALT"] = "test789"
313 |
314 | // Ensure create website is idempotent
315 | if err := createWebsite(&websiteConfig); err != nil {
316 | t.Fatal(err)
317 | }
318 |
319 | // Verify app pool settings match with given config
320 | if appPool, err := getAppPool(guid, true); err != nil {
321 | t.Fatal("Failed to get Site info!")
322 | } else {
323 | assert.Equal(websiteConfig.AppPoolIdentity.Identity, appPool.Add.ProcessModel.IdentityType, "AppPool Identity Type doesn't match!")
324 | assert.Equal(websiteConfig.AppPoolIdentity.Username, appPool.Add.ProcessModel.Username, "AppPool Identity Username doesn't match!")
325 | assert.Equal(websiteConfig.AppPoolIdentity.Password, appPool.Add.ProcessModel.Password, "AppPool Identity Password doesn't match!")
326 |
327 | // Verify env vars are properly set for both altered and non-altered env vars for IIS 10+
328 | if iisVersion, err := getVersion(); err != nil {
329 | t.Fatal(err)
330 | } else if iisVersion.Major >= 10 {
331 | expectedAppPoolEnvVars := []appPoolAddEnvVar{
332 | {Name: "EXAMPLE_ENV_VAR", Value: "test123"},
333 | {Name: "EXAMPLE_ENV_VAR_ALT", Value: "test789"},
334 | {Name: "FUN_SPACE", Value: "test456"},
335 | }
336 |
337 | assert.ElementsMatch(expectedAppPoolEnvVars, appPool.Add.EnvironmentVariables.Add, "AppPool EnvironmentVariables don't match!")
338 | }
339 | }
340 |
341 | // Verify that site settings match the given config
342 | if site, err := getSite(guid, true); err != nil {
343 | t.Fatal("Failed to get Site info!")
344 | } else {
345 | assert.Equal(site.Site.Application.VDirs[0].PhysicalPath, websiteConfig.Path, "Website path doesn't match desired path from config!")
346 | }
347 |
348 | // Start the website
349 | if err := startWebsite(guid); err != nil {
350 | t.Fatal(err)
351 | }
352 |
353 | // Ensure start website is idempotent
354 | if err := startWebsite(guid); err != nil {
355 | t.Fatal(err)
356 | }
357 |
358 | // Verify that the website is running
359 | if isRunning, err := isWebsiteRunning(guid); err != nil {
360 | t.Fatal(err)
361 | } else if !isRunning {
362 | t.Fatal("Website is not started!")
363 | }
364 |
365 | // Stop only the site
366 | if err := stopSite(guid); err != nil {
367 | t.Fatal(err)
368 | }
369 |
370 | // Kill all worker processes if they exist
371 | if processIDs, err := getWebsiteProcessIdsStr(guid); err != nil {
372 | t.Fatal(err)
373 | } else if len(processIDs) > 0 {
374 | for _, processID := range processIDs {
375 | cmd := exec.Command(`C:\Windows\System32\taskkill.exe`, "/PID", processID, "/F")
376 | if err := cmd.Run(); err != nil {
377 | t.Fatal(err)
378 | }
379 | }
380 | }
381 |
382 | // Verify that the website is deemed as not running
383 | if isRunning, err := isWebsiteRunning(guid); err != nil {
384 | t.Fatal(err)
385 | } else if isRunning {
386 | t.Fatal("Website is still running when Site is stopped!")
387 | }
388 |
389 | // Start only the site
390 | if err := startSite(guid); err != nil {
391 | t.Fatal(err)
392 | }
393 |
394 | // Verify that the website is deemed as running again
395 | if isRunning, err := isWebsiteRunning(guid); err != nil {
396 | t.Fatal(err)
397 | } else if !isRunning {
398 | t.Fatal("Website is not running when Site was started!")
399 | }
400 |
401 | // Stop only the apppool
402 | if err := stopAppPool(guid); err != nil {
403 | t.Fatal(err)
404 | }
405 |
406 | // Verify that the website is deemed as not running
407 | if isRunning, err := isWebsiteRunning(guid); err != nil {
408 | t.Fatal(err)
409 | } else if isRunning {
410 | t.Fatal("Website is still running when AppPool is stopped!")
411 | }
412 |
413 | // Start only the apppool
414 | if err := startAppPool(guid); err != nil {
415 | t.Fatal(err)
416 | }
417 |
418 | // Verify that the website is deemed as running again
419 | if isRunning, err := isWebsiteRunning(guid); err != nil {
420 | t.Fatal(err)
421 | } else if !isRunning {
422 | t.Fatal("Website is not running when AppPool was started!")
423 | }
424 |
425 | // Stop the website
426 | if err := stopWebsite(guid); err != nil {
427 | t.Fatal(err)
428 | }
429 |
430 | // Ensure stop website is idempotent
431 | if err := stopWebsite(guid); err != nil {
432 | t.Fatal(err)
433 | }
434 |
435 | // Verify that the website is not running
436 | if isRunning, err := isWebsiteRunning(guid); err != nil {
437 | t.Fatal(err)
438 | } else if isRunning {
439 | t.Fatal("Website is not stopped!")
440 | }
441 |
442 | // Delete the website
443 | if err := deleteWebsite(guid); err != nil {
444 | t.Fatal(err)
445 | }
446 |
447 | // Ensure delete website is idempotent
448 | if err := deleteWebsite(guid); err != nil {
449 | t.Fatal(err)
450 | }
451 |
452 | // Verify that the website is deleted
453 | if exists, err := doesWebsiteExist(guid); err != nil {
454 | t.Fatal(err)
455 | } else {
456 | assert.False(exists, "Website exists after deletion!")
457 | }
458 | }
459 |
460 | // Test website workflow with starter configs
461 | // Also tests stat collection with the configs enabling autostart for guaranteed worker processes
462 | func TestWebsiteWithConfig(t *testing.T) {
463 | assert := assert.New(t)
464 |
465 | // Clean any pre-existing websites
466 | if err := purgeIIS(); err != nil {
467 | t.Fatal("Error purging: ", err)
468 | }
469 |
470 | // Get parent dir of working dir to get xml file locations
471 | wd, err := os.Getwd()
472 | if err != nil {
473 | t.Fatal("Failed to get parent dir: ", err)
474 | }
475 | parentDir := filepath.Dir(wd)
476 |
477 | // Set default for appPool's identity here to prevent GHA-Hosted-CI from corrupting applicationHost.config due ot unknown user at time of running
478 | websiteConfig.AppPoolIdentity = iisAppPoolIdentity{}
479 |
480 | websiteConfig.AppPoolConfigPath = filepath.Join(parentDir, "test", "testapppool.xml")
481 | websiteConfig.SiteConfigPath = filepath.Join(parentDir, "test", "testsite.xml")
482 |
483 | // Create a website with the config and website name
484 | if err := createWebsite(&websiteConfig); err != nil {
485 | t.Fatal(err)
486 | }
487 |
488 | websiteConfig.Env["EXAMPLE_ENV_VAR_ALT"] = "test789"
489 |
490 | // Verify app pool settings match with given config
491 | if appPool, err := getAppPool(guid, true); err != nil {
492 | t.Fatal("Failed to get Site info!")
493 | } else {
494 | assert.Equal("ApplicationPoolIdentity", appPool.Add.ProcessModel.IdentityType, "AppPool Identity Type doesn't match!")
495 | assert.Equal(websiteConfig.AppPoolIdentity.Username, appPool.Add.ProcessModel.Username, "AppPool Identity Username doesn't match!")
496 | assert.Equal(websiteConfig.AppPoolIdentity.Password, appPool.Add.ProcessModel.Password, "AppPool Identity Password doesn't match!")
497 |
498 | // These values are supplied by the config.xml that is imported in from test/testapppool.xml and test/testsite.xml
499 | assert.Equal("", appPool.RuntimeVersion, "AppPool RuntimeVersion doesn't match!")
500 | assert.Equal("Integrated", appPool.PipelineMode, "AppPool PipelineMode doesn't match!")
501 |
502 | // Verify env vars are properly set for both altered and non-altered env vars for IIS 10+
503 | if iisVersion, err := getVersion(); err != nil {
504 | t.Fatal(err)
505 | } else if iisVersion.Major >= 10 {
506 | expectedAppPoolEnvVars := []appPoolAddEnvVar{
507 | {Name: "EXAMPLE_ENV_VAR", Value: "test123"},
508 | {Name: "EXAMPLE_ENV_VAR_ALT", Value: "test789"},
509 | {Name: "FUN_SPACE", Value: "test456"},
510 | }
511 |
512 | assert.ElementsMatch(expectedAppPoolEnvVars, appPool.Add.EnvironmentVariables.Add, "AppPool EnvironmentVariables don't match!")
513 | }
514 | }
515 |
516 | // Verify that site settings match the given config
517 | if site, err := getSite(guid, true); err != nil {
518 | t.Fatal("Failed to get Site info!")
519 | } else {
520 | assert.Equal(site.Site.Application.VDirs[0].PhysicalPath, websiteConfig.Path, "Website path doesn't match desired path from config!")
521 | }
522 |
523 | // Start the website
524 | if err := startWebsite(guid); err != nil {
525 | t.Fatal(err)
526 | }
527 |
528 | // Verify that the website is running
529 | if isRunning, err := isWebsiteRunning(guid); err != nil {
530 | t.Fatal(err)
531 | } else if !isRunning {
532 | t.Fatal("Website is not started!")
533 | }
534 |
535 | // Gather stats of the website's worker processes
536 | stats, err := getWebsiteStats(guid)
537 | if err != nil {
538 | t.Fatal(err)
539 | }
540 |
541 | // Verify gathering stats from a running site returns values
542 | assert.NotEqual(stats.WorkingSetPrivate, 0, "WorkingSetPrivate returned 0!")
543 | assert.NotEqual(stats.KernelModeTime, 0, "KernelModeTime returned 0!")
544 | assert.NotEqual(stats.UserModeTime, 0, "UserModeTime returned 0!")
545 |
546 | // Stop the website
547 | if err := stopWebsite(guid); err != nil {
548 | t.Fatal(err)
549 | }
550 |
551 | // Verify that the website is not running
552 | if isRunning, err := isWebsiteRunning(guid); err != nil {
553 | t.Fatal(err)
554 | } else if isRunning {
555 | t.Fatal("Website is not stopped!")
556 | }
557 |
558 | // Delete the website
559 | if err := deleteWebsite(guid); err != nil {
560 | t.Fatal(err)
561 | }
562 |
563 | // Verify that the website is deleted
564 | if exists, err := doesWebsiteExist(guid); err != nil {
565 | t.Fatal(err)
566 | } else {
567 | assert.False(exists, "Website exists after deletion!")
568 | }
569 | }
570 |
--------------------------------------------------------------------------------
/iis/state.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2020 Roblox Corporation
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 | */
17 |
18 | package iis
19 |
20 | import (
21 | "sync"
22 | )
23 |
24 | // taskStore provides a mechanism to store and retrieve
25 | // task handles given a string identifier. The ID should
26 | // be unique per task
27 | type taskStore struct {
28 | store map[string]*taskHandle
29 | lock sync.RWMutex
30 | }
31 |
32 | func newTaskStore() *taskStore {
33 | return &taskStore{store: map[string]*taskHandle{}}
34 | }
35 |
36 | func (ts *taskStore) Set(id string, handle *taskHandle) {
37 | ts.lock.Lock()
38 | defer ts.lock.Unlock()
39 | ts.store[id] = handle
40 | }
41 |
42 | func (ts *taskStore) Get(id string) (*taskHandle, bool) {
43 | ts.lock.RLock()
44 | defer ts.lock.RUnlock()
45 | t, ok := ts.store[id]
46 | return t, ok
47 | }
48 |
49 | func (ts *taskStore) Delete(id string) {
50 | ts.lock.Lock()
51 | defer ts.lock.Unlock()
52 | delete(ts.store, id)
53 | }
54 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2020 Roblox Corporation
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 | */
17 |
18 | package main
19 |
20 | import (
21 | log "github.com/hashicorp/go-hclog"
22 |
23 | "github.com/roblox/nomad-driver-iis/iis"
24 |
25 | "github.com/hashicorp/nomad/plugins"
26 | )
27 |
28 | func main() {
29 | // Serve the plugin
30 | plugins.Serve(factory)
31 | }
32 |
33 | // factory returns a new instance of a nomad driver plugin
34 | func factory(log log.Logger) interface{} {
35 | return iis.NewIISDriver(log)
36 | }
37 |
--------------------------------------------------------------------------------
/scripts/win_provision.ps1:
--------------------------------------------------------------------------------
1 | Set-ExecutionPolicy Bypass -Scope Process -Force
2 | [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
3 | Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
4 |
5 | choco install git.install --version=2.25.1 -y --no-progress
6 | choco install golang --version=1.15 -y --no-progress
7 | choco install nomad --version=1.0.4 -y --no-progress
8 |
9 | Stop-Service nomad
10 | Get-CimInstance win32_service -filter "name='nomad'" | Invoke-CimMethod -Name Change -Arguments @{StartName="LocalSystem"} | Out-Null
11 | $nomadDir = "C:\\ProgramData\\nomad"
12 | New-Item -ItemType Directory -Path "$nomadDir\\plugin" -Force
13 | Copy-Item "C:\\vagrant\\win_iis.exe" -Destination "$nomadDir\\plugin" -Force
14 | Copy-Item "C:\\vagrant\\test\\win_client.hcl" -Destination "$nomadDir\\conf\\client.hcl" -Force
15 | Start-Service nomad
16 |
17 | Import-PfxCertificate -FilePath "C:\\vagrant\\test\\test.pfx" -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String 'Test123!' -AsPlainText -Force)
18 |
19 | Install-WindowsFeature -Name Web-Server -IncludeAllSubFeature -IncludeManagementTools -Restart
20 |
--------------------------------------------------------------------------------
/test/test.pfx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Roblox/nomad-driver-iis/0ba1ca1435a51cc541d39ef5b4a441dcd9051fed/test/test.pfx
--------------------------------------------------------------------------------
/test/testapppool.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/test/testsite.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/test/win_client.hcl:
--------------------------------------------------------------------------------
1 | # Increase log verbosity
2 | log_level = "DEBUG"
3 | log_file = "C:\\ProgramData\\nomad\\logs\\nomad.log"
4 |
5 | # Setup data dir
6 | data_dir = "C:\\ProgramData\\nomad\\data"
7 | plugin_dir = "C:\\ProgramData\\nomad\\plugin"
8 |
9 | # Enable server mode
10 | server {
11 | enabled = true
12 | bootstrap_expect = 1
13 | }
14 |
15 | # Enable client mode
16 | client {
17 | enabled = true
18 | }
19 |
20 | advertise {
21 | http = "172.17.8.101"
22 | rpc = "172.17.8.101"
23 | serf = "172.17.8.101"
24 | }
25 |
26 | plugin "win_iis" {
27 | config {
28 | enabled = true
29 | stats_interval = "5s"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------