├── CONTRIBUTING.md ├── appengine ├── examples │ ├── pe_allowlist.yaml │ └── default.yaml ├── appengine.go ├── endpoints │ ├── sign_test.go │ ├── seed.go │ ├── seed_test.go │ └── sign.go └── README.md ├── .github └── workflows │ ├── go_tests.yml │ └── release.yml ├── .goreleaser.yml ├── cli ├── config │ ├── config_darwin.go │ ├── config_linux_test.go │ ├── config_linux.go │ ├── defaults.go │ ├── config_windows.go │ ├── README.md │ ├── config.go │ └── config_test.go ├── main_windows.go ├── main_unix.go ├── commands │ ├── list │ │ ├── list_test.go │ │ └── list.go │ └── write │ │ ├── write_test.go │ │ └── write.go ├── main.go ├── console │ ├── console_test.go │ └── console.go ├── README.md └── installer │ └── installer.go ├── go.mod ├── models └── models.go ├── README.md ├── LICENSE └── go.sum /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 4 | 5 | Unfortunately, we are not able to accept external contributions at this time. 6 | We'd love to hear about any issues you experience when using our product. Please 7 | file bugs and feature requests via GitHub's 8 | [Issue Tracker](https://github.com/google/fresnel/issues). 9 | 10 | ## Community Guidelines 11 | 12 | This project follows 13 | [Google's Open Source Community Guidelines](https://opensource.google.com/conduct/). 14 | -------------------------------------------------------------------------------- /appengine/examples/pe_allowlist.yaml: -------------------------------------------------------------------------------- 1 | # Format: 2 | # - 'boot.wim SHA-256 Hash' # 3 | 4 | ################################################################################################# 5 | # Release boot.wim hashes # 6 | ################################################################################################# 7 | - '123456789123456789123456789123456789123456789123456789EAC19FA883' # stable 8 | - '987654321987654321987654321123456789123456789123456789EAC19FA123' # testing 9 | -------------------------------------------------------------------------------- /.github/workflows/go_tests.yml: -------------------------------------------------------------------------------- 1 | name: Go Tests 2 | on: [push, pull_request] 3 | jobs: 4 | go_tests: 5 | strategy: 6 | matrix: 7 | go-version: [1.18.x, 1.19.x, 1.22.x] 8 | os: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v3 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v3 18 | 19 | - name: Run vet 20 | run: go vet ./... 21 | 22 | - name: Test 23 | run: go test -v ./... 24 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Make sure to check the documentation at https://goreleaser.com 2 | before: 3 | hooks: 4 | # You may remove this if you don't use go modules. 5 | - go mod tidy 6 | builds: 7 | # Custom environment variables to be set during the builds. 8 | - env: 9 | - CGO_ENABLED=0 10 | # For more info refer to: https://golang.org/doc/install/source#environment 11 | goos: 12 | - darwin 13 | - linux 14 | - windows 15 | goarch: 16 | - amd64 17 | main: ./cli/ 18 | archives: 19 | # Optionally override the matrix generation and specify only the final list of targets. 20 | - format: binary 21 | name_template: "cli_{{ .Os }}" 22 | checksum: 23 | name_template: "checksums.txt" 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | # Only create artifacts when there is a semantic-versioned release on GitHub (ex: v1.0.1) 6 | tags: 7 | - 'v*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: 1.19.x 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v2 28 | with: 29 | distribution: goreleaser 30 | version: latest 31 | args: release --rm-dist -f .goreleaser.yml 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /cli/config/config_darwin.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build darwin 16 | // +build darwin 17 | 18 | package config 19 | 20 | var ( 21 | // IsElevatedCmd is not required on Darwin. 22 | IsElevatedCmd = func() (bool, error) { return true, nil } 23 | 24 | // HasWritePermissions is not supported on Darwin. 25 | HasWritePermissions = func() error { return nil } 26 | ) 27 | -------------------------------------------------------------------------------- /cli/main_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // go:build windows 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | 23 | "github.com/google/deck/backends/eventlog" 24 | "github.com/google/deck" 25 | ) 26 | 27 | func init() { 28 | evt, err := eventlog.Init(binaryName) 29 | if err != nil { 30 | fmt.Println(err) 31 | os.Exit(1) 32 | } 33 | deck.Add(evt) 34 | } 35 | -------------------------------------------------------------------------------- /cli/main_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build unix || darwin 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | 23 | "github.com/google/deck/backends/syslog" 24 | "github.com/google/deck" 25 | ) 26 | 27 | func init() { 28 | sl, err := syslog.Init(binaryName, syslog.LOG_USER) 29 | if err != nil { 30 | fmt.Println(err) 31 | os.Exit(1) 32 | } 33 | deck.Add(sl) 34 | } 35 | -------------------------------------------------------------------------------- /cli/config/config_linux_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // +build linux 16 | 17 | package config 18 | 19 | import ( 20 | "errors" 21 | "testing" 22 | ) 23 | 24 | func TestIsRoot(t *testing.T) { 25 | got, err := isRoot() 26 | if !errors.Is(err, nil) { 27 | t.Errorf("isRoot() err: %v, want err: %v", err, nil) 28 | } 29 | if got != true { 30 | t.Errorf("isRoot() got: %t, want: %t", got, true) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /appengine/examples/default.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | handlers: 16 | - url: /.* 17 | script: _go_app 18 | 19 | # Supported time units for durations include 20 | # "ns", "us", "ms", "s", "m", "h". 21 | # https://golang.org/pkg/time/#ParseDuration 22 | env_variables: 23 | BUCKET: example-build-bucket 24 | SIGNED_URL_DURATION: 60m 25 | SEED_VALIDITY_DURATION: 24h 26 | VERIFY_SEED: 'true' 27 | VERIFY_SEED_SIGNATURE: 'true' 28 | VERIFY_SEED_SIGNATURE_FALLBACK: 'true' 29 | VERIFY_SEED_HASH: 'true' 30 | VERIFY_SIGN_HASH: 'true' 31 | -------------------------------------------------------------------------------- /cli/config/config_linux.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build linux 16 | // +build linux 17 | 18 | package config 19 | 20 | var ( 21 | // IsElevatedCmd injects the command to determine the elevation state of the 22 | // user context. 23 | IsElevatedCmd = isRoot 24 | 25 | // HasWritePermissions is not supported on Linux. 26 | HasWritePermissions = func() error { return nil } 27 | ) 28 | 29 | // isRoot always returns true on Linux, as sudo is built-in to all commands. 30 | func isRoot() (bool, error) { 31 | return true, nil 32 | } 33 | -------------------------------------------------------------------------------- /appengine/appengine.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package appengine is an web-application that provides a public API 16 | // for imaging Windows clients. It provides endpoints to permit 17 | // pre-authorization for builds, and for machines being built to obtain 18 | // required binaries from a GCS cloud bucket. 19 | package main 20 | 21 | import ( 22 | "net/http" 23 | 24 | "github.com/google/fresnel/appengine/endpoints" 25 | "google.golang.org/appengine" 26 | ) 27 | 28 | func main() { 29 | http.Handle("/sign", &endpoints.SignRequestHandler{}) 30 | http.Handle("/seed", &endpoints.SeedRequestHandler{}) 31 | 32 | appengine.Main() 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/google/fresnel 2 | 3 | go 1.18 4 | 5 | require ( 6 | cloud.google.com/go/storage v1.28.1 7 | github.com/docker/go-units v0.4.0 8 | github.com/dustin/go-humanize v1.0.0 9 | github.com/google/deck v0.0.0-20221215182815-59abc1280690 10 | github.com/google/glazier v0.0.0-20220803164842-3bfee96e658a 11 | github.com/google/go-cmp v0.5.9 12 | github.com/google/splice v1.0.0 13 | github.com/google/subcommands v1.2.0 14 | github.com/google/winops v0.0.0-20210803215038-c8511b84de2b 15 | github.com/olekukonko/tablewriter v0.0.5 16 | github.com/patrickmn/go-cache v2.1.0+incompatible 17 | golang.org/x/sys v0.13.0 18 | google.golang.org/appengine v1.6.7 19 | gopkg.in/yaml.v2 v2.4.0 20 | ) 21 | 22 | require ( 23 | cloud.google.com/go v0.110.0 // indirect 24 | cloud.google.com/go/compute v1.19.1 // indirect 25 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 26 | cloud.google.com/go/iam v0.13.0 // indirect 27 | github.com/go-ole/go-ole v1.2.5 // indirect 28 | github.com/godbus/dbus v4.1.0+incompatible // indirect 29 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect 30 | github.com/golang/protobuf v1.5.3 // indirect 31 | github.com/google/logger v1.1.1 // indirect 32 | github.com/google/uuid v1.3.0 // indirect 33 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect 34 | github.com/googleapis/gax-go/v2 v2.7.1 // indirect 35 | github.com/groob/plist v0.0.0-20210519001750-9f754062e6d6 // indirect 36 | github.com/mattn/go-runewidth v0.0.9 // indirect 37 | github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect 38 | go.opencensus.io v0.24.0 // indirect 39 | golang.org/x/net v0.17.0 // indirect 40 | golang.org/x/oauth2 v0.7.0 // indirect 41 | golang.org/x/text v0.13.0 // indirect 42 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 43 | google.golang.org/api v0.114.0 // indirect 44 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect 45 | google.golang.org/grpc v1.56.3 // indirect 46 | google.golang.org/protobuf v1.30.0 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /cli/commands/list/list_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package list 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "testing" 21 | 22 | "github.com/google/subcommands" 23 | "github.com/google/winops/storage" 24 | ) 25 | 26 | func TestName(t *testing.T) { 27 | list := &listCmd{} 28 | got := list.Name() 29 | if got == "" { 30 | t.Errorf("Name() got: %q, want: not empty, got", got) 31 | } 32 | } 33 | 34 | func TestSynopsis(t *testing.T) { 35 | list := &listCmd{} 36 | got := list.Synopsis() 37 | if got == "" { 38 | t.Errorf("Synopsis() got: %q, want: not empty, got", got) 39 | } 40 | } 41 | 42 | func TestUsage(t *testing.T) { 43 | list := &listCmd{} 44 | got := list.Usage() 45 | if got == "" { 46 | t.Errorf("Usage() got: %q, want: not empty, got", got) 47 | } 48 | } 49 | 50 | func TestExecute(t *testing.T) { 51 | tests := []struct { 52 | desc string 53 | fakeSearch func(string, uint64, uint64, bool) ([]*storage.Device, error) 54 | want subcommands.ExitStatus 55 | }{ 56 | { 57 | desc: "search error", 58 | fakeSearch: func(string, uint64, uint64, bool) ([]*storage.Device, error) { return nil, fmt.Errorf("error") }, 59 | want: subcommands.ExitFailure, 60 | }, 61 | { 62 | desc: "success", 63 | fakeSearch: func(string, uint64, uint64, bool) ([]*storage.Device, error) { 64 | return []*storage.Device{&storage.Device{}}, nil 65 | }, 66 | want: subcommands.ExitSuccess, 67 | }, 68 | } 69 | for _, tt := range tests { 70 | search = tt.fakeSearch 71 | list := &listCmd{} 72 | got := list.Execute(context.Background(), nil, nil) 73 | if got != tt.want { 74 | t.Errorf("%s: Execute() got: %d, want: %d", tt.desc, got, tt.want) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /cli/config/defaults.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import "fmt" 18 | 19 | // distributions configures the options for different operating system 20 | // installers. 21 | var ( 22 | // distributions configures the options for different operating system 23 | // installers. 24 | distributions = map[string]distribution{ 25 | "windows": distribution{ 26 | os: windows, 27 | label: "INSTALLER", 28 | name: "windows", 29 | seedServer: "https://appengine.address.com/seed", 30 | seedFile: "sources/boot.wim", 31 | seedDest: "seed", 32 | imageServer: "https://image.host.com/folder", 33 | images: map[string]string{ 34 | "default": "installer_img.iso", 35 | "stable": "installer_img.iso", 36 | }, 37 | }, 38 | "windowsffu": distribution{ 39 | os: windows, 40 | label: "INSTALLER", 41 | name: "windows", 42 | imageServer: "https://image.host.com/folder", 43 | confServer: "https://config.host.com/folder", 44 | images: map[string]string{ 45 | "default": "installer_img.iso", 46 | "stable": "installer_img.iso", 47 | "unstable": "installer_img.iso", 48 | }, 49 | configs: map[string]string{ 50 | "default": "installer_config.yaml", 51 | "stable": "installer_config.yaml", 52 | "unstable": "installer_config.yaml", 53 | }, 54 | }, 55 | "linux": distribution{ 56 | os: linux, 57 | name: "linux", 58 | imageServer: "", 59 | images: map[string]string{ 60 | "default": "installer.img.gz", 61 | "stable": "installer.img.gz", 62 | }, 63 | }, 64 | } 65 | 66 | // ErrUSBwriteAccess contains the Error message visible to users when USB write access is forbidden. 67 | ErrUSBwriteAccess = fmt.Errorf("contact IT helpdesk for help") 68 | ) 69 | -------------------------------------------------------------------------------- /cli/config/config_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build windows 16 | // +build windows 17 | 18 | package config 19 | 20 | import ( 21 | "fmt" 22 | 23 | win "golang.org/x/sys/windows" 24 | "github.com/google/glazier/go/registry" 25 | ) 26 | 27 | var ( 28 | 29 | // IsElevatedCmd injects the command to determine the elevation state of the 30 | // user context. 31 | IsElevatedCmd = isAdmin 32 | funcUSBPermissions = HasWritePermissions 33 | 34 | denyWriteRegKey = `SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices\{53f5630d-b6bf-11d0-94f2-00a0c91efb8b}` 35 | ) 36 | 37 | // isAdmin determines if the current user is running the binary with elevated 38 | // permissions on Windows. 39 | func isAdmin() (bool, error) { 40 | 41 | var sid *win.SID 42 | 43 | // https://docs.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-checktokenmembership 44 | err := win.AllocateAndInitializeSid( 45 | &win.SECURITY_NT_AUTHORITY, 46 | 2, 47 | win.SECURITY_BUILTIN_DOMAIN_RID, 48 | win.DOMAIN_ALIAS_RID_ADMINS, 49 | 0, 0, 0, 0, 0, 0, 50 | &sid) 51 | if err != nil { 52 | return false, fmt.Errorf("sid error: %v", err) 53 | } 54 | 55 | token := win.Token(0) 56 | defer token.Close() 57 | 58 | member, err := token.IsMember(sid) 59 | if err != nil { 60 | return false, fmt.Errorf("Token Membership Error: %v", err) 61 | } 62 | 63 | // user is currently an admin 64 | if member { 65 | return true, nil 66 | } 67 | 68 | return false, errElevation 69 | } 70 | 71 | // HasWritePermissions determines if the local machine is blocked from writing to removable media via policy. 72 | func HasWritePermissions() error { 73 | v, err := registry.GetInteger(denyWriteRegKey, "Deny_Write") 74 | if err != nil && err != registry.ErrNotExist { 75 | return err 76 | } 77 | if v == 1 { 78 | return ErrWritePerms 79 | } 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package models provides data structures for imaging requests, responses, 16 | // and status codes. 17 | package models 18 | 19 | import ( 20 | "time" 21 | 22 | "google.golang.org/appengine" 23 | ) 24 | 25 | // StatusCode represents an appengine status code, and is used to communicate 26 | // reasons for request and result rejections, as well as internal failures. 27 | type StatusCode int 28 | 29 | // Internal Status Messages, provided to the client as part of response messages. 30 | const ( 31 | StatusSuccess StatusCode = 0 32 | StatusConfigError StatusCode = iota + 100 33 | StatusReqUnreadable 34 | StatusJSONError 35 | StatusSignError 36 | StatusSeedError 37 | StatusSeedInvalidHash 38 | StatusInvalidUser 39 | ) 40 | 41 | // SignRequest models the data that a client can submit as part 42 | // of a sign request. 43 | type SignRequest struct { 44 | Seed Seed 45 | Signature []byte 46 | Mac []string 47 | Path string 48 | Hash []byte 49 | } 50 | 51 | // SignResponse models the response to a client sign request. 52 | type SignResponse struct { 53 | Status string 54 | ErrorCode StatusCode 55 | SignedURL string 56 | } 57 | 58 | // SeedRequest models the data that a client must submit as part of a Seed 59 | // request 60 | type SeedRequest struct { 61 | Hash []byte 62 | } 63 | 64 | // SeedResponse models the data that is passed back to the client when a seed 65 | // request is successfully processed. 66 | type SeedResponse struct { 67 | Status string 68 | ErrorCode StatusCode 69 | Seed Seed 70 | Signature []byte 71 | } 72 | 73 | // SeedFile models the file that is stored on disk by the bootstraper. It is 74 | // similar to SeedResponse, but does not contain the uneccessary Status and 75 | // ErrorCode fields, which can contain data not intended to be stored on 76 | // disk. 77 | type SeedFile struct { 78 | Seed Seed 79 | Signature []byte 80 | } 81 | 82 | // Seed represents the data that validates proof of origin for a request. It 83 | // is always accompanied by a signature that is used to decrypt and validate 84 | // its contents. 85 | type Seed struct { 86 | Issued time.Time 87 | Username string 88 | Certs []appengine.Certificate 89 | Hash []byte 90 | } 91 | -------------------------------------------------------------------------------- /cli/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // main is the entry point for the image writer. It implements image writing 16 | // functionality for installers to compatible devices through subcommands. 17 | package main 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | "os/signal" 24 | "path/filepath" 25 | "strings" 26 | "syscall" 27 | 28 | // Register subcommands. 29 | _ "github.com/google/fresnel/cli/commands/list" 30 | _ "github.com/google/fresnel/cli/commands/write" 31 | "github.com/google/deck/backends/logger" 32 | "github.com/google/deck" 33 | 34 | "flag" 35 | "github.com/google/subcommands" 36 | ) 37 | 38 | var ( 39 | binaryName = filepath.Base(strings.ReplaceAll(os.Args[0], `.exe`, ``)) 40 | logFile *os.File 41 | ) 42 | 43 | func setupLogging() error { 44 | // Initialize logging with the bare binary name as the source. 45 | lp := filepath.Join(os.TempDir(), fmt.Sprintf(`%s.log`, binaryName)) 46 | var err error 47 | logFile, err = os.OpenFile(lp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0660) 48 | if err != nil { 49 | return fmt.Errorf("Failed to open log file: %v", err) 50 | } 51 | deck.Add(logger.Init(logFile, 0)) 52 | 53 | return nil 54 | } 55 | 56 | func main() { 57 | 58 | // Explicitly set the log output flag from the log package so that we can see 59 | // info messages by default in the console and in the logs. Logging is 60 | // initialized in each sub-command. 61 | flag.Set("alsologtostderr", "true") 62 | flag.Set("vmodule", "third_party/golang/fresnel*=1") 63 | 64 | if err := setupLogging(); err != nil { 65 | fmt.Println(err) 66 | os.Exit(1) 67 | } 68 | defer logFile.Close() 69 | defer deck.Close() 70 | 71 | subcommands.Register(subcommands.HelpCommand(), "") 72 | subcommands.Register(subcommands.FlagsCommand(), "") 73 | subcommands.Register(subcommands.CommandsCommand(), "") 74 | 75 | if flag.NArg() < 1 { 76 | deck.Error("ERROR: No command specified.") 77 | } 78 | 79 | // Cancel the context on sigterm and sigint. 80 | ctx, cancelFn := context.WithCancel(context.Background()) 81 | defer cancelFn() 82 | signalCh := make(chan os.Signal, 1) 83 | signal.Notify(signalCh, syscall.SIGTERM, syscall.SIGINT) 84 | go func() { 85 | // Only handle the first sigterm or sigint and then cancel the context 86 | // and ignore these signals. 87 | sig := <-signalCh 88 | deck.Errorf("Received %s signal. Cancelling context ...\n", sig) 89 | signal.Ignore(syscall.SIGTERM, syscall.SIGINT) 90 | cancelFn() 91 | }() 92 | 93 | os.Exit(int(subcommands.Execute(ctx))) 94 | } 95 | -------------------------------------------------------------------------------- /cli/config/README.md: -------------------------------------------------------------------------------- 1 | # CLI Configuration 2 | 3 | 4 | 5 | The Fresnel CLI obtains all information for the distributions it makes available 6 | from [deafults.go](defaults.go). 7 | 8 | ## Distributions 9 | 10 | Distributions defines collections of related installers. In general, this refers 11 | to operating system families (windows, linux) but can also refer to a collection 12 | of utility installers, such as recovery disks. 13 | 14 | Adding a map within entry to distributions within defaults.go adds a 15 | distribution for the cli to use. 16 | 17 | ## Distribution 18 | 19 | A distribution defines the specific installer you wish to make available. 20 | 21 | ``` 22 | type distribution struct { 23 | os OperatingSystem // windows or linux 24 | name string // Friendly name: e.g. Corp Windows. 25 | label string // If set, is used to set partition labels. 26 | seedServer string // If set, a seed is obtained from here. 27 | seedFile string // This file is hashed when obtaing a seed. 28 | seedDest string // The relative path where the seed should be written. 29 | imageServer string // The base image is obtained here. 30 | images map[string]string 31 | } 32 | ``` 33 | 34 | ### Behaviors with specific fields 35 | 36 | * **label** - Sets the data partition of the installation media is this value. 37 | * **seedServer** - When configured, the CLI will attempt to retrieve a seed 38 | from your App Engine instance. See the 39 | [appengine documentation](../../appengine/README.md) for more information on 40 | seeds. 41 | * **seedFile** - When configured, this file is hashed and the hash send with 42 | the seed request. 43 | * **seedDest** - The relative path on the installation media where the seed 44 | should be written. 45 | * **imageServer** - The root path to the webserver that houses installation 46 | media images. 47 | 48 | ### Images 49 | 50 | Images are defined within a distribution. Think of them as a set of variants for 51 | an image. For example, you may have a stable and unstable installer for windows. 52 | They are represented by a map of strings with the key being the label and the 53 | value representing the relative path of the image file under imageServer. 54 | 55 | ## Example 56 | 57 | The following example is a valid configuration. 58 | 59 | ``` 60 | var distributions = map[string]distribution{ 61 | "windows": distribution{ 62 | os: windows, 63 | label: "INSTALLER", 64 | name: "windows", 65 | seedServer: "https://appengine.address.com/seed", 66 | seedFile: "sources/boot.wim", 67 | seedDest: "seed", 68 | imageServer: "https://image.host.com/folder", 69 | images: map[string]string{ 70 | "default": "installer_img.iso", 71 | }, 72 | }, 73 | "linux": distribution{ 74 | os: linux, 75 | name: "linux", 76 | imageServer: "", 77 | images: map[string]string{ 78 | "default": "installer.img.gz", 79 | }, 80 | }, 81 | } 82 | ``` 83 | -------------------------------------------------------------------------------- /cli/console/console_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package console 16 | 17 | import ( 18 | "bytes" 19 | "strings" 20 | "testing" 21 | ) 22 | 23 | // fakeDevice inherits all members of target.Device through embedding. 24 | // Unimplemented members send a clear signal during tests because they will 25 | // panic if called, allowing us to implement only the minimum set of members 26 | // required for testing. 27 | type fakeDevice struct { 28 | id string 29 | friendlyName string 30 | size uint64 31 | } 32 | 33 | func (f *fakeDevice) Identifier() string { 34 | return f.id 35 | } 36 | 37 | func (f *fakeDevice) FriendlyName() string { 38 | return f.friendlyName 39 | } 40 | 41 | func (f *fakeDevice) Size() uint64 { 42 | return f.size 43 | } 44 | 45 | func TestPrintDevices(t *testing.T) { 46 | deviceOne := &fakeDevice{ 47 | id: "drive1", 48 | friendlyName: "foo super duper drive", 49 | size: 1123456789, 50 | } 51 | deviceTwo := &fakeDevice{ 52 | id: "drive2", 53 | friendlyName: "bar bodacious drive", 54 | size: 9987654321, 55 | } 56 | deviceThree := &fakeDevice{ 57 | id: "drive3", 58 | friendlyName: "baz radical drive", 59 | size: 19987654321, 60 | } 61 | tests := []struct { 62 | desc string 63 | devices []TargetDevice 64 | json bool 65 | want string 66 | }{ 67 | { 68 | desc: "no devices", 69 | devices: []TargetDevice{}, 70 | json: false, 71 | want: "No matching devices were found.", 72 | }, 73 | { 74 | desc: "no devices with json", 75 | devices: []TargetDevice{}, 76 | json: true, 77 | want: "[]", 78 | }, 79 | { 80 | desc: "one device", 81 | devices: []TargetDevice{deviceOne}, 82 | json: false, 83 | want: deviceOne.Identifier(), 84 | }, 85 | { 86 | desc: "one device with json", 87 | devices: []TargetDevice{deviceOne}, 88 | json: true, 89 | want: "[{\"ID\":\"" + deviceOne.Identifier(), 90 | }, 91 | { 92 | desc: "two devices", 93 | devices: []TargetDevice{deviceOne, deviceTwo}, 94 | json: false, 95 | want: deviceTwo.Identifier(), 96 | }, 97 | { 98 | desc: "three devices", 99 | devices: []TargetDevice{deviceOne, deviceTwo, deviceThree}, 100 | json: false, 101 | want: deviceThree.Identifier(), 102 | }, 103 | } 104 | for _, tt := range tests { 105 | var got bytes.Buffer 106 | PrintDevices(tt.devices, &got, tt.json) 107 | if !strings.Contains(got.String(), tt.want) { 108 | t.Errorf("%s: PrintDevices() got = %q, must contain = %q", tt.desc, got.String(), tt.want) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # Fresnel CLI 2 | 3 | 4 | 5 | The Fresnel CLI runs on a trusted machine that is authorized to build boot media 6 | for an operating system installer. It's primary purpose is to prepare storage 7 | media (USB or Removable Disk) that will install an operating system. 8 | 9 | ## Getting Started 10 | 11 | Pre-compiled binaries are available as 12 | [release assets](https://github.com/google/fresnel/releases). 13 | 14 | Building Fresnel CLI manually: 15 | 16 | 1. Clone the repository 17 | 1. Install any missing imports with `go get -u` 18 | 1. Run `go build C:\Path\to\fresnel\src\cli` 19 | 20 | ## Subcommands 21 | 22 | Subcommands are required in order to operate the CLI. A list of available 23 | subcommands is available by using the help subcommand. 24 | 25 | ``` 26 | cli.exe help 27 | ``` 28 | 29 | A list of command line flags is available for each sub-command by calling help 30 | with that subcommand as the parameter. 31 | 32 | ``` 33 | cli.exe help write 34 | ``` 35 | 36 | ## Available Subcommands 37 | 38 | Commonly used sub-commands are listed here. A full list is available through the 39 | help subcommand. 40 | 41 | ### List 42 | 43 | The list sub-command outputs the storage devices that are suitable for 44 | provisioning your operating system installer. See parameters for a list of the 45 | defaults used when determining what a suitable device is. 46 | 47 | __**Usage**__ 48 | 49 | ``` 50 | cli.exe list 51 | ``` 52 | 53 | #### Common Flags 54 | 55 | **--show_fixed [bool]** 56 | 57 | Default = [False] 58 | 59 | Includes fixed disks when searching for suitable devices. 60 | 61 | __**Example**__ 62 | 63 | ``` 64 | cli.exe list --show_fixed 65 | ``` 66 | 67 | **--minimum** 68 | 69 | [int] Default = 2 GB 70 | 71 | The minimum size (in Gigabytes) of storage to search for. 72 | 73 | __**Example**__ 74 | 75 | ``` 76 | cli.exe list --minimum=8 77 | ``` 78 | 79 | **--maximum** 80 | 81 | [int] Default = 0 (no maximum) 82 | 83 | The maximum size (in Gigabytes) of storage to search for. 84 | 85 | __**Example**__ 86 | 87 | ``` 88 | cli.exe list --maximum=64 89 | ``` 90 | 91 | ### Write 92 | 93 | The write subcommand writes an operating system installer to storage media. The 94 | list of available operating system installers is configured by modifying the 95 | config package and its [deafults.go](config/defaults.go) file. 96 | 97 | __**Usage Examples**__ 98 | 99 | ``` 100 | cli.exe write -distro=windows -track=stable 1 101 | 102 | cli write -distro=linux -track=stable sda 103 | ``` 104 | 105 | #### Common Flags 106 | 107 | **--distro [string]** 108 | 109 | Default = [None] 110 | 111 | The distribution you wish to provision onto the selected media. The options for 112 | this value are configured by adding an entry in the map for distributions in 113 | [defaults.go](config/defaults.go). A distribution is generally defined as the 114 | operating system you wish to install (e.g. windows or linux). It can represent 115 | any collection of related images that you wish to make avaialble. 116 | 117 | **--track [string]** 118 | 119 | Default = [None] 120 | 121 | The track indicates the specific installer within a distribution to provision 122 | onto the selected media. For example, you may have a stable, testing and 123 | unstable versions of Windows that you wish to make available. 124 | 125 | __**Examples**__ 126 | 127 | ``` 128 | cli.exe write --distro=windows -track=stable 1 129 | 130 | cli write --distro=linux -track=unstable sda 131 | ``` 132 | 133 | ## Important Behaviors 134 | 135 | Specific behaviors are automatically triggered by configuring fields for your 136 | distribution within [config.go](config/defaults.go). For example, seeds are 137 | automatically retrieved when the write command is running if a seedServer and 138 | seedFile is added to the distribution configuration. For more information on 139 | these, see the documentation for [config](config/README.md). 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fresnel 2 | 3 | 4 | 5 | Support | Actions | Contributing | Open Issues | License 6 | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------- 7 | [![Google Groups - Fresnel](https://img.shields.io/badge/Support-Google%20Groups-blue)](https://groups.google.com/forum/#!forum/fresnel-discuss) | [![Go Tests](https://github.com/google/fresnel/workflows/Go%20Tests/badge.svg)](https://github.com/google/fresnel/actions?query=workflow%3A%22Go+Tests%22) [![release](https://github.com/google/fresnel/actions/workflows/release.yml/badge.svg)](https://github.com/google/fresnel/actions/workflows/release.yml)| [![Contributing](https://img.shields.io/badge/Contributions-Closed-red)](https://github.com/google/fresnel/blob/master/CONTRIBUTING.md) | [![Open Issues](https://img.shields.io/github/issues/google/fresnel)](https://github.com/google/fresnel/issues) | [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg)](https://github.com/google/fresnel/blob/master/LICENSE) 8 | 9 | [Fresnel](https://en.wikipedia.org/wiki/Fresnel_lens) */fray-NEL/* projects 10 | Windows out into the world. 11 | 12 | Fresnel is an infrastructure service which allows operating system images to be 13 | retrieved and provisioned from anywhere internet access is available. It allows 14 | an authorized user to create sanctioned boot media that can be used to provision 15 | a machine, and it allows the installer to obtain files needed to build trust. 16 | 17 | ## Overview 18 | 19 | In a traditional deployment scenario, a device must be on-premises at a business 20 | location to be provisioned with a business appropriate operating system image. 21 | The imaging platform generally relies on PXE boot to permit the client system to 22 | begin the provisioning process. 23 | 24 | The process introduces dependencies on the local network: 25 | 26 | * PXE must be locally available in order to obtain a sanctioned boot image. 27 | * The sanctioned boot image must come from a trustworthy source. 28 | * The boot image must be able to obtain files/resources. 29 | * The local network is considered trusted and is used to provide all of the 30 | above. 31 | 32 | A basic solution to this problem is to provide all needed files and media on 33 | pre-created boot media, but this introduces limitations. The boot media often 34 | quickly becomes stale and it is undesirable to store any type of secret on such 35 | media. 36 | 37 | Fresnel addresses these limitations by providing an intermediary broker for the 38 | bootstrap and provisioning process. The Fresnel infrastructure provides a place 39 | where an authorized user can obtain and generate up-to-date sanctioned boot 40 | media. It also provides a place where the installer can obtain the files needed 41 | to build trust prior to connecting to the business network. Once the 42 | provisioning process gains trust, trusted connectivity can be provided by a VPN 43 | or another solution to provides access to the remaining files needed to complete 44 | the provisioning process. 45 | 46 | ## Documentation 47 | 48 | See the [App Engine documentation](appengine/README.md) for information on 49 | installing and configuring Fresnel App Engine and for information on how to make 50 | requests for signed-urls from App Engine. 51 | 52 | See the [CLI Documentation](cli/README.md) for information on using the Fresnel 53 | CLI to provision your installer. 54 | 55 | ## Contact 56 | 57 | We have a public discussion list at 58 | [fresnel-discuss@googlegroups.com](https://groups.google.com/forum/#!forum/fresnel-discuss) 59 | 60 | ## Disclaimer 61 | 62 | This is not an official Google product. 63 | -------------------------------------------------------------------------------- /appengine/endpoints/sign_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package endpoints 16 | 17 | import ( 18 | "bytes" 19 | "encoding/hex" 20 | "encoding/json" 21 | "errors" 22 | "fmt" 23 | "io" 24 | "net/http" 25 | "net/http/httptest" 26 | "os" 27 | "strings" 28 | "testing" 29 | "time" 30 | 31 | "github.com/google/fresnel/models" 32 | ) 33 | 34 | const bucket = "test" 35 | 36 | var ( 37 | goodSeed = models.Seed{ 38 | Issued: time.Now(), 39 | Username: "test", 40 | } 41 | expiredSeed = models.Seed{ 42 | Issued: time.Now().Add(time.Hour * -169), 43 | Username: "test", 44 | } 45 | bogusSeed = models.Seed{Username: "bogus"} 46 | 47 | // Invalid JSON that cannot be unmarshalled correctly. 48 | badJSON = []byte(`{"name":bogus?}`) 49 | ) 50 | 51 | // prepEnvVariables takes a map of variables and their values and sets the environment appropriately; it returns a cleanup function that unsets any values set during the call. 52 | func prepEnvVariables(envVars map[string]string) (func() error, error) { 53 | for key, value := range envVars { 54 | err := os.Setenv(key, value) 55 | if err != nil { 56 | return func() error { return nil }, fmt.Errorf("could not set env variable %v, got err %v", key, err) 57 | } 58 | } 59 | return func() error { 60 | for key := range envVars { 61 | err := os.Unsetenv(key) 62 | if err != nil { 63 | return fmt.Errorf("failed to cleanup environment variable %s, got: %v", key, err) 64 | } 65 | } 66 | return nil 67 | }, nil 68 | } 69 | 70 | // prepTestHash returns an accepted hash as a []byte or an error. 71 | func prepTestHash() ([]byte, error) { 72 | return hex.DecodeString("314aaa98adcbd86339fb4eece6050b8ae2d38ff8ebb416e231bb7724c99b830d") 73 | } 74 | 75 | // prepTestSignRequest returns a valid sign request and mocks out the allowed hash file 76 | func prepSignTestRequest() (models.SignRequest, error) { 77 | h, err := prepTestHash() 78 | if err != nil { 79 | return models.SignRequest{}, fmt.Errorf("could not create test hash prepTestHash returned: %v", err) 80 | } 81 | 82 | return models.SignRequest{ 83 | Seed: goodSeed, 84 | Mac: []string{"123456789ABC", "12:34:56:78:9A:BC"}, 85 | Path: "dummy/folder/file.txt", 86 | Hash: h, 87 | }, nil 88 | } 89 | 90 | // errReader is an io.Reader that always returns an error when you 91 | // attempt to read from it. 92 | type errReader int 93 | 94 | func (errReader) Read(p []byte) (n int, err error) { 95 | return 0, errors.New("failure") 96 | } 97 | 98 | func TestUnmarshalSignRequest(t *testing.T) { 99 | goodReq, err := prepSignTestRequest() 100 | if err != nil { 101 | t.Fatalf("failed to prep good sign test request: %v", err) 102 | } 103 | 104 | good, err := json.Marshal(goodReq) 105 | if err != nil { 106 | t.Fatalf("setup, json.Marshal(%v) returned %v", goodReq, err) 107 | } 108 | 109 | type result struct { 110 | statusCode models.StatusCode 111 | err error 112 | } 113 | 114 | tests := []struct { 115 | desc string 116 | in io.Reader 117 | out result 118 | }{ 119 | { 120 | "valid http request", 121 | bytes.NewReader(good), 122 | result{ 123 | statusCode: models.StatusSuccess, 124 | err: nil, 125 | }, 126 | }, 127 | { 128 | "unreadable http request body", 129 | errReader(0), 130 | result{ 131 | statusCode: models.StatusReqUnreadable, 132 | err: errors.New("unable to read"), 133 | }, 134 | }, 135 | { 136 | "empty request body", 137 | nil, 138 | result{ 139 | statusCode: models.StatusJSONError, 140 | err: errors.New("empty"), 141 | }, 142 | }, 143 | { 144 | "unable to unmarshal json", 145 | bytes.NewReader(badJSON), 146 | result{ 147 | statusCode: models.StatusJSONError, 148 | err: errors.New("unable to unmarshal"), 149 | }, 150 | }, 151 | } 152 | 153 | for _, tt := range tests { 154 | t.Logf("Running '%s'; expecting %d %v", tt.desc, tt.out.statusCode, tt.out.err) 155 | 156 | r := httptest.NewRequest(http.MethodPost, "/sign", tt.in) 157 | _, got, err := unmarshalSignRequest(r) 158 | if got != tt.out.statusCode { 159 | t.Errorf("%s; got %d %v, want %d %v", 160 | tt.desc, got, err, tt.out.statusCode, tt.out.err) 161 | } 162 | if err == tt.out.err { 163 | continue 164 | } 165 | if !strings.Contains(err.Error(), tt.out.err.Error()) { 166 | t.Errorf("%s; got %v %d, want %v %d", 167 | tt.desc, err, got, tt.out.err, tt.out.statusCode) 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /appengine/README.md: -------------------------------------------------------------------------------- 1 | # Fresnel App Engine 2 | 3 | 4 | 5 | Fresnel App Engine runs in your Google Cloud Platform (GCP) project. It serves 6 | as the intermediary between a client and the resources needed to build trust for 7 | your OS installer. 8 | 9 | ## Project Selection 10 | 11 | Fresnel can run in your existing GCP organization, either in its own project, or 12 | within a project you already run. 13 | 14 | 1. Go to https://console.cloud.google.com 15 | 1. Select your project, or create a new one if necessary. 16 | 17 | ## Installation 18 | 19 | 1. Prepare your project to host Fresnel App Engine by following 20 | [these instructions](https://cloud.google.com/appengine/docs/standard/go/console). 21 | 1. Prepare your app.yaml and pe_allowlist.yaml files. 22 | 1. Test and deploy the application, including these files using 23 | [gcloud deploy](https://cloud.google.com/appengine/docs/standard/go/testing-and-deploying-your-app#deploying_your_application). 24 | 1. Note the address of your AppEngine instance, and update your CLI 25 | configuration with this address. The CLI will use this address to obtain 26 | seeds during provisioning. 27 | 28 | ## Endpoints 29 | 30 | ### /seed 31 | 32 | Used by the Fresnel CLI to obtain seeds when the distribution is configured to 33 | request them. Seeds are signed using the 34 | [AppEngine Identity API](https://cloud.google.com/appengine/docs/standard/go111/appidentity#asserting_identity_to_third-party_services), 35 | and the CLI asserts that seeds come from the expected App Engine instance. 36 | 37 | ### /sign 38 | 39 | Sign is available for use with your OS installer. It fulfills requests for a 40 | [signed url](https://cloud.google.com/storage/docs/access-control/signed-urls) 41 | representing a resource in a google cloud bucket, typically present in the same 42 | cloud project as Fresnel App Engine. See below for additional information on how 43 | to use the cloud bucket. 44 | 45 | If you wish to allow an OS Installer to retrieve a file using the /sign endpoint 46 | do the following: 47 | 48 | 1. Retrieve a seed beforehand using the /seed endpoint. 49 | 1. Co-locate the seed with your installation image. 50 | 1. When your installer requires a resource from the cloud bucket, send a JSON 51 | encoded request to the /sign endpoint that includes your seed. 52 | 1. The /sign endpoint will respond with a signed-url that you can use to 53 | retrieve the resource. 54 | 1. Continue provisioning. 55 | 56 | This process can be repeated for as many files/resources as are required by your 57 | installer. 58 | 59 | ### /sign request format 60 | 61 | Installers making requests for signed-url's should submit those requests to the 62 | /sign endpoint using the following structure. See the models package in this 63 | repository for additional information. 64 | 65 | ``` 66 | type SignRequest struct { 67 | Seed Seed 68 | Signature []byte 69 | Mac []string 70 | Path string 71 | Hash []byte 72 | } 73 | ``` 74 | 75 | ## app.yaml 76 | 77 | Your application should be deployed using an app.yaml configured for your 78 | project. See the [examples folder](examples/default.yaml) for a starter 79 | configuration. 80 | 81 | ### Env Variables 82 | 83 | These are required in order to configure your App Engine instance for your 84 | project and use case. They are declared in your app.yaml. See the 85 | [example](examples/default.yaml) for more information. 86 | 87 | * BUCKET [string]: The identifier for the cloud bucket you want to use with 88 | the /sign endpoint. 89 | * SIGNED_URL_DURATION [string]: Signed URLs provided by /sign will expire 90 | after this duration. 91 | * SEED_VALIDITY_DURATION [string]: Seeds will be considered stale and not be 92 | accepted by the /sign endpoint after this duration. 93 | * VERIFY_SEED [string]: 'true' or 'false' determines if seeds are checked when 94 | making requests to /sign. 95 | * VERIFY_SEED_SIGNATURE [string]: 'true' or 'false' determines if seed 96 | signatures are checked when making requests to /sign. 97 | * VERIFY_SEED_SIGNATURE_FALLBACK [string]: 'true' or 'false' if /sign requests 98 | provide their own certificate chain, allow these to be used to validate the 99 | identity of signer. 100 | * VERIFY_SEED_HASH [string]: 'true' or 'false' when making a request to /seed, 101 | the hash is checked against pe_allowlist.yaml to see if it is permitted. 102 | * VERIFY_SIGN_HASH [string]: 'true' or 'false' a seed hash is verified 103 | cryptographically on requests to /sign and pe_allowlist.yaml is checked for 104 | the presence of that hash. 105 | 106 | ## Allowlist 107 | 108 | The pe_allowlist.yaml file lists the hashes that may be signed when requesting 109 | seeds. It is also checked when requests are made to /sign to determine if the 110 | seed making the request was generated using an acceptable hash. 111 | 112 | pe_allowlist.yaml must be stored in your cloud bucket in the a folder named 113 | 'appengine_config'. 114 | 115 | See [pe_allowlist.yaml](examples/pe_allowlist.yaml) in the examples folder for 116 | more information. 117 | 118 | ## Cloud Bucket 119 | 120 | Configure a cloud bucket where your pe_allowlist and any resources you wish to 121 | use with the /sign endpoint can be stored. Your cloud bucket must allow at least 122 | read access for the project where you are running your App Engine project. 123 | -------------------------------------------------------------------------------- /cli/commands/list/list.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package list defines the list subcommand to display the devices available 16 | // that qualify for provisioning with an installer. 17 | package list 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | "path/filepath" 24 | "strings" 25 | 26 | "flag" 27 | "github.com/google/fresnel/cli/console" 28 | "github.com/google/deck" 29 | "github.com/google/subcommands" 30 | "github.com/google/winops/storage" 31 | ) 32 | 33 | var ( 34 | // The name of this binary, set in init. 35 | binaryName = "" 36 | // Dependency injections for testing. 37 | search = storage.Search 38 | ) 39 | 40 | func init() { 41 | binaryName = filepath.Base(strings.ReplaceAll(os.Args[0], `.exe`, ``)) 42 | subcommands.Register(&listCmd{}, "") 43 | } 44 | 45 | // listCmd represents the list subcommand. 46 | type listCmd struct { 47 | // listFixed determines whether we want to consider fixed drives when listing 48 | // available devices. It is defaulted to false by flag. 49 | listFixed bool 50 | 51 | // minSize is the minimum size device to search for in GB. For convenience, 52 | // this value is defaulted to to a reasonable minimum size by flag. 53 | minSize int 54 | 55 | // maxSize is the largest size device to search for in GB. For convenience, 56 | // this value is set to 'no limit (0)' by default by flag. 57 | maxSize int 58 | 59 | // json silences any unnecessary text output and returns the device list in JSON. 60 | // This value is defaulted to false by flag. 61 | json bool 62 | } 63 | 64 | var oneGB = 1073741824 65 | 66 | // Ensure listCommand implements the subcommands.Command interface. 67 | var _ subcommands.Command = (*listCmd)(nil) 68 | 69 | // Name returns the name of the subcommand. 70 | func (*listCmd) Name() string { 71 | return "list" 72 | } 73 | 74 | // Synopsis returns a short string (less than one line) describing the subcommand. 75 | func (*listCmd) Synopsis() string { 76 | return "list available devices suitable for provisioning with an installer" 77 | } 78 | 79 | // Usage returns a long string explaining the subcommand and its usage. 80 | func (*listCmd) Usage() string { 81 | return fmt.Sprintf(`list [flags...] 82 | 83 | List available devices suitable for provisioning with an installer. 84 | 85 | Flags: 86 | --show_fixed - Includes fixed disks when searching for suitable devices. 87 | --minimum [int] - The minimum size in GB to consider when searching. 88 | --maximum [int] - The maximum size in GB to consider when searching. 89 | 90 | Example #1: Perform a standard search with defaults (removable media only > 2GB) 91 | '%s list' 92 | 93 | Example #2: Limit search to larger devices. 94 | '%s list --minimum=8' 95 | 96 | Example #3: Search fixed devices and removable devices. 97 | '%s list --show_fixed' 98 | 99 | Example output: 100 | 101 | DEVICE | MODEL | SIZE | INSTALLER PRESENT 102 | -------+---------+-------+-------------------- 103 | disk1 | Unknown | 16 GB | Not Present 104 | disk3 | Cruzer | 64 GB | Present 105 | 106 | Defaults: 107 | `, binaryName, binaryName, binaryName) 108 | } 109 | 110 | // SetFlags adds the flags for this command to the specified set. 111 | func (c *listCmd) SetFlags(f *flag.FlagSet) { 112 | f.BoolVar(&c.listFixed, "show_fixed", false, "Also display fixed drives.") 113 | f.IntVar(&c.minSize, "minimum", 2, "The minimum size [in GB] of drives to search for.") 114 | f.IntVar(&c.maxSize, "maximum", 0, "The maximum size [in GB] drives to search for.") 115 | f.BoolVar(&c.json, "json", false, "Display the device list in JSON with no additional output") 116 | } 117 | 118 | // Execute runs the command and returns an ExitStatus. 119 | func (c *listCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 120 | // Scan for the available drives. Warn that this may take a while. 121 | if c.json { 122 | // Turning on verbose will silence console output 123 | console.Verbose = true 124 | } 125 | 126 | console.Print("Searching for devices. This may take up to one minute...\n") 127 | deck.InfoA("Searching for devices.").With(deck.V(1)).Go() 128 | devices, err := search("", uint64(c.minSize*oneGB), uint64(c.maxSize*oneGB), !c.listFixed) 129 | if err != nil { 130 | deck.Errorf("storage.Search(%d, %d, %t) returned %v", c.minSize, c.maxSize, !c.listFixed, err) 131 | return subcommands.ExitFailure 132 | } 133 | // Wrap devices in an []console.TargetDevice. 134 | available := []console.TargetDevice{} 135 | for _, d := range devices { 136 | available = append(available, d) 137 | } 138 | 139 | console.PrintDevices(available, os.Stdout, c.json) 140 | 141 | // Provide contextual help for next steps. 142 | console.Printf(` 143 | Use the 'windows' subcommand to provision an installer on one, several, or all flash drives. 144 | Example #1 (Windows): Use '%s windows 1 2' to write a windows installer image to disk 1 and 2. 145 | Example #2 (Linux): Use '%s windows --all' to write a windows installer image to all suitable drives. 146 | 147 | For additional examples, see the help for the windows subcommand, '%s help windows'.`, binaryName, binaryName, binaryName) 148 | 149 | return subcommands.ExitSuccess 150 | } 151 | -------------------------------------------------------------------------------- /cli/console/console.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package console provides simple utilities to print human-readable messages 16 | // to the console. For specific message types, additional verbosity is 17 | // available through Verbose. 18 | package console 19 | 20 | import ( 21 | "bufio" 22 | "encoding/json" 23 | "errors" 24 | "fmt" 25 | "io" 26 | "os" 27 | "strings" 28 | "time" 29 | 30 | "github.com/docker/go-units" 31 | "github.com/dustin/go-humanize" 32 | "github.com/olekukonko/tablewriter" 33 | ) 34 | 35 | var ( 36 | // Verbose is used to control whether or not print messages are printed. 37 | // It is exposed as package state to allow the verbosity to be uniformly 38 | // controlled across packages that use it. 39 | Verbose = false 40 | ) 41 | 42 | // Print displays a console message when Verbose is false. Arguments 43 | // are handled in the same manner as fmt.Print. 44 | func Print(v ...interface{}) { 45 | if !Verbose { 46 | fmt.Print(v...) 47 | } 48 | } 49 | 50 | // Printf displays a console message when Verbose is false. Arguments 51 | // are handled in the same manner as fmt.Printf. 52 | func Printf(format string, v ...interface{}) { 53 | if !Verbose { 54 | fmt.Printf(format+"\n", v...) 55 | } 56 | } 57 | 58 | // PromptUser displays a warning that the actions to be performed are 59 | // destructive. It returns an error if the user does not respond with a 'y'. 60 | // It is always printed, regardless of the value of Verbose. 61 | func PromptUser() error { 62 | msg := "\nIMPORTANT: Proceeding will DESTROY the contents of a device!\n\n" + 63 | "Do you want to erase and re-initialize the devices listed? (y/N)? " 64 | fmt.Print(msg) 65 | 66 | reader := bufio.NewReader(os.Stdin) 67 | r, err := reader.ReadString('\n') 68 | if err != nil { 69 | return fmt.Errorf("reader.ReadString('\n') returned: %v", err) 70 | } 71 | r = strings.Trim(r, "\r\n") 72 | if !strings.EqualFold(r, "y") { 73 | return errors.New("canceled media initialization") 74 | } 75 | return nil 76 | } 77 | 78 | // TargetDevice represents target.Device. 79 | type TargetDevice interface { 80 | Identifier() string 81 | FriendlyName() string 82 | Size() uint64 83 | } 84 | 85 | type rawDevice struct { 86 | ID string 87 | Name string 88 | Size string 89 | } 90 | 91 | // PrintDevices takes a slice of target devices and prints relevant information 92 | // as a human-readable table to the console. If the json flag 93 | // is present the target devices will be printed as JSON rather than a table. 94 | func PrintDevices(targets []TargetDevice, w io.Writer, json bool) { 95 | 96 | if json { 97 | Printjson(targets, w) 98 | // Return immediately after raw output to ensure the output is proper JSON only. 99 | return 100 | } 101 | 102 | //Check if any devices exist. 103 | if len(targets) == 0 { 104 | fmt.Fprintf(w, "No matching devices were found.") 105 | return 106 | } 107 | 108 | // Display the table to the user otherwise, output devices with table 109 | table := tablewriter.NewWriter(w) 110 | table.SetBorder(false) 111 | table.SetAutoWrapText(false) 112 | table.SetHeader([]string{"Device", "Model", "Size"}) 113 | table.SetHeaderColor( 114 | tablewriter.Colors{tablewriter.FgGreenColor}, // Green text for device column. 115 | tablewriter.Colors{}, // No color change for model column. 116 | tablewriter.Colors{}, // No color change for size column. 117 | ) 118 | for _, device := range targets { 119 | table.Append([]string{ 120 | device.Identifier(), 121 | device.FriendlyName(), 122 | humanize.Bytes(device.Size()), 123 | }, 124 | ) 125 | } 126 | table.Render() 127 | } 128 | 129 | // Printjson takes a slice of target devices and prints relevant information 130 | // as JSON to the console when the json flag is present on the PrintDevices 131 | // function. 132 | func Printjson(targets []TargetDevice, w io.Writer) error { 133 | 134 | result := []rawDevice{} 135 | for _, device := range targets { 136 | result = append(result, rawDevice{ 137 | ID: device.Identifier(), 138 | Name: device.FriendlyName(), 139 | Size: humanize.Bytes(device.Size()), 140 | }) 141 | } 142 | 143 | output, err := json.Marshal(result) 144 | if err != nil { 145 | return err 146 | } 147 | fmt.Fprintf(w, "%s", output) 148 | return nil 149 | } 150 | 151 | type progressReader struct { 152 | reader io.Reader 153 | operation string 154 | 155 | // Total length of data and counter for what has been read. 156 | length int64 157 | read int64 158 | 159 | // Counter for progress bar and how frequently to update the bar in msec. 160 | bars int64 161 | freq int64 162 | 163 | start time.Time 164 | lastLog time.Time 165 | } 166 | 167 | // ProgressReader wraps an io.Reader and writes the read progress to the 168 | // console. The writes are displayed on call of the Read method and at most 169 | // every 5 seconds. The messages include the supplied human readable operation. 170 | // The provided length can also be zero if it is unknown ahead of time. A 171 | // ProgressReader always outputs to the console, regardless of the value of 172 | // verbose. 173 | func ProgressReader(reader io.Reader, operation string, length int64) io.Reader { 174 | now := time.Now() 175 | if length < 0 { 176 | length = 0 177 | } 178 | pr := progressReader{ 179 | reader: reader, 180 | operation: operation, 181 | length: length, 182 | read: 0, 183 | bars: 0, 184 | freq: 300, // The bar is updated every 300 msec. 185 | start: now, 186 | lastLog: now, 187 | } 188 | return &pr 189 | } 190 | 191 | func (pr *progressReader) Read(p []byte) (int, error) { 192 | n, err := pr.reader.Read(p) 193 | if err != nil { 194 | return n, err 195 | } 196 | 197 | pr.read += int64(n) 198 | now := time.Now() 199 | diff := now.Sub(pr.lastLog) 200 | if diff.Milliseconds() < pr.freq { 201 | return n, nil 202 | } 203 | 204 | // Prepare to log progress. 205 | pr.lastLog = now 206 | length := float64(pr.length) // in bytes. 207 | read := float64(pr.read) // in bytes. 208 | 209 | // Determine read speed. 210 | diff = now.Sub(pr.start) 211 | since := diff.Seconds() 212 | var speed float64 // in bytes/s. 213 | if since != 0 { 214 | speed = read / since 215 | } 216 | 217 | // Log progress. 218 | speeds := units.BytesSize(speed) + "/s" 219 | if pr.length >= 0 { 220 | // Determine remaining bytes and time until finished. 221 | remain := length - read // Remaining bytes to read. 222 | if remain < 0 { 223 | remain = 0 // This shouldn't ever happen. 224 | } 225 | var until float64 // Seconds until finished. 226 | if speed != 0 { 227 | until = remain / speed 228 | } 229 | lengths := units.BytesSize(length) 230 | // Print the speed and estimated time remaining just once, above 231 | // the progress bar. 232 | if diff.Milliseconds() <= pr.freq+(pr.freq/3) { 233 | fmt.Printf("%s started: %s, %0.2f seconds remaining\n", pr.operation, speeds, until) 234 | fmt.Printf("Size: [--------------------------------------------------] %s\n", lengths) 235 | fmt.Print("Progress: ") 236 | } 237 | // Calculate the progress and update the progress bar. 238 | progress := int64(read / length * 100 / 2) 239 | for pr.bars <= progress { 240 | fmt.Print("=") 241 | pr.bars++ 242 | } 243 | } 244 | 245 | return n, nil 246 | } 247 | -------------------------------------------------------------------------------- /appengine/endpoints/seed.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package endpoints 16 | 17 | import ( 18 | "context" 19 | "crypto/sha256" 20 | "encoding/binary" 21 | "encoding/hex" 22 | "encoding/json" 23 | "errors" 24 | "fmt" 25 | "io/ioutil" 26 | "net/http" 27 | "os" 28 | "strings" 29 | "time" 30 | 31 | "github.com/google/fresnel/models" 32 | "google.golang.org/appengine" 33 | "google.golang.org/appengine/log" 34 | "google.golang.org/appengine/user" 35 | ) 36 | 37 | var ( 38 | signSeed = signSeedResponse 39 | supportedHash = map[int]bool{ 40 | sha256.Size: true, 41 | } 42 | ) 43 | 44 | // SeedRequestHandler implements http.Handler for signed URL requests. 45 | type SeedRequestHandler struct{} 46 | 47 | func (SeedRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 48 | ctx := appengine.NewContext(r) 49 | w.Header().Set("Content-Type", "application/json") 50 | 51 | // Seed to be used during error conditions 52 | errSeedResp := `{"Status":"%s","ErrorCode":%d}` 53 | 54 | sr, err := unmarshalSeedRequest(r) 55 | if err != nil { 56 | log.Errorf(ctx, "unmarshalSeedRequest(): %v", err) 57 | http.Error(w, fmt.Sprintf(errSeedResp, err, models.StatusJSONError), http.StatusInternalServerError) 58 | return 59 | } 60 | 61 | u := user.Current(ctx) 62 | if u == nil { 63 | log.Errorf(ctx, "seed requested without user information in context: #%s", ctx) 64 | http.Error(w, fmt.Sprintf(errSeedResp, "no user", models.StatusInvalidUser), http.StatusInternalServerError) 65 | return 66 | } 67 | 68 | hashCheck := os.Getenv("VERIFY_SEED_HASH") 69 | if hashCheck != "true" { 70 | log.Infof(ctx, "VERIFY_SEED_HASH is not set to true, hash validation will be logged but not enforced") 71 | } 72 | acceptedHashes, err := populateAllowlist(ctx) 73 | if err != nil { 74 | log.Errorf(ctx, "failed to populate hash allowlist: %v", err) 75 | if hashCheck == "true" { 76 | http.Error(w, fmt.Sprintf(errSeedResp, err, models.StatusSeedError), http.StatusInternalServerError) 77 | return 78 | } 79 | } 80 | 81 | if err := validateSeedRequest(u, sr, acceptedHashes); err != nil { 82 | log.Errorf(ctx, "validateSeedRequest(%s,%#v,%#v): %v", u.String(), sr, acceptedHashes, err) 83 | if !strings.Contains(err.Error(), "not in allowlist") || hashCheck == "true" { 84 | http.Error(w, fmt.Sprintf(errSeedResp, err, models.StatusReqUnreadable), http.StatusInternalServerError) 85 | return 86 | } 87 | } 88 | log.Infof(ctx, "validated seed request from %s with hash %x", u.String(), sr.Hash) 89 | 90 | s := generateSeed(sr.Hash, u) 91 | log.Infof(ctx, "successfully generated Seed: %#v", s) 92 | 93 | resp, err := signSeed(ctx, s) 94 | if err != nil { 95 | log.Errorf(ctx, "signSeed(): %v", err) 96 | http.Error(w, fmt.Sprintf(errSeedResp, err, models.StatusSignError), http.StatusInternalServerError) 97 | return 98 | } 99 | log.Infof(ctx, "successfully signed seed: %+v", resp.Seed) 100 | 101 | jsonResponse, err := json.Marshal(resp) 102 | if err != nil { 103 | es := fmt.Sprintf("json.Marshall(%v): %v", resp, err) 104 | log.Errorf(ctx, es) 105 | http.Error(w, fmt.Sprintf(errSeedResp, err, models.StatusJSONError), http.StatusInternalServerError) 106 | return 107 | } 108 | 109 | if _, err = w.Write(jsonResponse); err != nil { 110 | log.Errorf(ctx, fmt.Sprintf("failed to write response to client: %s", err)) 111 | return 112 | } 113 | 114 | if resp.ErrorCode == models.StatusSuccess { 115 | log.Infof(ctx, "successfully processed SeedRequest with response: %+v", resp) 116 | } 117 | } 118 | 119 | // generateSeed generates an object that contains the response to the media generation tool 120 | // client request for a seed. 121 | func generateSeed(hash []byte, u *user.User) models.Seed { 122 | return models.Seed{ 123 | Issued: time.Now(), 124 | Username: u.String(), 125 | Hash: hash, 126 | } 127 | 128 | } 129 | 130 | // unmarshalSeedRequest parses a JSON object passed in an http request in to a models.SeedRequest object. 131 | func unmarshalSeedRequest(r *http.Request) (models.SeedRequest, error) { 132 | var seedRequest models.SeedRequest 133 | 134 | body, err := ioutil.ReadAll(r.Body) 135 | if err != nil { 136 | return models.SeedRequest{}, 137 | fmt.Errorf("error reading request body: %v", err) 138 | } 139 | 140 | if len(body) == 0 { 141 | return models.SeedRequest{}, 142 | fmt.Errorf("received empty seed request") 143 | } 144 | 145 | if err := json.Unmarshal(body, &seedRequest); err != nil { 146 | return models.SeedRequest{}, 147 | fmt.Errorf("unable to unmarshal JSON request: %v", err) 148 | } 149 | 150 | return seedRequest, 151 | nil 152 | } 153 | 154 | // validateSeedRequest ensures seed request is populated with a valid hash. 155 | func validateSeedRequest(u *user.User, sr models.SeedRequest, ah map[string]bool) error { 156 | if len(u.String()) < 1 { 157 | return fmt.Errorf("no username detected: %s", u.String()) 158 | } 159 | 160 | h := hex.EncodeToString(sr.Hash) 161 | if _, ok := ah[h]; ok { 162 | return nil 163 | } 164 | 165 | return fmt.Errorf("request hash %v not in allowlist: %#v", hex.EncodeToString(sr.Hash), ah) 166 | } 167 | 168 | // signSeed will generate a seed response from a valid seed. 169 | func signSeedResponse(ctx context.Context, s models.Seed) (models.SeedResponse, error) { 170 | certs, err := appengine.PublicCertificates(ctx) 171 | if err != nil { 172 | return models.SeedResponse{}, fmt.Errorf("appengine.PublicCertificates(): %v", err) 173 | } 174 | s.Certs = certs 175 | // Limit the maximum number of public certificates to 4 prior to the signing request because of 176 | // GAE 8kb blob limit: b/304275368 177 | if len(certs) >= 4 { 178 | s.Certs = certs[:4] 179 | } 180 | 181 | log.Infof(ctx, "certs: %v", len(certs)) 182 | 183 | jsonSeed, err := json.Marshal(s) 184 | if err != nil { 185 | return models.SeedResponse{}, 186 | fmt.Errorf("failed to marshal seed before signing: %v", err) 187 | } 188 | 189 | log.Infof(ctx, "marshalled with a total byte size of: %v", binary.Size(jsonSeed)) 190 | 191 | _, sig, err := appengine.SignBytes(ctx, jsonSeed) 192 | if err != nil { 193 | return models.SeedResponse{}, 194 | fmt.Errorf("sign failed: %v", err) 195 | } 196 | 197 | // nil out hash so it's not sent to the client, the client will regenerate hash and send with sign requests. 198 | s.Hash = nil 199 | 200 | return models.SeedResponse{ 201 | Status: "success", 202 | ErrorCode: models.StatusSuccess, 203 | Seed: s, 204 | Signature: sig, 205 | }, 206 | nil 207 | } 208 | 209 | // populateAllowlist will return a map of hashes allowed to request a seed or signed url. 210 | func populateAllowlist(ctx context.Context) (map[string]bool, error) { 211 | b := os.Getenv("BUCKET") 212 | if b == "" { 213 | return nil, errors.New("BUCKET environment variable not set") 214 | } 215 | 216 | var err error 217 | ih, found := c.Get("acceptedHashes") 218 | if !found { 219 | ih, err = getAllowlist(ctx, b, "appengine_config/pe_allowlist.yaml") 220 | if err != nil { 221 | return nil, fmt.Errorf("retrieving allowlist returned error: %v", err) 222 | } 223 | c.Set("acceptedHashes", ih, time.Duration(5*time.Minute)) 224 | } 225 | 226 | ah, ok := ih.(map[string]bool) 227 | if !ok { 228 | return nil, fmt.Errorf("could not convert allowlist to map: %#v", ih) 229 | } 230 | return ah, nil 231 | } 232 | -------------------------------------------------------------------------------- /appengine/endpoints/seed_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package endpoints 16 | 17 | import ( 18 | "bytes" 19 | "context" 20 | "encoding/hex" 21 | "encoding/json" 22 | "fmt" 23 | "io" 24 | "net/http" 25 | "net/http/httptest" 26 | "strings" 27 | "testing" 28 | 29 | "github.com/google/fresnel/models" 30 | "google.golang.org/appengine/aetest" 31 | "google.golang.org/appengine/user" 32 | ) 33 | 34 | const ( 35 | testHash = "0123456789abcdeffedcba9876543210" 36 | ) 37 | 38 | func TestValidateSeedRequestSuccess(t *testing.T) { 39 | testGood := []struct { 40 | desc string 41 | u user.User 42 | req models.SeedRequest 43 | }{ 44 | { 45 | "valid request", 46 | user.User{Email: "test@googleplex.com"}, 47 | models.SeedRequest{Hash: []byte("00000000000000000000000000000000")}, 48 | }, 49 | } 50 | for _, tt := range testGood { 51 | ah := make(map[string]bool) 52 | ah[hex.EncodeToString(tt.req.Hash)] = true 53 | err := validateSeedRequest(&tt.u, tt.req, ah) 54 | if err != nil { 55 | t.Errorf("%s: validateSeedRequest returned: %s; expected nil", tt.desc, err) 56 | } 57 | } 58 | } 59 | 60 | func TestValidateSeedRequestFailure(t *testing.T) { 61 | testBad := []struct { 62 | desc string 63 | u user.User 64 | req models.SeedRequest 65 | err string 66 | }{ 67 | { 68 | "null request", 69 | user.User{}, 70 | models.SeedRequest{Hash: []byte(nil)}, 71 | "no username detected", 72 | }, 73 | { 74 | "invalid hash", 75 | user.User{Email: "test@googleplex.com"}, 76 | models.SeedRequest{Hash: []byte("0000000000000000000000000000000000")}, 77 | "not in allowlist", 78 | }, 79 | { 80 | "no user", 81 | user.User{}, 82 | models.SeedRequest{Hash: []byte("00000000000000000000000000000000")}, 83 | "no username detected", 84 | }, 85 | } 86 | ah := make(map[string]bool) 87 | ah[hex.EncodeToString([]byte("00000000000000000000000000000000"))] = true 88 | for _, tt := range testBad { 89 | err := validateSeedRequest(&tt.u, tt.req, ah) 90 | if err == nil { 91 | t.Errorf("testing %s: validateSeedRequest returned nil expected err", tt.desc) 92 | } 93 | if !strings.Contains(err.Error(), tt.err) { 94 | t.Errorf("testing %s: expected string '%s' was not found in returned error '%s'", tt.desc, tt.err, err) 95 | } 96 | } 97 | } 98 | 99 | func TestUnmarshalSeedRequestSuccess(t *testing.T) { 100 | testGood := []struct { 101 | desc string 102 | body io.Reader 103 | }{ 104 | { 105 | "valid request", 106 | bytes.NewReader([]byte(fmt.Sprintf(`{"Hash":"%s"}`, testHash))), 107 | }, 108 | } 109 | 110 | for _, tt := range testGood { 111 | req := httptest.NewRequest(http.MethodPost, "/seed", tt.body) 112 | sr, err := unmarshalSeedRequest(req) 113 | if err != nil { 114 | t.Errorf("%s for unmarshalSeedRequest resulted in err where none expected: %s", tt.desc, err) 115 | } 116 | if bytes.Equal(sr.Hash, []byte(testHash)) { 117 | t.Errorf("%s failed to produce expected seed request from unmarshalSeedRequest\n got: %s\n want: %s", tt.desc, sr.Hash, testHash) 118 | } 119 | } 120 | } 121 | 122 | func TestUnmarshalSeedRequestFailure(t *testing.T) { 123 | testBad := []struct { 124 | desc string 125 | body io.Reader 126 | err string 127 | }{ 128 | { 129 | "null request", 130 | nil, 131 | "empty", 132 | }, 133 | { 134 | "invalid json", 135 | bytes.NewReader([]byte("this should fail")), 136 | "unable to unmarshal JSON", 137 | }, 138 | { 139 | "ioreader error", 140 | errReader(0), 141 | "error reading", 142 | }, 143 | } 144 | for _, tt := range testBad { 145 | req := httptest.NewRequest(http.MethodPost, "/seed", tt.body) 146 | _, err := unmarshalSeedRequest(req) 147 | if err == nil { 148 | t.Errorf("testing %s: unmarshalSeedRequest received %s expected error containing %s", tt.desc, err, tt.err) 149 | } else { 150 | if !strings.Contains(err.Error(), tt.err) { 151 | t.Errorf("%s: unmarshalSeedRequest got: %s want: %s", tt.desc, err, tt.err) 152 | } 153 | } 154 | } 155 | } 156 | 157 | func TestSignSeedFailure(t *testing.T) { 158 | seed := models.Seed{Username: "test@googleplex.com"} 159 | // Ensuring we don't pass an appengine context to ensure signing fails. 160 | ss, err := signSeedResponse(context.Background(), seed) 161 | if err == nil { 162 | t.Fatalf("signSeedResponse(%v) returned nil, want error.\n%v", seed, ss) 163 | } 164 | if !strings.Contains(err.Error(), "appengine.PublicCertificates") { 165 | t.Errorf(`"signSeedResponse(%v) got err: %v expected error to contain "sign"`, seed, err) 166 | } 167 | } 168 | 169 | func serveHTTPValid(t *testing.T, inst aetest.Instance) (*httptest.ResponseRecorder, *http.Request) { 170 | b, err := generateTestSeedRequest() 171 | if err != nil { 172 | t.Fatalf("failed to generate test seed request: %v", err) 173 | } 174 | 175 | rb := bytes.NewReader(b) 176 | r, err := inst.NewRequest(http.MethodPost, "/seed", rb) 177 | if err != nil { 178 | t.Fatalf("could not mock appengine request: %v", err) 179 | } 180 | 181 | u := user.User{Email: "test@googleplex.com"} 182 | w := httptest.NewRecorder() 183 | 184 | aetest.Login(&u, r) 185 | 186 | r.Header.Add("Content-Type", "application/json") 187 | 188 | return w, r 189 | } 190 | 191 | func generateTestSeedRequest() ([]byte, error) { 192 | h, err := prepTestHash() 193 | if err != nil { 194 | return []byte(nil), fmt.Errorf("could not create test hash prepTestHash returned: %v", err) 195 | } 196 | sr := models.SeedRequest{Hash: h} 197 | return json.Marshal(sr) 198 | } 199 | 200 | func generateBadTestSeedRequest() ([]byte, error) { 201 | h, err := hex.DecodeString("BEEF") 202 | if err != nil { 203 | return nil, fmt.Errorf("failed to generate bad test hash: %v", err) 204 | } 205 | sr := models.SeedRequest{Hash: h} 206 | return json.Marshal(sr) 207 | } 208 | 209 | func serveHTTPFailUnmarshal(t *testing.T, inst aetest.Instance) (*httptest.ResponseRecorder, *http.Request) { 210 | b := []byte("bad json") 211 | rb := bytes.NewReader(b) 212 | r, err := inst.NewRequest(http.MethodPost, "/seed", rb) 213 | if err != nil { 214 | t.Fatalf("could not mock appengine request: %v", err) 215 | } 216 | 217 | u := user.User{Email: "test@googleplex.com"} 218 | aetest.Login(&u, r) 219 | 220 | w := httptest.NewRecorder() 221 | r.Header.Add("Content-Type", "application/json") 222 | return w, r 223 | } 224 | 225 | func serveHTTPFailUser(t *testing.T, inst aetest.Instance) (*httptest.ResponseRecorder, *http.Request) { 226 | b, err := generateTestSeedRequest() 227 | if err != nil { 228 | t.Fatalf("failed to generate test seed request: %v", err) 229 | } 230 | 231 | rb := bytes.NewReader(b) 232 | r, err := inst.NewRequest(http.MethodPost, "/seed", rb) 233 | if err != nil { 234 | t.Fatalf("could not mock appengine request: %v", err) 235 | } 236 | 237 | aetest.Logout(r) 238 | 239 | w := httptest.NewRecorder() 240 | r.Header.Add("Content-Type", "application/json") 241 | return w, r 242 | } 243 | 244 | func serveHTTPFailValidate(t *testing.T, inst aetest.Instance) (*httptest.ResponseRecorder, *http.Request) { 245 | b, err := generateBadTestSeedRequest() 246 | if err != nil { 247 | t.Fatalf("failed to generate test seed request: %v", err) 248 | } 249 | 250 | rb := bytes.NewReader(b) 251 | r, err := inst.NewRequest(http.MethodPost, "/seed", rb) 252 | if err != nil { 253 | t.Fatalf("could not mock appengine request: %v", err) 254 | } 255 | 256 | u := user.User{Email: "test@googleplex.com"} 257 | aetest.Login(&u, r) 258 | 259 | w := httptest.NewRecorder() 260 | r.Header.Add("Content-Type", "application/json") 261 | return w, r 262 | } 263 | 264 | func serveHTTPFailSign(t *testing.T, inst aetest.Instance) (*httptest.ResponseRecorder, *http.Request) { 265 | // replace signSeed with a function guaranteed to return an error for this test. 266 | signSeed = func(context.Context, models.Seed) (models.SeedResponse, error) { 267 | return models.SeedResponse{}, fmt.Errorf("test failure") 268 | } 269 | 270 | return serveHTTPValid(t, inst) 271 | } 272 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /cli/config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package config parses flags and returns a configuration for imaging a usb 16 | package config 17 | 18 | import ( 19 | "errors" 20 | "fmt" 21 | "os/user" 22 | "path/filepath" 23 | "regexp" 24 | "strings" 25 | ) 26 | 27 | var ( 28 | // Dependency injections for testing. 29 | currentUser = user.Current 30 | 31 | // Wrapped errors for testing. 32 | errDistro = errors.New(`distribution selection error`) 33 | errDevice = errors.New(`device error`) 34 | errElevation = errors.New(`elevation detection error: are you running the application as admin (Windows) or sudo (Mac/Linux)?`) 35 | errInput = errors.New("invalid or missing input") 36 | errSeed = errors.New("seed error") 37 | errTrack = errors.New("track error") 38 | 39 | // Exported errors 40 | 41 | // ErrWritePerms indicates a problem obtaining write permissions for removable media 42 | ErrWritePerms = errors.New("removable media write prevented by policy") 43 | 44 | // Regex Matching 45 | regExDevicePath = regexp.MustCompile(`^[a-zA-Z0-9/]`) 46 | regExDeviceID = regexp.MustCompile(`^[a-zA-Z0-9]+$`) 47 | regExFQDN = regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.){2,}([A-Za-z0-9/]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]){2,}$`) 48 | regExFileName = regexp.MustCompile(`[\w,\s-]+\.[A-Za-z.]+`) 49 | ) 50 | 51 | // OperatingSystem is used to indicate the OS of the media to be generated. 52 | type OperatingSystem string 53 | 54 | const ( 55 | // windows declares that the OS will be Windows. 56 | windows OperatingSystem = "windows" 57 | // linux declares that the OS will be Linux. 58 | linux OperatingSystem = "linux" 59 | ) 60 | 61 | // distribution defines a target operating system and the configuration 62 | // required to obtain the resources required to install it. 63 | type distribution struct { 64 | os OperatingSystem 65 | confFile string // The final name of the config file. 66 | confServer string // The FFU configs are obtained here. 67 | imageServer string // The base image is obtained here. 68 | label string // If set, is used to set partition labels. 69 | name string // Friendly name: e.g. Corp Windows. 70 | seedDest string // The relative path where the seed should be written. 71 | seedFile string // This file is hashed when obtainng a seed. 72 | seedServer string // If set, a seed is obtained from here. 73 | images map[string]string 74 | configs map[string]string // Contains config file names. 75 | } 76 | 77 | // Configuration represents the state of all flags and selections provided 78 | // by the user when the binary is invoked. 79 | type Configuration struct { 80 | cleanup bool 81 | devices []string 82 | distro *distribution 83 | dismount bool 84 | ffu bool 85 | update bool 86 | eject bool 87 | elevated bool // If the user is running as root. 88 | track string 89 | confTrack string 90 | warning bool 91 | } 92 | 93 | // New generates a new configuration from flags passed on the command line. 94 | // It performs sanity checks on those parameters. 95 | func New(cleanup, warning, eject, ffu, update bool, devices []string, os, track, confTrack, seedServer string) (*Configuration, error) { 96 | // Create a partial config using known good values. 97 | conf := &Configuration{ 98 | cleanup: cleanup, 99 | warning: warning, 100 | ffu: ffu, 101 | eject: eject, 102 | update: update, 103 | } 104 | if len(devices) > 0 { 105 | if err := conf.addDeviceList(devices); err != nil { 106 | return nil, fmt.Errorf("addDeviceList(%q) returned %v", devices, err) 107 | } 108 | } 109 | // Sanity check the chosen distribution and add it to the config. 110 | if err := conf.addDistro(os); err != nil { 111 | return nil, fmt.Errorf("addDistro(%q) returned %v", os, err) 112 | } 113 | var err error 114 | // Sanity check the image and configuration tracks and add them to the config. 115 | if conf.track, err = validateTrack(track, conf.distro.images); err != nil { 116 | return nil, err 117 | } 118 | if ffu { 119 | if conf.confTrack, err = validateTrack(confTrack, conf.distro.configs); err != nil { 120 | return nil, err 121 | } 122 | } 123 | // Sanity check the seed server and override if instructed to do so by flag. 124 | if err := conf.addSeedServer(seedServer); err != nil { 125 | return nil, err 126 | } 127 | // Determine if the user is running with elevated permissions. 128 | elevated, err := isElevated() 129 | if err != nil { 130 | return nil, fmt.Errorf("%w: isElevated() returned %v", errElevation, err) 131 | } 132 | conf.elevated = elevated 133 | 134 | return conf, nil 135 | } 136 | 137 | func (c *Configuration) addDistro(choice string) error { 138 | distro, ok := distributions[choice] 139 | if !ok { 140 | var opts []string 141 | for o := range distributions { 142 | opts = append(opts, o) 143 | } 144 | return fmt.Errorf("%w: image %q is not in %v", errDistro, choice, opts) 145 | } 146 | // If a seed server is configured, it must be accompanied by a seedFile. 147 | if distro.seedServer != "" && distro.seedFile == "" { 148 | return fmt.Errorf("%w: seedServer(%q) specified without a seedFile(%q)", errInput, distro.seedServer, distro.seedFile) 149 | } 150 | // If a seedFile is configured, a destination for the seed must be specified. 151 | // A seed is always stored as 'seed.json' in the location specified by 152 | // seedDest. 153 | if distro.seedFile != "" && distro.seedDest == "" { 154 | return fmt.Errorf("%w: seedFile(%q) specified without a destination(%q)", errSeed, distro.seedFile, distro.seedDest) 155 | } 156 | 157 | // The chosen distro is known, set it and return successfully. 158 | c.distro = &distro 159 | return nil 160 | } 161 | 162 | // addDeviceList sanity checks the provided devices and adds them to the 163 | // configuration or returns an error. 164 | func (c *Configuration) addDeviceList(devices []string) error { 165 | if len(devices) < 1 { 166 | return fmt.Errorf("%w: no devices were specified", errInput) 167 | } 168 | // Check that the device IDs appear valid. Throw an error if a partition 169 | // or drive letter was specified. 170 | for _, d := range devices { 171 | if !regExDeviceID.Match([]byte(d)) { 172 | return fmt.Errorf("%w: device(%q) must be a device ID (sda[#]), number(1-9) or disk identifier(disk[#])", errDevice, d) 173 | } 174 | } 175 | // Set devices in config. 176 | c.devices = devices 177 | return nil 178 | } 179 | 180 | func (c *Configuration) addSeedServer(fqdn string) error { 181 | // If no fqdn was provided, the existing default stands and we simply return. 182 | if fqdn == "" { 183 | return nil 184 | } 185 | // Check that the fqdn is correctly formatted. 186 | if !regExFQDN.Match([]byte(fqdn)) { 187 | return fmt.Errorf("%w: %q is not a valid FQDN", errSeed, fqdn) 188 | } 189 | if !strings.HasPrefix(fqdn, "http") { 190 | fqdn = `https://` + fqdn 191 | } 192 | // Override the default seed server if one was provided by flag. 193 | if fqdn != "" { 194 | c.distro.seedServer = fqdn 195 | } 196 | return nil 197 | } 198 | 199 | func validateTrack(track string, distro map[string]string) (string, error) { 200 | // Check that a default is available in the distro. 201 | if _, ok := distro["default"]; !ok { 202 | return "", fmt.Errorf("%w: a default track is not available", errInput) 203 | } 204 | // If no track was provided, the existing default is used. 205 | if track == "" { 206 | return "default", nil 207 | } 208 | // Sanity check the specified track against the available 209 | // options for the distro. 210 | if _, ok := distro[track]; !ok { 211 | var opts []string 212 | for o := range distro { 213 | opts = append(opts, o) 214 | } 215 | return "", fmt.Errorf("%w: invalid track requested: %q is not in %v", errTrack, track, opts) 216 | } 217 | // Return the chosen, sanity checked track. 218 | return track, nil 219 | } 220 | 221 | // Distro returns the name of the selected distribution, or blank 222 | // if none has been selected. 223 | func (c *Configuration) Distro() string { 224 | return c.distro.name 225 | } 226 | 227 | // DistroLabel returns the label that should be used for media provisioned with the 228 | // selected distribution. Can be empty. 229 | func (c *Configuration) DistroLabel() string { 230 | return c.distro.label 231 | } 232 | 233 | // Track returns the selected track of the installer image. This generally maps 234 | // to one of default, unstable, testing, or stable. 235 | func (c *Configuration) Track() string { 236 | return c.track 237 | } 238 | 239 | // ConfTrack returns the selected confTrack for FFU. This generally maps 240 | // to one of default, unstable, testing, or stable. 241 | func (c *Configuration) ConfTrack() string { 242 | return c.confTrack 243 | } 244 | 245 | // ImagePath returns the full path to the raw image for this configuration. 246 | func (c *Configuration) ImagePath() string { 247 | return fmt.Sprintf(`%s/%s`, c.distro.imageServer, c.distro.images[c.track]) 248 | } 249 | 250 | // ImageFile returns the filename of the raw image for this configuration. 251 | func (c *Configuration) ImageFile() string { 252 | // Return the filename only. 253 | return filepath.Base(c.distro.images[c.track]) 254 | } 255 | 256 | // Cleanup returns whether or not the cleanup of temp files was requested by 257 | // flag. 258 | func (c *Configuration) Cleanup() bool { 259 | return c.cleanup 260 | } 261 | 262 | // Devices returns the devices to be provisioned. 263 | func (c *Configuration) Devices() []string { 264 | return c.devices 265 | } 266 | 267 | // UpdateDevices updates the list of devices to be provisioned. 268 | func (c *Configuration) UpdateDevices(newDevices []string) { 269 | c.devices = newDevices 270 | } 271 | 272 | // FFU returns whether or not to place the FFU config file after provisioning. 273 | func (c *Configuration) FFU() bool { 274 | return c.ffu 275 | } 276 | 277 | // ConfFile returns the final name of the configuration file. 278 | func (c *Configuration) ConfFile() string { 279 | return c.distro.confFile 280 | } 281 | 282 | // FFUConfFile returns the name of the config file. 283 | func (c *Configuration) FFUConfFile() string { 284 | // Return the filename only. 285 | return filepath.Base(c.distro.configs[c.confTrack]) 286 | } 287 | 288 | // FFUConfPath returns the path to the config. 289 | func (c *Configuration) FFUConfPath() string { 290 | return fmt.Sprintf(`%s/%s`, c.distro.confServer, c.distro.configs[c.confTrack]) 291 | } 292 | 293 | // PowerOff returns whether or not devices should be powered off after write 294 | // operations. 295 | func (c *Configuration) PowerOff() bool { 296 | return c.eject 297 | } 298 | 299 | // UpdateOnly returns whether only an update is being requested. 300 | func (c *Configuration) UpdateOnly() bool { 301 | return c.update 302 | } 303 | 304 | // Warning returns whether or not a warning should be presented prior to 305 | // destructive operations. 306 | func (c *Configuration) Warning() bool { 307 | return c.warning 308 | } 309 | 310 | // SeedServer returns the configured seed server for the chosen distribution. 311 | func (c *Configuration) SeedServer() string { 312 | return c.distro.seedServer 313 | } 314 | 315 | // SeedFile returns the path to the file that is to be hashed when obtaining 316 | // a seed. 317 | func (c *Configuration) SeedFile() string { 318 | return c.distro.seedFile 319 | } 320 | 321 | // SeedDest returns the relative path where a seed should be written. 322 | func (c *Configuration) SeedDest() string { 323 | return c.distro.seedDest 324 | } 325 | 326 | // Elevated identifies if the user is running the binary with elevated 327 | // permissions. 328 | func (c *Configuration) Elevated() bool { 329 | return c.elevated 330 | } 331 | 332 | // String implements the fmt.Stringer interface. This allows config to be passed to 333 | // logging for a human-readable display of the selected configuration. 334 | func (c *Configuration) String() string { 335 | return fmt.Sprintf(` Configuration: 336 | ------------- 337 | Cleanup : %t 338 | Elevated : %t 339 | Update : %t 340 | Warning : %t 341 | 342 | Distribution: %q 343 | Label : %q 344 | Track : %q 345 | ImagePath : %q 346 | ImageFile : %q 347 | 348 | SeedServer : %q 349 | SeedFile : %q 350 | SeedDest : %q 351 | 352 | confTrack : %q 353 | confFile : %q 354 | FFUConfPath : %q 355 | FFUConfFile : %q 356 | 357 | Targets : %v 358 | PowerOff : %t`, 359 | c.Cleanup(), 360 | c.Elevated(), 361 | c.UpdateOnly(), 362 | c.Warning(), 363 | c.Distro(), 364 | c.DistroLabel(), 365 | c.Track(), 366 | c.ImagePath(), 367 | c.ImageFile(), 368 | c.SeedServer(), 369 | c.SeedFile(), 370 | c.SeedDest(), 371 | c.ConfTrack(), 372 | c.ConfFile(), 373 | c.FFUConfPath(), 374 | c.FFUConfFile(), 375 | c.Devices(), 376 | c.PowerOff()) 377 | } 378 | 379 | // isElevated determines if the current user is running the binary with elevated 380 | // permissions, such as 'sudo' (Linux) or 'run as administrator' (Windows). 381 | func isElevated() (bool, error) { 382 | return IsElevatedCmd() 383 | } 384 | -------------------------------------------------------------------------------- /appengine/endpoints/sign.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package endpoints provides the functions used to receive requests 16 | // and serve data via imaging. 17 | package endpoints 18 | 19 | import ( 20 | "context" 21 | "crypto" 22 | "crypto/rsa" 23 | "crypto/x509" 24 | "encoding/hex" 25 | "encoding/json" 26 | "encoding/pem" 27 | "errors" 28 | "fmt" 29 | "io" 30 | "io/ioutil" 31 | "net/http" 32 | "os" 33 | "regexp" 34 | "strings" 35 | "time" 36 | 37 | "github.com/google/fresnel/models" 38 | "google.golang.org/appengine" 39 | "google.golang.org/appengine/log" 40 | "cloud.google.com/go/storage" 41 | "github.com/patrickmn/go-cache" 42 | "gopkg.in/yaml.v2" 43 | ) 44 | 45 | var ( 46 | c = cache.New(5*time.Minute, 90*time.Minute) 47 | macRegEx = "([^0-9,a-f,A-F,:])" 48 | bucketFileFinder = bucketFileHandle 49 | ) 50 | 51 | // SignRequestHandler implements http.Handler for signed URL requests. 52 | type SignRequestHandler struct{} 53 | 54 | func (SignRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 55 | errResp := `{"Status":"%s","ErrorCode":%d}` 56 | 57 | ctx := appengine.NewContext(r) 58 | w.Header().Set("Content-Type", "application/json") 59 | 60 | resp := signResponse(ctx, r) 61 | 62 | if resp.ErrorCode != models.StatusSuccess { 63 | w.WriteHeader(http.StatusInternalServerError) 64 | } 65 | 66 | jsonResponse, err := json.Marshal(resp) 67 | if err != nil { 68 | es := fmt.Sprintf("json.Marshall(%#v): %v", resp, err) 69 | log.Errorf(ctx, es) 70 | http.Error(w, fmt.Sprintf(errResp, err, models.StatusJSONError), http.StatusInternalServerError) 71 | return 72 | } 73 | 74 | if _, err = w.Write(jsonResponse); err != nil { 75 | log.Errorf(ctx, fmt.Sprintf("failed to write response to client: %s", err)) 76 | return 77 | } 78 | log.Infof(ctx, "successfully returned response %#v to client", resp) 79 | return 80 | } 81 | 82 | // signResponse processes a signed URL request and provides a valid response to the client. 83 | func signResponse(ctx context.Context, r *http.Request) models.SignResponse { 84 | bucket := os.Getenv("BUCKET") 85 | if bucket == "" { 86 | log.Errorf(ctx, "BUCKET environment variable not set for %v", ctx) 87 | return models.SignResponse{Status: "BUCKET environment variable not set", ErrorCode: models.StatusConfigError} 88 | } 89 | 90 | d := os.Getenv("SIGNED_URL_DURATION") 91 | if d == "" { 92 | log.Errorf(ctx, "SIGNED_URL_DURATION environment variable not set for %v", ctx) 93 | return models.SignResponse{Status: "SIGNED_URL_DURATION environment variable not set", ErrorCode: models.StatusConfigError} 94 | } 95 | 96 | duration, err := time.ParseDuration(d) 97 | if err != nil { 98 | log.Errorf(ctx, "SIGNED_URL_DURATION was %q, which is not a valid time duration.", d) 99 | return models.SignResponse{Status: "SIGNED_URL_DURATION environment variable not set", ErrorCode: models.StatusConfigError} 100 | } 101 | 102 | resp, req := ProcessSignRequest(ctx, r, bucket, duration) 103 | if resp.ErrorCode != models.StatusSuccess { 104 | log.Warningf(ctx, "could not process SignRequest %v", resp) 105 | } 106 | 107 | if resp.ErrorCode == models.StatusSuccess { 108 | log.Infof(ctx, "successfully processed SignRequest for seed issued to %#v at:%#v Response: %q", req.Seed.Username, req.Seed.Issued, resp.SignedURL) 109 | } 110 | return resp 111 | } 112 | 113 | // ProcessSignRequest takes a models.SignRequest that is provided by a client, 114 | // validates and processes it. A response is always provided using models.SignResponse. 115 | func ProcessSignRequest(ctx context.Context, r *http.Request, bucket string, duration time.Duration) (models.SignResponse, models.SignRequest) { 116 | req, code, err := unmarshalSignRequest(r) 117 | if err != nil { 118 | log.Errorf(ctx, "unmarshalSignRequest called with: %#v, returned error: %s", r, err) 119 | return models.SignResponse{ 120 | Status: err.Error(), 121 | ErrorCode: code, 122 | }, req 123 | } 124 | 125 | if err := validSignRequest(ctx, req); err != nil { 126 | return models.SignResponse{ 127 | Status: err.Error(), 128 | ErrorCode: models.StatusSignError, 129 | }, req 130 | } 131 | 132 | url, err := signedURL(ctx, bucket, req.Path, duration) 133 | if err != nil { 134 | return models.SignResponse{ 135 | Status: err.Error(), 136 | ErrorCode: models.StatusSignError, 137 | }, req 138 | } 139 | 140 | return models.SignResponse{ 141 | Status: "Success", 142 | ErrorCode: models.StatusSuccess, 143 | SignedURL: url, 144 | }, req 145 | } 146 | 147 | // unmarshalSignRequest takes an incoming request, returning a models.SignRequest and 148 | // and a models.StatusCode code representing whether it was read successfully. 149 | func unmarshalSignRequest(r *http.Request) (models.SignRequest, models.StatusCode, error) { 150 | var signRequest models.SignRequest 151 | body, err := ioutil.ReadAll(r.Body) 152 | if err != nil { 153 | return models.SignRequest{}, 154 | models.StatusReqUnreadable, 155 | errors.New("unable to read HTTP request body") 156 | } 157 | 158 | if len(body) == 0 { 159 | return models.SignRequest{}, 160 | models.StatusJSONError, 161 | errors.New("empty HTTP JSON request body") 162 | } 163 | 164 | if err = json.Unmarshal(body, &signRequest); err != nil { 165 | return models.SignRequest{}, 166 | models.StatusJSONError, 167 | fmt.Errorf("unable to unmarshal JSON request, error: %v", err) 168 | } 169 | 170 | return signRequest, 171 | models.StatusSuccess, 172 | nil 173 | } 174 | 175 | func validSignRequest(ctx context.Context, sr models.SignRequest) error { 176 | for _, mac := range sr.Mac { 177 | m := strings.Replace(mac, ":", "", -1) 178 | // A valid Mac is neither shorter nor longer than 12 characters. 179 | if len(m) < 12 { 180 | return fmt.Errorf("%s is too short(%d) to be a Mac address", m, len(m)) 181 | } 182 | if len(m) > 12 { 183 | return fmt.Errorf("%s is too long(%d) to be a Mac address", m, len(m)) 184 | } 185 | // A valid Mac address can only contain hexadecimal characters and colons. 186 | matched, err := regexp.MatchString(macRegEx, mac) 187 | if err != nil { 188 | return fmt.Errorf("regexp.MatchString(%s): %v", mac, err) 189 | } 190 | if matched { 191 | return fmt.Errorf("%s is not a valid mac address", mac) 192 | } 193 | } 194 | 195 | hashCheck := os.Getenv("VERIFY_SIGN_HASH") 196 | if hashCheck != "true" { 197 | log.Infof(ctx, "VERIFY_SIGN_HASH is not set to true, hash validation will be logged but not enforced") 198 | } 199 | err := validSignHash(ctx, sr.Hash) 200 | if err != nil { 201 | log.Warningf(ctx, "failed to validate sign request hash: %v", err) 202 | } 203 | if err != nil && hashCheck == "true" { 204 | return fmt.Errorf("validSignHash: %v", err) 205 | } 206 | 207 | // insert hash into seed to validate signature 208 | sr.Seed.Hash = sr.Hash 209 | if err := validSeed(ctx, sr.Seed, sr.Signature); err != nil { 210 | return fmt.Errorf("validSeed: %v", err) 211 | } 212 | 213 | if len(sr.Path) < 1 { 214 | return errors.New("sign request path cannot be empty") 215 | } 216 | 217 | return nil 218 | } 219 | 220 | // validSignHash takes the current context and the hash submitted with the sign 221 | // request and determines if the submitted hash is in a list of acceptable hashes 222 | // which is stored in a cloud bucket. 223 | func validSignHash(ctx context.Context, requestHash []byte) error { 224 | b := os.Getenv("BUCKET") 225 | if b == "" { 226 | return fmt.Errorf("BUCKET environment variable not set for %v", ctx) 227 | } 228 | acceptedHashes, err := populateAllowlist(ctx) 229 | if err != nil { 230 | return fmt.Errorf("cache.Get(acceptedHashes): %v", err) 231 | } 232 | log.Infof(ctx, "retrieved acceptable hashes: %#v", acceptedHashes) 233 | 234 | h := hex.EncodeToString(requestHash) 235 | if _, ok := acceptedHashes[h]; ok { 236 | log.Infof(ctx, "%v passed validation", h) 237 | return nil 238 | } 239 | return fmt.Errorf("submitted hash %v not in accepted hash list", hex.EncodeToString(requestHash)) 240 | } 241 | 242 | // validSeed takes a seed and its signature, verifies the seed contents and 243 | // optionally the signature. Verification attempts to use the current set 244 | // of appengine.PublicCertificates first, and can fall back to those included 245 | // in the seed. If the requested validation fails, an error is returned. 246 | func validSeed(ctx context.Context, seed models.Seed, sig []byte) error { 247 | // Return immediately if seed verification is disabled. 248 | enabled := os.Getenv("VERIFY_SEED") 249 | if enabled != "true" { 250 | log.Infof(ctx, "VERIFY_SEED=%s or not set, skipping seed verification.", enabled) 251 | return nil 252 | } 253 | 254 | // Check that the username is present 255 | if len(seed.Username) < 3 { 256 | return fmt.Errorf("the username %q is invalid or empty", seed.Username) 257 | } 258 | 259 | // Check that the seed is not expired or invalid. 260 | validityPeriod := os.Getenv("SEED_VALIDITY_DURATION") 261 | if validityPeriod == "" { 262 | return errors.New("SEED_VALIDITY_DURATION environment variable is not present") 263 | } 264 | d, err := time.ParseDuration(validityPeriod) 265 | if err != nil { 266 | return fmt.Errorf("time.parseDuration(%s): %v", validityPeriod, err) 267 | } 268 | expires := seed.Issued.Add(d) 269 | now := time.Now() 270 | if seed.Issued.After(now) { 271 | return fmt.Errorf("seed issued in the future %s", seed.Issued) 272 | } 273 | if expires.Before(now) { 274 | return fmt.Errorf("seed expired on %s, current date is %s", expires, now) 275 | } 276 | 277 | // Skip signature verification if it is not enabled. 278 | sigCheck := os.Getenv("VERIFY_SEED_SIGNATURE") 279 | if sigCheck != "true" { 280 | log.Infof(ctx, "VERIFY_SEED_SIGNATURE=%s or not set, skipping seed signature check", sigCheck) 281 | return nil 282 | } 283 | 284 | if err := validSeedSignature(ctx, seed, sig); err != nil { 285 | return fmt.Errorf("validSeedSignature: %v", err) 286 | } 287 | 288 | return nil 289 | } 290 | 291 | func validSeedSignature(ctx context.Context, seed models.Seed, sig []byte) error { 292 | // Check the seed signature using the App Identity. 293 | // https://cloud.google.com/appengine/docs/standard/go/appidentity/ 294 | certs, err := appengine.PublicCertificates(ctx) 295 | if err != nil { 296 | return fmt.Errorf("appengine.PublicCertificates(%+v): %v", ctx, err) 297 | } 298 | 299 | enableFallback := os.Getenv("VERIFY_SEED_SIGNATURE_FALLBACK") 300 | if enableFallback == "true" { 301 | log.Infof(ctx, "VERIFY_SEED_SIGNATURE_FALLBACK=%s, adding certificates from seed for fallback verification", enableFallback) 302 | certs = append(certs, seed.Certs...) 303 | } 304 | 305 | log.Infof(ctx, "attempting signature verification using %d certs", len(certs)) 306 | for _, cert := range certs { 307 | block, _ := pem.Decode(cert.Data) 308 | if block == nil { 309 | log.Infof(ctx, "pem.Decode returned an empty block for data %q.", cert.Data) 310 | continue 311 | } 312 | 313 | x509Cert, err := x509.ParseCertificate(block.Bytes) 314 | if err != nil { 315 | log.Infof(ctx, "x509.ParseCertificate(%s): %v.", block.Bytes, err) 316 | continue 317 | } 318 | 319 | pubkey, ok := x509Cert.PublicKey.(*rsa.PublicKey) 320 | if !ok { 321 | log.Infof(ctx, "certificate '%v' issued by '%v' is does not contain an RSA public key.", x509Cert.Subject, x509Cert.Issuer) 322 | continue 323 | } 324 | 325 | jsonSeed, err := json.Marshal(seed) 326 | if err != nil { 327 | log.Warningf(ctx, "failed to marshal seed for signature verification: %v", err) 328 | continue 329 | } 330 | seedHash := crypto.SHA256 331 | h := seedHash.New() 332 | h.Write(jsonSeed) 333 | hashed := h.Sum(nil) 334 | if err := rsa.VerifyPKCS1v15(pubkey, seedHash, hashed, sig); err != nil { 335 | log.Infof(ctx, "unable to verify seed %#v with signature %q using certificate '%#v'", seed, sig, x509Cert.Subject) 336 | continue 337 | } 338 | 339 | log.Infof(ctx, "successfully verified signature using certificate '%#v'", x509Cert.Subject) 340 | return nil 341 | } 342 | 343 | return fmt.Errorf("unable to verify signature for seed issued on '%v' to %s", seed.Issued, seed.Username) 344 | } 345 | 346 | // signedURL takes a bucket name and relative file path, and returns an 347 | // equivalent signed URL using the appengine built-in service account. 348 | // https://cloud.google.com/appengine/docs/standard/go/appidentity/ 349 | func signedURL(ctx context.Context, bucket, file string, duration time.Duration) (string, error) { 350 | sa, err := appengine.ServiceAccount(ctx) 351 | if err != nil { 352 | return "", fmt.Errorf("appengine.ServiceAccount: %v", err) 353 | } 354 | 355 | return storage.SignedURL(bucket, file, &storage.SignedURLOptions{ 356 | GoogleAccessID: sa, 357 | SignBytes: func(b []byte) ([]byte, error) { 358 | _, sig, err := appengine.SignBytes(ctx, b) 359 | return sig, err 360 | }, 361 | Method: "GET", 362 | Expires: time.Now().Add(time.Minute * duration), 363 | }) 364 | } 365 | 366 | // getAllowlist returns a map of hashes and whether they are acceptable. 367 | func getAllowlist(ctx context.Context, b string, f string) (map[string]bool, error) { 368 | log.Infof(ctx, "reading acceptable hashes from cloud bucket") 369 | h, err := bucketFileFinder(ctx, b, f) 370 | if err != nil { 371 | return nil, fmt.Errorf("bucketFileFinder(%s, %s): %v", b, f, err) 372 | } 373 | 374 | y, err := ioutil.ReadAll(h) 375 | if err != nil { 376 | return nil, fmt.Errorf("reading allowlist contents: %v", err) 377 | } 378 | 379 | var wls []string 380 | if err := yaml.Unmarshal(y, &wls); err != nil { 381 | return nil, fmt.Errorf("failed parsing allowlist: %v", err) 382 | } 383 | 384 | mwl := make(map[string]bool) 385 | for _, e := range wls { 386 | mwl[strings.ToLower(e)] = true 387 | } 388 | return mwl, nil 389 | } 390 | 391 | func bucketFileHandle(ctx context.Context, b string, f string) (io.Reader, error) { 392 | client, err := storage.NewClient(ctx) 393 | if err != nil { 394 | return nil, fmt.Errorf("failed to create cloud storage client: %v", err) 395 | } 396 | bh := client.Bucket(b) 397 | fh := bh.Object(f) 398 | return fh.NewReader(ctx) 399 | } 400 | -------------------------------------------------------------------------------- /cli/commands/write/write_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package write 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "os" 21 | "path/filepath" 22 | "testing" 23 | 24 | "flag" 25 | "github.com/google/fresnel/cli/config" 26 | "github.com/google/fresnel/cli/console" 27 | "github.com/google/fresnel/cli/installer" 28 | "github.com/google/subcommands" 29 | "github.com/google/winops/storage" 30 | ) 31 | 32 | func TestName(t *testing.T) { 33 | name := "testCmd" 34 | write := &writeCmd{name: name} 35 | got := write.Name() 36 | if got != name { 37 | t.Errorf("Name() got: %q, want: %q", got, name) 38 | } 39 | } 40 | 41 | func TestSynopsis(t *testing.T) { 42 | write := &writeCmd{name: "testCmd"} 43 | got := write.Synopsis() 44 | if got == "" { 45 | t.Errorf("Synopsis() got: %q, want: not empty", got) 46 | } 47 | } 48 | 49 | func TestUsage(t *testing.T) { 50 | write := &writeCmd{name: "testCmd"} 51 | got := write.Usage() 52 | if got == "" { 53 | t.Errorf("Usage() got: %q, want: not empty", got) 54 | } 55 | } 56 | 57 | func TestExecute(t *testing.T) { 58 | tests := []struct { 59 | desc string 60 | cmd *writeCmd 61 | args []string // Commandline arguments to be passed 62 | execute func(c *writeCmd, f *flag.FlagSet) error 63 | logDir string 64 | verbose bool // Expected state of console.Verbose 65 | want subcommands.ExitStatus 66 | }{ 67 | { 68 | desc: "no devices specified", 69 | cmd: &writeCmd{}, 70 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 71 | want: subcommands.ExitUsageError, 72 | }, 73 | { 74 | desc: "run error", 75 | cmd: &writeCmd{}, 76 | args: []string{"1"}, 77 | execute: func(c *writeCmd, f *flag.FlagSet) error { return errors.New("test") }, 78 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 79 | want: subcommands.ExitFailure, 80 | }, 81 | { 82 | desc: "success", 83 | cmd: &writeCmd{}, 84 | args: []string{"1"}, 85 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 86 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 87 | verbose: false, 88 | want: subcommands.ExitSuccess, 89 | }, 90 | { 91 | desc: "verbose it set with --info", 92 | cmd: &writeCmd{}, 93 | args: []string{"--info", "1"}, 94 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 95 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 96 | verbose: true, 97 | want: subcommands.ExitSuccess, 98 | }, 99 | { 100 | desc: "verbose it set with --verbose", 101 | cmd: &writeCmd{}, 102 | args: []string{"--verbose", "1"}, 103 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 104 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 105 | verbose: true, 106 | want: subcommands.ExitSuccess, 107 | }, 108 | { 109 | desc: "verbose it set with --v=2", 110 | cmd: &writeCmd{}, 111 | args: []string{"--v=2", "1"}, 112 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 113 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 114 | verbose: true, 115 | want: subcommands.ExitSuccess, 116 | }, 117 | { 118 | desc: "no drives specified but --all flag specified", 119 | cmd: &writeCmd{}, 120 | args: []string{"--all"}, 121 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 122 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 123 | verbose: false, 124 | want: subcommands.ExitSuccess, 125 | }, 126 | { 127 | desc: "both --all and --show_fixed specified", 128 | cmd: &writeCmd{}, 129 | args: []string{"--all", "--show_fixed"}, 130 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 131 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 132 | verbose: false, 133 | want: subcommands.ExitFailure, 134 | }, 135 | { 136 | desc: "--conf_track passed on non ffu distro", 137 | cmd: &writeCmd{}, 138 | args: []string{"--track=stable", "--conf_track=stable", "1"}, 139 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 140 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 141 | verbose: false, 142 | want: subcommands.ExitSuccess, 143 | }, 144 | { 145 | desc: "--conf_track and --ffu_track passed on ffu distro", 146 | cmd: &writeCmd{ffu: true}, 147 | args: []string{"--conf_track=testing", "1"}, 148 | execute: func(c *writeCmd, f *flag.FlagSet) error { return nil }, 149 | logDir: filepath.Dir(filepath.Join(os.TempDir(), binaryName)), 150 | verbose: false, 151 | want: subcommands.ExitSuccess, 152 | }, 153 | } 154 | for _, tt := range tests { 155 | funcUSBPermissions = func() error { return nil } 156 | // Generate the logDir if specified 157 | if tt.logDir != "" { 158 | if err := os.MkdirAll(tt.logDir, 0755); err != nil { 159 | t.Errorf("%s: os.MkDirAll(%s, 0755) returned %v", tt.desc, tt.logDir, err) 160 | } 161 | } 162 | // Reset console.Verbose and perform substitutions. 163 | console.Verbose = false 164 | write := tt.cmd 165 | execute = tt.execute 166 | 167 | // Generate the flagSet and set Flags 168 | flagSet := flag.NewFlagSet("test", flag.ContinueOnError) 169 | write.SetFlags(flagSet) 170 | if err := flagSet.Parse(tt.args); err != nil { 171 | t.Errorf("%s: flagSet.Parse(%v) returned %v", tt.desc, tt.args, err) 172 | } 173 | 174 | // Get results 175 | got := write.Execute(context.Background(), flagSet, nil) 176 | if got != tt.want { 177 | t.Errorf("%s: Execute() got: %d, want: %d", tt.desc, got, tt.want) 178 | } 179 | if console.Verbose != tt.verbose { 180 | t.Errorf("%s: console.Verbose = %t, want: %t", tt.desc, console.Verbose, tt.verbose) 181 | } 182 | } 183 | } 184 | 185 | // fakeDevice inherits all members of storage.Device through embedding. 186 | // Unimplemented members send a clear signal during tests because they will 187 | // panic if called, allowing us to implement only the minimum set of members 188 | // required. 189 | type fakeDevice struct { 190 | // storage.Device is embedded, fakeDevice inherits all its members. 191 | storage.Device 192 | 193 | id string 194 | 195 | dmErr error 196 | ejectErr error 197 | partErr error 198 | selErr error 199 | wipeErr error 200 | writeErr error 201 | } 202 | 203 | func (f *fakeDevice) Dismount() error { 204 | return f.dmErr 205 | } 206 | 207 | func (f *fakeDevice) Eject() error { 208 | return f.ejectErr 209 | } 210 | 211 | func (f *fakeDevice) Identifier() string { 212 | return f.id 213 | } 214 | 215 | func (f *fakeDevice) Partition(label string) error { 216 | return f.partErr 217 | } 218 | 219 | func (f *fakeDevice) SelectPartition(uint64, storage.FileSystem) (*storage.Partition, error) { 220 | return nil, f.partErr 221 | } 222 | 223 | func (f *fakeDevice) Wipe() error { 224 | return f.wipeErr 225 | } 226 | 227 | // fakeInstaller inherits all members of installer.Installer through embedding. 228 | // Unimplemented members send a clear signal during tests because they will 229 | // panic if called, allowing us to implement only the minimum set of members 230 | // required. 231 | type fakeInstaller struct { 232 | // installer.Installer is embedded, fakeInstaller inherits all its members. 233 | installer.Installer 234 | 235 | prepErr error // Returned when Prepare() is called. 236 | provErr error // Returned when Provision() is called. 237 | retErr error // Returned when Retrieve() is called. 238 | finErr error // Returned when Finalize() is called. 239 | } 240 | 241 | func (i *fakeInstaller) Prepare(installer.Device) error { 242 | return i.prepErr 243 | } 244 | 245 | func (i *fakeInstaller) Provision(installer.Device) error { 246 | return i.provErr 247 | } 248 | 249 | func (i *fakeInstaller) Retrieve() error { 250 | return i.retErr 251 | } 252 | 253 | func (i *fakeInstaller) Finalize([]installer.Device, bool) error { 254 | return i.finErr 255 | } 256 | 257 | func TestRun(t *testing.T) { 258 | tests := []struct { 259 | desc string 260 | cmd *writeCmd 261 | isElevatedCmd func() (bool, error) 262 | searchCmd func(string, uint64, uint64, bool) ([]installer.Device, error) 263 | newInstCmd func(config installer.Configuration) (imageInstaller, error) 264 | args []string // Commandline arguments to be passed 265 | want error 266 | }{ 267 | { 268 | desc: "config.New error", 269 | cmd: &writeCmd{}, 270 | isElevatedCmd: func() (bool, error) { return false, nil }, 271 | want: errConfig, 272 | }, 273 | { 274 | desc: "elevation error", 275 | cmd: &writeCmd{distro: "windows"}, 276 | isElevatedCmd: func() (bool, error) { return false, nil }, 277 | want: errElevation, 278 | }, 279 | { 280 | desc: "search failure", 281 | cmd: &writeCmd{distro: "windows"}, 282 | isElevatedCmd: func() (bool, error) { return true, nil }, 283 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { return nil, errors.New("error") }, 284 | want: errSearch, 285 | }, 286 | { 287 | desc: "unsuitable device", 288 | cmd: &writeCmd{distro: "windows"}, 289 | isElevatedCmd: func() (bool, error) { return true, nil }, 290 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { return nil, nil }, 291 | args: []string{"4"}, 292 | want: errDevice, 293 | }, 294 | { 295 | desc: "new.Installer error", 296 | cmd: &writeCmd{distro: "windows"}, 297 | isElevatedCmd: func() (bool, error) { return true, nil }, 298 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 299 | return []installer.Device{&fakeDevice{id: "1"}}, nil 300 | }, 301 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { return nil, errors.New("") }, 302 | args: []string{"--warning=false", "1"}, 303 | want: errInstaller, 304 | }, 305 | { 306 | desc: "retrieve error", 307 | cmd: &writeCmd{distro: "windows"}, 308 | isElevatedCmd: func() (bool, error) { return true, nil }, 309 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 310 | return []installer.Device{&fakeDevice{id: "1"}}, nil 311 | }, 312 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { 313 | return &fakeInstaller{retErr: errors.New("error")}, nil 314 | }, 315 | args: []string{"--warning=false", "1"}, 316 | want: errRetrieve, 317 | }, 318 | { 319 | desc: "prepare error", 320 | cmd: &writeCmd{distro: "windows"}, 321 | isElevatedCmd: func() (bool, error) { return true, nil }, 322 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 323 | return []installer.Device{&fakeDevice{id: "1"}}, nil 324 | }, 325 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { 326 | return &fakeInstaller{prepErr: errors.New("error")}, nil 327 | }, 328 | args: []string{"--warning=false", "1"}, 329 | want: errPrepare, 330 | }, 331 | { 332 | desc: "provision error", 333 | cmd: &writeCmd{distro: "windows"}, 334 | isElevatedCmd: func() (bool, error) { return true, nil }, 335 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 336 | return []installer.Device{&fakeDevice{id: "1"}}, nil 337 | }, 338 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { 339 | return &fakeInstaller{provErr: errors.New("error")}, nil 340 | }, 341 | args: []string{"--warning=false", "1"}, 342 | want: errProvision, 343 | }, 344 | { 345 | desc: "finalize error", 346 | cmd: &writeCmd{distro: "windows"}, 347 | isElevatedCmd: func() (bool, error) { return true, nil }, 348 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 349 | return []installer.Device{&fakeDevice{id: "1"}}, nil 350 | }, 351 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { 352 | return &fakeInstaller{finErr: errors.New("error")}, nil 353 | }, 354 | args: []string{"--warning=false", "1"}, 355 | want: errFinalize, 356 | }, 357 | { 358 | desc: "finalize error", 359 | cmd: &writeCmd{distro: "windows"}, 360 | isElevatedCmd: func() (bool, error) { return true, nil }, 361 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 362 | return []installer.Device{&fakeDevice{id: "1"}}, nil 363 | }, 364 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { 365 | return &fakeInstaller{finErr: errors.New("error")}, nil 366 | }, 367 | args: []string{"--warning=false", "1"}, 368 | want: errFinalize, 369 | }, 370 | { 371 | desc: "success", 372 | cmd: &writeCmd{distro: "windows"}, 373 | isElevatedCmd: func() (bool, error) { return true, nil }, 374 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 375 | return []installer.Device{&fakeDevice{id: "1"}}, nil 376 | }, 377 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { 378 | return &fakeInstaller{}, nil 379 | }, 380 | args: []string{"--warning=false", "1"}, 381 | want: nil, 382 | }, 383 | { 384 | desc: "--all flag provided", 385 | cmd: &writeCmd{distro: "windows"}, 386 | isElevatedCmd: func() (bool, error) { return true, nil }, 387 | searchCmd: func(string, uint64, uint64, bool) ([]installer.Device, error) { 388 | return []installer.Device{&fakeDevice{id: "1"}, &fakeDevice{id: "2"}}, nil 389 | }, 390 | newInstCmd: func(config installer.Configuration) (imageInstaller, error) { 391 | return &fakeInstaller{}, nil 392 | }, 393 | args: []string{"--warning=false", "--all"}, 394 | want: nil, 395 | }, 396 | } 397 | for _, tt := range tests { 398 | // Perform substitutions, generate the flagSet and set Flags. 399 | config.IsElevatedCmd = tt.isElevatedCmd 400 | funcUSBPermissions = func() error { return nil } 401 | search = tt.searchCmd 402 | newInstaller = tt.newInstCmd 403 | 404 | flagSet := flag.NewFlagSet("test", flag.ContinueOnError) 405 | write := tt.cmd 406 | write.SetFlags(flagSet) 407 | if err := flagSet.Parse(tt.args); err != nil { 408 | t.Errorf("%s: flagSet.Parse(%v) returned %v", tt.desc, tt.args, err) 409 | } 410 | 411 | // Get results 412 | got := run(write, flagSet) 413 | if !errors.Is(got, tt.want) { 414 | t.Errorf("%s: run() got: %v, want: %v", tt.desc, got, tt.want) 415 | } 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /cli/config/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "strings" 21 | "testing" 22 | ) 23 | 24 | var ( 25 | imageServer = `https://foo.bar.com` 26 | // goodDistro represents a valid distribution list. 27 | goodDistro = distribution{ 28 | imageServer: imageServer, 29 | images: map[string]string{ 30 | "default": "default_installer.img", 31 | "stable": "stable_installer.img", 32 | }, 33 | configs: map[string]string{ 34 | "default": "default_config.yaml", 35 | "stable": "stable_config.yaml", 36 | }, 37 | } 38 | distroDefaults = distributions 39 | ) 40 | 41 | // cmpConfig is a custom comparer for the Configuration struct. We use a custom 42 | // comparer to inspect public-facing members of the two structs. Errors 43 | // describing members that do not match are returned. When all checked fields 44 | // are equal, nil is returned. 45 | // https://godoc.org/github.com/google/go-cmp/cmp#Exporter 46 | func cmpConfig(got, want Configuration) error { 47 | if got.track != want.track { 48 | return fmt.Errorf("image track mismatch, got: %q, want: %q", got.track, want.track) 49 | } 50 | if got.confTrack != want.confTrack { 51 | return fmt.Errorf("configuration track mismatch, got: %q, want: %q", got.confTrack, want.confTrack) 52 | } 53 | if got.cleanup != want.cleanup { 54 | return fmt.Errorf("configuration cleanup mismatch, got: %t, want: %t", got.cleanup, want.cleanup) 55 | } 56 | if got.warning != want.warning { 57 | return fmt.Errorf("configuration warning mismatch, got: %t, want: %t", got.warning, want.warning) 58 | } 59 | if !equal(got.devices, want.devices) { 60 | return fmt.Errorf("configuration devices mismatch, got: %v, want: %v", got.devices, want.devices) 61 | } 62 | // If no distro was provided anywhere, we can return now. 63 | if got.distro == nil && want.distro == nil { 64 | return nil 65 | } 66 | // If either distro is nil at this point, we have a mismatch. 67 | if got.distro == nil || want.distro == nil { 68 | return fmt.Errorf("configuration distro mismatch, got: %+v\n want: %+v", got.distro, want.distro) 69 | } 70 | // distro's are generally static in config, so if we get here, we can safely 71 | // assume a match, and return. 72 | return nil 73 | } 74 | 75 | // equal compares two string slices and determines if they are the same. 76 | func equal(left, right []string) bool { 77 | if len(left) != len(right) { 78 | return false 79 | } 80 | for i, value := range left { 81 | if value != right[i] { 82 | return false 83 | } 84 | } 85 | return true 86 | } 87 | 88 | func TestNew(t *testing.T) { 89 | tests := []struct { 90 | desc string 91 | fakeIsElevated func() (bool, error) 92 | ffu bool 93 | devices []string 94 | os string 95 | track string 96 | confTrack string 97 | seedServer string 98 | out *Configuration 99 | want error 100 | }{ 101 | { 102 | desc: "bad devices", 103 | devices: []string{"f:", "/dev/sda1p1"}, 104 | want: errDevice, 105 | }, 106 | { 107 | desc: "bad distro", 108 | devices: []string{"disk1"}, 109 | os: "foo", 110 | want: errDistro, 111 | }, 112 | { 113 | desc: "bad track", 114 | devices: []string{"disk1"}, 115 | os: "windows", 116 | track: "foo", 117 | want: errTrack, 118 | }, 119 | { 120 | desc: "bad ffu track", 121 | devices: []string{"disk1"}, 122 | ffu: true, 123 | os: "windowsffu", 124 | confTrack: "foo", 125 | track: "foo", 126 | fakeIsElevated: func() (bool, error) { return true, nil }, 127 | want: errTrack, 128 | }, 129 | { 130 | desc: "bad seed server", 131 | devices: []string{"disk1"}, 132 | os: "windows", 133 | track: "stable", 134 | seedServer: "test.foo@bar.com", 135 | want: errSeed, 136 | }, 137 | { 138 | desc: "isElevated error", 139 | devices: []string{"disk1"}, 140 | os: "windows", 141 | track: "stable", 142 | fakeIsElevated: func() (bool, error) { return false, errors.New("error") }, 143 | want: errElevation, 144 | }, 145 | { 146 | desc: "valid config", 147 | devices: []string{"disk1"}, 148 | os: "windows", 149 | track: "stable", 150 | fakeIsElevated: func() (bool, error) { return true, nil }, 151 | out: &Configuration{ 152 | distro: &goodDistro, 153 | track: "stable", 154 | devices: []string{"disk1"}, 155 | elevated: true, 156 | }, 157 | want: nil, 158 | }, 159 | { 160 | desc: "valid config with ffu", 161 | devices: []string{"disk1"}, 162 | os: "windowsffu", 163 | ffu: true, 164 | confTrack: "unstable", 165 | track: "unstable", 166 | fakeIsElevated: func() (bool, error) { return true, nil }, 167 | out: &Configuration{ 168 | distro: &goodDistro, 169 | track: "unstable", 170 | confTrack: "unstable", 171 | devices: []string{"disk1"}, 172 | elevated: true, 173 | }, 174 | want: nil, 175 | }, 176 | } 177 | for _, tt := range tests { 178 | IsElevatedCmd = tt.fakeIsElevated 179 | c, got := New(false, false, false, tt.ffu, false, tt.devices, tt.os, tt.track, tt.confTrack, tt.seedServer) 180 | if got == tt.want { 181 | continue 182 | } 183 | if c == tt.out { 184 | continue 185 | } 186 | if !errors.Is(got, tt.want) { 187 | t.Errorf("%s: New() got: '%v', want: '%v'", tt.desc, got, tt.want) 188 | } 189 | if err := cmpConfig(*c, *tt.out); err != nil { 190 | t.Errorf("%s: %v", tt.desc, err) 191 | } 192 | } 193 | } 194 | 195 | func TestAddDistro(t *testing.T) { 196 | // Generate a good distro that is missing seedFile. 197 | noSeedFile := goodDistro 198 | noSeedFile.seedServer = `http://foo.bar.com` 199 | noSeedDest := noSeedFile 200 | noSeedDest.seedFile = "fake.wim" 201 | 202 | tests := []struct { 203 | desc string 204 | choice string 205 | distros map[string]distribution 206 | out Configuration 207 | want error 208 | }{ 209 | { 210 | desc: "empty choice", 211 | distros: map[string]distribution{"foo": goodDistro}, 212 | out: Configuration{}, 213 | want: errDistro, 214 | }, 215 | { 216 | desc: "bad choice", 217 | choice: "foo", 218 | distros: map[string]distribution{"bar": goodDistro}, 219 | out: Configuration{}, 220 | want: errDistro, 221 | }, 222 | { 223 | desc: "missing seedFile", 224 | choice: "baz", 225 | distros: map[string]distribution{"baz": noSeedFile}, 226 | out: Configuration{}, 227 | want: errInput, 228 | }, 229 | { 230 | desc: "missing seedDest", 231 | choice: "baz", 232 | distros: map[string]distribution{"baz": noSeedDest}, 233 | out: Configuration{}, 234 | want: errSeed, 235 | }, 236 | { 237 | desc: "good choice", 238 | choice: "good", 239 | distros: map[string]distribution{"good": goodDistro}, 240 | out: Configuration{distro: &goodDistro}, 241 | want: nil, 242 | }, 243 | } 244 | for _, tt := range tests { 245 | c := Configuration{} 246 | distributions = tt.distros 247 | got := c.addDistro(tt.choice) 248 | if err := cmpConfig(c, tt.out); err != nil { 249 | t.Errorf("%s: %v", tt.desc, err) 250 | } 251 | if got == tt.want { 252 | continue 253 | } 254 | if !errors.Is(got, tt.want) { 255 | t.Errorf("%s: addDistro() got: '%v', want: '%v'", tt.desc, got, tt.want) 256 | } 257 | } 258 | distributions = distroDefaults // reset defaults for other tests 259 | } 260 | 261 | func TestAddDeviceList(t *testing.T) { 262 | tests := []struct { 263 | desc string 264 | devices []string 265 | out Configuration 266 | want error 267 | }{ 268 | { 269 | desc: "empty list", 270 | out: Configuration{}, 271 | want: errInput, 272 | }, 273 | { 274 | desc: "bad windows path", 275 | devices: []string{"f:", "g", "1"}, 276 | out: Configuration{}, 277 | want: errDevice, 278 | }, 279 | { 280 | desc: "bad linux path", 281 | devices: []string{"/dev/sda", "sdb1", "sdb"}, 282 | out: Configuration{}, 283 | want: errDevice, 284 | }, 285 | { 286 | desc: "good linux path", 287 | devices: []string{"sda"}, 288 | out: Configuration{devices: []string{"sda"}}, 289 | want: nil, 290 | }, 291 | { 292 | desc: "good darwin path", 293 | devices: []string{"disk3"}, 294 | out: Configuration{devices: []string{"disk3"}}, 295 | want: nil, 296 | }, 297 | { 298 | desc: "good windows path", 299 | devices: []string{"2"}, 300 | out: Configuration{devices: []string{"2"}}, 301 | want: nil, 302 | }, 303 | } 304 | for _, tt := range tests { 305 | c := Configuration{} 306 | got := c.addDeviceList(tt.devices) 307 | if err := cmpConfig(c, tt.out); err != nil { 308 | t.Errorf("%s: %v", tt.desc, err) 309 | } 310 | if !errors.Is(got, tt.want) { 311 | t.Errorf("%s: addDeviceList() got: '%v', want: '%v'", tt.desc, got, tt.want) 312 | } 313 | } 314 | } 315 | 316 | func TestAddSeedServer(t *testing.T) { 317 | tests := []struct { 318 | desc string 319 | server string 320 | distro distribution 321 | out Configuration 322 | want error 323 | }{ 324 | { 325 | desc: "no override", 326 | distro: goodDistro, 327 | out: Configuration{distro: &goodDistro}, 328 | want: nil, 329 | }, 330 | { 331 | desc: "bad fqdn", 332 | server: "test.foo@bar.com", 333 | distro: goodDistro, 334 | out: Configuration{distro: &goodDistro}, 335 | want: errSeed, 336 | }, 337 | { 338 | desc: "good fqdn", 339 | server: "foo.bar.com", 340 | distro: goodDistro, 341 | out: Configuration{distro: &goodDistro}, 342 | want: nil, 343 | }, 344 | } 345 | for _, tt := range tests { 346 | c := Configuration{distro: &tt.distro} 347 | got := c.addSeedServer(tt.server) 348 | if err := cmpConfig(c, tt.out); err != nil { 349 | t.Errorf("%s: %v", tt.desc, err) 350 | } 351 | if got == tt.want { 352 | continue 353 | } 354 | if !errors.Is(got, tt.want) { 355 | t.Errorf("%s: addSeedServer() got: '%v', want: '%v'", tt.desc, got, tt.want) 356 | } 357 | } 358 | } 359 | 360 | func TestValidateTrack(t *testing.T) { 361 | badDistro := distribution{ 362 | imageServer: imageServer, 363 | images: map[string]string{ 364 | "stable": "stable_installer.img", 365 | }, 366 | } 367 | 368 | tests := []struct { 369 | desc string 370 | track string 371 | maps map[string]string 372 | out string 373 | want error 374 | }{ 375 | { 376 | desc: "no default track", 377 | maps: badDistro.images, 378 | out: "", 379 | want: errInput, 380 | }, 381 | { 382 | desc: "empty track", 383 | maps: goodDistro.images, 384 | out: "default", 385 | want: nil, 386 | }, 387 | { 388 | desc: "non-existent track", 389 | track: "foo", 390 | maps: goodDistro.images, 391 | out: "", 392 | want: errTrack, 393 | }, 394 | { 395 | desc: "valid track", 396 | track: "stable", 397 | maps: goodDistro.images, 398 | out: "stable", 399 | want: nil, 400 | }, 401 | } 402 | for _, tt := range tests { 403 | got, err := validateTrack(tt.track, tt.maps) 404 | if !errors.Is(err, tt.want) { 405 | t.Errorf("%s: validateTrack() got: '%v', want: '%v'", tt.desc, got, tt.want) 406 | } 407 | if got != tt.out { 408 | t.Errorf("%s: validateTrack() got: '%v', want: '%v'", tt.desc, got, tt.out) 409 | } 410 | } 411 | } 412 | 413 | func TestDistro(t *testing.T) { 414 | want := "test" 415 | distro := distribution{name: want} 416 | c := Configuration{distro: &distro} 417 | if got := c.Distro(); got != want { 418 | t.Errorf("Distro() got: %q, want: %q", got, want) 419 | } 420 | } 421 | 422 | func TestDistroLabel(t *testing.T) { 423 | want := "testLabel" 424 | distro := distribution{label: want} 425 | c := Configuration{distro: &distro} 426 | if got := c.DistroLabel(); got != want { 427 | t.Errorf("DistroLabel() got: %q, want: %q", got, want) 428 | } 429 | } 430 | 431 | func TestTrack(t *testing.T) { 432 | want := "default" 433 | c := Configuration{track: want} 434 | if got := c.Track(); got != want { 435 | t.Errorf("Track() got: %q, want: %q", got, want) 436 | } 437 | } 438 | 439 | func TestImage(t *testing.T) { 440 | imageServer := `https://foo.bar.com` 441 | track := `default` 442 | distro := distribution{ 443 | imageServer: imageServer, 444 | images: map[string]string{ 445 | track: "test_installer.img", 446 | }, 447 | } 448 | want := fmt.Sprintf(`%s/%s`, imageServer, distro.images[track]) 449 | c := Configuration{track: track, distro: &distro} 450 | if got := c.ImagePath(); got != want { 451 | t.Errorf("ImagePath() got: %q, want: %q", got, want) 452 | } 453 | } 454 | 455 | func TestImageFile(t *testing.T) { 456 | tests := []struct { 457 | desc string 458 | images map[string]string 459 | want string 460 | }{ 461 | { 462 | desc: "iso", 463 | images: map[string]string{"default": "test_iso.iso"}, 464 | want: "test_iso.iso", 465 | }, 466 | { 467 | desc: "nested iso", 468 | images: map[string]string{"default": "nested/test_iso.iso"}, 469 | want: "test_iso.iso", 470 | }, 471 | { 472 | desc: "img", 473 | images: map[string]string{"default": "test_img.img"}, 474 | want: "test_img.img", 475 | }, 476 | { 477 | desc: "nested img", 478 | images: map[string]string{"default": "nested/test_img.img"}, 479 | want: "test_img.img", 480 | }, 481 | { 482 | desc: "compressed img", 483 | images: map[string]string{"default": "compressed-img.img.gz"}, 484 | want: "compressed-img.img.gz", 485 | }, 486 | { 487 | desc: "nested compressed img", 488 | images: map[string]string{"default": "nested/compressed-img.img.gz"}, 489 | want: "compressed-img.img.gz", 490 | }, 491 | } 492 | for _, tt := range tests { 493 | c := Configuration{ 494 | track: "default", 495 | distro: &distribution{ 496 | imageServer: imageServer, 497 | images: tt.images, 498 | }, 499 | } 500 | got := c.ImageFile() 501 | if got != tt.want { 502 | t.Errorf("%s: ImageFile() got: %q, want: %q", tt.desc, got, tt.want) 503 | } 504 | } 505 | } 506 | 507 | func TestFFUConfFile(t *testing.T) { 508 | track := `default` 509 | distro := distribution{ 510 | configs: map[string]string{ 511 | track: "conf.yaml", 512 | }, 513 | } 514 | want := "conf.yaml" 515 | c := Configuration{confTrack: track, distro: &distro} 516 | if got := c.FFUConfFile(); got != want { 517 | t.Errorf("FFUConfFile() got: %q, want: %q", got, want) 518 | } 519 | } 520 | 521 | func TestFFUConfPath(t *testing.T) { 522 | track := `default` 523 | distro := distribution{ 524 | confServer: `https://foo.bar.com/configs/yaml`, 525 | configs: map[string]string{ 526 | track: "conf.yaml", 527 | }, 528 | } 529 | want := "https://foo.bar.com/configs/yaml/conf.yaml" 530 | c := Configuration{confTrack: track, distro: &distro} 531 | if got := c.FFUConfPath(); got != want { 532 | t.Errorf("FFUConfPath() got: %q, want: %q", got, want) 533 | } 534 | } 535 | 536 | func TestCleanup(t *testing.T) { 537 | want := true 538 | c := Configuration{cleanup: want} 539 | if got := c.Cleanup(); got != want { 540 | t.Errorf("Cleanup() got: %t, want: %t", got, want) 541 | } 542 | } 543 | 544 | func TestPowerOff(t *testing.T) { 545 | want := true 546 | c := Configuration{eject: want} 547 | if got := c.PowerOff(); got != want { 548 | t.Errorf("PowerOff() got: %t, want: %t", got, want) 549 | } 550 | } 551 | 552 | func TestWarning(t *testing.T) { 553 | want := true 554 | c := Configuration{warning: want} 555 | if got := c.Warning(); got != want { 556 | t.Errorf("Warning() got: %t, want: %t", got, want) 557 | } 558 | } 559 | 560 | func TestSeedServer(t *testing.T) { 561 | seedServer := `https://seed.foo.com` 562 | distro := distribution{ 563 | seedServer: seedServer, 564 | } 565 | want := seedServer 566 | c := Configuration{distro: &distro} 567 | if got := c.SeedServer(); got != want { 568 | t.Errorf("SeedServer() got: %q, want: %q", got, want) 569 | } 570 | } 571 | 572 | func TestSeedFile(t *testing.T) { 573 | seedFile := `sources/base.wim` 574 | distro := distribution{ 575 | seedFile: seedFile, 576 | } 577 | want := seedFile 578 | c := Configuration{distro: &distro} 579 | if got := c.SeedFile(); got != want { 580 | t.Errorf("SeedFile() got: %q, want: %q", got, want) 581 | } 582 | } 583 | 584 | func TestSeedDest(t *testing.T) { 585 | seedDest := "test" 586 | distro := distribution{ 587 | seedDest: seedDest, 588 | } 589 | want := seedDest 590 | c := Configuration{distro: &distro} 591 | if got := c.SeedDest(); got != want { 592 | t.Errorf("SeedDest() got: %q, want: %q", got, want) 593 | } 594 | } 595 | 596 | func TestString(t *testing.T) { 597 | want := "test-distro" 598 | distro := distribution{ 599 | name: want, 600 | } 601 | c := Configuration{distro: &distro} 602 | if got := c.String(); !strings.Contains(got, want) { 603 | t.Errorf("String() got: %q, want contains: %q", got, want) 604 | } 605 | } 606 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | bitbucket.org/creachadair/stringset v0.0.9/go.mod h1:t+4WcQ4+PXTa8aQdNKe40ZP6iwesoMFWAxPGd3UGjyY= 2 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys= 4 | cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= 5 | cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY= 6 | cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= 7 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 8 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 9 | cloud.google.com/go/iam v0.13.0 h1:+CmB+K0J/33d0zSQ9SlFWUeCCEn5XJA0ZMZ3pHE9u8k= 10 | cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= 11 | cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM= 12 | cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI= 13 | cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= 14 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 15 | github.com/StackExchange/wmi v1.2.0/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= 16 | github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9/go.mod h1:257CYs3Wd/CTlLQ3c72jKv+fFE2MV3WPNnV5jiroYUU= 17 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 18 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 19 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 20 | github.com/creachadair/staticfile v0.1.3/go.mod h1:a3qySzCIXEprDGxk6tSxSI+dBBdLzqeBOMhZ+o2d3pM= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= 24 | github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 25 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 26 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 27 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 28 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 29 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 30 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 31 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 32 | github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= 33 | github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY= 34 | github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 35 | github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= 36 | github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= 37 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 38 | github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 39 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= 40 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 41 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 42 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 43 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 44 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 45 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 46 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 47 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 48 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 49 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 50 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 51 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 52 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 53 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 54 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 55 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 56 | github.com/google/deck v0.0.0-20221215182815-59abc1280690 h1:JpBQKOeDKpEP6LJMIQN0e/6qhhyLOOuZIgkOBvXsvys= 57 | github.com/google/deck v0.0.0-20221215182815-59abc1280690/go.mod h1:DoDv8G58DuLNZF0KysYn0bA/6ZWhmRW3fZE2VnGEH0w= 58 | github.com/google/glazier v0.0.0-20220803164842-3bfee96e658a h1:51VRosGMhps3790b5MhjtI1EWvB5fB6Gw1CtorPD5o0= 59 | github.com/google/glazier v0.0.0-20220803164842-3bfee96e658a/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg= 60 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 61 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 62 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 63 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 64 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 65 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 66 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 67 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 68 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 69 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 70 | github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ= 71 | github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ= 72 | github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= 73 | github.com/google/splice v1.0.0 h1:ZnBUFzO8USYjchgaHuYwq6hrFTDgl99tY7v1PdN/lsM= 74 | github.com/google/splice v1.0.0/go.mod h1:hoL3efFrcdxwybVnaD8D4bXbcfuSr4nn1FBOdfmfQhU= 75 | github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= 76 | github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= 77 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 78 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 79 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 80 | github.com/google/winops v0.0.0-20210803215038-c8511b84de2b h1:+GvN4K42Xrj5Dy+XuyruOaDskO0QDwrA6FCI8WzlbU0= 81 | github.com/google/winops v0.0.0-20210803215038-c8511b84de2b/go.mod h1:ShbX8v8clPm/3chw9zHVwtW3QhrFpL8mXOwNxClt4pg= 82 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= 83 | github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= 84 | github.com/googleapis/gax-go/v2 v2.7.1 h1:gF4c0zjUP2H/s/hEGyLA3I0fA2ZWjzYiONAD6cvPr8A= 85 | github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= 86 | github.com/groob/plist v0.0.0-20210519001750-9f754062e6d6 h1:RyfUvLxQ4XCqPzRlNc0rlN/yYaLgReYhpAWmBdtm6ak= 87 | github.com/groob/plist v0.0.0-20210519001750-9f754062e6d6/go.mod h1:itkABA+w2cw7x5nYUS/pLRef6ludkZKOigbROmCTaFw= 88 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 89 | github.com/iamacarpet/go-win64api v0.0.0-20210311141720-fe38760bed28/go.mod h1:oGJx9dz0Ny7HC7U55RZ0Smd6N9p3hXP/+hOFtuYrAxM= 90 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 91 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 92 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 93 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 94 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 95 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 96 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 97 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 98 | github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 99 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 100 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 101 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 102 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 103 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 104 | github.com/rickb777/date v1.14.2/go.mod h1:swmf05C+hN+m8/Xh7gEq3uB6QJDNc5pQBWojKdHetOs= 105 | github.com/rickb777/plural v1.2.2/go.mod h1:xyHbelv4YvJE51gjMnHvk+U2e9zIysg6lTnSQK8XUYA= 106 | github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e h1:+/AzLkOdIXEPrAQtwAeWOBnPQ0BnYlBW0aCZmSb47u4= 107 | github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA= 108 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 109 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 110 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 111 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 112 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 113 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 114 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 115 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 116 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 117 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 118 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 119 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 120 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 121 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 122 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 123 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 124 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 125 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 126 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 127 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 128 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 129 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 130 | golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 131 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 132 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 133 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 134 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 135 | golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= 136 | golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= 137 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 138 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 139 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 140 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 141 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 142 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 143 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 144 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 145 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 146 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 147 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 148 | golang.org/x/sys v0.0.0-20200622182413-4b0db7f3f76b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 149 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 150 | golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 151 | golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 152 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 153 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 154 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 155 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 156 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 157 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 158 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 159 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 160 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 161 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 162 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 163 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 164 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 165 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 166 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 167 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= 168 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= 169 | google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE= 170 | google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= 171 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 172 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 173 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 174 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 175 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 176 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 177 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 178 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= 179 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= 180 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 181 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 182 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 183 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 184 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 185 | google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc= 186 | google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= 187 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 188 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 189 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 190 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 191 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 192 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 193 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 194 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 195 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 196 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 197 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 198 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 199 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 200 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 201 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 202 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 203 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 204 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 205 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 206 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 207 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 208 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 209 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 210 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 211 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 212 | -------------------------------------------------------------------------------- /cli/commands/write/write.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package write implements the write subcommand for provisioning an installer. 16 | package write 17 | 18 | import ( 19 | "context" 20 | "errors" 21 | "fmt" 22 | "os" 23 | "path/filepath" 24 | "runtime" 25 | "strings" 26 | 27 | "flag" 28 | "github.com/google/fresnel/cli/config" 29 | "github.com/google/fresnel/cli/console" 30 | "github.com/google/fresnel/cli/installer" 31 | "github.com/google/deck/backends/logger" 32 | "github.com/google/deck" 33 | "github.com/google/subcommands" 34 | "github.com/google/winops/storage" 35 | ) 36 | 37 | const ( 38 | oneGB int = 1073741824 // Represents one GB of data. 39 | minSize int = 2 // The default minimum size for available storage. 40 | ) 41 | 42 | var ( 43 | binaryName string 44 | 45 | // Wrapped errors for testing. 46 | errConfig = errors.New("config error") 47 | errDevice = errors.New("device error") 48 | errInstaller = errors.New("installer error") 49 | errElevation = errors.New("elevation error") 50 | errFinalize = errors.New("finalize error") 51 | errPrepare = errors.New("prepare error") 52 | errProvision = errors.New("provision error") 53 | errRetrieve = errors.New("retrieve error") 54 | errSearch = errors.New("search error") 55 | 56 | // Dependency Injections for testing 57 | execute = run 58 | search = storageSearch 59 | newInstaller = installerNew 60 | funcUSBPermissions = config.HasWritePermissions 61 | ) 62 | 63 | func init() { 64 | binaryName = filepath.Base(strings.ReplaceAll(os.Args[0], `.exe`, ``)) 65 | 66 | // write registers several aliases to allow simper interactions at the 67 | // command line. An alternate name and a set of default values is provided 68 | // for each alias. For example, write can be registered under the name 69 | // 'linux' or 'windows' with a default value in the distro field to eliminate 70 | // the need to provide a value for any flags at the command line. A default 71 | // subcommand of write with no defaults is always provided. 72 | // 73 | // e.g. 'image-writer windows sdc' instead of 'image-writer write -distro=windows sdc'. 74 | subcommands.Register(&writeCmd{name: "write"}, "") 75 | subcommands.Register(&writeCmd{name: "update", distro: "windows", track: "stable", update: true}, "") 76 | subcommands.Register(&writeCmd{name: "windows", distro: "windows", track: "stable"}, "") 77 | subcommands.Register(&writeCmd{name: "windowsdev", distro: "windowsdev", track: "stable"}, "") 78 | subcommands.Register(&writeCmd{name: "windowsffu", distro: "windowsffu", track: "stable", ffu: true}, "") 79 | } 80 | 81 | // writeCmd is the write subcommand to download and write the installer image 82 | // to available storage devices. 83 | type writeCmd struct { 84 | // name is the name of the write command. 85 | name string 86 | 87 | // allDevices determines whether the image is written to all suitable 88 | // devices. If true, specified devices are not used. 89 | allDrives bool 90 | 91 | // cleanup determines whether temporary files generated during provisioning 92 | // are cleaned up after provisioning. Defaults to true. 93 | cleanup bool 94 | 95 | // dismount determines whether devices are dismounted after provisioning 96 | // to limit accidental writes afterwords. The default value is specified 97 | // when initializing the subcommand. 98 | dismount bool 99 | 100 | // distro specifies the OS distribution to be provisioned onto selected 101 | // devices. The available values are determined by the config package. 102 | // The write subcommand can be initialized with a default value present 103 | // in distro to eliminate the need to pass the distro flag when running 104 | // the command. A distro specifies what track values are available. 105 | distro string 106 | 107 | // ffu determines whether split ffu files are placed on the bootable media 108 | // after provisioning is complete. Defaults to false. 109 | ffu bool 110 | 111 | // track specifies the distribution track or variant of the image distribution 112 | // to be provisioned. 113 | // Examples: 'stable', 'testing', 'unstable', 'test'. 114 | track string 115 | 116 | // conftrack specifies the distribution track or variant of the configuration file 117 | // to be provisioned. 118 | // Examples: 'stable', 'testing', 'unstable', 'test'. 119 | confTrack string 120 | 121 | // seedServer permits overriding the default server used to obtain a seed 122 | // for distributions that require them. If the chosen distribution does not 123 | // require a seed, this setting is ignored. The default value is specified 124 | // in the configuration for the distribution. 125 | seedServer string 126 | 127 | // warning provides a confirmation prompt before devices are overwritten. It 128 | // defaults to true. Warnings are automatically skipped when all devices 129 | // already have an installer, as no data loss is possible. 130 | warning bool 131 | 132 | // eject powers off and ejects a device after writing the image. The default 133 | // value is specified when the subcommand is initialized. 134 | eject bool 135 | 136 | // update signals write to perform an erase and refresh instead of a full 137 | // wipe and write of contents. Update automatically detects the distribution 138 | // type. It is typically used by non-admin users. 139 | update bool 140 | 141 | // info causes console messages to be displayed with debugging information 142 | // included. 143 | info bool 144 | 145 | // v controls the level of log verbosity. It defaults to 1, and higher levels 146 | // increase the info logging that is provided. 147 | v int 148 | 149 | // verbose is a convenience control that turns log verbosity up to the 150 | // maximum. It is most often used for simplicity when troubleshooting. 151 | verbose bool 152 | 153 | // listFixed determines whether we want to consider fixed drives when 154 | // determining available devices. It is defaulted to false by flag. 155 | // If listFixed is specified, the all flag is disallowed. 156 | listFixed bool 157 | 158 | // minSize is the minimum size device to search for in GB. For convenience, 159 | // this value is defaulted to minSize. 160 | minSize int 161 | 162 | // maxSize is the largest size device to search for in GB. For convenience, 163 | // this value is set to 'no limit (0)' by default by flag. 164 | maxSize int 165 | } 166 | 167 | // Ensure writeCommand implements the subcommands.Command interface. 168 | var _ subcommands.Command = (*writeCmd)(nil) 169 | 170 | // Name returns the name of the subcommand. 171 | func (c *writeCmd) Name() string { 172 | return c.name 173 | } 174 | 175 | // Synopsis returns a short string (less than one line) describing the subcommand. 176 | func (c *writeCmd) Synopsis() string { 177 | d := c.distro 178 | if d == "" { 179 | d = "specific (see -distro flag)" 180 | } 181 | return fmt.Sprintf("Provision a %s %s installer to devices", d, c.track) 182 | } 183 | 184 | // Usage returns a long string explaining the subcommand and giving usage information. 185 | func (c *writeCmd) Usage() string { 186 | return fmt.Sprintf(`%s [flags...] [device(s)...] 187 | 188 | Download and provision an image (installer) to one or more storage devices. 189 | This operation requires elevated permissions such as 'sudo' on Linux/Mac or 190 | 'run as administrator' on Windows. 191 | 192 | Flags: 193 | --all - Provision all suitable devices that are attached to this system. 194 | --a - Alias for --all 195 | --cleanup - Cleanup temporary files after provisioning completes. 196 | --dismount - Dismount devices after provisioning completes. 197 | --eject - Eject/PowerOff devices after provisioning completes. 198 | --ffu - Place the split ffu files on the media after provisioning completes. 199 | --warning - Display a confirmation prompt before non-installers are overwritten. 200 | --distro - The os distribution to be provisioned, typically 'windows' or 'linux' 201 | --track - The track (variant) of the installer to provision. 202 | --conf_track - The track (variant) of the configuration to provision. 203 | --update - Attempts to perform a device refresh only (for non-admin users). 204 | --info - Display console messages with debugging information included. 205 | --verbose - Increase info log verbosity to maximum, used as an alias for '--v 5'. 206 | --v - Controls the level of info log verbosity. 207 | 208 | --show_fixed - Includes fixed disks when searching for suitable devices. 209 | --minimum [int] - The minimum size in GB to consider when searching. 210 | --maximum [int] - The maximum size in GB to consider when searching. 211 | 212 | Use the 'list' command to list available devices or use the '--all' flag to 213 | write to all suitable devices. 214 | 215 | Example #1 (Linux): 'provision a windows installer on storage devices sdy and sdz' 216 | - '%s windows sdy sdz' 217 | 218 | Example #2 (Windows): 'provision a windows installer on storage device 1' 219 | - '%s windows 1' 220 | 221 | Example #3 (Windows) 'provision a windows installer on all storage devices' 222 | - '%s windows -all' 223 | 224 | Example #4 (Mac) 'provision a windows test image on storage device disk1' 225 | - '%s write -distro=windows -track=test disk1' 226 | 227 | Example #5 (Windows) 'update a windows installer on storage device disk1' 228 | - '%s update 1' 229 | 230 | Defaults: 231 | `, c.name, binaryName, binaryName, binaryName, binaryName, binaryName) 232 | } 233 | 234 | // SetFlags adds the flags for this command to the specified set. 235 | func (c *writeCmd) SetFlags(f *flag.FlagSet) { 236 | f.BoolVar(&c.allDrives, "all", false, "write the installer to all suitable storage devices") 237 | f.BoolVar(&c.allDrives, "a", false, "write the installer to all suitable flash drives (shorthand)") 238 | f.BoolVar(&c.cleanup, "cleanup", true, "cleanup temporary files after provisioning is complete") 239 | f.BoolVar(&c.eject, "eject", c.eject, "eject/power-off devices after provisioning is complete") 240 | f.BoolVar(&c.ffu, "ffu", c.ffu, "place the split ffu files onto storage devices after initial provisioning") 241 | f.BoolVar(&c.warning, "warning", true, "display a confirmation prompt before non-installer storage devices are overwritten") 242 | f.BoolVar(&c.update, "update", c.update, "attempts to perform a device refresh only for non-admin users") 243 | f.StringVar(&c.distro, "distro", c.distro, "the os distribution to be provisioned, typically 'windows' or 'linux'") 244 | f.StringVar(&c.track, "track", c.track, "track (variant) of the installer to provision") 245 | f.StringVar(&c.confTrack, "conf_track", c.track, "track (variant) of the configuration file to provision, only valid with FFU based distros") 246 | f.StringVar(&c.seedServer, "seed_server", "", "override the default server to use for obtaining seeds, only used for debugging") 247 | f.BoolVar(&c.info, "info", false, "display console messages with debugging information included") 248 | f.IntVar(&c.v, "v", 1, "controls the level of info log verbosity") 249 | f.BoolVar(&c.verbose, "verbose", false, "increase info log verbosity to maximum, alias for '-v 5'") 250 | // Search related flags. 251 | f.BoolVar(&c.listFixed, "show_fixed", false, "also consider fixed drives, cannot be combined with --all") 252 | f.IntVar(&c.minSize, "minimum", minSize, "minimum size [in GB] of drives to consider as available") 253 | f.IntVar(&c.maxSize, "maximum", 0, "maximum size [in GB] drives to consider as available") 254 | 255 | // Special case flag handling. 256 | 257 | // The dismount flag is always defaulted to true on Linux as we don't want 258 | // to leave temp mount folders behind. On Windows and Darwin it is generally 259 | // expected that the device remain available post-provioning. 260 | d := c.dismount 261 | if runtime.GOOS == "linux" { 262 | d = true 263 | } 264 | f.BoolVar(&c.dismount, "dismount", d, "dismount devices after provisioning is complete") 265 | } 266 | 267 | // imageInstaller represents installer.Installer. 268 | type imageInstaller interface { 269 | Cache() string 270 | Finalize([]installer.Device, bool) error 271 | Retrieve() error 272 | Prepare(installer.Device) error 273 | Provision(installer.Device) error 274 | } 275 | 276 | // Execute executes the command and returns an ExitStatus. 277 | func (c *writeCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) (exitStatus subcommands.ExitStatus) { 278 | // Enable turning verbosity up past log.V(1) for the cli with a single bool 279 | // flag to retain flag equivalence with similar tooling on Windows. To avoid 280 | // excessive verbosity, V is only increased for local libraries. 281 | if c.verbose { 282 | c.v = 5 283 | } 284 | if c.info || c.v > 1 { 285 | console.Verbose = true 286 | } 287 | 288 | if console.Verbose { 289 | deck.Add(logger.Init(os.Stdout, 0)) 290 | } 291 | 292 | // Verbosity will need to be a flag in main 293 | deck.SetVerbosity(c.v) 294 | 295 | // Log startup for upstream consumption by dashboards. 296 | deck.InfofA("%s is initializing.\n", binaryName).With(deck.V(1)).Go() 297 | 298 | // Check if any devices were specified. 299 | if f.NArg() == 0 && !c.allDrives { 300 | console.Printf("No devices were specified.\n"+ 301 | "Use the 'list' command to list available devices or use the '--all' flag to write to all suitable devices.\n"+ 302 | "usage: %s %s\n", os.Args[0], c.Usage()) 303 | return subcommands.ExitUsageError 304 | } 305 | 306 | // Setting both all and listFixed is not allowed to protect users from 307 | // unintentional wiping of their fixed (os) disks. 308 | if c.allDrives && c.listFixed { 309 | console.Print("Only one of '--all' or '--show_fixed' is allowed.") 310 | deck.Errorln("Only one of '--all' or '--show_fixed' is allowed.") 311 | return subcommands.ExitFailure 312 | } 313 | 314 | // FFU images are the only ones that use confTrack. Default confTrack = track for reusability. 315 | if !c.ffu && c.confTrack != "" { 316 | deck.InfofA("Ignoring confTrack flag %q, as this is only used for windowsffu", c.confTrack).With(deck.V(1)).Go() 317 | c.confTrack = "" 318 | } 319 | 320 | // We now know we have a valid list of devices to provision, and we can 321 | // begin provisioning. 322 | if err := execute(c, f); err != nil { 323 | console.Printf("%s completed with errors: %v", binaryName, err) 324 | deck.Errorf("%s completed with errors: %v", binaryName, err) 325 | return subcommands.ExitFailure 326 | } 327 | 328 | // Log completion for upstream consumption by dashboards. 329 | console.Printf("%s completed successfully.", binaryName) 330 | deck.InfofA("%s completed successfully.", binaryName).With(deck.V(1)).Go() 331 | return subcommands.ExitSuccess 332 | } 333 | 334 | func run(c *writeCmd, f *flag.FlagSet) (err error) { 335 | if err := funcUSBPermissions(); err != nil { 336 | if errors.Is(err, config.ErrWritePerms) { 337 | console.Print(err) 338 | } 339 | deck.Warning(err) 340 | return config.ErrUSBwriteAccess 341 | } 342 | // Generate a writer configuration. 343 | conf, err := config.New(c.cleanup, c.warning, c.eject, c.ffu, c.update, f.Args(), c.distro, c.track, c.confTrack, c.seedServer) 344 | if err != nil { 345 | return fmt.Errorf("%w: config.New(cleanup: %t, warning: %t, eject: %t, ffu: %t, devices: %v, distro: %s, track: %s, seedServer: %s) returned %v", 346 | errConfig, c.cleanup, c.warning, c.eject, c.ffu, f.Args(), c.distro, c.track, c.seedServer, err) 347 | } 348 | // Write requires elevated permissions, Update does not. 349 | if !c.update && !conf.Elevated() { 350 | return fmt.Errorf("%w: elevated permissions are required to use the %q command, try again using 'sudo' (Linux/Mac) or 'run as administrator' (Windows)", errElevation, c.name) 351 | } 352 | 353 | // Pull a list of suitable devices. 354 | console.Printf("Searching for available devices... ") 355 | deck.InfofA("Searching for available devices... ").With(deck.V(1)).Go() 356 | available, err := search("", uint64(c.minSize*oneGB), uint64(c.maxSize*oneGB), !c.listFixed) 357 | if err != nil { 358 | return fmt.Errorf("%w: %v", errSearch, err) 359 | } 360 | 361 | // If the --all flag was specified, update the target list. 362 | if c.allDrives { 363 | // Build a list of the device names. 364 | all := []string{} 365 | for _, d := range available { 366 | all = append(all, d.Identifier()) 367 | } 368 | conf.UpdateDevices(all) 369 | } 370 | 371 | // Build a simple map of available devices for lookups. 372 | verified := make(map[string]installer.Device) 373 | for _, d := range available { 374 | verified[d.Identifier()] = d 375 | } 376 | // Check if the requested devices are available and build a list of targets. 377 | targets := []installer.Device{} 378 | for _, t := range conf.Devices() { 379 | d, ok := verified[t] 380 | if !ok { 381 | return fmt.Errorf("%w: requested device %q is not suitable for provisioning, available devices %v", errDevice, t, verified) 382 | } 383 | targets = append(targets, d) 384 | } 385 | 386 | deck.InfofA("Configuration to be applied:\n%s", conf).With(deck.V(3)).Go() 387 | // Adjust wording based on whether or not we're doing an update. 388 | writeType := "provisioned" 389 | if c.update { 390 | writeType = "updated" 391 | } 392 | console.Printf("The following devices will be %s with the latest %s [%s] installer:\n", writeType, conf.Distro(), conf.Track()) 393 | deck.InfofA("Devices %v will be %s with the latest %s [%s] installer.\n", writeType, conf.Devices(), conf.Distro(), conf.Track()).With(deck.V(2)).Go() 394 | 395 | // Wrap targets in the interface required for the prompt. 396 | devices := []console.TargetDevice{} 397 | for _, device := range targets { 398 | devices = append(devices, device) 399 | } 400 | // Display information about the device(s) and warn the user. 401 | console.PrintDevices(devices, os.Stdout, false) 402 | if conf.Warning() { 403 | if err := console.PromptUser(); err != nil { 404 | return fmt.Errorf("console.PromptUser() returned %v", err) 405 | } 406 | } 407 | 408 | // Initialize the installer. 409 | i, err := newInstaller(conf) 410 | if err != nil { 411 | return fmt.Errorf("%w: installer.New() returned %v", errInstaller, err) 412 | } 413 | 414 | // Defer dismounts, power-off, and cleanup. Finalize only performs these 415 | // actions if configuration states to do so. Cleanup is performed only after 416 | // the last device has been finalized. 417 | defer func(devices []installer.Device) { 418 | if err2 := i.Finalize(devices, c.dismount); err2 != nil { 419 | if err == nil { 420 | err = fmt.Errorf("%w: Finalize() returned %v", errFinalize, err2) 421 | } else { 422 | err = fmt.Errorf("%w: %v\nFinalize() returned %v", errFinalize, err, err2) 423 | } 424 | } 425 | }(targets) 426 | 427 | // Retrieve the image. This step occurs only once for n>0 devices. 428 | console.Printf("\nRetrieving image...\n %s ->\n %s", conf.ImagePath(), i.Cache()) 429 | deck.InfofA("Retrieving image...\n %s ->\n %s\n\n", conf.ImagePath(), i.Cache()).With(deck.V(1)).Go() 430 | if err := i.Retrieve(); err != nil { 431 | return fmt.Errorf("%w: Retrieve() returned %v", errRetrieve, err) 432 | } 433 | // Prepare and provision devices. This step occurs once per device. 434 | for _, device := range targets { 435 | console.Printf("\nPreparing device %q...", device.FriendlyName()) 436 | deck.InfofA("Preparing device %q...", device.FriendlyName()).With(deck.V(1)).Go() 437 | // Prepare the device. 438 | if err := i.Prepare(device); err != nil { 439 | return fmt.Errorf("%w: Prepare(%q) returned %v: ", errPrepare, device.FriendlyName(), err) 440 | } 441 | console.Printf("Provisioning device %q...", device.FriendlyName()) 442 | deck.InfofA("Provisioning device %q...", device.FriendlyName()).With(deck.V(1)).Go() 443 | // Provision the device. 444 | if err := i.Provision(device); err != nil { 445 | return fmt.Errorf("%w: Provision(%q) returned %v", errProvision, device.FriendlyName(), err) 446 | } 447 | } 448 | return nil 449 | } 450 | 451 | // storageSearch wraps storage.Search and returns an appropriate interface. 452 | func storageSearch(deviceID string, minSize, maxSize uint64, removableOnly bool) ([]installer.Device, error) { 453 | devices, err := storage.Search(deviceID, minSize, maxSize, removableOnly) 454 | if err != nil { 455 | return nil, fmt.Errorf("storage.Search(%s, %d, %d, %t) returned %v", deviceID, minSize, maxSize, removableOnly, err) 456 | } 457 | // Wrap storage.Device in installer.Device 458 | results := []installer.Device{} 459 | for _, d := range devices { 460 | results = append(results, d) 461 | } 462 | return results, nil 463 | } 464 | 465 | // installerNew wraps installer.New and returns an appropriate interface. 466 | func installerNew(config installer.Configuration) (imageInstaller, error) { 467 | return installer.New(config) 468 | } 469 | -------------------------------------------------------------------------------- /cli/installer/installer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package installer provides a uniform, cross-platform implementation 16 | // for handling OS installer provisioning for supported target platforms. 17 | package installer 18 | 19 | import ( 20 | "bytes" 21 | "crypto/sha256" 22 | "encoding/hex" 23 | "encoding/json" 24 | "errors" 25 | "fmt" 26 | "io" 27 | "io/ioutil" 28 | "net/http" 29 | "os" 30 | "os/user" 31 | "path/filepath" 32 | "regexp" 33 | "runtime" 34 | "strings" 35 | 36 | "github.com/google/fresnel/cli/console" 37 | "github.com/google/fresnel/models" 38 | "github.com/google/deck" 39 | "github.com/dustin/go-humanize" 40 | "github.com/google/winops/iso" 41 | "github.com/google/winops/storage" 42 | 43 | fetcher "github.com/google/splice/cli/appclient" 44 | ) 45 | 46 | const ( 47 | oneGB = uint64(1073741824) 48 | seedDestFile = `seed.json` 49 | confDestFile = `startimage.yaml` 50 | ) 51 | 52 | var ( 53 | // Dependency injections for testing. 54 | currentUser = user.Current 55 | connect = fetcherConnect 56 | connectWithCert = tlsConnect 57 | downloadFile = download 58 | mount = mountISO 59 | selectPart = selectPartition 60 | writeISOFunc = writeISO 61 | 62 | // Wrapped errors for testing. 63 | errCache = errors.New("missing cache") 64 | errConfig = errors.New("invalid config") 65 | errConfName = errors.New("missing configuration file name") 66 | errConfPath = errors.New("missing configuration file path") 67 | errConnect = errors.New("connect error") 68 | errDownload = errors.New("download error") 69 | errDevice = errors.New("device error") 70 | errElevation = errors.New("elevation is required for this operation") 71 | errEmpty = errors.New("iso is empty") 72 | errEmptyUser = errors.New("could not determine username") 73 | errFile = errors.New("file error") 74 | errFinalize = errors.New("finalize error") 75 | errFormat = errors.New("format error") 76 | errImage = errors.New("image download error") 77 | errInput = errors.New("input error") 78 | errIO = errors.New("io error") 79 | errManifest = errors.New("manifest error") 80 | errMount = errors.New("mount error") 81 | errNotEmpty = errors.New("device not empty") 82 | errPartition = errors.New("partitioning error") 83 | errPath = errors.New("path error") 84 | errPerm = errors.New("permissions error") 85 | errPost = errors.New("http post error") 86 | errPrepare = errors.New("preparation error") 87 | errProvision = errors.New("provisioning error") 88 | errRename = errors.New("file rename error") 89 | errResponse = errors.New("requested boot image is not in allowlist") 90 | errStatus = errors.New("invalid status code") 91 | errSeed = errors.New("invalid seed response") 92 | errUnmarshal = errors.New("unmarshalling error") 93 | errUnsupported = errors.New("unsupported") 94 | errUser = errors.New("user detection error") 95 | errWipe = errors.New("device wipe error") 96 | errYAML = errors.New("yaml retrieval error") 97 | 98 | // ErrLabel is made public to that callers can warn on mismatches. 99 | ErrLabel = errors.New(`label error`) 100 | 101 | // Regex for file matching. 102 | regExFileExt = regexp.MustCompile(`\.[A-Za-z.]+`) 103 | regExFileName = regexp.MustCompile(`[\w,\s-]+\.[A-Za-z.]+$`) 104 | ) 105 | 106 | // httpDoer represents an http client that can retrieve files with the Do 107 | // method. 108 | type httpDoer interface { 109 | Do(*http.Request) (*http.Response, error) 110 | } 111 | 112 | // Configuration represents config.Configuration. 113 | type Configuration interface { 114 | ConfFile() string 115 | DistroLabel() string 116 | ImagePath() string 117 | ImageFile() string 118 | Elevated() bool 119 | FFU() bool 120 | PowerOff() bool 121 | SeedDest() string 122 | SeedFile() string 123 | SeedServer() string 124 | UpdateOnly() bool 125 | FFUConfFile() string 126 | FFUConfPath() string 127 | } 128 | 129 | // Device represents storage.Device. 130 | type Device interface { 131 | Dismount() error 132 | Eject() error 133 | FriendlyName() string 134 | Identifier() string 135 | Partition(string) error 136 | DetectPartitions(bool) error 137 | SelectPartition(uint64, storage.FileSystem) (*storage.Partition, error) 138 | Size() uint64 139 | Wipe() error 140 | } 141 | 142 | // partition represents storage.Partition. 143 | type partition interface { 144 | Contents() ([]string, error) 145 | Erase() error 146 | Format(string) error 147 | Identifier() string 148 | Label() string 149 | Mount(string) error 150 | MountPoint() string 151 | } 152 | 153 | // isoHandler represents iso.Handler. 154 | type isoHandler interface { 155 | Contents() []string 156 | Copy(string) error 157 | Dismount() error 158 | ImagePath() string 159 | MountPath() string 160 | Size() uint64 161 | } 162 | 163 | // Installer represents an operating system installer. 164 | type Installer struct { 165 | cache string // The path where temporary files are cached. 166 | config Configuration // The configuration for this installer. 167 | } 168 | 169 | // New generates a new Installer from a configuration, with all the 170 | // information needed to provision the installer on an available device. 171 | func New(config Configuration) (*Installer, error) { 172 | if config == nil { 173 | return nil, errConfig 174 | } 175 | 176 | // Connect serves only to give an early warning if the SSO token is expired. 177 | // It is only called if the config specifies that a seed is required. 178 | if config.SeedServer() != "" { 179 | if _, err := connect(config.ImagePath(), ""); err != nil { 180 | return nil, fmt.Errorf("fetcher.Connect(%q) returned %v: %w", config.ImagePath(), err, errConnect) 181 | } 182 | } 183 | 184 | // Create a folder for temporary files. We do not need to worry about 185 | // cleaning up this folder as this is explicitly handled as part of 186 | // Finalize. 187 | temp, err := ioutil.TempDir("", "installer_") 188 | if err != nil { 189 | return nil, fmt.Errorf("ioutil.TempDir() returned: %v", err) 190 | } 191 | 192 | return &Installer{ 193 | cache: temp, 194 | config: config, 195 | }, nil 196 | } 197 | 198 | // fetcherConnect wraps fetcher.Connect and returns an httpDoer. 199 | func fetcherConnect(path, user string) (httpDoer, error) { 200 | return fetcher.Connect(path, user) 201 | } 202 | 203 | // tlsConnect wraps fetcher.TLSClient and returns an httpDoer. 204 | func tlsConnect() (httpDoer, error) { 205 | return fetcher.TLSClient(nil, nil) 206 | } 207 | 208 | // username obtains the username of the user requesting the installer. If the 209 | // binary is running under sudo, the user who ran sudo is returned instead. 210 | func username() (string, error) { 211 | u, err := currentUser() 212 | if err != nil { 213 | return "", fmt.Errorf("user.Current returned %v: %w", err, errUser) 214 | } 215 | username := u.Username 216 | if username == "root" { 217 | username = os.Getenv("SUDO_USER") 218 | } 219 | if username == "" { 220 | return "", errEmptyUser 221 | } 222 | return username, nil 223 | } 224 | 225 | // retrieveFile locates and obtains the files, 226 | // placing them in the temporary directory. 227 | // Where additional metadata should be obtained or checked 228 | // (such as a signature or a seed) prior to returning. 229 | func (i *Installer) retrieveFile(fileName, filePath string) (err error) { 230 | path := filepath.Join(i.cache, fileName) 231 | f, err := os.Create(path) 232 | if err != nil { 233 | return fmt.Errorf("ioutil.TempFile(%q, %q) returned %w: %v", i.cache, fileName, errFile, err) 234 | } 235 | // Close the file on return. 236 | defer func() { 237 | if err2 := f.Close(); err2 != nil { 238 | if err != nil { 239 | err = fmt.Errorf("%w %v", err2, err) 240 | return 241 | } 242 | err = err2 243 | } 244 | }() 245 | 246 | // Connect to the download server and retrieve the file. 247 | client, err := connectWithCert() 248 | if err != nil { 249 | return fmt.Errorf("fetcher.TLSClient() returned %w: %v", errConnect, err) 250 | } 251 | return downloadFile(client, filePath, f) 252 | } 253 | 254 | // Retrieve passes the necessary parameters to retrieveFile 255 | // depending on whether or not the distribution will be FFU based. 256 | func (i *Installer) Retrieve() (err error) { 257 | // Confirm that the Installer has what we need. 258 | if i.config.ImagePath() == "" { 259 | return fmt.Errorf("%w: missing image path", errConfig) 260 | } 261 | if i.cache == "" { 262 | return errCache 263 | } 264 | 265 | // If FFU is false, retrieve only the image file. 266 | // Otherwise retrieve the image file and FFU manifest. 267 | if !i.config.FFU() { 268 | return i.retrieveFile(i.config.ImageFile(), i.config.ImagePath()) 269 | } 270 | 271 | // Check for missing conf file name. 272 | if i.config.FFUConfFile() == "" { 273 | return errConfName 274 | } 275 | 276 | // Check conf path configuration. 277 | if i.config.FFUConfPath() == "" { 278 | return errConfPath 279 | } 280 | 281 | if err := i.retrieveFile(i.config.FFUConfFile(), i.config.FFUConfPath()); err != nil { 282 | return fmt.Errorf("%w: %v", errYAML, err) 283 | } 284 | 285 | return i.retrieveFile(i.config.ImageFile(), i.config.ImagePath()) 286 | } 287 | 288 | // download obtains the installer using the provided client and writes it 289 | // to the provided io.Writer. It is aliased by downloadFile for testing 290 | // purposes. 291 | func download(client httpDoer, path string, w io.Writer) error { 292 | // Input sanity checks. 293 | if client == nil { 294 | return fmt.Errorf("empty http client: %w", errConnect) 295 | } 296 | if path == "" { 297 | return fmt.Errorf("image path was empty: %w", errInput) 298 | } 299 | if w == nil { 300 | return fmt.Errorf("no file to write to: %w", errFile) 301 | } 302 | 303 | // Obtain the file including status updates. 304 | req, err := http.NewRequest("GET", path, nil) 305 | if err != nil { 306 | return fmt.Errorf(`http.NewRequest("GET", %q, nil) returned %v`, path, err) 307 | } 308 | resp, err := client.Do(req) 309 | if err != nil { 310 | return fmt.Errorf("get for %q returned %v: %w", path, err, errDownload) 311 | } 312 | defer resp.Body.Close() 313 | if resp.StatusCode != http.StatusOK { 314 | return fmt.Errorf("%w for %q with response %d", errStatus, path, resp.StatusCode) 315 | } 316 | 317 | // Provide updates during the download. 318 | fileName := regExFileName.FindString(path) 319 | op := "\nDownload of " + fileName 320 | r := console.ProgressReader(resp.Body, op, resp.ContentLength) 321 | if _, err := io.Copy(w, r); err != nil { 322 | return fmt.Errorf("failed to write body of %q, %v: %w", path, err, errIO) 323 | } 324 | return nil 325 | } 326 | 327 | // Prepare takes a device and prepares it for provisioning. It supports 328 | // device preparation based on the source image file format. Currently, 329 | // it supports preparation for the ISO and IMG (Raw) formats. 330 | func (i *Installer) Prepare(d Device) error { 331 | // Sanity check inputs. 332 | if i.config == nil { 333 | return errConfig 334 | } 335 | if i.config.ImageFile() == "" { 336 | return fmt.Errorf("missing image: %w", errInput) 337 | } 338 | ext := regExFileExt.FindString(i.config.ImageFile()) 339 | if ext == "" { 340 | return fmt.Errorf("could not find extension for %q: %w", i.config.ImageFile(), errFile) 341 | } 342 | f, err := os.Stat(filepath.Join(i.cache, i.config.ImageFile())) 343 | if err != nil { 344 | return fmt.Errorf("%v: %w", err, errPath) 345 | } 346 | // Compensate for very small image files that can cause the wrong partition 347 | // to be selected. 348 | size := uint64(f.Size()) 349 | if size < oneGB { 350 | size = oneGB 351 | } 352 | // Prepare the devices for provisioning. 353 | switch { 354 | case ext == ".iso" && i.config.UpdateOnly(): 355 | return i.prepareForISOWithoutElevation(d, size) 356 | case ext == ".iso": 357 | return i.prepareForISOWithElevation(d, size) 358 | case ext == ".img": 359 | return i.prepareForRaw(d) 360 | } 361 | return fmt.Errorf("%q is not a supported image type: %w", ext, errProvision) 362 | } 363 | 364 | // prepareForISOWithElevation prepares a device to be provisioned with an 365 | // ISO-based image. It wipes, re-partitions and re-formats the device in order 366 | // to be prepared for file copy operations. Elevated permissions are required 367 | // in order to prepare a device in this manner. 368 | func (i *Installer) prepareForISOWithElevation(d Device, size uint64) error { 369 | deck.InfofA("Preparing %q for ISO with elevation.", d.FriendlyName()).With(deck.V(2)).Go() 370 | if !i.config.Elevated() { 371 | return errElevation 372 | } 373 | // Preparing a device for an ISO follows these steps: 374 | // Wipe -> Re-Partition -> Format 375 | deck.InfofA("Wiping %q.", d.FriendlyName()).With(deck.V(2)).Go() 376 | if err := d.Wipe(); err != nil { 377 | return fmt.Errorf("%w: Wipe() returned %v", errWipe, err) 378 | } 379 | deck.InfofA("Partitioning %q.", d.FriendlyName()).With(deck.V(2)).Go() 380 | if err := d.Partition(i.config.DistroLabel()); err != nil { 381 | return fmt.Errorf("Partition returned %v: %w", err, errPartition) 382 | } 383 | // Formatting is not needed on Darwin. 384 | if runtime.GOOS == "darwin" { 385 | return nil 386 | } 387 | deck.InfofA("Looking for a partition larger than %v on %q.", humanize.Bytes(size), d.FriendlyName()).With(deck.V(2)).Go() 388 | part, err := selectPart(d, size, "") 389 | if err != nil { 390 | return fmt.Errorf("SelectPartition(%d) returned %v: %w", size, err, errPrepare) 391 | } 392 | deck.InfofA("Formatting partition on %q and setting a label of %q.", d.FriendlyName(), i.config.DistroLabel()).With(deck.V(2)).Go() 393 | if err := part.Format(i.config.DistroLabel()); err != nil { 394 | return fmt.Errorf("Format returned %v: %w", err, errFormat) 395 | } 396 | return nil 397 | } 398 | 399 | // prepareForISOWithoutElevation prepares a device to be provisioned with an 400 | // ISO-based image. It attempts to erase the contents of the installer 401 | // partition and checks for an appropriate label. A label mismatch suggests 402 | // that the device may or may not result in a fully bootable image, and a 403 | // warning is provided to state that the operation is considered "best effort" 404 | // when there is a label mismatch. Elevated permissions are not required for 405 | // this operation. 406 | func (i *Installer) prepareForISOWithoutElevation(d Device, size uint64) error { 407 | deck.InfofA("Preparing %q for ISO without elevation.", d.FriendlyName()).With(deck.V(2)).Go() 408 | // Preparing the device for an ISO follows these steps: 409 | // Erase default partition -> Check label (warn if necessary) 410 | part, err := selectPart(d, size, storage.FAT32) 411 | if err != nil { 412 | return fmt.Errorf("SelectPartition(%d, %q) returned %v: %w", size, storage.FAT32, err, errPartition) 413 | } 414 | base := "" 415 | if runtime.GOOS != "windows" { 416 | base = i.cache 417 | } 418 | deck.InfofA("Mounting %q for erasing.", part.Identifier()).With(deck.V(2)).Go() 419 | if err := part.Mount(base); err != nil { 420 | return fmt.Errorf("Mount() for %q returned %v: %w", part.Identifier(), err, errMount) 421 | } 422 | deck.InfofA("Preparing to erase contents of %q (device: %q, partition %q).", part.Label(), d.FriendlyName(), part.Identifier()).With(deck.V(2)).Go() 423 | if err := part.Erase(); err != nil { 424 | return fmt.Errorf("%w: partition.Erase() returned %v", errWipe, err) 425 | } 426 | if !strings.Contains(part.Label(), i.config.DistroLabel()) { 427 | console.Printf("\nWarning: Selected partition %q does not have a label that contains %q. Updating devices that were not previously provisioned by this tool is a best effort service. The device may not function as expected.\n", part.Identifier(), i.config.DistroLabel()) 428 | deck.Warningf("Selected partition %q does not have a label that contains %q. Updating devices that were not previously provisioned by this tool is a best effort service. The device may not function as expected.", part.Label(), i.config.DistroLabel()) 429 | } 430 | return nil 431 | } 432 | 433 | func fileCopy(srcFile, dest, cache string, p partition) error { 434 | path := filepath.Join(cache, srcFile) 435 | newPath := filepath.Join(p.MountPoint(), dest, srcFile) 436 | // Add colon for windows paths if its a drive root. 437 | if runtime.GOOS == "windows" && len(p.MountPoint()) < 2 { 438 | newPath = filepath.Join(fmt.Sprintf("%s:", p.MountPoint()), dest, srcFile) 439 | } 440 | if err := os.MkdirAll(filepath.Dir(newPath), 0744); err != nil { 441 | return fmt.Errorf("failed to create path: %v", err) 442 | } 443 | source, err := os.Open(path) 444 | if err != nil { 445 | return fmt.Errorf("%w: couldn't open file(%s) from cache: %v", errPath, path, err) 446 | } 447 | defer source.Close() 448 | destination, err := os.Create(newPath) 449 | if err != nil { 450 | return fmt.Errorf("%w: couldn't create target file(%s): %v", errFile, path, err) 451 | } 452 | defer destination.Close() 453 | cBytes, err := io.Copy(destination, source) 454 | if err != nil { 455 | return fmt.Errorf("failed to copy file to %s: %v", newPath, err) 456 | } 457 | console.Printf("Copied %d bytes", cBytes) 458 | return nil 459 | } 460 | 461 | // selectPartition wraps device.SelectPartition and returns its output wrapped 462 | // in the partition interface. 463 | func selectPartition(d Device, size uint64, fs storage.FileSystem) (partition, error) { 464 | return d.SelectPartition(size, fs) 465 | } 466 | 467 | // prepareForRaw prepares a device to be provisioned with an raw-based image. 468 | // Raw only requires the device to be dismounted so that the operating system 469 | // can write the directly to it. Though preparation does not require elevation, 470 | // direct writes to disk always do. 471 | func (i *Installer) prepareForRaw(d Device) error { 472 | return d.Dismount() 473 | } 474 | 475 | // Provision takes a device and provisions it with the installer. It provisions 476 | // based on the source image file format. Each supported format enforces its 477 | // own requirements for the device. Provision only checks that all needed 478 | // configuration is present and that the image file has already been downloaded 479 | // to cache. 480 | func (i *Installer) Provision(d Device) error { 481 | // Sanity check inputs and configuration. Device checks are left to the 482 | // specific format based provisioning call itself. 483 | if i.config == nil { 484 | return errConfig 485 | } 486 | if i.cache == "" { 487 | return errCache 488 | } 489 | if i.config.ImageFile() == "" { 490 | return fmt.Errorf("missing image: %w", errInput) 491 | } 492 | ext := regExFileExt.FindString(i.config.ImageFile()) 493 | if ext == "" { 494 | return fmt.Errorf("could not find extension for %q: %w", i.config.ImageFile(), errFile) 495 | } 496 | // Check that the image is already in the cache. 497 | deck.InfofA("Checking %q for existence of %q.", i.cache, i.config.ImageFile()).With(deck.V(2)).Go() 498 | path := filepath.Join(i.cache, i.config.ImageFile()) 499 | if _, err := os.Stat(path); err != nil { 500 | return fmt.Errorf("os.Stat(%q) returned %v: %w", path, err, errPath) 501 | } 502 | // Check that the FFU config is already in the cache. 503 | if i.config.FFU() { 504 | deck.InfofA("Checking %q for existence of %q.", i.cache, i.config.FFUConfFile()).With(deck.V(2)).Go() 505 | path := filepath.Join(i.cache, i.config.FFUConfFile()) 506 | if _, err := os.Stat(path); err != nil { 507 | return fmt.Errorf("os.Stat(%q) returned %v: %w", path, err, errPath) 508 | } 509 | } 510 | 511 | // Provision the device. 512 | switch ext { 513 | case ".img": 514 | return fmt.Errorf("img is not a supported image type: %w", errUnsupported) 515 | case ".iso": 516 | return i.provisionISO(d) 517 | } 518 | return fmt.Errorf("%q is an unknown image type: %w", ext, errProvision) 519 | } 520 | 521 | // provisionISO provisions a device with an ISO based image. It does this by 522 | // preparing the image and mounting it, and then hands off writing to the 523 | // device. If a seedServer is configured, it is used to add a seed to the 524 | // device. 525 | func (i *Installer) provisionISO(d Device) (err error) { 526 | // Construct the path to the ISO. 527 | path := filepath.Join(i.cache, i.config.ImageFile()) 528 | // Obtain an iso.Handler by mounting the ISO. 529 | deck.InfofA("Mounting ISO at %q.", path).With(deck.V(2)).Go() 530 | handler, err := mount(path) 531 | if err != nil { 532 | return fmt.Errorf("mount(%q) returned %v: %w", path, err, errMount) 533 | } 534 | // Close the handler on return, capturing the error if there is one. 535 | defer func() { 536 | deck.InfofA("Dismounting ISO at %q.", handler.MountPath()).With(deck.V(2)).Go() 537 | if err2 := handler.Dismount(); err2 != nil { 538 | if err != nil { 539 | err = fmt.Errorf("Dismount() for %q returned %v: %w", handler.MountPath(), err, err2) 540 | return 541 | } 542 | err = err2 543 | } 544 | }() 545 | // Set a minimum partition size so that very small ISO's don't cause us to 546 | // select an EFI partition unexpectedly. 547 | minSize := handler.Size() 548 | if handler.Size() < oneGB { 549 | minSize = oneGB 550 | } 551 | // Find a compatible partition to write to and mount if necessary. 552 | deck.InfofA("Searching %q for a %q partition larger than %v.", d.FriendlyName(), humanize.Bytes(minSize), storage.FAT32).With(deck.V(2)).Go() 553 | p, err := selectPart(d, minSize, storage.FAT32) 554 | if err != nil { 555 | return fmt.Errorf("SelectPartition(%q, %q, %q) returned %v: %w", d.FriendlyName(), humanize.Bytes(minSize), storage.FAT32, err, errPartition) 556 | } 557 | // Specify the cache folder as the base mount directory for non-Windows. 558 | base := "" 559 | if runtime.GOOS != "windows" { 560 | base = i.cache 561 | } 562 | deck.InfofA("Mounting %q for writing.", p.Identifier()).With(deck.V(2)).Go() 563 | if err := p.Mount(base); err != nil { 564 | return fmt.Errorf("Mount() for %q returned %v: %w", p.Identifier(), err, errMount) 565 | } 566 | // Write the ISO. 567 | deck.InfofA("Writing ISO at %q to %q.", handler.ImagePath(), d.FriendlyName()).With(deck.V(2)).Go() 568 | if err := writeISOFunc(handler, p); err != nil { 569 | return fmt.Errorf("writeISO() returned %v: %w", err, errProvision) 570 | } 571 | 572 | // If FFU, write config to disk. 573 | if i.config.FFU() { 574 | if err := i.writeConfig(p); err != nil { 575 | return fmt.Errorf("writeConfig() returned %v", err) 576 | } 577 | } 578 | 579 | // If no seed is required, return early, otherwise, retrieve and write 580 | // the seed. 581 | if i.config.SeedServer() == "" { 582 | return nil 583 | } 584 | if err := i.writeSeed(handler, p); err != nil { 585 | return fmt.Errorf("writeSeed() returned %v", err) 586 | } 587 | return nil 588 | } 589 | 590 | // mountISO wraps the concrete iso.Mount return value in an equivalent interface. 591 | func mountISO(path string) (isoHandler, error) { 592 | return iso.Mount(path) 593 | } 594 | 595 | // writeISO takes an isoHandler and copies its contents to a partition. The 596 | // ISO is expected to be mounted and available. The contents are copied to 597 | // the device's default partition unless a destination partition has been 598 | // specified. The destination partition must be empty. 599 | func writeISO(iso isoHandler, part partition) error { 600 | // Check inputs. 601 | if part == nil { 602 | return fmt.Errorf("partition was empty: %w", errPartition) 603 | } 604 | // Validate that the partition is ready for writing. If the drive is not 605 | // mounted, attempt to mount it. 606 | if part.MountPoint() == "" { 607 | return fmt.Errorf("partition is not available: %w", errMount) 608 | } 609 | contents, err := part.Contents() 610 | if err != nil { 611 | return fmt.Errorf("Contents(%q) returned %v", part.MountPoint(), err) 612 | } 613 | // Some operating systems list the device or indexes. 614 | if len(contents) > 2 { 615 | deck.InfofA("contents of '%s(%s)'\n%v", part.Identifier(), part.Label(), contents).With(deck.V(3)).Go() 616 | return fmt.Errorf("destination partition not empty: %w", errNotEmpty) 617 | } 618 | // Validate that the ISO is ready to be copied. 619 | if iso.MountPath() == "" { 620 | return fmt.Errorf("iso not mounted: %w", errInput) 621 | } 622 | if len(iso.Contents()) < 1 { 623 | return errEmpty 624 | } 625 | deck.InfofA("iso.Copy(): src(%s) dst(%s)", iso.MountPath(), part.MountPoint()).With(deck.V(3)).Go() 626 | return iso.Copy(part.MountPoint()) 627 | } 628 | 629 | // writeSeed obtains a seed and writes it to a mounted partition. 630 | func (i *Installer) writeSeed(h isoHandler, p partition) error { 631 | // Input checks. 632 | if p.MountPoint() == "" { 633 | return fmt.Errorf("partition %q is not mounted: %w", p.Label(), errInput) 634 | } 635 | // We need to construct the path to the file to be hashed from configuration. 636 | // Then we request a seed using that hash. 637 | f := filepath.Join(h.MountPath(), i.config.SeedFile()) 638 | hash, err := fileHash(f) 639 | if err != nil { 640 | return fmt.Errorf("fileHash(%q) returned %w", err, errFile) 641 | } 642 | deck.InfofA("Hashed %q: %q.", f, hex.EncodeToString(hash)).With(deck.V(2)).Go() 643 | // Connect to the seed server and request the seed. 644 | u, err := username() 645 | if err != nil { 646 | return fmt.Errorf("username() returned %v: %w", err, errUser) 647 | } 648 | deck.InfofA("Connecting to seed endpoint as user %q: %q.", u, i.config.SeedServer()).With(deck.V(2)).Go() 649 | client, err := connect(i.config.SeedServer(), u) 650 | if err != nil { 651 | return fmt.Errorf("fetcher.Connect(%q) returned %v: %w", i.config.SeedServer(), err, errConnect) 652 | } 653 | deck.InfofA("Requesting seed from %q.", i.config.SeedServer()).With(deck.V(2)).Go() 654 | sr, err := seedRequest(client, string(hash), i.config) 655 | if err != nil { 656 | return fmt.Errorf("seedRequest returned %v: %w", err, errDownload) 657 | } 658 | seedFile := models.SeedFile{ 659 | Seed: sr.Seed, 660 | Signature: sr.Signature, 661 | } 662 | // See that the seed contents are human readable. 663 | content, err := json.MarshalIndent(seedFile, "", "") 664 | if err != nil { 665 | return fmt.Errorf("json.MarshalIndent(%v) returned: %v", seedFile, err) 666 | } 667 | deck.InfofA("Retrieved seed: %s", content).With(deck.V(3)).Go() 668 | // Determine where the seed should be written to and write it. Accommodate 669 | // for Windows not understanding drive letters vs relative paths. 670 | root := p.MountPoint() 671 | if runtime.GOOS == "windows" && !strings.Contains(root, `:`) { 672 | root = root + `:` 673 | } 674 | path := filepath.Join(root, i.config.SeedDest()) 675 | deck.InfofA("Creating seed directory: %q.", path).With(deck.V(2)).Go() 676 | // Permissions = owner:read/write/execute, group:read/execute" 677 | if err := os.MkdirAll(path, 0755); err != nil { 678 | return fmt.Errorf("os.MkdirAll(%q, 0755) returned %v: %w", path, err, errPerm) 679 | } 680 | s := filepath.Join(path, seedDestFile) 681 | deck.InfofA("Writing seed: %q.", s).With(deck.V(2)).Go() 682 | // Permissions = owner:read/write, group:read" 683 | if err := ioutil.WriteFile(s, content, 0644); err != nil { 684 | return fmt.Errorf("ioutil.WriteFile(%q) returned %v: %w", s, err, errIO) 685 | } 686 | return nil 687 | } 688 | 689 | // writeConfig writes the FFU config file to disk using SeedDest directory. 690 | func (i *Installer) writeConfig(p partition) error { 691 | source := filepath.Join(i.cache, i.config.FFUConfFile()) 692 | content, err := ioutil.ReadFile(source) 693 | if err != nil { 694 | return fmt.Errorf("ioutil.ReadFile(%q) returned %v: %w", source, err, errIO) 695 | } 696 | root := p.MountPoint() 697 | if runtime.GOOS == "windows" && !strings.Contains(root, `:`) { 698 | root = root + `:` 699 | } 700 | dest := filepath.Join(root, i.config.SeedDest()) 701 | deck.InfofA("Creating config directory: %q.", dest).With(deck.V(2)).Go() 702 | // Permissions = owner:read/write/execute, group:read/execute" 703 | if err := os.MkdirAll(dest, 0755); err != nil { 704 | return fmt.Errorf("os.MkdirAll(%q, 0755) returned %v: %w", dest, err, errPerm) 705 | } 706 | destFile := filepath.Join(dest, confDestFile) 707 | deck.InfofA("Writing config: %q.", destFile).With(deck.V(2)).Go() 708 | // Permissions = owner:read/write, group:read" 709 | if err := ioutil.WriteFile(destFile, content, 0644); err != nil { 710 | return fmt.Errorf("ioutil.WriteFile(%q) returned %v: %w", destFile, err, errIO) 711 | } 712 | return nil 713 | } 714 | 715 | // fileHash returns a the SHA-256 hash of the file at the provided path. 716 | func fileHash(path string) ([]byte, error) { 717 | if path == "" { 718 | return nil, fmt.Errorf("path was empty: %w", errInput) 719 | } 720 | f, err := os.Open(path) 721 | if err != nil { 722 | return nil, fmt.Errorf("os.Open(%q) returned %v: %w", path, err, errPath) 723 | } 724 | defer f.Close() 725 | 726 | h := sha256.New() 727 | if _, err := io.Copy(h, f); err != nil { 728 | return nil, fmt.Errorf("hashing %q returned %v: %w", f.Name(), path, errIO) 729 | } 730 | hash := h.Sum(nil) 731 | return hash, nil 732 | } 733 | 734 | // seedRequest obtains a signed seed for the installer and returns it for use. 735 | func seedRequest(client httpDoer, hash string, config Configuration) (*models.SeedResponse, error) { 736 | if hash == "" { 737 | return nil, fmt.Errorf("missing hash: %w", errInput) 738 | } 739 | // Build the request. 740 | sr := &models.SeedRequest{ 741 | Hash: []byte(hash), 742 | } 743 | reqBody, err := json.Marshal(sr) 744 | if err != nil { 745 | return nil, fmt.Errorf("could not marshal seed request(%+v): %v", sr, err) 746 | } 747 | req, err := http.NewRequest("POST", config.SeedServer(), bytes.NewReader(reqBody)) 748 | if err != nil { 749 | return nil, fmt.Errorf("error composing post request %v: %w", err, errConnect) 750 | } 751 | req.Header.Set("Content-Type", "application/json") 752 | 753 | // Post the request and obtain a response. 754 | resp, err := client.Do(req) 755 | if err != nil { 756 | return nil, fmt.Errorf("%w: %v", errPost, err) 757 | } 758 | defer resp.Body.Close() 759 | respBody, err := ioutil.ReadAll(resp.Body) 760 | if err != nil { 761 | return nil, fmt.Errorf("error reading response body: %v", err) 762 | } 763 | // If the server responded that the hash is not in the allowlist, return. 764 | if strings.Contains(fmt.Sprintf("%s", respBody), "not in allowlist") { 765 | return nil, fmt.Errorf("%w: %q", errResponse, hex.EncodeToString([]byte(hash))) 766 | } 767 | 768 | r := &models.SeedResponse{} 769 | if err := json.Unmarshal(respBody, r); err != nil { 770 | return nil, fmt.Errorf("json.Unmarhsal(%s) returned %v: %w", respBody, err, errFormat) 771 | } 772 | if r.ErrorCode != models.StatusSuccess { 773 | return nil, fmt.Errorf("%w: %v %d", errSeed, r.Status, r.ErrorCode) 774 | } 775 | return r, nil 776 | } 777 | 778 | // Finalize performs post-provisioning tasks for a device. It is meant to 779 | // be called after all provisioning tasks are completed. For example, if a set 780 | // of devices are being provisioned, it can be called at the end of the process 781 | // so that artifacts like downloaded images can be obtained just once and 782 | // re-used during Preparation and Provisioning steps. If the cache exists 783 | // it is automatically cleaned up. Optionally, the device can also be 784 | // dismounted and/or powered off during the Finalize step. 785 | func (i *Installer) Finalize(devices []Device, dismount bool) error { 786 | for _, device := range devices { 787 | if dismount { 788 | deck.InfofA("Refreshing partition information for %q prior to dismount.", device.Identifier()).With(deck.V(2)).Go() 789 | if err := device.DetectPartitions(false); err != nil { 790 | return fmt.Errorf("DetectPartitions() for %q returned %v: %w", device.Identifier(), err, errFinalize) 791 | } 792 | console.Printf("Dismounting device %q.", device.Identifier()) 793 | deck.InfofA("Dismounting device %q.", device.Identifier()).With(deck.V(2)).Go() 794 | if err := device.Dismount(); err != nil { 795 | return fmt.Errorf("Dismount(%s) returned %v: %w", device.Identifier(), err, errDevice) 796 | } 797 | } 798 | if i.config.PowerOff() { 799 | console.Printf("Ejecting device %q.", device.Identifier()) 800 | deck.InfofA("Ejecting device %q.", device.Identifier()).With(deck.V(2)).Go() 801 | if err := device.Eject(); err != nil { 802 | return fmt.Errorf("Eject(%s) returned %v: %w", device.Identifier(), err, errIO) 803 | } 804 | } 805 | } 806 | // Clean up the cache if it still exists. os.RemoveAll returns nil if the 807 | // path doesn't exist, which is convenient for us here. 808 | deck.InfofA("Cleaning up installer cache %q.", i.cache).With(deck.V(2)).Go() 809 | if err := os.RemoveAll(i.cache); err != nil { 810 | return fmt.Errorf("os.RemoveAll(%s) returned %v: %w", i.cache, err, errPath) 811 | } 812 | return nil 813 | } 814 | 815 | // Cache returns the location of the cache folder for a given installer. 816 | func (i *Installer) Cache() string { 817 | return i.cache 818 | } 819 | --------------------------------------------------------------------------------