Repository: plunder-app/plunder Branch: master Commit: 58306fce81c9 Files: 85 Total size: 441.4 KB Directory structure: gitextract_qm74k6_1/ ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── Readme.md ├── cmd/ │ ├── automate.go │ ├── configGenerator.go │ ├── plunder.go │ └── server.go ├── docs/ │ ├── actions.md │ ├── application_architecture.md │ ├── deployment.md │ ├── example_architecture.md │ ├── example_deployment.md │ ├── provisioning.md │ ├── readme.md │ └── service.md ├── go.mod ├── go.sum ├── hack/ │ └── comboot_ipxe/ │ ├── Dockerfile │ └── gen_comboot.ipxe ├── main.go ├── pkg/ │ ├── apiserver/ │ │ ├── README.md │ │ ├── client.go │ │ ├── config.go │ │ ├── endpoints.go │ │ ├── go.mod │ │ ├── handlerApiserver.go │ │ ├── logging.go │ │ ├── loggingHandlers.go │ │ ├── server.go │ │ └── types.go │ ├── certs/ │ │ ├── certs.go │ │ └── go.mod │ ├── go.mod │ ├── parlay/ │ │ ├── go.mod │ │ ├── handler.go │ │ ├── parlay.go │ │ ├── parlay_ui.go │ │ ├── parlaytypes/ │ │ │ ├── finder.go │ │ │ ├── go.mod │ │ │ └── parlaytypes.go │ │ ├── parser.go │ │ ├── parser_builder.go │ │ ├── plugin/ │ │ │ └── plugin.go │ │ ├── restore.go │ │ └── validate.go │ ├── plunderlogging/ │ │ ├── consolelogger.go │ │ ├── filelogger.go │ │ ├── go.mod │ │ ├── jsonlogger.go │ │ └── logger.go │ ├── services/ │ │ ├── deployments.go │ │ ├── go.mod │ │ ├── handler.go │ │ ├── server.go │ │ ├── serverDHCP.go │ │ ├── serverHTTP.go │ │ ├── serverHTTPISO.go │ │ ├── serverImageHTTP.go │ │ ├── serverTFTP.go │ │ ├── services.go │ │ ├── static_pxe.go │ │ ├── templateBOOTy.go │ │ ├── templateESXi.go │ │ ├── templateKickstart.go │ │ ├── templatePreseed.go │ │ ├── templateUtils.go │ │ └── types.go │ ├── ssh/ │ │ ├── go.mod │ │ ├── sshClient.go │ │ ├── sshCommand.go │ │ ├── sshConfig.go │ │ ├── sshImport.go │ │ └── sshTransfer.go │ └── utils/ │ ├── go.mod │ ├── ipxe.go │ ├── nic.go │ └── utils.go ├── plugin/ │ ├── docker/ │ │ ├── docker.go │ │ └── docker_actions.go │ ├── example.go │ └── kubeadm/ │ ├── kubeadm.go │ └── kubeadm_actions.go └── testing.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .plunderserver.yaml plunder plunderclient.yaml ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:experimental # Build BOOTy as an init FROM golang:1.14-alpine as dev RUN apk add --no-cache git ca-certificates make COPY . /go/src/github.com/plunder-app/plunder WORKDIR /go/src/github.com/plunder-app/plunder ENV GO111MODULE=on RUN --mount=type=cache,sharing=locked,id=gomod,target=/go/pkg/mod/cache \ --mount=type=cache,sharing=locked,id=goroot,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux make build FROM scratch COPY --from=dev /go/src/github.com/plunder-app/plunder/plunder / ENTRYPOINT ["/plunder"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ SHELL := /bin/sh # The name of the executable (default is current directory name) TARGET := plunder .DEFAULT_GOAL: $(TARGET) # These will be provided to the target VERSION := 0.5.0 BUILD := `git rev-parse HEAD` # Required for the move to go modules for >v0.5.0 export GO111MODULE=on # Operating System Default (LINUX) TARGETOS=linux # Use linker flags to provide version/build settings to the target LDFLAGS=-ldflags "-X=main.Version=$(VERSION) -X=main.Build=$(BUILD) -s" REPOSITORY = plndr DOCKERREPO ?= $(TARGET) DOCKERTAG ?= latest .PHONY: all build clean install uninstall fmt simplify check run lint vet all: check install $(TARGET): $(SRC) @go build $(LDFLAGS) -o $(TARGET) build: $(TARGET) @true clean: @rm -f $(TARGET) install: @echo Building and Installing project @go install $(LDFLAGS) install_plugin: @make plugins @echo Installing plugins -mkdir ~/plugin -cp -pr ./plugin/*.plugin ~/plugin/ uninstall: clean @rm -f $$(which ${TARGET}) fmt: @gofmt -l -w $(SRC) vet: @go vet $(SRC) lint: @golint $(SRC) # This is typically only for quick testing dockerx86: @docker buildx build --platform linux/amd64 --load -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) -f Dockerfile . @echo New Multi Architecture Docker image created docker: @docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) -f Dockerfile . @echo New Multi Architecture Docker image created plugins: @echo Building plugins @GO111Module=off go build -buildmode=plugin -o ./plugin/example.plugin ./plugin/example.go @GO111Module=off go build -buildmode=plugin -o ./plugin/kubeadm.plugin ./plugin/kubeadm/* @GO111Module=off go build -buildmode=plugin -o ./plugin/docker.plugin ./plugin/docker/* release_darwin: @echo Creating Darwin Build @GOOS=darwin make build @GOOS=darwin make plugins @zip -9 -r plunder-darwin-$(VERSION).zip ./plunder ./plugin/*.plugin @rm plunder @rm ./plugin/*.plugin release_linux: @echo Creating Linux Build @GOOS=linux make build @GOOS=linux make plugins @zip -9 -r plunder-linux-$(VERSION).zip ./plunder ./plugin/*.plugin @rm plunder @rm ./plugin/*.plugin simplify: @gofmt -s -l -w $(SRC) check: @test -z $(shell gofmt -l main.go | tee /dev/stderr) || echo "[WARN] Fix formatting issues with 'make fmt'" make lint make vet run: install @$(TARGET) ================================================ FILE: Readme.md ================================================ # Plunder The complete tool for finding **Infrastructure** gold amongst bits of bare-metal! ![Plunder Captain](./image/plunder_captain.png) ## Overview Plunder is a single-binary server that is all designed in order to make the provisioning of servers, platforms and applications easier. It is deployed as a server that an end user can interact with through it's **Api-server** in order to control and automate the usage. At this time interacting with the api-server is detailed in the source [https://github.com/plunder-app/plunder/blob/master/pkg/apiserver/endpoints.go](https://github.com/plunder-app/plunder/blob/master/pkg/apiserver/endpoints.go), however documentation will be added soon. From an end-user interaction a plunder control utility has been created: [https://github.com/plunder-app/pldrctl](https://github.com/plunder-app/pldrctl) - provides the capability to query and create deployments and configurations within a plunder instance. ### Services - `DHCP` - Allocating an IP addressing and pointing to a TFTP server - `TFTP` - Bootstrapping an Operating system install (uses iPXE) - `HTTP` - Provides a services where the bootstrap can pull the components needed for the OS install. An operating system can be easily performed using either **preseed** or **kickstart**, alternatively custom kernels and init ramdisks can be specified to be used based upon Mac address. ### Automation Further more once the operating system has been provisioned there are usually post-deployment tasks in order to complete an installation. Plunder has the capability to do the following: - `Remote command execution` - Over SSH (key configured above) - `Scripting engine` - A JSON/YAML language that also supports plugins to extend the capablities of the automation engine. A small repository of existing deployment maps has been created [https://github.com/plunder-app/maps](https://github.com/plunder-app/maps) ### Additional features - `iso support` - Plunder no longer requires a user with elevated privileges to mount an OS ISO in order to read the contents. Plunder can read files directly from the iso file and expose them to an installer through `http`. - `online updates` - As all configuration to plunder is exposed and managed through an API, it provides the capability of performing most configuration changes with no down time or restarts. - `in-memory configurations` - Plunder will create all deployment configurations and hold them in memory, meaning that it is stateless and it doesn't leave configuration all over a filesystem - `VMware deployment support` - Plunder can deploy preseed/kickstart and now vSphere installations. - `Management of unclaimed devices` - Plunder will watch and keep a pool of devices that aren't being deployed and can force them to reboot/restart until they're needed for deployment. - `Logging of remote execution` - Plunder can now store all execution logs in-memory until told to clear them. ## Getting Plunder Prebuilt binaries for Darwin(MacOS)/Linux and Windows can be found on the [releases](https://github.com/plunder-app/plunder/releases) page. ### Building If you wish to build the code yourself then this can be done simply by running: ``` go get -u github.com/plunder-app/plunder ``` Alternatively clone the repository and either `go build` or `make build`, note that using the makefile will ensure that the current git commit and version number are returned by `plunder version`. ## Usage! One of the key design concepts was to try to simplify the amount of moving parts required to bootstrap a server, therefore `plunder` aims to be a single tool that you can use. It also aims to simplify the amount of configuration files and configuration work required, it does this by auto-detecting most configuration and producing mainly completed configuration as needed. One thing to be aware of is that `plunder` doesn't require replacing anything that already exists in the infrastructure. The documentation is available [here](./docs/) ### Warning *NOTE 1* As this provides low-level networking services, only run on a network that is safe to do so. Providing DHCP on a network that already provides DHCP services can lead to un-expected behaviour (and angry network administrators) *NOTE 2* As DHCP/TFTP and HTTP all bind to low ports < 1024, root access (or sudo) is required to start the plunder services. # Troubleshooting PXE booting provides very little feedback when things aren't working, but usually the hand-off is why things wont work i.e. `DHCP` -> `TFTP` boot. Logs from `plunder` should show the hand-off from the CLI. # Roadmap - Ability to automate deployments over VMware VMTools - Windows deployments - Tidier logging - Stability enhancements - Additional plugins ================================================ FILE: cmd/automate.go ================================================ package cmd import ( "encoding/json" "fmt" "io/ioutil" "net/http" "net/url" "os" "github.com/ghodss/yaml" "github.com/plunder-app/plunder/pkg/parlay" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" parlayplugin "github.com/plunder-app/plunder/pkg/parlay/plugin" "github.com/plunder-app/plunder/pkg/ssh" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) // These flags are used to determine a deployment var deploymentSSH, mapFile, logFile, deploymentEndpoint *string // These flags are used to override SSH configuration var usernameSSH, keypathSSH, addressSSH *string // These flags are used to determine if a particular deployment, action and specific host need to be used. var deploymentName, actionName, host *string // These flags are used for management of plugins var pluginPath, pluginAction, pluginActions *string // This flag determines if a singular action should occur or whether to resume all actions from this point var resume *bool // UI Json output only, when this is try the UI selections will just create the associated JSON var jsonOutput, yamlOutput *bool func init() { // Global flags for automation logFile = plunderAutomate.PersistentFlags().String("logfile", "", "Path to where plunder will write automation logs") mapFile = plunderAutomate.PersistentFlags().String("map", "", "Path to a plunder map") // SSH Deployment flags deploymentSSH = plunderAutomate.PersistentFlags().String("deployconfig", "", "Path to a plunder deployment configuration") usernameSSH = plunderAutomate.PersistentFlags().String("overrideUsername", "", "(optional) Override Username") keypathSSH = plunderAutomate.PersistentFlags().String("overrideKeypath", "", "(Optional) Override path to a key") addressSSH = plunderAutomate.PersistentFlags().String("overrideAddress", "", "(Optional) Override address to automate against") // Plunder endpoing Deployment flags deploymentEndpoint = plunderAutomate.PersistentFlags().String("deployendpoint", "", "URL of plunder server to pull the deployment configuration") // Deployment control flags deploymentName = plunderAutomateSSH.Flags().String("deployment", "", "Automate a specific deployment") actionName = plunderAutomateSSH.Flags().String("action", "", "Automate a specific action") host = plunderAutomateSSH.Flags().String("host", "", "Automate the deployment for a specific host") resume = plunderAutomateSSH.Flags().Bool("resume", false, "Resume all actions after the one specified by --action") // Plugin Flags pluginPath = plunderAutomatePluginUsage.Flags().String("plugin", "", "Path to a specific plugin typically ~./plugin/[X].plugin") pluginAction = plunderAutomatePluginUsage.Flags().String("action", "", "Action to retrieve the usage of") pluginActions = plunderAutomatePluginActions.Flags().String("plugin", "", "Path to a specific plugin typically ~./plugin/[X].plugin") jsonOutput = plunderAutomateUI.Flags().Bool("json", false, "Print the JSON to stdout, no execution of commands") yamlOutput = plunderAutomateUI.Flags().Bool("yaml", false, "Print the YAML to stdout, no execution of commands") plunderAutomatePlugins.AddCommand(plunderAutomatePluginUsage) plunderAutomatePlugins.AddCommand(plunderAutomatePluginActions) plunderAutomatePlugins.AddCommand(plunderAutomatePluginTest) // Automate Subcommands plunderAutomate.AddCommand(plunderAutomateValidate) plunderAutomate.AddCommand(plunderAutomateSSH) plunderAutomate.AddCommand(plunderAutomateVMware) plunderAutomate.AddCommand(plunderAutomatePlugins) plunderAutomate.AddCommand(plunderAutomateUI) plunderCmd.AddCommand(plunderAutomate) } // PlunderAutomate var plunderAutomate = &cobra.Command{ Use: "automate", Short: "Automate the deployment of a platform/application", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) cmd.Help() return }, } // plunderAutomatePlugins var plunderAutomatePlugins = &cobra.Command{ Use: "plugin", Short: "Automate the deployment of a platform/application", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) parlayplugin.ListPlugins() return }, } // plunderAutomatePlugins var plunderAutomatePluginUsage = &cobra.Command{ Use: "usage", Short: "Display the usage of a plugin action", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) parlayplugin.UsagePlugin(*pluginPath, *pluginAction) return }, } // plunderAutomatePlugins var plunderAutomatePluginActions = &cobra.Command{ Use: "actions", Short: "Display the actions of a particular plugin", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) parlayplugin.ListPluginActions(*pluginActions) return }, } // plunderAutomatePlugins var plunderAutomatePluginTest = &cobra.Command{ Use: "test", Short: "Test the actions of the example plugin", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) test := `{ "name": "Example of test action", "type": "exampleAction/test", "plugin": { "credentials": "AAABBBCCCCDDEEEE", "address": "172.0.0.1" } }` var action parlaytypes.Action _ = json.Unmarshal([]byte(test), &action) _, err := parlayplugin.ExecuteActionInPlugin("./plugin/example.plugin", "127.0.0.1", "example/test", action.Plugin) if err != nil { log.Fatalf("%v", err) } return }, } // plunderAutomateSSH var plunderAutomateSSH = &cobra.Command{ Use: "ssh", Short: "Automate over ssh", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) // If deploymentPath is not blank then the flag has been used if *deploymentSSH != "" { log.Infof("Reading deployment configuration from [%s]", *deploymentSSH) // Check the actual path from the string if _, err := os.Stat(*deploymentSSH); !os.IsNotExist(err) { config, err := ioutil.ReadFile(*deploymentSSH) if err != nil { log.Fatalf("%v", err) } err = ssh.ImportHostsFromRawDeployment(config) if err != nil { cmd.Help() log.Fatalf("%v", err) } } else { log.Fatalf("Unable to open [%s]", *deploymentSSH) } } else if *deploymentEndpoint != "" { u, err := url.Parse(*deploymentEndpoint) if err != nil { log.Fatalf("%v", err) } // TODO - fix dynamic u.Path = "/deployments" resp, err := http.Get(u.String()) if err != nil { log.Fatalf("%v", err) } //var config server.DeploymentConfigurationFile defer resp.Body.Close() config, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatalf("%v", err) } err = ssh.ImportHostsFromRawDeployment(config) if err != nil { cmd.Help() log.Fatalf("%v", err) } } // Add a host from the override flags if *addressSSH != "" { err := ssh.AddHost(*addressSSH, *keypathSSH, *usernameSSH) if err != nil { log.Fatalf("%v", err) } } // If there are zero hosts in the ssh Host array then we have no authentication information if len(ssh.Hosts) == 0 { cmd.Help() log.Fatalf("No Deployment information imported") } log.Infof("Found [%d] ssh configurations", len(ssh.Hosts)) if *mapFile != "" { log.Infof("Reading deployment configuration from [%s]", *mapFile) var deployment parlaytypes.TreasureMap // // Check the actual path from the string if _, err := os.Stat(*mapFile); !os.IsNotExist(err) { b, err := ioutil.ReadFile(*mapFile) if err != nil { log.Fatalf("%v", err) } deployment, err = parseMapFile(b) if err != nil { log.Fatalf("%v", err) } // If a specific deployment is being used then find it's details if *deploymentName != "" { log.Infof("Looking for deployment [%s]", *deploymentName) foundDeployment, err := deployment.FindDeployment(*deploymentName, *actionName, *host, *logFile, *resume) if err != nil { log.Fatalf("%s", err) } err = parlay.DeploySSH(foundDeployment, *logFile, false, false) } else { // Parse the entire deployment err = parlay.DeploySSH(&deployment, *logFile, false, false) } if err != nil { log.Fatalf("%v", err) } } else { log.Fatalf("%v", err) } } return }, } // plunderAutomateValidate var plunderAutomateValidate = &cobra.Command{ Use: "validate", Short: "Validate a deployment map", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) if *mapFile != "" { log.Infof("Reading deployment configuration from [%s]", *mapFile) //var err error var deployment parlaytypes.TreasureMap // // Check the actual path from the string if _, err := os.Stat(*mapFile); !os.IsNotExist(err) { b, err := ioutil.ReadFile(*mapFile) if err != nil { log.Fatalf("%v", err) } deployment, err = parseMapFile(b) if err != nil { log.Fatalf("%v", err) } deploymentCount := len(deployment.Deployments) if deploymentCount == 0 { log.Fatalf("Zero deployments have been found") } log.Infof("Validating [%d] deployments", deploymentCount) for x := range deployment.Deployments { actionCount := len(deployment.Deployments[x].Actions) if actionCount == 0 { log.Fatalf("Zero deployments have been found") } log.Infof("Validating [%d] actions", actionCount) for y := range deployment.Deployments[x].Actions { err := parlay.ValidateAction(&deployment.Deployments[x].Actions[y]) if err != nil { log.Warnf("Action [%s] Error [%v]", deployment.Deployments[x].Actions[y].Name, err) } } } } else { log.Fatalf("Unable to open [%s]", *mapFile) } } else { cmd.Help() log.Fatalln("No Deployment map specified") } }, } // plunderAutomateVMware var plunderAutomateVMware = &cobra.Command{ Use: "vmw", Short: "Automate over VMware tools protocol", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) //var newMap *parlay.TreasureMap if *mapFile != "" { log.Infof("Reading deployment configuration from [%s]", *mapFile) //var err error var deployment parlaytypes.TreasureMap // // Check the actual path from the string if _, err := os.Stat(*mapFile); !os.IsNotExist(err) { b, err := ioutil.ReadFile(*mapFile) if err != nil { log.Fatalf("%v", err) } deployment, err = parseMapFile(b) if err != nil { log.Fatalf("%v", err) } // If a specific deployment is being used then find it's details if *deploymentName != "" { log.Infof("Looking for deployment [%s]", *deploymentName) foundDeployment, err := deployment.FindDeployment(*deploymentName, *actionName, *host, *logFile, *resume) if err != nil { log.Fatalf("%s", err) } err = parlay.DeploySSH(foundDeployment, *logFile, false, false) } else { // Parse the entire deployment err = parlay.DeploySSH(&deployment, *logFile, false, false) } if err != nil { log.Fatalf("%v", err) } } else { log.Fatalf("%v", err) } } }, } // plunderAutomateUI var plunderAutomateUI = &cobra.Command{ Use: "ui", Short: "Enable the user interface to manage a deployment", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) var newMap *parlaytypes.TreasureMap if *mapFile != "" { log.Infof("Reading deployment configuration from [%s]", *mapFile) //var err error var deployment parlaytypes.TreasureMap // // Check the actual path from the string if _, err := os.Stat(*mapFile); !os.IsNotExist(err) { b, err := ioutil.ReadFile(*mapFile) if err != nil { log.Fatalf("%v", err) } deployment, err = parseMapFile(b) if err != nil { log.Fatalf("%v", err) } newMap, err = parlay.StartUI(&deployment) if err != nil { log.Fatalf("%v", err) } } } // If we're using the UI to build a new map then print to stdout(in either format) if *jsonOutput == true { b, _ := json.MarshalIndent(newMap, "", "\t") fmt.Printf("%s\n", b) return } if *yamlOutput == true { b, _ := yaml.Marshal(newMap) fmt.Printf("%s\n", b) return } if *deploymentSSH != "" { log.Infof("Reading deployment configuration from [%s]", *deploymentSSH) // Check the actual path from the string if _, err := os.Stat(*deploymentSSH); !os.IsNotExist(err) { config, err := ioutil.ReadFile(*deploymentSSH) if err != nil { log.Fatalf("%v", err) } // Parse all of the hosts in the deployment configuration and update the ssh package with their details err = ssh.ImportHostsFromRawDeployment(config) if err != nil { cmd.Help() log.Fatalf("%v", err) } } else { log.Fatalf("Unable to open [%s]", *deploymentSSH) } } else if *deploymentEndpoint != "" { // Parse the endpoint, this will attempt to pull all of the configuration information and pass it to the SSH package u, err := url.Parse(*deploymentEndpoint) if err != nil { log.Fatalf("%v", err) } u.Path = "/deployment" resp, err := http.Get(u.String()) if err != nil { log.Fatalf("%v", err) } defer resp.Body.Close() config, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatalf("%v", err) } err = ssh.ImportHostsFromRawDeployment(config) if err != nil { cmd.Help() log.Fatalf("%v", err) } } // Add a host from the override flags if *addressSSH != "" { err := ssh.AddHost(*addressSSH, *keypathSSH, *usernameSSH) if err != nil { log.Fatalf("%v", err) } } // If there are zero hosts in the ssh Host array then we have no authentication information if len(ssh.Hosts) == 0 { cmd.Help() log.Fatalf("No Deployment information imported") } err := parlay.DeploySSH(newMap, *logFile, false, false) if err != nil { log.Fatalf("%v", err) } }, } func parseMapFile(b []byte) (deployment parlaytypes.TreasureMap, err error) { jsonBytes, err := yaml.YAMLToJSON(b) if err == nil { // If there were no errors then the YAML => JSON was succesful, no attempt to unmarshall err = json.Unmarshal(jsonBytes, &deployment) if err != nil { return deployment, fmt.Errorf("Unable to parse [%s] as either yaml or json", *mapFile) } } else { // Couldn't parse the yaml to JSON // Attempt to parse it as JSON err = json.Unmarshal(b, &deployment) if err != nil { return deployment, fmt.Errorf("Unable to parse [%s] as either yaml or json", *mapFile) } } return deployment, nil } ================================================ FILE: cmd/configGenerator.go ================================================ package cmd import ( "encoding/json" "fmt" "net" "os" "strings" "time" "github.com/ghodss/yaml" booty "github.com/plunder-app/BOOTy/pkg/plunderclient/types" "github.com/plunder-app/plunder/pkg/apiserver" "github.com/plunder-app/plunder/pkg/certs" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" "github.com/plunder-app/plunder/pkg/services" "github.com/plunder-app/plunder/pkg/utils" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) // These variables are used to capture input from the CLI var output, detectNic, serverPath, clientPath string var configAPIServerPort int var pretty bool func init() { plunderCmd.AddCommand(plunderConfig) plunderConfig.PersistentFlags().StringVarP(&output, "output", "o", "json", "Ouput type, should be either JSON or YAML") plunderConfig.PersistentFlags().BoolVarP(&pretty, "pretty", "p", false, "Ouput JSON in a pretty/Human readable format") plunderServerConfig.PersistentFlags().StringVarP(&detectNic, "nic", "n", "", "Build configuration for a particular network interface") // Persistent above both client functions plunderAPIConfig.PersistentFlags().IntVar(&configAPIServerPort, "port", 60443, "Port that the plunder API server should use") // Path for Server plunderAPIConfigServer.Flags().StringVar(&serverPath, "path", ".plunderserver.yaml", "Path that the plunder API server config should be written to") // Path for Client plunderAPIConfigClient.Flags().StringVar(&clientPath, "path", "plunderclient.yaml", "Path that the plunder API client config should be written to") // Add sub commands to APIServer plunderAPIConfig.AddCommand(plunderAPIConfigClient) plunderAPIConfig.AddCommand(plunderAPIConfigServer) // Add all sub commands to the config sub command plunderConfig.AddCommand(plunderAPIConfig) plunderConfig.AddCommand(plunderServerConfig) plunderConfig.AddCommand(plunderDeploymentConfig) plunderConfig.AddCommand(PlunderParlayConfig) plunderCmd.AddCommand(plunderGet) } // PlunderConfig - This is for intialising a blank or partial configuration var plunderConfig = &cobra.Command{ Use: "config", Short: "Initialise a plunder configuration", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) cmd.Help() return }, } // PlunderServerConfig - This is for intialising a blank or partial configuration var plunderServerConfig = &cobra.Command{ Use: "server", Short: "Initialise a plunder configuration", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) // Indent (or pretty-print) the configuration output bc := &services.BootConfig{ Kernel: "/kernelPath", Initrd: "/initPath", Cmdline: "cmd=options", ConfigName: "demo config", ConfigType: "default", } detectServerConfig() services.Controller.BootConfigs = append(services.Controller.BootConfigs, *bc) err := renderOutput(services.Controller, pretty) if err != nil { log.Fatalf("%v", err) } return }, } // PlunderDeploymentConfig - This is for intialising a blank or partial configuration var plunderDeploymentConfig = &cobra.Command{ Use: "deployment", Short: "Initialise a server configuration", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) // Create an example Global configuration globalConfig := services.HostConfig{ Gateway: "192.168.0.1", NTPServer: "192.168.0.1", NameServer: "192.168.0.1", Adapter: "ens192", Subnet: "255.255.255.0", // OS Provision Username: "user", Password: "pass", Packages: "openssh-server cloud-guest-utils", RepositoryAddress: "192.168.0.1", MirrorDirectory: "/ubuntu", SSHKeyPath: "/home/deploy/.ssh/id_pub.rsa", SSHKey: "ssh-rsa AABBCCDDEE1122334455", // BOOTy BOOTYAction: booty.ReadImage, LVMRootName: "/dev/ubuntu-vg/root", DestinationDevice: "/dev/sda", DestinationAddress: "http://192.168.0.1/image", SourceImage: "http://192.168.0.1/images/ubuntu.img", SourceDevice: "/dev/sda", } // Set compressed pointer compressed := false globalConfig.Compressed = &compressed // Addtional step to create the partition information defaultPartition := 1 globalConfig.GrowPartition = &defaultPartition // Create an example Host configuration hostConfig := services.HostConfig{ IPAddress: "192.168.0.2", ServerName: "Server01", } hostDeployConfig := services.DeploymentConfig{ MAC: "00:11:22:33:44:55", ConfigHost: hostConfig, //ConfigName: "default", } configuration := &services.DeploymentConfigurationFile{ GlobalServerConfig: globalConfig, } configuration.Configs = append(configuration.Configs, hostDeployConfig) // Indent (or pretty-print) the configuration output err := renderOutput(configuration, pretty) if err != nil { log.Fatalf("%v", err) } return }, } // PlunderParlayConfig - This is for intialising a parlay deployment var PlunderParlayConfig = &cobra.Command{ Use: "parlay", Short: "Initialise a parlay configuration", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) parlayActionPackage := parlaytypes.Action{ Name: "Add package", ActionType: "pkg", PkgManager: "apt", PkgOperation: "install", Packages: "mysql", } parlayActionCommand := parlaytypes.Action{ Name: "Run Command", ActionType: "command", Command: "which uptime", CommandSudo: "root", CommandSaveAsKey: "cmdKey", } parlayActionUpload := parlaytypes.Action{ Name: "Upload File", ActionType: "upload", Source: "./my_file", Destination: "/tmp/file", } parlayActionDownload := parlaytypes.Action{ Name: "Download File", ActionType: "download", Destination: "./my_file", Source: "/tmp/file", } parlayActionKey := parlaytypes.Action{ Name: "Execute key", ActionType: "command", KeyName: "cmdKey", } parlayDeployment := parlaytypes.Deployment{ Name: "Install MySQL", Hosts: []string{"192.168.0.1", "192.168.0.2"}, } parlayDeployment.Actions = append(parlayDeployment.Actions, parlayActionPackage) parlayDeployment.Actions = append(parlayDeployment.Actions, parlayActionCommand) parlayDeployment.Actions = append(parlayDeployment.Actions, parlayActionUpload) parlayDeployment.Actions = append(parlayDeployment.Actions, parlayActionDownload) parlayDeployment.Actions = append(parlayDeployment.Actions, parlayActionKey) parlayConfig := &parlaytypes.TreasureMap{} parlayConfig.Deployments = []parlaytypes.Deployment{} parlayConfig.Deployments = append(parlayConfig.Deployments, parlayDeployment) // Render the output to screen err := renderOutput(parlayConfig, pretty) if err != nil { log.Fatalf("%v", err) } return }, } // plunderGet - The Get command will pull any required components (iPXE boot files) var plunderGet = &cobra.Command{ Use: "get", Short: "Get any components needed for bootstrapping (internet access required)", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) err := utils.PullPXEBooter() if err != nil { log.Fatalf("%v", err) } return }, } // plunderAPIConfig - The Get command will pull any required components (iPXE boot files) var plunderAPIConfig = &cobra.Command{ Use: "apiserver", Short: "Generate the configuration for the api server", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) cmd.Help() return }, } // plunderAPIConfigServer - The Get command will pull any required components (iPXE boot files) var plunderAPIConfigServer = &cobra.Command{ Use: "server", Short: "Generate the configuration for the api server", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) err := certs.GenerateKeyPair(nil, time.Now(), (24*time.Hour)*365) if err != nil { log.Fatalln(err) } err = apiserver.WriteServerConfig(serverPath, "", "", configAPIServerPort, certs.GetPem(), certs.GetKey()) if err != nil { log.Fatalln(err) } return }, } // plunderAPIConfigServer - The Get command will pull any required components (iPXE boot files) var plunderAPIConfigClient = &cobra.Command{ Use: "client", Short: "Generate the configuration for a client for the API server", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) s, err := apiserver.OpenServerConfig(serverPath) if err != nil { log.Fatalln(err) } hostname, err := os.Hostname() if err != nil { log.Fatalln(err) } apiserver.WriteClientConfig(clientPath, hostname, s) return }, } func renderOutput(data interface{}, pretty bool) error { var d []byte var err error switch strings.ToLower(output) { case "yaml": d, err = yaml.Marshal(data) case "json": if pretty { d, err = json.MarshalIndent(data, "", "\t") } else { d, err = json.Marshal(data) } default: return fmt.Errorf("Unknown output type [%s]", output) } if err != nil { return err } // Print out the output to STDOUT fmt.Printf("%s\n", d) return nil } func detectServerConfig() error { // Find an example nic to use, that isn't the loopback address nicName, nicAddr, err := utils.FindIPAddress(detectNic) if err != nil { return err } // Attempt to parse th returned IP address and apply simple incrementation to determin DHCP start range ip := net.ParseIP(nicAddr) ip = ip.To4() if ip == nil { return fmt.Errorf("error parsing IP address of adapter [%s]", detectNic) } ip[3]++ // Prepopulate the flags with the found nic information services.Controller.AdapterName = &nicName services.Controller.HTTPAddress = &nicAddr services.Controller.TFTPAddress = &nicAddr *services.Controller.PXEFileName = "undionly.kpxe" // DHCP Settings services.Controller.DHCPConfig.DHCPAddress = nicAddr services.Controller.DHCPConfig.DHCPSubnet = "255.255.255.0" services.Controller.DHCPConfig.DHCPGateway = nicAddr services.Controller.DHCPConfig.DHCPDNS = nicAddr services.Controller.DHCPConfig.DHCPLeasePool = 20 services.Controller.DHCPConfig.DHCPStartAddress = ip.String() return nil } ================================================ FILE: cmd/plunder.go ================================================ package cmd import ( "fmt" "os" "strconv" "github.com/plunder-app/plunder/pkg/utils" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) // Release - this struct contains the release information populated when building plunder var Release struct { Version string Build string } var plunderCmd = &cobra.Command{ Use: "plunder", Short: "This is a tool for finding gold amongst bare-metal (and provisioning kubernetes)", } var logLevel int var filePath string func init() { plunderUtilsEncode.Flags().StringVar(&filePath, "path", "", "Path to a file to encode") // Global flag across all subcommands plunderCmd.PersistentFlags().IntVar(&logLevel, "logLevel", 4, "Set the logging level [0=panic, 3=warning, 5=debug]") plunderCmd.AddCommand(plunderVersion) plunderCmd.AddCommand(plunderUtils) plunderUtils.AddCommand(plunderUtilsEncode) } // Execute - starts the command parsing process func Execute() { if os.Getenv("PLUNDER_LOGLEVEL") != "" { i, err := strconv.ParseInt(os.Getenv("PLUNDER_LOGLEVEL"), 10, 8) if err != nil { log.Fatalf("Error parsing environment variable [PLUNDER_LOGLEVEL") } // We've only parsed to an 8bit integer, however i is still a int64 so needs casting logLevel = int(i) } else { // Default to logging anything Info and below logLevel = int(log.InfoLevel) } log.SetLevel(log.Level(logLevel)) if err := plunderCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } var plunderVersion = &cobra.Command{ Use: "version", Short: "Version and Release information about the plunder tool", Run: func(cmd *cobra.Command, args []string) { fmt.Printf("Plunder Release Information\n") fmt.Printf("Version: %s\n", Release.Version) fmt.Printf("Build: %s\n", Release.Build) }, } var plunderUtils = &cobra.Command{ Use: "utils", Short: "Additional utilities for Plunder", Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, } var plunderUtilsEncode = &cobra.Command{ Use: "encode", Short: "This will encode a file into Hex", Run: func(cmd *cobra.Command, args []string) { hex, err := utils.FileToHex(filePath) if err != nil { log.Fatalf("%v", err) } fmt.Printf("%s", hex) }, } ================================================ FILE: cmd/server.go ================================================ package cmd import ( "io/ioutil" "os" "github.com/plunder-app/plunder/pkg/apiserver" "github.com/plunder-app/plunder/pkg/parlay" "github.com/plunder-app/plunder/pkg/services" "github.com/plunder-app/plunder/pkg/utils" "github.com/spf13/cobra" log "github.com/sirupsen/logrus" ) //var controller server.BootController var dhcpSettings services.DHCPSettings var apiServerPath, gateway, dns, startAddress, configPath, deploymentPath, defaultKernel, defaultInitrd, defaultCmdLine *string var leasecount, port *int var anyboot, insecure *bool func init() { // Prepopulate the flags with the found nic information services.Controller.AdapterName = PlunderServer.Flags().String("adapter", "", "Name of adapter to use e.g eth0, en0") services.Controller.HTTPAddress = PlunderServer.Flags().String("addressHTTP", "", "Address of HTTP to use, if blank will default to [addressDHCP]") services.Controller.TFTPAddress = PlunderServer.Flags().String("addressTFTP", "", "Address of TFTP to use, if blank will default to [addressDHCP]") services.Controller.EnableDHCP = PlunderServer.Flags().Bool("enableDHCP", false, "Enable the DCHP Server") services.Controller.EnableTFTP = PlunderServer.Flags().Bool("enableTFTP", false, "Enable the TFTP Server") services.Controller.EnableHTTP = PlunderServer.Flags().Bool("enableHTTP", false, "Enable the HTTP Server") services.Controller.PXEFileName = PlunderServer.Flags().String("iPXEPath", "undionly.kpxe", "Path to an iPXE bootloader") // DHCP Settings PlunderServer.Flags().StringVar(&services.Controller.DHCPConfig.DHCPAddress, "addressDHCP", "", "Address to advertise leases from, ideally will be the IP address of --adapter") PlunderServer.Flags().StringVar(&services.Controller.DHCPConfig.DHCPGateway, "gateway", "", "Address of Gateway to use, if blank will default to [addressDHCP]") PlunderServer.Flags().StringVar(&services.Controller.DHCPConfig.DHCPDNS, "dns", "", "Address of DNS to use, if blank will default to [addressDHCP]") PlunderServer.Flags().IntVar(&services.Controller.DHCPConfig.DHCPLeasePool, "leasecount", 20, "Amount of leases to advertise") PlunderServer.Flags().StringVar(&services.Controller.DHCPConfig.DHCPStartAddress, "startAddress", "", "Start advertised address [REQUIRED]") //HTTP Settings defaultKernel = PlunderServer.Flags().String("kernel", "", "Path to a kernel to set as the *default* kernel") defaultInitrd = PlunderServer.Flags().String("initrd", "", "Path to a ramdisk to set as the *default* ramdisk") defaultKernel = PlunderServer.Flags().String("cmdline", "", "Additional command line to pass to the *default* kernel") // Config File configPath = PlunderServer.Flags().String("config", "", "Path to a plunder server configuration") deploymentPath = PlunderServer.Flags().String("deployment", "", "Path to a plunder deployment configuration") PlunderServer.Flags().StringVar(&services.DefaultBootType, "defaultBoot", "", "In the event a boot type can't be found default to this") // API Server configuration port = PlunderServer.Flags().IntP("port", "p", 60443, "Port that the Plunder API server will listen on") insecure = PlunderServer.Flags().BoolP("insecure", "i", false, "Start the Plunder API server without encryption") apiServerPath = PlunderServer.Flags().String("path", ".plunderserver.yaml", "Path to configuration for the API Server") plunderCmd.AddCommand(PlunderServer) } // PlunderServer - This is for intialising a blank or partial configuration var PlunderServer = &cobra.Command{ Use: "server", Short: "Start Plunder Services", Run: func(cmd *cobra.Command, args []string) { log.SetLevel(log.Level(logLevel)) var deployment []byte // If deploymentPath is not blank then the flag has been used if *deploymentPath != "" { // if *anyboot == true { // log.Errorf("AnyBoot has been enabled, all configuration will be ignored") // } log.Infof("Reading deployment configuration from [%s]", *deploymentPath) if _, err := os.Stat(*deploymentPath); !os.IsNotExist(err) { deployment, err = ioutil.ReadFile(*deploymentPath) if err != nil { log.Fatalf("%v", err) } } } // if *anyboot == true { // services.AnyBoot = true // } // If configPath is not blank then the flag has been used if *configPath != "" { log.Infof("Reading configuration from [%s]", *configPath) // Check the actual path from the string if _, err := os.Stat(*configPath); !os.IsNotExist(err) { configFile, err := ioutil.ReadFile(*configPath) if err != nil { log.Fatalf("%v", err) } // Read the controller from either a yaml or json format err = services.ParseControllerData(configFile) if err != nil { log.Fatalf("%v", err) } } else { log.Fatalf("Unable to open [%s]", *configPath) } } if *services.Controller.EnableDHCP == false && *services.Controller.EnableHTTP == false && *services.Controller.EnableTFTP == false { log.Warnln("All services are currently disabled") } // If we've enabled DHCP, then we need to ensure a start address for the range is populated if *services.Controller.EnableDHCP && services.Controller.DHCPConfig.DHCPStartAddress == "" { log.Fatalln("A DHCP Start address is required") } if services.Controller.DHCPConfig.DHCPLeasePool == 0 { log.Fatalln("At least one available lease is required") } services.Controller.StartServices(deployment) // Run the API server in a seperate go routine go func() { err := apiserver.StartAPIServer(*apiServerPath, *port, *insecure) if err != nil { log.Fatalf("%v", err) } }() // Register the packages to the apiserver services.RegisterToAPIServer() parlay.RegisterToAPIServer() // Sit and wait for a control-C utils.WaitForCtrlC() return }, } ================================================ FILE: docs/actions.md ================================================ # Actions When a deployment is executed against a host(s) typically one or more **actions** will be performed against that host in order to configure as expected. This document details the **built-in** actions, however to extend the functionality of [plunder](github.com/plunder-app/plunder) there is the capability to extend the available actions through the use of plugins. ## Built-in Actions All actions are defined by a `type` which specifies what tasks the action will perform, also all actions should come with a `name` that identifies what the action will perform. The names should make it easy to identify relevant tasks as they're executed or when selecting individual tasks when using the user Interface. Example in json and yaml below: ```json { "task" : "command", "command" : "docker run image", "name" : "Starts the docker image \"image\"" } ``` ```yaml - task: download source: "/home/user/my_archive.tar.gz" name: "Retrieve the home archive" ``` ### Command The **command** action type is used to execute a command either locally or remote, it will exit execution if the command fails (or it can be ignored) and the results can be stored to be executed at a later point. Set the `ignoreFail` to `true` to allow execution of tasks to continue in the event that the command fails. If a long running task should be known to only execute for a specific amount of time, commands can be given a timeout which will end the command should it not complete in time. The `timeout` setting should be set in seconds which will specify how long the task is allowed to execute for. ```yaml - task: command command: "sleep 100" timeout: 99 ignoreFail: true ``` *The above example will execute a sleep for a hundred seconds, however the command has a timeout set for only 99 seconds. Execution will be halted once the timeout is met, and if the task returns a fail code the execution will continue onto the next action* If a command requires elevated privileges, the `commandSudo` option allows executing a command as different user, with it's entitled privileges. **Note**: This requires `NOPASSWD` to be set for the current user. ```yaml { "task" : "command", "command" : "cat /dev/null > /var/log/messages", "name" : "Concatenate the messages file to clear space", "commandSudo" : "root" } ``` #### Using commands between actions deployments There may be a requirment to save the output of a command to be used in a different action or a different deployment, some commands will generate tokens or output that can be used at a later point. There are two options to save the output of a command: - `commandSaveFile` - saves the command output to a path - `commandSaveAsKey` - Saves the ouput in-memory under a specified `key` These saved ouputs can then be used later through the use of the `key` options: - `KeyFile` - executes the commands in the file specified under the `path` - `KeyName` - executes the commands saved in-memory under the specified `key` The below example will create a command Key under the name `joinKey` (JSON format) : ```json { "name" : "Generate a join token", "type" : "command", "command" : "kubeadm token create --print-join-command 2>/dev/null", "commandSaveAsKey" : "joinKey" } ``` This key can now be used in a different deployment with different hosts (YAML format): ```yaml - type: "command" name: "Join worker to Kubernetes cluster" keyName: "joinKey" commandSudo : "root" ``` #### Piping data between commands In the event that data needs to piped into a remote command the options `commandPipeFile` and `commandPipeCmd` can be used. The first will take the contents of `path` and pass it as `STDIN` to the command being executed under the option `command`. The `commandPipeCmd` will execute a command locally and pass the `STDOUT` of that command into the `STDIN` of the command being ran under the `command` option. The below example will run the command `echo "deb https://apt.kubernetes.io/ kubernetes-xenial main"` locally, and pass the `STDOUT` to the command `tee /etc/apt/sources.list.d/kubernetes.list` that is being ran using `sudo` privileges. ```yaml - type: command command: "tee /etc/apt/sources.list.d/kubernetes.list" commandPipeCmd: echo "deb https://apt.kubernetes.io/ kubernetes-xenial main" name: Set Kubernetes Repository commandSudo: root ``` This is useful for a variety of usecases, although it has been found very useful for appending data to existing files that require elevated privilieges. **Example reasons for piping data to a command** The command `echo "SOME data" | tee /a/file/that/needs/sudo/privs` will fail even with `commandSudo`, the reason for this is that the `sudo` is only going to work for everything upto the pipe. The remaining part of the command will be ran as the current user and therefore doesn't have the required privileges. ### Upload / Download of files Both of the command types `upload` and `download` have the same set of options: - `destination` - Where the file will be once the `upload`/`download` has completed - `source` - The file that will be either `uploaded`/`downloaded` - `name` - Details what the action will be doing ```yaml - type: download destination: ./ubuntu.tar.gz name: Retrieve local copy of ubuntu.tar.gz source: ./ubuntu.tar.gz ``` ### Plugins Plugins allow the creation of unique actions to be performed, such as specific interactions with platforms, programs and infrastructure. All plugins will load at startup and register their actions into the parlay engine. Passing information to a plugin should be done in the following manner: ```yaml - name: Push kubernetes images for managers plugin: imageName: - k8s.gcr.io/kube-apiserver:v1.14.0 - k8s.gcr.io/kube-controller-manager:v1.14.0 - k8s.gcr.io/kube-scheduler:v1.14.0 localSudo: true remoteSudo: true type: docker/image ``` The main differences are: - `plugin` - Contains all of the specifics that will be passed to the plugin logic - `type` - Should be the action defined by the plugin itself. ## Additonal configuration ### No Password sudo To enable password-less sudo the `/etc/sudoers` file needs modifying (DO NOT DO THIS MANUALLY). To edit the sudo file use the following command: ``` sudo visudo ``` Then add the following entry to the end of the file, replacing the `username` with the correct entry : ``` username ALL=(ALL) NOPASSWD:ALL ``` This can be tested by either opening a new session or logging out and back in and then testing that `sudo ` doesn't require a password. ================================================ FILE: docs/application_architecture.md ================================================ # Application Architecture The purpose of this document is to outline the architecture of the plunder program itself, as it has predominantly been developed by a single developer the logic sometimes is hard to fathom (or to understand after a period of absence) ## Application Server routine When starting plunder as a server for deployment a number of files are parsed and internal structures populated, below is a step through of the actions that take place. ### Starting the server (HTTP Enabled) We will start `plunder` with a *default* json configuration, with the services enabled and pointing to a default ubuntu kernel/initrd. The deployment file has a single server defined in it. `plunder server --config ./config.json --deployment ./deployment.json` 1. Plunder starts - parses flags - parses global `config.json` 2. Plunder will start services enabled in the configuration `controller.StartServices(deployment)` (`cmd\server.go`) 3. If a deployment file is passed then it should be parsed `err := UpdateConfiguration(deployment)` (`pkg\server\services.go`) - The parsing of this will generate strings that are mapped to urls that are tracked in a map `httpPaths` - The function `UpdateConfiguration(configFile []byte)` (`pkg/server/generator.go`) will generate these in memory by iterating through the file and checking the deployment type. 4. HTTP Server is started with `err := c.serveHTTP()`(`pkg\server\services.go`) 5. This function will create a number of prebuilt PXE boot strings using the kernels etc. from `config.json`, configurations such as `/preboot.ipxe` etc. 6. In the event that new configuration is passed to the server then steps 3 are ran again. ### Client connections 1. A Host starts and proceeds to PXE boot, by doing a DHCP request. 2. The DHCP server defaults to point the `BootFileName` Option `dhcp.OptionBootFileName:[]byte(*c.PXEFileName)`(`pkg/server/services.go`), also checks for the dhcp option `77` saying `iPXE` (which will be false) 3. This is passed of TFTP to the booting host which will start iPXE and re-do a DHCP request 4. This time however the DHCP client will have the option `77` set to `iPXE` which means that it's ready for provisioning. 5. The DHCP server will look for an existing configuration `deploymentType := FindDeployment(mac)` (`pkg/server/serve_dhcp.go`), which should return `preseed` etc. 6. The DHCP server will then look to see if a specific configuration has been created with `if httpPaths[fmt.Sprintf("%s.ipxe", dashMac)] == ""` (`pkg/server/serve_dhcp.go`), if not it will default to a deployment type 7. If there exists a pre-defined configuration then it will set the DHCP option to that. ================================================ FILE: docs/deployment.md ================================================ # Deployment Configuration ## Generating a configuration A `./plunder config deployment > deployment.json` will create a blank deployment configuration that can be pre-populated in order to create specific deployments. A configured deployment should resemble something like the example below: ```json { "globalConfig": { "adapter": "ens192", "gateway": "192.168.0.1", "subnet": "255.255.255.0", "nameserver": "192.168.0.1", "ntpserver": "192.168.0.1", "username": "user", "password": "pass", "repoaddress": "192.168.0.1", "mirrordir": "/ubuntu", "sshkeypath": "/home/deploy/.ssh/id_pub.rsa", "sshkey": "ssh-rsa AABBCCDDEE1122334455", "packages": "nginx openssh-server" }, "deployments": [ { "mac": "00:11:22:33:44:55", "bootConfigName": "default", "bootConfig": { "configName": "", "kernelPath": "", "initrdPath": "", "cmdline": "" }, "config": { "address": "192.168.0.2", "hostname": "Server01" } } ] } ``` ## Configuration overview The *globalConfig* is the configuration that is inherited by any of the deployment configurations where that information has been omitted, typically a lot of networking information, keys or package information will be shared amongst deployments. Placing the same information into an actual deployment will **override** the configuration inherited from the `globalConfig`. ### Shared Configuration overview - `gateway` - The gateway a server will be configured to use as default router - `subnet` - The network range server will be configured to use - `nameserver` - DNS server to resolve hostnames - `ntpserver` - The address of a timeserver - `adapter` - Which specific adapter will be configured - `swapEnabled` - Build the Operating system without swap being created - `username` - A default user that will be created - `password` - A password for the above user - `repoaddress` - The hostname/ip address of the server where the OS packages reside - `sshkeypath` - The path to an ssh key that will be added to the image for authenticating ### Deployment specific - `address` - A unique network address that will be added to the server - `hostname` - A unique hostname to be added to the provisioned server As mentioned above, a lot of fields can be ignored and the entry from the `globalConfig` will be used. ### Deployments The deployment contains things that will make a server unique! - `mac` - The unqique HW mac address of a server to configure - `kernelPath` - If a specific kernel should be used (for things like LinuxKit) - `initrdPath` - If a specific init ramdisk should be used - `cmdline` - Any arguments that should be passed to the kernel ramdisk The `deployment` specifies how the server will be provisioned, there are three options: - `preseed` Ubuntu/Debian pressed deployment - `kickstart` CentOS/RHEL deployment - `reboot` This is for servers that need to be kept on a reboot loop. The remaining `config` allows updates or overrides to the global confgiguration detailed above. ### Online updates of deployment configuration The webserver exposes a `/deployment` end point that can be used to provide an online update of the configuration, this has the following benefits: - Allows automation of updates, through things like an API call - Provides no-downtime, stopping and starting the server to load a new configuration can result in a broken installation as the network connection will be broken during restart *Retrieve the existing configuration* The currently active configuration can be retrieved through a simple get on the `/deployment` endpoint e.g. `curl -vX /deployment` *Updating the configuration* The configuration can be updated by `POST`ing the configuration JSON to the same URL. e.g. `curl -vX POST deploy01/deployment -d @deployment.json --header "Content-Type: application/json"` ## Usage With configuration for both the services and the deployments completed, they can both be passed to `plunder` in order for servers to be built. As shown below: ``` sudo ./plunder server --config ./config.json --deployment ./deployment.json --logLevel 5 [sudo] password for dan: INFO[0000] Reading configuration from [./config.json] INFO[0000] Starting Remote Boot Services, press CTRL + c to stop DEBU[0000] Server IP: 192.168.1.1 Adapter: ens192 Start Address: 192.168.1.2 Pool Size: 100 INFO[0000] RemoteBoot => Starting DHCP INFO[0000] RemoteBoot => Starting TFTP DEBU[0000] Server IP: 192.168.1.1 PXEFile: undionly.kpxe INFO[0000] Opening and caching undionly.kpxe INFO[0000] RemoteBoot => Starting HTTP INFO[0286] DCHP Message: Discover ``` ## Next Steps Servers that have their mac addresses in the `deployment` file will be passed the correct bootloader and they will ultimately be provisioned with the networking information as part of the configuration, they also will be provisioned with the credentials and specified ssh key. For provisioning applications or a platform details are [here](./provisioning.md). ================================================ FILE: docs/example_architecture.md ================================================ # Example architecture This document outlines an example architecture that one can consider when structuring or designing a network that will ultimately make use of servers bootstrapped by plunder. ## Infrastructure design In the architecture below the blue cube is the server or VM that will host Plunder and expose it's services. This machine has two adapters `ens160` and `ens192` although they could well be `eth0`/`eth1` depending on your Linux distribution. The adapter `ens160` is connected to a public network where a user can connect to it's exposed IP address (`192.168.0.100`) over a protocol such as SSH, in order to interact with the OS (and plunder). The second adapter `ens192` is connected to a private network, where a number of other hosts as repeatedly rebooting waiting for a bootstrap server to provision them. ![](../image/simple_architecture.jpg) ## Services Overview The services that plunder can expose will bind to the existing operating system in two ways. #### DHCP This will ultimately bind to an adapter, and this adapter should be configured with an address. #### TFTP This will ultimately bind to an IP address. #### HTTP This will also bind to an IP address. ## Example Plunder usage The CLI examples below don't make use of any configuration files or dynamic updates and provide a quick and easy way of exposing multiple services from Plunder. ``` sudo ./plunder server \ --adapter ens192 \ --enableDHCP \ --enableTFTP \ --enableHTTP \ --initrd initrd.gz \ --kernel kernel \ --cmdline "console=tty0" \ --addressDHCP 192.168.1.1 \ --startAddress 192.168.1.130 \ --addressTFTP 192.1.1.1 \ --addressHTTP 192.168.1.1 \ --anyboot ``` To understand the CLI line above, we will break it down into what some of the more hard-to-understand flags actually are doing. - `--adapter <...>` This specified which adapter DHCP will broadcast from - `--enableXXXX` Enable a specific service, in most cases all will be needed unless existing services already exist. - `--addressDHCP ` This is the address that should be configured on the adapter that you're binding too. - `--addressTFTP/HTTP` This can either be the same address as above or an address of an existing service - `--startAddress ` This is the beginning on the advertised DHCP addresses. **Note** `sudo` has to be used as binding to an adapter and ports <1024 requires root privileges. ## Example Plunder usage with Linuxkit If you're using [LinuxKit](https://github.com/linuxkit/linuxkit) images then they can be consumed in the same way as described above. We've simply copied the created OS image files from linuxkit and copied them to our deployment server in the `~/linuxkit/` folder. ``` sudo ./plunder server \ --adapter ens192 \ --enableDHCP \ --enableTFTP \ --enableHTTP \ --initrd linuxkit/linuxkit-initrd.img \ --kernel linuxkit/linuxkit-kernel \ --cmdline $(cat ./linuxkit/linuxkit-cmdline) \ --addressDHCP 192.168.1.1 \ --startAddress 192.168.1.130 \ --addressTFTP 192.1.1.1 \ --addressHTTP 192.168.1.1 \ --anyboot ``` ================================================ FILE: docs/example_deployment.md ================================================ # Example Deployment for off-line Kubernetes **This example will make use of Plunders User Interface** In order for an offline installation to be succesful, a lot of the packages and containers will need downloading to where Plunder will be ran from. ## Offline Calico parts ### Download the manifests ``` wget https://docs.projectcalico.org/v3.5/getting-started/kubernetes/installation/hosted/etcd.yaml ``` and ``` wget https://docs.projectcalico.org/v3.5/getting-started/kubernetes/installation/hosted/calico.yaml ``` ### Download the named images One Liner to pull the calico images and etcd image ``` for image in $(cat etcd.yaml | grep image | awk '{ print $2 }') ; do sudo docker pull $image; done ``` ``` for image in $(cat calico.yaml | grep image | awk '{ print $2 }') ; do sudo docker pull $image; done ``` At this point you'll have the images as part of the local docker repository and the two manifests in the local directory. ## Offline Ubuntu packages One liner to get the packages needed for the kubernetes hosts to run `kubelet` ``` apt-get download socat ethtool ebtables; tar -cvzf ubuntu_pkg.tar.gz socat* ethtool* ebtables*; rm socat* ethtool* ebtables* ``` This command will download everything needed into an archive named `ubuntu_pkg.tar.gz` ## Offline Docker packages One liner to get the docker-ce packages for all kubernetes hosts, ensure that the docker repository has been added to the hosts repositories before attempting to download the package. ``` apt-get download docker-ce=18.06.1~ce~3-0~ubuntu; tar -cvzf docker_pkg.tar.gz ./docker-ce_18.06.1~ce~3-0~ubuntu_amd64.deb; rm docker-ce_18.06.1~ce~3-0~ubuntu_amd64.deb ``` ## Offline Kubernetes packages One liner to get the kubernetes packages for all kubernetes hosts, ensure that the kubernets repository has been added to the hosts repositories before attempting to download the package. ``` apt-get download kubelet kubeadm kubectl cri-tools kubernetes-cni; tar -cvzf kubernetes_pkg.tar.gz kubelet* kubeadm* kubectl* cri-tools* kubernetes-cni*; rm kubelet* kubeadm* kubectl* cri-tools* kubernetes-cni* ``` ## Offline Kubernetes images The easiest way of managing this is to install kubeadm on the pluder host and use `kubeadm` to prep the local docker image store with the images needed. `kubeadm config images list` - will list all images `kubeadm config images pull` - will pull them all to the local host Once all of the images have been pulled locally or downloaded as tars manually from the registry we can modify out deployment map and deploy as expected. ## Example deployment map There is an example deployment map as a `gist` available [https://gist.github.com/thebsdbox/f12b621a9d3943128b6bb16688497cd0](https://gist.github.com/thebsdbox/f12b621a9d3943128b6bb16688497cd0) ## Deployment in action [![asciicast](https://asciinema.org/a/reh3reEgJQKCOB5e92D96l6tt.png)](https://asciinema.org/a/reh3reEgJQKCOB5e92D96l6tt) ================================================ FILE: docs/provisioning.md ================================================ # Provisioning Configuration The provisioning works by running remote commands or uploading/downloading files to a remote system, in order for it to be configured correctly. A parsing engine called "parlay" was written in order to provide repeatable scripting to ease deployments. A Deployment map can contain multiple **deployments**, which in turn will contain one or more **actions** that will be performed on one or more **hosts**. Also a deployment map can be parsed as either **JSON** or as **YAML** (yaml being somewhat easier to read as a human and creating much smaller files). ### Example deployment map This script below (for offline installations) will upload a tarball containing the docker packages and then install them on all remote systems listed under `hosts`. **Note** the tarball was created by `apt-get download docker-ce=18.06.1~ce~3-0~ubuntu; tar -cvzf docker_pkg.tar.gz ./docker-ce_18.06.1~ce~3-0~ubuntu_amd64.deb` #### JSON Example ```json { "deployments": [ { "name": "Upload Docker Packages", "parallel": false, "sessions": 0, "hosts": [ "192.168.1.3", "192.168.1.4", "192.168.1.5" ], "actions": [ { "name": "Upload Docker Packages", "type": "upload", "source": "./docker_pkg.tar.gz", "destination": "/tmp/docker_pkg.tar.gz" }, { "name": "Extract Docker packages", "type": "command", "command": "tar -C /tmp -xvzf /tmp/docker_pkg.tar.gz" }, { "name": "Install Docker packages", "type": "command", "command": "dpkg -i /tmp/docker/*", "commandSudo": "root" } ] } ] } ``` #### YAML Example ```yaml deployments: - actions: - destination: /tmp/docker_pkg.tar.gz name: Upload Docker Packages source: ./docker_pkg.tar.gz timeout: 0 type: upload - command: tar -C /tmp -xvzf /tmp/docker_pkg.tar.gz name: Extract Docker packages timeout: 0 type: command - command: dpkg -i /tmp/docker/* commandSudo: root name: Install Docker packages timeout: 0 type: command hosts: - 192.168.1.3 - 192.168.1.4 - 192.168.1.5 name: Upload Docker Packages parallel: false parallelSessions: 0 ``` The above example only covers simple usage of `uploading` and `command` usages. ## Usage When automating a deployment ssh credentials are required to map a host with the correct credentials. To simplify this `plunder` can make use of: - A `deployment` file as detailed [here](./deployment.md), which parlay will extract the `ssh` information from to allow authentication - A **deployment endpoint**, which is effectively the url of a running plunder instance. Parlay will evaluate the endpoint for the configuration details to allow authentication. **Example** Using a map to deploy wordpress (`wordpress.yaml`) and a local deployment file. `plunder automate ssh --map ./wordpress.yaml --deployconfig ./deployment.json` Using a map to deploy wordpress (`wordpress.yaml`) and a deployment endpoint. `plunder automate ssh --map ./wordpress.yaml --deployendpoint http://localhost` It is possible to override or completely omit deployment configuration and specify the configuration at runtime through the flags `--override{Address/Keypath/Username}`. By **default** plunder will attempt to populate the Keypath and username from the current user and their `$HOME/.ssh/` directory. `/plunder automate --map ./stackedmanager.yaml --overrideAddress 192.168.1.105` Under most circumstances plunder will execute all actions in every deployment (on every host in the deployment), however it is possible to tell plunder to execute a single deployment/action from a map and on which particular host. Additional Flags: - The `--deployment` flag now will point to a specific deployment in a map - The `--action` flag can be used to point to a specific action in a deployment - The `--host` flag will point to a specific host in the deployment - The `--resume` will determine if to continue executing all remaining actions ### User Interface Plunder can also make automation easier by providing a user interface for a map and allowing the user to select which Deployments, actions and the hosts that will be acted upon. To use the user interface the subcommand `ui` should be used, all other flags are the same as above. **Example** ``` plunder automate ui --map ./stackedmanager.yaml --deployendpoint http://localhost INFO[0000] Reading deployment configuration from [./stackedmanager.yaml] ? Select deployment(s) [Use arrows to move, type to filter] > [ ] Reset any Kubernetes configuration (and remove packages) [ ] Configure host OS for kubernetes nodes [ ] Deploy Kubernetes Images for 1.14.0 [ ] Initialise Kubernetes Master (1.14) [ ] Deploy Calico (3.6) ``` The UI also provides additional capability to create new maps based upon selected deployments and actions, and also to convert between formats. - `--json` Print the JSON to stdout, no execution of commands - `--yaml` Print the YAML to stdout, no execution of commands **Execution of a map is shown in the screen shot below** ![](../image/parlay.jpg) *The above example uses screen, where the output from `plunder` is on the top and `tail -f output` is below* ================================================ FILE: docs/readme.md ================================================ # Plunder Usage When using `plunder` there are a few things that you will need to ensure that a configuration exists for, these things are: - Service configuration (IP Addresses, adapter names, service enablement) - Deployment configuration (MAC Addresses, Package management, networking) - Provisioning configuration (File transfer, remote command execution) Most of the configuration required will be automatically generated by `plunder` through the use of the `plunder config` sub command. To view an example architecture and quick usage than look [here](./example_architecture.md). ### Service The services such as DHCP and TFTP etc.. are the basic requirement in order to bootstrap a **blank** bare-metal server or new virtual machine. Service configuration overview and usage is located [here](./service.md). ### Deployment Once a **blank** server boots it will need an Operating System (+ packages) installing, along with setting up networking and credentials. Deployment configuration overview and usage is located [here](./deployment.md). ### Provisioning Once a server has been deployed it is on to provisioning that server for a particular use case, such as a docker swarm cluster or a kubernetes platform A Provisioning overview with usage is located [here](./provisioning.md). ================================================ FILE: docs/service.md ================================================ # Service Configuration ## Generating a configuration A `./plunder config server > config.json` will look at the network configuration of the current machine and build a default configuration file (in json). This file will need opening in your favourite text editor an modifying to ensure that `plunder` works correctly. ### Modifying the configuration ```json { "adapter": "en0", "enableDHCP": false, "dhcpConfig": { "addressDHCP": "192.168.0.142", "startDHCP": "192.168.0.143", "leasePoolDHCP": 20, "gatewayDHCP": "192.168.0.142", "nameserverDHCP": "192.168.0.142" }, "enableTFTP": false, "addressTFTP": "192.168.0.142", "enableHTTP": false, "addressHTTP": "192.168.0.142", "pxePath": "undionly.kpxe", "bootConfigs": [ { "configName": "default", "kernelPath": "/kernelPath", "initrdPath": "/initPath", "cmdline": "cmd=options", "isoPrefix": "ubuntu", "isoPath": "/path/to/iso" } ] } ``` *Example generated configuration above* ### Sections By **default** the configuration that is generated will have all of the services disabled (dhcp/tftp/http) and attempting to start plunder will result in an error message saying that no services are being started. #### Services The `enable` will ensure that a particular functionality is enabled within Plunder. The `addressTFTP` and `addressHTTP` are still required to be set even if you're not enabling the service, this is because those values will be passed through `DHCP` to a server that is being bootstrapped. So if `TFTP` or `HTTP` services already exist on your network, then modify those values accordingly. #### DHCP The `dhcpConfig` section details all of the configuration for the running DHCP server such as the `startDHCP` setting which should typically be `addressDHCP` +1 and then the `leasePoolDHCP` defines how many free addresses will be allocated sequentially from the start address. #### Boot Configurations The boot configurations are an array of configurations that define various remote booting configurations and are referenced via the `configName`. The `kernelPath` and `initrdPath` should point to a kernel and init ramdisk on the local filesystem that will be passed to the server once the bootloader has finished. Finally, the `isoPrefix` (determines the beginning and unique path to contents) and the `isoPath` allow OS installation content to be read from within an ISO file. e.g. `plunderAddress/isoPrefix/path/to/file` #### Additional The `pxePath` should point to an iPXE bootloader if needed, however if the file doesn't exist or if the option is blank then `plunder` will fall back to an embedded bootloader. ## Usage At this point you can start various services and you'll see servers on the network requesting `DHCP` addresses etc.. however in order to do anything we will need to configure the [deployment](./deployment.md). ================================================ FILE: go.mod ================================================ module github.com/plunder-app/plunder go 1.12 require ( github.com/AlecAivazis/survey/v2 v2.0.7 // indirect github.com/c4milo/gotoolkit v0.0.0-20190525173301-67483a18c17a // indirect github.com/ghodss/yaml v1.0.0 github.com/gorilla/mux v1.7.4 // indirect github.com/hooklift/assert v0.0.0-20170704181755-9d1defd6d214 // indirect github.com/hooklift/iso9660 v1.0.0 // indirect github.com/kr/pty v1.1.8 // indirect github.com/krolaw/dhcp4 v0.0.0-20190909130307-a50d88189771 // indirect github.com/mattn/go-colorable v0.1.6 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.11.0 // indirect github.com/plunder-app/BOOTy v0.0.0-20200513203223-f43f6ea742c4 github.com/plunder-app/plunder/pkg/apiserver v0.0.0-20200514155151-dfdcaab2e5cd github.com/plunder-app/plunder/pkg/certs v0.0.0-20200514155151-dfdcaab2e5cd github.com/plunder-app/plunder/pkg/parlay v0.0.0-20200514155151-dfdcaab2e5cd github.com/plunder-app/plunder/pkg/parlay/parlaytypes v0.0.0-20200514155151-dfdcaab2e5cd github.com/plunder-app/plunder/pkg/plunderlogging v0.0.0-20200514155151-dfdcaab2e5cd // indirect github.com/plunder-app/plunder/pkg/services v0.0.0-20200514155151-dfdcaab2e5cd github.com/plunder-app/plunder/pkg/ssh v0.0.0-20200514155151-dfdcaab2e5cd github.com/plunder-app/plunder/pkg/utils v0.0.0-20200514155151-dfdcaab2e5cd github.com/sirupsen/logrus v1.6.0 github.com/spf13/cobra v1.0.0 github.com/spf13/pflag v1.0.5 // indirect github.com/thebsdbox/go-tftp v0.0.0-20190329154032-a7263f18c49c // indirect github.com/whyrusleeping/go-tftp v0.0.0-20180830013254-3695fa5761ee // indirect golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 // indirect golang.org/x/net v0.0.0-20200513185701-a91f0712d120 // indirect golang.org/x/text v0.3.2 // indirect ) replace ( github.com/plunder-app/plunder/pkg/apiserver => ./pkg/apiserver github.com/plunder-app/plunder/pkg/certs => ./pkg/certs github.com/plunder-app/plunder/pkg/parlay => ./pkg/parlay github.com/plunder-app/plunder/pkg/services => ./pkg/services github.com/plunder-app/plunder/pkg/ssh => ./pkg/ssh github.com/plunder-app/plunder/pkg/utils => ./pkg/utils github.com/plunder-app/BOOTy => ../../plunder-app/BOOTy ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z35w/rc= github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/c4milo/gotoolkit v0.0.0-20190525173301-67483a18c17a h1:+uvtaGSLJh0YpLLHCQ9F+UVGy4UOS542hsjj8wBjvH0= github.com/c4milo/gotoolkit v0.0.0-20190525173301-67483a18c17a/go.mod h1:txokOny9wavBtq2PWuHmj1P+eFwpCsj+gQeNNANChfU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/digineo/go-dhclient v1.0.2/go.mod h1:DPvyqGEW8irJvp2lrnGfQWpjj6VidXX9STLBTfNing4= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/gopacket v1.1.17/go.mod h1:UdDNZ1OO62aGYVnPhxT1U6aI7ukYtA/kB8vaU0diBUM= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/hooklift/assert v0.0.0-20170704181755-9d1defd6d214 h1:WgfvpuKg42WVLkxNwzfFraXkTXPK36bMqXvMFN67clI= github.com/hooklift/assert v0.0.0-20170704181755-9d1defd6d214/go.mod h1:kj6hFWqfwSjFjLnYW5PK1DoxZ4O0uapwHRmd9jhln4E= github.com/hooklift/iso9660 v1.0.0 h1:GYN0ejrqTl1qtB+g+ics7xxWHp7J2B1zmr25O9EyG3c= github.com/hooklift/iso9660 v1.0.0/go.mod h1:sOC47ru8lB0DlU0EZ7BJ0KCP5rDqOvx0c/5K5ADm8H0= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/krolaw/dhcp4 v0.0.0-20190909130307-a50d88189771 h1:t2c2B9g1ZVhMYduqmANSEGVD3/1WlsrEYNPtVoFlENk= github.com/krolaw/dhcp4 v0.0.0-20190909130307-a50d88189771/go.mod h1:0AqAH3ZogsCrvrtUpvc6EtVKbc3w6xwZhkvGLuqyi3o= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdlayher/raw v0.0.0-20191004140158-e1402808046b/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.11.0 h1:4Zv0OGbpkg4yNuUtH0s8rvoYxRCNyT29NVUo6pgPmxI= github.com/pkg/sftp v1.11.0/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/plunder-app/BOOTy v0.0.0-20200513203223-f43f6ea742c4 h1:XBPLmj1YuM8GlnueA/IMUKQDVVKP7E7NOjx1SlAd/g0= github.com/plunder-app/BOOTy v0.0.0-20200513203223-f43f6ea742c4/go.mod h1:ItncOB62Q244Txg1v6/S3XZq3KB+9uHcmmZhJF+5Z+A= github.com/plunder-app/plunder/pkg/parlay v0.0.0-20200513203243-eccb418a5255 h1:FBXZMKgQ+YD3i18EZlSlnSFyhyHE654d+woetczrsC8= github.com/plunder-app/plunder/pkg/parlay v0.0.0-20200513203243-eccb418a5255/go.mod h1:5UuNaULcTUSFIkrbe+NA/ufh9iyUEtsKqmjlbl88AVw= github.com/plunder-app/plunder/pkg/parlay/parlaytypes v0.0.0-20200513203243-eccb418a5255 h1:765Djvc0TdpwZFlEmyq1ruUPx69klzHQOMAMCK1KJZM= github.com/plunder-app/plunder/pkg/parlay/parlaytypes v0.0.0-20200513203243-eccb418a5255/go.mod h1:QtxXmGRkwdtiiH03oveOPcYXucOv/FxJq2a170aXkxg= github.com/plunder-app/plunder/pkg/parlay/parlaytypes v0.0.0-20200514154439-933b7120d270 h1:p59jcYFaRO6LOJz/OAcdPNSlbgl1QPH4cmlFs69BZ6g= github.com/plunder-app/plunder/pkg/parlay/parlaytypes v0.0.0-20200514154439-933b7120d270/go.mod h1:QtxXmGRkwdtiiH03oveOPcYXucOv/FxJq2a170aXkxg= github.com/plunder-app/plunder/pkg/parlay/parlaytypes v0.0.0-20200514155151-dfdcaab2e5cd h1:CA9lXqJAJxwA/NGepdfE/hQapGplV4CNNal3eZ9C/k0= github.com/plunder-app/plunder/pkg/parlay/parlaytypes v0.0.0-20200514155151-dfdcaab2e5cd/go.mod h1:QtxXmGRkwdtiiH03oveOPcYXucOv/FxJq2a170aXkxg= github.com/plunder-app/plunder/pkg/plunderlogging v0.0.0-20200513203243-eccb418a5255 h1:gzlti8QQwa02qAGlFktceQucT4HZUZFWIm6h4b03JBo= github.com/plunder-app/plunder/pkg/plunderlogging v0.0.0-20200513203243-eccb418a5255/go.mod h1:ketI5Vxh8nmxwiWnS644ZaEwVt9M/bpP6ISaFeWpcS0= github.com/plunder-app/plunder/pkg/plunderlogging v0.0.0-20200514154439-933b7120d270 h1:abUhCq54uc/4Q/ApJMGtWmsSGSLJcni5P8VXyrLZ49o= github.com/plunder-app/plunder/pkg/plunderlogging v0.0.0-20200514154439-933b7120d270/go.mod h1:ketI5Vxh8nmxwiWnS644ZaEwVt9M/bpP6ISaFeWpcS0= github.com/plunder-app/plunder/pkg/plunderlogging v0.0.0-20200514155151-dfdcaab2e5cd h1:luKDb4GvbZlnythTGjypGW8EdCyRSSXwUvbr+ZrB2zU= github.com/plunder-app/plunder/pkg/plunderlogging v0.0.0-20200514155151-dfdcaab2e5cd/go.mod h1:ketI5Vxh8nmxwiWnS644ZaEwVt9M/bpP6ISaFeWpcS0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/thebsdbox/go-tftp v0.0.0-20190329154032-a7263f18c49c h1:cYCrFUo78/407dxOlYw2g4pdm4Ly8RSPedsYB+z7h1s= github.com/thebsdbox/go-tftp v0.0.0-20190329154032-a7263f18c49c/go.mod h1:yXG6GIu/ptjkk0fd++y96R2cahlvxZr4LhMdf0j/L2Q= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/whyrusleeping/go-tftp v0.0.0-20180830013254-3695fa5761ee h1:P2Wwq5QukiLY/I6+mc7NyLFX/atHAj6pGwiVu6fld98= github.com/whyrusleeping/go-tftp v0.0.0-20180830013254-3695fa5761ee/go.mod h1:ZemSN4DPuG1ppDttxnu45zl8BenKT9xSjMyapUd+Dd0= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/zcalusic/sysinfo v0.0.0-20200228145645-a159d7cc708b/go.mod h1:WGLNaWsjKQ2gXmAHh+MQztgu3FLFAnOFJjFzhpgShCY= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20191003171128-d98b1b443823 h1:Ypyv6BNJh07T1pUSrehkLemqPKXhus2MkfktJ91kRh4= golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f h1:QBjCr1Fz5kw158VqdE9JfI9cJnl/ymnJWAdMuinqL7Y= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120 h1:EZ3cVSzKOlJxAd8e8YAJ7no8nNypTxexh/YE/xW3ZEY= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1 h1:R4dVlxdmKenVdMRS/tTspEpSTRWINYrHD8ySIU9yCIU= golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9 h1:L2auWcuQIvxz9xSEqzESnV/QN/gNRXNApHi3fYwl2w0= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c h1:kISX68E8gSkNYAFRFiDU8rl5RIn1sJYKYb/r2vMLDrU= golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= ================================================ FILE: hack/comboot_ipxe/Dockerfile ================================================ FROM gcc:latest AS IPXE_BUILD RUN git clone git://git.ipxe.org/ipxe.git RUN sed -i '/COMBOOT/s/\/\///g' ipxe/src/config/general.h WORKDIR /ipxe/src/ RUN make bin/undionly.kpxe FROM scratch COPY --from=IPXE_BUILD /ipxe/src/bin/undionly.kpxe . ================================================ FILE: hack/comboot_ipxe/gen_comboot.ipxe ================================================ #!/bin/bash echo Building latest container for iPXE, with comboot support cd comboot_ipxe docker build -t ipxe_comboot . docker run -it -v $(echo $PWD):/tmp/ipxe ipxe_comboot /bin/sh -c "cp undionly.kpxe /tmp/ipxe" ================================================ FILE: main.go ================================================ package main import "github.com/plunder-app/plunder/cmd" // Version is populated from the Makefile and is tied to the release TAG var Version string // Build is the last GIT commit var Build string func main() { cmd.Release.Version = Version cmd.Release.Build = Build cmd.Execute() } ================================================ FILE: pkg/apiserver/README.md ================================================ # API Server documentation This documentation is a quick overview of the CRUD operations that take place within `plunder`, this should be a living document as the various endpoint mature over time. ## Using the API Server The API Server now starts as default and listens on a different port to HTTP services used for deployment, by default the `plunder` API server will listen on port `60443` however the `-p` `--port` flag can be used to specify a specific port. Currently the API server will bind to all interfaces. ### Starting the API Server The below example will start the API server on a custom port. ``` plunder server -p 12345 ``` ## Accessing the API Server The API Endpoints should be accessed using REST methodologies and JSON payloads, the API Endpoints should **always** be defined in `endpoints.go` (this may change later). ## Current issues ### Server configuration Currently DHCP can be stopped and started but logging output is buggy, HTTP/TFTP Can be started but can't be stopped or restarted. ================================================ FILE: pkg/apiserver/client.go ================================================ package apiserver import ( "bytes" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io/ioutil" "net/http" "net/url" log "github.com/sirupsen/logrus" ) //FindFunctionEndpoint - will do a look up to find an exposed dynamic endpoint func FindFunctionEndpoint(u *url.URL, c *http.Client, f, m string) (*EndPoint, *Response) { // Create local URL for the API call newURL := *u newURL.Path = fmt.Sprintf("%s/%s/%s", FunctionPath(), f, m) // Interact with the API server to find the endpoint response, err := ParsePlunderGet(&newURL, c) if err != nil { return nil, &Response{ Warning: fmt.Sprintf("Unable to find method [%s] for function [%s]", m, f), Error: err.Error(), } } var ep EndPoint err = json.Unmarshal(response.Payload, &ep) if err != nil { response.Error = err.Error() return nil, response } return &ep, response } //BuildEnvironmentFromConfig will use the apiserver pkg to parse a configuration file and create a http client with the correct authentication and URL func BuildEnvironmentFromConfig(path, urlFlag string) (*url.URL, *http.Client, error) { log.Debugf("Parsing Configuration file [%s]", path) // Open the configuration c, err := openClientConfig(path) if err != nil { return nil, nil, err } // Retrieve the certificate cert, err := c.RetrieveClientCert() if err != nil { return nil, nil, err } // Build the certificate pool from the unencrypted cert caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(cert) // Create a HTTPS client and supply the created CA pool client := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: caCertPool, }, }, } // Build the URL from the configuration serverURL := c.GetServerAddressURL() // Overwrite the configuration url if if urlFlag != "" { serverURL, err = url.Parse(urlFlag) if err != nil { return nil, nil, err } } return serverURL, client, nil } //ParsePlunderGet will attempt to retrieve data from the plunder API server func ParsePlunderGet(u *url.URL, c *http.Client) (*Response, error) { var response Response log.Debugf("Querying the Plunder Server [%s]", u.String()) resp, err := c.Get(u.String()) if err != nil { return nil, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if resp.StatusCode > 200 { return nil, fmt.Errorf(resp.Status) } err = json.Unmarshal(body, &response) if err != nil { return nil, err } return &response, nil } //ParsePlunderPatch will attempt to retrieve data from the plunder API server func ParsePlunderPatch(u *url.URL, c *http.Client, data []byte) (*Response, error) { var response Response log.Debugf("Posting [%d] bytes to the Plunder Server [%s]", len(data), u.String()) req, err := http.NewRequest("PATCH", u.String(), bytes.NewBuffer(data)) if err != nil { return nil, err } resp, err := c.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if resp.StatusCode > 200 { return nil, fmt.Errorf(resp.Status) } err = json.Unmarshal(body, &response) if err != nil { return nil, err } return &response, nil } //ParsePlunderPost will attempt to retrieve data from the plunder API server func ParsePlunderPost(u *url.URL, c *http.Client, data []byte) (*Response, error) { var response Response log.Debugf("Posting [%d] bytes to the Plunder Server [%s]", len(data), u.String()) resp, err := c.Post(u.String(), "application/json", bytes.NewBuffer(data)) if err != nil { return nil, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if resp.StatusCode > 200 { return nil, fmt.Errorf(resp.Status) } err = json.Unmarshal(body, &response) if err != nil { return nil, err } return &response, nil } //ParsePlunderDelete will attempt to retrieve data from the plunder API server func ParsePlunderDelete(u *url.URL, c *http.Client) (*Response, error) { var response Response log.Debugf("Requesting DELETE method to [%s]", u.String()) // Create request req, err := http.NewRequest("DELETE", u.String(), nil) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if resp.StatusCode > 200 { return nil, fmt.Errorf(resp.Status) } err = json.Unmarshal(body, &response) if err != nil { return nil, err } return &response, nil } ================================================ FILE: pkg/apiserver/config.go ================================================ package apiserver import ( "encoding/base64" "encoding/json" "fmt" "io/ioutil" "net/url" "github.com/ghodss/yaml" ) // ClientConfig is the structure of an expected configuration for pldctl type ClientConfig struct { Address string `json:"address,omitempty"` Port int `json:"port"` ClientCert string `json:"cert"` } // ServerConfig is the structure of an expected configuration for pldctl type ServerConfig struct { ClientConfig ServerKey string `json:"key"` } //openClientConfig will open and parse a Plunder server configuration file func openClientConfig(path string) (*ClientConfig, error) { var c ClientConfig // Create a CA certificate pool and add cert.pem to it b, err := ioutil.ReadFile(path) if err != nil { return nil, err } jsonBytes, err := yaml.YAMLToJSON(b) if err == nil { // If there were no errors then the YAML => JSON was successful, no attempt to unmarshall err = json.Unmarshal(jsonBytes, &c) if err != nil { return nil, fmt.Errorf("Unable to parse configuration as either yaml or json") } } else { // Couldn't parse the yaml to JSON // Attempt to parse it as JSON err = json.Unmarshal(b, &c) if err != nil { return nil, fmt.Errorf("Unable to parse configuration as either yaml or json") } } return &c, nil } //OpenServerConfig will open and parse a Plunder server configuration file func OpenServerConfig(path string) (*ServerConfig, error) { var s ServerConfig // Create a CA certificate pool and add cert.pem to it b, err := ioutil.ReadFile(path) if err != nil { return nil, err } jsonBytes, err := yaml.YAMLToJSON(b) if err == nil { // If there were no errors then the YAML => JSON was successful, no attempt to unmarshall err = json.Unmarshal(jsonBytes, &s) if err != nil { return nil, fmt.Errorf("Unable to parse configuration as either yaml or json") } } else { // Couldn't parse the yaml to JSON // Attempt to parse it as JSON err = json.Unmarshal(b, &s) if err != nil { return nil, fmt.Errorf("Unable to parse configuration as either yaml or json") } } return &s, nil } // WriteServerConfig - will write out the server configuration for the API Server func WriteServerConfig(path, hostname, address string, port int, cert, key []byte) error { var s ServerConfig // base64 the certificates encodedKey := base64.StdEncoding.EncodeToString(key) encodedCert := base64.StdEncoding.EncodeToString(cert) // Add the encoded certificates to the struct s.ClientCert = encodedCert s.ServerKey = encodedKey // Add the port for automated startup s.Port = port // Marshall to yaml b, err := yaml.Marshal(s) if err != nil { return err } err = ioutil.WriteFile(path, b, 0600) if err != nil { return err } return nil } // WriteClientConfig - will write out the server configuration for the API Server func WriteClientConfig(path, address string, s *ServerConfig) error { var c ClientConfig // Add the encoded certificates to the struct c.ClientCert = s.ClientCert // Add the host information for automated startup c.Port = s.Port c.Address = address // Marshall client configuration to yaml b, err := yaml.Marshal(c) if err != nil { return err } err = ioutil.WriteFile(path, b, 0600) if err != nil { return err } return nil } //GetServerAddressURL will retrieve a parsed URL func (c *ClientConfig) GetServerAddressURL() *url.URL { var plunderURL url.URL plunderURL.Scheme = "https" // Build a url plunderURL.Host = fmt.Sprintf("%s:%d", c.Address, +c.Port) return &plunderURL } func retrieveCert(cert string) ([]byte, error) { return base64.StdEncoding.DecodeString(cert) } // RetrieveKey will decode the base64 certificate func (s *ServerConfig) RetrieveKey() ([]byte, error) { return retrieveCert(s.ServerKey) } // RetrieveClientCert will decode the base64 certificate func (s *ServerConfig) RetrieveClientCert() ([]byte, error) { return retrieveCert(s.ClientCert) } // RetrieveClientCert will decode the base64 certificate func (c *ClientConfig) RetrieveClientCert() ([]byte, error) { return retrieveCert(c.ClientCert) } ================================================ FILE: pkg/apiserver/endpoints.go ================================================ package apiserver import ( "net/http" log "github.com/sirupsen/logrus" //"github.com/gorilla/mux" ) // EndPointManager - Contains all of the dynamically created endpoints var EndPointManager []EndPoint // EndPoint is the source of truth for handling all of the endpoints exposed through the API Server // it also provides a mechanism to interact with the apiserver to find/create api endpoints type EndPoint struct { Name string `json:"name"` Path string `json:"path"` FunctionPath string `json:"functionEndpoint"` Description string `json:"description"` Method string `json:"method"` } // AddDynamicEndpoint - will add an endpoint to the api server and link it back to a function func AddDynamicEndpoint(endpointPattern, path, description, name, method string, epFunc http.HandlerFunc) { for i := range EndPointManager { if EndPointManager[i].Name == name && EndPointManager[i].Method == method { log.Warnf("Endpoint [%s] already exists with method [%s]", name, method) } } // First we add the endpoint to the Manager so we can query it EndPointManager = append(EndPointManager, EndPoint{ FunctionPath: endpointPattern, Path: path, Description: description, Method: method, Name: name, }) // Then we add the endpoint to the apiServer endpoints.HandleFunc(endpointPattern, epFunc).Methods(method) } // GetEndpoint - will return the details for an endpoint func GetEndpoint(name, method string) *EndPoint { for i := range EndPointManager { if EndPointManager[i].Name == name && EndPointManager[i].Method == method { return &EndPointManager[i] } } return nil } // FunctionPath - this will return the api server path for any external caller using the package func FunctionPath() string { return "/api" } ================================================ FILE: pkg/apiserver/go.mod ================================================ module github.com/plunder-app/plunder/pkg/apiserver go 1.12 ================================================ FILE: pkg/apiserver/handlerApiserver.go ================================================ package apiserver import ( "encoding/json" "fmt" "net/http" "github.com/gorilla/mux" ) // Delete the parlay results from the plunder server func getAPIFunctionMethod(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp Response // Find the deployment ID f := mux.Vars(r)["function"] m := mux.Vars(r)["method"] ep := GetEndpoint(f, m) if ep == nil { // RETREIVE the deployment Logs (TODO) rsp.Warning = fmt.Sprintf("Unable to find HTTP method [%s] for function [%s]", m, f) rsp.Error = "Error looking up in API Server" } else { jsonData, err := json.Marshal(ep) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error retrieving deployment Configuration" rsp.Error = err.Error() } else { rsp.Payload = jsonData } } json.NewEncoder(w).Encode(rsp) } // Delete the parlay results from the plunder server func getAPIs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp Response jsonData, err := json.Marshal(EndPointManager) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error retrieving deployment Configuration" rsp.Error = err.Error() } else { rsp.Payload = jsonData } json.NewEncoder(w).Encode(rsp) } ================================================ FILE: pkg/apiserver/logging.go ================================================ package apiserver import ( "fmt" "net/http" "sync" "github.com/gorilla/mux" ) // MVP of a streaming logging provider // The notificationCenter is in charge of handling the various notification managers, whihc // in turn will notify all of their subscribers var notificationCenter map[string]*notificationManager // Notification is what will be sent to subscribers of a manager type Notification struct { ID string RawData []byte } // RegisterNotificationManager will create a manager and an endpoint func RegisterNotificationManager(managerName, endpoint string) error { // Register the new Manager to the Notification Center notificationCenter[managerName] = newNotificationManager() AddDynamicEndpoint(endpoint, endpoint, fmt.Sprintf("Automatically generated notification endpoint for [%s]", managerName), managerName, http.MethodGet, handleSubscribers(notificationCenter[managerName])) return nil } // NotifyManager - This will Notify a Manager that there is a new notification that needs to go to subscribers func NotifyManager(managerName string, n Notification) error { manager := notificationCenter[managerName] if manager == nil { return fmt.Errorf("Notification Manager [%s], hasn't been registered", managerName) } manager.notifySubscribers(n) return nil } // -------------- Notication MAGIC below -------------- func init() { // Initialise the notificationCenter map notificationCenter = make(map[string]*notificationManager) } type unsubscribeFunc func() error type subscriber interface { subscribe(n chan Notification) (unsubscribeFunc, error) } func handleSubscribers(s subscriber) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Subscribe n := make(chan Notification) unsubscribeFn, err := s.subscribe(n) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Set environment for streaming events w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") Looping: for { select { case <-r.Context().Done(): if err := unsubscribeFn(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } break Looping default: // Find the log ID id := mux.Vars(r)["id"] // retrieve the notification newNotification := <-n // compare the notification ID with that of the URL, optionally retrieve "all" notifications if newNotification.ID == id || id == "all" { // if the correct id then send them the data fmt.Fprintf(w, "%s\n", newNotification.RawData) } w.(http.Flusher).Flush() } } } } type notifier interface { Notify(n Notification) error } type notificationManager struct { subscribers map[chan Notification]struct{} subscribersMu *sync.Mutex } func newNotificationManager() *notificationManager { return ¬ificationManager{ subscribers: map[chan Notification]struct{}{}, subscribersMu: &sync.Mutex{}, } } func (nc *notificationManager) subscribe(n chan Notification) (unsubscribeFunc, error) { nc.subscribersMu.Lock() nc.subscribers[n] = struct{}{} nc.subscribersMu.Unlock() unsubscribeFn := func() error { nc.subscribersMu.Lock() delete(nc.subscribers, n) nc.subscribersMu.Unlock() return nil } return unsubscribeFn, nil } func (nc *notificationManager) notifySubscribers(n Notification) error { // Lock them until updates are complete nc.subscribersMu.Lock() defer nc.subscribersMu.Unlock() for c := range nc.subscribers { select { case c <- n: default: } } return nil } ================================================ FILE: pkg/apiserver/loggingHandlers.go ================================================ package apiserver //var map parlay[] ================================================ FILE: pkg/apiserver/server.go ================================================ package apiserver import ( "crypto/tls" "fmt" "net/http" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" ) var endpoints *mux.Router func init() { // Initialise a new HTTP Router so that connections can be created before the API server starts, any registered will be added to this router and // evaluated once the API Server starts endpoints = mux.NewRouter() } //StartAPIServer - will parse a configuration file and passed variables and start the API Server func StartAPIServer(path string, port int, insecure bool) error { // Open and Parse the server configuration conf, err := OpenServerConfig(path) if err != nil { log.Warnln(err) if insecure == false { log.Warningln("Secure server enabled, but no certificates have been loaded [no communication to API server is possible]") } // Create a blank server config as one wont be returned by the above OpenFile conf = &ServerConfig{} } if port != 0 { conf.Port = port } log.Infof("Starting API server on port %d", conf.Port) address := fmt.Sprintf(":%d", conf.Port) // Add the apiserver end point AddDynamicEndpoint("/api", "/api", "Endpoint for interacting with the api server", "apis", http.MethodGet, getAPIs) // Add the apiserver end point AddDynamicEndpoint("/api/{function}/{method}", "/api", "Endpoint for interacting with the api server", "apiFunctions", http.MethodGet, getAPIFunctionMethod) // Begin the start of a secure endpoint (TODO) if insecure == false { cert, err := conf.RetrieveClientCert() if err != nil { return err } key, err := conf.RetrieveKey() if err != nil { return err } certPair, err := tls.X509KeyPair(cert, key) cfg := &tls.Config{Certificates: []tls.Certificate{certPair}} srv := &http.Server{ TLSConfig: cfg, Addr: address, Handler: endpoints, // TODO - exposing no timeout will lead to exhausted file descriptors // ReadTimeout: time.Minute, // WriteTimeout: time.Minute, } return srv.ListenAndServeTLS("", "") } // Start an insecure http server (TODO - warning) return http.ListenAndServe(address, endpoints) } ================================================ FILE: pkg/apiserver/types.go ================================================ package apiserver import "encoding/json" //Response - This is the wrapper for responses back to a client, if any errors are created then the payload isn't guarenteed type Response struct { Warning string `json:"warning,omitempty"` // when it maybe worked Error string `json:"error,omitempty"` // when it goes wrong Success string `json:"success,omitempty"` // when it goes correct Payload json.RawMessage `json:"payload,omitempty"` } ================================================ FILE: pkg/certs/certs.go ================================================ package certs // generate-tls-cert generates root, leaf, and client TLS certificates. import ( "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "io/ioutil" "math/big" "os" "time" "github.com/plunder-app/plunder/pkg/utils" ) // Internal variables to hold the outputs var keyData, pemData []byte // GenerateKeyPair - (TODO) func GenerateKeyPair(hosts []string, start time.Time, length time.Duration) error { ca := &x509.Certificate{ SerialNumber: big.NewInt(2019), Subject: pkix.Name{ Organization: []string{"Plunder"}, Country: []string{"UK"}, Province: []string{""}, Locality: []string{"Yorkshire"}, StreetAddress: []string{""}, PostalCode: []string{""}, }, NotBefore: time.Now(), NotAfter: time.Now().AddDate(10, 0, 0), IsCA: true, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, BasicConstraintsValid: true, } // create our private and public key caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return err } // create the CA caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) if err != nil { return err } // pem encode caPEM := new(bytes.Buffer) pem.Encode(caPEM, &pem.Block{ Type: "CERTIFICATE", Bytes: caBytes, }) caPrivKeyPEM := new(bytes.Buffer) pem.Encode(caPrivKeyPEM, &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(caPrivKey), }) // Find all IP addresses on a server serverAddresses, err := utils.FindAllIPAddresses() if err != nil { return err } // Find the hostname of the server serverName, err := os.Hostname() if err != nil { return err } // set up our server certificate cert := &x509.Certificate{ SerialNumber: big.NewInt(2019), Subject: pkix.Name{ Organization: []string{"Plunder"}, Country: []string{"UK"}, Province: []string{""}, Locality: []string{"Yorkshire"}, StreetAddress: []string{""}, PostalCode: []string{""}, }, IPAddresses: serverAddresses, NotBefore: time.Now(), NotAfter: time.Now().AddDate(10, 0, 0), SubjectKeyId: []byte{1, 2, 3, 4, 6}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature, DNSNames: []string{serverName}, } certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return err } certBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &certPrivKey.PublicKey, caPrivKey) if err != nil { return err } certPEM := new(bytes.Buffer) pem.Encode(certPEM, &pem.Block{ Type: "CERTIFICATE", Bytes: certBytes, }) pemData = certPEM.Bytes() certPrivKeyPEM := new(bytes.Buffer) pem.Encode(certPrivKeyPEM, &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), }) keyData = certPrivKeyPEM.Bytes() return nil } // WriteKeyToFile - will write a generated Key to a file path func WriteKeyToFile(path string) error { err := ioutil.WriteFile(path, keyData, 0600) if err != nil { return err } return nil } // WritePemToFile - will write a generated pem to a file path func WritePemToFile(path string) error { err := ioutil.WriteFile(path, pemData, 0600) if err != nil { return err } return nil } // GetKey - will return the []byte of the key func GetKey() []byte { return keyData } // GetPem - will return the []byte of the key func GetPem() []byte { return pemData } ================================================ FILE: pkg/certs/go.mod ================================================ module github.com/plunder-app/plunder/pkg/certs go 1.12 ================================================ FILE: pkg/go.mod ================================================ module github.com/plunder-app/plunder/pkg go 1.12 ================================================ FILE: pkg/parlay/go.mod ================================================ module github.com/plunder-app/plunder/pkg/parlay go 1.12 ================================================ FILE: pkg/parlay/handler.go ================================================ package parlay import ( "encoding/json" "io/ioutil" "net/http" "strings" "github.com/gorilla/mux" "github.com/plunder-app/plunder/pkg/apiserver" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" "github.com/plunder-app/plunder/pkg/services" "github.com/plunder-app/plunder/pkg/ssh" log "github.com/sirupsen/logrus" ) var registered bool // RegisterToAPIServer - will add the endpoints to the API server func RegisterToAPIServer() { // Ensure registration only happens once if registered == true { return } // ------------------------------------------------ // Parlay API registration // ------------------------------------------------ apiserver.AddDynamicEndpoint("/parlay", "/parlay", "Create a parlay automation deployment", "parlay", http.MethodPost, postParlay) apiserver.AddDynamicEndpoint("/parlay/logs/{id}", "/parlay/logs", "Retrieve the logs from a parlay deployment", "parlayLog", http.MethodGet, getParlay) apiserver.AddDynamicEndpoint("/parlay/logs/{id}", "/parlay/logs", "Delete the cached logs from a specific parlay deployment", "parlayLog", http.MethodDelete, delParlay) registered = true } // Retrieve a specific plunder deployment configuration func postParlay(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response if b, err := ioutil.ReadAll(r.Body); err == nil { // Parse the treasure map in the POST data var m parlaytypes.TreasureMap err := json.Unmarshal(b, &m) // Unable to parse the JSON payload if err != nil { rsp.Warning = "Error parsing the parlay actions" rsp.Error = err.Error() } else { // Parsed succesfully, we will deploy this in a go routine and use GET /parlay/MAC to view progress // err = ssh.ImportHostsFromDeployment(services.Deployments) if err != nil { rsp.Warning = "Error importing the hosts from deployment" rsp.Error = err.Error() } else { err = DeploySSH(&m, "", true, true) if err != nil { rsp.Warning = "Error performing the parlay actions" rsp.Error = err.Error() log.Errorf("%s", err.Error()) } } } } else { rsp.Warning = "Error reading HTTP data" rsp.Error = err.Error() } json.NewEncoder(w).Encode(rsp) } // Retrieve a specific parlay automation func getParlay(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response // Find the deployment ID id := mux.Vars(r)["id"] // We need to revert the mac address back to the correct format (dashes back to colons) target := strings.Replace(id, "-", ".", -1) // Use the mac address to lookup the deployment logs, err := GetTargetLogs(target) // If the deployment exists then process the POST data if err != nil { // RETREIVE the deployment Logs (TODO) rsp.Warning = "Error reading Parlay Logs" rsp.Error = err.Error() } else { jsonData, err := json.Marshal(logs) if err != nil { // RETREIVE the deployment Logs (TODO) rsp.Warning = "Error parsing Parlay Logs" rsp.Error = err.Error() } else { rsp.Payload = jsonData } } json.NewEncoder(w).Encode(rsp) } // Delete the parlay results from the plunder server func delParlay(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response // Find the deployment ID id := mux.Vars(r)["id"] // We need to revert the mac address back to the correct format (dashes back to colons) target := strings.Replace(id, "-", ".", -1) // Use the mac address to lookup the deployment err := DeleteTargetLogs(target) // If the deployment exists then process the POST data if err != nil { // RETREIVE the deployment Logs (TODO) rsp.Warning = "Error reading deleting logs" rsp.Error = err.Error() } json.NewEncoder(w).Encode(rsp) } ================================================ FILE: pkg/parlay/parlay.go ================================================ package parlay type actionType string const ( //upload - defines that this action will upload a file to a remote system upload actionType = "upload" // download actionType = "download" command actionType = "command" pkg actionType = "package" ) // KeyMap // Keys are used to store information between sessions and deployments var Keys map[string]string func init() { // Initialise the map Keys = make(map[string]string) } ================================================ FILE: pkg/parlay/parlay_ui.go ================================================ package parlay import ( "fmt" "strings" "github.com/AlecAivazis/survey/v2" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" ) func contains(v string, a []string) bool { for _, i := range a { if strings.Contains(v, i) { return true } } return false } // StartUI will enable parlay to provide an easier way of selecting which operations will be performed func StartUI(m *parlaytypes.TreasureMap) (*parlaytypes.TreasureMap, error) { deployments := []string{} for i := range m.Deployments { deployments = append(deployments, m.Deployments[i].Name) } if len(deployments) == 0 { return nil, fmt.Errorf("No Deployments were found") } var multiQs = []*survey.Question{ { Name: "letter", Prompt: &survey.MultiSelect{ Message: "Select deployment(s)", Options: deployments, }, }, } deploymentAnswers := []string{} // ask the question err := survey.Ask(multiQs, &deploymentAnswers) if err != nil { return nil, err } // Create a new TreasureMap from the answered questions newMap, err := m.FindDeployments(deploymentAnswers) if err != nil { return nil, err } for i := range newMap.Deployments { // Ask for Hosts multiQs[0].Prompt = &survey.MultiSelect{ Message: fmt.Sprintf("Select Hosts(s) for [%s]", newMap.Deployments[i].Name), Options: newMap.Deployments[i].Hosts, } hostAnswers := []string{} err := survey.Ask(multiQs, &hostAnswers) if err != nil { return nil, err } // Ask for Actions actions := []string{} for y := range newMap.Deployments[i].Actions { actions = append(actions, m.Deployments[i].Actions[y].Name) } if len(actions) == 0 { return nil, fmt.Errorf("No Deployments were found") } multiQs[0].Prompt = &survey.MultiSelect{ Message: fmt.Sprintf("Select Actions(s) for [%s]", newMap.Deployments[i].Name), Options: actions, } deploymentAnswers := []string{} err = survey.Ask(multiQs, &deploymentAnswers) if err != nil { return nil, err } newMap.Deployments[i].Hosts = hostAnswers foundActions, err := newMap.Deployments[i].FindActions(deploymentAnswers) if err != nil { return nil, err } newMap.Deployments[i].Actions = foundActions } return newMap, nil } ================================================ FILE: pkg/parlay/parlaytypes/finder.go ================================================ package parlaytypes import ( "fmt" log "github.com/sirupsen/logrus" ) // FindDeployments - This will iterate through a deployment map and build a new deployment map from found deployments func (m *TreasureMap) FindDeployments(deployment []string) (*TreasureMap, error) { var newDeploymentList []Deployment for x := range deployment { for y := range m.Deployments { if m.Deployments[y].Name == deployment[x] { newDeploymentList = append(newDeploymentList, m.Deployments[y]) } } } // If this is zero it means that no deployments have been found if len(m.Deployments) == 0 { return nil, fmt.Errorf("No Deployment(s) have been found") } m.Deployments = newDeploymentList return m, nil } // FindHosts - will iterate through the deployment hosts and compare to the array of hosts to return func (d *Deployment) FindHosts(hosts []string) (*Deployment, error) { var newHostList []string for x := range hosts { for y := range d.Hosts { if d.Hosts[y] == hosts[x] { newHostList = append(newHostList, d.Hosts[y]) } } } // If this is zero it means that no hosts have been found if len(d.Hosts) == 0 { return nil, fmt.Errorf("No Host(s) have been found") } d.Hosts = newHostList return d, nil } // FindActions - will iterate through the deployment actions and compare to the array of actions to return func (d *Deployment) FindActions(actions []string) ([]Action, error) { var newActionList []Action for x := range actions { for y := range d.Actions { if d.Actions[y].Name == actions[x] { newActionList = append(newActionList, d.Actions[y]) } } } // If this is zero it means that no hosts have been found if len(d.Actions) == 0 { return nil, fmt.Errorf("No Action(s) have been found") } return newActionList, nil } //FindDeployment - takes a number of flags and builds a new map to be processed func (m *TreasureMap) FindDeployment(deployment, action, host, logFile string, resume bool) (*TreasureMap, error) { var foundMap TreasureMap if deployment != "" { log.Debugf("Looking for deployment [%s]", deployment) for x := range m.Deployments { if m.Deployments[x].Name == deployment { foundMap.Deployments = append(foundMap.Deployments, m.Deployments[x]) // Find a specific action and add or resume from if action != "" { // Clear the slice as we will be possibly adding different actions foundMap.Deployments[0].Actions = nil for y := range m.Deployments[x].Actions { if m.Deployments[x].Actions[y].Name == action { // If we're not resuming that just add the action that we want to happen if resume != true { foundMap.Deployments[0].Actions = append(foundMap.Deployments[0].Actions, m.Deployments[x].Actions[y]) } else { // Alternatively add all actions from this point foundMap.Deployments[0].Actions = m.Deployments[x].Actions[y:] } } } // If this is zero it means that no actions have been found if len(foundMap.Deployments[0].Actions) == 0 { return nil, fmt.Errorf("No actions have been found, looking for action [%s]", action) } } // If a host is specified act soley on it if host != "" { // Clear the slice as we will be possibly adding different actions foundMap.Deployments[0].Hosts = nil for y := range m.Deployments[x].Hosts { if m.Deployments[x].Hosts[y] == host { foundMap.Deployments[0].Hosts = append(foundMap.Deployments[0].Hosts, m.Deployments[x].Hosts[y]) } } // If this is zero it means that no hosts have been found if len(foundMap.Deployments[0].Hosts) == 0 { return nil, fmt.Errorf("No host has been found, looking for host [%s]", host) } } } } // If this is zero it means that no actions have been found if len(foundMap.Deployments) == 0 { return nil, fmt.Errorf("No deployment has been found, looking for deployment [%s]", deployment) } } else { return nil, fmt.Errorf("No deployment was specified") } return &foundMap, nil //return parlay.DeploySSH(foundMap, logFile, false, false) } ================================================ FILE: pkg/parlay/parlaytypes/go.mod ================================================ module github.com/plunder-app/plunder/pkg/parlay/parlaytypes go 1.12 ================================================ FILE: pkg/parlay/parlaytypes/parlaytypes.go ================================================ package parlaytypes import ( "encoding/json" ) // TreasureMap - X Marks the spot // The treasure maps define the automation that will take place on the hosts defined type TreasureMap struct { // An array/list of deployments that will take places as part of this "map" Deployments []Deployment `json:"deployments"` } // Deployment defines the hosts and the action(s) that should be performed on them type Deployment struct { // Name of the deployment that is taking place i.e. (Install MySQL) Name string `json:"name"` // An array/list of hosts that these actions should be performed upon Hosts []string `json:"hosts"` // Parallel allow multiple actions across multiple hosts in parallel Parallel bool `json:"parallel"` ParallelSessions int `json:"parallelSessions"` // The actions that should be performed Actions []Action `json:"actions"` } // Action defines what the instructions that will be executed type Action struct { Name string `json:"name"` ActionType string `json:"type"` Timeout int `json:"timeout"` // File based operations Source string `json:"source,omitempty"` Destination string `json:"destination,omitempty"` FileMove bool `json:"fileMove,omitempty"` // Package manager operations PkgManager string `json:"packageManager,omitempty"` PkgOperation string `json:"packageOperation,omitempty"` Packages string `json:"packages,omitempty"` // Command operations Command string `json:"command,omitempty"` Commands []string `json:"commands,omitempty"` CommandLocal bool `json:"commandLocal,omitempty"` CommandSaveFile string `json:"commandSaveFile,omitempty"` CommandSaveAsKey string `json:"commandSaveAsKey,omitempty"` CommandSudo string `json:"commandSudo,omitempty"` // Piping commands, read in a file and send over stdin, or capture stdout from a local command CommandPipeFile string `json:"commandPipeFile,omitempty"` CommandPipeCmd string `json:"commandPipeCmd,omitempty"` // Ignore any failures IgnoreFailure bool `json:"ignoreFail,omitempty"` // Key operations KeyFile string `json:"keyFile,omitempty"` KeyName string `json:"keyName,omitempty"` //Plugin Spec Plugin json.RawMessage `json:"plugin,omitempty"` } ================================================ FILE: pkg/parlay/parser.go ================================================ package parlay import ( "fmt" "github.com/plunder-app/plunder/pkg/plunderlogging" log "github.com/sirupsen/logrus" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" parlayplugin "github.com/plunder-app/plunder/pkg/parlay/plugin" "github.com/plunder-app/plunder/pkg/ssh" ) // This logger will manage all of the logging for Parlay var logger plunderlogging.Logger // GetTargetLogs will retrieve the JSON logs func GetTargetLogs(target string) (*plunderlogging.JSONLog, error) { return logger.GetJSONLogs(target) } // DeleteTargetLogs will retrieve the JSON logs func DeleteTargetLogs(target string) error { return logger.DeleteLogs(target) } // DeploySSH - will iterate through a deployment and perform the relevant actions func DeploySSH(m *parlaytypes.TreasureMap, logFile string, jsonLogging, background bool) error { if len(ssh.Hosts) == 0 { log.Warnln("No hosts credentials have been loaded, only commands with commandLocal = true will work") } if len(m.Deployments) == 0 { return fmt.Errorf("No Deployments in parlay map") } for x := range m.Deployments { // Build new hosts list from imported SSH servers and compare that we have required credentials _, err := ssh.FindHosts(m.Deployments[x].Hosts) if err != nil { return err } } // Begin the deployment if logFile != "" { //enable logging logger.InitLogFile(logFile) } if jsonLogging { logger.InitJSON() } if background { go startDeployments(m.Deployments) } else { startDeployments(m.Deployments) } // TODO - test original automation // for x := range m.Deployments { // // Build new hosts list from imported SSH servers and compare that we have required credentials // hosts, err := ssh.FindHosts(m.Deployments[x].Hosts) // if err != nil { // return err // } // // Beggining of deployment work // log.Infof("Beginning Deployment [%s]\n", m.Deployments[x].Name) // logger.WriteLogEntry("", "", "", fmt.Sprintf("Beginning Deployment [%s]\n", m.Deployments[x].Name)) // // Set Restore checkpoint // restore.Deployment = m.Deployments[x].Name // restore.Hosts = m.Deployments[x].Hosts // if m.Deployments[x].Parallel == true { // // Begin this deployment in parallel across all hosts // err = parallelDeployment(m.Deployments[x].Actions, hosts, &logger) // if err != nil { // return err // } // } else { // // This work will be sequential, one host after the next // for z := range m.Deployments[x].Hosts { // var hostConfig ssh.HostSSHConfig // // Find the hosts SSH configuration // for i := range hosts { // if hosts[i].Host == m.Deployments[x].Hosts[z] { // hostConfig = hosts[i] // } // } // // Set the state of logging actions to in-progress // logger.SetLoggingState(hostConfig.Host, "Running") // err = sequentialDeployment(m.Deployments[x].Actions, hostConfig, &logger) // if err != nil { // logger.SetLoggingState(hostConfig.Host, "Failed") // return err // } // // Set the state of logging actions to completed // logger.SetLoggingState(hostConfig.Host, "Completed") // } // } // } return nil } func startDeployments(d []parlaytypes.Deployment) error { for x := range d { // Build new hosts list from imported SSH servers and compare that we have required credentials hosts, err := ssh.FindHosts(d[x].Hosts) if err != nil { return err } // Beggining of deployment work log.Infof("Beginning Deployment [%s]\n", d[x].Name) logger.WriteLogEntry("", "", "", fmt.Sprintf("Beginning Deployment [%s]\n", d[x].Name)) // Set Restore checkpoint restore.Deployment = d[x].Name restore.Hosts = d[x].Hosts if d[x].Parallel == true { // Begin this deployment in parallel across all hosts err = parallelDeployment(d[x].Actions, hosts, &logger) if err != nil { return err } } else { // This work will be sequential, one host after the next for z := range d[x].Hosts { var hostConfig ssh.HostSSHConfig // Find the hosts SSH configuration for i := range hosts { if hosts[i].Host == d[x].Hosts[z] { hostConfig = hosts[i] } } // Set the state of logging actions to in-progress logger.SetLoggingState(hostConfig.Host, "Running") err = sequentialDeployment(d[x].Actions, hostConfig, &logger) if err != nil { logger.SetLoggingState(hostConfig.Host, "Failed") return err } // Set the state of logging actions to completed logger.SetLoggingState(hostConfig.Host, "Completed") } } } return nil } // Begin host by host deployments as part of each deployment func sequentialDeployment(action []parlaytypes.Action, hostConfig ssh.HostSSHConfig, logger *plunderlogging.Logger) error { var err error for y := range action { switch action[y].ActionType { case "upload": err = hostConfig.UploadFile(action[y].Source, action[y].Destination) if err != nil { // Set checkpoint restore.Action = action[y].Name restore.Host = hostConfig.Host restore.createCheckpoint() logger.WriteLogEntry(hostConfig.Host, action[y].Name, "", err.Error()) // Return the error return fmt.Errorf("Upload task [%s] on host [%s] failed with error [%s]", action[y].Name, hostConfig.Host, err) } log.Infof("Upload Task [%s] on node [%s] completed successfully", action[y].Name, hostConfig.Host) case "download": err = hostConfig.DownloadFile(action[y].Source, action[y].Destination) if err != nil { // Set checkpoint restore.Action = action[y].Name restore.Host = hostConfig.Host restore.createCheckpoint() logger.WriteLogEntry(hostConfig.Host, action[y].Name, "", err.Error()) // Return the error return fmt.Errorf("Download task [%s] on host [%s] failed with error [%s]", action[y].Name, hostConfig.Host, err) } log.Infof("Succesfully Downloaded [%s] to [%s] from [%s]", action[y].Source, action[y].Destination, hostConfig.Host) case "command": // Build out a configuration based upon the action cr := parseAndExecute(action[y], &hostConfig) // This will end command execution and print the error if cr.Error != nil && action[y].IgnoreFailure == false { // Set checkpoint restore.Action = action[y].Name restore.Host = hostConfig.Host restore.createCheckpoint() // Output error messages logger.WriteLogEntry(hostConfig.Host, action[y].Name, cr.Result, cr.Error.Error()) // cr.Result is ommited here TODO return fmt.Errorf("Command task [%s] on host [%s] failed with error [%s]", action[y].Name, hostConfig.Host, cr.Error) } // if there is an error and we're set to ignore it then process accordingly if cr.Error != nil && action[y].IgnoreFailure == true { log.Warnf("Command Task [%s] on node [%s] failed (execution will continute)", action[y].Name, hostConfig.Host) log.Debugf("Command Results ->\n%s", cr.Result) logger.WriteLogEntry(hostConfig.Host, action[y].Name, cr.Result, cr.Error.Error()) //logger.WriteLogEntry(hostConfig.Host, fmt.Sprintf("Command task [%s] on host [%s] has failed (execution will continute)\n", action[y].Name, hostConfig.Host)) } // No error, task was completed correctly if cr.Error == nil { // Output success Messages log.Infof("Command Task [%s] on node [%s] completed successfully", action[y].Name, hostConfig.Host) log.Debugf("Command Results ->\n%s", cr.Result) //logger.WriteLogEntry(hostConfig.Host, fmt.Sprintf("Command task [%s] on host [%s] has completed succesfully\n", action[y].Name, hostConfig.Host)) //logger.WriteLogEntry(hostConfig.Host, fmt.Sprintf("Command task [%s] Output [%s]\n", action[y].Name, cr.Result)) logger.WriteLogEntry(hostConfig.Host, action[y].Name, cr.Result, "") } case "pkg": case "key": default: // Set checkpoint (the actiontype may be modified or spelling issue) restore.Action = action[y].Name restore.Host = hostConfig.Host restore.createCheckpoint() pluginActions, err := parlayplugin.ExecuteAction(action[y].ActionType, hostConfig.Host, action[y].Plugin) if err != nil { logger.WriteLogEntry(hostConfig.Host, action[y].Name, "", err.Error()) return err } log.Debugf("About to execute [%d] actions", len(pluginActions)) err = sequentialDeployment(pluginActions, hostConfig, logger) if err != nil { return err } } } return nil } // Peform all of the actions in parallel on all hosts in the host array // this function will make use of the parallel ssh calls func parallelDeployment(action []parlaytypes.Action, hosts []ssh.HostSSHConfig, logger *plunderlogging.Logger) error { for y := range action { switch action[y].ActionType { case "upload": //TODO - Remove or repurpose GENERAL output logger.WriteLogEntry("upload", fmt.Sprintf("Uploading file [%s] to Destination [%s] to multiple hosts\n", action[y].Source, action[y].Destination), "", "") results := ssh.ParalellUpload(hosts, action[y].Source, action[y].Destination, action[y].Timeout) // TODO - Unlikely that this should happen if len(results) == 0 { return fmt.Errorf("No results have been returned from the parallel execution") } // Parse the results from the parallel updates for i := range results { if results[i].Error != nil { // Set checkpoint restore.Action = action[y].Name restore.createCheckpoint() logger.WriteLogEntry(results[i].Host, action[y].Name, "", results[i].Error.Error()) logger.SetLoggingState(results[i].Host, "Failed") //logger.WriteLogEntry("", fmt.Sprintf("[%s] Error uploading file [%s] to Destination [%s] to host [%s]\n", time.Now().Format(time.ANSIC), action[y].Source, action[y].Destination, results[i].Host)) //logger.WriteLogEntry("", fmt.Sprintf("[%s] [%s]\n", time.Now().Format(time.ANSIC), results[i].Error())) return fmt.Errorf("Upload task [%s] on host [%s] failed with error [%s]", action[y].Name, results[i].Host, results[i].Error) } logger.WriteLogEntry(results[i].Host, action[y].Name, fmt.Sprintf("Completed uploading file [%s] to path [%s]", action[y].Source, action[y].Destination), results[i].Error.Error()) //logger.WriteLogEntry("", fmt.Sprintf("[%s] Completed uploading file [%s] to Destination [%s] to host [%s]\n", time.Now().Format(time.ANSIC), action[y].Source, action[y].Destination, results[i].Host)) log.Infof("Succesfully uploaded [%s] to [%s] on [%s]", action[y].Source, action[y].Destination, results[i].Host) } case "download": logger.WriteLogEntry("download", fmt.Sprintf("Downloading file [%s] to Destination [%s] from multiple hosts\n", action[y].Source, action[y].Destination), "", "") results := ssh.ParalellDownload(hosts, action[y].Source, action[y].Destination, action[y].Timeout) // Unlikely that this should happen if len(results) == 0 { return fmt.Errorf("No results have been returned from the parallel execution") } // Parse the results from the parallel updates for i := range results { if results[i].Error != nil { // Set checkpoint restore.Action = action[y].Name restore.createCheckpoint() logger.WriteLogEntry(results[i].Host, action[y].Name, "", results[i].Error.Error()) logger.SetLoggingState(results[i].Host, "Failed") //logger.WriteLogEntry("", fmt.Sprintf("[%s] Error downloading file [%s] to [%s] to host [%s]\n", time.Now().Format(time.ANSIC), action[y].Source, action[y].Destination, results[i].Host)) //logger.WriteLogEntry("", fmt.Sprintf("[%s] [%s]\n", time.Now().Format(time.ANSIC), results[i].Error)) return fmt.Errorf("Download task [%s] on host [%s] failed with error [%s]", action[y].Name, results[i].Host, results[i].Error) } logger.WriteLogEntry(results[i].Host, action[y].Name, fmt.Sprintf("Completed Downloading file [%s] to path [%s]", action[y].Source, action[y].Destination), results[i].Error.Error()) //logger.WriteLogEntry("", fmt.Sprintf("[%s] Completed uploading file [%s] to Destination [%s] to host [%s]\n", time.Now().Format(time.ANSIC), action[y].Source, action[y].Destination, results[i].Host)) log.Infof("Succesfully uploaded [%s] to [%s] on [%s]", action[y].Source, action[y].Destination, results[i].Host) } case "command": logger.WriteLogEntry("command", fmt.Sprintf("Executing command action [%s] to multiple hosts\n", action[y].Name), "", "") command, err := buildCommand(action[y]) if err != nil { // Set checkpoint restore.Action = action[y].Name restore.createCheckpoint() return err } crs := ssh.ParalellExecute(command, action[y].CommandPipeFile, action[y].CommandPipeCmd, hosts, action[y].Timeout) var errors bool // This will only be set to true if a command fails for x := range crs { if crs[x].Error != nil { // Set checkpoint restore.Action = action[y].Name restore.createCheckpoint() logger.WriteLogEntry(crs[x].Host, action[y].Name, crs[x].Result, crs[x].Error.Error()) logger.SetLoggingState(crs[x].Host, "Failed") //log.Errorf("Command task [%s] on host [%s] failed with error [%s]\n\t[%s]", action[y].Name, crs[x].Host, crs[x].Result, crs[x].Error.Error()) errors = true // An error has been found //logger.WriteLogEntry("", fmt.Sprintf("------------ Output ------------\n%s\n----------------------------------\n", crs[x].Result)) return fmt.Errorf("Command task [%s] on host [%s] failed with error [%s]\n\t[%s]", action[y].Name, crs[x].Host, crs[x].Error, crs[x].Result) } log.Infof("Command Task [%s] on node [%s] completed successfully", action[y].Name, crs[x].Host) logger.WriteLogEntry(crs[x].Host, action[y].Name, crs[x].Result, "") //logger.WriteLogEntry("", fmt.Sprintf("[%s] Command task [%s] on host [%s] has completed succesfully\n", time.Now().Format(time.ANSIC), action[y].Name, crs[x].Host)) //logger.WriteLogEntry("", fmt.Sprintf("------------ Output ------------\n%s\n----------------------------------\n", crs[x].Result)) } if errors == true { return fmt.Errorf("An error was encountered on command Task [%s]", action[y].Name) } case "pkg": case "key": default: return fmt.Errorf("Unknown Action [%s]", action[y].ActionType) } } return nil } ================================================ FILE: pkg/parlay/parser_builder.go ================================================ package parlay import ( "fmt" "os" "os/exec" "strings" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" "github.com/plunder-app/plunder/pkg/ssh" log "github.com/sirupsen/logrus" ) func buildCommand(a parlaytypes.Action) (string, error) { var command string // An executable Key takes presedence if a.KeyName != "" { keycmd := Keys[a.KeyName] // Check that the key exists if keycmd == "" { return "", fmt.Errorf("Unable to find command under key '%s'", a.KeyName) } if a.CommandSudo != "" { // Add sudo to the Key command command = fmt.Sprintf("sudo -n -u %s %s", a.CommandSudo, keycmd) } else { command = keycmd } } else { // Not using a key, using a shell command if a.CommandSudo != "" { // Add sudo to the Shell command command = fmt.Sprintf("sudo -n -u %s %s", a.CommandSudo, a.Command) } else { command = a.Command } } return command, nil } func parseAndExecute(a parlaytypes.Action, h *ssh.HostSSHConfig) ssh.CommandResult { // This will parse the options passed in the action and execute the required string var cr ssh.CommandResult var b []byte command, err := buildCommand(a) if err != nil { cr.Error = err return cr } if a.CommandLocal == true { log.Debugf("Command [%s]", command) cmd := exec.Command("bash", "-c", command) b, cr.Error = cmd.CombinedOutput() if cr.Error != nil { return cr } cr.Result = strings.TrimRight(string(b), "\r\n") } else { log.Debugf("Executing command [%s] on host [%s]", command, h.Host) cr = ssh.SingleExecute(command, a.CommandPipeFile, a.CommandPipeCmd, *h, a.Timeout) cr.Result = strings.TrimRight(cr.Result, "\r\n") // If the command hasn't returned anything, put a filler in if cr.Result == "" { cr.Result = "[No Output]" } if cr.Error != nil { return cr } } // Save the results into a key to be used at another point if a.CommandSaveAsKey != "" { log.Debugf("Adding new results to key [%s]", a.CommandSaveAsKey) Keys[a.CommandSaveAsKey] = cr.Result } // Save the results into a file to be used at another point if a.CommandSaveFile != "" { var f *os.File f, cr.Error = os.Create(a.CommandSaveFile) if cr.Error != nil { return cr } defer f.Close() _, cr.Error = f.WriteString(cr.Result) if cr.Error != nil { return cr } f.Sync() } return cr } ================================================ FILE: pkg/parlay/plugin/plugin.go ================================================ package parlayplugin import ( "encoding/json" "fmt" "os" "path/filepath" "plugin" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" log "github.com/sirupsen/logrus" ) // The pluginCache contains a map of action->plugin var pluginCache map[string]string func init() { // Initialise the map pluginCache = make(map[string]string) } // Find plugins returns an array of all .plugin files func findPlugins(pluginDir string) ([]string, error) { var plugins []string // This function will look for all files in a specified directory (defaults to PWD/plugin) filepath.Walk(pluginDir, func(path string, f os.FileInfo, err error) error { if err != nil { return err } if !f.IsDir() { if filepath.Ext(path) == ".plugin" { absPath, _ := filepath.Abs(path) plugins = append(plugins, absPath) } } return nil }) return plugins, nil } func findFunctionInPlugin(pluginPath, functionName string) (plugin.Symbol, error) { plug, err := plugin.Open(pluginPath) if err != nil { log.Debugf("%v", err) return nil, fmt.Errorf("Unable to open Plugin [%s]", pluginPath) } symbol, err := plug.Lookup(functionName) if err != nil { log.Debugf("%v", err) return nil, fmt.Errorf("Unable to read functions from Plugin [%s]", pluginPath) } return symbol, nil } func init() { pluginList, err := findPlugins("./plugin") if err != nil { log.Errorf("%v", err) } else { log.Debugf("Found [%d] plugins", len(pluginList)) for x := range pluginList { symbol, err := findFunctionInPlugin(pluginList[x], "ParlayActionList") if err != nil { log.Errorf("%v", err) continue } pluginExec, ok := symbol.(func() []string) if !ok { log.Errorf("Unable to read functions from Plugin [%s]", pluginList[x]) continue } actions := pluginExec() for z := range actions { // This will give us a mapping of "action" => plugin pluginCache[actions[z]] = pluginList[x] } } } } //ListPlugins - func ListPlugins() { pluginList, err := findPlugins("./plugin") if err != nil { log.Errorf("%v", err) } else { log.Debugf("Found [%d] plugins", len(pluginList)) for x := range pluginList { symbol, err := findFunctionInPlugin(pluginList[x], "ParlayPluginInfo") if err != nil { log.Errorf("%v", err) continue } pluginExec, ok := symbol.(func() string) if !ok { log.Errorf("Unable to read functions from Plugin [%s]", pluginList[x]) continue } sanitizedPath := filepath.Base(pluginList[x]) fmt.Printf("%s\t%s\n", sanitizedPath, pluginExec()) } } } //ListPluginActions - func ListPluginActions(pluginPath string) { symbol, err := findFunctionInPlugin(pluginPath, "ParlayActionList") if err != nil { log.Errorf("%v", err) return } pluginExec, ok := symbol.(func() []string) if !ok { log.Errorf("Unable to read functions from Plugin [%s]", pluginPath) return } actions := pluginExec() symbol, err = findFunctionInPlugin(pluginPath, "ParlayActionDetails") if err != nil { log.Errorf("%v", err) return } pluginExec, ok = symbol.(func() []string) if !ok { log.Errorf("Unable to read functions from Plugin [%s]", pluginPath) return } descriptions := pluginExec() if len(actions) != len(descriptions) { log.Warnf("Not all actions have descriptions, contact your plugin provider to have this fixed") } for x := range actions { fmt.Printf("%s\t%s\n", actions[x], descriptions[x]) } } //UsagePlugin returns the usage of a plugin function func UsagePlugin(pluginPath, action string) { symbol, err := findFunctionInPlugin(pluginPath, "ParlayUsage") if err != nil { log.Errorf("%v", err) return } pluginExec, ok := symbol.(func(string) (json.RawMessage, error)) if !ok { log.Errorf("Unable to read functions from Plugin [%s]", pluginPath) return } result, err := pluginExec(action) if err != nil { log.Errorf("%v", err) return } a := parlaytypes.Action{ Name: fmt.Sprintf("Example name for action [%s]", action), ActionType: action, Plugin: result, } b, _ := json.MarshalIndent(a, "", "\t") fmt.Printf("%s\n", b) } // ExecuteAction uses the cache to find an action/plugin mapping func ExecuteAction(action, host string, raw json.RawMessage) ([]parlaytypes.Action, error) { if pluginCache[action] == "" { // No KeyMap meaning that the action doesn't map to a plugin return nil, fmt.Errorf("Action [%s] does not exist or has no plugin associated with it", action) } return ExecuteActionInPlugin(pluginCache[action], action, host, raw) } // ExecuteActionInPlugin specifies the plugin and action directly func ExecuteActionInPlugin(pluginPath, action, host string, raw json.RawMessage) ([]parlaytypes.Action, error) { // Check a function with the name ParlayExec exists symbol, err := findFunctionInPlugin(pluginPath, "ParlayExec") if err != nil { return nil, fmt.Errorf("%v", err) } log.Debugf("Attempting plugin [%s]", action) // Check the function has the correct parameters pluginExec, ok := symbol.(func(string, string, json.RawMessage) ([]parlaytypes.Action, error)) if !ok { return nil, fmt.Errorf("Unable to read functions from Plugin [%s]", pluginPath) } // Pass the action type and the interface to the plugin return pluginExec(action, host, raw) } ================================================ FILE: pkg/parlay/restore.go ================================================ package parlay import ( "encoding/json" "io/ioutil" "os" "github.com/mitchellh/go-homedir" ) //Restore provides a checkpoint to resume from type Restore struct { Deployment string `json:"deployment"` // Name of deployment to restore from Action string `json:"action"` // Action to restore from Host string `json:"host"` // Single host to start from Hosts []string `json:"hosts"` // Restart operation on a number of hosts } // restore is an interal struct used for execution restoration var restore Restore const restoreFile = ".parlay_restore" // restoreFilePath will build a path where a file will be read/writted func restoreFilePath() (string, error) { home, err := homedir.Dir() if err != nil { return "", err } return home + "/" + restoreFile, nil } func (r *Restore) createCheckpoint() error { // This function will create a checkpoint file that will allow Plunder to restart in the event of failure path, err := restoreFilePath() if err != nil { return err } // Marshall the struct to a byte array b, err := json.Marshal(r) if err != nil { return err } // Write the checkpoint file err = ioutil.WriteFile(path, b, 0644) return err } //RestoreFromCheckpoint will attempt to find a restoration checkpoint file func RestoreFromCheckpoint() *Restore { path, err := restoreFilePath() if err != nil { return nil } if _, err := os.Stat(path); !os.IsNotExist(err) { b, err := ioutil.ReadFile(path) if err != nil { return nil } var r Restore err = json.Unmarshal(b, &r) if err != nil { return nil } return &r } return nil } ================================================ FILE: pkg/parlay/validate.go ================================================ package parlay import ( "fmt" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" ) // ValidateAction will parse an action to ensure it is valid func ValidateAction(action *parlaytypes.Action) error { switch action.ActionType { case "upload": // Validate the upload action if action.Source == "" { return fmt.Errorf("The Source field can not be blank") } if action.Destination == "" { return fmt.Errorf("The Destination field can not be blank") } return nil case "download": // Validate the download action if action.Source == "" { return fmt.Errorf("The Source field can not be blank") } if action.Destination == "" { return fmt.Errorf("The Destination field can not be blank") } return nil case "command": // Validate the Command action if action.Command == "" && action.KeyName == "" { return fmt.Errorf("Neither a command or a key has been specified to execute") } if action.Command != "" && action.KeyName != "" { return fmt.Errorf("Unable to use both a Command and a Command Key") } return nil case "pkg": // Validate the Package action if action.PkgManager == "" { return fmt.Errorf("The Package Manager field can not be blank") } else if action.PkgManager != "apt" && action.PkgManager != "yum" { return fmt.Errorf("Unknown Package Manager [%s]", action.PkgManager) } if action.PkgOperation == "" { return fmt.Errorf("The Package Operation field can not be blank") } else if action.PkgOperation != "install" && action.PkgOperation != "remove" { return fmt.Errorf("Unknown Package Operation [%s]", action.PkgOperation) } if action.Packages == "" { return fmt.Errorf("The Packages field can not be blank") } return nil case "key": // Validate the Key action if action.KeyFile == "" { return fmt.Errorf("The KeyField field can not be blank") } return nil default: return fmt.Errorf("Unknown Action [%s]", action.ActionType) } } ================================================ FILE: pkg/plunderlogging/consolelogger.go ================================================ package plunderlogging ================================================ FILE: pkg/plunderlogging/filelogger.go ================================================ package plunderlogging import ( "fmt" "os" "sync" ) // FileLogger allows parlay to log output to a file on the local filesystem type FileLogger struct { enabled bool f *os.File } var fileLogging FileLogger func (l *FileLogger) initFileLogger(logFile string) (err error) { l.enabled = true l.f, err = os.Create(logFile) if err != nil { return err } return nil } // This file based logging function may error, but logging should never break the running of a system, so errors are passed to "Debug" logging func (l *FileLogger) writeEntry(target, entry string) error { var fileMutex sync.Mutex if l.enabled == true { // As this may be called by numerous goroutines, we impose a mutex lock on it fileMutex.Lock() defer fileMutex.Unlock() // TODO - Does this produce readable logging output _, err := l.f.WriteString(fmt.Sprintf("Target=%s Entry=%s", target, entry)) return err } return nil } func (l *FileLogger) setLoggingState(target, state string) error { return nil } ================================================ FILE: pkg/plunderlogging/go.mod ================================================ module github.com/plunder-app/plunder/pkg/plunderlogging go 1.12 ================================================ FILE: pkg/plunderlogging/jsonlogger.go ================================================ package plunderlogging import ( "fmt" "time" log "github.com/sirupsen/logrus" ) // JSONLogger allows parlay to log output to an in-memory jsonStruct type JSONLogger struct { enabled bool logger map[string]*JSONLog } // JSONLog contains all of the output from a parlay execution type JSONLog struct { State string `json:"state"` Entries []JSONLogEntry `json:"entries"` } // JSONLogEntry contains the details a specific action type JSONLogEntry struct { Created time.Time `json:"created"` TaskName string `json:"task"` Err string `json:"error"` Entry string `json:"entry"` } func (j *JSONLogger) initJSONLogger() { j.enabled = true j.logger = make(map[string]*JSONLog) } func (j *JSONLogger) writeEntry(target, task, entry, err string) { // Create new entry newEntry := JSONLogEntry{ Created: time.Now(), Entry: entry, TaskName: task, Err: err, } // Check if the logger exists existingLog, ok := j.logger[target] if ok { // Update an existing entry existingLog.Entries = append(existingLog.Entries, newEntry) } else { // Create a new logger newLog := JSONLog{ State: "Running", } // Append the entry to it newLog.Entries = append(newLog.Entries, newEntry) // Update the in-memory log store j.logger[target] = &newLog log.Debugf("Creating new logs for target [%s]", target) } } func (j *JSONLogger) deleteLog(target string) error { // Check if the entry exists _, ok := j.logger[target] if ok { // If it does, then we use the in-built function to delete the log entry delete(j.logger, target) } else { // Return a warning return fmt.Errorf("In-Memory logging for [%s] either doesn't exist or has already been deleted", target) } return nil } func (j *JSONLogger) setLoggingState(target, state string) error { // Check if the logger exists existingLog, ok := j.logger[target] if ok { // Update an existing entry existingLog.State = state } else { return fmt.Errorf("In-Memory logging for [%s] either doesn't exist or has already been deleted", target) } return nil } ================================================ FILE: pkg/plunderlogging/logger.go ================================================ package plunderlogging import "fmt" // Logger - is a stuct that manages the verious types of logger available type Logger struct { json JSONLogger file FileLogger } // EnableJSONLogging - will enable logging through JSON func (l *Logger) EnableJSONLogging(e bool) { l.json.enabled = e l.json.initJSONLogger() } // EnableFileLogging - will enable logging to a file func (l *Logger) EnableFileLogging(e bool) { l.file.enabled = e } // InitLogFile - will initialise file based logging func (l *Logger) InitLogFile(path string) error { if l.file.enabled != true { return l.file.initFileLogger(path) } // Dont re-initialise the file return nil } // InitJSON - will start/initialise the JSON logging functionality func (l *Logger) InitJSON() { // Dont re-initialise the json if l.json.enabled != true { l.json.initJSONLogger() } } // target - the entity we're affecting // entry - the results of the operation on the target // WriteLogEntry will capture what is transpiring and where func (l *Logger) WriteLogEntry(target, task, entry, err string) { if l.file.enabled { l.file.writeEntry(target, entry) } if l.json.enabled { l.json.writeEntry(target, task, entry, err) } // A logging system shouldnt break anything so any errors are just outputed to STDOUT } // SetLoggingState - currently a NOOP (TODO) func (l *Logger) SetLoggingState(target, state string) { if l.file.enabled { l.file.setLoggingState(target, state) } if l.json.enabled { l.json.setLoggingState(target, state) } // A logging system shouldnt break anything so any errors are just outputed to STDOUT } // GetJSONLogs - returns a pointer to the current JSON Logs func (l *Logger) GetJSONLogs(target string) (*JSONLog, error) { if l.json.logger == nil { return nil, fmt.Errorf("JSON Logging hasn't been enabled") } // Check if the logger exists existingLog, ok := l.json.logger[target] if ok { return existingLog, nil } return nil, fmt.Errorf("No Logs for Target [%s] exist", target) } // DeleteLogs - will remove logs for a particular target func (l *Logger) DeleteLogs(target string) error { if l.json.logger == nil { return nil } return l.json.deleteLog(target) } ================================================ FILE: pkg/services/deployments.go ================================================ package services import ( "encoding/json" "fmt" "strings" "github.com/plunder-app/plunder/pkg/utils" log "github.com/sirupsen/logrus" ) // DefaultBootType specifies what a server will default to if no config is found var DefaultBootType string // This stores the mapping for a url to the data /macaddress.file => data var httpPaths map[string]string func init() { // Initialise the paths map httpPaths = make(map[string]string) } // rebuildConfiguration - will parse the entire deployment configuration and update anything that is missing func rebuildConfiguration(updateConfig *DeploymentConfigurationFile) error { // If HTTP isn't enabled we can't build the multiplexer for URLs if serveMux == nil { return fmt.Errorf("Deployment HTTP Server isn't enabled, so parsing deployments isn't possible") } // If a key is specified then we read it and base64 the file into the SSHKEY string if updateConfig.GlobalServerConfig.SSHKeyPath != "" { err := updateConfig.GlobalServerConfig.parseSSH() if err != nil { log.Errorf(err.Error()) } } log.Debugf("Parsing [%d] Configurations", len(updateConfig.Configs)) for i := range updateConfig.Configs { // inMemipxeConfig is a custom configuration that matches kernel/initrd & cmdline and is 00:11:22:33:44:55.ipxe var inMemipxeConfig string // inMemipxeConfig is a custom configuration that is specific to the boot type [preseed/kickstart/vsphere] and is 00:11:22:33:44:55.cfg var inMemBootConfig string // imMemESXiKickstart is a custom configuration specific to vSphere for it's kickstart var imMemESXiKickstart string // inMemBOOTyConfig is a custom configuration that matches kernel/initrd & cmdline and is 00:11:22:33:44:55.bty var inMemBOOTyConfig string // We need to move all ":" to "-" to make life a little easier for filesystems and internet standards dashMac := strings.Replace(updateConfig.Configs[i].MAC, ":", "-", -1) // Find the deployment configuration for this host, either custom or inherit from the controller bootConfig := findBootConfigForDeployment(updateConfig.Configs[i]) // If there is no deployment configuration under this name return an error if bootConfig == nil { errorString := fmt.Errorf("Host [%s] uses unknown config [%s], stopping config update", updateConfig.Configs[i].MAC, updateConfig.Configs[i].ConfigName) log.Errorln(errorString) return errorString } // Ensure this entry has the correct mapping updateConfig.Configs[i].ConfigBoot = *bootConfig // This will populate anything missing from the global configuration updateConfig.Configs[i].ConfigHost.PopulateFromGlobalConfiguration(updateConfig.GlobalServerConfig) // If a key is specified then we read it and base64 the file into the SSHKEY string if updateConfig.Configs[i].ConfigHost.SSHKeyPath != "" { err := updateConfig.Configs[i].ConfigHost.parseSSH() if err != nil { log.Errorf(err.Error()) } } else { log.Errorf("This server [%s] will be deployed with no SSH Key", updateConfig.Configs[i].ConfigHost.ServerName) } // Look for understood config types switch updateConfig.Configs[i].ConfigBoot.ConfigType { case "preseed": inMemipxeConfig = utils.IPXEPreeseed(httpAddress, bootConfig.Kernel, bootConfig.Initrd, bootConfig.Cmdline) log.Debugf("Generating preseed ipxeConfig for configName [%s]", dashMac) inMemBootConfig = updateConfig.Configs[i].ConfigHost.BuildPreeSeedConfig() case "kickstart": inMemipxeConfig = utils.IPXEKickstart(httpAddress, bootConfig.Kernel, bootConfig.Initrd, bootConfig.Cmdline) log.Debugf("Generating kickstart ipxeConfig for configName [%s]", dashMac) inMemBootConfig = updateConfig.Configs[i].ConfigHost.BuildKickStartConfig() case "vsphere": inMemipxeConfig = utils.IPXEVSphere(httpAddress, bootConfig.Kernel, bootConfig.Cmdline) log.Debugf("Generating vsphere ipxeConfig for configName [%s]", dashMac) inMemBootConfig = updateConfig.Configs[i].ConfigHost.BuildESXiConfig() imMemESXiKickstart = updateConfig.Configs[i].ConfigHost.BuildESXiKickStart() case "booty": inMemipxeConfig = utils.IPXEBOOTy(httpAddress, bootConfig.Kernel, bootConfig.Initrd, bootConfig.Cmdline) log.Debugf("Generating booty ipxeConfig for configName [%s]", dashMac) inMemBOOTyConfig = updateConfig.Configs[i].ConfigHost.BuildBOOTYconfig() default: log.Debugf("Generating default ipxeConfig for configName [%s]", updateConfig.Configs[i].ConfigBoot.ConfigName) inMemipxeConfig = utils.IPXEAnyBoot(httpAddress, bootConfig.Kernel, bootConfig.Initrd, bootConfig.Cmdline) } // Build the configuration that is passed to iPXE on boot if inMemipxeConfig != "" { path := fmt.Sprintf("/%s.ipxe", dashMac) if _, ok := httpPaths[path]; !ok { // Only create the handler if one doesn't exist serveMux.HandleFunc(path, rootHandler) } httpPaths[path] = inMemipxeConfig } // Build a boot configuration that is passed to a kernel if inMemBootConfig != "" { path := fmt.Sprintf("/%s.cfg", dashMac) if _, ok := httpPaths[path]; !ok { // Only create the handler if one doesn't exist serveMux.HandleFunc(path, rootHandler) } httpPaths[path] = inMemBootConfig } // Build a vSphere kickstart configuration that is passed to an installer if imMemESXiKickstart != "" { path := fmt.Sprintf("/%s.ks", dashMac) if _, ok := httpPaths[path]; !ok { // Only create the handler if one doesn't exist serveMux.HandleFunc(path, rootHandler) } httpPaths[path] = imMemESXiKickstart } // Build a BOOTy configuration that is passed to an installer if inMemBOOTyConfig != "" { path := fmt.Sprintf("/%s.bty", dashMac) if _, ok := httpPaths[path]; !ok { // Only create the handler if one doesn't exist serveMux.HandleFunc(path, rootHandler) } httpPaths[path] = inMemBOOTyConfig } } if len(updateConfig.Configs) == 0 { // No changes, leave as is (with a warning) log.Warnln("No deployment configuration, any existing configuration will remain") } else { // Updated configuration has been parsed, update internal deployment configuration log.Infoln("Updating of deployment configuration complete") Deployments = *updateConfig } return nil } // UpdateDeploymentConfig will read a configuration string and build the iPXE files needed func UpdateDeploymentConfig(rawDeploymentConfig []byte) error { // Read through the deployment configuration log.Infoln("Updating the Deployment Configuration") // Work out if it is a YAML/JSON or unknown updateConfig, err := ParseDeployment(rawDeploymentConfig) if err != nil { return err } return rebuildConfiguration(updateConfig) } // AddDeployment - This function will add a new deployment to the deployment configuration func AddDeployment(rawDeployment []byte) error { var newDeployment DeploymentConfig err := json.Unmarshal(rawDeployment, &newDeployment) if err != nil { return fmt.Errorf("Unable to parse deployment configuration") } // Find the original deployment via it's mac address for i := range Deployments.Configs { // Compare this deployment to the one we're looking for if Deployments.Configs[i].MAC == newDeployment.MAC { return fmt.Errorf("Duplicate entry for MAC address [%s]", newDeployment.MAC) } } // We will now duplicate our configuration updateConfig := Deployments // We will need to create space to copy the existing configurations over updateConfig.Configs = make([]DeploymentConfig, len(Deployments.Configs)) // Copy our existing configurations into the new configuration copy(updateConfig.Configs, Deployments.Configs) // Append our new configuration into our new copy updateConfig.Configs = append(updateConfig.Configs, newDeployment) // Remove the deployment from the unleased addresses controller.DelUnLeased(newDeployment.MAC) // Parse the new configuration return rebuildConfiguration(&updateConfig) } // GetDeployment - This function will add a new deployment to the deployment configuration func GetDeployment(macAddress string) *DeploymentConfig { // Iterate through all the deployments for i := range Deployments.Configs { if macAddress == Deployments.Configs[i].MAC { return &Deployments.Configs[i] } } return nil } // UpdateDeployment - This function will add a new deployment to the deployment configuration func UpdateDeployment(macAddress string, rawDeployment []byte) error { var newDeployment DeploymentConfig err := json.Unmarshal(rawDeployment, &newDeployment) if err != nil { return fmt.Errorf("Unable to parse deployment configuration") } // if no ID or specific MAC address was passed then assume the mac address of the deployment if macAddress == "" { macAddress = newDeployment.MAC } // We will now duplicate our configuration updateConfig := Deployments // We will need to create space to copy the existing configurations over updateConfig.Configs = make([]DeploymentConfig, len(Deployments.Configs)) // Copy our existing configurations into the new configuration copy(updateConfig.Configs, Deployments.Configs) // Find the original deployment via it's mac address for i := range updateConfig.Configs { // Compare this deployment to the one we're looking for if updateConfig.Configs[i].MAC == macAddress { // Remove the old matching configuration updateConfig.Configs = append(updateConfig.Configs[:i], updateConfig.Configs[i+1:]...) // Append our new configuration into our new copy updateConfig.Configs = append(updateConfig.Configs, newDeployment) // Parse the new configuration return rebuildConfiguration(&updateConfig) } } return fmt.Errorf("Unable to find existing deployment for MAC address [%s]", macAddress) } // DeleteDeploymentMac - This function will delete a deployment based upon it's mac Address func DeleteDeploymentMac(macAddress string, rawDeployment []byte) error { // We will now duplicate our configuration updateConfig := Deployments // We will need to create space to copy the existing configurations over updateConfig.Configs = make([]DeploymentConfig, len(Deployments.Configs)) // Copy our existing configurations into the new configuration copy(updateConfig.Configs, Deployments.Configs) // Find the original deployment via it's mac address for i := range updateConfig.Configs { // Compare this deployment to the one we're looking for if updateConfig.Configs[i].MAC == macAddress { // Remove http Handler (if it exists) _, ok := httpPaths[fmt.Sprintf("%s.ipxe", updateConfig.Configs[i].MAC)] if ok { delete(httpPaths, fmt.Sprintf("%s.ipxe", updateConfig.Configs[i].MAC)) } // Remove the old matching configuration updateConfig.Configs = append(updateConfig.Configs[:i], updateConfig.Configs[i+1:]...) // Parse the new configuration return rebuildConfiguration(&updateConfig) } } return fmt.Errorf("Unable to find existing deployment for Address [%s]", macAddress) } // DeleteDeploymentAddress - This function will delete a deployment based upon it's IP Address func DeleteDeploymentAddress(address string, rawDeployment []byte) error { // We will now duplicate our configuration updateConfig := Deployments // We will need to create space to copy the existing configurations over updateConfig.Configs = make([]DeploymentConfig, len(Deployments.Configs)) // Copy our existing configurations into the new configuration copy(updateConfig.Configs, Deployments.Configs) // Find the original deployment via it's mac address for i := range updateConfig.Configs { // Compare this deployment to the one we're looking for if updateConfig.Configs[i].ConfigHost.IPAddress == address { // Remove http Handler (if it exists) _, ok := httpPaths[fmt.Sprintf("%s.ipxe", updateConfig.Configs[i].MAC)] if ok { delete(httpPaths, fmt.Sprintf("%s.ipxe", updateConfig.Configs[i].MAC)) } // Remove the old matching configuration updateConfig.Configs = append(updateConfig.Configs[:i], updateConfig.Configs[i+1:]...) // Parse the new configuration return rebuildConfiguration(&updateConfig) } } return fmt.Errorf("Unable to find existing deployment for Address [%s]", address) } // UpdateGlobalDeploymentConfig - This allows updating of the global configuration independently func UpdateGlobalDeploymentConfig(rawDeployment []byte) error { var globalDeploymentConfig HostConfig err := json.Unmarshal(rawDeployment, &globalDeploymentConfig) if err != nil { return fmt.Errorf("Unable to parse deployment configuration") } // Update the deployments with the new configuration Deployments.GlobalServerConfig = globalDeploymentConfig return nil } //FindDeploymentConfigFromMac - this will return the deployment configuration, allowing the DHCP server to return the correct DHCP options func FindDeploymentConfigFromMac(mac string) string { // AnyBoot will just boot the specified kernel/initrd // if AnyBoot == true { // return "anyboot" // } if len(Deployments.Configs) == 0 { // No configurations have been loaded log.Warnln("Attempted to perform Mac Address lookup, however no configurations have been loaded") return "" } for i := range Deployments.Configs { log.Debugf("Comparing [%s] to [%s]", mac, strings.ToLower(Deployments.Configs[i].MAC)) if mac == strings.ToLower(Deployments.Configs[i].MAC) { return Deployments.Configs[i].ConfigName } } return DefaultBootType } ================================================ FILE: pkg/services/go.mod ================================================ module github.com/plunder-app/plunder/pkg/services go 1.12 ================================================ FILE: pkg/services/handler.go ================================================ package services import ( "encoding/json" "fmt" "io/ioutil" "net/http" "strings" "github.com/gorilla/mux" "github.com/plunder-app/plunder/pkg/apiserver" ) // RegisterToAPIServer - will add the endpoints to the API server func RegisterToAPIServer() { // ------------------------------------------------ // Server configuration API registration // ------------------------------------------------ apiserver.AddDynamicEndpoint("/config", "/config", "Allows the retrieving of Plunder Server configuration", "config", http.MethodGet, getConfig) apiserver.AddDynamicEndpoint("/config", "/config", "Allows the creation of Plunder Server configuration", "config", http.MethodPost, postConfig) apiserver.AddDynamicEndpoint("/config/boot/{id}", "/config/boot", "Allows the creation of Plunder Server Boot configuration", "configBoot", http.MethodPost, postBootConfig) apiserver.AddDynamicEndpoint("/config/boot/{id}", "/config/boot", "Performs the deletion of Plunder Server Boot configuration", "configBoot", http.MethodDelete, deleteBootConfig) // ------------------------------------------------ // DHCP configuration API registration // ------------------------------------------------ apiserver.AddDynamicEndpoint("/dhcp/{id}", "/dhcp", "Allows the retrieval of DHCP information", "dhcp", http.MethodGet, getDHCP) // ------------------------------------------------ // Deployment configuration API registration // ------------------------------------------------ apiserver.AddDynamicEndpoint("/deployments", "/deployments", "Allows the retrieving of Plunder Server deployments", "deployments", http.MethodGet, getDeployments) apiserver.AddDynamicEndpoint("/deployments", "/deployments", "Allows the creation of Plunder Server deployments", "deployments", http.MethodPost, postDeployments) apiserver.AddDynamicEndpoint("/deployment", "/deployment", "Allows the creation of a specific Plunder deployment", "deployment", http.MethodPost, postDeployment) apiserver.AddDynamicEndpoint("/deployment", "/deployment", "Allows the patching of a Plunder Server deployment", "deployment", http.MethodPatch, updateDeployment) apiserver.AddDynamicEndpoint("/deployment/{id}", "/deployment", "Allows the retrieval of specific information about a deployment", "deploymentID", http.MethodGet, getSpecificDeployment) apiserver.AddDynamicEndpoint("/deployment/{id}", "/deployment", "Allows the patching of Plunder Server deployments", "deploymentID", http.MethodPatch, updateDeployment) apiserver.AddDynamicEndpoint("/deployment/{id}", "/deployment", "Allows the deletion of a Plunder Server deployment", "deploymentID", http.MethodDelete, deleteDeployment) apiserver.AddDynamicEndpoint("/deployment/mac/{id}", "/deployment/mac", "Allows the deletion of a Plunder Server deployment based upon its MAC address", "deploymentMac", http.MethodDelete, deleteDeploymentMac) apiserver.AddDynamicEndpoint("/deployment/address/{id}", "/deployment/address", "Allows the deletion of a Plunder Server deployment based upon its network address", "deploymentAddress", http.MethodDelete, deleteDeploymentAddress) } func getConfig(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response jsonData, err := json.Marshal(Controller) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error retrieving Server Configuration" rsp.Error = err.Error() } else { rsp.Payload = jsonData } json.NewEncoder(w).Encode(rsp) } // Apply the plunder server global configuration func postConfig(w http.ResponseWriter, r *http.Request) { if b, err := ioutil.ReadAll(r.Body); err == nil { var rsp apiserver.Response // This function needs to parse both the data and then evaluate the state of running services err := ParseControllerData(b) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error updating Server Configuration" rsp.Error = err.Error() } json.NewEncoder(w).Encode(rsp) Controller.StartServices(nil) } } // Apply a Specific Boot Configuration func postBootConfig(w http.ResponseWriter, r *http.Request) { if b, err := ioutil.ReadAll(r.Body); err == nil { var rsp apiserver.Response // This function needs to parse both the data and then evaluate the state of running services var newBoot BootConfig err := json.Unmarshal(b, &newBoot) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error updating Server Configuration" rsp.Error = err.Error() } else { for x := range Controller.BootConfigs { if Controller.BootConfigs[x].ConfigName == newBoot.ConfigName { // Found a duplicate w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error duplicate Server Configuration" rsp.Error = fmt.Sprintf("Boot Configuration [%s] already exists", Controller.BootConfigs[x].ConfigName) json.NewEncoder(w).Encode(rsp) return } } // // Parse the boot configuration (preload ISOs etc.) err = newBoot.Parse() // err = Controller.ParseBootController() if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error updating Server Configuration" rsp.Error = err.Error() } else { // Add the Boot configuration to the controller Controller.BootConfigs = append(Controller.BootConfigs, newBoot) // Generate the handlers (this can probably GO soon) Controller.generateBootTypeHanders() } // // Parse the boot configuration (preload ISOs etc.) // err = Controller.ParseBootController() // if err != nil { // w.Header().Set("Content-Type", "application/json") // rsp.Warning = "Error updating Server Configuration" // rsp.Error = err.Error() // } } json.NewEncoder(w).Encode(rsp) } } // Apply a Specific Boot Configuration func deleteBootConfig(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Find the deployment ID id := mux.Vars(r)["id"] var rsp apiserver.Response // We need to revert the mac address back to the correct format (dashes back to colons) err := Controller.DeleteBootControllerConfig(id) if err != nil { if err != nil { rsp.Warning = "Error updating Deployment Configuration" rsp.Error = err.Error() rsp.Payload = nil } } json.NewEncoder(w).Encode(rsp) } func getDeployments(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response jsonData, err := json.Marshal(Deployments) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error retrieving deployment Configuration" rsp.Error = err.Error() } else { rsp.Payload = jsonData } json.NewEncoder(w).Encode(rsp) } // Apply the plunder global deployment configuration func postDeployments(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if b, err := ioutil.ReadAll(r.Body); err == nil { err := UpdateDeploymentConfig(b) var rsp apiserver.Response if err != nil { rsp.Warning = "Error updating Deployment Configuration" rsp.Error = err.Error() rsp.Payload = nil } json.NewEncoder(w).Encode(rsp) } } // Retrieve a specific plunder deployment configuration func getSpecificDeployment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response // Find the deployment ID id := mux.Vars(r)["id"] // We need to revert the mac address back to the correct format (dashes back to colons) mac := strings.Replace(id, "-", ":", -1) deployment := GetDeployment(mac) if deployment != nil { jsonData, err := json.Marshal(deployment) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error retrieving deployment Configuration" rsp.Error = err.Error() } else { rsp.Payload = jsonData } } else { rsp.Error = fmt.Sprintf("Unable to find %s", mac) } json.NewEncoder(w).Encode(rsp) } // Retrieve a specific plunder deployment configuration func postDeployment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if b, err := ioutil.ReadAll(r.Body); err == nil { err := AddDeployment(b) var rsp apiserver.Response if err != nil { rsp.Warning = "Error updating Deployment Configuration" rsp.Error = err.Error() rsp.Payload = nil } json.NewEncoder(w).Encode(rsp) } } // Retrieve a specific plunder deployment configuration func updateDeployment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response // Find the deployment ID id := mux.Vars(r)["id"] // Are we updating the deployment "global" if id == "global" { if b, err := ioutil.ReadAll(r.Body); err == nil { err := UpdateGlobalDeploymentConfig(b) if err != nil { rsp.Warning = "Error updating Global Configuration" rsp.Error = err.Error() rsp.Payload = nil } } } else { // We need to revert the mac address back to the correct format (dashes back to colons) mac := strings.Replace(id, "-", ":", -1) if b, err := ioutil.ReadAll(r.Body); err == nil { err := UpdateDeployment(mac, b) if err != nil { rsp.Warning = "Error updating Deployment Configuration" rsp.Error = err.Error() rsp.Payload = nil } } } json.NewEncoder(w).Encode(rsp) } // Retrieve a specific plunder deployment configuration func deleteDeployment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Find the deployment ID id := mux.Vars(r)["id"] var rsp apiserver.Response if b, err := ioutil.ReadAll(r.Body); err == nil { // Try the Mac address first // We need to revert the mac address back to the correct format (dashes back to colons) err := DeleteDeploymentMac(strings.Replace(id, "-", ":", -1), b) if err != nil { // We need to revert the ip address back to the correct format (dashes back to periods) err = DeleteDeploymentAddress(strings.Replace(id, "-", ".", -1), b) if err != nil { rsp.Warning = "Error updating Deployment Configuration" rsp.Error = err.Error() rsp.Payload = nil } } } json.NewEncoder(w).Encode(rsp) } // Retrieve a specific plunder deployment configuration func deleteDeploymentMac(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Find the deployment ID id := mux.Vars(r)["id"] var rsp apiserver.Response if b, err := ioutil.ReadAll(r.Body); err == nil { // We need to revert the mac address back to the correct format (dashes back to colons) err := DeleteDeploymentMac(strings.Replace(id, "-", ":", -1), b) if err != nil { rsp.Warning = "Error updating Deployment Configuration" rsp.Error = err.Error() rsp.Payload = nil } } json.NewEncoder(w).Encode(rsp) } // Retrieve a specific plunder deployment configuration func deleteDeploymentAddress(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Find the deployment ID id := mux.Vars(r)["id"] var rsp apiserver.Response if b, err := ioutil.ReadAll(r.Body); err == nil { // We need to revert the mac address back to the correct format (dashes back to colons) err = DeleteDeploymentAddress(strings.Replace(id, "-", ".", -1), b) if err != nil { rsp.Warning = "Error updating Deployment Configuration" rsp.Error = err.Error() rsp.Payload = nil } } json.NewEncoder(w).Encode(rsp) } func getDHCP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var rsp apiserver.Response // Find the deployment ID id := mux.Vars(r)["id"] if id == "leases" { jsonData, err := json.Marshal(Controller.GetLeases()) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error retrieving allocated leases" rsp.Error = err.Error() } else { rsp.Payload = jsonData } } // Are we updating the deployment "global" if id == "unleased" { jsonData, err := json.Marshal(Controller.GetUnLeased()) if err != nil { w.Header().Set("Content-Type", "application/json") rsp.Warning = "Error retrieving allocated leases" rsp.Error = err.Error() } else { rsp.Payload = jsonData } } json.NewEncoder(w).Encode(rsp) } ================================================ FILE: pkg/services/server.go ================================================ package services import ( "encoding/json" "fmt" "github.com/ghodss/yaml" log "github.com/sirupsen/logrus" ) // This is needed by other functions to build strings var httpAddress string // Controller contains all the "current" settings for booting servers var Controller BootController // Deployments - contains an accessible "current" configuration for all deployments var Deployments DeploymentConfigurationFile // ParseControllerData will read in a byte array and attempt to parse it as yaml or json func ParseControllerData(b []byte) error { jsonBytes, err := yaml.YAMLToJSON(b) if err == nil { // If there were no errors then the YAML => JSON was successful, no attempt to unmarshall err = json.Unmarshal(jsonBytes, &Controller) if err != nil { return fmt.Errorf("Unable to parse configuration as either yaml or json") } } else { // Couldn't parse the yaml to JSON // Attempt to parse it as JSON err = json.Unmarshal(b, &Controller) if err != nil { return fmt.Errorf("Unable to parse configuration as either yaml or json") } } return nil } // Parse will read through a new configuration and implement the configuration if possible func (b *BootConfig) Parse() error { if isoMapper == nil { // Ensure it is initialised before trying to use it isoMapper = make(map[string]string) } if b.ISOPrefix == "" || b.ISOPath == "" { log.Debugf("No ISO is being parsed for configuration %s", b.ConfigName) } else { // Atempt to open the ISO and add it to the map for usage later err := OpenISO(b.ISOPath, b.ISOPrefix) if err != nil { log.Errorf("Error parsing ISO [%v]", err) return err } // Create the prefix urlPrefix := fmt.Sprintf("/%s/", b.ISOPrefix) // Only create the handler if one doesn't exist if _, ok := isoMapper[b.ISOPrefix]; !ok { log.Debugf("Adding handler %s", urlPrefix) serveMux.HandleFunc(urlPrefix, isoReader) // Add the iso path to the correct prefix isoMapper[b.ISOPrefix] = b.ISOPath } log.Debugf("Updating handler %s for config %s", urlPrefix, b.ConfigName) } log.Infof("Boot Config [%s] of type [%s] parsed succesfully", b.ConfigName, b.ConfigType) // No errors and BootConfig is applied return nil } // // ParseBootController - will iterate through the boot controller and see if any changes need applying // // this is mainly for the dynamic loading of ISOs // func (c *BootController) ParseBootController() error { // for i := range c.BootConfigs { // // If either the prefix or path are blank then iterate over, both need to be set in order to load the ISO // if c.BootConfigs[i].ISOPrefix == "" || c.BootConfigs[i].ISOPath == "" { // log.Debugf("No ISO is being parsed for configuration %s", c.BootConfigs[i].ConfigName) // } else { // // Atempt to open the ISO and add it to the map for usage later // err := OpenISO(c.BootConfigs[i].ISOPath, c.BootConfigs[i].ISOPrefix) // if err != nil { // log.Errorf("Error parsing ISO [%v]", err) // return err // } // // Create the prefix // urlPrefix := fmt.Sprintf("/%s/", c.BootConfigs[i].ISOPrefix) // // Only create the handler if one doesn't exist // if _, ok := isoMapper[c.BootConfigs[i].ISOPrefix]; !ok { // log.Debugf("Adding handler %s", urlPrefix) // serveMux.HandleFunc(urlPrefix, isoReader) // } // log.Debugf("Updating handler %s for config %s", urlPrefix, c.BootConfigs[i].ConfigName) // } // } // // Parse the boot controllers for new configuration changes // c.generateBootTypeHanders() // return nil // } // DeleteBootControllerConfig - will iterate through the boot controller and see if any changes need applying // this is mainly for the dynamic loading of ISOs func (c *BootController) DeleteBootControllerConfig(configName string) error { for i := range c.BootConfigs { if c.BootConfigs[i].ConfigName == configName { // Remove the mapping to an ISO path if isoMapper != nil { // Ensure it is initialised before trying to remove boot config isoMapper[c.BootConfigs[i].ISOPrefix] = "" } c.BootConfigs = append(c.BootConfigs[:i], c.BootConfigs[i+1:]...) return nil } } return fmt.Errorf("Unable to find boot configuration %s", configName) } // ParseDeployment will read in a byte array and attempt to parse it as yaml or json func ParseDeployment(b []byte) (*DeploymentConfigurationFile, error) { var deployment DeploymentConfigurationFile jsonBytes, err := yaml.YAMLToJSON(b) if err == nil { // If there were no errors then the YAML => JSON was successful, no attempt to unmarshall err = json.Unmarshal(jsonBytes, &deployment) if err != nil { return nil, fmt.Errorf("Unable to parse configuration as either yaml or json\n %s", err.Error()) } } else { // Couldn't parse the yaml to JSON // Attempt to parse it as JSON err = json.Unmarshal(b, &deployment) if err != nil { return nil, fmt.Errorf("Unable to parse configuration as either yaml or json\n %s", err.Error()) } } return &deployment, nil } ================================================ FILE: pkg/services/serverDHCP.go ================================================ package services import ( "fmt" "math/rand" "net" "strings" "time" dhcp "github.com/krolaw/dhcp4" log "github.com/sirupsen/logrus" ) // Lease defines a lease that is allocated to a client type Lease struct { MAC string `json:"mac"` // Client's Physical Address Expiry time.Time `json:"time"` // When the lease expires } // DHCPSettings - type DHCPSettings struct { IP net.IP // Server IP to use Options dhcp.Options // Options to send to DHCP Clients Start net.IP // Start of IP range to distribute LeaseRange int // Number of IPs to distribute (starting from start) LeaseDuration time.Duration // Lease period Leases map[int]Lease // Map to keep track of leases UnLeased []Lease // Map to keep track of unleased devices, and when they were seen } // Discover - Is the discovering of a DHCP server on the network and the typical result is an lease "offer" // Request - The Request is typically the acceptance of a DHCP lease // Release - A Release is the client notifying that server that the lease is no longer required //ServeDHCP - Is the function that is called when ever plunder recieves DHCP packets. func (h *DHCPSettings) ServeDHCP(p dhcp.Packet, msgType dhcp.MessageType, options dhcp.Options) (d dhcp.Packet) { mac := strings.ToLower(p.CHAddr().String()) log.Debugf("DCHP Message Type: [%v] from MAC Address [%s]", msgType, mac) // Retrieve teh deployment type deploymentType := FindDeploymentConfigFromMac(mac) // Convert the : in the mac address to dashes to make life easier dashMac := strings.Replace(mac, ":", "-", -1) // These packets typicallty will be in one of a number of phases: switch msgType { case dhcp.Discover: // Look for an existing license free := -1 for i, v := range h.Leases { // Find previous lease if v.MAC == mac { free = i goto reply } } // Look for a free lease if free = h.freeLease(); free == -1 { // No leases available return } reply: //TODO - work out why this is here h.Options[dhcp.OptionVendorClassIdentifier] = h.IP // if DHCP option "OptionUserClass" is set to iPXE then we know that it's default booted to the correct bootloader if string(options[dhcp.OptionUserClass]) == "iPXE" { // This will ensure that the leasing table is kept updated for when a server was last seen h.leaseHander(deploymentType, mac) // TODO - This can be removed and left in the REQUEST section only // if an entry doesnt exist then drop it to a default type, if not then it has its own specific if httpPaths[fmt.Sprintf("%s.ipxe", dashMac)] == "" { h.Options[dhcp.OptionBootFileName] = []byte("http://" + h.IP.String() + "/" + deploymentType + ".ipxe") } else { h.Options[dhcp.OptionBootFileName] = []byte("http://" + h.IP.String() + "/" + dashMac + ".ipxe") } } ipLease := dhcp.IPAdd(h.Start, free) log.Debugf("Allocated IP [%s] for [%s]", ipLease.String(), mac) return dhcp.ReplyPacket(p, dhcp.Offer, h.IP, ipLease, h.LeaseDuration, h.Options.SelectOrderOrAll(options[dhcp.OptionParameterRequestList])) case dhcp.Request: if server, ok := options[dhcp.OptionServerIdentifier]; ok && !net.IP(server).Equal(h.IP) { return nil // Message not for this dhcp server } reqIP := net.IP(options[dhcp.OptionRequestedIPAddress]) if reqIP == nil { reqIP = net.IP(p.CIAddr()) } if len(reqIP) == 4 && !reqIP.Equal(net.IPv4zero) { if leaseNum := dhcp.IPRange(h.Start, reqIP) - 1; leaseNum >= 0 && leaseNum < h.LeaseRange { if l, exists := h.Leases[leaseNum]; !exists || l.MAC == p.CHAddr().String() { // Specify the new lease h.Leases[leaseNum] = Lease{ MAC: p.CHAddr().String(), Expiry: time.Now().Add(h.LeaseDuration), } // if DHCP option "OptionUserClass" is set to iPXE then we know that it's default booted to the correct bootloader if string(options[dhcp.OptionUserClass]) == "iPXE" { // Only Print out this notification if it's from the iPXE Boot loader log.Infof("Mac address [%s] is assigned a [%s] deployment type", mac, deploymentType) } // if an entry doesnt exist then drop it to a default type, if not then it has its own specific if httpPaths[fmt.Sprintf("/%s.ipxe", dashMac)] == "" { h.Options[dhcp.OptionBootFileName] = []byte("http://" + h.IP.String() + "/" + deploymentType + ".ipxe") } else { h.Options[dhcp.OptionBootFileName] = []byte("http://" + h.IP.String() + "/" + dashMac + ".ipxe") } return dhcp.ReplyPacket(p, dhcp.ACK, h.IP, reqIP, h.LeaseDuration, h.Options.SelectOrderOrAll(options[dhcp.OptionParameterRequestList])) } } } return dhcp.ReplyPacket(p, dhcp.NAK, h.IP, nil, 0, nil) case dhcp.Release, dhcp.Decline: for i, v := range h.Leases { if v.MAC == mac { log.Debugf("Releasing lease for [%s]", mac) delete(h.Leases, i) break } } } return nil } // leaseHandler() will take care of adding and removing leases based upon use-case func (h *DHCPSettings) leaseHander(deploymentType, mac string) { if deploymentType == "" || deploymentType == "autoBoot" || deploymentType == "reboot" { // Create a lease for an un-used server (dont by default) newUnleased := Lease{ MAC: mac, Expiry: time.Now(), } // False by default var macFound bool // Look through array for i := range h.UnLeased { if mac == h.UnLeased[i].MAC { h.UnLeased[i].Expiry = time.Now() // Found this entry macFound = true } } // New entry if macFound == false { // Update the unleased map with this mac address being seen h.UnLeased = append(h.UnLeased, newUnleased) } } // If this mac address has no deployment type for whatever reason, ensure a warning message is presented if deploymentType == "" { log.Warnf("Mac address[%s] is unknown, not returning an address", mac) } } func (h *DHCPSettings) freeLease() int { now := time.Now() b := rand.Intn(h.LeaseRange) // Try random first for _, v := range [][]int{{b, h.LeaseRange}, {0, b}} { for i := v[0]; i < v[1]; i++ { if l, ok := h.Leases[i]; !ok || l.Expiry.Before(now) { return i } } } return -1 } // GetLeases - This will retrieve all of the allocated leases from the boot controller func (c *BootController) GetLeases() *[]Lease { var l []Lease for i := range c.handler.Leases { l = append(l, c.handler.Leases[i]) } return &l } // GetUnLeased - This will retrieve all of the un-allocated leases from the boot controller func (c *BootController) GetUnLeased() *[]Lease { if c.handler == nil { var emptyLease []Lease return &emptyLease } return &c.handler.UnLeased } // DelUnLeased - This will retrieve all of the un-allocated leases from the boot controller func (c *BootController) DelUnLeased(mac string) { if c == nil || c.handler == nil { return } if len(c.handler.UnLeased) == 0 { return } for i := range c.handler.UnLeased { if mac == c.handler.UnLeased[i].MAC { c.handler.UnLeased = append(c.handler.UnLeased[:i], c.handler.UnLeased[i+1:]...) } } } ================================================ FILE: pkg/services/serverHTTP.go ================================================ package services import ( "io" "net/http" "path/filepath" "github.com/plunder-app/plunder/pkg/utils" log "github.com/sirupsen/logrus" ) // These strings container the generated iPXE details that are passed to the bootloader when the correct url is requested var autoBoot, preseed, kickstart, defaultBoot, vsphere, reboot string // controller Pointer for the config API endpoint handler var controller *BootController var serveMux *http.ServeMux // TODO - this should be removed func (c *BootController) generateBootTypeHanders() { // Find the default configuration defaultConfig := findBootConfigForType("default") if defaultConfig != nil { defaultBoot = utils.IPXEPreeseed(*c.HTTPAddress, defaultConfig.Kernel, defaultConfig.Initrd, defaultConfig.Cmdline) } //else { // log.Warnf("Found [%d] configurations and no \"default\" configuration", len(c.BootConfigs)) //} // If a preeseed configuration has been configured then add it, and create a HTTP endpoint preeseedConfig := findBootConfigForType("preseed") if preeseedConfig != nil { preseed = utils.IPXEPreeseed(*c.HTTPAddress, preeseedConfig.Kernel, preeseedConfig.Initrd, preeseedConfig.Cmdline) } // If a kickstart configuration has been configured then add it, and create a HTTP endpoint kickstartConfig := findBootConfigForType("kickstart") if kickstartConfig != nil { kickstart = utils.IPXEPreeseed(*c.HTTPAddress, kickstartConfig.Kernel, kickstartConfig.Initrd, kickstartConfig.Cmdline) } // If a vsphereConfig configuration has been configured then add it, and create a HTTP endpoint vsphereConfig := findBootConfigForType("vsphere") if vsphereConfig != nil { vsphere = utils.IPXEVSphere(*c.HTTPAddress, vsphereConfig.Kernel, vsphereConfig.Cmdline) } } func (c *BootController) serveHTTP() error { // This function will pre-generate the boot handlers for the various boot types c.generateBootTypeHanders() autoBoot = utils.IPXEAutoBoot() reboot = utils.IPXEReboot() docroot, err := filepath.Abs("./") if err != nil { return err } // Created only once // TOTO - alloew this to be customisable serveMux.Handle("/", http.FileServer(http.Dir(docroot))) // Boot handlers serveMux.HandleFunc("/health", HealthCheckHandler) serveMux.HandleFunc("/reboot.ipxe", rebootHandler) serveMux.HandleFunc("/autoBoot.ipxe", autoBootHandler) serveMux.HandleFunc("/default.ipxe", rootHandler) serveMux.HandleFunc("/kickstart.ipxe", kickstartHandler) serveMux.HandleFunc("/preseed.ipxe", preseedHandler) serveMux.HandleFunc("/vsphere.ipxe", vsphereHandler) // Set the pointer to the boot config controller = c return http.ListenAndServe(":80", serveMux) } func rootHandler(w http.ResponseWriter, r *http.Request) { log.Debugf("Requested URL [%s]", r.RequestURI) w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") // Return the preseed content log.Debugf("Requested URL [%s]", r.URL.Host) io.WriteString(w, httpPaths[r.URL.Path]) } func preseedHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") // Return the preseed content io.WriteString(w, preseed) } func kickstartHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") // Return the kickstart content io.WriteString(w, kickstart) } func vsphereHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") // Return the vsphere content io.WriteString(w, vsphere) } func defaultBootHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") // Return the default boot content io.WriteString(w, defaultBoot) } func rebootHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") // Return the reboot content io.WriteString(w, reboot) } func autoBootHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/plain") // Return the reboot content io.WriteString(w, autoBoot) } // HealthCheckHandler - func HealthCheckHandler(w http.ResponseWriter, r *http.Request) { // A very simple health check. w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // In the future we could report back on the status of our DB, or our cache // (e.g. Redis) by performing a simple PING, and include them in the response. io.WriteString(w, `{"alive": true}`) } ================================================ FILE: pkg/services/serverHTTPISO.go ================================================ package services import ( "bytes" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "github.com/hooklift/iso9660" log "github.com/sirupsen/logrus" ) // TODO - This currently is inefficient and results in an open/parse of an iso for every file operation. // github.com/qeedquan/iso9660 may need looking at later on. ( thebsdbox / [1/9/19] ) // Comments are left incase we/I revert // isoMapper at this point just maps the prefix to the path this may change var isoMapper map[string]string // iso9660PathSanitiser will take a "standard" file path and convert it into something that make sense within iso9660 TOC // The iso9660 constraints: // - A-Z (uppercase) // - '_' is the only other character // - Filename can only be 32 characters (inclucing the terminating semicolon ';') func iso9660PathSanitiser(unsanitisedPath string) string { // Get the filename from the string fullFilename := filepath.Base(unsanitisedPath) // Get the extension extension := filepath.Ext(fullFilename) // Remove the extension and leave just the filename filename := strings.TrimSuffix(fullFilename, extension) // Store the filename and shorten if over 31 characters trimmedFilename := filename pathLength := len(filename) + len(extension) if pathLength > 31 { // If the path is too long then we shrink the extension to a seperator and three characters if len(extension) > 3 { extension = extension[0:4] } // work out how much of the remaining filename can survive trimCount := 31 - len(extension) trimmedFilename = filename[0:trimCount] } rebuiltFileName := strings.ToUpper(fmt.Sprintf("%s%s", trimmedFilename, extension)) // Find if there is a full stop in the file name stopCount := strings.Count(rebuiltFileName, ".") var isoFilename string switch stopCount { case 0: // Append one as there is no filepath isoFilename = fmt.Sprintf("%s.", rebuiltFileName) case 1: // Not needed, just the semicolon isoFilename = fmt.Sprintf("%s", rebuiltFileName) default: // Ensure all other stops are changed to underscores isoFilename = fmt.Sprintf("%s", strings.Replace(rebuiltFileName, ".", "_", stopCount-1)) } //rebuild the path uppercase rebuildPath := strings.ToLower(fmt.Sprintf("%s/%s", filepath.Dir(unsanitisedPath), isoFilename)) // strD replacer replacer := strings.NewReplacer("+", "_", "-", "_", " ", "_", "~", "_") // Format the final output isoFormatted := replacer.Replace(rebuildPath) return isoFormatted } // This takes care of parsing a URL to identify if it should map to an ISO hosted file. // ISOReader - func isoReader(w http.ResponseWriter, r *http.Request) { // Sanitise the URL, there are a number of steps involved with turning the url into something we can use // Remove the beginning slash rawURL := strings.TrimLeft(r.URL.String(), "/") // Unescape the Http query isoURL, err := url.QueryUnescape(rawURL) if err != nil { w.WriteHeader(http.StatusInternalServerError) io.WriteString(w, fmt.Sprintf("%s", err.Error())) log.Error(err) return } // Split the URL to find the prefix (first part of the URL) urlElements := strings.Split(isoURL, "/") // Ensure the URL can be parsed if len(urlElements) > 1 { isoPrefix := urlElements[0] isoPath := iso9660PathSanitiser(strings.Replace(isoURL, isoPrefix, "", 1)) // We now have the ISO prefix to look up files, and the path to look up in the ISO // Check for ISO log.Debugf("Original URL: %s ISO Path: %s", isoURL, isoPath) if _, ok := isoMapper[isoPrefix]; ok { file, err := os.Open(isoMapper[isoPrefix]) if err != nil { w.WriteHeader(http.StatusInternalServerError) io.WriteString(w, fmt.Sprintf("%s", err.Error())) log.Error(err) return } defer file.Close() r, err := iso9660.NewReader(file) if err != nil { log.Error(err) return } for { f, err := r.Next() if err == io.EOF { w.WriteHeader(http.StatusNotFound) io.WriteString(w, fmt.Sprintf("Unable to read/find file %s", isoPath)) log.Error(fmt.Sprintf("Unable to read/find file %s", isoPath)) return } if err != nil { log.Error(err) return } if f.Name() == isoPath { freader := f.Sys().(io.Reader) buf := new(bytes.Buffer) buf.ReadFrom(freader) w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/x-binary") io.WriteString(w, buf.String()) return } } // isoFile, err := isoMapper[isoPrefix].Open(isoPath) // if err != nil { // w.WriteHeader(http.StatusNotFound) // io.WriteString(w, fmt.Sprintf("%s", err.Error())) // return // } // fileStat, err := isoFile.Stat() // if err != nil { // w.WriteHeader(http.StatusNotFound) // io.WriteString(w, fmt.Sprintf("Unable to stat file on ISO %s", isoPath)) // return // } // fileBytes = make([]byte, fileStat.Size()) // _, err = isoFile.Read(fileBytes) // if err != nil { // w.WriteHeader(http.StatusNotFound) // io.WriteString(w, fmt.Sprintf("Unable to read file on ISO %s", isoPath)) // return // } } } else { w.WriteHeader(http.StatusNotFound) io.WriteString(w, fmt.Sprintf("Unable to find content ISO Prefix %s", isoURL)) return } w.WriteHeader(http.StatusNotFound) io.WriteString(w, fmt.Sprintf("Unable to find content ISO Prefix %s", isoURL)) return } // OpenISO will open an iso and add it to out ISO Map for reading at a later point func OpenISO(isoPath, isoPrefix string) error { // Check that the file exists _, err := os.Stat(isoPath) // We could use os.IsNotExist() but we may as well capture all errors if err != nil { return fmt.Errorf("Error reading file [%s]", isoPath) } return nil } ================================================ FILE: pkg/services/serverImageHTTP.go ================================================ package services import ( "fmt" "io" "net/http" "os" "strings" "time" log "github.com/sirupsen/logrus" "github.com/dustin/go-humanize" ) // WriteCounter counts the number of bytes written to it. It implements to the io.Writer interface // and we can pass this into io.TeeReader() which will report progress on each write cycle. type WriteCounter struct { Total uint64 } var data []byte func (wc *WriteCounter) Write(p []byte) (int, error) { n := len(p) wc.Total += uint64(n) return n, nil } func tickerProgress(byteCounter uint64) { // Clear the line by using a character return to go back to the start and remove // the remaining characters by filling it with spaces fmt.Printf("\r%s", strings.Repeat(" ", 35)) // Return again and print current status of download // We use the humanize package to print the bytes in a meaningful way (e.g. 10 MB) fmt.Printf("\rDownloading... %s complete", humanize.Bytes(byteCounter)) fmt.Println("") } func imageHandler(w http.ResponseWriter, r *http.Request) { log.Infof("Incoming image from [%s]", r.RemoteAddr) r.ParseMultipartForm(32 << 20) file, handler, err := r.FormFile("BootyImage") if handler != nil { log.Infof("Beginning to recieve image [%s]", handler.Filename) } if err != nil { log.Errorf("%v", err) return } defer file.Close() out, err := os.OpenFile(handler.Filename, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Fatalf("%v", err) } defer out.Close() // Create our progress reporter and pass it to be used alongside our writer ticker := time.NewTicker(500 * time.Millisecond) counter := &WriteCounter{} go func() { for ; true; <-ticker.C { tickerProgress(counter.Total) } }() if _, err = io.Copy(out, io.TeeReader(file, counter)); err != nil { log.Errorf("%v", err) } log.Infof("Written of image [%s] to disk", handler.Filename) ticker.Stop() w.WriteHeader(http.StatusOK) } func configHandler(w http.ResponseWriter, r *http.Request) { w.Write(data) } // Serve will start the webserver for BOOTy images func (c *BootController) serveImageHTTP() error { fs := http.FileServer(http.Dir("./images")) http.HandleFunc("/image", imageHandler) http.Handle("/images/", http.StripPrefix("/images/", fs)) log.Println("Plunder OS Image Services --> Starting HTTP :3000") err := http.ListenAndServe(":3000", nil) if err != nil { log.Fatal(err) } return nil } ================================================ FILE: pkg/services/serverTFTP.go ================================================ package services import ( "bufio" "bytes" "encoding/hex" "errors" "io" "io/ioutil" "os" log "github.com/sirupsen/logrus" tftp "github.com/thebsdbox/go-tftp/server" ) var iPXEData []byte // HandleWrite : writing is disabled in this service func HandleWrite(filename string) (w io.Writer, err error) { err = errors.New("Server is read only") return } // HandleRead : read a ROfs file and send over tftp func HandleRead(filename string) (r io.Reader, err error) { r = bytes.NewBuffer(iPXEData) return } // tftp server func (c *BootController) serveTFTP() error { log.Printf("Opening and caching undionly.kpxe") f, err := os.Open(*c.PXEFileName) if err != nil { log.Warnf("No local undionly.kpxe found, falling back to embedded version which may be out of date") iPXEData, err = hex.DecodeString(pxeFile) } else { // Use bufio.NewReader to get a Reader. // ... Then use ioutil.ReadAll to read the entire content. r := bufio.NewReader(f) iPXEData, err = ioutil.ReadAll(r) if err != nil { return err } } s := tftp.NewServer("", HandleRead, HandleWrite) err = s.Serve(*c.TFTPAddress + ":69") if err != nil { return err } return nil } ================================================ FILE: pkg/services/services.go ================================================ package services import ( "net/http" "time" "github.com/plunder-app/plunder/pkg/utils" log "github.com/sirupsen/logrus" dhcp "github.com/krolaw/dhcp4" "github.com/krolaw/dhcp4/conn" ) var dhcpServer = make(chan bool) var dhcpError = make(chan error, 1) var runningDHCP, runningTFTP, runningHTTP bool // find BootConfig will look through a Boot controller for a booting configuration identified through a configuration name func findBootConfigForDeployment(deployment DeploymentConfig) *BootConfig { // // First check is to look inside the deployment configuration for a custom configuration // if deployment.ConfigBoot.Kernel != "" && deployment.ConfigBoot.Initrd != "" { // // A Custom Kernel and initrd are specified // log.Debugf("The server [%s] has a custom bootConfig defined", deployment.MAC) // return &deployment.ConfigBoot // } // Second check is to find a matching controller configuration to adopt for i := range Controller.BootConfigs { if Controller.BootConfigs[i].ConfigName == deployment.ConfigName { // Set the specific deployment configuration to the controller config return &Controller.BootConfigs[i] } } // Either there is no custom kernel/initrd/cmdline or a bootconfig doesn't exist as part of the server configuration return nil } // find BootConfig will look through a Boot controller for a booting configuration identified through a configuration name func findBootConfigForType(ConfigType string) *BootConfig { // Find a matching controller configuration to return for i := range Controller.BootConfigs { if Controller.BootConfigs[i].ConfigType == ConfigType { return &Controller.BootConfigs[i] } } // No configuration could be found return nil } // find BootConfig will look through a Boot controller for a booting configuration identified through a configuration name func (c *BootController) setBootConfig(configName, configType, kernel, initrd, cmdline string) { newConfig := &BootConfig{ ConfigName: configName, ConfigType: configType, Kernel: kernel, Initrd: initrd, Cmdline: cmdline, } c.BootConfigs = append(c.BootConfigs, *newConfig) } // StartServices - This will start all of the enabled services func (c *BootController) StartServices(deployment []byte) error { log.Infof("Starting Remote Boot Services, press CTRL + c to stop") if *c.EnableDHCP == true { c.handler = &DHCPSettings{} // DHCP Server address ip, err := utils.ConvertIP(c.DHCPConfig.DHCPAddress) if err != nil { log.Fatalf("DHCP Server -> %v", err) } c.handler.IP = ip // Start address of DHCP Range ip, err = utils.ConvertIP(c.DHCPConfig.DHCPStartAddress) if err != nil { log.Fatalf("DHCP Start Address -> %v", err) } c.handler.Start = ip // Additional DHCP options c.handler.LeaseDuration = 2 * time.Hour //TODO, make time modifiable c.handler.LeaseRange = c.DHCPConfig.DHCPLeasePool // Initialise the two maps c.handler.Leases = make(map[int]Lease, c.DHCPConfig.DHCPLeasePool) var options = dhcp.Options{} // Subnet ip, err = utils.ConvertIP(c.DHCPConfig.DHCPSubnet) if err != nil { log.Fatalf("DHCP Subnet -> %v", err) } options[dhcp.OptionSubnetMask] = ip // Gateway / Router ip, err = utils.ConvertIP(c.DHCPConfig.DHCPGateway) if err != nil { log.Fatalf("DHCP Gateway -> %v", err) } options[dhcp.OptionRouter] = ip // DNS ip, err = utils.ConvertIP(c.DHCPConfig.DHCPDNS) if err != nil { log.Fatalf("DHCP DNS ->%v", err) } options[dhcp.OptionDomainNameServer] = ip // Set bootname path (used by tftp) options[dhcp.OptionBootFileName] = []byte(*c.PXEFileName) c.handler.Options = options log.Debugf("\nServer IP:\t%s\nAdapter:\t%s\nStart Address:\t%s\nPool Size:\t%d\n", c.DHCPConfig.DHCPAddress, *c.AdapterName, c.DHCPConfig.DHCPStartAddress, c.DHCPConfig.DHCPLeasePool) log.Println("Plunder Services --> Starting DHCP") if runningDHCP == false { newConnection, err := conn.NewUDP4FilterListener(*c.AdapterName, ":67") if err != nil { log.Fatalf("%v", err) } go func() { //Close the connection when we're tidying up defer newConnection.Close() runningDHCP = true dhcpError <- dhcp.Serve(newConnection, c.handler) runningDHCP = false }() go func() { select { case <-dhcpError: log.Infof("%s\n", dhcpError) case <-dhcpServer: newConnection.Close() } }() } } else { log.Debugf("Stopping DHCP Server") if runningDHCP { dhcpServer <- true runningDHCP = false } } if *c.EnableTFTP == true { go func() { log.Println("Plunder Services --> Starting TFTP") log.Debugf("\nServer IP:\t%s\nPXEFile:\t%s\n", *c.TFTPAddress, *c.PXEFileName) err := c.serveTFTP() if err != nil { log.Fatalf("%v", err) } }() } if *c.EnableHTTP == true { if len(c.BootConfigs) == 0 { log.Warn("No Boot settings specified in configuration") } httpAddress = *c.HTTPAddress go func() { log.Println("Plunder Services --> Starting HTTP") err := c.serveHTTP() if err != nil { log.Fatalf("%v", err) } }() // Use of a Mux allows the redefinition of http paths serveMux = http.NewServeMux() // Parse the boot controller configuration // err := c.ParseBootController() for x := range c.BootConfigs { // // Parse the boot configuration (preload ISOs etc.) err := c.BootConfigs[x].Parse() if err != nil { // Don't quit on error as updated configuration can be uploaded through the API log.Errorf("%v", err) } } c.generateBootTypeHanders() // If a Deployment file is set then update the configuration if len(deployment) != 0 { err := UpdateDeploymentConfig(deployment) if err != nil { // Don't quit on error as updated configuration can be uploaded through the API log.Errorf("%v", err) } } } go c.serveImageHTTP() // // Image OS // go func() { // fs := http.FileServer(http.Dir("./images")) // http.Handle("/images/", http.StripPrefix("/images/", fs)) // log.Println("Plunder OS Image Services --> Starting HTTP :3000") // err := http.ListenAndServe(":3000", nil) // if err != nil { // log.Fatal(err) // } // }() // everything has been started correctly return nil } ================================================ FILE: pkg/services/static_pxe.go ================================================ package services const pxeFile = `ea0500c007669c66600fa80fa0061e66684c5245542e8c1621052e6689261d058cc88ed8b840008ee08cc80306d4038ed066bc00080000fcbecb0331ffe87005660fb52e1d0565c45e34e89100746d658b5e20658e4608e89900741db85056cd1a720a3d4e567505e88800740ce8ac00744ae8ac000f85e500891e6d058c066f0526817f060102721526807f082c720926c45f28e847007423e88000741ec41e6d052666ff770a26ff772426ff772626ff772026ff7722bed603eb7f891e71058c0673052666ff771026ff773026ff773626ff772826ff772ebee203eb5d2666813f21505845750a260fb64f0483f958731dc32666813f5058454e75f526817f04562b75ed260fb64f0883f92872e35089de31c026ac00c4e2fa58c3bade00eb03baf30064a11300c1e00648403dff9f77088ec031dbffd275f2c3668f066705668f066305668f067505e86304e8b001b02ce8340466a175056685c07509beec03e84c04e99601a165058b1e63058b0e69058b16670539c177039187dac1e806a3790583c20fc1ea0401d183c13fc1e906890e7b05be0204e81504c41e7505e85e01be1304e80804c41e6305e85101be3004e8fb03c41e6705e84401be4004e8ee03a17905e84a01b02de8bc03a17b05e83f01be4304e8d703bb1200e882017305e89b01eb22a025053c027402eb1966a1260566a38305a12e05a37d05be4804e8ad03e8df03eb06be6504e8a203bb1300e84d017305e86601eb2ebe9704e88f038d362505e8880366813e250545746865751666813e290572626f6f750bbe9f04e86c03830e6b0501b00ae83b036631f68cd689362b05c70629050008c7062705ec04c70625050200bb7100e8f2007305e80b01eb1066c1e6046681c600080000668936b5046631f68cd689362905c7062705ec0cc70625050010bbe800e8c0007217a1250585c0741066c1e6046681c6ec0c0000668936b904bb0500e8a1007303e8ba00f7066b0501007518bb7000e88e007305e8a700eb0ba17905648b1e1300e8640083268705f9bebd04e8c00264a11300e81b00bec704e8b302e99c00508cc0e8c202b03ae87e0289d8e8b80258c350535152bb0a0031c931d2f7f3524185c075f658e8af02e2fa5a595b58c35051570689d8c1e0068ec0b9000431ff31c0f3aa075f5958c35339d87406e8dfff43ebf65b643b1e1300750064a31300c357061e07bf2305065753ff1e750583c406a12305f885c07401f9fc075fc356beed04e82a0293e83e02be0505e8200293e83402be1b05e816025ec3e85c038ed3bc20308ec3bf0800be6d05b91c00f3a48b3e2105668b2e1d05668b36b904668b0eb5048edb5068f000cb5058452d3e45423a007f10205058454e562b206174200020215058452061742000204e6f2050584520737461636b20666f756e64210a0020656e74727920706f696e7420617420000a202020202020202020554e444920636f6465207365676d656e7420002c2064617461207365676d656e7420002028006b42290a00202020202020202020554e444920646576696365206973205043492000202020202020202020556e61626c6520746f2064657465726d696e6520554e444920706879736963616c20646576696365002c207479706520002028776f726b61726f756e6420656e61626c656429000000000000000000202020202020202020006b4220667265652062617365206d656d6f72792061667465722050584520756e6c6f61640a00202020202020202020554e4449204150492063616c6c2000206661696c65643a2073746174757320636f646520000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffff00000000070050535585ff7405880547eb0fbb0700b40e3c0a7504cd10b00dcd105d5b58c350b020e8dbff58c350ac84c07405e8d0ffebf658c366c1c810e8040066c1c81086c4e8020086c4c0c804e80300c0c80450240f3c0a1c692fe8a6ff58c35086c4e8e4ffb03ae899ff88e0c0e803e8d7ffb02ee88cff88e02407e8d4ff58c3665167f3a46659c366515031c067f3aa586659c36650665566680093cf006668ffff0000666830098f00166aff6668b0098f000e6aff666a00166a1f660fb7ec66c166020466016e0266c1660a0466c166120483ec080f0146f80fa80fa0061e160e689b06fa660f0156000f20c00c010f22c0ea7e060800b810008ed0b818008ed88ec08ee08ee8ffd30f20c09c24fe9d0f22c0cb171f070fa10fa9660f0156f88d6620665d6658c3665153bb180ce862ff721c66f7d96601d1bb0e06e854ff6683c60f6683e6f06683c70f6683e7f05b6659c30fa06a400fa164a11300c1e0062d1803502d8c0050c1e80664a31300585b0fa1c3665666576655e8d6ff6631f66631ff6683cdffe80700665d665f665ec366601e06fc161f6689e18ed366bc703100001e66510e1f665766566631f68cce66c1e60466566681c6b00c0000660fb7f866c1e70466b9ee00000066baee000000e853ff0f82420166596629f166f7d9665e0e6873075068e500cb0f8234010e6880075068e900cb0f8230016685f675068cce66c1e6046601ce66b9a207000066baa2070000e80eff0f82fd00660fb7fb66c1e70466b94801000066ba48010000e8f3fe0f82e2008edb665f66506685ff752ab488cd1589c76681c7000400006681ef7902000083e7fc66c1e70a6681ff00001000730666bf000010006658665766b920bf010066baa8e10900e8a7fe0f829600665f67660fb224246657068ec366bf4801000066b9703100006629f9e8e3fd07665fa3f400ff1ef2000e50682806cb665753bb0606e8d6fd5b665fff1ef200a3f800ff1ef600071f6661c30e1f31ffe85ffde847fd6689f0e856fde83efd6689c8e84dfdbe7a08e83afdfaf4ebfc0a496e7374616c6c6174696f6e206661696c6564202d2063616e6e6f7420636f6e74696e75650a0066b8101b101be9acff66b8200a200ae9a3ff6631c0e99dff67f6450bff75126766c16508086766c165040867ac6788450467668b4508c366506652e8daff66c1e80b67660fb7541d0066f7e2678b541d00676639450473176766894508f7da81c20008c1ea056701541d0031c0eb1467662945086766294504c1ea056729541d0042f9665a6658c36657516689df66b801000000678d1c47e89cffd1d0e2f56689fb59665f0fb3c8c351e8dbffd0d8d0d4e2fac1e80859c3665751526689df66b801000000d0c189ca20f289d300df30db01c3678d1c5fe85dffd1d088c6f6d630f220d584e474dd6689fb5a59665f30e4c366535166526631d2e81bff66d1e8676689450867668b5d046629c378056766895d0466d1d366d1d280f201e2db6689d0665a59665bc36631db6766397d00740526678a7fffc0ef06d0e7678d9c5bfa07000031c980fa06760f67668b450e66f7d026678a0c07b501e85bff67aa80ea03790230d280fa07720380ea03f8c36650665166576689dfb9030067c7450c020067668d1fe8aefe67668d5f04742067668d5f02e89ffe67668d5f1467c6450c0a740c67668d5f24b10867c6450c12e8d5fe6701450c6689fb665f66596658c366b903000000eb0c660fb6c967668b448d0ee31367668b5c8d0a6766895c8d0ee2f2676689450e665667660fb74d0c67668b750066f7de67668d7437ff6639c6720c66f7d067668d34072667f3a4665ec3665780fa06b2077602b20a66bbb2030000e84aff678b5d0c83eb0283fb047203bb0300c1e30781c3ae00b90600e846fe3c0472406689c783e70183cf0289c1d0e9fec96689fbd3e329c3678d9c1bac02000080f906721480e90466d3e7e880fe6609c7b10466bb9203000066d3e7e826fe6609f8665fe936ffb9000867668d1c554e000000e89dfd751867668d1c5596000000e88ffd752b67c7450c0100b509eb2afec167668d1c5566000000e875fd7411fec167668d1c557e000000e865fd80d10066bbd6050000e892fe80fa0688ea7602b20be9dffe67668d1c551e000000e841fd0f842efe67668d1c5536000000e831fd0f8408ffe97eff6689f266f7da67668d4c17fb67668d1c166639cb77282667ac24fe3ce875ed266766ad6639c87306266766295efc66f7d06639d873d6266766014efcebcec36601f266bbffffffff67ac30c3b9080066d1db73076681f32083b8ede2f26639f275e66685db7401f9c3665066536651665266556766ad6689c26656e8c1ff665e726e6681ecfc1f00006689e566570616076689ef6631c066b9ff0700006766f3ab67668d7d1eb8000466b9ef0f000067f3ab07665f6766897d0067ac6766ad660fc867668945046766ff4d0866ba00000000e808ff73fbe831fc665667668b7500e81cff665e6766ad6681c4fc1f0000665d665a6659665b6658c3ffffffffffffe100000000749f70230b2b98f0793213c56f349302d136fe64cda1a6dbf809bfe3ab96044c49b99ef76525d24e9f1f332caf09c5d6a0ce2b6eed8ab0730dcf6eb44d48392330ee87ca71a679f15717d766c367aabb183520ea82fd6d5d3b4b1f6b5e7fdefb227d1cbb9146c58b6306fa62de4755e70d83ae5fba29085bed85837dfb5d1762d1d9e5ccf8b988894cf33fe555a028f1fb385958eb7fcf59ad3584a9705b2957df7e7f1c800ae9272ad37fa28f6157448bb17345601f1564c4f373084917b888acb7bd5b961398ab8472493093aa0a1769559a04e1bcc3ffd77f100034439fa6ffffffffffffffffffffffcd0500000046039865d1e6c44899130938f923c1872ad016328574821eaf78524b92a7fce195df4a226a41695874f9cf5e1cc08910529f4593612a0ebf37fc0eb8bfdb5084f71ba0271ee024b0617ae00046cb092301b3e9d6f69e6a3929931a271d0c645bcc8a6ac1e0d14dae0e19ad499179e2a4d1ba390ed056a2f13bd25c9159d41fbac9aa036e6ae2b44dfa3052f100114b173958f2f891f6e544b89ed8fc16e59e9767f7ff39cacf4253a6120e792434ddc0a3c49259c0147705d5bcb551d549391507c74b4c3278a74a2f42674a3deeb655c4c4f608a317f812cc3953215b000c892e759a1b32d34f8bd4dda9ed991d578ed885d178d2e1b74a2749024d7baa15ee75fec2b57fbe8b63b85320025e8a6e7287223c3a987fdfd31537905ccc17bdef4f93e9996377b280aa868a13fe0795357e6ad44ee92f92ea810d2dfdfdcc4b0426590ba84474aff8a45d4269f0702971a7f90788075a0385381cf966be8b12eb1ead1a53b4b189d944b622d63d8e02f7e4875aada9ddc4d93e7646c170e4fa16a73f66f162b5eeaf57e4c0fdc180d357f8c40183f5dd733fbe62286bfffb015193479a5db52c8bfff3c16893cfe8fa2dac0e8f8e0ebf42975a0f958e8d6dfc30c1e89cfa6fd983340ea94d88c76c14c43fd3302392d1cd34e5e298b1338b2d685f2c9a4edd0cc94caf79f546754052fba2b3c7d4145e19e6e0eb3b71e1def8576bbaf90637026b80a7b6f8a834d68d588b271144452b892260fbafa601da968a0edcd74135be1e203785001d9d8d27decfcd6ec176efcb4df50f4b3fb572baafb935c7e8012e3c41cf2a315d549325b257d17dd5ebf7c5d372985d001fdc82b1f9de949756fd61e9921a789a0617be365cf02cfbaab02a9252ebe415ff449130da7e190ae27c0de32ed22b8884e0c12d4dfef0fe5a4afde446332b2fd010b15659ab5e3dc3f6d79e6cb2acdfac5fe3cdf99b048789568f1ba8e057959b47054b1c26f451638d9033b3f3f5621de54cfca37629455a34cbf0aa73ad44ad3127fc9def3f8984ef88b954348dd5b55f4b668d17b589487bf8ff5b8d7b76d4a502cc850787a2caa924e1b33c203bc563d0f2f97127124011d156411bdcae996e900908882e90fd5d0bdf637443c66bca491ea077d58f9c2df79523c5f3cc480b930281c96730e93a20612fd1f00f89db6b52373f9491c2d614d531db7e80877cd4529f2cd65bd541d56e461a64c0a30a36852a2011d5dbe4b472aaace3351206e32f5e8fe75f5e3d8253da75554675c51b7df80b562c903d582b53739e082940228f69551a0b3fa5d3b5b13111a3ab2075b73af5876fbc3c898a899a3f6f7c007e73b3ee7bdd9ba835f6e27f69a11f5ce474d0c03c55e964d7eaabe7eeacf6faee8e77c7dddd9cd5ad3e7b47d5f2ad1db0e6db0637345b1a62bc5641a48615afa2f1c60ee5301c97eb59b330d3fbc506026daa20785aca3d93de7d919f97dd63a7033946a7abc48523c2a2d3062a0119c72f35b767fc1391211f291b4d47d62ddf4df5785a3be298dbf4ab94b334cef68f26aa6a6a6a4a0f3cb80e7d12f14bccac150c83ea45ad93093a3d4ef48f2591cce1bd869772fa8432ffca289ffab74e18d437af6545b62ef8e4ab8d51435fb3d909436e04f24a3f13de30d8253f08c69664457c8be68542fe317a24cb90f03dff12e8b2e7bc1377a8d0d2336fb730b7936b093907f3c04955ef0112bbbbf7097d59c0c24dcd21862c80f7cd0d8efacb65337c99625a429769953cba9f0ab6fb5fedfac9bcaed97aa9b85a72442f0251eb2890fdc95aa9713b5739822ca66afbff8a053906d94d6b282289490c66205ea05b68aa183f7ae9fab7e568fa64e437674216290946106ffb0b66efa3e7d051132297a288020dc753379a65f9deb8296a81b3155ee04f882bc556b3ca714b5d30f11a8c0db0d3335f86e159b695d3ef4be767c71808cb45bec3407dfb43fdeaba9899c77ee33d311ce784d92445cb2c9e35155838522c7fb7a74b93138d456e06b623ff40b1606be60458de8ff40875e0156991e9f562cc32f5f4857ea832acee3a0066afffeb50e27160a05f2dffffffffffffffffffffffffffffff440000000000057cc0af7c191a0640ca0b40d5bf34657c04cc3664a7cd929ca01d4cff351d96eaa094c3d4193469a79f90ecfae3ffdda562f657bc52a64f9fff699e00001dee6a27ffffffffffffffff10f400000000695c0da3041ed452661d7bda46ddff2fa8261e0591c1d698aaf2a6eb5cc57ec38b0ca565ddf952114dccaecf1be89398af4a48bb9ab0e19770c3846d4d114ea6bdafd800940f6f0ea3bc0a822b0edb76c0052a9ed305ea1c420572ea39eb4afb391f8ee91e0309480e32f0ee85494199b0afb8df6d634a5eef37f97e9a5324d37c6238b61489e2f5c3af2a7417540c7a88fd6dcff14d68f6cf307e047dd0affd7fb7a2cbe303d2eadde9cd4e8f3fb73b1edbd3f5fe2b1435ff2d6a4ffbc426da081b2518d89edf1528a1b3d7d6680a9644f7082eda1323b4f228807bf9723c28d5e5b6053912d83204bb4c3c9d5a81e7df35cfaeca62dbc113b31da44ad6171d5fc1b9b064edf5749620b2a7ec20f1915aa0d03e41c249a5c93a0def69ef5e7031f364b52ea6069b341c2b213acbad0fe3a1f7e72eb67a104c2450ae20f5dee3852f7d0039451a8107846ad65d8be126ef26927f204a1808a71b349692069791b85c8e9faf1f75038bd685e89e8d7b29dc13d81be498d30d05c22c9b7ebca80224f5b282566442e82f491baed53ff310e2ab86f4fc5bc458512d3d6681f9b79cef00618acb236c5f8943db29679f351da9d749c89fadbc7950a6e943e3b24061badff07f0a779646bcaa114f44d95696787b23c1ffc8058eabe1ff43e4710d985dec88ca6784db90300906ce88c2513cf9c2d5ea8e8b9367bc83c2927b1800257c763a9aa70b46fe53d5ab2d0e6976b1281ca11861c6e1544bf41f31ffee7fd78a045eb9600920b1274dbb0f8e1523101d37dc703d9a47abcf0f724b63e7d43afad741e8700cd956aaa89c300cb845525d94043ae7fac98b0fd845c03e7c77e17abed6a588f3a6ba5038a6d8259d848f6aafcaf8c5073d6f6e3e5341afde4840c8ce83ffc46b8aae0be0fbf276bf6f92be287c4388a2d743e1af2416eabf63d8447844fe01d1d8b7b5ae688becb7f0404a15476b883f3555ccc46713a9a756df7751cba55a5ee61bd20633a220caecdc49ff2c77a0b985dce90c2e3e994de2bae06e9090260796c18e7fce21f22e423ff953836f74c34ea8b47c92494d76ff8f0e0bdc90975599b155389c3d98c53904fc04fa06b4bf2195ec86f07ed9799cac1266f9bc18b1fb2dd21cd414113063a3a47bf4061c4332c6d810047534655b5fe0e9e6ff489150196ee54fb128435781cb0ffd4d1ef3b9ea4f2a009cd55bbe1f5133a1626388d3434474bb1091bcf6adde559216c7462b6a2d1fa9dc42dbf83e737363b95070b27eb136521f0d0f8f5e003772072b7f917fcdeeb26018c75c5a0e85d19f2637cf85e75abefe5527582133e365395993cd1068b5095ecca90ea73883bb9a9beca07e30c34d79648e2a4a9b45fe5dd6d76dcd77be2451f189c4fde4df402597fc6efa99a1a9708374eb4defbb17119f71c62e54aabc23fb518d880974c403a04ed92f47e9cf4bdb99f0207d1b425d0c816c70050d32ebe6fe7be51c6fdaa98e45b02130d3aa6186a137803980b461873bdf18ecf7811f26c3797791544a075f66ec542399c57c91c19d71009a52bb341e670b32e6bf5d5f63e669fa7c7ff4cf2cdf0aa2739315ea5e57d7684bd2ba9cf93188241b29799dcf701a39528ccf19b4416307a3b1efae4014ce1db803f1ca05f11eb0f2c2db838efd7dded20a3e1578d836e5b275de97de1f0c63b39b2be51401e376d2aad048ddd1d0bbdfa5cec0edaa6adaae830b40d89b83877ff8c571c75243a94143c403619f3d4f3c45e2560eb3a796fb03ce981422423d1564d5d572b57bb146a03e24481090b3930dfb0272966e03874adf02dbc646bb5ea1f8b182743d0878d52baafc20b4d9d5decd7db6cbb8ece612361ed078b03dcee9a7ada5b0fc889c77faf401f69aedf9fd354117a2c985cce05430467d55744d195aaa075921e8c1d713a7e670c6f847cc1bce6b2208476173c8b5edff5c09b2e13c7671db0e994f79ff15416e64b50177db17ff860c1068221cad7f4de015970343607c2331fc5a40c8207a898c3e812024d47ceae8532bd39dea9ad995880905ce09391fb4fe924342ee05138cefdac439da1c151ec72f050c67e59aab7d9b14883a3c1390498a3c7a5bb71e159904b52fa8b9c4c3157e03831c1c31ffa629e865caf3cae0b415a0a1506856d3665432637018ae4c9b3fb29b60e59dfeedddde5c95dc4ad80b256a246ad88227760b433668060cdbf908c83d9ff840a5c0a0e51c13b647c45c209e81f89df808a624cdefb9aa0db11c7e29cb68a792ac74ed4528710afc9b272593fad8e91f126bc512f28ee82b5b4600c23134a1e9dc7f3ddedb5a0cb1921b4e75c24345bf649bd35f5346c63762323adef8ff1426c10ff50e8abfab1abae16aae7944cd1d6c13ec5f591f0820fbfbda8b1b95d1190a6ccd96925e1b26acbe4f678160dec7d9555e35e8c9b0f3d5e63f86006f053840de2140209790813b1b75fb529c57bbf95b582a2eaad38ee587987ff70367540a252e9155ba65665c51d4a3f62b24b4e957a81077589c3b46a0fa17d16d3af26834f774c3d9f32d8eac79e096f9b02b38a5d7860a070da508231909a97c9ba4571cae6ae49dc92f9a05f49db3dbe275e3f1dc18d411d4f379533f1128dc58defc0fd39675ee708dad2bce61b4898b573624dd8b962de77e1cdc6acd054ddcdbbf76b203acceb1955d9eb018af29867f146f7cab585254de8192c9dbe857ccca5592dfb6842ad7591c62481e52e573b749989de0d449617a56775bd13c27f8315a4b4c7260b707628b0f24002e5f914ae8a3162c24329d28a80cad5d5f2d8d48985f8ab5cecc2e86e3a0f9d07055d10a8fa87f6cc5be0a58232c88a8e5b7456f335e3de089aa4b15f072a00538a531e19d76442aad880e935cd133ff3dee15a23e06bb9206091d19c776b0504efbc88c20fcaacd56557eff525ab63c08fc7c1e3f5930374ee6a1a7304346b6d5fbcd30ed7227eef475da60109ec836ce1ea25af91849b99ed3a13b1b62ef697ab74868eaf97e8d023abfcae6e5fdf1e9bd6373cce89a5e7ad28a5d6c3dd8400c5d08b1c77bd2fa8d277b28f00a2f97c6cad7be17641cfda291f0b7358f333060ffa8027af088e1e1740fe446502710de1d4ccd4c2ad004d0af64804544e7f3a4caf962b17324dd3b9e7743d750cb0e64314f142252152d94d40009dbd8fea8dad51efa992cb963a0b23554e2b8f89cec9a7f76e39fdecb6a55e530fc8ef8a6ed523c3d4da06bfe7ef316fc4fa8224382ffce37d74b05edc5a8935f6c017f786fbe5214b584db6ceb28c0ddfefcc930fd72889ab7d7ae8dc2fd0173ca1c6fb057e10e9b34e90975c5a7becbc925c13ecc353b3dd69572d5c6bcdfb83a19c64080bc16e89d6d3362135f4c1393d2d4097473538c763d5e3b5f7cf25c6dae115689db2034c4583fc06c8c9751aff8b73e575ce00011cc19e3476fa34a5955ea8dcbad8cfd77de55ae28ae58aa4a0740c3e66613d8f03ba090912c61e1332f5e82514cf64c9463d1c507e7a657b14124d708b2d51b37b1c566b5a7ebdf23317fefe06be9b7441a28f478f940222cb100a86dc5d69480e2616471aefb69bfe1d91e4cf5ced120fa656d4f5dd8781853f9ae9070c05effa054c755648080936445125d5f47bcaaa88f87954c4265d2499b5e8554df1629b99178ff97c925ef08981591d8360d4d1e0bfe6d6eb56bff40fc5e94f6e6e61f5fc422384302fd4f9c0bd9eac0ae43954f28cb75f8ab237b56009ff8599dbc1735d2cde2a4f98171743b662b9276325d128b3b3b31d54a92396a00f4a6999c97205fde6fabb49199c132d9d9c767a455b6b2fa0bf74c12f827423aff8dea613c55d315ba17d43528213ac51c2fa82978be681699226f4f5aa4c651abc68fbae0a06c659729ae24a46c915f65e4757884c39651a2403fecc0b99de29fe03d815bdd7836cef5b923c0eb6f249e9af8f0e0a1fdebf64110f322ab6d47c6ce1e0a1d4cefdb7ba72bd05b148dbc41a84db1069b3fb2c4ddfab8ef0255ad648c59944b86c34b9d5685af27cea9aefde3b7c6f6c4e21c6bad88de274c8a4536be9bd24fc053031c9f37d03f203e3498abe78a2b3b2e9b7d96ee6ceed269a113e02129cbcab5dada09c63e15b0fcc0bd7f045908d6c6238f6d4bb90acd70dcdc6e81db9e2c756a828cf15b7c83eb58fcffdbba29aa9299f14d637d52b875713896d325b75a5e7d6fd814b729a2b0b5d43b15af6031213f3185ef0c1207e8a394e1b336e5a911cfcd7cb9455bc64e8fecb67a23cdaee1fd8eb652f77a6ae5eb2d6f51561af7894038dbd88c096681c8f97aa3affc443b0cee9790755126af4dde0898702d7b5e3bb72f555bf98db16ce29e4becbbc09fca212d7634870e71c19bc27bd1438fc5c36ee922f85cbb3e939ad29cbee54c9809e2c0d20702a8f8be23c640d9f8516ece1bc8c57ef6720e85d0fa959c86e8fb3aa9e44de16acc87ba58cb39993666755e8d6aaf94d1905994e2df237a63006fbbce225c929075ea749405a438ea36004e4e944841c92de256e47da39e0cd7dacd3f93ac979b05a3997a4b1275e3807f53ac5e69bfd0081ec88952f3950f891669e71d2528ec9f0d5bc56a9311d45e110d1251c08c175a8a17993ff1082b1a046cee12e8c87ca4334556ecd6e74cb3a09b49f41e67a81dc805c02115fb75fe65492c243c950e7b0c7ced8f3fb98a27cb4bd1237ab3562b1a3e92a375f86f2a64eb2d507af4b4aa064f18769fea11e5c6a662a0b57b4bafb150622f934127256ba573d495f6281031afb927da6a3b1529bfe7fa3aa316a08a31f075ecec5ff43e47f40226d65d3e62224f9d270f99570c6815c4824e1dd99ad84d51804adc14a4689e3297bd465c18ab3daff0a77fe49e9d6bc0fbd3ec5c35db162b9b2fab0a816b1b00c7499550e71343aa126ba4b581c915dfedf6712f5ae9a54e38e200bfb3888954aa7fbdb00ef2f28beac162daa7175b95b8c2b6bfcef4a01d505c7386a575c4db7cafe265c251d7150e99a437e9f2ffbafc6589b5b10db772e3ab62cb17156810c65b17db5ff84da31f75e02a39268a24331254ee948ccdcf75ac5bec82dcad0307bdbde010001875726e9d9b0f28ce3e976656a805652f9e658774f4f17c80821bdb0038be2208fe81c7ce0a309a496a6941696fbbad9575bb14fcf13bb732464b940a306e4dc416fd9f0701f76644d1ade9bd206cd7aa3f293f77efa426025b8f7ac88a681943cd2865cefd482d6a66a3e551ed6f270bd594e9ea7728c5d577491248d3e0774f214340bac4c5731b77b1e3fcebab16919b4d7eb170a725cb93257ba155ee987d3b18d2e21b200d27557c93bf3ee0f3969c50acf487141da5f2e2fd6c84dd0427eff02de5ff330837ad63ea493eae40092f0398f963bd58ccbbef50ff40bffdff4d28ad98b93f20cb857f62911a3d38d6ecd39496b9a009e32e687a8aae2c634a4c66846f09f1e81cd661780587d7076d069bd8de96ba9cdb72d4986d0fdd2781d6aab6f7c8e4d7c1386c17cfb8892934049fc36211b3b8742bfbc6dedc344e569a56ed07a4b5c94fdf069922b1c08c0c5a0689bcdeaf493495c692f0aeb3a65a37ace324ade32c508446dbf42ec7849b3327b2744f1e06a7211d9754568115be082564e3d5e481ba6fad89be0f5987e0544c642c1b44db3ebe5079309c822f27342274977a19414d136b77d270ea3943992d82a59f94824621ba0778078a57716b085ebb782062cec89477c0a55467559342b5e1c3eebb4bb5d24eb628b9cf0fe418c6a0df917e708c9736879e3c9df9419df7412287dae41bc19e16038b62b3f7aa4c2d6f98cc3c27347c612034fa09611c8068694802b1995ecee2ee8ceab79f72a2c337cb896cd680dbb48b2b46fcaae2f05874306c1c07ca47b36266ac4e1144c9fc0f6453ce5ed905ef7fd4e2ebe72f9664675d36761c6f001b9206eaa02adf0967b64a46217798edd605483ba159892e3ea672c79bdf7666db42124e2d9084c927629c3e312683bf5643df4fcc7fb92959a3a6afdd751e0c3dc7152d059ce1cf5c884f03cfad0c1b5885d4af46fa86d8a1a62130e8406f3591389c2a5a8c094e36c0ab3c05d87c2f308c2caea6634260f0d16fd961f0963ca890d2f69cd0e1ddc50d37dd6ea4d9341fed4024a8190b7aa988468e99966f1e1f059f9028bbd473e3736b0efe26bb567abf53db5d206e806bfcfdfb3e3d2359d26ccf44bf74df30ef6c450b13569506fd27f1ba48154111a0e4350a6fc0a3e77f31f0d58e780e6d87af89a40ecfc7e33546e99254670d00c3ccdc2e718eb7e81ee61fc6065986fcf48e8c82197f423752f1efe728c55d35d740abd08efdb1376ad28303a3d4c756c81100a4c2eb0a1a0bdef9d8e606eb40b368e6ecb82d70a52f49904f07db9a03c2a91e40d740f1f185cc67c73aceb5ef00d65647cb300feaccea59ecc06dc768976ca1ccc62b7545f9f1e9f64d50dd63b650578205f8f0dc4ec08d89795be0cca030b555ed109a932e4ee2f74a757e0bfd046589c72ec7e40a5daf6f1a436cd3ac7818145d2529e21cb79d33b00b7ae104524708e110ebd80d97299afc68defec91f2eea20f415ae8c9033622f39e8c9d12d9e537c474b8e0de91cd53507554738ee0746e778b4438fa4174e0c76f594e53ff7636889fb62da47f1ead52844ee2e6bdf2f9e71dda246e413d0d170b9773b3340ed9033021d3c2c01d061d29084581b54609c2332297d1c0509b1204c598646ef8d0a3ed92778b4f8a24159b203499d4d91d665348999b2e07ce89a99ad58319d819bc37d40a2fad2f373f6255455f03f1229e28fadd5ef5913877f0e06e3e352c234080eac07d612a680da21446657efe4dad2eaf59a4b748e8b908927fb0fb0e5b3808b6039cfeeb4fdd1f0da94f987221be5a4f26180cf6a31d4296b52d477eac0f3e09da90048bbb179cfe3c9f2a006712992737ebba11b2a67a53095ef2b8611d72209c7ef313e4ead25aa3e9992722637a92b72bad54664ebc696ab32838109cdf2b7d360746a11c543eaabd73b8a0444092e61a5370506cc0d6c62cb4967c0e72e509318ba3a6fc94a7c2dd9a47a5f48e30be1adbf8ce54380fa7b0219ce08bb8ec09c89f2134be793686e452dcf0202f531815096c0075a1de410f443ffcf55a3dc675fcf8e3e4fc53ea50b0391528f6e200c98ec76b3c1b4f1f87ef5688effe85c91663b56540ad47a8feb2e1baac0d1a217ee217b0372758be4826f21d0ea6ac0b9e39dc3e6c0d7d9442a7a2ce33372a53aa050dfa69c9ce494142c86934d51b8bfefa89637c9ef7834a607c6a22e85c9ee7a79b473ee8a9ad518cb2fb4c968ba632e7804a015c41fca302ee5c47819ead5598d8d1061a3c8f31f83bd63b9e8f3a4a7d52d17c2a0cd77b4ddcb084c45a58ad6b19b717e603ed5eee72fa89ff53a17d4509171b137b565415c493944916aba85b9e25b922715f14a0699de9bb53709576e88e03734a130efa75cf87bf2548806f99455249b3d131026166d0c7bd5d7383afe837cfc8d8f737e8fb5ce175e495b12c3be8c13b40616d984557f13da7eb78ed9f1dd0bd09ac44425a9523d80e312a44a6d5e64c42bc613092d9b189359a60d86d8c6a6f367ae54794e29af6f42ce5b92bd469b66fc4d8f6945334db1e9e1de378b534acf45e36429b2db33d222d2393b83c2f713cce1fec449ec35a02f1c34d1e09fe80232af9a8b1fa44eb117761ee9b53cd19e7d48e1b6870b39e2cdee3eda0655ed884cc33b3d854efa86f17be0ba020bb44bbc5a5620e6530fd210404d23c8fbdc46654c687ccf512557af46cb8c659dd65a64654b88b6cdd72d7a0ec6b968805be30b50b743d50ae066d285ac6bfc46c5c1dd3f3c371ac05c2eba6026245ace3863f0cf7c14f533de695092ed1ce6299e55ca547cd22700ba3f3bdd1dd0420e79eca2122f81d21c1d819b78782280dcd0246d61b4935483bf91d39fa56ff48046c94bf904e736a15ddfd5e633f5edd563d954838a9a51b604eb3d12016592940ddcc6972f8e52183bdbad71279de915f612f4f45ed38fb8256ed5c0060b671b51e7957893aa9f8148589242e2e94012afb4d8161394258b0ab68eb1464e1dfe11a5b88ee84f0b4a9b5dc397aa8a1902ad8a14851fd99954df42802f6c631fc752c88c0b92f67ab5ac216905951fda37566b8ca706140e9e5bb2992f1025f89098eb46b06c63cf1609945052d9977b6fad03b225febff9ed06463c3b2b640cc459d6422bb369573edf948ca396b5b2d34b033f65951fe426a70324d686bb7c17fadcbd02eff6ea663ef3e089df02e9fea6295ed0e3b3bcdcd8b1697ea4cf5f38197554389743d6126293b4b57547cfebf7abbb815116bf49f21d62da9fd9d5dfdbe0e7de2fdaacacdfec572627a61cb0acfb6a9c5a5c8ea26f3c2d0f0fdbf4f2a8f52c888e08752a4c7328fa0ca81af975de4962ebb5d7f01b2f4a871f8d2f5d84216b4cb51d40f0e196188f86cb96b1f87299563388073eb1553a8954ca904bdbaa8057d3e1f8d150664430a32fd2dbc012a100fbb77efcf52187b64353649d4c0e440825cb92898ff7cecce9557cc0395a54aab457da4c4912cfd62e3d42d91b2f314f4732932f473beb3a97f1bc65ba44aebf4ec6989f67edf17b835ac02bd8c3717cddec3c991bb7294f194cfbbca1e03bcb87352644ee46b4c660ccfb086c9223e348109140597e49be47a283e0b28c1eafc6d1e5bf65c967c463fdc75e8e10b21c7d7ec85cbbea34f97063a0d4140591ea937b5f31812612025bc160ff48676c02844686c306848b228b8242453ae5d56908d029f879a92c99e0e17a03a6933c2e083e66bf0b7968e64fbdb32ff7231c764ee2e89b3b9fe2357a2461c3f5954b547441ee640721026c7fe2936b55438952130dca956d2360298c4be39ba6aa2d797df69f4e0242c684a9b1aba75133df5c9df3bc643fc8f0877d1fa9a3ea68d4aa67791441c41e69a19a9bee40fde6a39d4bf279de3e2c463c2efdb21377ad4da98829d2ceda3085b71265c68f5541e75448b21828305ce23d412be4f795fcdf698e86a52db4abc7dca882d7422314ba408321f2baa47b958f0954ebece9cad46994b72830be0fadebfb2eda936a1ef45dead0fe8bcaef6c8bba9878568c7656238e2851e281b55ff9e54c4664d63f13154cc2aaa28117380a8f8c2cf315e157ac953dd917cac7bdd136697911eebb7be543575370ef285770e77f0efcc53c165bbcacc16fd77e7231e1e53372cf3a2e890f109742c8ad881eaa2a465c7d79c3557426b74de2be3e79622b08504c95a7b74a7a98083850522b3643ebb77182ef834a43af68b0f54fc38d2378ef0f775b83b76264f4a6796544a4dad1c6081c876829d2bdb73808802a69da9a847fd46eae944970734c695e61a85b283b943638c17677b4f35d2425b1fb0bf16fbff1c1340f80842f3b05d1a17216f8db4a3f3da0415e95299a3a82c1e94e8dd26cb703cfb95302d8cf3dd3e55f9f5db39c32edec92c4b833a9ec2015bb5c38df5c7e4fc7e92b86c68e57c8592d24ad4a02b728a6c89d0c8b47704b41c9c18db5126356964f622138cb466e6ecd0519e42b2050d47f8f8044db43b1ee9fa1e6dfb2c6ecd41bda8b48262d30990d844d4afbe757899c7506fb738437af9aed3ab0b12ef947bb201e2191099891d65be08b1f43c80a7cff0d7f9c8d18a7adb04514b040d3dc7938cfc41f2325930c50a53fdb12ed8798e7465c1a7bd23db5d01ee04524015a1107348d082af7ac6f4e1397b0abdeb9425c100a287e1a957109cbccd396359894a6d656670eb4036ab0adc45fab0d84d7656690f831e21e00e1ad87c0f0edc67872b0eb531d7ba428ce5b75c68b7c00bd5d46b3ba71f82b752aa968ea1516fef2f4c82e998c95ba88ad10c0c6549044d25c865d0b290f1c7be9051bc8423c28b72a01f62f9e182f6ed74aa419042ff856ca917cd754777159d5b656a9504fcc3100bee5b7d53694f86d21f401012a13e02c5e8a4991ce5363a7a81dd4cb8c906986bd5a02e538d4e01b9acf9b35a8163f3ffaecc70ead38b0d58956b8212cf029e179b36d10d100d2cfe06a34669319a716a6f91bb7f4a49743dc12e874c241eca239f57975679adca66f8f27ff00237a37350ba96a4f432cd3410b0bdaa239224604236fcf16ad22137bb9cd7c849a983bba5f6975915263ec88cfbe3f06f8b05c538fe0c6663c773c8d003e42c061b96cea915c0c4bb12f90d7eb365aff32b2024a0b176a2142be49bf89584fc99de0a1af84137708ff2cf5be682f1f1e4d0fabcae09bde0f1248baa6106adb2b39d0c211dab0214f2d26d2b6a3808d68dcadb783bb772373a2d0e0e53fd520c85dd1e6bbdf02c76a4805507097000647914c990f5a9f14bdceac6f70bb48cf8c82d26156597ee835bad01fb2b16707da1eac999872c1d39fdb1dae3783721543390f227b2de3f997a7f5b8deb13bd3077a9fe2a2e42f0f33d9a51671a00a5cad49c7ee7902cfa64f28c010b8a7453127f28422c906ed7040c04d2155c2584f5a0adcb9bc5247dfd5abab2dac3a9023a11896d0f4c3bf8b21bce6c616b4b941fa518555b340048cf3c0830510ebb511348859b49b8899e9419b3a3c70daa16e3e2880b59bbaf7184098a4dde9b2c0bd241ce44d3eab92c0a4cd01c0c1b3291dfd2ca0cb875b1285588b28e6e614376e8becf7a5bfb9ca4f6eae568a25dabf2c5493faef3bbaa90af55c879d63eb94e44d7e31d4f5b16b8ca2bf5463e9ac2a1abe7004b24cc5b2767f3a309faa9db541a94f76afbdfce06c16321a4762d83414825b2a7e51516c48b5e382b60ded825e50e76f48473451f3d5aefcbf539c775027c5f6761be7b69105a7a15a85fa34da7f6e6bf3851f3530597e6bf63a0612e114c7276d67698016ea8015431b094e72c19181a8f703cc70ca5c72fac2db7aacf55318b1b9e11cb67d2d09e381bb03503cad94f119632bffd85bc4ddddef69207cb05a5915db9896429c1a4cf2a530dd2ef5416ca09950b0b0a7adbf305504397b9811e91fe62ffc4ddfbf2bb09f7fde453306c6a3ad6dc28a193bbe742508c561b37cf0de35ac50b2025c662521dc265c28ad47311bfed1d80a70a8220b50f9f0f1c2ebb2d7f6d8e9b9ef96f673f46ef8143a7f1e36a844152270e582c94d1f8814cd1f800a164aa0c3af15ef7284122d1208d8c2e33fee9d9b2a2a30b8dc27b5460e0de3369ddfce6cda4184a7c3c219249f6a1bde120d7977d4339abd8092e0fe09dd02c559ae73bb6fb61f2150575336ae98da59ad68fdacf89ef5337ccb5bf6a4e59dbcac78693f2afcc2177c72451d64fd4419867d023a01ae555593d425f2fe4f1716abd051df26f7300c6b9b29aa946b3d77781eb6000c064042546582ae5efae2f633f4ffb6e02a7a8d039fafe1ee72d9446fe52fc94e78849ba727b0ab43faab2241840dfdd00582bff5105d0a4e18b18c502e17395add231954be358eebf19e774c81a44f650910be98e1ddb71b1ec5b78545e83a03e8ad26ffcfe09cc5d9eec8bfbe5b1547b1325e35195f1b134f3ce23bc652a506551a499e4c6cb26ec971932ce6f851d66f872542d99ff0c4ae3c1faba39de1b657ca28e0070ec45471bbe6dd7db7b4c06eec5b2361fac12a3470f4288f6e7aae5b2a5c2893bb59cd609d7410554ff6afc192b63a45aa1c72ac87f0d2962d3dcc81f83667bc99e0cb60ca9ad510498b7dec8dfa4066dd1189d4159bc5b910eb707fbb599c48f60cb1e2d7a4084b4b6894daa6499efa205830149551b9772f1a3c26ce811c4ee2da96fca0a0d85122a0c3f5bd44216acc4ffdde95880969747689e723815c22b49cae0bd20a9d72fa3281fab7046e50cf4b6a4366e23264a540ee71cd4b0f5e6fa8f9a673ee0ff3e603c25a69d0ce3ed0bd650c1f629caf2891226f2418eaa82cd49869447ffd6c271b311304df0d53b598f93c9308a0faa78d90d46974816fd7d72705a27bcaf5dd15c18d101924b6e57be89551894718a16485405565e98334cfbf81dd5890040f32c7579d7316ddd1ee51fd008bc2d2e3974ece4cd77f690edc34243f51a3fef4050de693179419d409aed8ce972568d4c58289a7b39c701f27df4de6aadccd7532cf7d8b639206abba4749e56d6be0ff1c73b5fefb8e4c1d719ebb7058b5bc08374665a5e8a31b4f858e7d2f2a7b334fde3a033df083bcbfdca443212dbea6e19e42891e49ba35775646bd1ea445d4d819b67598e880385ee89617115272345cf003bb10fc5af9f225479e102bdae6fcb254a45296e884c12221b35a921a53441a573e030ce5fdbb1cbe83c68aa24fffe5d6ea8424ee8526f19be878ba1838745edb202bc7ebfb0062c73aa8fded786d8b4a2159fd871a2fb6d6ae95ed45fb1b17a52756508cb2c2da6106e66b574bc3700870436ee473a96d045b64b87972664fb4639638c2014594f1dbad9ecdee3992cceb032dc4948484d5f34a5717372e2f7a90b697ff561ecdd9c5ae88d7393a8bd6f42c7ccfc79122c830ae1f2277663d3bc33b371fd5d51fef569dcba951440040c8d84dd72b400ef618a29f75b2ced3529c3f7ba02ca828cfa6fd58ae593b4a8c5758f501c9dbdd5c47ddd0d40fb1c1fd5d123d10b1d89e087763d51c5895f06a039f0b001b2a06a4732a9606f9ea8ad8106673278eed232fd97818811df656aa501153cca0077eab7a2efbbcd641b8a5659fe22663145c674de7d008b75c6e9174626bfb0babc3fe7d0d0347e2ecaf1495979a9287059a29211e78c75f1a499dd38962155b6d9d3bcdbc19b45f1a45423eee23926ebacc87e4441023bf62f30d7f98d7b2ed6dd029d804be8259c0e9a0ddffa165615c6a4ad5a2b35739e264affcc3726928891790da69a1b9ad4c1f93e01d50673b004d2256c33b7995ade928be3a3c345a81e33baa02cc5de05a9d7e67492dbd8162239ad03260797df1082693b384bf9d99a2ed29ccaa8a53b89fc44e74b090d9762a248d07e952660c57ff8c9e88c9d4e1338be09639e0c8b756c18eeab307759d26e54404279cc7e74707b7b0db5152516dc428474e9ad046d062f030ebce9a9779b1a21921ee8dcc87f19a9e74e79fa424b5d2c813e43ef662d6bcf57dc52c4048a0ba7139f0e859ddf53c8b6f8d2b0952c6eac8968171d067b083d0dafe57afd07e8399aa39171863d619e00a60db73d101245edefe321bec22fec76668d9a55a8906a2b840753f197f14ebc3bfe9de9f62af3766ceccd982993a0cd2d870f2da7ea75043a44a7aa031f7892bb17e2b2427146af406d5167b4859cfb70c99edfa55bb186edf5eee2db3f792c0c69793cdda5d223edba349fe244045cf9543a16981bd0e08cee2ac81000b824c2e0f0347ddd73cb861487e09c14a47112b9f2467fc0aca329b6037877786deb942dadcad5fe41db865c9b13ad8482f962b7dc5c2243bb20fe3bcbf7f353722cd96ea3682eac6791a61d596b1631d015ca53b54af2998f3e19f44abe6676acd9c10c37a1e147547f9edfa11fddfb4d69502e4927272667a8f4337752877870a6dcb707515bd6ea33475012e9ffd02679f30f64995b6a9ddf1289ba01bba1887f5aec7f3b131a0bc0b5ee25aa8b2ff39c8c286a5fa544ec82085d4bdc79931e255ac8991215985da6c2ad6689ba660595004881fb92eaa8e672373ced1f12b9bfb303f096b0af8ff09b900ae9b275cc20bf6172e90bd34b312c43d5a1f9bd63b62598e016112ac8240b16722a1b3fbb97c6ec42ffc1538c2c27bd54af0af66649bde4cd7588320fa0c230413157d8be40744107eeaf798bd3f61457b1a7a6aba5e8c1ee998ec72e23981c18c509fe86c1e4774e42956e96aeb12fdec0e1f95238c67dc19d9a191219ed35504cf4793116f39a29018c7b9eb54b1570f184e4df98396581df4b5e53058324e7a37df817f97bd77dd341e39ec9f7d4db8736827f474cbb1857b4585a41ef50c9124feadc638aa50d0a57477e8e6309d89f43d3b2b8a207ad0224727efe2b773f86e85f8bfe9b9d8f94a2948d31663377b753832f69e45f6c26fd4365e88a248577a172f6b86db2da4719c7defa2eddfe635c15a6fff87e0c5b6ec3cf188564dfdd501d30131293fe77c3a26dd053577abccf1e922b52d4bd168c72bd849fe6874c7f9db6d0c1249f90b29a8d319835e719972e2613a4d83232d88ddcebefa98aeba925d1d6d41cfb68bc247c4ff6063d35862a8103a1fe6dc70b73024b0160592241649443b9a4485d73b3be2e541930c2ae5d0c53a7ffcc2680a13ea8cbf2e12cac212e7d6b70775425993261482319af6ded4b35c6637f6116b23ee6b8c523cab07a53e925430474b7e61e961cae4fe9cbd94cec4b9a902089afcd120587a6784205753101acae5cc22606daaac228c11155289eb6ed2ff3bfb0b92ceed73c1e06b1e11d5a0117de5b0c556b47743a398cce89915f345ec90994629445f1b7b08b066573191bca832f77f75b079399b5997f7b19d065b7f6ccc4192b6854a28adf66d585a87b47d49a2a432697caaabc329d360a26c7538a94e894a33c83bfc99c12c28d6f42a22d46ded1b9768c5f9ff35a290386309a4a3c40dbf7a8a7227a1526b322351f3ca852caacc0cc40431d386bb86101fb905805331564c15d67ee25122c1e10e06fc48b19a54d22b0b2e90cd748d6855876ed96d3846961b0e891dfe7a6169b95194351fd1fd9278185c61ffd993b4a610081133609dfeeed3e9ee69f63f2463204cb345ca32249f6e8c244c8b38175d06af9621881c840c3130f0717320697c620ea905a1c42a02932848239296c707f74c3303f079677020f03a240247293b5506be91cacfe29452eb366af500e86bc948896c001c1ef4eb62649d6c7bebba887234f2c734e7cb5e768f80221c7e50f7106aae8b44958ad63aeebf68db72dc9a6e7506ba19320631b73831b720c53f8f931b0505d1504a45989de54a399d6d91269748840f95bb7da6cf76ea3f778576d6bdfaa831c0176f19a587bdbfa6c7353de86cb49b9ccc1b37f7a7b33d7764f51730d88a625f73621dbebd8fce55fcde947f372146076a17eac3b7586c1136a25cc8aa9a3684b6e6ccbcc724e457f26b531d9604c8ad192ab14661cd0a27fad32c0fb76d717e9efb24c89294c8d4533d67f066d774b13a9f576f3f4b4641ca6bc7c9291a97ddf0be6caf6ab3bdd89996d98eeb3b81448fac1e001abbbe0e8ea206589d9dd222e12c34b8baf92be9260003c1a0ba958b95fdafb7e13147226e51604adeb27ac7d8c6b78ff4bad30cc94cc09b3f6fc8384e817199e9f6c0df42de56988c7ff1441c866428edf4203c74ce32a8a51d100298ff071a0f5ea63fd95093ef8fea16dbdcf1f34f2101e5c4c6c565084f8823b6033876d6d34a8a00a13c3bc54fd4775e64b114a6d909abf3d685a736fd37052b39435c7ea200efa8c80a76a71c9c61a3bf18f6a07552fb523b37709be6b6018ee38e04435cba245d9bff4b3d3019f07ab771e274276371271740fc9868d22040a87910b9b31487551852a2711cab3ff14d283103634d90a300b570fe408b9b352d2859ba9154f8bee8829707c96146b59b350e3876f205d4732d29303beb0ab8c9ada8f3e6e85c72ab8f7973def85baf02edd25fbe7304d196d02efcafee0c8c61f72a70ecca8213150b1258bda5e3f2c307f91b1dcbba11bb8a1cedb5bd981e3486ff55f617e74ebf2f898483b079b2cdbdefbd9c733340dec1e06615d3672fa2caaed46de022c4f2b24ef99263b99ddb8edea6d75af9a73032a552332c2776727b6393c715460f8b15cc92889df1194f7f984b909761ba0e73dd67705017abb1ae8f311207f5dd9b4ca14e1ea5a982143b6fd7a25412fafe843323410c1b4ad93a27fb16d17c90fb6d6369f35cae02318fba6a02b54504b91489495ad369938df0daf84a8be67647555e6a8ae83a17fb6881761933e190a1b13a4e8dd9710377a82d37358470bee6a3445702773493238a5bb693d7b7268ea557af058a97cf4d1c64865af9f13e8cb67138ec08c774e202479456e722476041566ca6554a8995707d8d0626c1c5feefc30625d45dccca4a5530f9f61356dda9dffd57942629bf67fd2919e6865023cd39565be1caf90c40f699599c263fb64e7548fd56ad1c5af8f39ed03309d4116a838f776a25b43ccfe18387e9b11c488994a25f80279c76e998db40bb319eaf5ab566276404709b1d0f33cbfdf9a2da4299f0571a122698ff54666933a38f9538bc955fd7d7c5ec0fbb48b5cbd219deef1740e53d61c29666cfc842150e5e44764fc60a339e3b371ed2f70f91687f6370c6a559a6127285b341a6f24d4629a3b3dc2d42fe5c23a94840d1165fde8227d516c2c18f51d2083ac0ae7c13eb3d4442c105731234b34964529879e37c6dddac60bf71dfcdfbff1db0e8014825c8dc5135d5e6696d91effc8a7a31ef0876a432bd0155995c6eb16bab890e1adbc0fd723250df5a547c0df41c8aed7d91a6c6c88c7f13b45153c994d35f4c0ab0003d025cccd12f107b6d7f4ec2d46a443389cc144d39fe528a57ae1b5aac11041cbffc6811f1e9095b73e01db70705839ab698611e5a150bf0adca9390114711c1224409247cbef0d17ed01b7be94f07daba9e9e52f37658e381b70dcabad51708b8fa5a90ad7bcd88ab1131e14793228116fca63c18330b6ee66571fdb6918a8d08ad3436389fc18233e39ac2c21e6a0d593fc2ea344420bc0e60dcb1c97679b3d1e29a7cb41f836356c7c4aadc0b4848e0c12ba19d4e61b11736ed089ea854a502b01039332022081c12784fb8cacc970101ca22b7700fa675c222fc060b6d53e35a2215132131b48893113124dc565e2fc8f591d3958678e8d7b52067e89548ed4f9d4e0e9d2c02ab43b4eedcc28cbd1d407a5b78c957fb5b37a9d0730972810d65fc9af3c82396a88f7e9edf74ef31696806d68781fbdbdb67b2edd6f4a22bdeea2b897af4cf0781d88c9d3e86186c383f2ef9eb4f3374635cc10bd0a3c448fdff823ba4f3621d3d4a4af7798292158e7b4caa4ab4c2dc150301f3dc58df9950ce56380e41af794dd271fdc680e7a224a39d973688bc0fa39af2dbea0e1f2fb848e3af1bf9e460e25531e4ddb2eaca997a45947cf7862c26be374e889eb592f28b50fa7a634174feebe5f7aadabb3b6db80a71c8909ac822f265df4bab7919c0d39a670a657978c4feab84caaee22fefcf491c78a6b322a8d8050512707b86c86df2e15e31d80454953011c4ebaa62b520b4f92aa13a79f5315644d4025662f0ff1eb5db51f5680f1c66b3548a2339e69f72f20aded8ef4ec43b41c7152194b3c5919a26668325a6ebc3c1b365eea7df2279747272923ebd5e9f46d3d17f44908c0653bab3b773e22b67a271d696c1f06b2c3223613faf5d7488ab3b8407e709e941fe21b2379b5cbb0bca1e13336259b232e802d51a95f96a58e631e1232937dad7d2a8bc2a24f5cf39413064f7146c9bc5458847390e898b761a56bf282a670d6c274e84bfeecb3411c689a25b1187e5d1e320d0c78e556ad9290c85227a90a793c32f8375c9167fd7295493bdefcebfd61377feca8fe6a463249ba9fdbb431c2354df373007de9606e87fa4cc0b07afa5955b1100957f28b28d900af369c68ee25ebb3b72ea48c567494c5efe755d293309d04321ffdd61f2ff9e6d7e4519b4a77d0e3764d9dc17869e99af4364d17bb242fc8baab993f9cce77da42429c68b92be6fd2b4704e7c5c96b8d7a3cb764d5f18c21bd6d6dcd84fd5a5105860592d065ffcb67d348334089b66407da086e3ba1d82ee0d07061dcc4692525766cd03877232bc8519334b68c67a3a07d181d4b8df3f4eb50852e0a478b72584829fa01079f94bf33ca32625c164d7d58fbcd61db7c1ec6440c10e5d59aed448db5ba53a085d3b4bbc572e29261dcac7f453a89e35e0b3626d33609b843af65d9396306166717dc6277f5897a0e54ddbd0ae802b8fde95052d33f19c36a202133539c63683db482a8de1d21865f18732d1f7f378e27efdea2acb1028b0932af326e500be881777ecc6f0386eef65a3e16f6217005db584bd9602d894985f16f24556d3ed997681b22bce6540ea191b47071a97698a5ff4be5b14536df8c36ca425d047ad6bfaa25b4231e8b33153c1c08d220c0a44f2971ccb69e04647986ecb6420cda59022ab2e3f7b3d9d3df2bd1478359d6e879fa9cb65ac57adf5dfdfc90678e76066d004a428853ea45fb26d441e261ab8bde5d3fa287152fc4852ee6eb7cf619a8c3628b26613fee692940e694d65750be0831d2f15835200fdaba3925ca37eb9a75eb0c14bf645991fe3a24365ce986119157d3be7a14d11c4d5b01b27842d0228041c01d0cd99aa55d8cfdafdfb12cbe0a31cd5ec4464b5579ecd71674534953f997d1db2a14899d753a6d56f3d5f159bf2c53438c2b971e57f1f686ff919aff53ff9961218aea4f19b56e29e113f6bcf7df00495f8ffa43e391e4b0d4d2d2c36d7c3ba787abec5d58e7eed72a7595f4e787a0907df34e0c07df0ec3f435a59ff72f1aef33bfe7d6916ec7aab0ac58c78b33d65e299107ef56c3ccf08afb046e2be48b00f2a92207ae0f9ab0b47bc09f6c6bcee94c25cf94807415a7737438e31faf4e24569f17b7644abd48c920d065b50d3edba23efb09fec72094a249924da15c47cb96c4e535892d009410a3a905107232be41544366210e7ccbb378b0e4fff41abbfee924629f29b60aab36244d60068cb4db09b952ff3956656c1c29ae5f5ad0ca2f14d9694e13479ab5e7974bee72431d7c3f824005e97a7d62c3bb7349025eac82e3f69f20b5556aa2b18bf59618445d668e2cbe08ed136ded6122605117c58f277f2c8638ef4abad2a5f0945ebf7a0f5fd6bcf24b4c94d9ea7227f2ccbf4f80b08e663c7bec0325b2db0998177399ac816ce6341bb7b8054b261527200e3b5ccee48adf039fd59ff9c5b11cd09049ca866094f898280b43ea645a074f700fb7b9bf0768d647157744f2a82d2d298509b28eb5299186139960e6b7bb8f1d3835b51eee130dbded7babc9aff3af50461d2c8671cd0ad0f6cf71cbc7e084a145d66827681c4b7dd901eb0e50be1ee3588c43a4f60e4922e1f976574fbecc30efbd251134a703ed4c0ff9f20b40822017f1d5ecf0c9fd64946042ded63b38ee8b82e3a5c43a05ca9df6613deff2eb433fceeb6eb223426a4a7f2b0337ff614d10b8970cc35faca2007300faac95f054bb6daafbfe255387fbaf79ce75ffe7f1db2afd06202338b6f2e869492f941d750ba202164e8fafa8fa481e2e84724f28bd232edc904ef2fd6a2b3d71639c9f20df7f5218c27041a83c4990060e60f70b636fb09295f909b43ab0d3fc226bb1390673cff5918e5e8ec5d1fe8ee34fa7186aa13a56aa346c868bdd1c235b67e8264198890a04efba36a0b751ca699095e63f02ecb07ac4a5c4a2da2d11fad8c4b318a3273bd82a6e405c11e8d6bac6dbc91c1f8e3070c58ac3d7f16d534f37d962374d226ec3fa37d37e52955a9c40c62d1a6baa7a8c3dc2decb56047aa2ca09702dc8dd00fe046a6eff392b91bb4012f6ad62bb2b87aaf4ed8e18003e8a632b73dfcfa62b1185bbdbae8221499fc9e4c6f7e137e1a141e4fcb383ff11d6bf5517b6c3ffdcadf694ef8bf2f74bcd1099f4f46e95ab951602f5ae8cbc30e14dd9db04e7576c6cf553d67b09a72a4fe87bfe24fa0a0c91222c67d8ef5147299fc47c193a84994f8944d5b25b29f6ee04ff3389c2460c085ed88e6c602cfee783c86c115093265fe88512159fdcff0360b374089b7ba59ee57d8020dccb0dce2d74dfebf7f83dacd5ddddea113f1d8f43db2eb4612ff72064d3855ac8d69bb2cb89719dcd150dffaac7a661e57972203358fe69595752d9ad3f934945dc350f25f390a8a9f438cc56b98826376c1ef7f65d277f500dddba82bb827aa655ce4ae6ef5d7afe884d86955185254e49439b571cfbfe3307ee8479280c73f177a0bf0035207ba92bcdb4e18fbf56c5f6e02e4481fad96f3ec36aab1d1ad2e90792d77a857f8bd6f1e71d2037326a0dd26587eab0158af48fdaa13617b90d35dbf871613f4ff83642d06452a71920daa5f3a55d36106ab8ef9750d5065e0ed5b73b182fe943d1cd8e2e4126d083c713ca70d20925f92c67a082f3eeaebaca203e78d8af13a9a5cb85debc5e01044d83624302f5c716c7a7701c2129d4ae7e37a4d7ad90071876cc3eaeb90e3bd9419a4cc03d8171b6146d0135dbfc7cba1c3962341858d206598ac4f481828c7e550d9c587d28451821e1d59f5d9df2e92e6c740e6aa3865f7b74a6bd35f92856615b8489394d727e3cf6acf1854fd221b1645a342e61cbc2a7c5b122574de91f08007c38c84638c3fb1bfc01676c28d32bac906ab213bfea261220567d2790a31aa8cd4188f523ef776add1a21575b2b4750a03f4837f1ccaec6ce634fb6d1d3733c7a30c2cfd0407cd63d685c3df6ac0f29e87f94ec46b373467026479d90eecaa25cc986b8cd845d09962602fa06fa93706220cfcbcc08fe7675eb7e5abe0a8dd1820d6cda76f97ab6a6a83f8467e4c334a15ad505edf47b9063bf4a733758a3076ed156ff3ae9bd6fb90f68d3612acef65e10c866c700c56edad54724a96e7c4eaacce705090bf24c3042763b98ac8f16e3bdc3d9a98f51ad8ca7bbeaa01d18019d587490f8f338463184ac462453157b7472335eec7041ed9f2bdab16c488d9bbe0c3258740f922f3dc8542c4ca0e717d63763f0fd035981a9a0e7ed7917cfc1fccafd23ff7aed968d54b52f10772fa466f6b09110db22e4d0e7bcd874791beb1e3e59d0b05db4d419db79a216ed7ff92a69d18395d9eebdb78cdb6b74b77c2d1e5ab91d006ee2bb0fb40cb86b0c8c6bd5ed0a08324600668a9f772f669d3ba4c75f099e86d3d92dbc52d6fd7ca8f22958642e75417cd7fa43e06947111eb52a227352548d706adc2b5bc5a3b86984018af34b5c3d719074695b7c91918014462da058562077ca067236db32d00b83879567295abf1019108e290992bddc67e82a4d13de1e1f21d3ffb94d1380b98c9e1a055a68780b533fb680d4f9f5ee2fc73a87d90c1eda834fa434d44e01bc0c8d624f9f8ad2788b7e57735878453fdc5bce8513d73465e1cdf8dd7bc111e785625bd44cf7af41246673e2c10e9ae9edb43f99e4c1ddf1673b145357a4bc462837f46f828ae727f34a2d84f354f21070ad172e0c74ef12e3fd61bd7bd4d285722e14c21dec0044a0c6da8a9bbf1febbfd5c8c2f42f1e4b617ecedbb75a6480cf637d574e7e83cad4a9f0c3bfc2cc6c1f37756f84e6dafed5a4e695725875f7a3c47bd76a2390c874873b80c3575f7b277afe4e2c9e81141e313a6f0435bdd936a459226b25d355bc3ec2c440a09b64a9ec202f26679076421c75d53fc4ee13b677df6ca1f5bbd310f707a171e4fbf636dc3821411b266705a776169f49ee430b61460b0856f79fc111f16cde952ab19a9dbe40dd64dc4b3bf9c0104b11a6c1418c6b286481ffcef3cc50824b1669845ce2f299482c7d67ce9f023177245680fcb03ae3f1721ec5c5318e4bb642c69d099bf5d31ae50edfb7632921d6fdd3039f02d5f7fc2ddd1ee8ed6e3bc29200b5dd10ee6cda24e53547d0886263cf4e134a0f63bb90b7ff28def70c2f96a4caafb06ab0f1e14091cea8dff45850bbbf929c20c5d8117eec29406ba105f47b25e5250a732c4b0f4b7a0d0677c3e9f38eb361b7d10454f4c59505686a3e0492ed95288f786348f5f537424b8a5250aaef7dc4c5b3ca727bee705d82dd9334d39083d3065fb9bc6a5e45301f7808d2d4d46ba3a458f7aa844a570be90263420fc9950d5c7be4189ba5de1a102607786c3dd73abcfb8adcdc0ed096b0cc1841187304b943c00e9061ff0f346b5b6416ffb054dff0de0dd928d98256bf010255659f3f010d400beb1c29db7711d829e16032aa46ef9e13f3a66cc04ffdffc120ec8f61e2bc1d634c536b9383292d1ea495b8697aeeecb62c71058a73d346131a7a164aa9b655b50d386f3dfc9a75f4d3a522618989c28b88143f67641120bc84fbcdd40d9451151cbca84d705a2b61025077fb1a9316301849e9742160242723f2f2ee53dbf6a5b6b27892ef9800c2fc1a0243c9c955036bbbde9e44c9450d9ff5a459d40b4a291353f8d3cec86a0ae4761a302c031e0c47b9f3591a89d62bf71601db5cfd3bbb87470c80cde1871e0819544748a91176278908cec39176f0c2cfe16114008e1cc746632efba71188965561fb45cb733cd4800d0a2a1c982326ef2f16a129113d80dd088274abbfa799343edb5556261de9e8a2e7a8e9fd325ea98b2299df7a7f6035fd56112f3e7607fc5f61870c2f7130f901eebb8a1747a9efea7851f4ad127c724453987769758d2e806e72a2a3a8ca268f3c9f5424cdb1f1c7876868b424dfa744d824aa2edbd2e26186a31281d84072f3acc391f876f477fdc13dbf15498567cb6a763475f9460532bd11410bfca4920f938a7d631b8c56c24776d4e331638cf25f8a1e9fea6871c985bf50e0eba5a08e22dcc1418a5688cca1e478ed153801380411cb9863fd55d8ca25046f2018faa18e6011a4378f19d96ffb4d5de047151583ab26cfa9bddb0ae8443e2c15b8ef73de5c983e40c94349eb294c8d9632a4e462c65043a1914fd474d45e2657b45f0df3c58443debd1f72859f7ae70d1f3276e67f6fa59459bd7117a92e76f8eb6733752cd190bc01c64de435c5f56017fb22b51c5bdc31ac943ebd0567855da2c2300d34db393f5ef6be8a9584163b8cf591c0495c6b50d26f608923754549937becfb841e5a83b0afbf62088ebaa1f3477c49d06b6f507af940b4bee468d97ed50fa21d81231e0b72e82dc68b10f81632bac6d8be85d6681d9e3b8636a91d91f4dc654d727f5da4fe19ed73200ea769056e789fe16c9d077e264e342873a1ebad33218a27bdff2a3d6daedc1306408a35930b83c7be88584a5c2ce0ace46065bbf862853c991403686dba6897122d014ae1d6483e58bdb02efb80590cf00c1cec668f67095ecb9de4489a9427b57b9976730099468ab3cdd14ec9ec70f01a3913a569ef7078880bdc2db5f5f0b67db1f3e4b09edef82da3228274bf22222b5bde7ec0a70ab70814d4fcf30b7a8a6167e0d0c24ffff3a345324b49dd2b04aa6258ff667600d395de22ef34c63080c8fbb119544d4830cf0e7eab594c84e44b55b8e612cbda62a237beb32029e4a058466f90d499d080eac0e948685d37f192c2a50130a7c96d5926981ae49793f1d259210f98883916c837840c859fa00999a5c6894f341554702b3e17f7d027bae6e4c11448cc5a60d0d717e7e5ce67251f6370df8f47d964713d6b4d46f0a0cbc42760f2d5a9582d52c3f37bd77b2388d956f8ce6802f1da74f4dd50d345ff14d980b4e5190d70b2f164241413b826b9ebb62dcc22ae2ad6292c317e3bd7b2553725f9089ff69d4ed8c0cc1c7dee7334bed87a182c72710a7b2240e57d16f7937e9772abe3fe2f535f68ed450c717f912574df11b583e1a558658fab99a78249d40b3636ed5108a86863e667079f019b3aff2b2509ab3d3557a4400e8b97a742e87c4108af1f65472dd7d7aa9b1b700485f95eca5a88f34b47121514546aaf90134d8df9a9ad7c1538a1da976bea7da5f4aa8aec129b4395e549f1b7dfd5e96e9837706b26170d1553c93fa4df654b32e75d91fe77dabec0655a5196eeec683a5ba034c40595074e4c61f67139e4334d05a5b2796435361dead562ccec717ee6a7626222c581c35e42231251af3eb2b072509d2fb0ace121e429a908f9731b9a7d74090fd0f255781af87560bfdd7b679f32333441f0eb4ab31c34433691e22865758a59e31ee49e4deac0ac9f5eb605659a9abe89f34a703e9289ac6be31defd2cabe300a03fa048991f9fc14f464b73d817418fccb4958b510e9179bc38fba60a610880ae80163af67843cb23333ca0687c04d97da5923705344f9f0e6940ad37f6abccc99faa839a44fa30e09bfc0a5155ee288f77b61a450e76bcf6f3cee345125ef36ace27a40ffc369c92463fb6d3cec7ca9ffe5cd34d8b386be1bfe06d0ab4528699136de5f68bd99a2810127c15d3930b753930b0cc4dd1663c07ee11dc8146019a3ab65d044fa9fe9a3ab2c51149f5227f9b521c21524ca5e6bd133bacfd34f28e832a4876a0d3babeee7c3458afbc1af81c716f8cee2ce9e7ffba4b020c990b67b0350ddbba39aa6c6224c670949840db9801d9f0c2125ba6b536336762787f00e9188ec35c35b51a76ca552dd96a4c83c3ab363557e9e8a1cd78216c8a41420649edf933c6b41f9ce8d7beb5ec71e2f11b877f6b5c6266bea7e2b0d1298a9b24c57026e67e6f3b7e4139b2f67b128fdc79bbf69361437f115d7a36b3b2517bc9fc262807f0b448e6c15707b7dade1d87e30dd152e30b3ce51526f91dc504b22e30b52f1242ee36e6baf36cb4bc53074b2341467d9691c208d51d42f66af80fd86334265ca515c022d2c47e5e82120d84933ad971b66b69cc067ced801c1623354431e739085c3cb273acb5f3de123f71ef911af6564f59a93da1ec1bd6f120f00b5c160506186c41f0b721922d9e91e92e31a65adee66638bcf0389cb5985c83518f0e6e2e6482799744bee811b1dc6a05516be66c0ba0857dd10cf137582bd88d5f92b6550552c96fc65429ca799a5f1e69cd17dbb000d130289fb35a19d7ecfb6a81e1aed4b43930384d164fbb8f61a6e371f9eea019c1187324a4100278de80fc6d942a55d32a4647dbe5868dcb2ab6dc62ac2e3f7d6efd428c624f92bd7042dce5cfe678d8b6989608628118775e4004c72071eb3b5bb9e16adf31f9f8a6b25d1163d22fca0de08aa98799060fb1dcd183bf9b2d2b3c60a8db5da3c66737695fb744543161139456edd04ed36f496899e3d10d0a3a331ae6fb08dc941cd4c1da5907f8f7432d0a32f2476f737af23a10c55c16ac923259c705d7db7984762b1128dde398cbb7586fd3a594f456682d14b34d6ad08fd7789b05c287ac9155a90e70770dbfafa3127c1540812c32282574335bcc14eb56225864848a9d474896159e156c7cd6f384afd37026748e66945eee6450796c01fd6f9e6d62f790fcb474e63a0adcdfb3ba746b40e4543443af10cfb59877689aae97416d39bfba42e93d003de775d7b6865d61bc6daefee9469fb4e5414165c2a084588793a7bb5c17be9b603a350a9552d1d675fdec3b03bf5b2bbbb2c2a075a56e623d3f2e4c2f4e87dc1250e3c01974bba3dcc87b340d4de12b9d7584f83e6f08236abe6c2d21c8678c170a237881bec7e35e50ca2413c9fcfec96e38d5a971e47b7a8346fea2fbd9fc36b96fd0fc4f8347546b9568c08e2db0e3eff7606345b959b7d0fc25b6879eee8498ba0c894f4c5a9104fa13568e03c288e12661f97ec078a8abe1870755942e460d4e5b0341682217479c61d239f9962269fc28c3f89b90c688c68106f961b137da39145f73f8dbe5f17a6c257e6594e58ae1b316bec890848b14b292bfcc1ea3b8aeb132c39036943ca4269d6577989a89b6b62aa2bd192b6f264087c250e47fb17cdc315422a9dafec72a1b2995ad7080da43d2f93793a4ae1d4ecb3500742a40312985a1986de28f433f940f60e94be7cba82961c4efb5464338393b3af67bee8909aab18d62d3988398b4500532d3a0de911f207fc7ed91bf358fb91e986fa80ea8b8cc12a61450e790c829aefe800e4d79322c4649ad60dc5b897c9b615590cdd52b2d8d5cb97188d2462c60030383181337bcf194f76b75cf71bace645ae2207ca5e78665c2baacd7066658074d30bb63722882f4371be3467a4abffb4e62de4a62265934aed4d9df142f6d70a4500abd6ea43d2da6fb5a61533260d36b8f27865aa86afa175c380bf92f987203ccabe8a92b558975e02639896c725cdde5f586623076a9a5cbeb27d45d63f1af3823e369597531ac49b215514254d4bdb1c5638db03fc6c2c610a507a3e1fd0b7793b4c8836f4c4d7db83cb7333919824c24f883323433090d96dee3c52dcdca3deb648487d53f1a894232bfccfd8dcb441f17b1308b847dd08959b91e085ba5d24599dac773f4642c379ff510498a6be429cacd713e3ade627d8a57a4bd34a3642e6337105254011bcf395a5e42f05a336ec9013f61aae56cb23baec8df5fcf7d0df684b5cac311fce19eaaa1fedc17bd80ff0994422705a6f45a104a491e320a298147c87aa364a9f6b4a463cea08deaa2587e6dbe8e0de46c1509097b62b4fab6e5877ec1be8d70d881a9dd68eca14f16189ca618a57b52786035cb277da77c7502df7619994f58669ea07dbf2255c42409b10d8285b9f2982ab0d936abe63ab4252d7881883eb98c699ceaef9d0e271701d21d543014b301c63aec8baa9c9945fb645dc575e966520953a252964ef29184ea442b3f188c6e4e1ff7a03eddeb68114da504705bec1783c0686f53ee5199df0977d1f4c77cecfc26e0fe84a9c29718ae5d3d1a4aedc7e38eefa20e7493e5a11a5384d7d8242e2b30f7b71595048bf1ca239308c1c1c50eeb428497ed8945e41c14662e4a6c89678060317364256f914c4b31f2facdf9cff1c0eef791b914e47c0cfd80fa8d55ae5b662105143c279c7a0e5dfdf90aaf39e159758c1a47c4f9aa8ed6fab74972e0f0bed169e43871052196bfc2db960e8f1a88d77a303b5948766ea1747f132722dcc197d9b903724e3c373bcc150a2c742948f1872dafb573e4399105acb795d5d3abb59afa39cd23bbc1b95aba2fe6fee929bee23ba3fcb28a68c2de29ed620c7630c870cc2d7f6fc3dd36f2204a81a30d25b67b2bfc16114b37f849a3f3d6d7a22e9e61d4df538445be24dee32391b43071882ac11297af1e9367dbad03a622890e38e2f9b8cf1f19a4c5a9c8e96e6d3766fc28a58a47e006ec96b16145be00a3eb059e8a86d17468cff8bfb55e84ab11798c1433cc1dd9c7691b302c8c9d6f3e2a918379d1426d21a001f30739cd670c952e34b433cb078d46c34a95948ba60dcb480af24a877b5e559ad121f285ec1bc89f3e3eed1bd13d3f1d56497d7907bfbbe30ddd1cb7f57979bdf74abe10ad6a537d093c34a5c23cb1b89dc32a8961caa752091ca0f465651f51380ca389edb55a681ef46ba3f7e22ab3552aeffdac9fd18b4b7182a1a9a501783c737ba1b66b37a584453c13af04b05c70d5ed057deb09b286787ff3f1b738d216a2281672433e617b2089a3c6eddbebffcb350b9e814868d3a14d1a13719fd906b9e74a69d0cd718898b40efacef447db0cc134a3d9a92da350b5fb1957171d443b620bed52004219eb0655badb4a986649eca801385bb2581b73240361efc6f880623fb25fef2f16bd15414d263bd377d2e86bb8128f7c27864f85f4482a244fef50841c8d991c641f32a4f4eb1562df63bad6600ae9de4c1fc79b45e61a8d2a28c6d3a9697746e116f90e0b9e6acb98168df498aa6da6a274a9ca079fbd434fc21e933d7e5828385125d8ced1653b42b50f912c9f2a117bd3ca716d034bd873738aa92d636b9e626e636e18af87209cea20feaa0eb7347ac9200a6c2a066a8420ebcff94e20fea02fcded419cf4646b7a0d05c2113954b79c8e9a7e4ec36b525e3ac67629fe7b1c5aba29e6713efa12fa4c6fe4c55d083eee42c49519ebc5dce0c3d5ed7f17429aacfcae5715d6cfc4966b9f1df72193cfc459b9abbdc5b9126449b001c96145aa14bb34b1113db34f8267427a584a6e117908cedf59f47c6c8b74b85f292b4822ba2ff54417f1547d2552590e766b1d6428b5815abf75cea6cb6f2ad2a9fc0e32e149149b7173cd89a87500ff45e52b515e53b1e24a0bd10a23c3e17fb225d137f8f6c2ecace49ca6f6ed8406b382b86e0d5b8bca3bbe2bf7d826d989f9c3eb8caca51868c37c99f5c763204f9b2623c5f69c63542627107aa6c330b9dceb79649155f80fe1beba5984079ba923c9b2c02c604afb8f3f0e3eeffa1f4a82c5ba16d1a20e93d2e7d4da8fd0eb47b0fc1d8257852d45c8a571ebfc9f4b9332a36fcba9b86ffde95cb85f1af7ec8780a5ad7758a2afb4dd508ef44277721404f65b5fe44e2d15538ec97f8aef11f78baaed249d76b0439b232855f779d246fdc31ab1cc9b323dec8f33c312a27cfb21a3d14d4059876bd444b64c2e1ab244559b1c6db06e72f1b916a4a422b99f96803193ddd3a2aef06e77333aa190ca0f4557e147f0594b87aef957db4c68c247f0827966763128419e238e23071d67444b44a758893ff0e5d7de45e9242fe6ebb57908236a4a91bc0a5dac30e0e4c7e870d96dbbe4a1259da6a94808b5bf6c5cdde6d1d9c2ba06a02bf4dda12e9aaceffa00adc8d549d28efe23c0dfa4feee8d4d2f0f6a4950991ab61c78dd9de1accb85746222d8aec70cb6a20ef5ee3255b4c87aa32260dbcd3f8027ab7190e3562f1b2840fb9b48c8e3cac258fdb666e0586bb9785182bf22ebb2a0b3f76eb6dac0ba4f384f65fc8711bd73190a36c8aa381b5d2f79d395328f2a816888968eddfd65d75fcc99647adff79bf381ea01c23f6e0f7382cd29eac8c465e718069250abafca7b22478b2fe7c444433760498a1691f1dec9402174d35f4e3f4f7c2af698b7feda9a4e9d6d90e1b27dd3a3cd1ea3d01bab2127d91d4ae6af8b3f0ae189d192c07ea3a8eba923c389e9373fb5d9f4ed9375fb9c5ba88de0832263ab7ebf872055f27779b1e4bcc23b766e270fd1ff583de32cf23154473ccf5177b22ac97c46d23998898cc962841d891dce8b7407cd29b78937b5c374a23a83361702feb8f0fdcf05c0e4fbf73d26e9109e3c7f981f495a3308f9b7c4f53303b795ca811023c46026c79381996985d4a159f59115795bbd58174372d76327a928cc38c29689a12001868bae4a6a4048f557c2ca869893f10375ce54a7ddd62c147abad926cdb7aedd1df6ea41edf1b6f6ec829353653a7c3a9c1f00bfeb02b21090f711236e5c13cdd8a52c4dc9868951cbd634cdd41456d9c8b7d10bd2b86e52e9c7bdf5bbb332ecd33231c63f40ea5b291c1a5d77816e1969a0ffef9a400d6d9c30fb8f99232b6880916a82697941a34278034ac76a229c852faf6611f46b07e9ad3b447fccb3ade54174314ce20f6c331c8b9fb626fe32c45b567013ee382ceba746c4f30856a61c5644d7d0d54245b950a60b737fdde6240f9df7360d746d1ea89ae80ec6cf420b2b5f117c1cf710d4ca50ac736f13b86f033fd9a0821a541fe2eb3e5e9506836060e33e63b5f9d1e4988ac5a66f5955d562919a6f3c6d9003fc48f52532ac679f684d3b9064036729cc7d1d5c97661ad793dd4541571961a992f60d75aedf111372e4be610a133c1ef1206fbf01848705cddca2b94099efd95bdda539ea915b4f1342581070c6c24d5d53a422a8b280df4c3e3cdc784bece9574449b7c4fa50e8888a371438fe33d8fcc2dfac9e2f4dce238e690de8833c53740f1422c49d091527eb11e8af66dd01de854482c2dbb7b5617e2ba4cbd2571be7367e1c3d17ea3b8efb4c0950b2f2fbdb647fbbe4cf25e9d49fdf12d51690b8e0299ee2658c78588145c3a13b42ddc3dbfedf1a9454ca88cc59ae923c6d8e99265111d84a6439426ada5d6104f7f5de41b63f38e87cd7f4656267ae175439463678991d1f4c40cf0babeb62ddf9f9405fd55d33248ea9ec285c1d1e4ae5c446956ae277270000e0f0360230a74e75cf94212c5b1921ff233b5af33b6e9a7c678b1fa6bd089823efada18b719c1b1446600fc4d579785d3017025ebc8bdb36668d6db4ae5b7ba115bdaf20845ef83a4ff7f0be3caa544dd75cfd36ac6049f6e8328b11a8010f7541f41c0b1f4ba7ada7edbdb0ea10ef96d6c2f18cca0e3bec4171f56acd335306616b7e8e16c0736cdeef70471af41293895a3f8606dc9c6f81ac8649c7bb80a87d0359d1567616b1cebe92f1388f28021392155ba88758d770d8c840c2fa48dd8fca051714b81efcb6380039634e5b09e980988e48d362cd37028bc36358ef407e5bc633b92dd4cee5d61b8fa030df16c9f0e14fc8da156a5d6fe9f0130b558913258e3753cb1be575253e4f7b90641ac6dd139a3718d52cb63e841b83e74cdf787ec9325d51526f3a78f479205fd96dedce791915d711a0339002266bba99a974b4aeb6d455619408fb7e0af6934852a5eb2407cd3d726b60bdaf01b0a23ac70fa8fed285ab00a44b3b724e4dad68be5b224cb1de655d302df04385688f40c3979f8ed33e9b0d744445b5caaeb10473ff68796d78dd0d9ed7bc4727c6008309cefda7ea011a190345921d2c367b32e9fddaae5d3aa7f9f003a9a1bd1fcea84983e888c4566fe6157c7c0871a336d2f6ebac90f2db7003e32e8dc47bfe4417d36ffcd2e20a30b23115c8f98d12dfffcec805bd4ab402763de683e38732702a922195338b200996532d8a0196bbdde5a5bb3b99b6a886dc4bbf353785f507897516aa3d0df1cd4958e74093e244f41221129ab68b8660970d497ccb8722c3f268b2ef39be6b3a1bf2d5185f0923951119c86e0636db712e86bbf1c7047b761fcebda0701bceedd1982d06427593baee7f536110a876ba472cecdbb5c4b1886512aec1f0e4b19a0cd890895aacd938357ac346237173b2a17d145bae827c0e269b321886358e80a2108491aa22efc447a3e186c9d54808521c38f85024816ad1f150e03b5d4bd500ebe13108ea51e04866b2375ef9da453873924b7bd2699666c07bf23fd1eeeba98d17ccda13990414052edacc4ead1ae347c9600bf0ce4fe0179aacfeb681be2049f61739c27983092a2e14d9bec49194af40cabbaaf7cd93834f4eb97c0f2b2b43f3d4c36411dbf0504d1ce7ce02e530c5023dac03637253ae7616b223e6c06f39aeb8c1f5fa2f43e8156c31f4b3ed0b75ed0fc46a7bead497e94c0cc1b8ef56bb91ca425538321b8193cf1571c1f0782d6d1f2000cafadef58112a7ce38cd388caa0bcca2eaaa5573df0cb38c8b0061fdb39a25f9977d84c8a51e815d7f83ba2a54325546defcd13a4a123ea54103e087011a1dee3e6164cc8a4bbd5f213020043261ea01b0c4aab2f5dca21d61ab8649208d04d6eeeaa69c01804d73ba802d667df6a79dd687dd8e2bae5e5126be4eaec1c2b4ae61d5cee00bef173fbabaa2bd054cbfd723c484cf916bd98828c2687d35659aa86a46c6ecaff0686ac58b75844ea737bec955648be4ef5ebad34a872bc3acac2160e898ac33745a03103e9891e2edfdf4cc014a0a387ed49415c8bb95bf3c2c0d56cdfb7203d0d5e9d30e96bfc18d604fc3ed93a9da442b3d1a7017a68caa815a2d5a1680f615075fda154938403fb5504be0599dff633df875646e464b4200c19d9382605d9ec6dcf8667cf464e28275270fe374fe3b128d95c4d6d2d3094fec8348d1d88c9d0f98df32e3ca6b99063ee54d4a461fed1d776128fde919088b089cb4e9693583cd67dcf8b4c66fec5c1a78a1184846f1bec66f74ed1c5e0034d34c4b1dc96d64f7aea7a58855f556196efa9ea4ebc8e50edb7fae8361b883167b89afd003270bb2e3ae65b7c79c9f9f9065393a6183f5738d1b58d4ab2cb87ff1ef5ae9d54bd0a844903c77dfb3d5675a357768709fbc61905476735fb0e474383651617fd2a39ab11a0fb4b85766e6182e2a283e0942e414ddad3d1868534c098a09d73e19fa1f304204465f4b2fa2bf20e80dc3f8a49a43a627f0bb0d4788ecb0d414b715d39663be60817ad2d1f7746bd028887a6cfa4a12f4ed5f4160cff628bc58a02d7f7a8a880b0a4af18e39f66c69731a59d75fa4bc401a968342bc84d5a16f5f1f32a77fa5a981815e03b5a3123dfc56d728e1646fa102a19b49a1da4f1f4a067ba3ddd0b3fc8c6a920de0929b8b7c8d14ba99e0e4809d7f1ad004f3d4f3d7f705cdb588b138172ed040e24fb0d7d954384d8cce63ca7904894067b36d27cd1daa5c7d9f811d6bb6216eeb23a7f2f9d050252946571fe9cc62f081a86de205022b9c1d159e13c9c6e9f2260bb57965ce3dce2e2cfb494241cd7fae53b8031c6827a5c2f45f20ebe26d797d4612368f87cc50a313d9e8b627afcdc1fa0844924d9099af85be0150ec1e9df803d1990894eccd6ddfd1f95113261f957af05ac1dbd3f448592097c7f9ea8bd0df8709b928c2d045f18e390d373c82bbc99e2502da2a72b71b66f9ed764b0cdcbc1a4dabbdc2dedbbbc9e9827feafab7e2666dbef3bd5ce4847343d7039768fdc3e2cbfd1f23eca3bd20d4834949de043e4dab64c181af58ee3ed659f6b140686698dc9be6c9387c507a933462f6aa41935abcab51e392959cedf68f2cb47f81320a18772844c71202b8fcea0fd9c24c0b50aec27bd81c43908310b654873d93779b42b34a0472b9bbe15a04ca3547b0b5157314962c8ed5e211065bea53dab97b07d972b9c9269f60fbc33f5d6ff7638001709768520feacdc540096431f2c0a6400e5a372f347b4388e905276c6628e81084abb88d362dca6f8e6f6bcd9a09730b3a3c84374e950eee7d99edb5bdddd3aa27719242c9a821f5e0da373de766453c8227d4db3433a3eedad07e46867d4e6eb3547fd79046a3050d86896802ce8cffae5b2259ba0db6208dac1b3f2abdf7a5e00e8f743d02cf4a6814ee8a9c7265d74fcbc05ab4911e903b3bd479bfd3496c7ef9a9fd786b702b1925dcdac8cf8dc40ef100912be631e83c2e2acdb8608519ac87785d0bef0aa44a300c42cb08cbe8c82562f4d2917fbe78b46f9ca666cf3ca01acea03d8cabfe60f2f8d6b9af71db5c871ce33e07d15cb9b01b43208d9068047882cfd7af11d72193c58628078197a343688d1862a9a4f2b01bd1abb5d1602a6a4a1d020f7898b99c8e1548a4ea1e326b6607b528b2eda4417c72655b12747d87be3be89e01f4f20c61b62a5df45fdae0b09dd8c887bed04c2a406dc8483aef815499e515d71edb176c01337e1024991d7c4d9aa5d47c5519974d58ad89a2393de45dd6693e62d6d3570c4e842ac56fde57969278e02f09917c17c93eb32466c83ddcdae65a3595f4104823496b5c5ee79743e1f74b8074c880e74ad7ac8ed68ea71e73ccb085f961df854f04072b29064be6e19fece70118cdfdd635648d9ce7a147f59308ffe419460392e7788ec4fdc1c13ae311ff981072b55923f55f35d4ef8309c40ff3b791627257d8a27b9a0f8a5fe02b503f6b0939c13819e8086e8120247e7648f7f03493061155b43876cd1fc63b2e29ecb09be271d5252d9dbf00bca365e8e3489d5d5ab576b673dda8a4f015623d4261ef9aadee2ad07ea65f5b4e5312a52954eecd8fa39f89410037808a3638a07c5e8203471c517263f4105716c64218c68c6e17cb65a9628ac555d4389176fb742e46dd547db053331d5e10fd2711f3b6fc774f895ce39ff461edcbc596999e87c904d47a8c1b6615a880acfc0bf9e541edc5906942c4a242d37f80af6660a6bc44f133919ec75c5805a2562516a302d420a6dba9182ae8b0b4708fcece1b0960539142e1524f7acca992b785c5ce058f11208d45885ccd7f3badb95b2065c6f05e9b077c2934d0d73ee99f48fc83122d87441824cc7510d13d4e10c37973953961f3ed7db9f4cb5764790935ead613af4b3fa45f2e2d5d7988929b12f1f05cc4e0b9ea4531d39fbde7c180cdf1ca9bc380f59b07227c5836e27ff952088a99287ea786e3297404a0ed5a023074a84f95f02b9dce17231ffeb7a9642372fc67945a618a9fc3fe0fd841ffc68bb0e374660b15a01f8326dd48fb545303aa2e062f5658d8d4d2dc072a8e61b35e02f7a7862cc74292c1b78490aca4c994c9953ebb66a8cf9064398593805684a17b9543c1b3d63f05a5004eb271e000305788b82dff70cc4fa858293a36b17e0f7d6400f1a8a1866de632a48b34aebd93f90ae3d4d37b122e65f54afef859e20361b4c00e8f545a19b42bf3cdc003edd4141e1af9c153d568acd84a95bfa06d2bf5acf3731a69f02b39a20e130f6d036ce5ee21b9e266f6f6aef8b7ab5206c033a51ca418d26e5d3ddf69a423959fa9650cf4e5742a62e29eb7b6763caf5403a28a433b4206aabb5a8dd6bf13835445c920e58fbf64a5f011f1566b8adc25ba8a07038a4fbcfd64e07f2a707c9bc92c9d28c4de61838d4ff47cb935286214368ab4e00af71f7fc20d7ab029e4de93d5aa610b4d127d794c3bba08e2300f99dd4e5e3edfe59bc809a50dcfc8eca51ae2eeec8e10b929a1c4951b5481d07734da26399abb29dc8564d6ace82c285b4bf7256c9254b660471de14eef493567d4910005151a9ceb91c0a064c9d051b51b1d6ba85cd4329ba0d49d4c06dddcf4960a3a7995372489f1d2decdbff1e1dae1ea82a055424ba866d9b51a517110e115a7777077b9a172049a24378cc2f03c1e2cdaa47c7aeefe6c60beefc81241b62ea64cc79fe90f70927ae6620b61c0e7bdaaee09d1077a591dc325e261478cf6db0e9c22a49a561f90ad935c0b631942a325c0b7525b049bdd784bc5eb0d510fa1b545586389fa70c0426a9fa4b37c233754606a69d04b90b79fb66d05f580f6519e478c8cafe532f72c4884c1f228faec58584e323a2697ee3a995875e59261e74b9ec9ea137b5311233722647eca7465209714b0e5955b55de2512705dc3b2617ffc4eb64f72a204d7cbd25c2b47511a40e89f97998204993c4c9aaafa427560598b7ab4cfa23432b534f847a511d6d278fa602eff8dd12e30e26cf9c4ab4d7241709d53cf0427e061028e518771718bb00cab56e9b35c7acdc9eba0cdd53fdcd41b3695def6a2a8f5e0289adc62a93bd0637b28650e6dfc65919c856919d7d6602c16f7c5620e0d8ff221bdad3658de25e6d474b7590f413b875f28f453714fba040470956862487b7b2ee61bbcc05691027b041ef3329578428c180c30cd09c6d4291b046bb259378970b85f2d0c84f5f2c1f69cafd3b9c0d41bda9202aa3c30904effbb08cb01115d5adf0509f894d6e7ebabefbe90f95fd712dbb5f94588e1ed68b468ee8446c398de79c7e59efd50d67e998118a438ecb022f90deb5f8e604a6f0083832506ddb50acbede8f0b1de081437eb5cd309e2193d33f867dd113c19efed3816311e62ab43451e8a26a992fe1c74c0699dbdea13795d3b6973f5a82002f1ee161dd50cd44b7b2d761d6a8e655e6c6b97bb26d5e8cadef23b4a194df4917ec978c27122e08ec7e8134149c9178284072003d226bd7fa8e769b3847a3faaf1e6b25e81b36a6e2725e2ecb087062371ccce70d207ef06f9cca419ded5abb736e2542e914e7a6e5afe69a6c19761ba70e5f082b8f0fb7e002c60787bd19089ce134ae8aca03fd797801146c4f33a2ac167b8c701a54e4b04ed929399522b537ef7ffa3a74f6376fdddb0e76e63416fd5b5a0ff935da4b9055b5ddb9f4f7d646519ca2a3de9edbfb1beffd39f97985f955dd2a09beb25ab540d76e38629c741e08a6eee4878eab57e9245e44234a89f3c790f855a2559f388fe80cc1d174a880e4547fda5108d00f602624c04aa2e4da805b6dc027cefb4fd69f6d1fb653f725a8bb52689de52e6b806182e6d2c90c71d84bd86be6f85f92dc2301ef6a478634049875153512b4d7b3c9b9ed4341d377dad54a79a191c3a6117a3eba9814307efb6e308e1178e54e86875a930a9c368a5ef5f2e9a57e2f2779dc987c3a0265599de429ff02eaac05c9ac8d2ab30a181fafaec3059f112a4ef695892dc503a01d64836f8616208da1c67d48f259a6003b97201cffd6bed736be1550e6f528be186db754542ef6d2581dee67df113c8e928ae62efa40d0dea69e5df40f63452dac360b84994d19ec7eb0995278a56b08ec550e1d567281c58339277d259d967535d999560f29c0133cef5b2a6a21235f172a9c69a9934a0cafc3674208d58ec07657d87d3192e009399c060528a3de3860051174dc7db9b09879f2862c090fe55466967ea871bb1a889a08c2d981b9aaca9bbc1f1b70f48a7d42ca5b66d00e18577aebdda4f222ad70dd23b095e462513e29daa0e633ad8615f269ed056d3f803ae4180e5dab509483f8164a97bf0c31a94b22f2633a1073bd3636d265cd0b5faeb733b64aa8129933618f89c625d79a44457af1cc60ba2eb08de4bcd5e1e57f88f2db8eb07b3d7fb9d867f88de9462c292d29609b5b9c8f76647faabb892ddf87c0a5639755d19e1d5a0038c60bfff3b823d344aa23c9e0e2ef878d05a8e78c7be868630448b784af9178e3fb7b6dc65b7bd3f577145d53089829b38ce2ca935db321fcdf1cd7d042c624c4acbefbf25537b39ae76c93d1711dc4f0e3c3c39e8c529dbe9498c5ac8379bc4d7a3bc12fc256df353f1b715b13d7c57f597cfdeec0fd88ac370fab2e453df0469dcf27f35d852e583a9fdb140cf4953bdff73b5a35fa43878f7178402fdc7ef18912d011cc49e6bdb955ff7ca0b69c152f0d18e28511dbed145496b1cafac50ca955fef2d0b1dc2700d14c967de34da2b19796bb418aff16c8cbb4c145906b11e32b6ea2ba15414d954f67c2507ff262f837a40edbbadf512a0e921dbb85c760f9a25ff4ccf6956276409782609ae31616699ec348412a436f2af274e72392bf3f20fd6f0a45f355b7ccc3b9e516ef5c5cb7cc0c6431d0ee512ac6f1e030455f8316337297c8b9bbe45775ae6ab85ab7f3ce60fdde06e77b15982e6d7a596f0a584107f6bdb7eb5f2e9b173bbbd960baa18a402213dfcf1159077223ecb16c67de792d6d85d33b19df6646cd13d154a7d9e1261b8d65127a8a9a7703264db182ec0a937392ee34d3321b29c418c4d177a5ce626f993dd321091edfe60408dd10d96ee8da6e881658a55bd18fe3434d9c1adfbc2132ba0485662a9a10f42250077ca725341bd6666e0b44e1b3d1b253c6e34c3b862543603ac2c5586b1e212cbf63d33154434c3c77d2ff12dc8c278f38d6e4a9dc64893c9af7807668b4369b2da71120c1d3241669844143a47e1cedc5cfd00475efe5db010392aa4002bfaef275a9522004f4095f19a3dac7b3259f6bc6a21cb062a36cccc64d0831f70d1b1d6949c8195a84b3a89c863fc0c3585af719c6eda7f14458fecae7a53e0ed17fa6be58962c09555f4308a8dcc0167334b40e635899532c691fcecc547c3096057742a494d03cb7db0774dafc83c7526512efc0d359a48c42314cfb2f2a621cda09edd86f601f3b2653a4d83f1912e331b8740a007e157078186c9d8efea8a51be963284804cff0e346d7dfa92006c3fd5ae04e874c86b37a4c838f19b412aa8344879aa898a27e48f4a63d8fc92d7fb9ba35cee4f39b296e1a636a636c7603025a16ff7a1522ef0d1aee4989da44a13ce9e01084c0a9b6fe0b04f2060652985735b2e581e0db3122436ee7d0b38faaed02e00815579be06ddc13849efcaf7982beaf4b87f77c6244d8a12787168b7e07cbe28a20bb65caa0beeb2b49356cbc66105b64e54b7f7626803f29b6a208e0cfd513253e22ae013165b724bb5defc9b1b7d8e1ef203dffdf6f9407a5d278cea08a98e5d59e293c8cab3e8a1a13480ded7a5c33ac7a5d656f6408e40f322c39be58d067cc7f73fd7f8b3234e07c2f819b9147076593014fc41cfe30131c3132d6eb0d685eb017fe34a3aa32be2c0c1ac8851cb8f5f4855a762788c49929db20f3312ac36f46176f16adc417ac27a218b3722c7e6150339d677ac7acc7238f33085d8c3c3f64dd24bd6ac9df6f63410779bfc0c988c4b642add72e65738558fd00ed86c4227666f5ed09ce412ce6a519b695260b6bc9868fcaa23a9a2cc79c0a1c8c2ab19934b61efb3faf654f3d57ad18879b5377380fbc4363f94dca7cb7a13e736ae1f10a7b7c4626e447dbe59a6b28165fa4113e105e337bbc1fcc870115e7459a058c1846e918a6debda75c5858cdf4296e1c7e3a9d4a0168f58c135df31e83b5f548ee1ca1ea95f1b82e3a0ac023863304c3bf45ba0181d4f440ddb05d56f2d24f3da6ee9504118f3b612162f194ad26d9fd47bf32bff59235b32a06a0fd49da1864ec14880ed168c6daa0c571eeecb74eebb24bca24d68e4336e5616d2eaf785696c248a9c82a73d4ee214ced93d860da0bc8a4e0783badb94694e15214a1766a14368d06b2d75a4d435d0bb2f9313582ec1a52931ab2a8f9d4a28f15efc34dc0f4d11145a6480358daa642014e2a0afbc753df3c5f37a9e42e716604679432218dbfefbe10685a08360ac202fb9569f86411e3d76094ea08ebb323d4a330f8b239371dd95b0c155a0b74ab299a629c186919c577851907385bd9b0db4a2844698cf27fec3d437a74c3a84d0c2e7dc7f429dc5fa4f2f7c1abb476e8338204f14308cc95fc8766e97684b74244f81dfa281c1dccaccbef1046436129f946f51d7e49798dea0f62c839f5d71237d65b993d2de173793c9f52ce5371cee484b8f68af02bdb408b844564109f3286261215bc50fa34a8cb87e9648a9ebc109e4c09fba59b85a7eb6b2be567955939931344c1e511e4fe6f5592b03ad8945be4675750944c07ef85c7c39fd7cbd9590b71edddde14f4a7442eff67f3e61c439c07786a690e3389eb07a849461dd5f3aedf7e548b25c5f49ab8d8dbb2ba57f6f11dff7de075c6c22ebe26524408685c65a8733793afe4d4219e3af1506f6c105a3ea756e5380152c8bd881b1f47a46534868db4763bf594b91477a93b836f6e9edd9cb618e863c13e1f93b2833b5fdb1de4a64125ebac5ec7a83db113b942a9ba107df6246909dd637bbd9c8f04f8cfa737ea20dfecf2d82475cbad6ee94177febd55b6f5529b1c5966ab464edc0f85b48e7ab0d07f9d63d52457b45c03735a548445ac2b11cbd6cf36733da08c28d1062eebce04d968f2c8150f351717fdcb81e0dafe4e5c2760b3fa1b2d11f672d27ddd46353f7974fe05e1d99772a91b819378744b96cfdfe24264f9715b17779c41f4c4d2706dbeeb64bc49e7c481eaa50e57ea76500e8ac0c628cfff63265ec69dd2a39bac3f4fa22ed1e429fbd3aab27cd149b382999ddc78559cb95f1a38f6e20b1976adf26eb9d0a6640d678328f1b1d6d46920f90a2199d62aae68173f9b808d2dc5c35f9469a00918eeba95bce7bf357fadf4527cf39b48b6260c43ae42eeed3603519e5d226695359f98550615e5b6cfdbbd64bba926bcbd20321fcb01916d30c6402e93ab310af38a7db867511181a3a4b2ba0e4e47ed67cb9d2089c78bbe3f5b2fabca4db4ad2e251858dec064628eb7e7fbfb26674f8675a14596c176e65102dfe745b8e5ca3a6c8d4867fa405d14800871c3b30e214fa687f41053bcb00aad67e6a028f19723d2143cf8e4a651b23057f7e646c9279be4d9854ce915de57187f5d8529ae3fb6ed37d92caec4d8c8a8734825ad3650cfe81692169f3f58bda2eeb1a89f900b07b49cf0a3e77bf663fa208378a6c659cc56d817d7f5ea8e781e78cee7fa6d57df47d12ed680e709973e21b65071a5beae66468d88eacd6e72a8d2ecd8aba6e13fd8d3b44e4c7b5cd9b377587ba2ccbb6460e9b43d58b388ff67967acbdcb39dd99db199d23355a238017f424f495fd557248d2fcc6fa0e7676f6b76ec04eee12114b96da4b0da0e1f4e485b57b3a980d549fb35a84ecdce7c570b7403c083fb51f2371c87f3fbb85103bd86fa265a3567403b0c84d1f91a0e74afebad4744c6596f7efac0977deafb0395ee1b97f4e6b4910baa9b585bb408e2c0699e3230583696592f661dd9d43fba81f20c9779b0b5612e3f9d3b7f5cc8daba44401af8fa906bf26322fd2ab2fdb027b15208061f90d88a480c8a7d0f4adbb5436c0f2e0b753caa4f7c04f377891800e48b782cda62720439a5ea35eef28542f95b950d19266638749178fab54f7a30f5435f578938c5b10cf8023d2a002fcf61133431a1f70d6450c170759f880d26dbe126e4d6ef73bcaccbb642b8a124d2ee33a851f140ed745eec20183966d67de667f44558343c71e88c4d1e75bc1686b2b6bb8333db0c3daf6a7d6f4fe906548a09413b207028c727defe88babebbd6131b12db4d9c0e6eadb7c4ebff96136e2cba6a5efa871628f5261273fb8c308c3b0058e94d8e877b77df0d77d90a7c6aed93e8b4d613ee4dd57aafcd8c03dd408dd4c945364ba1b20721c4d79a51678da6e7ae7ba01e2aa4158e0ef995770a1bfb87926c2a6edb74f191fd99c59a6e32c583ab5d30a324bd1b04ea4340bfed72c3590ca3fd3e4e83fb84c54fe9cc84925f050b0c3e63a42b17dca837ecac03f0b796209e63f8565af309b37686030e2d68b60a1c8d91ef67a73d636063aa4a9afc4ecc922026394a3ae752103dc2b47c3eea5003ef8c4538435e961efa77e220d92f2afe26348adc6a27004de6e37a835e5e9871a2ea2bd93ef7599ce661150d01c4534e849a92e69afb11fdb09dac6d1bf7194404ba3942024ad2b128e5d8596f3478b0c510fd49a5e3fa8412b89d190c3f537beb2f49af3a4fc271816bbbf99f3139666296a0e3270eabaf7271a66b58d2660ebb7fa195da14d09a543d6b59d8f7eaeec351dc90236130377f0134c7cbc9ef6a251f2e27d5edd4402361748c64c05862a102e1e0fb95cc219213b77583416c6ba417921ab1b2a4d02aab8b236153972342b771a885b22b4bc7cd7f993334d21f688bb639133887a2ca9cc393cb2503092c7d7355b668b9bf6bb4da063f1dd295bdddfa1c82c43e56535077eda516536584ad958c647557675258fec8ec1d337c7f0e9bdc8f53c84f12d67e4f0e309607b36001aaddf2daac833f7ddaae3e0c91179a2741abcf1c6e00853cdeed3af13a41ee1aa91a864c1ac9733c0e5367ded071b1fca44d178200726b563456076618f4c4b199ab2bf0325c823b33f88631fbc8f49e2a47f663a1a4285defaf28c44cd156511367c614416581410423f05e958f48895c541050bfe6b365f5e1a3db5b91aade34eb1ae2427343f1e3a5c6893932a730890f3aaa0ac7509cef6e95342a43c9bea7601234043af6fe2b155427e9f39285f5257f2bb6c2f55473556317cbaec029f0c8a95cad299b24def02f19d378c0e2d19e63a278ed792666e9ae9bf99e6b8f81f1d12e0bbfb212859090ba287b4f8aa286860be21de6c72e1fa376f00bb060958deee13f5051f1644f81724f69153b7078f1cd8f1cdfbe2f4fa59be6884176289312e6ff957117afb8c60784dac18473c1fb8bdd11ec50403e6fb117858a028440b4f1a5862fe62237aa1c8fc67c808a2e749f1e7aa7c9f67aa7c3213c831bcde9691f98959de89f53fa3b895d2612a1ccad04aaf2927475b97af5dfad0cad511449449023e31bb960257c168d0987021dda4bc666596e696fef0033dc0b93d0dfc6beeda912ac0084aa719bb628cbc9353993479954843b840af237e8c73f80c38969b78a4e368964e51aff72c1aa8503ac48b33213ba2f3859a7016214824aa51787dd1e0b971fefec75e5cfed968befe80532b36302195d2ea32137044adf7ebf2fe8df4f7dcbd704382277944fb9a6a030b192355d6000dd963290ad4813f076c4afe0520d068edf71b7b1b0e4de821d6ea9060f5b18b714c0fd7fc62318ed2fc81f6eb110c11e68c204fb1b987a041461f67b5a06c8198ccceffe60de9b10850300aa9db9c0d32a7af11f6cc75d4ef7918a589ab9cb80266e45b236d3ad729caa11129aba1b8b0276d55cfc7976739b020da492a9a9a248c9854e12d10320d75bf4d46fa4c2a94b9e28d146b91548cf752e4370f6380bcd1bac4e4ecc2c1a668de6c3710a77f5698e054cff4a1c054b53823b1b3977d7eaf9cd3d23693e3784a28a62d6d5c10db637089579934253cd92db5077ed473ee4a02962fd974d40526d9a37c6ee3dbc6d271d2595eccbdd2ad53103bbb5cdc702bc0c54b60e67dae20d95fc2c913fec63dc8c1988c6d6807b34f240e3aca3462ef90f1629ea923c24650752f1a5b392b095b1c623c1703dcb422ae3c80cf1bfc374802d20d772c219b31729eaf2faad749b25256976babea1676b263f4a766130b8da3e2fd384f3caca250b5f56bafea2ce9a33e1c8083a031245122701af738e01ab31efb51d8e500076777bb784c0905974e3d6aa4cab7488a5cb608af3fee78c108eeb0fdfd6d73218ca0073b32992cb204f3c5abd92d6d52de41b6d33077a47836ed83054320dc83d6e064ddd10ca915ee3ab5d3b6e0736c5c067cd2908acec21962846eadd1f72392abf7266166f9ef389dc1f40d4494bc2128c0427fc697f373e43a8552746042547856b4ee4fa7613eccffaeee5651bd0905843bba2caf86ed32c8faaf994e52ab1fa9176fdfdc515212366ece617f42b8b8ca91eddcc7516559bfdb2c39dbecdd301adf01ae5ca0cb51e5e42df14146d19233cf4daceec9114cf448814b36b41a277ad907f9f04acf778b1bd9a91bcf4d0ce8b7659c636cc8fa9fb1dfdca0cc15f686b695cab4281f2bc3b4cc7e947c64cf39e00e2e52608fe8207b6a21434bcd72eca032435e381133556f0ae72409ab3d3ad10ac0ab2ff63be368a45538e93940aaff100bb28e4a89ed41dbb774ce26f9e8369c0e26c937b04552a633e671c9a83e368e0925b674c5f683a78986bc9a06fec4d74d0f62f3f0efb1ae161ac93a19483a39167c55b24750c87d63b0d432986bc56446ca245a5003dcbaf3cba78af71ba305cb86af12386495efd97d2a4eaf394afca4f5943ffd6166d1e3d4ead93741bfba6bafc35970c7ecee6c06079bd5249a984d3922cc31414c2cf6c8c62b25feb81b0034c6e36370480a49041bf0b0ddff6cef4e2611006aff3e9972ac53679a1fb855a85637a10e70ed14ff2613fa08e31a93df082d7154a14eb2a6200d0a5fc159aecb202f3a2cda349c1a14429876cc5c507979c3917ae786f3488088c467f31aad33404621e46419a66632be312f464d3e861bf025de084f7c87cfd2bfc24c4c07595289d1e6d1ca7f9952848868ea11dbe6ab13449bc207eadb7a667eff7e44a4644e1c3f72d9da3a62009fb845d29bb312ebbdd66a37cc7318664b7491e8d2b02c9ac7d3f6dd52707d4f899e669d115de86f35f935549f4465d622d4cb8fcfa7456dc79ba6075cce4d3be19c937cf4cdd503ca36e71a72ced87166de1612fac60b6f44deb852c8058c1fff7dfbf06873ecf5c87bb0539033a9fd03a943cc8fe71dbd695828faa25f6751678352570f08b26ce891c1a1681a389d431ad28cb6090451b40e633c65ed7a97cfa2063f38bd84f8487c6cbb36ccb5a404af46150a46e45417a328a3962bb720e340a3885968182b017fdbb8ceb10b75de2791b19814c7ee57497f48a16a180a2ab33bdd784cecde73434dd20a5eb57a32ae2e3f413d8466fce540c2eae3a4ea1be7b7505e97f339d496bb4f6753268b11af2be2c60516f3d2b1e2d46da6546c7772ac28f2868cc198b4ea80e714c1047b2e025d6a189b30184822b6985aeb49d1e7023ec1c1627913c26b5f414c3d771ba0d8cb4ad7404df9c618ea83d81f951ffb431b3a0cb03a8848397de442731a310ec0b441098c898b65cace625fe67ad2ea27011239f3d018d83489136817e7733f8559f25a728483db087e3e25b0c15472fb0760297063bbe1e117b0899f7b3041574ba77ae7230205b2d0d9a56e1ac922a28b1b9b415bcabcb96d2279b8da3f6f0c292a41bfecace3ae82282c656d41bc6b593e0c4e07679c18895b3a02db54d343df8b0f5617c74a25b594d10c429c3d43f848585f03bb5dde125070c2c2bcfedc08b87ee36de1ac9031f930be87dfdb32a234ac8e87c991127eb68ca8fbfe0bb2cf963b2a65b39d5cfc2e1bebef90e68815ca6b898c5abe0a2bd207cc0460a5f551b2813bfa3e55daa4d8755ca21d04a88628aa6e34aba1b9d7a3f3533405226d2b78a664aac043a074af4591ddbbfb0d721a62154df854bbf6dd6e64a0a9a2d46636ad50c28425adfc6c70d7aa25d319e2165ba774c534fbdc0840aef5c26ad2978d09501d04e10ecdb7ed8832bb86e8062bad0d53762fbdbc3e848505fcc57c52878b121be9191171fe8d73e24dfdcd338df6e9093f117383c42d31bb3630c507565698acca7a4f31b2314f34109c0098e68381e98c489c1765449b4d80b7e0d0410c27eb9aae967826da68e49373c50927c280ad76195870b8d048a76222603505d8f25af9b419043304874f59159b6192799fb4e184c15073f2c913d9629447ca8eebfee028ef65bf9b340a00f30178fec7b1a07522cfe99a95453dd59d021fe77e78d92fc678c00c8d346448558e4098d3e3b78838b7ac16737a14dd8b7ebe71b1a07d5cd7cdee5beca13107fdb13799b101f39ed2456f96c249ed66931d85174e3767a9e41e3596094fb48f50270a1d47c42bfc077b4a9821a4d6066f7b200492b9ea57bd62a9472a93c0c9dea7f4629319f12e1062291dbc0ac35eeb92a69d18ed293a62d782ee4cf5e7be008e0b4abe6c9d83e2148eb642d46dcd8b337f8d38532adf7b0ea24aab99f168de01aecbc6d24199d1a4454ec0d1ef4fd43c2093de50d810c26bf2c261d066268a267124b8334ab2097c612cc5ff9eb2955c0ef943a17373ab27b3f0f07af64bfd131662fc40d138375e97d611eea395356aadc0efbce0fc80234c3b948ea646c809c9f0e946173d8b54f35e0440c22039aef26680e8cffc484ac3391f9de64b373eca657bf291b8de023d88fbf7170739b1ab35a0dcd4f8617ac7ae013f53427eb39c54afa3687989293cf1746a830be69f42d08cfcf17de5b93a9d79985e85d360da282ef291f23695f65f5f9fad7db4ff8cb5b06aca3809ea96eb9609115aca01440fce24bba1d21a11ce7c58a966ed621be9b37750ec3b0d212aa6395798535e56ed46c2fd325e39155a2ee773e08f341697534a63392a6b179bcbe656b6053a7af61cb85f62d206a215833575fc31ed1f60b6ed440262f371bc22b0ca23da5b12e19aa14a2a2e12351f364e4652933f2c7f26fdc8ab2652d5101f43fd37f1d43cf0266d019e6655b76fe8c01777003e0a9b611db00cefa668d0d4dfc69fa9fdde2a5c6ecb8ed91a64229c1574394ede0f32d3d0bb661309617a8e3c67f906932d123dd9fd8c7ca981d2d3d030fd587283ad386620f2601139e9c13cc8bb213b14dd0bbe63f32e47a9bcbf059eaad4cafdb1aeee28eabd1db1bb5ab1875fe046245d2afa3af536b3b8e0ff40fc00b4463a5a75eb9fb4f3df3fce44c5f5e72aae2363c17c7f7922d0948b2cf9ded15d3d2988156e90392944da1d13d06dc842ea54cf5cb9e0b77758909c329efa83e93e2d4af025e918740d4aef2f75ed6121f0e21b9fcbeb17fc407854c188e4767c73b701236feee3b25699e2c2cded44f256c326bc174f45d27056c34fca261f31731723cfdac79a619d8d23b64eb2f6aaa1d1c7d7987386e13e0c313cdaa42db2e1d4261b23ff80d733642bbb5f3e0db7cc23caf313720568d38d917e282c91f0fe5e6d3013f87130c4aa9b8f19cefa3612296f862848e3c55934a7d99b86f7f16d302c92abb98c3624b2eabd36dca06376a0c227637cf5b5bb1e3e36cbab7fa81864f2ecdda1f1f5b6a813f6b44ad6e1a6a071bb14f140204d75018db1d1ffcbc0c484a5ee001fed0aedb5a345dfd83a79976a6f6800ce32301aafd564fc892d8b6a7e7a51faba81b5284b9a651f2fd7b007b2d9769143b4e94124abb5f9100ce312c47ccbcf4b2d5864549b9646dfff220a3fda75eebc0bac0bf6d79f749aa449f1578809dad8deb0e95efa6836c530e9f554c5499d8c868fd7509633adf4669a7e1bf2f4d629888ff9ee932158a8524f2db837a79dd4e73859694cdaa6249fe8de5dc94ce1357dca5c1e69a27212294d5302297b955a3d6c6eee8b2a7a91bbbc50f34f4ce9406a2310424a0b8768f6ce3d97ce100324d65585cbcc93a5a34967e0361a7d5a2f8918c22d7fe43801bea626783cd1e759a462d33b857164ffb49b805dca13422029574f7c0d04d8898e24df1376a0328c69acb795be5d60721638a4585d89e73d33bd70e5fb381dc438f180323bf2583b6590b8ff7ab4bbaa147b3e01e52e50211c6df903c18c8730a5b846c63d4b3ba5637d615698cdadf9596f78992a1060e2d5fdd383900e6552efc09f05e1145ef492f1b812d5519d0c12db89c5dd8c9d42c5cd4b373a91b16ece3d677063a9b3fc056afeba239ed2990fb4e023b2987c2b9a6b5690c4d1faf8788c8af801859c4a94b25f0914ec6fa33d96fc31c884ac8af5feca1109f5a0090826b01ac9756b620df498b817e2eabf06a15116ef1ac894d951ffaf8e8a7b1ac03af641431a899c063edd003e834e1233005ee13c3c81a9e31941677168d304cb45077be54ec0d7ee77281aac070905e089d18d46619178bc4ae9a381cf77bd3c75c0b75bc044737fa70865239ba07f2f0dfeb1c72dc2d5f12cd333a22401558fe332c7b6e9c73a172ed4f82e11271dc10e777e127fb6f7baab5a5e96e5d76a390a66dd2f2537500fcb0dbd2e5a569ca5518c7c34511808e79e250c16c2b64924fe890df3036663184489319ff39ba6e0d471089a9a7976c0549c7ec2f21abb07dab79bf9b2d564bf3200998f0346b4b1d35a8ea0c315e79bc086a1f0fdbeff0b82e573cf991f6dbfab63d8d067c50d3d4f42a2c0d3dcc83fdb9afe2288c403db3a792bcf557906e22ed9d27b3f383f571559984c28d02c8ca73cb0d14a709acfa22a00f0cddda802128c60cfb992178bb45476801446b7d8454f3c77387c767437eee80cb2076a1a8a12f83641972178683aaedf8f6e57616985f9f51705ed32d341274aaff4f1d12364019443defde6bcfb387c3bb68de0c3950b517ea5b5ec52b06805b82bf3416a74cd2dc715cb9b9b36666e188690b3097a365c709d2aebe1c423d89b3799b0b44eccc68c46a00d88acd0dcc28c9ac8e5a8d2235b0ecb4c12daa416e4ac5490b57a9f3375e6e15f598c2792fb967c04c3051ec4974b4d42ad50e43d772ac0b45bd359a0e781264e2b67b7242c6ef4e0ca12664956aebdff2cf10b96babe1161c43dc977719f425d4ce129a62b2936cfa8e744da45609f04c6e1fa9d0ab801f168681ad4ed83c6ad3dd6c6c5856a03c3d727fea68324794eb3719a4ef68f735c35087cf010f081b79233d601fa67c52d94be8aa0bb4dc5d582edd8d6c931cc98cbdc88095f054aa1327e50e2cc0eb1756bcbf8fa96ed23e91aedba91496ca1b70ce204aa4e89d2f8485bb436642c003320c75fb71df983f7330280b5dd2ef1b4d4db3b05e44e62864eeeb4fab0c0df2916fa17fb5134db90927f6b30f8632250bbe7fc1fd25b423d87d8dd3cb885669d20f9846eeafbc212e0afa7b3fc7d5dcff4e7ca165914338108a6d494f216840e6829a678dfd5d43c8fe43415392cb71bce34612fcabb24ba278e702976f4059936eb46a9c879e454ec06dc1036eeecb8f322fcd30b74dc2098c4e6d94a55c205815bb69eef1c01332b92e872621f4ad3cfefbc45a430ba3563594c95944963462142566a8de4a81b5e5d1cdc6138d1cac819533db1782f5e0f451d9315a2c7eb7d03df6271b6e8b79c531d6efffa6fd2f20ce7b25d104e4ab7c539b19a8faed00617d4ab5c3c5d30367d0e680d8eadce4240075078ac2b2044065b7d14e2fb6921149943b492ec02e0f001d9e700010f4c3152495c0e3a484abdefe1b94d68ca9067de6da6547d3ad99e98bc3430490730d97b4f358f07bc82a06101daf06759aa9e45cc588dc9e1176b8f97446562de2148e4113c28af1d7872a50edbcc3c88cac4f5915185d1d9aa1c6c3309154ee8e3206f6633b0b1a231246307d078182cd9c794c2778f0b2fe2800e9dab3f2fc0eb3c7c39f950a49b4573aba189e906e9bebd97969ce96ad5ca7417a24e972667c14d00b92a55cf8e6709d692a78b9b22079c80ba8c036caeb2e2521781ccc9bc2f5d03c480ff726993f02d4d71a45a2a3c6ce64c8fbfb09b0c8a9aaf6ebf932ea50277a0df66a4401bf7605d133baa6a5ac271a5796a1a0d9fa458e7d5f5a0c6b533c6e21eec87c6ee9fbd9baf4488aa1ea8de09a4e3e11d04fe000fb2b1fdcb15663936d92a3af3653b937168039ba2426f4b31d28eec84387028b9abe11e76e4080a69478f1e8d725f2232999fae3f55c4ba1e439041753df3a1e553613971a39de9f5545f475b8292e61cb83a1a28baca0647d22ed878dd457b0a06e44769819de6f0cac35c66b50590d2ebd3938cc5b937c69ab4b3f0863d8e048693bb1bd9ba2e9f883b3da81777001cabcc41900cf0173c39ad208dc5fac0547deb5cced305a8f8772d789e73169268e6d64e83edbb5f0d9d5a456fd78ff0f94d2323ba2a2200164ef48b5dee2c4554783fa7653c8d7fc28b136718272a07f20a13232b5d7f5d426c1a52c02d63b66b956891fa14ae27d86b76fbeea8050f8d803a42a04c685d817dfbb29badb715d9bb953a223867b53a0656ed38cc2d3a4bf782859bcd4389c828b040edfc71f4b5688157f289369145975993418f6d7b9e3d7f580cd6b2ee63a162c9e775743fdeabc97fe02dfb20d3fea588766a357feafb0a96afd1a32027ff19326c181a7e22f730e68a4a392600754b2c10118ab8e171631be052495968cc2aad0c69ea3b0355c875a0e913dfb6a5f52b8e1df5738499ab56f81fa4b532c1cc86df83f31962af5503755f37c6fbba6b4802e6dc1ffd10d2c590b6cb6587b30e14ddb08a4e846ca497b9a3e899dd0a444d65ef23b1a723953e6a4a6f1e1f0a6e19b418c5a87e184657bbb46e0b5551505b4958fccf8c081eed6397394c3f06b82926d6c699bdde54cf8817eb5308256ed720242fe4225a6503762ab5f6ea966b2ee37b85476f331a62e566f2d226f21466d516587c38013631d193b4a8255c5192be489d979f3ab6cc0e70b6282be33215aa518447c63ff0c3d444d1d67af2f4b9fbec2830e20199528872b4a748f36333597f6d4b1d6967ffb86fa5da46624b1c0b3ca6a19b97f715b02a44bafc8e938df066555090a74c5929b0aac456bfcfac8c0b30e7df5c7b47fed9493c0596e8ec662dbdd7eb6467fb100ab1b369e47683544b9ab3c72868ecfaaee90877ea7c96a164a6c4512500ae09f60d6239492f248c024fe794aee927351ada5e78b77d7531e974059d55dafbb621aecff47c105a0a7fcdf4a5f7e992c5a4d443928bfe214d6a51560618cc4d9843d209d3c8c6fd3c57035ba76070c0acee971a811d1a7ab8da99878bebe628e9698607e00d1305d3e353b93370883af5364330ec42016215d1365b8c0673facd1e61f7275b533730dd0a0ab3444ce63b9ce04205b54c9d4f503c635a26cdeecce1f802f7787299e55da44b7e48d7e5a20b1ddd33990408b63fed3135ac679bf15ef63f3183832a4d6d40401e29de12aeb0f114344e6a9534cdb7e7336e2a08332e72a0acdea60a2ff149d8b4922c8f10c8317d7ce1d86c2d29e6f9fe8a02d9878b83a99aa2ea4d909a5910dba61411728db540b17244a43e57971bbe9ce81df0903c7fc9ebd52ef4b1251c214729e0d5b9fee80f03ea498721c194fd6105d4578e9dca023d710af6ab979802fa3abaa58445c6f132129c419634a0d3a2e6b8a26b9e7e261e1e2ed27bd43e28986de59b6636a37530d126a5ca58d4925f684dd2a6b4024df403f60de3a882dcd55b5b087859a6016a330e99e9c4b193a3f116bf247b357c0fe3dff49dc9f5ece2969bee8c09824bb13559ae9e4cb18ac1f40ba53f1adeaf6c6f6ef4eebc9dce6b03cf8a54d97511acfd8c8184a2ca789309f440d62be4338441b25bd07ed4be21377108dfab9a3ad006d99bf95135b163fafa16b95ad01828cad6791b4e82d7ac7cdd4c31fa69181c070c24305fe4d6ef148b6255c52ec5efbf89519e265cf54d08a05dfcd823bc5fb702063a0675552c471383a2069bfa5b60a4e283f627fe15eed5db5f2b23d2594696665069464a47c5524c0dbcb9d1eb1dcad3f01e1c01269d3b3cd1879c6b400ed59d5504993b5da7f28396816e6fa33bd50be6f3df70fa1912f9eee2984882062a0d6c8635fd3400ab78bb1fb19b8584d8b472e1e32994d7cee4ed49afd443670f6952a11a327d790986be992cc816d6aba85131d0eccd2d7c86f4f8dd9669b1278213a5c51335e208eb5a038d0de75dbd1a6715748ad7d358e6a39ae476db12fbb7caeb3686a4ddc2c031de7fbf06302f2ee1753d6051b3a69ade427829c9707a0d202a6a909e2036aa1ea25ce58bec7d2ce44badbda186f6158a675d083274020aae3fd1689ac391d9e070d5cfceff1c4c9476d1c5d6da65960ad08b3d796b658284cae7fb9498aa877e8427995bb64358efb5e0fe687daff498839ddcf55f2d5f3eaec222ee406b81aaecd7d8c888a8d7f8bfb5d889ff6f717f67ee01fe1ce326cdf12efd57593461644eb3439e147208216202e5d4dc55d677468caccd47aa4cc980fc60c4b5d38dd995e89d5f9a5e428b4a21dd454135912b1be976bdf2931ca8ead9dac979ebe2757ec915fed451b47d265a88242155908a49ab67cdb7babbcc84e64b150a7f262560c6e1dccb5f1bef18fe9322bc436d586f5838d8af1017809ffb86175fc79b0e7be53a4339a600270396e30122bde5257f4dbcc96780e905dafe08d21684470f6f0e4a00df08d7ea3025d75ff0571c79d18c7528e991dc7af20b452fe16ba359d0d3c8f028126e5e3382a7034e0f53abf382b263aa7e6cedae9cbca1419a46ce9db6fb794cb9c62419b83fd790bc21700ab3fcf065487ca664ef6dccd7d8c7de64b3736fc389f36ca286d06e803d96346a62338130511afa9131dc52c340f11a0581fe4d4334502e328ddef0fdbe2d94f2c6ed7985882ed5a5c3ba6f0b3da2ddaf50d3864346851e698db55591fd085333fa915f94e1f958367427f0c7a0234deecf797f916069f47356444ebfeaa4b56cfc9719bb929c989d5ca3e9cce8383938ced8783e6bca45ed6b818856244fe6e4bea7085dcdf786d80003d7f45f1f861f8118b42a71efeb921dc45c911a073f92ffadec532a9278184e1433b21f63d8e876df95203cc8716eb72fa61b0b4e5adf718b4e4f96bd5a07772e0de49db06350a22168d0e4851e24c98fae1fbc492cb80cfa607a35f9d2e1cf373e3afc2aaf28cf98993d691c427a5680520652decf8b8a8d00676a49a157430b2ced643d31e52cc2ae9a01bdfd22fd8d54eea1330b0c4169fe6bdbfb432d58c71284d5a34708c2b316d1f6e5ff9f0b7001fc82aa71c7860d5c3e0aec2bbdc22088b260af952fe56a19342430ff46978278f11d2bb6e2b92aff7f1ab0587729d3277eaf5ff050541d030a4c79081ab7e0010c37439bc13e07114a3e3219c10fe0543e8ac19693383a69b9bfe3202e13d0f0c4e526cdc820b2dce7f13d08d378cfcfd0b0f5ddef02884af874113623abab7e5ed58018f49e23d1ff9260e1c9b8d2554affb108bae848ed700d71e741edbe9a10c6f7922bc2b380f6cefd09a4146f02f7b3e2cd75c0fd043de1a53002ea897615de5f5174853706dddc7378ddaa6b1cbb0202f2b37ea2506c48bc70bf386160bf8f1a522392e7d7deaf994f355eeb4b40a9b4ad734107d6966649f363f1c22e98d7a38d42ba4a27625f60023af96989db0a012528c2478241f20ea296d6c8c116cfd02664791fb2936abbbbffbce7e592dbf9982024b02b8c53bdc29a03a7865841c64d6e14fa5be38602281fadd5562194cc8f4317e9b9a5eb13e56b8c36cb054e9fef007c4647f05c5226e67a3e1c7648f96038cca687a24959470988f172d7dbf0db2990e93d5da9849260a7f586ab779e1ae1040dcbe377119b78fa0a4cc0d1bf2dd296003ff5e84564adde0cd0e691ec80e26821f0652e41eed4b34fc0d1abc78a13b1867fb7d8a90d5f56c3daf3420180e3fed196e58513692a0fc2254a1b975af2c7cf6fedf696929c050e46eac1658e213082e1c93ae494ab8238f9356a7ab9aa682602f32c93a515a59eb9af644531bf84cebd5996cc9007d786a9293811684e2de448af760369661aac20c1a10babec5128403f44a0840df854401651b9dc66bbbef40b6aa8faffef2c82fab63667fa3fa60076d347a11d8b1b7b87b00515fe5e1877ba459a5e768b3e53e47ef01f5f82c659a7ec94c456e8d19e949bd66997878990fb0c55dcfcac122d57cbb4367e646c22ec8f60823e19beebf4e8ae72efc40440a5a4e3e925517f9fd61645557538fb1fb382afe98e33cca57ebb896a393d249ac86e968eadbf19e20cb5f1ddb06a41fb57eb2d786242acf8472ed1ff848941183befdccf0c3d528599a4def142f524b68c1ba1a9b91ada6651d436581ad51a4d929e0b6c7af858653d190d12e791e9838ef30d3e003a9d4f407e93bf0a4d15d85d003f98b53350021b3794e474fa4af9f9be416600f1bcec235915f2ccc3bfa043a9532dfeee5f130de466e94220aec33480b34f01df97027cc9c8622f3824341d915209f69dba056ad27f41cbc7fd0788ad0014b45298fbf9cb542ef4b6cf052278c08112b7707e6b8a927a7824cc2031ffa4a781b7d31b5de57ed69afab7829591c980f0ab4392395de2a2c6a487994ca3de07a37593cd0e66dcd7eee9669801a4387d987935a6c8f4ca8fb25647f9a58d6abd0a3d9b8ba0804538ac51c65d21aa503dfb0b496e27b551ae1507f698293783b796575fb5e9522437b3fc77cc1c64b5c8c8436bc3bf67354ddaf40adec308da34307a61276c756ec5ec5d2e2aa33a433bd95910dc485cd68a8eb119dc7f7f7a8789ad3157f4d9586f3eeebd3be61bc5f6eb1f2d0bc35b530818493a9445b7330f9fbd2af808e07748a40a60769ae67d26366de62b3665ca936f0dd0cfb95c57119aa1e26d86f872e15d545e680fa0c910fc2b27ee645c99e6344c99ac3a15a03ae94824fee7a70b64f25158d6ee7bc35b95f019bcb2a2010a974053d1a12cfbcee3e0ec59ce6ed4c7db172f9590a0d17aedbde1dc7417a4e62678628d5ead4bc80a3cdba1925c352cd4c1746641face0ffe7951932e85fe9edee3a2af8a1bc760e01ec1c988f91b3f3fb2f5fd79851bc9cea8e48af98d03ececabeb28979ba5af78d8d37296d1bb0ccd4ec7e35569f684bfeadf0e249619b4b6996de1480580a1db9b78970c8c75c0ccf37ea2cf432675ae89a7cc53242129ab2b3454b151831607505b5e1a15c74f8843b88ab9dda50c60394ea2b820935a5b9ee9cbe5cb493f065e27c2057b014e8a4c363f7dd44393b91d7193a85adbc900a2ba559bf7509f85e884520e96c61495d52ec2279ef5fddf940abb5f2877f935bea0c94b1c24e44a583f3e8331c6527a45a8ed895fa4bbc9ff623c3b609e040bbedea5bfe394ee7a00b1e94b20b3e0183d68de7fd99e229b4533c2c123ca0016e8df6b13c708e874e06ca27e90c60df8228eb0bf8e077c165630f06f696db577f2dcccd953d6902093fc504c350105971de6b60335f69ade663cfa825df7024a4479be45fc93b3e08d5070809b5b3b74c90a83a1a363f1262a096168210a84b989ba5e16c284e02f0e9df9bcf6f802f3ba6d81ae40f09ab2924d1cd8ad22d7eaffc0660f69ffd5bc2e5c7971e525f1f9532585e479dac12e5cfee50725cb4ac5ec4dcb1f9d61642f8061e91140c9de09a4356a095c6c56aa11a226c5155f0dbf9390d4f101f0925ae559e6d466dcc6cb54ee052651c6bf9904e9f8eb47c2a399d0e1dd81b9338c21aa7c41a6bcdab7cd8639b292f2c76a2febb9927c9aa09e1ad3af353a769172ceadc772abaabe2a9f1b2f2d24d194e7cd9f168dacc1c10d3bf29a08dfc89e323b555359133e20032274a6e9634828202dd80999e7a2924b6662f0a401d3f61a39c9adbdaa5b1224823e277d408b85a5b55b32f56393c163d35f6589645bced756e2aa5bdad218488549208972774d05858d768a5f3fe55816005e65538cd464bd09b82501002feb79539f756cca804b8f20db2c9a328f4d424bf0b3af359521b1d10784bb81b3b57700ff9341d8b6d8270fbd106f66a9de1cbdaf5ac5a9d462a61df5b793535438099dc3c94074c89be93c250b00e8d5ba862182e15d5db02d4232bb9adf600eab64787afb542b1ecdc7a6d4781a143d2b6fc058eb4c64da0af002504d2d35e3cf0a8aa5df099b25cd803efc981810d7ddd83455d96de055a1abc0f6ce29af8b7743a9c7db71ad8960e8d7da61cc862068f3c251a137926f6d51fbc130a2013c60c7d08e000bcb40b5645adab263a66bc7c765c981f7ba3ce2bafacb912afa23c7b73aa53a38bf1b92f1571c2e64bb387aece4e209b8b38736649451ce476bafddf55cbd21ca9cc032484e258841ccb3371cc3efaf806274cda843583cf9bda54735601aa9e0e7536265f45ada38fa4141bf20c44f9d951ccffaefc2c999bf536a9ee6387492f0e07b627f1ee886977f159e91bbd5807c0782c63408a77fd2a893457f8887e70adeeaa3feef2e56b6c6061d5b819c78c9647300c4f2f4b8349f11228566eff5094b22ad471ad714625aac003c4314bb2ececef6317685ade56f536b2a7f800b2ab40ede4414fe10de39e7e2ba757e0ef0b3000bc6a1c8404f442f68a372991c2336a8ca3294af2139ae6f46297ab75ed99b817e5f1e1d9656eee4b56c410910007c3bf58427fd30807a353d139420e47c262dbf6161739535c4794d8e61c09ea800f79a37018c23211bf46d621567852b24251d6cba208465e0d77ac8158fae7d0e5aea9f18c3211d869d92ef41da512da39cc8e2fcee08df3a13a84d4cbfff44fbbe06f48a8030623434021f97ac26b5407cc7334c8af3527538ece0184ab8dfb4424b99429e0129b88db103a005454fadaaa18647ed2e844f5f3867133c8f0a4ba63e71015df31e29a689579224ed60a8632069ea82ca2c723ed2e633f861a53313b00d9435833defe31dad2091dd7a700c5396688823abe3543f3cb78f23e15ca2885e6d00ce46a8cd968687d8a0dec78b84de6defa63a937b392f9cd34ef089c53c32753e7e1e1a8df56f55263ee5ccd76d9eef22009ccfb75fe6d641e3ad58fbb170aa8d0c80c016fd65c8d2abac03d2376a91a135aa2c84ac70bfebf0d21ccac507041876a1720938f2754a8b3cca2a34a79b5f04bf6edefdab254c79b63ade88a6a418e62ba3946ecf6fd7132ae9b9d16119eabfe643fe44321f10bebdf1c320f33d1bb254c6dc56098fc2908b068b6d34bb56d0f4e08b744eeb427351ef7f9db3fc1eb8b21d40e312763ef6f4fa9b3114d7272f60001fc107829cc2377b00e94b9291dc07b7a299ddd48260462a6c6329458737e0f5bce6be79800f4a50ccd90a86daaaa52a6f2a5333f18ac58e0f21e67d3db1ccd09f51ef0b36a673b3b8d1680e8337382f78404df920342836e165778cc60d40b87fd8346a0fc899e960dc475202e553bf72062cb95ffa5dd85daa49ca36a65d02b4ade568800ea0172168ad7935e415e08e92129d895d1eb57888797c4d3324a5086b23f0ad9e3b9f9d3f17b6e0ee3016698037fbba7208d57e12a97de54ff88281ecf661404c657947922b8d1701c2e7e6e2eaf8d302812a48eea7893aad246828382fff5e06c4b917b09b8a56f65197bbffd420eb229c6ffc0b102b33da674fef19c78e5f5fb462b88f7ea35b94294b8c6621674769922868a4c93e3cc17331d72a0b31e7837bcb92a0b2a5134a43c76255bccc5c24fa31154c14e3ce2fbcdc3ec3d76f49d8f23d6f7f77769560aa42bfa1d2444c06c29e1ab658f350d92379e5e8249f69675a4f7fc9c168d1d2f4c2cc6a899cc0aa8dccfe4c69f8021448dd3296ba33f8596340c77d471d3ab48b0899aefe1e152b2ff84a37e45dd55017df1e293efaa5468b48ca9853f5698a863bfa814cd3c8f01f5a771427119b2afc7a009aa476e924609f9fdc5c5179f67e9cb652e2a6868e07fe4d84889af51483e319d81d04b979a0551d64c3170e11025ac59cd9d757e6bec05df25091b5fb0815a1e2ec299cab5d95014ffe15854fb69566623f0f2dbe097bf8424523dd5137b35135aa068336b5c73350d8b97f36a58ebee4b8bb4782ace7d73c8b71358e34424d5ce1f8a130c8ed15c17cbed19903caaf91961461df94883cd05db7d8a95d8e09778acd28dd7ad46d366e9baa360aef0cf5f3bb90a98f4305dc7a9e8f93ef1fb7986426581a372dea93faf40587b9aafb38b5d9e51377da995da320641b72d83f6bddfbe273c31dc68519e1b445aa89b3ac2219e6a21f968b0c0ea79e12c3c92fe4c7307a7e6a1e31c76f0e92fcf8675c4e9d229c56d13594797f7573158a179b155392ce3a6fffbea06778b0b3bb2ba1b0b47d02d0ecc32cda9ca5557d01d145e812f8df406d59d058c54bee9dfd0685eecaadf924076557a00916b6fac27a3486f34ad08574029b18b5d925f69a6e9fafd7c66f5f1c4e05e52dc2bda385302e376fc1060614b6a4cb4f998d33bc1bac879fb660ab4815bdf7643b4116de54b82a37fc8794241e2c1f640ffad56ad1f31c36a5f903addb4616835df25e563a033b72836f65d8874237abba48137f8a149860520700d5fb687d826497bbe437ff000c04fa65a23716803c38a654a7e3d69de6d0df1acda2793b7f42deac5644cee7a926c81b18ce175b6c459b2bc553da1830bb58cf42e6afc72ff35c8da7a53c5554aaf4bb8489ee6c4011509a9c218938c1f2ee4c85b2431916349f2e58d7090fd1a81f1b72548bb77f19584cd4e75f0227e0b96b8630841b2982033c5a67742787cc184de15f8f6bfa35b0c8663f0039aa5c89912857eb6dfb120f022123af64b1092ff8725f55f6c6c38f63b1932ff8713fb0e767bcf5870ff589ed215b11eedfccd13a6c1c0df9ae9392e63670f9bc1fbad4cd8728eabbc642e6b53a698e1bd67ba7235a15ab2d880fbee0a10225fe7c20062b94c1417f253bdf0a4b146158f1b51b094cfc84ced327ddf7c5bce935eae4f9d2f4fcd56c68c97d51fd7a78f6a1ec6d7ceac3465e73998d6972bc728288db79f7d41446a61ece4d3810549f0efc219aa53a821a9a33e7d7037760bd990e068984c9f1caefe2a35bcbd08153222020b9c7155e0f978ccbd0ec7715702461f5ddc418207b561d29df8c7b1330d5c1fbb398e09adf58f98145104c8b5104625b13f21f1596ddf76acd2dd528ad9e7956ae48a0d0b442aef0c9e18f612d4b60a10c891147a6b37912a58339034113e806f3aecf419914f8dc788b32721a576dc946682c7aff6dc9abd27b69398c3b0d4338a4d59721ec3ca23c059bc846635cc5fddf9d9757efde60e74a37b9719ee676196da825c74650abfe3272fb523b0c85ab388016011d7101c1dd154abf14b29bccdc33d7c2c53c2cf16870e5c0b8ea8338dfaad56d2ac330e5e446f57bc8709182111c3e94e5a859860319e767890197379b07d7b05babfa6cb611160f0d1ce943ef40051e162f0364e33593351f07185a065385cebee173df34889fec752236dc3e8fb0cd6f64d75d7ec6465502d255b4a46f8de1f7e95c944d3bf91d8d7729e8cf56b887a3aaa1de24fe62d86aa47bcda8712dcf79434e087e1bd5609aafccad0d4815e3d6ff43f1a9ceb31c7f0386ff92a64aa423b7d07114fb954cd95ed46b93a4779e284608f30ed51aaf381ccf3bf477f913757dc973bb08a691553f4fdb10ba9cc9d19ab12337a1154da3c8e565242000f2342bd71e509643d3f1598b17f3ae80900ab2598f02ed7fe295291a29674804dfa207663868a8fa738799f5758ef1e214c7f9c2bf1005e2503ac7c3299e7bd1b969a1ee0fd8727aaca418ac241600b619e320a1af303b24ac9c3aa069cc8d3140be224cc7a3fbd970236093e6c1ff9dec3fcc7386dad3345f7770bfe974073d653291ab86b525fe6d71f616a24382e2d302bc0dff201bb262562c76a16f0a6e8f884874593eac4adf17722f3325a98f0a4638806916b5ae4d1c7b59c056f3462cd540ef94e072bf71cc177a4c87d24791c22d7a391d90081a04d6771870c710a97fb6bcafc11f27ebb310da40db3de5fb58b44db723433fbb5c833551a1def972e54ceb5c163ed049a4c092745c1b9ba2ba5be24533b237695c25bb920e3bd9789ab180aa6e7f50b449f532f790eab5c5e79ad2402164f7b536fc1952b5da43898aa2866b6239082fd26fd12dbd8a1d94c1421276812f61dafa85f7f60299b4d64887521ea9eeae4da71b8412cb5bcd5fb314b47590522d931b4c99c4afb5433f584b39d9d2f6490c6b66b05458b5687d3dedd3c25d59c6133f73acff963825fea7972c35922c3bd82d4a32fd2e0804640c3b238dbf4dcd9b2368b76fe7bab8f3dd0886641a91088447fe6d2a3f5dbf5268a81ee1c03b66adca888d027a601dc7a18e7939abd26df0e86934e356c8d65d11120a392d5153637c43bd37719bb5ded0ac0bb211a724b86ada7bc66ca7b5cbd4388940293f865d1d00571025a386d9ace79b7b245c100bd50f322dd93e224ce8715ebc429c197a1677ffaa4fd35e468a8698e2063f1e291f48567dcadaf90a90c5fc06868f9f133e64b57a4d2de0f70936a6b507fbc81c432edf68a11d85c830d32f5a4b3d370f6dea65b7a448ad80267901cd0026313d93bd6a18c0c1176e1e8655e710d2cb1427e8df185bce60cde9d04dfd1e9b84fadacd1bab63b1a2a0ecbc277e3699948ed2e7dd13a00ad75764d647073a63032052c97f98503fbc6477ff26360cfdb06b79452721af5b1b7c51b2f03b4fa60b172bf9c7b6816da7f7f1ebde729ec40e9025c0d80d9b1f7858cab86cf154cf0ea5b7b70cf9702583cdf812b5ce41c34991c49a878ae585baffc08be84b86ab90c6bd270110f4b4391daf60a5438c879895248ca37e342e7bd6cfd4810a02567b7471f40edef32822931cf8f38e2f06e57f8fb0c464675f8a96f334e3d50c4e62985dcf885462deb51efba7d1a06ecda876c2cd049fa5818dd35abb0713b3a71daf7a746a7d91da45f09cbbc7c32d2045e25b739cd9fb598b429ce940bca7b49b19bdbfd871bcb42b533e2d3f5a3c10328e73741ff6ecf94ee1cacf743072d08e6dc0be14d6c0e4e9ca9c8a52cc90c675057661c18e1b17537454c5ad49474a9c0fe6b11b14e9b7774f4062bdf976cbd969dbfa3a062d17cea5bcf8ab88525d5504436e4ff15bacde6be3d8ae45bc3901305e2cb01f24e5364e5f692a921394e5506c3056b450b67a2abce684af62e2ba21ed61c424cb689ef1e7fade30e42da0434c43c93f61f434d27baef6af4e9a966a082601e71245e46ca3ef6d05bfa69f9ba1325977fc38975f2bcdda4482b4d6ff1c495aa9d02a2d91197a95e3f4e908bf3c64b209e1f8438ba0e284c04b7aee7d9e7b97fe87a3a427da2204acbcaa3f916fc86d307cda19edfa21e2389f35796ae77d68d99075d5e7ede0aa46c750b2d980b0a654ca4fb8e9b6df1180ac678d96a1ebb04e916d891071ef7d1caa69f18f945277e991ba114186200edfa1828943435f5306fb2ac5046b42e0f859a36c6dde480998b43c6caade6990ea4499198d233f81e952e96002406083514b3b48312123f6a944232e9455b44134e3831923d1cb09002ea4e1473ed9dccd18d1720e6f25b6c098c71d191c7d78bf09c8fee7bf69ca08a116e275b64e933e32669da55881b9188de57c4c4681b6a84a556bd5ac364053e05e681050420bdc609f159da505beeb23a18da7d1959b246b8dbbb80e0e2c10fd4ba7f97a7d7964a9362e9580f75068595cfc8da89072d3835299bdb1670c204d29a73a6ff86f15a1ed5003f89a4be7df9b9d5a1dcd15edae56e04e9c85fc2b649e4444bdb4aba487482e2355b435df483468d2da2d978def47adc33e54e6bee29a752d48dbb37e8538d92652f53f3e5056b8ebe24a25719e0fa3f4dbc02c5b5490a54191ec25a69038b82ea8280afdfcc8ef7eeca7badc4155dcedaa4674b00445dd9ceb4010a68ef8e0ccd0ec954af0c7aa304d77170ae87c3f53fdc0caf2da8d8962c47538643f59bf21061a00040c55c6a634eda08be328e3346692ee9690f41de26a0a3c73d51e0372fbd0398be015d7630dab9a397aa87372c14e90ae88b17c4e5f787b3e5f55262cdaef0fce158e8f7e9aacf1e2040840824fde34d91ea6b83de02155fbd8150636ce96b3034b7f2259ad94be9f3cb22990b4649ed1d9c7db425f156f56cf755c35cdcaa3d83b9545ad4d131415395b9ca70392b30a3310951f29dd7b3c0d542c04f3bd8bd181efe149d54b745b4362ee4696b4c305cc7190e21c7c065725b036959b6059d30cad8185daf4ef0d3b704f86fca87bb73c4d77d0322230f72244c2d818e376678f82f4421e4c1c9de7f9ef76458257c90385b37eac306a998186eca3c167bcfd22068c3c8ef509890d4168d1883c17043f4fed1de7f2cb0b0921fbb7125b30c55e37e90baeddb15b41125c179ae12ea6cf8ffe9c2947c08cd878b4ff5ea873591a4c4938751aa95235af70a50d760ae706efa29ee3d5fd679ad7905a30aaebde857a9781f8038d57a368eedaf5b8d02dacfe15443dd04a11e90f8351f2c9fa69a22e7312cf2bfb021fc01708025c36447e52c829caa3c30d38088af92b6b7e473d22c339781da9265fbcc771a7185e69324d1a9d4c4bbe0929a99bcb9baf2f789a86e2b60c1bb3ceea3976547f620142934e77ac92b01cbea0febdd2c3218cc2758faef86cfdab89f8858d84fd0a00d84184b16214008d6224e04152f8e84564aa53ed541cb1b77db5c878228f022840c6190acbe24a496e3b8c3ac9d2945e2b9f9bad4afb9e2217dbe83a2b59420e6da41d647b77cf0c10402b0fc183c642c01c24deb6b46847fa743a98a0b0789909066bbd6bdbf9116e34cd6eafd1ba648050de7c84b9a844a055a2b0660f4f796a6f45d076fe8ab0bcf26e310f5465fae87a6638466c29cea2dc01bf9bc7ad7e02334ed0b2f787a2811ab5570b26e694a3f96456924c7da6a1390c5d07af17e98347949ac83cc356127a4721412c6444b94c91e48cb0c90968c500b7b07e479b42989ac310c8f6cc61f81b033e7e2b6445b8bf852ee3ccb72e083f335aac2bea8d7023d5b608e146c274f8479155e859ef78ef9be823f9709f6fd411e80f998df96072bf511af496915e4af74fa2911764e530768b955c5b6d13996bdbeaf4200f183247e266cf20cb6089e63154e443b87e11f0c775517cbfdcbfb85d8b348499f1e2740897175ba546cabef5ed8dd4a224426f67fb0f8e7ba1bdbb9c3a708c919de092c9c637ab5a63eb92e2a09cf3ff46bf4c8d13058c4ee7de1040eb6f455eed5075dff8bf65f2b865ea00ccd9327d09d5949b926b5bbb5509986e4b4cafcf60f4eb8008702e4d14da71e89d515d33e9d27943d61e05d6131239c005f921c45547b516b480871aafd5f9a0d52e3134203ccaddfb9397323e4c72cef631c1dbe6ac36e41bfba39c3586fdf13a24f9cbc90a97bf3b4ca8b882cc3091acb96bfbfc68b3ba7718e81bf7d6bff987011291d6a1c78610c349cd5c51f13412cb0d379eb7eeeeb1936983889cd6047df0f86a5b2985686572f297e237436908507749b5d56e8be301be47d12460df659dda4bb5fcd0ed799ed85a11c65439497999e763d10a9bdf0cf2a37399d3b767766b83c06d632a67c63a8edcc45bd768a311fc7c0c0e437a730b0cbb0b48589e412cf6f24d239f531d42d1915bf0a67bd9cb740bae49faaca3c05a1cd4eb9dfc2082b7f73b925ced27aa0b38fee0145c8e40300ad6ad3ae35c66d9b2a1c1dad3820e8055d159a9912b1b6a24ce2b2902fd6d06683c905c4a092391525f159756db1a45adb758d2c120402b575c6f494fa1bdc87a54ea9bd82db0025f8130c0684a585ff909b938551fb4028e5a8edc49b0fadfa2f673b9e736b1ed42904577bf0921549218137e4f61801235986eed49e181e5c1235bd24e60ae7207a7bd0c5b5f6c806b1dc9162ea8aa89310d1bb051282b40db54e01fdb09583901911c3dc3c2e6062a804c6fdb4c48cd4ba1bb139b46f784de77e3d7bd43ffe8b3eab63afcc51b261df2812643cdaab6b2af3ec56d76df29a16fcf46292a4c327436580c95ccc0d8e447be6a8fc910d9185489db62c97720e33a079d11d385c23c49eaab98d3e345d47144f476263b93629e52b0d06e029377c753f46634fd66628befea9c129246ba8b304ca1313d55fcd6708840162e1bf1ac179b9afb48fd67a6038857f34173c2bda27a26b8c8409b9f59430e2c3c6335f928989d0066599e0ac3839d81e46f781c834a51127ee7f03daef043154d2a1515f3065cc47dd4ade80e5a9ca836b584a2aab42a41df2b4f7eddb339a2fcb9140bfce87ea9000bc600fbad94df3a7eca0db0e2af7c02795832d37099509e276663ed08d86923db9bccf70f6d205d387dc09d73ea0af8aeabcc70d6ed4e04ac13efa74c7ef935da32b281ee58df554a1ece8a17a46dfedb2ba18a95cea847020362bc2b0564f781766a605aed2c5475c42202afeeef4cdbd386aa7464919f69d6d02bcbcdff5bb5fac0f66cc5596ec3fb9a8deff47000dc29e95c82370033078c93ba8821c24665b6fe66de683b2a99dcc752ba10eb551b193d16266a8c8b86f1edbc18a45ac4ead05b9f91e85ec902e9c9b038befc4a3e6dde76b422a757847efb6eff16fc0a2fb6d842b27ac3a4bbaa2f6a7da57aeae9a2c26d868e14426ff3382a44cc209c718651617b0e5368e020b4300f011dc5f666772af3603c799daa55eb37c0b9734b9213cd3043bf2580a98084f8043ab9e09eb84f11585dd7a3898a382e5dc9179ce3e6d6ceca6a155e9eb5db2d7a17e597fa73deca1b268656b90c4140d88af106e064eedf6fddd821c7cc806a0ad426ceef4e0cf314000a7f3e2a5deaced5435ceaf9eadc7e0d865234e450b63cfaffeb236de9e38ac7b447b545478e26b1b36d1ee0efb1e0fc18dd3d7cb269e6ff6faaf560538f34def724fa80894ea13204fe55b804e5bc42c94560e5d3f559ee6d31d485b0e4e3bbd276146c24d9469d12992bc8618daa21a29b98cd339ff973f6baf19772b46f8a8373499cf169454a6293ac4505e4c07cd35d59880cbf12d56e277f494de039a7a0a8679577c883021c6e6e6cf554ebc548f2e2443be1e3cdc3997efdd6c27101e57812d4cb064be6491e2ee076e0c128b04d9b08c0877c790e9edb6fbbbcbfad9dafb809ae3dc86bbebab375e8fa441e0034b8d189026ebb19d02a560da22c8f206cd80b7666a0e8f55a05ef941d26bb17f6af0e3f41d4db33af9531a91d160e6807d21c20dc9a89d6158c4d5a3cf30f54ab4d5918c2c110c32e2bec9d4d6b8525d5893d253128a20b357f92e0181d5b57a3abe9e3298602e0c26148179a2631d723bb3a206f2d0e08bc0ffed92b4c4ce3c97f8a5edf7f7e489f47b5d5abff00752b0bdf05c6db2724bf9a3b0132ba98880c39e61d409a358d96e7f72293fb1d07f6c863a84530e8c74d7e910062279f3e570dbc1aa79bedd1fe286d3f6c2cceff0a80bf86dba9fd9d2afc7362d18f36892c494387b68419f4944e5586ff7ce9cc3141d92080267cf50a18c70dc407422db0cda4c67852d833f75ed54e8d09adf1929257217fb6b9f2019c924abfbd21582e483f6762df4e27b4350b701ee96b1c1d73d74d3f281a11fa2c39f1693e7880a073f63b8efe5daf80194de0371ef4f5a43c6b39af4634486d4a6aabdb89690ce1e6b1805085fc2d0a00029999617bba8c45c720140facfc461563e44367b9d7e45d3bd944dc56b91f184c4731b583cacd3c279b3fdae4d93c16c6ae9220d2a8fcde3ae2902c70c317556b1efe7c195e90eccb2bd6843ba30f0dba6c916ac013e682dfc1af672f2c72fdee72c87b4869060d83bebfc2039ae04197db95a3c961aafef9f7293c9173e0e2ae39d2ef3f54e118dfa28b44e025b996bf1708b96cb2281a3e2151e83f26532406293e910b532ed8a0f8472264ad68219d5d6bb7fa2254326d79ab846c1630e3c167cf0bc99f3c53130ee7ca156e50824424e5ac171cbf44e81cefd3e16f89c2f68f37cedd0bcb59ad0b2154221cf7ec27cc8612fbb9589524a0e7b08bd322b10a9527d107bd664fe2e81f0b3035f6911de90d0095bfb369cc9338af74c4eb7dff7beb2b06a42a2a95d97f7c02a7987a69d8d9fb936206588c2a2dbcaef12f0006b9a057d5f468660af1f6625f1c4375345dd7e0a345020d4fcbf2747ed750b04326d613e12414bd2483017e6f1c70902ac2915bd86b92196ce6f2bee6c23c603461c81f60cdb681490f850d7c964a12e3d9101a92b8bc82c7fd93a166c086d0b13aaf2f1b8caccd617904a0b82aa83a5158f481ec427384ca652687eddbe558c48167b9ba356e01f16619f0c853b2296f91509ec89d6f4013b7d6f9f6d8e3c58b96812a810f0d5a28ff4b7320fbac20c6b6d7f18cd62444018c1900dfd03f042ee8671e1c1e70ba5a9586efbfd690d832ecafcbdc1929f3130a8a54c277f8fa520aea83ee2af7df351493df1ed1f084577f4abf46a1c486087a8134dd6016692a99ff5404ef1ba4cd0970a26c1ab3064b7368e67832e5e44dde26475a835de9366e1a23389102cffb6f1f7bd8348b463f838927e475f18cbc44e78d02ff6dc4ca9622d0248aa7af1040e616e8da74031587c62051425010e16e4dac17827ddfabf45f8f4bb974fc4f5ef78fe719311c40cfdaf0ea032c242adb02c5b8f3b3829047253f809749b6ca43848979cae9c3ec4eb8e674e188122050c527d59fd38f9efe63affb43b15cb0e758bafd79f3f792a02082dd1326174ad15dc7e105ae2ded85998a69b9ccc016dadf82e3905e0a5864522ad2153019204653c3f5d2bc1fa1f884c4786d0d00fff4200ce518a6a5a7e32fd04fee273058a2a07ccb4a33927e5d1fe500cfb45c9537361d4ee159f59bf5072b77a49db27cc9a30bb355f5948c9b51409fb6dd09ef283094db12f147241ec735371d35b333c55efed92d399852d50b5b762f720c43ce329a702be79f0f0e54f62a8e3df463f224f5b6774eb875498e2d19110a1f6da20dd716ee2bd219c82d5cb15868075afe33231b34039822dcd356875a1722e4e33ccd57e9b3db5f883a4168a682552c9b4d68df2e1086d7ed2bcb463b36c97ab777b8f4016e86ff7ccfeb001525f23e58ad508bc59b224e38786fc8154089f85ceea5b628620282c74d5a3e4a619ca3a087192cf93fb45fd547dba1045c4652aa38459efe2617fcd343278e457a1c90fbf9543e11780ad9e3b8ee99083bcc7e7599264527af0af6807a01e46bc9545f38a4197c6296ce7194f584250484f87ad36cc8119e4fcc5de86a54e0b509adedeaabed87b5b13b860f5b92085746683cb85bc76742abcf747eea8afced47e1e89d582add6a739aaf6fd3ccebd7915cd4b43220bc04a6aa4fa359c61950f241e0eeb24ec0b0c3f29f82b5d39b7e00cae5fde24f5dadeb784dc0643e53164e4ad10046db42e6ad37f0a842efcb130cc316be1f2aaa4c3c902970d95b13dde72940af8611099535f989fc527bae411599a6d45c3eabdae374d2be3c3d7b4c17694accba6cf8a7a6e7ad04be5cb800b2b1769d4e7916faf12ae7bf90bd07968f1a7c55995c4a81e865d329996ab52a12abfe336e848ed4b16037753fc253985fb640d66e6b5e3c714b41d0f0b742cc8d6fd111fbf16f971798161cfd3805a24b246dc4ae5208760dc3204a85e8b7eeefc5f9196f78b5aa78a07c5ddef22343260f174610e1665fd2a4ee11df5bd564e07617b230d573c7ae2fd06f283dfc0f7d5b8f016f3a82d708c6a9073e7325f18000fa099ecbe727ee2d8ae2b1caf44da0163ed24c998882ffa21601638b3c8f639682ac1d72e312e2e99a9bd8a4baa3ec10d17c74485902f89df7180c1a17472792fc2c1dbfae3c2ad5d16745e587d1c90d6c1225f4b9228584f4d1488640669ae6b60960b622611d35aeee3effe3fc967043574e10845a54b5a8915138dda726fa885b9f2d2d3bd23c969fde701ebc7f0680f9988f82d709b6dfa8331c0079062ed74bd660861ef18d2a9142c474f5e2e4ad6c9892f7f2f24f345f210507af6a90e681c1afad15963935f4ceeb58c19bb21716fa6408981b6923a0614d9803d567ce9e4a6138d8270d8ac409dde2cd933833e9ece6e37965fa770dca12813a763e763e48855da80735c55d3741f423a7925e8a28f12ca699d6cca384a8fd9e8573ecd559b712f8486b4207d7760a31a46e3df3f346636786de62699b66603515ebaf4b99de9e5e076a970ed99ddf3f0eab5bb8775fcfe4d0d1c89e7feddb1a51f97ffb5c161be6ea97eefc2e9d87cfc173af25f18d6541b865f7bfa6dc6ba6aad4ebd4bf40a96a51cf4ece7dce524ee7444e06400ba98b3c7d57ce9fc9b750f3973fc56eff6abba91b6ad680c52e553cd5d24256bee4dd1131d6adcc0ee434a72ec320b9153d433c30bd932e7729ab790104183773529f7f878773f6acbed502f26f3eb3ace5f96be7aa43507991bb1a049cc2cb72f9008b631e2756faac7b2f510aa938024077608387dc5ce32f840d74e30d2b76aa3e3f8ef53187dab359ad21e5784dd841877b643a9ba6146454b00e3f03480c059c23e83725cf7f242413aeff03bdd1e8fd6926148bf22d6ff65fc7fcf6436912c00b22fae7582cb05881744efaa71d376a43e924106626c1d0e8f1e39dfba52035047065f46ad75aac0be96b91235a3a55c0cf5664f3efae071747f414d73e4bb588fa27c84b6b8b0d3053ec5c417c95c3df3a5603df7fcaa339b585022d2bf724aa3edec20a21a49b433eabf95e7bc39985b8f0f69a9583d49b414af5f6013c0444a054fab8603562ca38348288cde57d5d29b49ceffa4e55dfd0679205e3630a54b2beac8ae2492bfec8761c141d79ebd589b3348e6ca5c5c4fc28eeab1da15f21841effd0c188cbe3157ade03fab4b3f02503d6e26463a36f6e879c51fc6b693e875e27bb98920dc282d122bcc3f6c4f764ff8c7f09ffd455d0dadf94d4a989d3944a0d4624b636cfabfa7370639f4cc3e38ed3d5cdc61105c973774ef715962a20a6ec3cd40259e4708ba6dc0453ed09b7ab41f83a2c49acde01e8d03b6a057fc6a5f36cba17ab354f5b6372003b8b89790ef8552cd473809e0ac90510729829d512ac08dc0a952ad5875c461705055b2c94a4243b231930f2cf7358952fffd8ca68f79800090f92d8da2167b01e7c8763eb01bce49b28c157c8faa78f9aaf4a3b69d0033bd5e74b499c414b8f274bfea623ede9232efb578f7e615d072c1181624226dd1e696f372d2dedefd32cddab5e0f5b200eb6f535624ef8b05cc51d7fd0741eae89dc516d5ff4f9ab7e0fc82ad69921c2d68c875e45251aa21e517126d3859e8cf9ee271513ee4e36dccd9f8925a947231a3e79c9f8b56ea586fb20724983ec22967df064c7287af41e5842b3df295ffc8d0be9654edf97e97de423ad6462be1e1c441b9ebab6b368a8429fd66875dbad54a3aff60567e5c80f0badfe0e2a13da617b4bd797da02dc0fff7c50a2344233b564dface7065923d5f2819d40ca6ccc9fcb10acd1bbdcec14d4f80f5a582fea52f1e49860f39c9776c0aab1b200d17be4d6b926477f6902f952b98dbe528aad44db31ef10ffcd88405583b37ab976ab3730d8d8a08bcf4ffb900340b87e521c5a18ceb174c00eafd3aa1c289937c30b7e570ddc5140bedd8491a1833bb839a7a843747363e6e6d05f3cac9c3fc6c837be195de6b8d3747c152aa20e290173758ede5b9937f24a472aeacf387cd2a6bd1accb3a132375f311a52f554f27a16ec717f1c538f1c154383a098e462218de78967a3ade811b8f991584b9740edec6107318f6bef5704c5a9999abfb550c0ccdcc8f53bf50971615b9f513fb2b6c00f74952cc20acb955c53cbb7fcae89d750233243e7236ffb98bf460b11dad6662705dde92b8a928e9720f2219c08d382027ad34a6d0486a01af89ef6f862075ab69d50a195073c249b15db3708f20cf8bebf145fe3a661e79876f95014cf99a3a9044c53570dd0b88909e886f6a1a6e127b0210694cc52a597a3a45ce461595e9d0c6fd460ca31067baa38e99aa98b7dd439922b77a32623601411eeab83ac605516e4669df54329ff6d49c90043e6a253fada300f4fe971a2bc9ab434aa4d2306058807a83fb3c2230b282a27e71da2faf5179e25c6c1b935faf194faa2eb3fe28e93bfa2c08d052bad3da45b2354229aaf901058ec80210e07bfc4c9565569c18af9821aed15b454af33d6d1a36157d6cd482e76cfcc8ea4a5a16d785c44a532bc86f6f1db31b404bf350cf3b0e5e02e257d787c6abbe35edc1dc39fa15376a95cbd8d9f79a897d538c878c4d298dd5f09267681efb697385b62b4bdd979aeea7ca5b250cb1a3157f815b68ce9a70546909c30b1a3aa1231825505efc2d3de5830ed6b019ad6d0e3daaec4c2600e34982cdd8b004631f44626717843457544c735abc96f334bb9ecd0cea275e732239dde95acf8dfbd7c4e47fa609c456684380fb941e77a2cc802403b706006b24dde532d9d6858db149b60c60ad2f31a72d4f8470f6cbd79063286eea9c5b61f08233f57faa27ac15a3cf7660cfd109359119fa4b14bb6e774aa07f6f7fef28d53183a683ccf7c9da7d14472fd5233424f9f940ab808d7e8e62a5a7a305d3b3a8369dd01e5274006c16b286c3076aebf7867e275f860e6c07b2e86c7fba7e94746b8ca18a91bb48af9e9ef01197602c5729ae4a1ebe7e22753bd4a4463b04dd8a361ae45d4a6e05e0435b2118db21849766d34d5504ca4d7cefce08ee4fc6ee93a1706081cc8443fe81366c56243550ee4b21b8d899222f867ca506ac16a0df9f71281b304c0c7b551981ce5d03ae8c752e46c53faf54e5592d4dca41d0fe90ed3b4af761638b8f46e9f6055a61e801f56085b00aaaa26260ae9c4716b869ca357b92b91137347cb38640366991f230144fae2dc0523f42fadb4b69a9d5fbda1faa90e17f79b39ae71f9af0e94c4631a0a56bd4f33af3627ae26e9bc26e6392f2601bf8d8c155f76283fd44ad9a63a84919e6ca7ede43c65310c0e3d521897bb467ddaff3a3ecba5fa15468d66ae1189649228a47033ed5015ce575fb4141f282cbb811736ed626fe36b966a8620a2e50b23d27b64e8bf47eca08f09a15637bf512f24526b0c8e80d10acc4644b759d2bc2bc9944a31364bdb6c35713514ecb6fe77c9e3cf7bbc59be8957b8cc6a6425544b7c486c8d58fa2bc05bdfae91b9a8ee2e8a2d4d654cafe53598cfd0cf6442e214595b3db83998f2ce2eb54e7fa004447470a55e918a08f5636fff0d7eb1936d6f28e87bce380edad9813f91b3818d57ee69a085bd5341458760d588fdcce682eabb7babbcd7ae90fd168900e937a1160180de57292f317e7f0254b7433afa71f51928c9a80e59c33cca38a4ffa1e3ce9a82b60fc0abf278322fda150a0ff68cd91b4c0c93e1a79ee2fbe3a95f0bb5950477e95664299a2bd112c8f5cc292e5461b8405fc41f9216773ff6db7216ad1d56253459a0939f6902bf35b487eea101c022f2f54ff07c4d23ed41ace2cb4e1609475838a6788aa144d39ed31ae23a82ab554963969d8ce0491c06e78be66538c27a83a29ebae0007578884677397413dfb53dd9748be7d19f9e32fda4785a13b0c2bbc6707635f512dcc5824f578429c07ff558db8fe8b0e3c556782e9bdff154e8ab64277df785e987c6a7e82d1e81b23018bf5973b9da8840147af910ba911af87ecadb38ad88c4e63457729688d197c393204905686fb61d007bf1468b5f83bca314eb9f1af00395ae82d4e5b1688888ff2e6234661e88beff5823839a41c85a01fa90883680d828d7046d6cd4ffc67aec20f45cc16a42b68e223b46b4c221ca87b238340236c4303858a32f38f4c7a88e98196502271577ea5ce2addc097ccab4be8aa074a00990669086570ee7fdddc729f0b7ee60e4acc9942093ac6798785c2563eada65fc655b2279232ff84297af102256dec1d0f68eb1c40295da6378b367aa919fc04a820fca4f10eee7796442c9f09ab819cbcb6a2d659b45c41a3e99ab0ed9b51a32d53d8bc0f1d00a92dce06b2a09c43f78221cfb2af2b0c643f53628daacdcd9b479b179ad59e92b74877dc32c9e388f2e6d9f45264c289add93dc8a9001b3a3d9c16dc76452270492dbd6fd8f8c9bc2400469e9196984eb20b5a9bf70146e23b071ac9b114118d2ab331206a8d0cb58b9c1771e6e1650c58392fee71cee18139a34e384b5c564ea2c4b7c8a3aacbd561ab195232ae2ef3e1436863963b2cd702c63af9bc4fd0560a2a4e55efcd91e1f491bac7bd7a44be80d7ba31c8c5d356374fc5498e0097ff44a3c11f448bf92de6e35eb449cfb75d54cc055b3b2adceead7a0f37d4db0cdfa48eed73c8f1ca1477d9e951bc7ed7943e891038a0766e211650e9da464f738a9670bd24cc2e68a34ecc73558bedf5ebd48a0c8826a6b4cd77eb9e50464488860a5598233a45e1ca84be2d57b9bfe8b5d4427b132f760b1994ab9c53de821ea67fbae3b9c541321f092d9133b6b6b2f9865539483dcc4ac20a9a9ae59c8dfc7c55edde63bc3c34897648302f9b3dec5b66441fff39b3fef43cdc3579f9ebbbb0aaea2e18905dbe551c204005679fecab6de0b8a371cca5c0a0c3c7db225ad09bd9dd62cce534d3e46c88968ad13c511c94c8ec60b8db7ade8b8e1b58bbd5d8bedf38a3d850ee17ea70af4cb9aa08018ff0fde01b45af37ed499fd0f3ed63547cc632b2f51284794c46042fce0bb765e16fde4b289cdb701acfcc29daadad792df88deaa16379119f51336b4ebf5b2937ff95dff1c96a015e1e5708dad2010802f5c8126f639b7b018164e90107c46da0bfe6c4eb8b87d049171a1b48a568701482fe6a56643199811acbd3f1dd18d6710d9492cae49e28945f8946f5f04de04c57523c12efdd68bb96766dc6d5928685ece1071bf227fc86cc37bb55d3925616ce859311bfdc31283bf9b7d254545f5cabfc88e14f4b21f41cbe67cefdc3638e815b38f2f734ec20858d9213a2f095e79d8fba7d795d12a3081d585e104f238cbe08fc6d01c8c222fcbb15a6e63b57cb8283b62d30e746133605e1e20d12ec16c8521a115805998d5f993f48ab75d64d710dfc84d522a9d60fc91c7d097cf8f8ef17c1c4da1adbb83a996a3fb575d16e26fa088470627deff776b7e4620e452e9a63f6bded48715979b30e08df6ded865b3d827a2e5548ae2ba34cc43c582e2d9b22aa1eb04cae2e5ef8ceb0b719aa611475742d481eef77c214f1a5dfbc6c234916f779ae173bfb500005f0ea686b3caa06105d49b3c69a72c30906146b60536681e513306d447dd814dac2dd3708d24b211bf42ac638cdd380bccd30895aa9e73154e3efb6d824e07eecda3ec6b0968b07bba0527091244c870b4852ce55c52df8567eb5afab5a0ef8e3c94cd7dadfa675babcf35065c1ddd09dce094bb616ae1dd8b8e1db488c8344b979465a1bec9598520e6958d995b3f526866739df11b4c29d45018d9fea2e6c9ff12bac593b37ce7682ffcaf49a88ab8d5076374875ad0720994803fd955eceffd33986a1b1058b78d9b0bb31ea83009f7f94e59a7c689c3b569b1042d1a60a16d01059f4a4d0eb733ed80a9b8dd02a105b072f647f695287e6a9722e3270b2b30705342d1e3319d7fa07b42543e1c8df3c500d1acc6dd75aaea92dc17ed2c971d23e0a5e282e9ea4816f482b29b61f50c5791ecb6be0a0b235f746e332fe21f7ac693c2011c326f2acf805236ea0d24d1390aa4af695dbca948fbf832ec0a913773908eac0213797b9a3e22876a9d2bc7230fc4933a4e6488531e0ca78a9b053b095b5344fd4bdbb6295d54e44447d832e298c13509471a12d9a154efb09817719bcc2245dbcbf17c061610d657c1123ac54c20585a0861908867d9efaf440aaa91a10b146e9e712d1d6b1f22942653e867fc69974c22100455b9b0c1245ab53db0208d3d41020be02b5044ce5169ece1a1df54a933602d1a0b114004d7a120a9e93c0b511cd2f0fd53c0fbb2610c5e8d22d9469876d1fe12bdf63390b9ce209394a921072c51da1a781adcee68423a53745c7dde17b649a69d6961ad52c996858880620035c05554655f76461ac1fce809f91d443821637c4196509f7cad1d32644cbafe8c8980695407588d1a4205c6e5940c409b10b1c674b7707f94d180c562988116fb75c3011c4add62e7a67d1d7f614c86b137809bf0f18a3a4e7e69d9fd913cba81a4df7a4a2c32f87490e3e282d0bfa75c890fff860a61616e086265410ed5168d541b6b2e5dbe64c0e26c9e3676f6e259721f08f3ec8f1a536a344bcae57127dd3d3c31398a4dc400b20794da6643e78faf0b84494fc91c7298689ccd08cfa66f2eca4e3630b0cd61cb3ebf21a91caa519770fe15abdbd149000db6df355c3c0e8066e7c2779c3e9c0b028e0842f80987ef2b919ae9ca94f048e4b7a8144a4b650a45309a8ce0bf603ad859971e35e25679d3242643526a315fe4bcf4330b70b90d899243f84f7ab8842ee11bf1bb3f7b25447ee3ac840fec2f3b7eeaeffcaa488964d63e9010e954106c0e281baef641c71773844e5c246983426404bd0376083b200f78fe5f088d592548931470262eaf6ea69f86154d70ac848fde662018d2ab1575b5c9e9e41d7c5a2572d6b55bb06232e1dcc2ba369e2b1469831422b5c2fab8e3ecf0bba14dd26d44cf37cbd30a5992beac0563aaaab7d8e14945dec59656051a76d0e4c49b9f99a07811abf70549965354cc008adf691ab374c7888466234aa09e7948347cc0ebd6129dac70341441ed953eb1d882222f40f27181a6c9c89a4574dfab5cba8c2710aaf9c2914d0fb1e2ec0cdcc3879b508126b22c47745d0e844c3df3f82953b26437409439ef9eb7d4c87a26fa439ef0278532bcb7a01824683675af8cb0cf202d4b248d9f675e0fa152acf2f201070eeaaea0c144d15f32208acd1d942f383be1556da02842c46ebe0969f8e119a80e8a6bc58f2f3993e26d4cb95cb33015954ed85d8c6b2ab354c356b8bafad2c7c028f687f3f7798552f7cd6b7e94d37878b5bcf0769efefd916c150cbc1e372c2470c50f3b33a9cd5a0d768ccb33befdc2a350c559d3a824225649a348c2d7f29ca3e28fd7e4c0862d46516c08c4a34f824606615d5fe999100e97e0bc352db0dfd39b67ee064208aad134bd549355abb128d644a3cd5a853f61d54eed018e58bc590371b601a58c248aea682b05dac7c295e3cebf9eefa4a383f0e51dfd09532b75c4e3b140424937403b73340a132b43e77c42e59158be8c1be1fd2bf26587bf8c6a1f954560b793bf60ab2adf42af8412d96dfba0d52c8230645b41112b65cd26d7427444102a0d954f0dae927c0a1cc90b19ed3871c283c9a8aa7007b4cdb1b9efb3ba2934510988a1b686a9e793cac8ae37d27753d379cbcd8ae78c69a2080dcacb2aeb50bb2d03c092169874ca32b91384b6868060644f0b2056419fdd4721ca844a321a27ef5640d9f91164f6435c340c74f9542cc2ade019e2f691a7eda229b440a8718628ca9fcc99153688b88eed7813b41d6ff04c0d0e4770283545661324237c547a37c1112ca35df942a241289b0f224ecd349f872e2bc7eb2a25d9c14acda31d6bc84799c37c80cedf88466f23170e6d979204c6c0634b625c97a6d6147c562c5d9ab2d8ed88920047b787f1733b87645aea2bd2228921eff0251c3902a6aa6c3e327f7d4e9c87b2ba82f3c331669fe9944425c4f58a16e3f09f61e820b97c0940d98630116001fb8c5decf8db90d833ae6394182f12a4bd7abfcad137d731b1020234decc75253f2f28685badebb66c7f32633c2085c81d31d8863c20db7322197b1d33137ec477a7e26476fbea796531d2b88ded2ef5a36311bcfe5602681a8d3c7afce6cf8869496b86deb94d5f3f8845423cea032cd8c69ec35238a857d6858cba960915bb2048aca99572231afa42ab319cac796a348d36cb33dff1dd5ab7ab4ba1e8ef6715ec092fcb906d6e97727080b0fdd202c99942782258bc6d2ef6a7068740d1d1689c3f730446aa207a1c7f775e29deaa4f3e4493461cbc32a00d1ce196a32a59c3c00ec13ef2d4fb6d53972e701c117da899a37e671d8559ab5a2dbcd49bfc4612d04a918315c5544b8a08beffd4f3e42dfffc8c3c86a21157dad2bcee478038e1a46caf81f0b4a900e7e6aab10d935ad9f194b3bff9095c8214961a302d6b9168b2eb35fcd0f9ec643d652b54893a7af8ad200b2b0aa9396b813d764324d96804e6ce5627f998b3c3987bf3167603db8ca1ca8087f92ac87b09aca0384e0b39b539883b4927bc0e284a341fb8e96998400dbfa1ab69d89ce9e86567f384678d613f70e0afe19b56e7da424c9a68a39d0af04e2aead1c671b173d860d5e1e54eca6a6cd6ea71fc4742ad88d154f32bd85338aa58ffa5b2900cc82ea149e31833a00cca5a0cff25db8d44410817719762d906e56c8f8081d5adfe1946696184ddd10f4dbbb3b4572247095cd1e45b976e790cb54eb007bd92ca5d48cdc23a7d817c3567b4d29010b43d601549ab5480f5d83331bd14183df35f43559fa343701a7c9bc918385d2d7199d150471ccf5cac19792e4259a1627ce11f9aad16eed612e518bfba8f436cc54372138be6a884a1b5a428f1856e5598433634ccd9e82d7c30c6b13c64a091d9b5384302e99b9bf67ef85b057c466b101121d0df6c8b17ea40e3f34651c80e5aac13cce67aaf91b9418ae73299e8bf894a74365dc889b91bd1415df1c39d564ca41f4e1cf5d516a960fd6b6a7b2d7762219d71ecd2145e302b7d816824836fe2b0f5ad81932d9a392439871d8c7c331ff2e38d7d8c8ef3315b9473c4c113b79f67d10dde52deca065ac06c3e44a73a6c5cf69d50d9b2f3ec34ffecf98d55a383886406f1313085de6877d5bf44ef5433113bdae47be66f4989a671bd57bac1373f36b7413bcfca6566972d692b463bebcbc625c61352de89379cb42105d9ad24869bc408a91e1eaee273819a7b1238a650f94252bc055b2e7bc22baccbae26bbbb906cf35b1e65dc9df6abf7473945ce2bfcf077c1ee38b4865293753083bcbb8e3e4cb7a8c87aabfaaa49932639eb434f9dccf3c97195c65198856e3eeb4cd0ab5686e803373da8db004874a4340da4ec879bcb3db9330277898c89a88b039351273f1e5ee5c062e27865e185a2f2374b5840163f132e08e907a599e53fef1ca6620d74257a802a04f16d73bbcdc75a891d9a9cf12c4679e174c0873b075c025688f22b5235a770b546c1381032ec1a218d873049aa8115d78f661c0bbf00ffed6bd4e6e651007a885924b53e8902b17826800fe783cf606ccf8e9c4b2731cc80fca3f5877696326d1729d9bbeb7fbae600b3c3b9b7461a640b454d53d5c565ecf154bba3a77fc73be1a21f25e4038dd5bf2133afd824d5318a94d92e4a9c22f90a7106f0759774c8b4853cb06c1eeb8da709b876bcaa4e2467b762055b1ec202b9253c8341106c5a7141122367de8875bb3123af6af5e6aa1a06ac4f2a3a19e33b1b34b60acd0137f985e36f53f279a30e96a7465bf5b270f9284d48f7f1a2f2aec05b6854c123d17a9f9de223f9f523589933fba5fec07cc455e825e15e8f4c97197c9bf9b1e98a319633df5d324ba33a0be7073c833406d38e2740f9c88914825c276c9f3e3c96907a8c6e651f9222758b7a6082f3dcef350a403584d5d3371b5535ba7cc4a2166259e702adf6bf0943197a9869b76a8878605fd3403392537f36dbbf86fff93e856af1ba8e40becedf7edd6cef672e06d4f34143efc9204f01a9e224c442348b84db2f968c42366e7716a7b43e7f2a109892afbd170b562c33f3fb4deb5f6893e14b651ef183442a3d7761a3067e1886250fc227cf42d935c51d79ac57294fa2637f553e8437dccea6e7e2796c01ac78d74508184cb710916fc78260714b07ea1433cff66d620b874e01f1ff8b90e078d2dd7aadc63e327a6cef3902bdec1557262a8c3fa755af297982948c3add24f332ea34bfc01a0307f397df87e47eff436240e4f788c8020d4eb157f4ca72d4e96cb642822bf56df8dbb162516a92289639e25d3b8b0460d1985ffdf223d9961ee17603619275282c785565dd83fbcbae0229bf30b6797c47572dcdc0139ba88a48e47ace90a40b84d7f94ee79555f181d5a8a4a3abbc85323233a18fff1bab715275b2ccfeededa58bd933a1d791c2220e84d53772fd08ec24dbf0166f883d1396f45815d224be4c46223cff95a6c6dd250657757c78d02c59162a5bf000185793287de8d935f2348089014d181c598c99c02950f29060126dc0aad14378e1e09cc5c08442b1c53064781cfad30f49014874cb6a19d33e8e337bf9f0aaf0c397a56fab4bd59eec5ba2c5c38cfa0ec9b92441bc41662cfa4328c7da26f29945fea63893b88538cb30cea382676438ccc9a76659c8b05fe08a2b2c56f646a49cd84fbedb55babe4c833054da6f50c6e64237e8cbe454d5849e82d7d090ebc40bf3d7879825cead77f041c5d3af946e347ee46227d62e7eae97a4e4afaee442f45c42eef72fbb0bdb678f5b1149807e981bdfd2e467aef8d039bd414db27a9c0b4a38b839b5f69c192837a8d5935b20f7664f5cc9ad4fa3e0fb645b93622a6839145e18d0d8a1c3448a5b2b3575c8c853bd4ab366c213a17c79334290e0e55fa93e7b54e5890cabf605e9160017c37b7301500b23b1f28295196ba2e1182f4223b2e96bb560c5be381f854d6242e4e00421b30b206e6a4e70256dc15feebc65eafa1a50b9f764069156e08c9ccb3ea0c38652791233c48d2957c65d6428d940dc716b389a31ba4669d92e363c3d236e5fa7ff4ae34e4f165efb54c99f873310936a0372d92b4494d74180de60d65982d08445d693d5b710a78408aa1860645a4a334732352a5645e42ac9f74c8b7559979a356d03b3c7deea7387f76612b5a51fb35155cc9a4594b19cc9e9d528f226cebd32b97b6da6095231694e937b64611f48393d4ff2a911575f60cd23f05b0bdf4073ad20b6978bff0eed0bb4160ece62770b1ff6b7225cf6e9b96548ee78462d47fbcc7541cce1e285e01773007a19ebb21b606a5b929f38fcca09a8780bd121c692f71ab50eb7936e97c0228213ee9dd784f16f423a222be14df83ad7ba44279b33ce0e44a954cfe081632eaba3411ca7e99310e996e86267f5eb301a16308274763f251752e883a41f34bdd7cbae55b95477cec3fbbdb474389e05aac54e6a380116027813c31b30526073d937789c33f4d2c8b576d4e1d3f8bbfe0ac4991ad3d188ebc21f78ab26ac9a3d3f06ead515bf868f7239b752f27588ba8d20bc72ff1b31f7caa691c1a7a1485a4c80f458243d54339215159736ae5eb0776b2d528ba46c5c48db4551ec192aa121d312aaf258254562eb878013785a37c31c896e63b95dcd860fa21ed8868cf3e4f44817b7fc3bcf076fc021a4481d2c7975e062cc110f5aaf9c9714c2dacc4c67f7602cce0e4e18264cf8ca1c0949a9401a77e13b11534df89dacceae7f390d43563bffe59e800457e5fb283d0228dcc06e5b482307176f632c9bd6df382617605ffcca5be263a1892cc8bb1d3e49ca2b522cf497233f98b6cc0f656fb7b15018314f2fe2887d80345bf331a0e28819b3c9433c5ffb192c13f5fd5b964a7cd925468fc0e52821de95abedfc40ff19a316b8b53731d9eeb128d6e3f53be7d0d9cd0b50923e330e108771fec24c02262fde3559c2edc75761ebd76b298b5e9ccb7bfa3e13092a550185f4533954bfe3b117066be7b0078ff0543f2540c85ed1bd1a188c962dbfb9bc3a0978d74085605df9dd35bf8ee94cb13e5f85e8d6bb7eb9c4c40e9a4c886fdc99d02758367c07a802005f0e7a2d6db80b3b4694209fef15b9ca1168e2656e623bd0637e3202fbb24f29fb3969131a45eb92e4ac2046d059e5f919908bfec0b30b25bb01a0d7f724c6b505bf75c9ef4a6671e8bb3fcbaa1b42cedf2ce71f5a0c63d5e2982abc68283bbb49ac691688d22a6ae46df6f7cb4b8705089cf36db02724eb0ee04e7afc153889512aadbfe5d165f80330f5d9381f7db309b421ec7b5a87fd5e29013fe8464bf9f50004e121cfc2df3c2b41b21892030b6192ac9e752f91f2f616b049f9b178df7fd0c661c2a53a2f5e863584834612441328f8fcdc43879ed94c89418684e24cf20c05f33d4eacf8bc3664c164a10fa26fcd1523b15bbb0d2bd869a01f8abaabce30b7e95f8cf6a431748909c550761b332c5db8a67864aa7b287176de700edaed308e009b48e250123e02cb46044dc16c413a7912c8d237b7318fcd16def4f69707e5cf614ec1029ff0df5c47582e9a85cc2dc325cab0ed6702b1d809afaeede79db6c8e78de7ef269c359a40bc6fb6c30429fd01100590af9aaad3303decf67d27718034eac238ad780c9186b4ee3a74395355f5e306519d7b920cdcb602881edf750ce776baa9cc394e0f3e979511a41150d2aacaaecfbd6fc44203fabd3057fb05e5bc2bd0427ad08bb39eb20f80e4c5493816e5d589a7d7b40fbde1fde49c060f5c9c1e50c27808bac2b6858fc521de32a26e397a73e8edc170dde9cec534589daa0948b98ba868e931938989c41c8ac077bf0ce30a522360631fa576ee8f62e7e81c2d04aa23e51339b7b6f52ff0c704e2ee5885dd8f67fd5b2ec35a30006c1c0162fafbffec7eb114f37321a4da7cbe67ac008b293dc76d48a6bcdbe8711072b8f964ab483e5796fc406828bbc2f7cbe976a8fb41b88a1f356a5de263759060e1e055b910b6cb894fbb131d518aba7d22d522c9af5f228e1e2c8d3ffbc8822b9352bd2feb009826abf785ec0c8568497dae3c80e55d9bf79bc9ff839d2181e36f463eabd149b69ec99e8b6f55dcc7c49c3c3400c8645b9bc8bf247074d5241c8d024f9658b005dabf27446c87d08e212be74ccb13f6959d9fcb50a9d9e100f38639f466e40f3458dc2b4e56948db38e0063ead8359debbb2c97d53b6f5d6cb703b202bbed410b328d662fafb1946f5a12f8ffe53f11d7214c8ec47500f030985ff7d59c98fb6e4d99b4eae448999295e799f5fc3e5b3c2915c90da5129d9ac4a87ec547124af1230c8b02af6e17b2a0176c540b991605810d80a5913e01992cac93d90c1a7300fac1031781ae818e28cc3f7587c0f07872cfb4d23a0fda8ec6e0691042488e70e030843980d759044767d1a7339a4897a9c6f5c2960e74c31c4bc36f4f7f5045ba1a2e680c8327a27937dd1a57667cd01c3ec5920bc0f1c050eeed6e4645e10e7758b5d141a01cd32dbd01cfa42bc0cc5f34e28f1eaf789900f60f54be678c65ac631afca8ec1df50fdeabffb7f70d9409c73935a1dd863eff16c2515cd957a214cd12ea32f9b82c237d08952b119b0f1714681844f204fd1c57df133701df8322caab034bf80a84b16bec6138ad9619ddd2ceaf402e558a9343886ecca9c7b1013a9cb9b97e3cda293750b5dbe7fed470a311090c7a76be5df694d2476af5e324f543af6f097c7ac55cc41de8d552ae55a1a2730184f252f51a5fd275ff8d0d7df32ccb92444d027c4dcfd0ffe6d8a42facb2f437f6ce2c89f079efd4ad16dbe371eb06c597d03cf59028c866680d257e3b2b6d67f110fbc6a75b88fe981a52b2cdb7714098c79297b2f261a760d715111971cddaf331c581e5a75742c8b401c057a22b2d3de867a933d2856c4468617c50da44a424e657ed861edca74ac62f997b8fd338e627f19930258a0035e687deb6f79a0a6b53e5fee985ef2adff2088f404564955eae475e5b0e216ef9b8d391a919b94a8c74b03528901f0ab81f10a0ed8dea75923f383cee1ad5ed7642076031ba258a4d0988d7dfc307ca73e595b83a45527e5f527b3d2fbb922130d7e9bf16e3deceb15ca86acb0727ef60f6cce12a407fd9fcbb6203d76a2ca1122f0be26f1f3b5e6459b1bddba629c1f719eee7a2b437842cbe5fbb1dffb8d52061e47a8edf143e285853cece1a715f0c28edb7d98c00e4cfca67fd51ba1a240b8da8b3f05cfd5604a1deba83ce2c7f591c46b4bf872d27879e71f972d9104fd590a80056ee54951722f50895b835bce08394f6d4557f5f8af648023d0f151e67be1d25201d873b276139dd1cf0e68fa44cc41632e28d7a06728bb9296741287933b8abef6378873428e25b635e347224408f99d06b27d729bed79c0ee410da5b276766bfe5614a87ca4b72b82a3558dd3b291bba64c3a8f0ba410b6340c9843b5ad113f2e80a0ab4f150a39055e505d5753c8277da6dc214ed4a76b100f670b84c289c3bb46cfbe211fcbc4e11b71ff0dd6899d4f6ab8726391e4d60c1f843cf16366fd39bceec6a6fb464a1e55b682b3eef7aa668286a779b6790eabb9449892a6bcff9f4b1791368a287e7c728e9927379893b69bdc725135d0cb916c7e1d78ed0e255511309e5419f8608851916fba9f1df24c3eeb464c30d7e43c069c62b21474a4242e7d903b1feaedb8cd1d256342bf5b6c5399f5298053a4c496933a43df357ff9a3c194ece840924401cd40d78856397006ca11c7682a72d1226320290cdbb15a4ff749a5897b712bd75db53ba8ded2bfa21c8f97cced17fc2bb53b8b484bcada8a977958df572e0f604680224970c174279c4a41b20a344930cf222c9849fa3412f630762f9571512d94afff3046f9d9b53378a2313f0c4709de4b1d4fc8525fb0e94d992d864f3679127a88b9c46857aa35079b983a2fe4d44c8b162cfbaf1285763e67a3a258c3d1f52f37aa5c6942c4349fbfdc5566dcc6b707af783e6db8431b719b26d1ddfbac228761c072325deec2396d00318bf2852816015c07c6ad0a0aef86de9a4ec053e1711c864c168613ad764301b537f79bfe172b9a4d3a4ee19ee5ea88a4a5eb55b180c8aacd639dc0b3b3c436f1efe87597be2529d81efbf358fe174b37e7eaca96062035f5a02a618abb7a28674273a47fb9dc8fda99856fde8629fc807802175e3c152e6da3ff28c8680168a7dd23e2b75aabfe0accf37556f8908743c7885c5abb62f1391f7a9a88fd493df71ecffddfa11b89d4e7cc05d9c54a3299fedbd5bd03fd54f608ec767002a9e2595d9624fb4afaa93a41352428eaf0827d85b9ebf35a66873a4eb91512b3725c443a29d5e8ff38645d58500cb7de3517f1c20e0642d8f3d8d3bc829aeeb6c60f72f8da2b9072352af58c03a0b33e4fe0a48d41bc8a902f8efa717933a1828e9236cb097681e4ce8593c436cc47134a6acc68a2bb839f31eea0a29012da23df359a619f100afb8e4b458e31f98a1ac08db341219fd89277aa99d90e1ccdd03269b5b80aa563be3fa8446ad3b2bfd73c1fc63d56d9ad8a40970709bbff27673d26686a55b00664455e427a2a85ce6857c7c8481c0d23e5eea0d4ca9f0039db7653660b7afedee2124b023e325546d13f4d7c20c76358cdcb18c8cf12a65efd8520819b8baa9d71fe6582bffc7707afa24fdcc6bc8bb3851660542c367f6195504335ab08060d58a47f0b851cdc91c884a400200c1e104b04dc3d2d6aff5b39f4ff4e0bfc0e67c25cc09884fb733e33293bf5a2c166f7fdb947a0ff93512d24d9f52fbac77dec9f5e9ccf053500d3033c570b5f18564a1096b20fa81cfe9377b0f0b93352d429e28fde3090e280a593414c272f12bb51b806791f5c08e29972db34884dec9e7f8045423a15f022e7f687d26cb94df64b745cd9e8d9c157b2a83ae37d6ebe662b0e6c861fc47a468cde2b2f26eef648a155ab1afb428be2cc2b6fbd1eb7b11093042d3c1384b0e546c1757ff543c4922d1ab1aeec0d802a386b2bbd8513ccbc90648fdd45e8da71ea9a7c70054629f4784dfb852c299294900b0fa6fc0b56be1f5d1cbe1011bc6d5aa6fd4a7ead309d35f954d60c81da809a0b2bd3e21c6762b535f4b682239f13b0c176c121751d61f6d4a52b19dd722b236344de8aa3a60e66f0f3c04a604ba5f5b6c66286f20e32b49022081ab7a23f9ba41d8291681f0639c9c2546c95765c569de5902ffca4bd5e5f4bff5ba0897d091010721464a742e7c5f115613b4f5d70c01552ae693c7879633729c12012ceb9d7c61624c58f7d9ead586c80fef1b25760aaf6140f2bbc4b7f2580955523ff311001e8b0159b5a02270f095c9c5aef0d65415c727b6a7973fc5785ebbd1aeeee827d680659a6a465e8ebc3d1b2d809855b5ecd8e1b45659bd940dc2e3b29f3204bff8ec8bcb96fb6fa443a1d5da52da25122e61773996d09912474a2332583dcf5893e0d99034de77e84adbd6e516bf592fe6e5e0f78c6a2c7f9fc288a9648d359a43f4e6da8ad18ffba0a2abcf1025f39eadc00e2789fcef34fccab92e55b05423e377ebab91e4f52eddd456afe532ae3841714d4f17404623b0fc06ad02735fcbf3bc0af308433dcb9bd2ffaa497844d12bec2686d65cfcbcf6004b36e74b5b2fe4838285c2f0747ea825c297fb1db32bfd53e6c100aa2e803d215a173bd24ddc1c96df29d71b924e3f4f459fa41dc7b8b8cf0d3a0eacb44500158b46bc121fd4bfa84d68c449f633de5bf63afdac6d283d1aa9d4d3011cad2671d821253e231ca7d5d74491f5a3c4d9e06253ce347e71521c7f5444f99c73a0db8347fea51ea12e27a774f7acc22d4e8bc7527500da28d4d6faabad6e02c973877b63fe7422a311da88bc39c92b04f6915e59c8db6a81ff488a535a84380b353c180e82504573531362fdc303f67c6adebfc86b87306dd3b20fbeaf4348a712adee7f5d49b63eecc90cad25d11113277b09da2919afda1f09576779bd9f26b8774b5162308de6781552ec8c1f21429d717755ad594c53c54054c209e8761d0e5d34468c31ae385ca03df17b683914c8bdba0e8b794fd61c71430cf51b725bfba1e858129ea3600505d004a8c6712a57774a951f36bf170dbb5f5b3076a0ee2803b8d83fa9a49b395b240ecf1c95abc7acfc4236d979ee0597bb756eb95d65d14d099dc494473232ad9f4e53784c918f6d2a48a506b3c14ec689ff8461446273618459bde103adab1f9ac581bdc41e12ebc71463080d9b290daaf84af3a278854e74cb63b05cc46f2e709acc1d23cee5fb28a4398174fa61f7dd96946b7313bb07f1272826359e9434cbcdd54dd1f2b827bbf5179d8a1f42160f6113421795cd697394b05a79478216e516c3b36abc9cb6885eef7d90aa711d89a67d2e9090417bc66e68d98d11e4a67a3f34f71b9014ef64ca671656b61e65cbeb147d3962f236ffc3409cc3adb7039999db034c71935af7f1013b8471267717f27c5120919355817388efb8fe8e5d304e041c96384e81660cbd3cf8e16b6a205b1bfeb3a8c413de9bdd83884680b5c3c24eb1139f5c930cfd3732bb846607a5b69df385506ef03f8197a668b894c6f6e4395c417d1a972dc1479f139c0f2b4b78e4288eca556d734f84ac2108e0f9b212a09bcdee6355a5c960f05f742705d1fc6b381902fb97239e7023b4d949ae696c49ddee6ff2cb5725d3943cdf1d79ae4d9eff9fe4c29710c3d1dc23062e4b4c6e0e63557e3e8d4cfe76aa6aacae5ebd45fafe1375750a229425ec88fabfe468ba6bee47bed2700c988ac27ddd7da34204a283b231f16fc8601054cc9f953e0daec5ba9ce893786325181f20f02a85200407452ddbe27f8c3d74acc1237f36945ee410a92e76de73beddc06846ca73f8311c3f462a315e30137142f7476dfa5b6836b278e1bc95c58bbbc612807ae7c3edfd44a515960ea099e5aa2710ef30ad6aeb48b62fa1df97a5ac079d4432392aa167b1c1685448fad27ff16b20209a911e78d90e7fcb4cc1a2f1b7becdf8911780bca8e1b59212f9a6580fbccbceefdc600775957fede742a29b4ca99c22dc0be790364cecaeafb21e66717d552748f6253b5dedbc1ae3127aaa851751291713358ed4ee646e83f0327626b3e1124006c0f6711789c1bf2a3d448809dc49633bd9d9c252a0dbfebe3d0cef75b5756cf11c647c03de86e7f1cc7b04daf2f899df03f33fe33ae53c3b9c82c9fb642017f468cf5091d26f8448e0cf17b1ab64043ea12e798bebcb48e4fcd8da70f11e1d075f93e6b3c54fa71488e8a1bf4a1f77c6f9d0c46b878a6a55f0eed14beab6d0405437917bccf514356c921735d08876074a4c3df4c051b55fbbe6d07dc5eb5385893dcb9374090b2203850eda10859a3f3b67be2f0d98a08b3252ce1bb1aed21864ba880fad7102859cd26675a4693e93b5d975923bbff438692f87629261aeb6cf3c41fbea485b34da21fa063e456d316c2e6089fa0b85aa0ad3c5be3b112a1dbe022b5f84da5753e95b056d31cf839e90078da6145f7532b2dd7ecc52f34e607cbd8b17971da428cf1ea4f340616448f3138a8e1ebec6bdb30b2fc39c05f84e4334bda3a8b781523f8bdc0d03efd2a938b0ecae4de2657ff86077dc1eae70b9e6a92795733520ae64d9fd8ffa77c779734a39894982da616a6801c7d1ec08d11ad4634060f43f36d460beb1f4a0908d9994b4fa0acb046c0db0b87b35098cdd5fe8ba6579280766e530e77ff457a0fe2ba7d600a76e8af33ca5c26deaf80d3f0fcc0b772896625f6aa16e55362245e91184be17a872dd328d29a6cc07d3c58c30f29769e39ed697ce10c42ca6c22c2b7a9603ca7e86c9eac2e13d6a965c2f69da5397100844e5440e4a1f78816710317f0df11153d3269874fc37226b0e9d2fbc7b9e0fc8926f5aafd6ec16092fe6b4912bebff5ddb788ba535b19d213a799011a749bfbec0b903fbcea4c029b249ff87687f924a68601f0cd8678595c43dd6cf73bd888c2bce361624b71d4c9a43819064ee17a871facf26cc111afa818a50be1aa198d4b8e8e857966173d1e0af3264d51198beddc1977a142b2a72c3960ae9c15a8a8bec9516cb95212b2d85e4435c57d12fa74db96ac83e9a4302b6c97ccdf8145dc0d92c993fa0a98447dccd1c3a8a5c7e48c3da1e30b51f9008785d367270d51e23e89b5135f086837d9bd5abf0d1b57deb4045be551c00c2fa1c0323bed5b38156c8991e32c5dd182c1d7a016be528f5c0cdcb4083bfd2632e9c5f15920181a5b2908bf39a8870a11a3294bad33daed86fb0a4c2d880ce368d5313c062757d54cc075aad1cfacc23968ab128c992c4213a1ccb7a7c90a389b54f62d867e7f64c1552a57aa6eb1eeb94ad654c16888193d0a7b2036dd871bb3209e43b5ccddc421629ba37b901f9d73a30ef44db8ee6dcee7ac46df82734b10246ad5cc63cbbb966ef90b92305da58b5da518361b52b94324741370c6de46235d501871a350e5c37f0435cb7ee765e164a0cfea8453a9387041207ad578069b804911f230c63ca1f1f890a189d1e0b3a62d4204fc19751a2442b5c4e016c331f29689e3831e8897f8bdeefb5f45cca0bec38dc357d056fefac4b0c26e00bee268b81730cc662b31623a1b8f36fc060c3c4447c7262c53210ba61e535636f782b9ed3858ebe96b17e8737176715ccee635c6af3b28b4cf956cc85942ffdb96a3d97d8288e6ea30ba21d907d3852b7757068c0f0aab03bdf9d104a138f90ac1535e27745a5bbb5b3b54d0b8e117e3de2a0e3487563fd6b9db307a78984f794b9e321360c08ab2b4c50f82e13adc600b6742ea88f2804856ba6c75a60d672aef5e5ce8499c86b92b8e4650896920ca00861a8c7046cac670e1551da8e1e8b748ed2a849d1433ffc2624c6a5b02459655c30c01d03b8e7a80b57b300d5167a6d1056d45b9e35eec8d1fac9225f201c98d53b058b83e8bbf407f949cca1c8e8f8599e0d1b484157d0d329504f7c3ab63811080408ce46bffdf42ffc3c0358e6244ba6baaa4dc87c2f9c35bbebd4f0cd7174954d3a9b73c5c866a5bf79629f7dee0375a2e958f7d38757c583317ac272fde7bc2f0579e64d375f75b9cd91ef5ec896672792fdac38995c5ec3994b32e2c2087fb2005d463bec64cdb7f4a6903c79b120bd282d0c590e1e4ce715eac8fe57df3e8d2c789b67371754980253e3c848b70876a15c57b5f1b6cab3f3db275c6282b13922cd06b8d59702885d6c1338d85b7b74c20d11badb9dea0c109bb5a901193a1cde47b4f2cd5c3e9aed61c13eaebc95f81a917f8b50296e4a8b32bca65f91416799e3eb0d2b9b397fcb043bd025bed6d753bc0d5893f1b6555f89cd99fc8425a78eb4d48fc0315fb2b41a93b19d3ad8cdbaa0f5b43a3c8091561f129c03f5c6d4b536856370821db1f89ac08e409e2a0709eda1cd35d5f1a1e242e7c4b4881493ae8689cd6ed81ad041e6dc6af0190a95343e12ca2f2a69d6c041cf50dcfbe788a7dbdcd0b4a08d843389510aa53952bafe854900ab31380507e14c503035a810435fea16512d677796ee56795806c42650806ce66ac3bdbecb51df88dad7c615147890fcb09d08f1d26d7803b7ef046d8b43315ce462e1bdebd5f2c42aef31d0bbd8b96a53392e143ed537f5dcaa21a140e2589e60b6fe876277b22869bab13d7a96bfbe0c955049f5392a9d28108e3285d14de424c95f1da8ba450575309c08922bca613503422376f14c0b601cec093dbd28493d6f029cbdd5c1a49a02b966a207c22c6d422596830d96a67cb5547ee0544d9ee7dd2cc1ee427d30d8c4e283a8ff0e381bced3da02060787b78967e73d2c0207cc60085f68134224db5f0642e0865432e91f5a1567d144ae82e735d68e18b7fdc8fefa2d03cad2a22f77cffa40c2a33dca87864d0a03f37cc9e5f43f25e662971619d5039ab633a7e112ce5dbf8bce5108dfa8989c0854b75d0a68c47524c88cc1eb9eb2f35a300fb2d49af5d14c2d9d9d5ade1b0512b444a85e5956bd8669fd021e5278243f9bd5e7fcb0d3371d2e92fdba9fd2e2838c90c104da75ca718e8a6b44c3b91da6566d15095d4aec5b1e30ae36a56b04b94999ac5410bd8bab616de7a877fc5c9b69cc713489ad546adaf984f7d710b44cdf8dd44661f345a883f19dccdc7eba0d6b4b3eaf2a812537851e0c7919c2a0fffed148243cdeef5410afc3c9d87bbe5d146109596d702bf83507987ced08b7a444c88d55c441c1fc0a2f9f0faef2961626c8257117cbf2748ecc62337731c62cb978c55bda49e5079afb8c1e0af66ef3114dfcdff4cc997e4026a670187bac897c7d1ef75cc4b336e0341eadb6c8aabfc9d54bf8a574ad2256d6b462d7adda446789a86c3065f8b5170b476505dc1d7a93b21fe44ed4319c8509f0807cf3cf37878cefe68e6fc06b78c7782a192fae6fcd9add10dcd69ce90d8d460316ce56dffca0a2f9e40ce39c3e22cf2182fc086e170f1aa20434b118493d9a55180f40d37800ed891492b5618b805a74d2a7cc48897cf5361b6c57a5a0714349405e83d5b067aae0c3d4e6771f969118e4b3f93ef194f2c9d8aa217bdc01e75762fa391482199797b2dc443bb2cc5213c0a5df50b0a18af79758863cca2dc3c6879fd693d7a1ac6c96914b1e44010daa9c3b77efcff2e8303fd7e0e219d87f143fa36ba0be0dcdc01f2c2cb3b5a87613691a17bf3999ed78ba65d540bbd0de15488e8d923da100a3cbe055e79fe1e124abe4666e5179abe4c45df5a9a25f7a45b8ae9d06aaad2c3c60a51f471359cbd6146d99972a7e808527a2fef13f92da9d9a333b074b834989059483c55d96358e8e620c6d6fed865a81a0d472fa14b79643d50af5802ada18f794cd8018f7ead5f07279e0fa073595c8308e0732973e3b6118e8a37f04a3679d05b0424620c2c778617761f9d5c3e3b80f602103539484919557931549c53698d24ae658f5dc40994730ecba99027abebb22b2cfc9fffb436b4ca3359504` ================================================ FILE: pkg/services/templateBOOTy.go ================================================ package services import ( "encoding/json" "net" "github.com/plunder-app/BOOTy/pkg/plunderclient/types" "github.com/vishvananda/netlink" ) //BuildBOOTYconfig - Creates a new presseed configuration using the passed data func (config *HostConfig) BuildBOOTYconfig() string { a := types.BootyConfig{} // set the required action a.Action = config.BOOTYAction // Default to false if not in configuration if config.Compressed == nil { a.Compressed = false } else { a.Compressed = *config.Compressed } // Parse the strings subnet := net.ParseIP(config.Subnet) ip := net.ParseIP(config.IPAddress) // Change into a cidr cidr := net.IPNet{ IP: ip, Mask: subnet.DefaultMask(), } addr, _ := netlink.ParseAddr(cidr.String()) // Set configuration if addr != nil { a.Address = addr.String() a.Gateway = config.Gateway } // READ a.DestinationDevice = config.DestinationDevice a.SourceImage = config.SourceImage // WRITE a.DesintationAddress = config.DestinationAddress a.SourceDevice = config.SourceDevice // Default to false if not in configuration if config.GrowPartition == nil { a.GrowPartition = 0 } else { a.GrowPartition = *config.GrowPartition } a.LVMRootName = config.LVMRootName // Default to false if not in configuration if config.ShellOnFail == nil { a.DropToShell = false } else { a.DropToShell = *config.ShellOnFail } a.DropToShell = *config.ShellOnFail a.NameServer = config.NameServer b, _ := json.Marshal(a) return string(b) } ================================================ FILE: pkg/services/templateESXi.go ================================================ package services import ( "fmt" "strings" ) // bootcfgHead const, this is the basis for the configuration that will be modified per use-case for the boot.cfg // The main modifications with regards to this template is a replace-all on the module and kernel path const bootcfg67u2 = `bootstate=0 title=Loading Plunder ESXi installer timeout=5 prefix=http://%s/vsphere kernelopt=runweasel build= updated=0 kernel=b.b00` // The Modules list all of the required modules needed to deploy vSphere const modules67us = `modules=/jumpstrt.gz --- /useropts.gz --- /features.gz --- /k.b00 --- /chardevs.b00 --- /user.b00 --- /procfs.b00 --- /uc_intel.b00 --- /uc_amd.b00 --- /uc_hygon.b00 --- /vmx.v00 --- /vim.v00 --- /sb.v00 --- /s.v00 --- /ata_liba.v00 --- /ata_pata.v00 --- /ata_pata.v01 --- /ata_pata.v02 --- /ata_pata.v03 --- /ata_pata.v04 --- /ata_pata.v05 --- /ata_pata.v06 --- /ata_pata.v07 --- /block_cc.v00 --- /bnxtnet.v00 --- /bnxtroce.v00 --- /brcmfcoe.v00 --- /char_ran.v00 --- /ehci_ehc.v00 --- /elxiscsi.v00 --- /elxnet.v00 --- /hid_hid.v00 --- /i40en.v00 --- /iavmd.v00 --- /igbn.v00 --- /ima_qla4.v00 --- /ipmi_ipm.v00 --- /ipmi_ipm.v01 --- /ipmi_ipm.v02 --- /iser.v00 --- /ixgben.v00 --- /lpfc.v00 --- /lpnic.v00 --- /lsi_mr3.v00 --- /lsi_msgp.v00 --- /lsi_msgp.v01 --- /lsi_msgp.v02 --- /misc_cni.v00 --- /misc_dri.v00 --- /mtip32xx.v00 --- /ne1000.v00 --- /nenic.v00 --- /net_bnx2.v00 --- /net_bnx2.v01 --- /net_cdc_.v00 --- /net_cnic.v00 --- /net_e100.v00 --- /net_e100.v01 --- /net_enic.v00 --- /net_fcoe.v00 --- /net_forc.v00 --- /net_igb.v00 --- /net_ixgb.v00 --- /net_libf.v00 --- /net_mlx4.v00 --- /net_mlx4.v01 --- /net_nx_n.v00 --- /net_tg3.v00 --- /net_usbn.v00 --- /net_vmxn.v00 --- /nfnic.v00 --- /nhpsa.v00 --- /nmlx4_co.v00 --- /nmlx4_en.v00 --- /nmlx4_rd.v00 --- /nmlx5_co.v00 --- /nmlx5_rd.v00 --- /ntg3.v00 --- /nvme.v00 --- /nvmxnet3.v00 --- /nvmxnet3.v01 --- /ohci_usb.v00 --- /pvscsi.v00 --- /qcnic.v00 --- /qedentv.v00 --- /qfle3.v00 --- /qfle3f.v00 --- /qfle3i.v00 --- /qflge.v00 --- /sata_ahc.v00 --- /sata_ata.v00 --- /sata_sat.v00 --- /sata_sat.v01 --- /sata_sat.v02 --- /sata_sat.v03 --- /sata_sat.v04 --- /scsi_aac.v00 --- /scsi_adp.v00 --- /scsi_aic.v00 --- /scsi_bnx.v00 --- /scsi_bnx.v01 --- /scsi_fni.v00 --- /scsi_hps.v00 --- /scsi_ips.v00 --- /scsi_isc.v00 --- /scsi_lib.v00 --- /scsi_meg.v00 --- /scsi_meg.v01 --- /scsi_meg.v02 --- /scsi_mpt.v00 --- /scsi_mpt.v01 --- /scsi_mpt.v02 --- /scsi_qla.v00 --- /shim_isc.v00 --- /shim_isc.v01 --- /shim_lib.v00 --- /shim_lib.v01 --- /shim_lib.v02 --- /shim_lib.v03 --- /shim_lib.v04 --- /shim_lib.v05 --- /shim_vmk.v00 --- /shim_vmk.v01 --- /shim_vmk.v02 --- /smartpqi.v00 --- /uhci_usb.v00 --- /usb_stor.v00 --- /usbcore_.v00 --- /vmkata.v00 --- /vmkfcoe.v00 --- /vmkplexe.v00 --- /vmkusb.v00 --- /vmw_ahci.v00 --- /xhci_xhc.v00 --- /elx_esx_.v00 --- /btldr.t00 --- /esx_dvfi.v00 --- /esx_ui.v00 --- /esxupdt.v00 --- /weaselin.t00 --- /lsu_hp_h.v00 --- /lsu_inte.v00 --- /lsu_lsi_.v00 --- /lsu_lsi_.v01 --- /lsu_lsi_.v02 --- /lsu_lsi_.v03 --- /lsu_smar.v00 --- /native_m.v00 --- /qlnative.v00 --- /rste.v00 --- /vmware_e.v00 --- /vsan.v00 --- /vsanheal.v00 --- /vsanmgmt.v00 --- /tools.t00 --- /xorg.v00 --- /imgdb.tgz --- /imgpayld.tgz` // kickstart67u2 const, this is the template for the actual installation of ESXi const kickstart67u2 = `accepteula install --firstdisk --overwritevmfs rootpw %s reboot # vmserialnum --esx=PUT IN YOUR LICENSE KEY #network configuration network --bootproto=static --addvmportgroup=1 --ip=%s --netmask=%s --gateway=%s --nameserver=%s --hostname=%s # run the following command only on the firstboot %%firstboot --interpreter=busybox # enable & start remote ESXi Shell (SSH) vim-cmd hostsvc/enable_ssh vim-cmd hostsvc/start_ssh # enable & start ESXi Shell (TSM) vim-cmd hostsvc/enable_esx_shell vim-cmd hostsvc/start_esx_shell # enable High Performance # http://www.virtuallyghetto.com/2012/08/configuring-esxi-power-management.html esxcli system settings advanced set --option=/Power/CpuPolicy --string-value="High Performance" # supress ESXi Shell shell warning - Thanks to Duncan (http://www.yellow-bricks.com/2011/07/21/esxi-5-suppressing-the-localremote-shell-warning/) esxcli system settings advanced set -o /UserVars/SuppressShellWarning -i 1 #Disable ipv6 esxcli network ip set --ipv6-enabled=0 # NTP Configuration (thanks to http://www.virtuallyghetto.com) cat > /etc/ntp.conf << __NTP_CONFIG__ restrict default kod nomodify notrap noquerynopeer restrict 127.0.0.1 server 129.6.15.28 server 129.6.15.29 server 129.6.15.30 __NTP_CONFIG__ /sbin/chkconfig ntpd on ` //BuildESXiConfig - Creates a new presseed configuration using the passed data func (config *HostConfig) BuildESXiConfig() string { modules := strings.Replace(modules67us, "/", "", -1) vSphereConfig := fmt.Sprintf("%s\n%s", fmt.Sprintf(bootcfg67u2, config.RepositoryAddress), modules) return vSphereConfig } //BuildESXiKickStart - Creates a new presseed configuration using the passed data func (config *HostConfig) BuildESXiKickStart() string { // vSphere Kickststart vKickStart := fmt.Sprintf(kickstart67u2, config.Password, config.IPAddress, config.Subnet, config.Gateway, config.NameServer, config.ServerName) return vKickStart } ================================================ FILE: pkg/services/templateKickstart.go ================================================ package services import "fmt" // This initial template will be modifiable based upon the build requirements const kickstartFile = ` install cdrom lang en_US.UTF-8 keyboard us unsupported_hardware network --bootproto=dhcp --hostname centos-7.pelmet.loc rootpw vagrant firewall --disabled selinux --permissive timezone Europe/Prague unsupported_hardware bootloader --location=mbr text skipx zerombr clearpart --all --initlabel #Disk partitioning information part /boot --fstype ext4 --size=2048 part swap --asprimary --size=8192 part / --fstype ext4 --size=1 --grow auth --enableshadow --passalgo=sha512 --kickstart firstboot --disabled eula --agreed services --enabled=NetworkManager,sshd reboot user --name=vagrant --plaintext --password vagrant --groups=vagrant,wheel repo --name=base --baseurl=http://mirror.centos.org/centos/7.3.1611/os/x86_64/ repo --name=epel-release --baseurl=http://anorien.csc.warwick.ac.uk/mirrors/epel/7/x86_64/ repo --name=elrepo-kernel --baseurl=http://elrepo.org/linux/kernel/el7/x86_64/ repo --name=elrepo-release --baseurl=http://elrepo.org/linux/elrepo/el7/x86_64/ repo --name=elrepo-extras --baseurl=http://elrepo.org/linux/extras/el7/x86_64/ %packages --ignoremissing --excludedocs @Base @Core @Development Tools kernel-ml kernel-ml-devel kernel-ml-tools kernel-ml-tools-libs kernel-ml-headers openssh-clients expect make perl patch dkms gcc bzip2 sudo openssl-devel readline-devel zlib-devel net-tools vim wget curl rsync epel-release ansible libselinux-python -abrt-libs -abrt-tui -abrt-cli -abrt -abrt-addon-python -abrt-addon-ccpp -abrt-addon-kerneloops -kernel -kernel-devel -kernel-tools-libs -kernel-tools -kernel-headers -aic94xx-firmware -atmel-firmware -b43-openfwwf -bfa-firmware -ipw2100-firmware -ipw2200-firmware -ivtv-firmware -iwl100-firmware -iwl105-firmware -iwl135-firmware -iwl1000-firmware -iwl2000-firmware -iwl2030-firmware -iwl3160-firmware -iwl3945-firmware -iwl4965-firmware -iwl5000-firmware -iwl5150-firmware -iwl6000-firmware -iwl6000g2a-firmware -iwl6000g2b-firmware -iwl6050-firmware -iwl7260-firmware -libertas-usb8388-firmware -libertas-sd8686-firmware -libertas-sd8787-firmware -ql2100-firmware -ql2200-firmware -ql23xx-firmware -ql2400-firmware -ql2500-firmware -rt61pci-firmware -rt73usb-firmware -xorg-x11-drv-ati-firmware -zd1211-firmware -iprutils -fprintd-pam -intltool # unnecessary firmware -aic94xx-firmware -atmel-firmware -b43-openfwwf -bfa-firmware -ipw2100-firmware -ipw2200-firmware -ivtv-firmware -iwl100-firmware -iwl1000-firmware -iwl3945-firmware -iwl4965-firmware -iwl5000-firmware -iwl5150-firmware -iwl6000-firmware -iwl6000g2a-firmware -iwl6050-firmware -libertas-usb8388-firmware -ql2100-firmware -ql2200-firmware -ql23xx-firmware -ql2400-firmware -ql2500-firmware -rt61pci-firmware -rt73usb-firmware -xorg-x11-drv-ati-firmware -zd1211-firmware %end %post yum update -y yum install -y sudo echo "vagrant ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/vagrant sed -i "s/^.*requiretty/#Defaults requiretty/" /etc/sudoers /bin/echo 'UseDNS no' >> /etc/ssh/sshd_config yum clean all /bin/mkdir /home/vagrant/.ssh /bin/chmod 700 /home/vagrant/.ssh /bin/echo -e 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzIw+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoPkcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NOTd0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcWyLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQ== vagrant insecure public key' > /home/vagrant/.ssh/authorized_keys /bin/chown -R vagrant:vagrant /home/vagrant/.ssh /bin/chmod 0400 /home/vagrant/.ssh/* %end ` // BuildKickStartConfig - Creates a new presseed configuration using the passed data func (config *HostConfig) BuildKickStartConfig() string { return fmt.Sprintf("%s%s%s%s%s%s", preseed, preseedDisk, preseedNet, preseedPkg, preseedUsers, preseedCmd) } ================================================ FILE: pkg/services/templatePreseed.go ================================================ package services import ( "encoding/base64" "fmt" log "github.com/sirupsen/logrus" ) // Preseed const, this is the basis for the configuration that will be modified per use-case const preseedHead = ` # Force debconf priority to critical. debconf debconf/priority select critical # Override default frontend to Noninteractive debconf debconf/frontend select Noninteractive # Preseeding only locale sets language, country and locale. d-i debian-installer/locale string en_US # Disable automatic (interactive) keymap detection. d-i console-setup/ask_detect boolean false d-i keyboard-configuration/layoutcode string us ### Clock and time zone setup d-i clock-setup/utc boolean true d-i time/zone string Europe/GMT d-i clock-setup/ntp boolean true d-i clock-setup/ntp-server string 1.pl.pool.ntp.org ### Preseed Early d-i preseed/early_command string kill-all-dhcp; netcfg ` const preseedNet = ` ### Network configuration d-i netcfg/wireless_wep string # Set network interface or 'auto' d-i netcfg/choose_interface select %s # Any hostname and domain names assigned from dhcp take precedence over d-i netcfg/get_gateway string %s d-i netcfg/get_ipaddress string %s d-i netcfg/get_nameservers string %s d-i netcfg/get_netmask string %s d-i netcfg/use_dhcp string d-i netcfg/disable_dhcp boolean true d-i netcfg/get_hostname string ubuntu d-i netcfg/get_domain string internal d-i netcfg/hostname string %s` const preseedLVMDisk = ` d-i partman-auto/method string lvm # If one of the disks that are going to be automatically partitioned # contains an old LVM configuration, the user will normally receive a # warning. This can be preseeded away... d-i partman-lvm/device_remove_lvm boolean true # The same applies to pre-existing software RAID array: d-i partman-md/device_remove_md boolean true # And the same goes for the confirmation to write the lvm partitions. d-i partman-lvm/confirm boolean true # This makes partman automatically partition without confirmation, provided # that you told it what to do using one of the methods above. d-i partman-partitioning/confirm_write_new_label boolean true d-i partman/choose_partition select finish d-i partman/confirm boolean true d-i partman/confirm_nooverwrite boolean true # This makes partman automatically partition without confirmation. d-i partman-md/confirm boolean true # LVM confifirmation d-i partman-lvm/confirm boolean true d-i partman-lvm/confirm_nooverwrite boolean true d-i partman-partitioning/confirm_write_new_label boolean true d-i partman/choose_partition select finish d-i partman/confirm boolean true d-i partman/confirm_nooverwrite boolean true d-i partman-basicfilesystems/no_swap boolean false ### Finishing up the installation d-i finish-install/reboot_in_progress note d-i cdrom-detect/eject boolean true ### Preseeding other packages popularity-contest popularity-contest/participate boolean false ` const preseedLVMDiskRecipe = ` d-i partman-auto/choose_recipe select parlayfs d-i partman-auto/expert_recipe string \ parlayfs :: \ 269 269 269 ext4 $primary{ } $bootable{ } \ $defaultignore{ } \ $lvmignore{ } \ mountpoint{ /boot } \ method{ format } \ format{ } \ use_filesystem{ } \ filesystem{ ext4 } \ . \ 900 10000 -1 ext4 $lvmok{ } \ mountpoint{ / } \ lv_name{ lv_root } \ in_vg { vg-root } \ method{ format } \ format{ } \ use_filesystem{ } \ filesystem{ ext4 } \ . ` const preseedLVMDiskRecipe2 = ` d-i partman-auto/choose_recipe select parlayfs d-i partman-auto/expert_recipe string \ parlayfs :: \ 269 269 269 ext4 $primary{ } $bootable{ } $defaultignore{ } $lvmignore{ } mountpoint{ /boot } method{ format } format{ } use_filesystem{ } filesystem{ ext4 } . \ 900 10000 -1 ext4 $lvmok{ } mountpoint{ / } lv_name{ root } in_vg { ubuntu-vg } method{ format } format{ } use_filesystem{ } filesystem{ ext4 } . ` const preseedLVMDiskDisableSwap = ` # will result in a zero swapfile being created d-i partman-swapfile/percentage string 0 d-i partman-swapfile/size string 0 ` const preseedDiskAtomic = ` d-i partman-auto/choose_recipe select atomic ` const preseedDisk = ` ### Partitions d-i partman/mount_style select label ### Boot loader installation d-i grub-installer/only_debian boolean true d-i grub-installer/with_other_os boolean true ### Finishing up the installation d-i finish-install/reboot_in_progress note d-i cdrom-detect/eject boolean true ### Preseeding other packages popularity-contest popularity-contest/participate boolean false ### GRUB grub-pc grub-pc/hidden_timeout boolean true grub-pc grub-pc/timeout string 0 d-i grub-installer/bootdev string /dev/sda ### Regular, primary partitions d-i partman-auto/disk string /dev/sda #d-i partman/alignment string cylinder d-i partman/confirm_write_new_label boolean true d-i partman-basicfilesystems/choose_label string gpt d-i partman-basicfilesystems/default_label string gpt d-i partman-partitioning/choose_label string gpt d-i partman-partitioning/default_label string gpt d-i partman/choose_label string gpt d-i partman/default_label string gpt #d-i partman-partitioning/confirm_write_new_label boolean true d-i partman-basicfilesystems/no_swap boolean false d-i partman/choose_partition select finish d-i partman/confirm boolean true d-i partman/confirm_nooverwrite boolean true d-i partman-auto/method string regular d-i partman-auto/choose_recipe select parlayfs d-i partman-auto/expert_recipe string \ parlayfs :: \ 1 1 1 free \ $bios_boot{ } \ method{ biosgrub } . \ 200 200 200 fat32 \ $primary{ } \ method{ efi } format{ } . \ 512 512 512 ext3 \ $primary{ } $bootable{ } \ method{ format } format{ } \ use_filesystem{ } filesystem{ ext3 } \ mountpoint{ /boot } . \ 1000 20000 -1 ext4 \ $primary{ } \ method{ format } format{ } \ use_filesystem{ } filesystem{ ext4 } \ mountpoint{ / } . \ ` const swap = ` 65536 65536 65536 linux-swap \ $primary{ } \ method{ swap } format{ } .` const noswap = ` partman-basicfilesystems partman-basicfilesystems/no_swap boolean false ` const preseedUsers = ` ### Account setup d-i passwd/root-login boolean false d-i passwd/make-user boolean true d-i passwd/user-fullname string %s d-i passwd/username string %s d-i passwd/user-password password %s d-i passwd/user-password-again password %s d-i user-setup/allow-password-weak boolean true d-i user-setup/encrypt-home boolean false ` const preseedPkg = ` ### Apt setup d-i apt-setup/restricted boolean true d-i apt-setup/universe boolean false di- apt-setup/security_host %s d-i apt-setup/security_path string %s d-i mirror/http/hostname string %s d-i mirror/http/directory string %s d-i mirror/country string manual d-i mirror/http/proxy string ### Base system installation d-i base-installer/install-recommends boolean false ### Package selection tasksel tasksel/first multiselect tasksel/skip-tasks multiselect server d-i pkgsel/ubuntu-standard boolean false # Allowed values: none, safe-upgrade, full-upgrade d-i pkgsel/upgrade select none d-i pkgsel/ignore-incomplete-language-support boolean true d-i pkgsel/include string %s # Language pack selection d-i pkgsel/install-language-support boolean false d-i pkgsel/language-pack-patterns string d-i pkgsel/language-packs multiselect # or ... #d-i pkgsel/language-packs multiselect en, pl #d-i debian-installer/allow_unauthenticated boolean true # Policy for applying updates. May be "none" (no automatic updates), # "unattended-upgrades" (install security updates automatically), or # "landscape" (manage system with Landscape). d-i pkgsel/update-policy select unattended-upgrades d-i pkgsel/updatedb boolean false ` const preseedCmd = ` d-i preseed/late_command string \ in-target sed -i 's/^%%sudo.*$/%%sudo ALL=(ALL:ALL) NOPASSWD: ALL/g' /etc/sudoers; \ in-target /bin/sh -c "echo 'Defaults env_keep += \"SSH_AUTH_SOCK\" >> /etc/sudoers"; \ in-target mkdir -p /home/%s/.ssh; \ in-target /bin/sh -c "echo '%s' >> /home/%s/.ssh/authorized_keys"; \ in-target chown -R %s:%s /home/%s/; \ in-target chmod -R go-rwx /home/%s/.ssh/authorized_keys; \ in-target sudo sed -i '/ swap / s/^/#/' /etc/fstab ` //BuildPreeSeedConfig - Creates a new presseed configuration using the passed data func (config *HostConfig) BuildPreeSeedConfig() string { var key []byte var err error // Check the key has been populated if config.SSHKey == "" { log.Errorf("This server [%s] is being deployed with no SSH Key", config.ServerName) } else { // Decode the base64 into the SSH key key, err = base64.StdEncoding.DecodeString(config.SSHKey) if err != nil { log.Errorf(err.Error()) } } var parsedDisk string if *config.LVMEnable { // We're using LVM, check if swap should be disabled or not if *config.SwapDisabled { parsedDisk = preseedLVMDisk + preseedLVMDiskRecipe2 + preseedLVMDiskDisableSwap } else { parsedDisk = preseedLVMDisk + preseedLVMDiskRecipe } } else { if *config.SwapDisabled { parsedDisk = preseedDisk + noswap } else { parsedDisk = preseedDisk + swap } } parsedNet := fmt.Sprintf(preseedNet, config.Adapter, config.Gateway, config.IPAddress, config.NameServer, config.Subnet, config.ServerName) parsedPkg := fmt.Sprintf(preseedPkg, config.RepositoryAddress, config.MirrorDirectory, config.RepositoryAddress, config.MirrorDirectory, config.Packages) parsedCmd := fmt.Sprintf(preseedCmd, config.Username, key, config.Username, config.Username, config.Username, config.Username, config.Username) parsedUsr := fmt.Sprintf(preseedUsers, config.Username, config.Username, config.Password, config.Password) return fmt.Sprintf("%s%s%s%s%s%s", preseedHead, parsedDisk, parsedNet, parsedPkg, parsedUsr, parsedCmd) } ================================================ FILE: pkg/services/templateUtils.go ================================================ package services import ( "encoding/base64" "io/ioutil" "os" "strings" ) // ReadKeyFromFile - will attempt to read an sshkey from a file and populate the struct func (c *HostConfig) ReadKeyFromFile() (string, error) { var buffer []byte if _, err := os.Stat(c.SSHKeyPath); !os.IsNotExist(err) { buffer, err = ioutil.ReadFile(c.SSHKeyPath) if err != nil { // Unable to read the file return "", err } } else { // File doesn't exist return "", err } // TrimRight will remove the carriage return from the end of the buffer singleLine := strings.TrimRight(string(buffer), "\r\n") return singleLine, nil } // This will attempt to parse an SSH file in the host config and load it as a base64 encoded KEY func (c *HostConfig) parseSSH() error { // If a file is specified then lets read it and base64 the results (as long as a key doesn't already exist) if c.SSHKeyPath != "" && c.SSHKey == "" { data, err := c.ReadKeyFromFile() if err != nil { return err } c.SSHKey = base64.StdEncoding.EncodeToString([]byte(data)) } return nil } // PopulateFromGlobalConfiguration - This will read a deployment configuration and attempt to fill any missing fields from the global config func (c *HostConfig) PopulateFromGlobalConfiguration(globalConfig HostConfig) { // NETWORK CONFIGURATION // Inherit the global Gateway if c.Gateway == "" { c.Gateway = globalConfig.Gateway } // Inherit the global Subnet if c.Subnet == "" { c.Subnet = globalConfig.Subnet } // Inherit the global Name Server (DNS) if c.NameServer == "" { c.NameServer = globalConfig.NameServer } if c.Adapter == "" { c.Adapter = globalConfig.Adapter } // Disk Configuration if c.LVMEnable == nil && globalConfig.LVMEnable != nil { c.LVMEnable = globalConfig.LVMEnable } else { disabled := false c.LVMEnable = &disabled } if c.SwapDisabled == nil && globalConfig.SwapDisabled != nil { c.SwapDisabled = globalConfig.SwapDisabled } else { disabled := false c.SwapDisabled = &disabled } // REPOSITORY CONFIGURATION // Inherit the global Repository address if c.RepositoryAddress == "" { c.RepositoryAddress = globalConfig.RepositoryAddress } // Inherit the global Repository Mirror directory (typically /ubuntu) if c.MirrorDirectory == "" { c.MirrorDirectory = globalConfig.MirrorDirectory } // USER CONFIGURATION // Inherit the global Username if c.Username == "" { c.Username = globalConfig.Username } // Inherit the global Password if c.Password == "" { c.Password = globalConfig.Password } // Inherit the global SSH Key Path if c.SSHKeyPath == "" { c.SSHKeyPath = globalConfig.SSHKeyPath } // Package Configuration // Inherit the global package selection if c.Packages == "" { c.Packages = globalConfig.Packages } // BOOTy configuration (TODO CAN NOT LEAVE THIS HERE) if c.BOOTYAction == "" { c.BOOTYAction = globalConfig.BOOTYAction } if c.Compressed == nil && globalConfig.Compressed != nil { c.Compressed = globalConfig.Compressed } if c.GrowPartition == nil && globalConfig.GrowPartition != nil { c.GrowPartition = globalConfig.GrowPartition } if c.LVMRootName == "" { c.LVMRootName = globalConfig.LVMRootName } // WRITE to server if c.DestinationDevice == "" { c.DestinationDevice = globalConfig.DestinationDevice } if c.SourceImage == "" { c.SourceImage = globalConfig.SourceImage } // READ from server if c.DestinationAddress == "" { c.DestinationAddress = globalConfig.DestinationAddress } if c.SourceDevice == "" { c.SourceDevice = globalConfig.SourceDevice } // TODO if c.ShellOnFail == nil && globalConfig.ShellOnFail != nil { c.ShellOnFail = globalConfig.ShellOnFail } } ================================================ FILE: pkg/services/types.go ================================================ package services // TYPE DEFINITIONS Below // BootController contains the settings that define how the remote boot will type BootController struct { AdapterName *string `json:"adapter"` // A physical adapter to bind to e.g. en0, eth0 // Servers EnableDHCP *bool `json:"enableDHCP"` // Enable Server //DHCP Configuration DHCPConfig dhcpConfig `json:"dhcpConfig,omitempty"` // TFTP / HTTP configuration EnableTFTP *bool `json:"enableTFTP"` // Enable Server TFTPAddress *string `json:"addressTFTP"` // Should ideally be the IP of the adapter EnableHTTP *bool `json:"enableHTTP"` // Enable Server HTTPAddress *string `json:"addressHTTP"` // Should ideally be the IP of the adapter // TFTP Configuration PXEFileName *string `json:"pxePath"` // undionly.kpxe // Boot Configuration BootConfigs []BootConfig `json:"bootConfigs"` // Array of kernel configurations handler *DHCPSettings } type dhcpConfig struct { DHCPAddress string `json:"addressDHCP"` // Should ideally be the IP of the adapter DHCPStartAddress string `json:"startDHCP"` // The first available DHCP address DHCPLeasePool int `json:"leasePoolDHCP"` // Size of the IP Address pool DHCPSubnet string `json:"subnetDHCP"` // Subnet for leases DHCPGateway string `json:"gatewayDHCP"` // Gateway to advertise DHCPDNS string `json:"nameserverDHCP"` // DNS server to advertise } // BootConfig defines a named configuration for booting type BootConfig struct { ConfigName string `json:"configName"` ConfigType string `json:"configType"` // iPXE file settings - exported Kernel string `json:"kernelPath"` Initrd string `json:"initrdPath"` Cmdline string `json:"cmdline"` // ISO Reader settings ISOPath string `json:"isoPath,omitempty"` ISOPrefix string `json:"isoPrefix,omitempty"` } // DeploymentConfigurationFile - The bootstraps.Configs is used by other packages to manage use case for Mac addresses type DeploymentConfigurationFile struct { GlobalServerConfig HostConfig `json:"globalConfig"` Configs []DeploymentConfig `json:"deployments"` } // DeploymentConfig - is used to parse the files containing all server configurations type DeploymentConfig struct { MAC string `json:"mac"` ConfigName string `json:"bootConfigName,omitempty"` // To be discovered in the controller BootConfig array ConfigBoot BootConfig `json:"bootConfig,omitempty"` // Array of kernel configurations ConfigHost HostConfig `json:"config"` } // HostConfig - Defines how a server will be configured by plunder type HostConfig struct { // Not required for the global configuration Adapter string `json:"adapter,omitempty"` // Adapter to be configured with networking address IPAddress string `json:"address,omitempty"` // Allocated IP address for a host (ignored for global) ServerName string `json:"hostname,omitempty"` // Hostname to be applied to a server // Typically shared details Gateway string `json:"gateway,omitempty"` // Default Gateway Subnet string `json:"subnet,omitempty"` // Subnet to be used for the host NameServer string `json:"nameserver,omitempty"` // Set the default nameserver for DNS NTPServer string `json:"ntpserver,omitempty"` // Time Server to be used LVMEnable *bool `json:"lvmEnabled,omitempty"` // Use LVM for the configuration SwapDisabled *bool `json:"swapDisabled,omitempty"` // Dont create swap partitions Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` // RepositoryAddress is required for pre-seed / kickstart RepositoryAddress string `json:"repoaddress,omitempty"` // MirrorDirectory is an Ubuntu specific config MirrorDirectory string `json:"mirrordir,omitempty"` // SSHKeyPath will typically be referenced from a file ~/.ssh/id_rsa.pub SSHKeyPath string `json:"sshkeypath,omitempty"` // SSHKey is a full SSH Key in base 64 SSHKey string `json:"sshkey,omitempty"` // Packages to be installed Packages string `json:"packages,omitempty"` // OS Image provisioning BOOTYAction string `json:"bootyAction,omitempty"` Compressed *bool `json:"compressed,omitempty"` // Write image to disk from remote address SourceImage string `json:"sourceImage,omitempty"` DestinationDevice string `json:"destinationDevice,omitempty"` // Read image from disk from remote address SourceDevice string `json:"sourceDevice,omitempty"` DestinationAddress string `json:"destinationAddress,omitempty"` // Post tasks - Once the image has been deployed // Volume modifications (LVM2) GrowPartition *int `json:"growPartition,omitempty"` LVMRootName string `json:"lvmRootName,omitempty"` // Troubleshooting ShellOnFail *bool `json:"shellOnFail,omitempty"` } ================================================ FILE: pkg/ssh/go.mod ================================================ module github.com/plunder-app/plunder/pkg/ssh go 1.12 ================================================ FILE: pkg/ssh/sshClient.go ================================================ package ssh import ( "fmt" "strings" log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" ) // StartConnection - func (c *HostSSHConfig) StartConnection() (*ssh.Client, error) { var err error host := c.Host if !strings.ContainsAny(c.Host, ":") { host = host + ":22" } log.Debugf("Beginning connection to [%s] with user [%s] and timeout [%d]", c.Host, c.User, c.ClientConfig.Timeout) c.Connection, err = ssh.Dial("tcp", host, c.ClientConfig) if err != nil { return nil, err } return c.Connection, nil } // StopConnection - func (c *HostSSHConfig) StopConnection() error { if c.Connection != nil { return c.Connection.Close() } return fmt.Errorf("Connection not established") } // StartSession - func (c *HostSSHConfig) StartSession() (*ssh.Session, error) { var err error c.Connection, err = c.StartConnection() if err != nil { return nil, err } c.Session, err = c.Connection.NewSession() if err != nil { return nil, err } return c.Session, err } // StopSession - func (c *HostSSHConfig) StopSession() { if c.Session != nil { c.Session.Close() } } // To string func (c HostSSHConfig) String() string { return c.User + "@" + c.Host } //FindHosts - This will take an array of hosts and find the matching HostSSH Configuration func FindHosts(parlayHosts []string) ([]HostSSHConfig, error) { var hostArray []HostSSHConfig for x := range parlayHosts { found := false for y := range Hosts { //TODO : Probably needs strings.ToLower() (needs testing) if parlayHosts[x] == Hosts[y].Host { hostArray = append(hostArray, Hosts[y]) found = true continue } } if found == false { return nil, fmt.Errorf("Host [%s] has no SSH credentials", parlayHosts[x]) } } return hostArray, nil } ================================================ FILE: pkg/ssh/sshCommand.go ================================================ package ssh import ( "fmt" "io" "io/ioutil" "os" "os/exec" "path/filepath" "time" log "github.com/sirupsen/logrus" ) // SingleExecute - This will execute a command on a single host func SingleExecute(cmd, pipefile, pipecmd string, host HostSSHConfig, to int) CommandResult { var configs []HostSSHConfig configs = append(configs, host) result := ParalellExecute(cmd, pipefile, pipecmd, configs, to) return result[0] } //ParalellExecute - This will execute the same command in paralell across multiple hosts func ParalellExecute(cmd, pipefile, pipecmd string, hosts []HostSSHConfig, to int) []CommandResult { var cmdResults []CommandResult // Run parallel ssh session (max 10) results := make(chan CommandResult, 10) var d time.Duration // Calculate the timeout if to == 0 { // If no timeout then default to one year (TODO) d = time.Duration(8760) * time.Hour } else { d = time.Duration(to) * time.Second } // Set the timeout timeout := time.After(d) // Execute command on hosts for _, host := range hosts { go func(host HostSSHConfig) { res := new(CommandResult) res.Host = host.Host if pipefile != "" { if text, err := host.ExecuteCmdWithStdinFile(cmd, pipefile); err != nil { // Report any returned values res.Error = err res.Result = text } else { res.Result = text } } else if pipecmd != "" { if text, err := host.ExecuteCmdWithStdinCmd(cmd, pipecmd); err != nil { // Report any returned values res.Error = err res.Result = text } else { res.Result = text } } else { if text, err := host.ExecuteCmd(cmd); err != nil { // Report any returned values res.Error = err res.Result = text } else { res.Result = text } } results <- *res }(host) } for i := 0; i < len(hosts); i++ { select { case res := <-results: // Append the results of a succesfull command cmdResults = append(cmdResults, res) case <-timeout: // In the event that a command times out then append the details failedCommand := CommandResult{ Host: hosts[i].Host, Error: fmt.Errorf("Command Timed out"), Result: "", } cmdResults = append(cmdResults, failedCommand) } } return cmdResults } // ExecuteCmd - func (c *HostSSHConfig) ExecuteCmd(cmd string) (string, error) { if c.Session == nil { if _, err := c.StartSession(); err != nil { return "", err } } b, err := c.Session.CombinedOutput(cmd) return string(b), err } // ExecuteCmdWithStdinFile - func (c *HostSSHConfig) ExecuteCmdWithStdinFile(cmd, filePath string) (string, error) { if c.Session == nil { if _, err := c.StartSession(); err != nil { return "", err } } // get a stdin pipe si, err := c.Session.StdinPipe() if err != nil { return "", err } // get a stdout pipe so, err := c.Session.StdoutPipe() if err != nil { return "", err } // open file as an io.reader // Also resolve the absolute path just incase absPath, _ := filepath.Abs(filePath) file, err := os.Open(absPath) if err != nil { return "", err } // Start a command on our remote session, this should be something that is expecting stdin if err := c.Session.Start(cmd); err != nil { return "", err } // do the actual work n, err := io.Copy(si, file) if err != nil { return "", err } // Close the stdin as we've finised transmitting the data si.Close() log.Debugf("Copied %d bytes over the stdin pipe", n) // wait for process to finishe if err := c.Session.Wait(); err != nil { return "", err } // Read all the data from the bu var b []byte if b, err = ioutil.ReadAll(so); err != nil { return "", err } return string(b), nil } // ExecuteCmdWithStdinCmd - func (c *HostSSHConfig) ExecuteCmdWithStdinCmd(cmd, localCmd string) (string, error) { if c.Session == nil { if _, err := c.StartSession(); err != nil { return "", err } } // get a stdin pipe si, err := c.Session.StdinPipe() if err != nil { return "", err } // get a stdout pipe so, err := c.Session.StdoutPipe() if err != nil { return "", err } // Start a command on our remote session, this should be something that is expecting stdin if err := c.Session.Start(cmd); err != nil { return "", err } // Start our local command that should be exposing something through stdout localExecCmd := exec.Command("bash", "-c", localCmd) localso, err := localExecCmd.StdoutPipe() if err != nil { return "", err } err = localExecCmd.Start() if err != nil { return "", err } // do the actual work n, err := io.Copy(si, localso) if err != nil { return "", err } // Close the stdin/stdout as we've finised transmitting the data si.Close() localso.Close() log.Debugf("Copied %d bytes over the stdin pipe", n) // Wait for local process to finish err = localExecCmd.Wait() if err != nil { return "", err } // wait for remote process to finish if err := c.Session.Wait(); err != nil { return "", err } // Read all the data from the bu var b []byte if b, err = ioutil.ReadAll(so); err != nil { return "", err } return string(b), nil } ================================================ FILE: pkg/ssh/sshConfig.go ================================================ package ssh import ( "golang.org/x/crypto/ssh" ) // Hosts - The array of all hosts once loaded var Hosts []HostSSHConfig // HostSSHConfig - The struct of an SSH connection type HostSSHConfig struct { Host string User string Timeout int ClientConfig *ssh.ClientConfig Session *ssh.Session Connection *ssh.Client } // CommandResult - This is returned when running commands against servers type CommandResult struct { Host string // Host that the command was being ran against Error error // Errors that may have been returned Result string // The CLI results } // SetPassword - Turn a password string into an SSH auth method func SetPassword(password string) []ssh.AuthMethod { return []ssh.AuthMethod{ssh.Password(password)} } ================================================ FILE: pkg/ssh/sshImport.go ================================================ package ssh import ( "encoding/json" "fmt" "io/ioutil" "os/user" "path/filepath" "strings" "time" "github.com/mitchellh/go-homedir" "github.com/plunder-app/plunder/pkg/services" "golang.org/x/crypto/ssh" log "github.com/sirupsen/logrus" ) // cachedGlobalKey caches the content of the gloal SSH key to save on excessing file operations var cachedGlobalKey ssh.AuthMethod // cachedUsername caches the content of the gloal Username on the basis that a lot of key based ops will share the same user var cachedUsername string // The init function will look for the default key and the default user func init() { u, err := user.Current() if err != nil { log.Warnf("Failed to find current user, if this is overridden by a deployment configuration this error can be ignored") } // If the above call hasn't errored, then u shouldn't be nil if u != nil { cachedUsername = u.Username } cachedGlobalKey, err = findDefaultKey() if err != nil { log.Warnf("Failed to find default ssh key, if this is overridden by a deployment configuration this error can be ignored") } } // AddHost will append additional hosts to the host array that the ssh package will use func AddHost(address, keypath, username string) error { sshHost := HostSSHConfig{ Host: address, } // If a username exists use that, alternatively use the cached entry if username != "" { sshHost.User = username } else if cachedUsername != "" { sshHost.User = cachedUsername } else { return fmt.Errorf("No username data for SSH authentication has been entered or loaded") } // Find additional keys that may exist in the same location var keys []ssh.AuthMethod if keypath != "" { key, err := findPrivateKey(keypath) if err != nil { return err } keys = append(keys, key) } else { if cachedGlobalKey != nil { keys = append(keys, cachedGlobalKey) } else { return fmt.Errorf("Host [%s] has no key specified", address) } } // Default timeout for connecting to a host is five seconds (TODO) sshHost.ClientConfig = &ssh.ClientConfig{ User: sshHost.User, Auth: keys, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 2 * time.Second, } Hosts = append(Hosts, sshHost) return nil } // ImportHostsFromDeployment - This will parse a deployment (either file or HTTP post) func ImportHostsFromDeployment(deployment services.DeploymentConfigurationFile) error { if len(deployment.Configs) == 0 { return fmt.Errorf("No deployment configurations found") } // Find keys that are in the same places as the public Key if deployment.GlobalServerConfig.SSHKeyPath != "" { var err error // Find if the private key from the global configuration cachedGlobalKey, err = findDefaultKey() if err != nil { log.Debugf("Failed to find default key, using Public key to find private") cachedGlobalKey, err = findPrivateKey(deployment.GlobalServerConfig.SSHKeyPath) if err != nil { return err } } } else { log.Debugln("No global configuration has been loaded, will default to local users keys") } // Find a global username to use, in place of an empty config if deployment.GlobalServerConfig.Username != "" { cachedUsername = deployment.GlobalServerConfig.Username } else { log.Debugf("No global configuration has been loaded, default to user [%s]", cachedUsername) } // Parse the deployments for i := range deployment.Configs { var sshHost HostSSHConfig sshHost.Host = deployment.Configs[i].ConfigHost.IPAddress if deployment.Configs[i].ConfigHost.Username != "" { sshHost.User = deployment.Configs[i].ConfigHost.Username } else { sshHost.User = deployment.GlobalServerConfig.Username } // Find additional keys that may exist in the same location var keys []ssh.AuthMethod if deployment.Configs[i].ConfigHost.SSHKeyPath != "" { // Look up default key key, err := findDefaultKey() if err != nil { log.Debugf("Failed to find default key, using Public key to find private") key, err = findPrivateKey(deployment.Configs[i].ConfigHost.SSHKeyPath) if err != nil { return err } } keys = append(keys, key) } else { if cachedGlobalKey != nil { keys = append(keys, cachedGlobalKey) } else { return fmt.Errorf("Host [%s] has no key specified", deployment.Configs[i].ConfigHost.IPAddress) } } // Default timeout for connecting to a host is five seconds (TODO) sshHost.ClientConfig = &ssh.ClientConfig{ User: sshHost.User, Auth: keys, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 2 * time.Second, } Hosts = append(Hosts, sshHost) } return nil } // ImportHostsFromRawDeployment - This will parse a deployment (either file or HTTP post) func ImportHostsFromRawDeployment(config []byte) error { var deployment services.DeploymentConfigurationFile err := json.Unmarshal(config, &deployment) if err != nil { return err } return ImportHostsFromDeployment(deployment) } // findDefaultKey - This will look in the users $HOME/.ssh/ for a key to add func findDefaultKey() (ssh.AuthMethod, error) { home, err := homedir.Dir() if err != nil { return nil, err } return findPrivateKey(fmt.Sprintf("%s/.ssh/id_rsa", home)) } // readKeyFile - Reads a public key from a file func findPrivateKey(publicKey string) (ssh.AuthMethod, error) { // Typically turn id_rsa.pub -> id_rsa privateKey := strings.TrimSuffix(publicKey, filepath.Ext(publicKey)) b, err := ioutil.ReadFile(privateKey) if err != nil { return nil, err } key, err := ssh.ParsePrivateKey(b) if err != nil { return nil, err } return ssh.PublicKeys(key), nil } // readKeyFile - Reads a public key from a file func readKeyFile(keyfile string) (ssh.AuthMethod, error) { b, err := ioutil.ReadFile(keyfile) if err != nil { return nil, err } key, err := ssh.ParsePrivateKey(b) if err != nil { return nil, err } return ssh.PublicKeys(key), nil } // ReadKeyFiles - will read an array of keys from disk func ReadKeyFiles(keyFiles []string) ([]ssh.AuthMethod, error) { methods := []ssh.AuthMethod{} for _, keyname := range keyFiles { pkey, err := readKeyFile(keyname) if err != nil { return nil, err } if pkey != nil { methods = append(methods, pkey) } } return methods, nil } ================================================ FILE: pkg/ssh/sshTransfer.go ================================================ package ssh import ( "fmt" "io" "os" "time" "github.com/pkg/sftp" ) // ParalellDownload - Allow downloading a file over SFTP from multiple hosts in parallel func ParalellDownload(hosts []HostSSHConfig, source, destination string, to int) []CommandResult { var cmdResults []CommandResult // Run parallel ssh session (max 10) results := make(chan CommandResult, 10) var d time.Duration // Calculate the timeout if to == 0 { // If no timeout then default to one year (TODO) d = time.Duration(8760) * time.Hour } else { d = time.Duration(to) * time.Second } // Set the timeout timeout := time.After(d) // Execute command on hosts for _, host := range hosts { go func(host HostSSHConfig) { res := new(CommandResult) res.Host = host.Host if err := host.DownloadFile(source, destination); err != nil { res.Error = err } else { res.Result = "Download completed" } results <- *res }(host) } for i := 0; i < len(hosts); i++ { select { case res := <-results: // Append the results of a succesfull command cmdResults = append(cmdResults, res) case <-timeout: // In the event that a command times out then append the details failedCommand := CommandResult{ Host: hosts[i].Host, Error: fmt.Errorf("Download Timed out"), Result: "", } cmdResults = append(cmdResults, failedCommand) } } return cmdResults } // DownloadFile - func (c HostSSHConfig) DownloadFile(source, destination string) error { var err error c.Connection, err = c.StartConnection() if err != nil { return err } // New SFTP client sftp, err := sftp.NewClient(c.Connection) if err != nil { return err } defer sftp.Close() // Open remote source sftpSource, err := sftp.Open(source) if err != nil { return err } defer sftpSource.Close() // Open local destination localDestination, err := os.Create(destination) if err != nil { return err } defer localDestination.Close() // _, err = sftpSource.WriteTo(localDestination) if err != nil { return err } // An error here isn't cause for alarm, any new transaction should create a new connection _ = c.StopConnection() return nil } // ParalellUpload - Allow uploading a file over SFTP to multiple hosts in parallel func ParalellUpload(hosts []HostSSHConfig, source, destination string, to int) []CommandResult { var cmdResults []CommandResult // Run parallel ssh session (max 10) results := make(chan CommandResult, 10) var d time.Duration // Calculate the timeout if to == 0 { // If no timeout then default to one year (TODO) d = time.Duration(8760) * time.Hour } else { d = time.Duration(to) * time.Second } // Set the timeout timeout := time.After(d) // Execute command on hosts for _, host := range hosts { go func(host HostSSHConfig) { res := new(CommandResult) res.Host = host.Host if err := host.UploadFile(source, destination); err != nil { res.Error = err } else { res.Result = "Upload completed" } results <- *res }(host) } for i := 0; i < len(hosts); i++ { select { case res := <-results: // Append the results of a succesfull command cmdResults = append(cmdResults, res) case <-timeout: // In the event that a command times out then append the details failedCommand := CommandResult{ Host: hosts[i].Host, Error: fmt.Errorf("Upload Timed out"), Result: "", } cmdResults = append(cmdResults, failedCommand) } } return cmdResults } // UploadFile - func (c HostSSHConfig) UploadFile(source, destination string) error { var err error c.Connection, err = c.StartConnection() if err != nil { return err } // New SFTP client sftp, err := sftp.NewClient(c.Connection) if err != nil { return err } defer sftp.Close() // Open remote source sftpDestination, err := sftp.Create(destination) if err != nil { return err } defer sftpDestination.Close() // Open local destination localSource, err := os.Open(source) if err != nil { return err } defer localSource.Close() // copy source file to destination file _, err = io.Copy(sftpDestination, localSource) if err != nil { return err } // An error here isn't cause for alarm, any new transaction should create a new connection _ = c.StopConnection() return nil } ================================================ FILE: pkg/utils/go.mod ================================================ module github.com/plunder-app/plunder/pkg/utils go 1.12 ================================================ FILE: pkg/utils/ipxe.go ================================================ package utils import ( "fmt" "io" "net/http" "os" log "github.com/sirupsen/logrus" ) // Static URL for retrieving the bootloader const iPXEURL = "https://boot.ipxe.org/undionly.kpxe" // This header is used by all configurations const iPXEHeader = `#!ipxe dhcp echo . echo . echo . echo . echo +-------------------- Plunder ------------------------------- echo | echo | address.: ${net0/ip} echo | mac.....: ${net0/mac} echo | gateway.: ${net0/gateway} echo +------------------------------------------------------------ echo . echo . echo . echo .` ////////////////////////////// // // Helper Functions // ////////////////////////////// // IPXEReboot - func IPXEReboot() string { script := ` echo MAC ADDRESS is set to reboot, plunder will reboot the server in 5 seconds sleep 5 reboot ` return iPXEHeader + script } // IPXEAutoBoot - func IPXEAutoBoot() string { script := ` echo Unknown MAC address, PXE boot will keep retrying until configuration changes :retry_boot autoboot || goto retry_boot ` return iPXEHeader + script } // IPXEPreeseed - This will build an iPXE boot script for Debian/Ubuntu func IPXEPreeseed(webserverAddress, kernel, initrd, cmdline string) string { script := ` kernel http://%s/%s auto=true url=http://%s/${mac:hexhyp}.cfg priority=critical %s netcfg/choose_interface=${netX/mac} initrd http://%s/%s boot ` // Replace the addresses inline buildScript := fmt.Sprintf(script, webserverAddress, kernel, webserverAddress, cmdline, webserverAddress, initrd) return iPXEHeader + buildScript } // IPXEKickstart - This will build an iPXE boot script for RHEL/CentOS func IPXEKickstart(webserverAddress, kernel, initrd, cmdline string) string { script := ` kernel http://%s/%s auto=true url=http://%s/${mac:hexhyp}.cfg priority=critical %s initrd http://%s/%s boot ` // Replace the addresses inline buildScript := fmt.Sprintf(script, webserverAddress, kernel, webserverAddress, cmdline, webserverAddress, initrd) return iPXEHeader + buildScript } // IPXEVSphere - This will build an iPXE boot script for VMware vSphere/ESXi func IPXEVSphere(webserverAddress, kernel, cmdline string) string { script := ` kernel http://%s/%s -c http://%s/${mac:hexhyp}.cfg ks=http://%s/${mac:hexhyp}.ks %s boot ` // Replace the addresses inline buildScript := fmt.Sprintf(script, webserverAddress, kernel, webserverAddress, webserverAddress, cmdline) return iPXEHeader + buildScript } // IPXEBOOTy - This will build an iPXE boot script for the BOOTy boot loader func IPXEBOOTy(webserverAddress, kernel, initrd, cmdline string) string { script := ` kernel http://%s/%s BOOTYURL=http://%s %s initrd http://%s/%s boot ` // Replace the addresses inline buildScript := fmt.Sprintf(script, webserverAddress, kernel, webserverAddress, cmdline, webserverAddress, initrd) return iPXEHeader + buildScript } // IPXEAnyBoot - This will build an iPXE boot script for anything wanting to PXE boot func IPXEAnyBoot(webserverAddress string, kernel, initrd, cmdline string) string { script := ` kernel http://%s/%s auto=true url=http://%s/${mac:hexhyp}.cfg %s initrd http://%s/%s boot ` // Replace the addresses inline buildScript := fmt.Sprintf(script, webserverAddress, kernel, webserverAddress, cmdline, webserverAddress, initrd) return iPXEHeader + buildScript } // PullPXEBooter - This will attempt to download the iPXE bootloader func PullPXEBooter() error { log.Infoln("Beginning of iPXE download... ") // Create the file out, err := os.Create("undionly.kpxe") if err != nil { return err } defer out.Close() // Get the data resp, err := http.Get(iPXEURL) if err != nil { return err } defer resp.Body.Close() // Writer the body to file _, err = io.Copy(out, resp.Body) if err != nil { return err } log.Infoln("Completed") return nil } ================================================ FILE: pkg/utils/nic.go ================================================ package utils import ( "fmt" "net" ) // FindIPAddress - this will find the address associated with an adapter func FindIPAddress(addrName string) (string, string, error) { var address string list, err := net.Interfaces() if err != nil { return "", "", err } for _, iface := range list { addrs, err := iface.Addrs() if err != nil { return "", "", err } for _, a := range addrs { if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { if ipnet.IP.To4() != nil { address = ipnet.IP.String() // If we're not searching for a specific adapter return the first one if addrName == "" { return iface.Name, address, nil } else // If this is the correct adapter return the details if iface.Name == addrName { return iface.Name, address, nil } } } } } return "", "", fmt.Errorf("Unknown interface [%s]", addrName) } // FindAllIPAddresses - Will return all IP addresses for a server func FindAllIPAddresses() ([]net.IP, error) { var IPS []net.IP ifaces, err := net.Interfaces() if err != nil { return nil, err } for _, i := range ifaces { addrs, err := i.Addrs() if err != nil { return nil, err } for _, addr := range addrs { var ip net.IP switch v := addr.(type) { case *net.IPNet: ip = v.IP case *net.IPAddr: ip = v.IP } if ip != nil { IPS = append(IPS, net.IP(ip)) } // process IP address } } return IPS, nil } //ConvertIP - func ConvertIP(ipAddress string) ([]byte, error) { // net.ParseIP has returned IPv6 sized allocations o_O fixIP := net.ParseIP(ipAddress) if fixIP == nil { return nil, fmt.Errorf("Couldn't parse the IP address: %s", ipAddress) } if len(fixIP) > 4 { return fixIP[len(fixIP)-4:], nil } return fixIP, nil } ================================================ FILE: pkg/utils/utils.go ================================================ package utils import ( "encoding/hex" "io/ioutil" "os" "os/signal" "sync" ) //WaitForCtrlC - This function is the loop that will catch a Control-C keypress func WaitForCtrlC() { var endWaiter sync.WaitGroup endWaiter.Add(1) var signalChannel chan os.Signal signalChannel = make(chan os.Signal, 1) signal.Notify(signalChannel, os.Interrupt) go func() { <-signalChannel endWaiter.Done() }() endWaiter.Wait() } //FileToHex - this is a helper function to allow embedding files into .go files func FileToHex(filePath string) (sl string, err error) { bs, err := ioutil.ReadFile(filePath) if err != nil { return } sl = hex.EncodeToString(bs) return } ================================================ FILE: plugin/docker/docker.go ================================================ package main import ( "encoding/json" "fmt" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" ) const pluginInfo = `This plugin is used to managed docker automation` type image struct { // Image details ImageNames []string `json:"imageName"` ImageFiles []string `json:"imageFile"` DockerLocalSudo bool `json:"localSudo"` DockerRemoteSudo bool `json:"remoteSudo"` } type tag struct { // A list of sources and target tags SourceNames []string `json:"sourceNames,omitempty"` TargetNames []string `json:"targetNames,omitempty"` // These two fields are used to change out a tag (e.g. version number) or the repository itself TargetTag string `json:"imageTag,omitempty"` TargetRepo string `json:"imageRepo,omitempty"` } // Dummy main function func main() {} // ParlayActionList - This should return an array of actions func ParlayActionList() []string { return []string{ "docker/image", "docker/tag"} } // ParlayActionDetails - This should return an array of action descriptions func ParlayActionDetails() []string { return []string{ "This action automates the management of docker images", "This action manages the tagging of docker images"} } // ParlayPluginInfo - returns information about the plugin func ParlayPluginInfo() string { return pluginInfo } // ParlayUsage - Returns the json that matches the specific action // <- action is a string that defines which action the usage information should be // <- raw - raw JSON that will be manipulated into a correct struct that matches the action // -> err is any error that has been generated func ParlayUsage(action string) (raw json.RawMessage, err error) { // This example plugin only has the code for "exampleAction/test" however this switch statement // should handle all exposed actions from the plugin switch action { case "docker/image": a := image{ ImageFiles: []string{"./my_image.tar.gz", "./my__other_image.tar.gz"}, ImageNames: []string{"gcr.io/my_image:latest", "gcr.io/my_other_image:latest"}, } // In order to turn a struct into an map[string]interface we need to turn it into JSON return json.Marshal(a) case "docker/tag": a := tag{ SourceNames: []string{"gcr.io/my_image:latest"}, TargetNames: []string{"internal_repo/my_image:1.0"}, } // In order to turn a struct into an map[string]interface we need to turn it into JSON return json.Marshal(a) default: return raw, fmt.Errorf("Action [%s] could not be found", action) } } // ParlayExec - Parses the action and the data that the action will consume // <- action a string that details the action to be executed // <- raw - raw JSON that will be manipulated into a correct struct that matches the action // -> actions are an array of generated actions that the parser will then execute // -> err is any error that has been generated func ParlayExec(action, host string, raw json.RawMessage) (actions []parlaytypes.Action, err error) { // This example plugin only has the code for "exampleAction/test" however this switch statement // should handle all exposed actions from the plugin switch action { case "docker/image": var img image // Unmarshall the JSON into the struct err = json.Unmarshal(raw, &img) return img.generateImageActions(host), err case "docker/tag": var t tag // Unmarshall the JSON into the struct err = json.Unmarshal(raw, &t) return t.generateTagActions(host) default: return } } ================================================ FILE: plugin/docker/docker_actions.go ================================================ package main import ( "fmt" "path" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" ) func (i *image) generateImageActions(host string) []parlaytypes.Action { var generatedActions []parlaytypes.Action var a parlaytypes.Action var dockerRemoteString, dockerLocalString string // This should be set to true if sudo (NOPASSWD) is enabled and required on the local host if i.DockerLocalSudo == true { dockerLocalString = "sudo docker save" } else { dockerLocalString = "docker save" } // This should be set to true if sudo (NOPASSWD) is enabled and required on the remote host if i.DockerRemoteSudo == true { dockerRemoteString = "sudo docker" } else { dockerRemoteString = "docker" } if len(i.ImageFiles) != 0 { // If we've specified a file (tarball, or tar+gzip) we cat then pipe over SSH to a docker load for y := range i.ImageFiles { a = parlaytypes.Action{ ActionType: "command", Command: fmt.Sprintf("%s load ", dockerRemoteString), CommandPipeFile: i.ImageFiles[y], Name: fmt.Sprintf("Upload container image %s to remote docker host", path.Base(i.ImageFiles[y])), } generatedActions = append(generatedActions, a) } } else if len(i.ImageNames) != 0 { // If we've specified a an existing image from the local docker image store then we "save" it (pipe to stdin) // then we can cat then pipe over SSH to a docker load for y := range i.ImageNames { a = parlaytypes.Action{ ActionType: "command", Command: fmt.Sprintf("%s load", dockerRemoteString), CommandPipeCmd: fmt.Sprintf("%s %s", dockerLocalString, i.ImageNames[y]), Name: fmt.Sprintf("Upload container image %s to remote docker host", i.ImageNames[y]), } generatedActions = append(generatedActions, a) } } return generatedActions } func (t *tag) generateTagActions(host string) ([]parlaytypes.Action, error) { if len(t.SourceNames) != len(t.TargetNames) { return nil, fmt.Errorf("The number of images to retag doesn't match the number of tags") } var generatedActions []parlaytypes.Action // Iterate through all of the images and create retagging actions for y := range t.SourceNames { // Generate the retag action var a = parlaytypes.Action{ ActionType: "command", Command: fmt.Sprintf("sudo docker tag %s %s", t.SourceNames[y], t.TargetNames[y]), CommandSudo: "root", Name: fmt.Sprintf("Retag %s --> %s", t.SourceNames[y], t.TargetNames[y]), } generatedActions = append(generatedActions, a) } return generatedActions, nil } ================================================ FILE: plugin/example.go ================================================ package main import ( "encoding/json" "fmt" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" ) const pluginInfo = `This example plugin is used to demonstrate the structure of a plugin` // pluginAction - defines the struct that is unique to the type pluginTestAction struct { Credentials string `json:"credentials"` Address string `json:"address"` } // Dummy main function func main() {} // ParlayActionList - This should return an array of actions func ParlayActionList() []string { return []string{ "exampleAction/test", "exampleAction/demo", "exampleAction/example"} } // ParlayActionDetails - This should return an array of action descriptions func ParlayActionDetails() []string { return []string{ "This action handles the testing part of the example plugin", "This action handles the demonstration of the example plugin", "This action handles an example of the example plugin!"} } // ParlayPluginInfo - returns information about the plugin func ParlayPluginInfo() string { return pluginInfo } //ParlayActions - func ParlayActions(action string, iface interface{}) []parlaytypes.Action { var actions []parlaytypes.Action a := parlaytypes.Action{ Command: "example/test", } actions = append(actions, a) return actions } // ParlayUsage - Returns the json that matches the specific action // <- action is a string that defines which action the usage information should be // <- raw - raw JSON that will be manipulated into a correct struct that matches the action // -> err is any error that has been generated func ParlayUsage(action string) (raw json.RawMessage, err error) { // This example plugin only has the code for "exampleAction/test" however this switch statement // should handle all exposed actions from the plugin switch action { case "exampleAction/test": a := pluginTestAction{ Credentials: "AAABBBCCCCDDEEEE", Address: "172.0.0.1", } // In order to turn a struct into an map[string]interface we need to turn it into JSON return json.Marshal(a) default: return raw, fmt.Errorf("Action [%s] could not be found", action) } } // ParlayExec - Parses the action and the data that the action will consume // <- action a string that details the action to be executed // <- raw - raw JSON that will be manipulated into a correct struct that matches the action // -> actions are an array of generated actions that the parser will then execute // -> err is any error that has been generated func ParlayExec(action, host string, raw json.RawMessage) (actions []parlaytypes.Action, err error) { var t pluginTestAction // Unmarshall the JSON into the struct json.Unmarshal(raw, &t) // We can now use the fields as part of the struct // This example plugin only has the code for "exampleAction/test" however this switch statement // should handle all exposed actions from the plugin switch action { case "exampleAction/test": a := parlaytypes.Action{ Name: "Echo the address", ActionType: "command", Command: fmt.Sprintf("echo %s", t.Address), } actions = append(actions, a) a.Name = "Echo the Credentials" a.Command = fmt.Sprintf("echo %s", t.Credentials) actions = append(actions, a) return default: return } } ================================================ FILE: plugin/kubeadm/kubeadm.go ================================================ package main import ( "encoding/json" "fmt" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" ) const pluginInfo = `This plugin is used to managed kubeadm automation` // This defines the etcd kubeadm file (should use the kubernetes packages to define at a later point) const etcdKubeadm = `apiVersion: "kubeadm.k8s.io/%s" kind: ClusterConfiguration etcd: local: serverCertSANs: - "%s" peerCertSANs: - "%s" extraArgs: initial-cluster: %s=https://%s:2380,%s=https://%s:2380,%s=https://%s:2380 initial-cluster-state: new name: %s listen-peer-urls: https://%s:2380 listen-client-urls: https://%s:2379 advertise-client-urls: https://%s:2379 initial-advertise-peer-urls: https://%s:2380` // This defines the manager kubeadm file (should use the kubernetes packages to define at a later point) const managerKubeadm = `apiVersion: kubeadm.k8s.io/v1beta1 kind: ClusterConfiguration kubernetesVersion: %s apiServer: certSANs: - "%s" controlPlaneEndpoint: "%s:%d" etcd: external: endpoints: - https://%s:2379 - https://%s:2379 - https://%s:2379 caFile: /etc/kubernetes/pki/etcd/ca.crt certFile: /etc/kubernetes/pki/apiserver-etcd-client.crt keyFile: /etc/kubernetes/pki/apiserver-etcd-client.key` type etcdMembers struct { // Hostnames Hostname1 string `json:"hostname1,omitempty"` Hostname2 string `json:"hostname2,omitempty"` Hostname3 string `json:"hostname3,omitempty"` // Addresses Address1 string `json:"address1,omitempty"` Address2 string `json:"address2,omitempty"` Address3 string `json:"address3,omitempty"` // Intialise a Certificate Authority InitCA bool `json:"initCA,omitempty"` // Set kubernetes API version APIVersion string `json:"apiversion,omitempty"` } type managerMembers struct { // ETCD Nodes ETCDAddress1 string `json:"etcd01,omitempty"` ETCDAddress2 string `json:"etcd02,omitempty"` ETCDAddress3 string `json:"etcd03,omitempty"` // Version of Kubernetes Version string `json:"kubeVersion,omitempty"` // Load Balancer details (needed for initialising the first master) //loadBalancer // Stacked - means ETCD nodes are stacked on managers (false by default) Stacked bool `json:"stacked,omitempty"` } // Dummy main function func main() {} // ParlayActionList - This should return an array of actions func ParlayActionList() []string { return []string{ "kubeadm/etcd", "kubeadm/master"} } // ParlayActionDetails - This should return an array of action descriptions func ParlayActionDetails() []string { return []string{ "This action automates the provisioning of a the first etcd node and certificates for the remaining two nodes", "This action handles the configuration of the first master node"} } // ParlayPluginInfo - returns information about the plugin func ParlayPluginInfo() string { return pluginInfo } // ParlayUsage - Returns the json that matches the specific action // <- action is a string that defines which action the usage information should be // <- raw - raw JSON that will be manipulated into a correct struct that matches the action // -> err is any error that has been generated func ParlayUsage(action string) (raw json.RawMessage, err error) { // This example plugin only has the code for "exampleAction/test" however this switch statement // should handle all exposed actions from the plugin switch action { case "kubeadm/etcd": a := etcdMembers{ Hostname1: "etcd01.local", Hostname2: "etcd02.local", Hostname3: "etcd03.local", InitCA: true, APIVersion: "v1beta1", Address1: "10.0.101", Address2: "10.0.102", Address3: "10.0.103", } // In order to turn a struct into an map[string]interface we need to turn it into JSON return json.Marshal(a) default: return raw, fmt.Errorf("Action [%s] could not be found", action) } } // ParlayExec - Parses the action and the data that the action will consume // <- action a string that details the action to be executed // <- raw - raw JSON that will be manipulated into a correct struct that matches the action // -> actions are an array of generated actions that the parser will then execute // -> err is any error that has been generated func ParlayExec(action, host string, raw json.RawMessage) (actions []parlaytypes.Action, err error) { // This example plugin only has the code for "exampleAction/test" however this switch statement // should handle all exposed actions from the plugin switch action { case "kubeadm/etcd": var etcdStruct etcdMembers // Unmarshall the JSON into the struct err = json.Unmarshal(raw, &etcdStruct) return etcdStruct.generateActions(), err default: return } } ================================================ FILE: plugin/kubeadm/kubeadm_actions.go ================================================ package main import ( "fmt" "github.com/plunder-app/plunder/pkg/parlay/parlaytypes" ) func (e *etcdMembers) generateActions() []parlaytypes.Action { var generatedActions []parlaytypes.Action var a parlaytypes.Action if e.InitCA == true { // Ensure that a new Certificate Authority is generated // Create action a = parlaytypes.Action{ // Generate etcd server certificate ActionType: "command", Command: fmt.Sprintf("kubeadm init phase certs etcd-ca"), CommandSudo: "root", Name: "Initialise Certificate Authority", } generatedActions = append(generatedActions, a) } // Default to < 1.12 API version if e.APIVersion == "" { e.APIVersion = "v1beta1" } // Generate the configuration directories a.ActionType = "command" a.Command = fmt.Sprintf("mkdir -m 777 -p /tmp/%s/ /tmp/%s/ /tmp/%s/", e.Address1, e.Address2, e.Address3) a.Name = "Generate temporary directories" generatedActions = append(generatedActions, a) // Generate the kubeadm configuration files // Node 0 a.Name = "build kubeadm config for node 0" a.Command = fmt.Sprintf("echo '%s' > /tmp/%s/kubeadmcfg.yaml", e.buildKubeadm(e.APIVersion, e.Hostname1, e.Address1), e.Address1) generatedActions = append(generatedActions, a) // Node 1 a.Name = "build kubeadm config for node 1" a.Command = fmt.Sprintf("echo '%s' > /tmp/%s/kubeadmcfg.yaml", e.buildKubeadm(e.APIVersion, e.Hostname2, e.Address2), e.Address2) generatedActions = append(generatedActions, a) // Node 2 a.Name = "build kubeadm config for node 2" a.Command = fmt.Sprintf("echo '%s' > /tmp/%s/kubeadmcfg.yaml", e.buildKubeadm(e.APIVersion, e.Hostname3, e.Address3), e.Address3) generatedActions = append(generatedActions, a) // Add certificate actions generatedActions = append(generatedActions, e.generateCertificateActions([]string{e.Address3, e.Address2, e.Address1})...) return generatedActions } func (e *etcdMembers) buildKubeadm(api, host, address string) string { var kubeadm string // Generates a kubeadm for setting up the etcd yaml kubeadm = fmt.Sprintf(etcdKubeadm, api, address, address, e.Hostname1, e.Address1, e.Hostname2, e.Address2, e.Hostname3, e.Address3, host, address, address, address, address) return kubeadm } // generateCertificateActions - Hosts need adding in backward to the array i.e. host 2 -> host 1 -> host 0 func (e *etcdMembers) generateCertificateActions(hosts []string) []parlaytypes.Action { var generatedActions []parlaytypes.Action var a parlaytypes.Action a.Command = "mkdir -p /etc/kubernetes/pki" a.CommandSudo = "root" a.Name = "Ensure that PKI directory exists" a.ActionType = "command" generatedActions = append(generatedActions, a) for i, v := range hosts { // Tidy any existing client certificates a.ActionType = "command" a.Command = "find /etc/kubernetes/pki -not -name ca.crt -not -name ca.key -type f -delete" a.Name = "Remove any existing client certificates before attempting to generate any new ones" generatedActions = append(generatedActions, a) // Generate etcd server certificate a.ActionType = "command" a.Command = fmt.Sprintf("kubeadm init phase certs etcd-server --config=/tmp/%s/kubeadmcfg.yaml", v) a.Name = fmt.Sprintf("Generate etcd server certificate for [%s]", v) generatedActions = append(generatedActions, a) // Generate peer certificate a.Command = fmt.Sprintf("kubeadm init phase certs etcd-peer --config=/tmp/%s/kubeadmcfg.yaml", v) a.Name = fmt.Sprintf("Generate peer certificate for [%s]", v) generatedActions = append(generatedActions, a) // Generate health check certificate a.Command = fmt.Sprintf("kubeadm init phase certs etcd-healthcheck-client --config=/tmp/%s/kubeadmcfg.yaml", v) a.Name = fmt.Sprintf("Generate health check certificate for [%s]", v) generatedActions = append(generatedActions, a) // Generate api-server client certificate a.Command = fmt.Sprintf("kubeadm init phase certs apiserver-etcd-client --config=/tmp/%s/kubeadmcfg.yaml", v) a.Name = fmt.Sprintf("Generate api-server client certificate for [%s]", v) generatedActions = append(generatedActions, a) // These steps are only required for the first two hosts if i != (len(hosts) - 1) { // Archive the certificates and the kubeadm configuration in a host specific archive name a.Command = fmt.Sprintf("tar -cvzf /tmp/%s.tar.gz $(find /etc/kubernetes/pki -type f) /tmp/%s/kubeadmcfg.yaml", v, v) a.Name = fmt.Sprintf("Archive generated certificates [%s]", v) generatedActions = append(generatedActions, a) // Download the archive files to the local machine a.ActionType = "download" a.Source = fmt.Sprintf("/tmp/%s.tar.gz", hosts[i]) a.Destination = fmt.Sprintf("/tmp/%s.tar.gz", hosts[i]) a.Name = fmt.Sprintf("Retrieve the certificate bundle for [%s]", v) generatedActions = append(generatedActions, a) } else { // This is the final host, grab the certificates for use by a manager a.Command = fmt.Sprintf("tar -cvzf /tmp/managercert.tar.gz /etc/kubernetes/pki/etcd/ca.crt /etc/kubernetes/pki/apiserver-etcd-client.crt /etc/kubernetes/pki/apiserver-etcd-client.key") a.Name = fmt.Sprintf("Archive generated certificates [%s]", v) generatedActions = append(generatedActions, a) // Download the archive files to the local machine a.ActionType = "download" a.Source = "/tmp/managercert.tar.gz" a.Destination = "/tmp/managercert.tar.gz" a.Name = "Retrieving the Certificates for the manager nodes" generatedActions = append(generatedActions, a) } } return generatedActions } // At some point the functions for the various kubeadm arease will be split into seperate files to ease management func (m *managerMembers) generateActions() []parlaytypes.Action { var generatedActions []parlaytypes.Action var a parlaytypes.Action if m.Stacked == false { // Not implemented yet TODO return nil } // Upload the initial etcd certificates to the first manager node a = parlaytypes.Action{ // Upload etcd server certificate ActionType: "upload", Source: "/tmp/managercert.tar.gz", Destination: "/tmp/managercert.tar.gz", Name: "Upload etcd server certificate to first manager", } generatedActions = append(generatedActions, a) // Install the certificates for etcd a.Name = "Installing the etcd certificates" a.ActionType = "command" a.CommandSudo = "root" a.Command = fmt.Sprintf("tar -xvzf /tmp/managercert.tar.gz -C /") generatedActions = append(generatedActions, a) // Generate the kubeadm configuration file a.Name = "Generating the Kubeadm file for the first manager node" a.Command = fmt.Sprintf("echo '%s' > /tmp/kubeadmcfg.yaml", m.buildKubeadm()) generatedActions = append(generatedActions, a) // Initialise the first node a.Name = "Initialise the first control plane node" a.Command = "kubeadm init --config /tmp/kubeadmcfg.yaml" generatedActions = append(generatedActions, a) return generatedActions } func (m *managerMembers) buildKubeadm() string { var kubeadm string // Generates a kubeadm for setting up the etcd yaml kubeadm = fmt.Sprintf(managerKubeadm, m.Version, "LB HOSTNAME FIXME", "LB HOSTNAME FIXME", 1000000, m.ETCDAddress1, m.ETCDAddress2, m.ETCDAddress3) return kubeadm } ================================================ FILE: testing.sh ================================================ #!/bin/bash echo "This script will step through a number of tests agains plunder to ensure that functionality is as expected" echo "Building plunder with [go build]" INSECURE="-k" PLUNDERURL="https://localhost:60443" go build retVal=$? if [ $retVal -ne 0 ]; then echo "Error at go build" exit fi echo "Check for no version information" v=$(./plunder version | grep Version | awk '{ print $2 }') rm plunder if [ -z "$v" ] then echo "Version is empty" else echo "Version is NOT empty" fi echo "Building plunder with [make build]" make build retVal=$? if [ $retVal -ne 0 ]; then echo "Error at make build" exit fi echo "Check for version information" v=$(./plunder version | grep Version | awk '{ print $2 }') if [ -z "$v" ] then echo "Version is empty" else echo "Version is NOT empty [$v]" fi echo "Plunder server configuration, temporary output will live in ./testing" mkdir testing ./plunder config server -p > ./testing/server_test_config.json ./plunder config deployment -p > ./testing/deployment_config.json ./plunder config server -o yaml > ./testing/server_test_config.yaml ./plunder config deployment -o yaml > ./testing/deployment_config.yaml echo "Generating API Server certificates in ~./plunderserver.yaml" ./plunder config apiserver server echo "Creating alternative configuration with services enabled" sed '/enableHTTP/s/false/true/' ./testing/server_test_config.json > ./testing/server_test_http_config.json echo "Examining detected configuration" echo "Checking for Adapter" v=$(grep adapter ./testing/server_test_config.json | awk ' {print $2 }' | tr -d '"' | tr -d ',') if [ -z "$v" ] then echo "Adapter is empty" else echo "Adapter is NOT empty [$v]" fi echo "Checking for Gateway Address" v=$(grep gatewayDHCP ./testing/server_test_config.json | awk ' {print $2 }' | tr -d '"' | tr -d ',') if [ -z "$v" ] then echo "Gateway is empty" else echo "Gateway is NOT empty [$v]" fi i=$(id -u) n=$(id -un) if [[ $i -gt 0 ]] then echo "Testing as current user [NAME = $n / ID = $i]" echo "Starting with disabled configuration" ./plunder server --config ./testing/server_test_config.json retVal=$? if [ $retVal -ne 0 ]; then echo "Plunder correctly didn't start" fi echo "Starting with enabled HTTP configuration (check OSX)" sudo ./plunder server --config ./testing/server_test_http_config.json & retVal=$? if [ $retVal -ne 0 ]; then echo "Plunder correctly didn't start" exit 1 fi echo "Sleeping for 3 seconds to ensure plunder has started" sleep 3 echo "Print Configuration info"; echo "--------------------------" curl $INSECURE $PLUNDERURL/config; echo "" echo "Print Deployments info"; echo "--------------------------" curl $INSECURE $PLUNDERURL/deployments; echo "" echo "POST JSON Deployment to Plunder API" curl $INSECURE -X POST -d "@./testing/deployment_config.json" $PLUNDERURL/deployments echo "Print (UPDATED) Deployment info"; echo "--------------------------" curl $INSECURE $PLUNDERURL/deployments; echo "" echo "POST YAML Deployment to Plunder API" curl $INSECURE -X POST --data-binary "@./testing/deployment_config.yaml" $PLUNDERURL/deployments -H "Content-type: text/x-yaml" echo "Print (UPDATED) Deployment info"; echo "--------------------------" curl $INSECURE $PLUNDERURL/deployments; echo "" sudo kill -9 $( ps -ef | grep -i plunder | grep -v -e 'sudo' -e 'grep' | awk '{ print $2 }') wait $! 2>/dev/null sleep 1 else echo "Skipping permission tests as running as root" fi echo "The following tests rely on sudo, with NOPASSWD enabled" echo "Starting with disabled configuration" retVal=$? if [ $retVal -ne 0 ]; then echo "Error at make build" exit fi echo "To remote [./testing/] directory, and [./plunder] binary" echo "rm -rf ./testing/ ./plunder"