├── .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 | [![CI Actions Status](https://github.com/Roblox/nomad-driver-iis/workflows/CI/badge.svg)](https://github.com/Roblox/nomad-driver-iis/actions) 3 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/Roblox/nomad-driver-iis/blob/master/LICENSE) 4 | [![Release](https://img.shields.io/badge/version-0.1.0-blue)](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 | --------------------------------------------------------------------------------