├── .github └── workflows │ └── main.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── addresses.go ├── device_classes.go ├── error.go ├── groups.go ├── image.go ├── ks.go ├── options.go ├── pools.go ├── postconfig.go ├── users.go └── version.go ├── config.example.json ├── config └── config.go ├── crypto └── main.go ├── db └── main.go ├── dhcp.go ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── go.mod ├── go.sum ├── main.go ├── models ├── address.go ├── api.go ├── device_class.go ├── group.go ├── image.go ├── nullable_int.go ├── option.go ├── pool.go └── user.go ├── secrets └── main.go ├── serve.go ├── statik └── statik.go ├── tftpd.go ├── web ├── .browserslistrc ├── .editorconfig ├── .gitignore ├── README.md ├── angular.json ├── e2e │ ├── protractor.conf.js │ ├── src │ │ ├── app.e2e-spec.ts │ │ └── app.po.ts │ └── tsconfig.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── api.service.spec.ts │ │ ├── api.service.ts │ │ ├── app-routing.module.ts │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── help │ │ │ ├── help.component.html │ │ │ ├── help.component.scss │ │ │ ├── help.component.spec.ts │ │ │ └── help.component.ts │ │ ├── logs │ │ │ ├── logs.component.html │ │ │ ├── logs.component.scss │ │ │ ├── logs.component.spec.ts │ │ │ └── logs.component.ts │ │ ├── manage-dhcp-pools │ │ │ ├── manage-dhcp-pools.component.html │ │ │ ├── manage-dhcp-pools.component.scss │ │ │ ├── manage-dhcp-pools.component.spec.ts │ │ │ └── manage-dhcp-pools.component.ts │ │ ├── manage-groups │ │ │ ├── manage-groups.component.html │ │ │ ├── manage-groups.component.scss │ │ │ ├── manage-groups.component.spec.ts │ │ │ └── manage-groups.component.ts │ │ ├── manage-images │ │ │ ├── manage-images.component.html │ │ │ ├── manage-images.component.scss │ │ │ ├── manage-images.component.spec.ts │ │ │ └── manage-images.component.ts │ │ └── manage-users │ │ │ ├── manage-users.component.html │ │ │ ├── manage-users.component.scss │ │ │ ├── manage-users.component.spec.ts │ │ │ └── manage-users.component.ts │ ├── assets │ │ └── .gitkeep │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── polyfills.ts │ ├── styles.scss │ └── test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── tslint.json └── websockets └── main.go /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v2 15 | - 16 | name: Unshallow 17 | run: git fetch --prune --unshallow 18 | - 19 | name: Set up Go 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: 1.15 23 | - uses: azure/docker-login@v1 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | - 28 | name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@v2 30 | with: 31 | version: latest 32 | args: release --rm-dist --skip-validate 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | tftp 17 | tftp/* 18 | *.db 19 | go-via 20 | database/sqlite-database* 21 | cert 22 | cert/* 23 | secret 24 | secret/* 25 | statik 26 | statik/statik.go 27 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go get -v -d 4 | - go get -u github.com/swaggo/swag/cmd/swag 5 | - go generate ./... 6 | builds: 7 | - env: 8 | - CGO_ENABLED=1 9 | goos: 10 | - linux 11 | goarch: 12 | - amd64 13 | checksum: 14 | name_template: 'checksums.txt' 15 | snapshot: 16 | name_template: "{{ .Tag }}-next" 17 | changelog: 18 | sort: asc 19 | filters: 20 | exclude: 21 | - '^docs:' 22 | - '^test:' 23 | archives: 24 | - 25 | files: 26 | - config.example.json 27 | dockers: 28 | - ids: 29 | - go-via 30 | image_templates: 31 | - "docker.io/maxiepax/go-via:latest" 32 | build_flag_templates: 33 | - "--pull" 34 | - "--label=org.opencontainers.image.created={{.Date}}" 35 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 36 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 37 | - "--label=org.opencontainers.image.version={{.Version}}" 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16 2 | 3 | ADD go-via /usr/local/bin/go-via 4 | 5 | EXPOSE 8080 6 | 7 | ENTRYPOINT ["go-via"] 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Custom deployment tool for VMware ESXi Hypervisor 2 | ========================================= 3 | 4 | Credits 5 | ------- 6 | 7 | Massive credits go to one of my best friends, and mentor [Jonathan "stamp" G](https://www.github.com/stamp) for all the help, coaching and lessons during this project. 8 | Without your support this project would never have been a reality. 9 | 10 | VMware #clarity-ui channel for being super helpful with newbie questions about clarity! 11 | 12 | 13 | What is go-via? 14 | --------------- 15 | go-via is a single binary, that when executed performs the tasks of dhcpd, tftpd, httpd, and ks.cfg generator, with a angular front-end, and http-rest backend written in go, and sqlite for persisting. 16 | 17 | Why a new version of VMware Imaging Appliance? 18 | ---------------------------------------------- 19 | The old version of VIA had some things it didn't support which made it hard to run in enterprise environments. go-via brings added support for the following. 20 | 1. IP-Helper , you can have the go-via binary running on any network you want and use [RFC 3046 IP-Helper](https://tools.ietf.org/html/rfc3046) to relay DHCP requests to the server. 21 | 2. UEFI , go-via does not support BIOS, but does support UEFI and secure-boot. BIOS may be added in the future. 22 | 3. Virtual environments, it does not block nested esxi host deployment. 23 | 4. HTTP-REST, everything you can do in the UI, you can do via automation also. 24 | 5. Options to perform all prerequisites for VMware Cloud Foundation 4.x/5.x 25 | 26 | Supported Architectures 27 | ----------------------- 28 | UEFI x86_64 INTEL/AMD architecture 29 | UEFI arm_64 ARM architecture (including Project Monterey/SmartNICs) 30 | 31 | Default username / password / port 32 | ---------------------- 33 | username: admin
34 | password: VMware1!
35 | port: 8443
36 | 37 | Installation / Running 38 | ---------------------- 39 |

Option 1: docker container

40 | To run this container on a ubuntu 21.04 server, do the following:
41 | 42 | install docker-ce (https://docs.docker.com/engine/install/ubuntu/) 43 | ``` bash 44 | curl -fsSL https://get.docker.com -o get-docker.sh 45 | sudo sh get-docker.sh 46 | ``` 47 | 48 | install latest docker-compose, 49 | ``` bash 50 | sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/bin/docker-compose 51 | sudo chmod +x /usr/bin/docker-compose 52 | ``` 53 | 54 | Option A: create the following docker-compose.yaml file to not specify a config file (dhcpd will serve on all interfaces) 55 | ``` yaml 56 | version: "3.9" 57 | services: 58 | go-via: 59 | image: maxiepax/go-via:latest 60 | network_mode: host 61 | volumes: 62 | - ./tftp:/go/tftp 63 | - ./database:/go/database 64 | - ./config:/go/config 65 | - ./cert:/go/cert 66 | - ./secret:/go/secret 67 | 68 | ``` 69 | 70 | Option B: or create this docker-compose.yaml to specify a config file, and place config in ./config/config.json 71 | ``` yaml 72 | version: "3.9" 73 | services: 74 | go-via: 75 | image: maxiepax/go-via:latest 76 | network_mode: host 77 | volumes: 78 | - ./tftp:/go/tftp 79 | - ./database:/go/database 80 | - ./config:/go/config 81 | - ./cert:/go/cert 82 | - ./secret:/go/secret 83 | command: -file /go/config/config.json 84 | 85 | ``` 86 | Example config file 87 | ``` json 88 | { 89 | "network": { 90 | "interfaces": ["ens224", "ens192"] 91 | }, 92 | "port": 443 93 | } 94 | ``` 95 | 96 | now start the container 97 | 98 | ``` bash 99 | sudo docker-compose up -d 100 | ``` 101 | 102 |

Option 2: Download the latest release, and run ./go-via -file config.json

103 | 104 | Most linux distributions should work, this has been tested on Ubuntu 20.20. 105 | 106 | ``` bash 107 | #wget the release you want to download, e.g go-via_._linux_amd64.tar.gz 108 | wget https://github.com/maxiepax/go-via/releases/download//go-via_._linux_amd64.tar.gz 109 | 110 | 111 | #untar/extract it 112 | tar -zxvf go-via_._linux_amd64.tar.gz 113 | ``` 114 | This will extract the files README.MD (this document) and go-via binary. 115 | 116 | Optional: example config files. 117 | 118 | Multi interface, and custom port. 119 | ``` json 120 | { 121 | "network": { 122 | "interfaces": ["ens224", "ens192"] 123 | }, 124 | "port": 443 125 | } 126 | ``` 127 | Single interface, default port 8443 128 | ``` json 129 | { 130 | "network": { 131 | "interfaces": ["ens224"] 132 | } 133 | } 134 | ``` 135 | 136 | Now start the binary as super user, (optionally: pointing to the config file.) 137 | ``` bash 138 | #start the application with default settings 139 | sudo ./go-via 140 | 141 | #start the application with normal debug level 142 | sudo ./go-via -file config.json 143 | 144 | #start the application with verbose debug level 145 | sudo ./go-via -file config.json -debug 146 | ``` 147 | 148 | Example systemd.service config file 149 | ``` 150 | [Unit] 151 | Description=go-via 152 | After=network.target 153 | 154 | [Service] 155 | Type=simple 156 | Restart=always 157 | RestartSec=1 158 | User=root 159 | ExecStart=/home/govia/go-via 160 | WorkingDirectory=/home/govia/go-via 161 | 162 | [Install] 163 | WantedBy=multi-user.target 164 | ``` 165 | 166 | You should be greeted with the following output. 167 | ``` bash 168 | INFO[0000] Startup commit=none date=unknown version=dev 169 | WARN[0000] no interfaces have been configured, trying to find interfaces to serve to, will serve on all. 170 | INFO[0000] Existing database sqlite-database.db found 171 | INFO[0000] Starting dhcp server int=ens224 ip=172.16.100.1 mac="00:0c:29:91:cf:eb" 172 | INFO[0000] Starting dhcp server int=ens192 ip=192.168.1.173 mac="00:0c:29:91:cf:e1" 173 | INFO[0000] Starting dhcp server int=docker0 ip=172.17.0.1 mac="02:42:09:9f:04:4f" 174 | INFO[0000] cert server.crt="server.crt found" 175 | INFO[0000] Webserver port=":8443" 176 | ``` 177 | 178 |

Option 3: Download source and compile with go 1.16 and Angular 11

179 | 180 | with Ubuntu 20.20 installed, do the following: 181 | install golang 1.16.x compiler 182 | ``` bash 183 | sudo snap install go --classic 184 | ``` 185 | install npm 186 | ``` bash 187 | sudo apt-get install npm 188 | ``` 189 | install angular-cli 190 | ``` bash 191 | sudo npm install npm@latest -g 192 | sudo npm install -g @angular/cli 193 | ``` 194 | start two terminals: 195 | 196 | terminal 1: 197 | ``` bash 198 | mkdir ~/go 199 | cd ~/go 200 | git clone https://github.com/maxiepax/go-via.git 201 | cd go-via 202 | go run *.go 203 | ``` 204 | 205 | terminal 2: 206 | ``` bash 207 | cd ~/go-via/web 208 | npm install 209 | # to only allow localhost access to gui: 210 | ng serve 211 | # to allow anyone access to gui: 212 | ng serve --host 0.0.0.0 213 | ``` 214 | 215 | Troubleshooting 216 | --------------- 217 | To troubleshoot, enable debugging. 218 | 219 | Option 1: Docker Container. Append -debug to command. 220 | ``` bash 221 | command: -debug 222 | or 223 | command: -file /go/config/config.json -debug 224 | ``` 225 | 226 | Option 2: Source or Binary. Append -debug to command 227 | ``` bash 228 | ./go-via -debug 229 | ``` 230 | 231 | Known issues 232 | ------------ 233 | Please note that go-via is still under heavy development, and there may be bugs. Following is the list of known issues. 234 | 235 | currently tracking no known issues! :D 236 | 237 | Todo 238 | ----- 239 | - Currently no requests have been made for features. Please submit any ideas you have. 240 | -------------------------------------------------------------------------------- /api/addresses.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/imdario/mergo" 13 | "github.com/maxiepax/go-via/db" 14 | "github.com/maxiepax/go-via/models" 15 | "github.com/sirupsen/logrus" 16 | "gorm.io/gorm" 17 | ) 18 | 19 | // ListAddresses Get a list of all addresses 20 | // @Summary Get all addresses 21 | // @Tags addresses 22 | // @Accept json 23 | // @Produce json 24 | // @Success 200 {array} models.Address 25 | // @Failure 500 {object} models.APIError 26 | // @Router /addresses [get] 27 | func ListAddresses(c *gin.Context) { 28 | var items []models.Address 29 | if res := db.DB.Preload("Pool").Find(&items); res.Error != nil { 30 | Error(c, http.StatusInternalServerError, res.Error) // 500 31 | return 32 | } 33 | c.JSON(http.StatusOK, items) // 200 34 | } 35 | 36 | // GetAddress Get an existing address 37 | // @Summary Get an existing address 38 | // @Tags addresses 39 | // @Accept json 40 | // @Produce json 41 | // @Param id path int true "Address ID" 42 | // @Success 200 {object} models.Address 43 | // @Failure 400 {object} models.APIError 44 | // @Failure 404 {object} models.APIError 45 | // @Failure 500 {object} models.APIError 46 | // @Router /addresses/{id} [get] 47 | func GetAddress(c *gin.Context) { 48 | id, err := strconv.Atoi(c.Param("id")) 49 | if err != nil { 50 | Error(c, http.StatusBadRequest, err) // 400 51 | return 52 | } 53 | 54 | // Load the item 55 | var item models.Address 56 | if res := db.DB.Preload("Pool").First(&item, id); res.Error != nil { 57 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 58 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 59 | } else { 60 | Error(c, http.StatusInternalServerError, res.Error) // 500 61 | } 62 | return 63 | } 64 | 65 | c.JSON(http.StatusOK, item) // 200 66 | } 67 | 68 | // SearchAddress Search for an address 69 | // @Summary Search for an address 70 | // @Tags addresses 71 | // @Accept json 72 | // @Produce json 73 | // @Param item body models.Address true "Fields to search for" 74 | // @Success 200 {object} models.Address 75 | // @Failure 400 {object} models.APIError 76 | // @Failure 404 {object} models.APIError 77 | // @Failure 500 {object} models.APIError 78 | // @Router /addresses/search [post] 79 | func SearchAddress(c *gin.Context) { 80 | form := make(map[string]interface{}) 81 | 82 | if err := c.ShouldBind(&form); err != nil { 83 | Error(c, http.StatusBadRequest, err) // 400 84 | return 85 | } 86 | 87 | query := db.DB 88 | 89 | for k, v := range form { 90 | query = query.Where(k, v) 91 | } 92 | 93 | // Load the item 94 | var item models.Address 95 | if res := query.Preload("Pool").First(&item); res.Error != nil { 96 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 97 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 98 | } else { 99 | Error(c, http.StatusInternalServerError, res.Error) // 500 100 | } 101 | return 102 | } 103 | 104 | c.JSON(http.StatusOK, item) // 200 105 | } 106 | 107 | // CreateAddress Create a new addresses 108 | // @Summary Create a new addresses 109 | // @Tags addresses 110 | // @Accept json 111 | // @Produce json 112 | // @Param item body models.AddressForm true "Add ip address" 113 | // @Success 200 {object} models.Address 114 | // @Failure 400 {object} models.APIError 115 | // @Failure 500 {object} models.APIError 116 | // @Router /addresses [post] 117 | func CreateAddress(c *gin.Context) { 118 | var form models.AddressForm 119 | 120 | if err := c.ShouldBind(&form); err != nil { 121 | Error(c, http.StatusBadRequest, err) // 400 122 | return 123 | } 124 | 125 | item := models.Address{AddressForm: form} 126 | 127 | // get the pool network info to verify if this ip should be added to the pool. 128 | var na models.Pool 129 | db.DB.First(&na, "id = ?", item.AddressForm.PoolID) 130 | 131 | cidr := item.IP + "/" + strconv.Itoa(na.Netmask) 132 | network := na.NetAddress + "/" + strconv.Itoa(na.Netmask) 133 | 134 | // first check if the address is even in the network. 135 | _, neta, _ := net.ParseCIDR(network) 136 | ipb, _, _ := net.ParseCIDR(cidr) 137 | start := net.ParseIP(na.StartAddress) 138 | end := net.ParseIP(na.EndAddress) 139 | if neta.Contains(ipb) { 140 | //then check if it's in the given range by the pool. 141 | trial := net.ParseIP(item.IP) 142 | 143 | if bytes.Compare(trial, start) >= 0 && bytes.Compare(trial, end) <= 0 { 144 | logrus.WithFields(logrus.Fields{ 145 | "ip": trial, 146 | "start": start, 147 | "end": end, 148 | }).Debug("ip validation successful") 149 | } else { 150 | logrus.WithFields(logrus.Fields{ 151 | "ip": trial, 152 | "start": start, 153 | "end": end, 154 | }).Debug("the ip address is not in the scope of the dhcp pool associated with the group") 155 | Error(c, http.StatusBadRequest, fmt.Errorf("the ip address is not in the scope of the dhcp pool associated with the group")) // 400 156 | return 157 | } 158 | } else { 159 | Error(c, http.StatusBadRequest, fmt.Errorf("the ip address is not in the scope of the dhcp pool associated with the group")) // 400 160 | return 161 | } 162 | 163 | // ensure the mac address is properly formated. 164 | mac, _ := net.ParseMAC(item.Mac) 165 | item.Mac = mac.String() 166 | 167 | // if ip address checks pas, continue to commit. 168 | if item.ID != 0 { // Save if its an existing item 169 | if res := db.DB.Save(&item); res.Error != nil { 170 | Error(c, http.StatusInternalServerError, res.Error) // 500 171 | return 172 | } 173 | } else { // Create a new item 174 | if res := db.DB.Create(&item); res.Error != nil { 175 | Error(c, http.StatusInternalServerError, res.Error) // 500 176 | return 177 | } 178 | } 179 | 180 | // Load a new version with relations 181 | if res := db.DB.Preload("Pool").First(&item); res.Error != nil { 182 | Error(c, http.StatusInternalServerError, res.Error) // 500 183 | return 184 | } 185 | 186 | c.JSON(http.StatusOK, item) // 200 187 | 188 | logrus.WithFields(logrus.Fields{ 189 | "Hostname": item.Hostname, 190 | "Domain": item.Domain, 191 | "IP": item.IP, 192 | "MAC": item.Mac, 193 | "Pool ID": item.PoolID, 194 | "Group ID": item.GroupID, 195 | }).Debug("host") 196 | } 197 | 198 | // UpdateAddress Update an existing address 199 | // @Summary Update an existing address 200 | // @Tags addresses 201 | // @Accept json 202 | // @Produce json 203 | // @Param id path int true "Address ID" 204 | // @Param item body models.AddressForm true "Update an ip address" 205 | // @Success 200 {object} models.Address 206 | // @Failure 400 {object} models.APIError 207 | // @Failure 404 {object} models.APIError 208 | // @Failure 500 {object} models.APIError 209 | // @Router /addresses/{id} [patch] 210 | func UpdateAddress(c *gin.Context) { 211 | id, err := strconv.Atoi(c.Param("id")) 212 | if err != nil { 213 | Error(c, http.StatusBadRequest, err) // 400 214 | return 215 | } 216 | 217 | // Load the form data 218 | var form models.AddressForm 219 | if err := c.ShouldBind(&form); err != nil { 220 | Error(c, http.StatusBadRequest, err) // 400 221 | return 222 | } 223 | 224 | // Load the item 225 | var item models.Address 226 | if res := db.DB.First(&item, id); res.Error != nil { 227 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 228 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 229 | } else { 230 | Error(c, http.StatusInternalServerError, res.Error) // 500 231 | } 232 | return 233 | } 234 | 235 | // Merge the item and the form data 236 | if err := mergo.Merge(&item, models.Address{AddressForm: form}, mergo.WithOverride); err != nil { 237 | Error(c, http.StatusInternalServerError, err) // 500 238 | } 239 | 240 | // Mergo doesn't overwrite 0 or false values, force set 241 | item.AddressForm.Reimage = form.Reimage 242 | item.AddressForm.Progress = form.Progress 243 | 244 | // Save it 245 | if res := db.DB.Save(&item); res.Error != nil { 246 | Error(c, http.StatusInternalServerError, res.Error) // 500 247 | return 248 | } 249 | 250 | // Load a new version with relations 251 | if res := db.DB.Preload("Pool").First(&item); res.Error != nil { 252 | Error(c, http.StatusInternalServerError, res.Error) // 500 253 | return 254 | } 255 | 256 | c.JSON(http.StatusOK, item) // 200 257 | } 258 | 259 | // DeleteAddress Remove an existing address 260 | // @Summary Remove an existing address 261 | // @Tags addresses 262 | // @Accept json 263 | // @Produce json 264 | // @Param id path int true "Address ID" 265 | // @Success 204 266 | // @Failure 404 {object} models.APIError 267 | // @Failure 500 {object} models.APIError 268 | // @Router /addresses/{id} [delete] 269 | func DeleteAddress(c *gin.Context) { 270 | id, err := strconv.Atoi(c.Param("id")) 271 | if err != nil { 272 | Error(c, http.StatusBadRequest, err) // 400 273 | return 274 | } 275 | 276 | // Load the item 277 | var item models.Address 278 | if res := db.DB.First(&item, id); res.Error != nil { 279 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 280 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 281 | } else { 282 | Error(c, http.StatusInternalServerError, res.Error) // 500 283 | } 284 | return 285 | } 286 | 287 | // delete it 288 | if res := db.DB.Delete(&item); res.Error != nil { 289 | Error(c, http.StatusInternalServerError, res.Error) // 500 290 | return 291 | } 292 | 293 | c.JSON(http.StatusNoContent, gin.H{}) //204 294 | } 295 | -------------------------------------------------------------------------------- /api/device_classes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/imdario/mergo" 11 | "github.com/maxiepax/go-via/db" 12 | "github.com/maxiepax/go-via/models" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | // ListDeviceClasses Get a list of all device classes 17 | // @Summary Get all device classes 18 | // @Tags device_classes 19 | // @Accept json 20 | // @Produce json 21 | // @Success 200 {array} models.DeviceClass 22 | // @Failure 500 {object} models.APIError 23 | // @Router /device_classes [get] 24 | func ListDeviceClasses(c *gin.Context) { 25 | var items []models.DeviceClass 26 | if res := db.DB.Find(&items); res.Error != nil { 27 | Error(c, http.StatusInternalServerError, res.Error) // 500 28 | return 29 | } 30 | c.JSON(http.StatusOK, items) // 200 31 | } 32 | 33 | // GetDeviceClass Get an existing device class 34 | // @Summary Get an existing device class 35 | // @Tags device_classes 36 | // @Accept json 37 | // @Produce json 38 | // @Param id path int true "DeviceClass ID" 39 | // @Success 200 {object} models.DeviceClass 40 | // @Failure 400 {object} models.APIError 41 | // @Failure 404 {object} models.APIError 42 | // @Failure 500 {object} models.APIError 43 | // @Router /device_classes/{id} [get] 44 | func GetDeviceClass(c *gin.Context) { 45 | id, err := strconv.Atoi(c.Param("id")) 46 | if err != nil { 47 | Error(c, http.StatusBadRequest, err) // 400 48 | return 49 | } 50 | 51 | // Load the item 52 | var item models.DeviceClass 53 | if res := db.DB.First(&item, id); res.Error != nil { 54 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 55 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 56 | } else { 57 | Error(c, http.StatusInternalServerError, res.Error) // 500 58 | } 59 | return 60 | } 61 | 62 | c.JSON(http.StatusOK, item) // 200 63 | } 64 | 65 | // SearchDeviceClass Search for an device class 66 | // @Summary Search for an device class 67 | // @Tags device_classes 68 | // @Accept json 69 | // @Produce json 70 | // @Param item body models.DeviceClass true "Fields to search for" 71 | // @Success 200 {object} models.DeviceClass 72 | // @Failure 400 {object} models.APIError 73 | // @Failure 404 {object} models.APIError 74 | // @Failure 500 {object} models.APIError 75 | // @Router /device_classes/search [post] 76 | func SearchDeviceClass(c *gin.Context) { 77 | form := make(map[string]interface{}) 78 | 79 | if err := c.ShouldBind(&form); err != nil { 80 | Error(c, http.StatusBadRequest, err) // 400 81 | return 82 | } 83 | 84 | query := db.DB 85 | 86 | for k, v := range form { 87 | query = query.Where(k, v) 88 | } 89 | 90 | // Load the item 91 | var item models.DeviceClass 92 | if res := query.First(&item); res.Error != nil { 93 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 94 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 95 | } else { 96 | Error(c, http.StatusInternalServerError, res.Error) // 500 97 | } 98 | return 99 | } 100 | 101 | c.JSON(http.StatusOK, item) // 200 102 | } 103 | 104 | // CreateDeviceClass Create a new device class 105 | // @Summary Create a new device class 106 | // @Tags device_classes 107 | // @Accept json 108 | // @Produce json 109 | // @Param item body models.DeviceClassForm true "Add an device class" 110 | // @Success 200 {object} models.DeviceClass 111 | // @Failure 400 {object} models.APIError 112 | // @Failure 500 {object} models.APIError 113 | // @Router /device_classes [post] 114 | func CreateDeviceClass(c *gin.Context) { 115 | var form models.DeviceClassForm 116 | 117 | if err := c.ShouldBind(&form); err != nil { 118 | Error(c, http.StatusBadRequest, err) // 400 119 | return 120 | } 121 | 122 | item := models.DeviceClass{DeviceClassForm: form} 123 | 124 | if res := db.DB.Create(&item); res.Error != nil { 125 | Error(c, http.StatusInternalServerError, res.Error) // 500 126 | return 127 | } 128 | 129 | c.JSON(http.StatusOK, item) // 200 130 | } 131 | 132 | // UpdateDeviceClass Update an existing device class 133 | // @Summary Update an existing device class 134 | // @Tags device_classes 135 | // @Accept json 136 | // @Produce json 137 | // @Param id path int true "DeviceClass ID" 138 | // @Param item body models.DeviceClassForm true "Update an ip device class" 139 | // @Success 200 {object} models.DeviceClass 140 | // @Failure 400 {object} models.APIError 141 | // @Failure 404 {object} models.APIError 142 | // @Failure 500 {object} models.APIError 143 | // @Router /device_classes/{id} [patch] 144 | func UpdateDeviceClass(c *gin.Context) { 145 | id, err := strconv.Atoi(c.Param("id")) 146 | if err != nil { 147 | Error(c, http.StatusBadRequest, err) // 400 148 | return 149 | } 150 | 151 | // Load the form data 152 | var form models.DeviceClassForm 153 | if err := c.ShouldBind(&form); err != nil { 154 | Error(c, http.StatusBadRequest, err) // 400 155 | return 156 | } 157 | 158 | // Load the item 159 | var item models.DeviceClass 160 | if res := db.DB.First(&item, id); res.Error != nil { 161 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 162 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 163 | } else { 164 | Error(c, http.StatusInternalServerError, res.Error) // 500 165 | } 166 | return 167 | } 168 | 169 | // Merge the item and the form data 170 | if err := mergo.Merge(&item, models.DeviceClass{DeviceClassForm: form}, mergo.WithOverride); err != nil { 171 | Error(c, http.StatusInternalServerError, err) // 500 172 | } 173 | 174 | // Save it 175 | if res := db.DB.Save(&item); res.Error != nil { 176 | Error(c, http.StatusInternalServerError, res.Error) // 500 177 | return 178 | } 179 | 180 | c.JSON(http.StatusOK, item) // 200 181 | } 182 | 183 | // DeleteDeviceClass Remove an existing device class 184 | // @Summary Remove an existing device class 185 | // @Tags device_classes 186 | // @Accept json 187 | // @Produce json 188 | // @Param id path int true "DeviceClass ID" 189 | // @Success 204 190 | // @Failure 404 {object} models.APIError 191 | // @Failure 500 {object} models.APIError 192 | // @Router /device_classes/{id} [delete] 193 | func DeleteDeviceClass(c *gin.Context) { 194 | id, err := strconv.Atoi(c.Param("id")) 195 | if err != nil { 196 | Error(c, http.StatusBadRequest, err) // 400 197 | return 198 | } 199 | 200 | // Load the item 201 | var item models.DeviceClass 202 | if res := db.DB.First(&item, id); res.Error != nil { 203 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 204 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 205 | } else { 206 | Error(c, http.StatusInternalServerError, res.Error) // 500 207 | } 208 | return 209 | } 210 | 211 | // Save it 212 | if res := db.DB.Delete(&item); res.Error != nil { 213 | Error(c, http.StatusInternalServerError, res.Error) // 500 214 | return 215 | } 216 | 217 | c.JSON(http.StatusNoContent, gin.H{}) //204 218 | } 219 | -------------------------------------------------------------------------------- /api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/maxiepax/go-via/models" 6 | ) 7 | 8 | func Error(c *gin.Context, status int, err error) { 9 | c.JSON(status, models.APIError{ 10 | ErrorStatus: status, 11 | ErrorMessage: err.Error(), 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /api/groups.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "unicode" 10 | 11 | /*_ "github.com/GehirnInc/crypt/sha512_crypt"*/ 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/imdario/mergo" 15 | "github.com/maxiepax/go-via/db" 16 | "github.com/maxiepax/go-via/models" 17 | "github.com/maxiepax/go-via/secrets" 18 | "github.com/sirupsen/logrus" 19 | "gorm.io/gorm" 20 | ) 21 | 22 | // ListGroups Get a list of all groups 23 | // @Summary Get all groups 24 | // @Tags groups 25 | // @Accept json 26 | // @Produce json 27 | // @Success 200 {array} models.Group 28 | // @Failure 500 {object} models.APIError 29 | // @Router /groups [get] 30 | func ListGroups(c *gin.Context) { 31 | var items []models.NoPWGroup 32 | if res := db.DB.Preload("Pool").Preload("Option").Find(&items); res.Error != nil { 33 | Error(c, http.StatusInternalServerError, res.Error) // 500 34 | return 35 | } 36 | 37 | c.JSON(http.StatusOK, items) // 200 38 | } 39 | 40 | // GetGroup Get an existing group 41 | // @Summary Get an existing group 42 | // @Tags groups 43 | // @Accept json 44 | // @Produce json 45 | // @Param id path int true "Group ID" 46 | // @Success 200 {object} models.Group 47 | // @Failure 400 {object} models.APIError 48 | // @Failure 404 {object} models.APIError 49 | // @Failure 500 {object} models.APIError 50 | // @Router /groups/{id} [get] 51 | func GetGroup(c *gin.Context) { 52 | id, err := strconv.Atoi(c.Param("id")) 53 | if err != nil { 54 | Error(c, http.StatusBadRequest, err) // 400 55 | return 56 | } 57 | 58 | // Load the item 59 | var item models.NoPWGroup 60 | if res := db.DB.Preload("Pool").First(&item, id); res.Error != nil { 61 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 62 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 63 | } else { 64 | Error(c, http.StatusInternalServerError, res.Error) // 500 65 | } 66 | return 67 | } 68 | 69 | c.JSON(http.StatusOK, item) // 200 70 | } 71 | 72 | // CreateGroup Create a new groups 73 | // @Summary Create a new group 74 | // @Tags groups 75 | // @Accept json 76 | // @Produce json 77 | // @Param item body models.GroupForm true "Add ip group" 78 | // @Success 200 {object} models.Group 79 | // @Failure 400 {object} models.APIError 80 | // @Failure 500 {object} models.APIError 81 | // @Router /groups [post] 82 | func CreateGroup(key string) func(c *gin.Context) { 83 | return func(c *gin.Context) { 84 | var form models.GroupForm 85 | 86 | if err := c.ShouldBind(&form); err != nil { 87 | Error(c, http.StatusBadRequest, err) // 400 88 | return 89 | } 90 | 91 | item := models.Group{GroupForm: form} 92 | 93 | //remove whitespaces surrounding comma kickstart file breaks otherwise 94 | item.DNS = strings.Join(strings.Fields(item.DNS), "") 95 | item.NTP = strings.Join(strings.Fields(item.NTP), "") 96 | item.Syslog = strings.Join(strings.Fields(item.Syslog), "") 97 | 98 | //validate that password fullfills the password complexity requirements 99 | if err := verifyPassword(form.Password); err != nil { 100 | Error(c, http.StatusBadRequest, err) // 400 101 | return 102 | } 103 | item.Password = secrets.Encrypt(item.Password, key) 104 | 105 | if res := db.DB.Create(&item); res.Error != nil { 106 | Error(c, http.StatusInternalServerError, res.Error) // 500 107 | return 108 | } 109 | 110 | // Load a new version with relations 111 | if res := db.DB.Preload("Pool").First(&item); res.Error != nil { 112 | Error(c, http.StatusInternalServerError, res.Error) // 500 113 | return 114 | } 115 | 116 | c.JSON(http.StatusOK, item) // 200 117 | 118 | logrus.WithFields(logrus.Fields{ 119 | "Name": item.Name, 120 | "DNS": item.DNS, 121 | "NTP": item.NTP, 122 | "Image ID": item.ImageID, 123 | "Pool ID": item.PoolID, 124 | }).Debug("group") 125 | } 126 | } 127 | 128 | // UpdateGroup Update an existing group 129 | // @Summary Update an existing group 130 | // @Tags groups 131 | // @Accept json 132 | // @Produce json 133 | // @Param id path int true "Group ID" 134 | // @Param item body models.GroupForm true "Update an group" 135 | // @Success 200 {object} models.Group 136 | // @Failure 400 {object} models.APIError 137 | // @Failure 404 {object} models.APIError 138 | // @Failure 500 {object} models.APIError 139 | // @Router /groups/{id} [patch] 140 | func UpdateGroup(key string) func(c *gin.Context) { 141 | return func(c *gin.Context) { 142 | id, err := strconv.Atoi(c.Param("id")) 143 | if err != nil { 144 | Error(c, http.StatusBadRequest, err) // 400 145 | return 146 | } 147 | 148 | // Load the form data 149 | var form models.GroupForm 150 | if err := c.ShouldBind(&form); err != nil { 151 | Error(c, http.StatusBadRequest, err) // 400 152 | return 153 | } 154 | 155 | // Load the item 156 | var item models.Group 157 | if res := db.DB.First(&item, id); res.Error != nil { 158 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 159 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 160 | } else { 161 | Error(c, http.StatusInternalServerError, res.Error) // 500 162 | } 163 | return 164 | } 165 | 166 | // Merge the item and the form data 167 | if err := mergo.Merge(&item, models.Group{GroupForm: form}, mergo.WithOverride); err != nil { 168 | Error(c, http.StatusInternalServerError, err) // 500 169 | } 170 | 171 | //remove whitespaces surrounding comma kickstart file breaks otherwise. 172 | item.DNS = strings.Join(strings.Fields(item.DNS), "") 173 | item.NTP = strings.Join(strings.Fields(item.NTP), "") 174 | item.Syslog = strings.Join(strings.Fields(item.Syslog), "") 175 | 176 | // to avoid re-hashing the password when no new password has been supplied, check if it was supplied 177 | //validate that password fullfills the password complexity requirements 178 | if form.Password != "" { 179 | if err := verifyPassword(form.Password); err != nil { 180 | Error(c, http.StatusBadRequest, err) // 400 181 | return 182 | } 183 | 184 | item.Password = secrets.Encrypt(item.Password, key) 185 | } 186 | 187 | //mergo wont overwrite values with empty space. To enable removal of ntp, dns, syslog, vlan, always overwrite. 188 | item.GroupForm.Vlan = form.Vlan 189 | item.GroupForm.DNS = form.DNS 190 | item.GroupForm.NTP = form.NTP 191 | item.GroupForm.Syslog = form.Syslog 192 | item.GroupForm.BootDisk = form.BootDisk 193 | 194 | // Save it 195 | if res := db.DB.Preload("Pool").Save(&item); res.Error != nil { 196 | Error(c, http.StatusInternalServerError, res.Error) // 500 197 | return 198 | } 199 | 200 | // Load a new version with relations 201 | if res := db.DB.Preload("Pool").First(&item); res.Error != nil { 202 | Error(c, http.StatusInternalServerError, res.Error) // 500 203 | return 204 | } 205 | 206 | c.JSON(http.StatusOK, item) // 200 207 | } 208 | } 209 | 210 | // DeleteGroup Remove an existing group 211 | // @Summary Remove an existing group 212 | // @Tags groups 213 | // @Accept json 214 | // @Produce json 215 | // @Param id path int true "Group ID" 216 | // @Success 204 217 | // @Failure 404 {object} models.APIError 218 | // @Failure 500 {object} models.APIError 219 | // @Router /groups/{id} [delete] 220 | func DeleteGroup(c *gin.Context) { 221 | id, err := strconv.Atoi(c.Param("id")) 222 | if err != nil { 223 | Error(c, http.StatusBadRequest, err) // 400 224 | return 225 | } 226 | 227 | // Load the item 228 | var item models.Group 229 | if res := db.DB.Preload("Address").First(&item, id); res.Error != nil { 230 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 231 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 232 | } else { 233 | Error(c, http.StatusInternalServerError, res.Error) // 500 234 | } 235 | return 236 | } 237 | 238 | // check if the group is empty, if it's not, deny the delete. 239 | if len(item.Address) < 1 { 240 | // Delete it 241 | if res := db.DB.Delete(&item); res.Error != nil { 242 | Error(c, http.StatusInternalServerError, res.Error) // 500 243 | return 244 | } 245 | c.JSON(http.StatusNoContent, gin.H{}) //204 246 | } else { 247 | c.JSON(http.StatusConflict, "the group is not empty, please delete all hosts first.") 248 | } 249 | 250 | } 251 | 252 | func verifyPassword(s string) error { 253 | number := false 254 | upper := false 255 | special := false 256 | lower := false 257 | for _, c := range s { 258 | switch { 259 | case unicode.IsNumber(c): 260 | number = true 261 | case unicode.IsUpper(c): 262 | upper = true 263 | case unicode.IsPunct(c) || unicode.IsSymbol(c): 264 | special = true 265 | case unicode.IsLetter(c) || c == ' ': 266 | lower = true 267 | } 268 | } 269 | var b2i = map[bool]int8{false: 0, true: 1} 270 | classes := b2i[number] + b2i[upper] + b2i[special] + b2i[lower] 271 | 272 | if classes < 3 { 273 | return fmt.Errorf("you need to use at least 3 character classes (lowercase, uppercase, special and numbers)") 274 | } 275 | 276 | if len(s) < 7 { 277 | return fmt.Errorf("too short, should be at least 7 characters") 278 | } 279 | 280 | return nil 281 | } 282 | -------------------------------------------------------------------------------- /api/image.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "mime/multipart" 11 | "net" 12 | "net/http" 13 | "os" 14 | "path" 15 | "path/filepath" 16 | "strconv" 17 | "strings" 18 | 19 | "github.com/gin-gonic/gin" 20 | "github.com/imdario/mergo" 21 | "github.com/kdomanski/iso9660/util" 22 | "github.com/maxiepax/go-via/config" 23 | "github.com/maxiepax/go-via/db" 24 | "github.com/maxiepax/go-via/models" 25 | "github.com/sirupsen/logrus" 26 | "gorm.io/gorm" 27 | ) 28 | 29 | // ListImages Get a list of all images 30 | // @Summary Get all images 31 | // @Tags images 32 | // @Accept json 33 | // @Produce json 34 | // @Success 200 {array} models.Image 35 | // @Failure 500 {object} models.APIError 36 | // @Router /images [get] 37 | func ListImages(c *gin.Context) { 38 | var items []models.Image 39 | if res := db.DB.Find(&items); res.Error != nil { 40 | Error(c, http.StatusInternalServerError, res.Error) // 500 41 | return 42 | } 43 | c.JSON(http.StatusOK, items) // 200 44 | } 45 | 46 | // GetImage Get an existing image 47 | // @Summary Get an existing image 48 | // @Tags images 49 | // @Accept json 50 | // @Produce json 51 | // @Param id path int true "Image ID" 52 | // @Success 200 {object} models.Image 53 | // @Failure 400 {object} models.APIError 54 | // @Failure 404 {object} models.APIError 55 | // @Failure 500 {object} models.APIError 56 | // @Router /images/{id} [get] 57 | func GetImage(c *gin.Context) { 58 | id, err := strconv.Atoi(c.Param("id")) 59 | if err != nil { 60 | Error(c, http.StatusBadRequest, err) // 400 61 | return 62 | } 63 | 64 | // Load the item 65 | var item models.Image 66 | if res := db.DB.First(&item, id); res.Error != nil { 67 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 68 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 69 | } else { 70 | Error(c, http.StatusInternalServerError, res.Error) // 500 71 | } 72 | return 73 | } 74 | 75 | c.JSON(http.StatusOK, item) // 200 76 | } 77 | 78 | // CreateImage Create a new images 79 | // @Summary Create a new image 80 | // @Tags images 81 | // @Accept json 82 | // @Produce json 83 | // @Param item body models.ImageForm true "Add image" 84 | // @Success 200 {object} models.Image 85 | // @Failure 400 {object} models.APIError 86 | // @Failure 500 {object} models.APIError 87 | // @Router /images [post] 88 | func CreateImage(conf *config.Config) func(c *gin.Context) { 89 | return func(c *gin.Context) { 90 | 91 | f, err := c.MultipartForm() 92 | if err != nil { 93 | Error(c, http.StatusInternalServerError, err) // 500 94 | return 95 | } 96 | 97 | files := f.File["file[]"] 98 | 99 | for _, file := range files { 100 | 101 | filename := file.Filename 102 | 103 | item := models.Image{} 104 | item.ISOImage = filepath.Base(file.Filename) 105 | item.Path = path.Join(".", "tftp", filename) 106 | item.Hash = c.PostForm("hash") 107 | item.Description = c.PostForm("description") 108 | 109 | os.MkdirAll(filepath.Dir(item.Path), os.ModePerm) 110 | 111 | _, err = SaveUploadedFile(file, item.Path) 112 | if err != nil { 113 | Error(c, http.StatusInternalServerError, err) // 500 114 | return 115 | } 116 | 117 | if item.Hash == "" { 118 | logrus.WithFields(logrus.Fields{ 119 | "Hash": item.Hash, 120 | }).Warning("Image uploaded with no hash, please consider using a hash to avoid image corruption") 121 | } else { 122 | logrus.WithFields(logrus.Fields{ 123 | "Hash": item.Hash, 124 | }).Warning("Image uploaded with hash, comparing hash!") 125 | 126 | f, err := os.Open(item.Path) 127 | if err != nil { 128 | logrus.Warning(err) 129 | } 130 | defer f.Close() 131 | 132 | h := sha256.New() 133 | if _, err := io.Copy(h, f); err != nil { 134 | log.Fatal(err) 135 | } 136 | 137 | if hex.EncodeToString(h.Sum(nil)) != item.Hash { 138 | err := fmt.Errorf("hash was invalid") 139 | Error(c, http.StatusBadRequest, err) // 400 140 | os.Remove(item.Path) 141 | return 142 | } 143 | 144 | } 145 | 146 | f, err := os.Open(item.Path) 147 | if err != nil { 148 | log.Fatalf("failed to open file: %s", err) 149 | } 150 | defer f.Close() 151 | 152 | //strip the filextension, eg. vmware.iso = vmware 153 | fn := strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename)) 154 | //merge into filepath 155 | fp := path.Join(".", "tftp", fn) 156 | 157 | if err = util.ExtractImageToDirectory(f, fp); err != nil { 158 | log.Fatalf("failed to extract image: %s", err) 159 | } 160 | 161 | //remove the file 162 | err = os.Remove(item.Path) 163 | if err != nil { 164 | logrus.WithFields(logrus.Fields{ 165 | "error": err, 166 | }).Debug("image") 167 | } 168 | 169 | //update item.Path 170 | item.Path = fp 171 | 172 | // get size of extracted dir 173 | 174 | size, err := dirSize(fp) 175 | if err != nil { 176 | logrus.WithFields(logrus.Fields{ 177 | "error": err, 178 | }).Debug("image") 179 | } 180 | 181 | item.Size = size 182 | 183 | /* 184 | mime, err := mimetype.DetectFile(item.StoragePath) 185 | if err != nil { 186 | Error(c, http.StatusInternalServerError, err) // 500 187 | return 188 | } 189 | item.Type = mime.String() 190 | item.Extension = mime.Extension() 191 | */ 192 | 193 | if result := db.DB.Table("images").Create(&item); result.Error != nil { 194 | Error(c, http.StatusInternalServerError, result.Error) // 500 195 | return 196 | } 197 | logrus.WithFields(logrus.Fields{ 198 | "id": item.ID, 199 | "image": item.ISOImage, 200 | "path": item.Path, 201 | "size": item.Size, 202 | "description": item.Description, 203 | }).Info("image") 204 | c.JSON(http.StatusOK, item) // 200 205 | } 206 | } 207 | } 208 | 209 | func SaveUploadedFile(file *multipart.FileHeader, dst string) (int64, error) { 210 | src, err := file.Open() 211 | if err != nil { 212 | return -1, err 213 | } 214 | defer src.Close() 215 | 216 | out, err := os.Create(dst) 217 | if err != nil { 218 | return -1, err 219 | } 220 | defer out.Close() 221 | 222 | n, err := io.Copy(out, src) 223 | return n, err 224 | } 225 | 226 | // UpdateImage Update an existing image 227 | // @Summary Update an existing image 228 | // @Tags images 229 | // @Accept json 230 | // @Produce json 231 | // @Param id path int true "Image ID" 232 | // @Param item body models.ImageForm true "Update an image" 233 | // @Success 200 {object} models.Image 234 | // @Failure 400 {object} models.APIError 235 | // @Failure 404 {object} models.APIError 236 | // @Failure 500 {object} models.APIError 237 | // @Router /images/{id} [patch] 238 | func UpdateImage(c *gin.Context) { 239 | id, err := strconv.Atoi(c.Param("id")) 240 | if err != nil { 241 | Error(c, http.StatusBadRequest, err) // 400 242 | return 243 | } 244 | 245 | // Load the form data 246 | var form models.ImageForm 247 | if err := c.ShouldBind(&form); err != nil { 248 | Error(c, http.StatusBadRequest, err) // 400 249 | return 250 | } 251 | 252 | // Load the item 253 | var item models.Image 254 | if res := db.DB.First(&item, id); res.Error != nil { 255 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 256 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 257 | } else { 258 | Error(c, http.StatusInternalServerError, res.Error) // 500 259 | } 260 | return 261 | } 262 | 263 | // Merge the item and the form data 264 | if err := mergo.Merge(&item, models.Image{ImageForm: form}, mergo.WithOverride); err != nil { 265 | Error(c, http.StatusInternalServerError, err) // 500 266 | } 267 | 268 | // Save it 269 | if res := db.DB.Save(&item); res.Error != nil { 270 | Error(c, http.StatusInternalServerError, res.Error) // 500 271 | return 272 | } 273 | 274 | // Load a new version with relations 275 | if res := db.DB.First(&item); res.Error != nil { 276 | Error(c, http.StatusInternalServerError, res.Error) // 500 277 | return 278 | } 279 | 280 | c.JSON(http.StatusOK, item) // 200 281 | } 282 | 283 | // DeleteImage Remove an existing image 284 | // @Summary Remove an existing image 285 | // @Tags images 286 | // @Accept json 287 | // @Produce json 288 | // @Param id path int true "Image ID" 289 | // @Success 204 290 | // @Failure 404 {object} models.APIError 291 | // @Failure 500 {object} models.APIError 292 | // @Router /images/{id} [delete] 293 | func DeleteImage(c *gin.Context) { 294 | id, err := strconv.Atoi(c.Param("id")) 295 | if err != nil { 296 | Error(c, http.StatusBadRequest, err) // 400 297 | return 298 | } 299 | 300 | // Load the item 301 | var item models.Image 302 | if res := db.DB.First(&item, id); res.Error != nil { 303 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 304 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 305 | } else { 306 | Error(c, http.StatusInternalServerError, res.Error) // 500 307 | } 308 | return 309 | } 310 | 311 | //check if any group is using the image 312 | var group models.Group 313 | db.DB.First(&group, "image_id = ?", item.ID) 314 | 315 | if group.Name != "" { 316 | c.JSON(http.StatusConflict, "the image is being used by groups, please re-assign the groups to another image and then delete the image") 317 | } else { 318 | // Delete it 319 | //remove the entire directory and all files in it 320 | err = os.RemoveAll(item.Path) 321 | if err != nil { 322 | log.Fatal(err) 323 | Error(c, http.StatusInternalServerError, err) // 500 324 | } 325 | 326 | // remove record from database 327 | if res := db.DB.Delete(&item); res.Error != nil { 328 | Error(c, http.StatusInternalServerError, res.Error) // 500 329 | return 330 | } 331 | 332 | c.JSON(http.StatusNoContent, gin.H{}) //204 333 | } 334 | 335 | } 336 | 337 | func WriteToFile(filename string, data string) error { 338 | file, err := os.Create(filename) 339 | if err != nil { 340 | return err 341 | } 342 | defer file.Close() 343 | 344 | _, err = io.WriteString(file, data) 345 | if err != nil { 346 | return err 347 | } 348 | return file.Sync() 349 | } 350 | 351 | func GetInterfaceIpv4Addr(interfaceName string) (addr string, err error) { 352 | var ( 353 | ief *net.Interface 354 | addrs []net.Addr 355 | ipv4Addr net.IP 356 | ) 357 | if ief, err = net.InterfaceByName(interfaceName); err != nil { // get interface 358 | return 359 | } 360 | if addrs, err = ief.Addrs(); err != nil { // get addresses 361 | return 362 | } 363 | for _, addr := range addrs { // get ipv4 address 364 | if ipv4Addr = addr.(*net.IPNet).IP.To4(); ipv4Addr != nil { 365 | break 366 | } 367 | } 368 | if ipv4Addr == nil { 369 | return "", errors.New(fmt.Sprintf("interface %s don't have an ipv4 address\n", interfaceName)) 370 | } 371 | return ipv4Addr.String(), nil 372 | } 373 | 374 | func dirSize(path string) (int64, error) { 375 | var size int64 376 | err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { 377 | if err != nil { 378 | return err 379 | } 380 | if !info.IsDir() { 381 | size += info.Size() 382 | } 383 | return err 384 | }) 385 | // convert byte to mb 386 | size = size / 1024 / 1024 387 | return size, err 388 | } 389 | -------------------------------------------------------------------------------- /api/ks.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "text/template" 9 | 10 | "encoding/base64" 11 | 12 | "github.com/gin-gonic/gin" 13 | "github.com/maxiepax/go-via/db" 14 | "github.com/maxiepax/go-via/models" 15 | "github.com/maxiepax/go-via/secrets" 16 | "github.com/sirupsen/logrus" 17 | "gorm.io/gorm/clause" 18 | ) 19 | 20 | var defaultks = ` 21 | # Accept the VMware End User License Agreement 22 | vmaccepteula 23 | 24 | # Set the root password for the DCUI and Tech Support Mode 25 | rootpw {{ .password }} 26 | 27 | {{ if .erasedisks }} 28 | # Remove ALL partitions 29 | clearpart --overwritevmfs --alldrives {{ end }} 30 | 31 | {{ if .bootdisk }} 32 | install --disk=/vmfs/devices/disks/{{.bootdisk}} --overwritevmfs --novmfsondisk 33 | {{ else }} 34 | # Install on the first local disk available on machine 35 | install --overwritevmfs {{ if not .createvmfs }} --novmfsondisk {{ end }} --firstdisk="localesx,usb,ahci,vmw_ahci,VMware" 36 | {{ end }} 37 | 38 | # Set the network to static on the first network adapter 39 | network --bootproto=static --ip={{ .ip }} --gateway={{ .gateway }} --netmask={{ .netmask }} --nameserver={{ .dns }} --hostname={{ .hostname }} --device={{ .mac }} {{if .vlan}} --vlanid={{.vlan}} {{end}} 40 | 41 | reboot 42 | ` 43 | 44 | //func Ks(c *gin.Context) { 45 | func Ks(key string) func(c *gin.Context) { 46 | return func(c *gin.Context) { 47 | var item models.Address 48 | host, _, _ := net.SplitHostPort(c.Request.RemoteAddr) 49 | 50 | if res := db.DB.Preload(clause.Associations).Where("ip = ?", host).First(&item); res.Error != nil { 51 | Error(c, http.StatusInternalServerError, res.Error) // 500 52 | return 53 | } 54 | 55 | options := models.GroupOptions{} 56 | json.Unmarshal(item.Group.Options, &options) 57 | 58 | if reimage := db.DB.Model(&item).Where("ip = ?", host).Update("reimage", false); reimage.Error != nil { 59 | Error(c, http.StatusInternalServerError, reimage.Error) // 500 60 | return 61 | } 62 | 63 | laddrport, ok := c.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) 64 | if !ok { 65 | logrus.WithFields(logrus.Fields{ 66 | "interface": "could not determine the local interface used to apply to ks.cfgs postconfig callback", 67 | }).Debug("ks") 68 | } 69 | 70 | logrus.Info("Disabling re-imaging for host to avoid re-install looping") 71 | 72 | //convert netmask from bit to long format. 73 | nm := net.CIDRMask(item.Pool.Netmask, 32) 74 | netmask := ipv4MaskString(nm) 75 | 76 | //decrypt the password 77 | decryptedPassword := secrets.Decrypt(item.Group.Password, key) 78 | 79 | //cleanup data to allow easier custom templating 80 | data := map[string]interface{}{ 81 | "password": decryptedPassword, 82 | "ip": item.IP, 83 | "mac": item.Mac, 84 | "gateway": item.Pool.Gateway, 85 | "dns": item.Group.DNS, 86 | "hostname": item.Hostname, 87 | "netmask": netmask, 88 | "via_server": laddrport, 89 | "erasedisks": options.EraseDisks, 90 | "bootdisk": item.Group.BootDisk, 91 | "vlan": item.Group.Vlan, 92 | "createvmfs": options.CreateVMFS, 93 | } 94 | 95 | ks := defaultks 96 | 97 | // check if default ks has been overridden. 98 | if item.Ks != "" { 99 | dec, _ := base64.StdEncoding.DecodeString(item.Ks) 100 | ks = string(dec) 101 | logrus.WithFields(logrus.Fields{ 102 | "custom host ks": ks, 103 | }).Debug("ks") 104 | } else if item.Group.Ks != "" { 105 | dec, _ := base64.StdEncoding.DecodeString(item.Group.Ks) 106 | ks = string(dec) 107 | logrus.WithFields(logrus.Fields{ 108 | "custom group ks": ks, 109 | }).Debug("ks") 110 | } 111 | 112 | t, err := template.New("").Parse(ks) 113 | if err != nil { 114 | logrus.Info(err) 115 | return 116 | } 117 | err = t.Execute(c.Writer, data) 118 | if err != nil { 119 | logrus.Info(err) 120 | return 121 | } 122 | 123 | logrus.Info("Served ks.cfg file") 124 | logrus.WithFields(logrus.Fields{ 125 | "id": item.ID, 126 | "ip": item.IP, 127 | "host": item.Hostname, 128 | "message": "served ks.cfg file", 129 | }).Info("ks") 130 | logrus.WithFields(logrus.Fields{ 131 | "id": item.ID, 132 | "percentage": 50, 133 | "progresstext": "kickstart", 134 | }).Info("progress") 135 | item.Progress = 50 136 | item.Progresstext = "kickstart" 137 | db.DB.Save(&item) 138 | 139 | go ProvisioningWorker(item, key) 140 | 141 | logrus.Info("Started worker") 142 | } 143 | } 144 | 145 | func ipv4MaskString(m []byte) string { 146 | if len(m) != 4 { 147 | panic("ipv4Mask: len must be 4 bytes") 148 | } 149 | 150 | return fmt.Sprintf("%d.%d.%d.%d", m[0], m[1], m[2], m[3]) 151 | } 152 | -------------------------------------------------------------------------------- /api/options.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/imdario/mergo" 11 | "github.com/maxiepax/go-via/db" 12 | "github.com/maxiepax/go-via/models" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | // ListOptions Get a list of all options 17 | // @Summary Get all options 18 | // @Tags options 19 | // @Accept json 20 | // @Produce json 21 | // @Success 200 {array} models.Option 22 | // @Failure 500 {object} models.APIError 23 | // @Router /options [get] 24 | func ListOptions(c *gin.Context) { 25 | var items []models.Option 26 | if res := db.DB.Preload("Pool").Find(&items); res.Error != nil { 27 | Error(c, http.StatusInternalServerError, res.Error) // 500 28 | return 29 | } 30 | c.JSON(http.StatusOK, items) // 200 31 | } 32 | 33 | // GetOption Get an existing option 34 | // @Summary Get an existing option 35 | // @Tags options 36 | // @Accept json 37 | // @Produce json 38 | // @Param id path int true "Option ID" 39 | // @Success 200 {object} models.Option 40 | // @Failure 400 {object} models.APIError 41 | // @Failure 404 {object} models.APIError 42 | // @Failure 500 {object} models.APIError 43 | // @Router /options/{id} [get] 44 | func GetOption(c *gin.Context) { 45 | id, err := strconv.Atoi(c.Param("id")) 46 | if err != nil { 47 | Error(c, http.StatusBadRequest, err) // 400 48 | return 49 | } 50 | 51 | // Load the item 52 | var item models.Option 53 | if res := db.DB.Preload("Pool").First(&item, id); res.Error != nil { 54 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 55 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 56 | } else { 57 | Error(c, http.StatusInternalServerError, res.Error) // 500 58 | } 59 | return 60 | } 61 | 62 | c.JSON(http.StatusOK, item) // 200 63 | } 64 | 65 | // SearchOption Search for an option 66 | // @Summary Search for an option 67 | // @Tags options 68 | // @Accept json 69 | // @Produce json 70 | // @Param item body models.Option true "Fields to search for" 71 | // @Success 200 {object} models.Option 72 | // @Failure 400 {object} models.APIError 73 | // @Failure 404 {object} models.APIError 74 | // @Failure 500 {object} models.APIError 75 | // @Router /options/search [post] 76 | func SearchOption(c *gin.Context) { 77 | form := make(map[string]interface{}) 78 | 79 | if err := c.ShouldBind(&form); err != nil { 80 | Error(c, http.StatusBadRequest, err) // 400 81 | return 82 | } 83 | 84 | query := db.DB 85 | 86 | for k, v := range form { 87 | query = query.Where(k, v) 88 | } 89 | 90 | // Load the item 91 | var item models.Option 92 | if res := query.Preload("Pool").First(&item); res.Error != nil { 93 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 94 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 95 | } else { 96 | Error(c, http.StatusInternalServerError, res.Error) // 500 97 | } 98 | return 99 | } 100 | 101 | c.JSON(http.StatusOK, item) // 200 102 | } 103 | 104 | // CreateOption Create a new options 105 | // @Summary Create a new option 106 | // @Tags options 107 | // @Accept json 108 | // @Produce json 109 | // @Param item body models.OptionForm true "Add ip option" 110 | // @Success 200 {object} models.Option 111 | // @Failure 400 {object} models.APIError 112 | // @Failure 500 {object} models.APIError 113 | // @Router /options [post] 114 | func CreateOption(c *gin.Context) { 115 | var form models.OptionForm 116 | 117 | if err := c.ShouldBind(&form); err != nil { 118 | Error(c, http.StatusBadRequest, err) // 400 119 | return 120 | } 121 | 122 | item := models.Option{OptionForm: form} 123 | 124 | if res := db.DB.Create(&item); res.Error != nil { 125 | Error(c, http.StatusInternalServerError, res.Error) // 500 126 | return 127 | } 128 | 129 | // Load a new version with relations 130 | if res := db.DB.Preload("Pool").First(&item); res.Error != nil { 131 | Error(c, http.StatusInternalServerError, res.Error) // 500 132 | return 133 | } 134 | 135 | c.JSON(http.StatusOK, item) // 200 136 | } 137 | 138 | // UpdateOption Update an existing option 139 | // @Summary Update an existing option 140 | // @Tags options 141 | // @Accept json 142 | // @Produce json 143 | // @Param id path int true "Option ID" 144 | // @Param item body models.OptionForm true "Update an option" 145 | // @Success 200 {object} models.Option 146 | // @Failure 400 {object} models.APIError 147 | // @Failure 404 {object} models.APIError 148 | // @Failure 500 {object} models.APIError 149 | // @Router /options/{id} [patch] 150 | func UpdateOption(c *gin.Context) { 151 | id, err := strconv.Atoi(c.Param("id")) 152 | if err != nil { 153 | Error(c, http.StatusBadRequest, err) // 400 154 | return 155 | } 156 | 157 | // Load the form data 158 | var form models.OptionForm 159 | if err := c.ShouldBind(&form); err != nil { 160 | Error(c, http.StatusBadRequest, err) // 400 161 | return 162 | } 163 | 164 | // Load the item 165 | var item models.Option 166 | if res := db.DB.First(&item, id); res.Error != nil { 167 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 168 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 169 | } else { 170 | Error(c, http.StatusInternalServerError, res.Error) // 500 171 | } 172 | return 173 | } 174 | 175 | // Merge the item and the form data 176 | if err := mergo.Merge(&item, models.Option{OptionForm: form}, mergo.WithOverride); err != nil { 177 | Error(c, http.StatusInternalServerError, err) // 500 178 | } 179 | 180 | // Save it 181 | if res := db.DB.Preload("Pool").Save(&item); res.Error != nil { 182 | Error(c, http.StatusInternalServerError, res.Error) // 500 183 | return 184 | } 185 | 186 | // Load a new version with relations 187 | if res := db.DB.Preload("Pool").First(&item); res.Error != nil { 188 | Error(c, http.StatusInternalServerError, res.Error) // 500 189 | return 190 | } 191 | 192 | c.JSON(http.StatusOK, item) // 200 193 | } 194 | 195 | // DeleteOption Remove an existing option 196 | // @Summary Remove an existing option 197 | // @Tags options 198 | // @Accept json 199 | // @Produce json 200 | // @Param id path int true "Option ID" 201 | // @Success 204 202 | // @Failure 404 {object} models.APIError 203 | // @Failure 500 {object} models.APIError 204 | // @Router /options/{id} [delete] 205 | func DeleteOption(c *gin.Context) { 206 | id, err := strconv.Atoi(c.Param("id")) 207 | if err != nil { 208 | Error(c, http.StatusBadRequest, err) // 400 209 | return 210 | } 211 | 212 | // Load the item 213 | var item models.Option 214 | if res := db.DB.First(&item, id); res.Error != nil { 215 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 216 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 217 | } else { 218 | Error(c, http.StatusInternalServerError, res.Error) // 500 219 | } 220 | return 221 | } 222 | 223 | // Save it 224 | if res := db.DB.Delete(&item); res.Error != nil { 225 | Error(c, http.StatusInternalServerError, res.Error) // 500 226 | return 227 | } 228 | 229 | c.JSON(http.StatusNoContent, gin.H{}) //204 230 | } 231 | -------------------------------------------------------------------------------- /api/pools.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/imdario/mergo" 12 | "github.com/maxiepax/go-via/db" 13 | "github.com/maxiepax/go-via/models" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | // ListPools Get a list of all pools 18 | // @Summary Get all pools 19 | // @Tags pools 20 | // @Accept json 21 | // @Produce json 22 | // @Success 200 {array} models.Pool 23 | // @Failure 500 {object} models.APIError 24 | // @Router /pools [get] 25 | func ListPools(c *gin.Context) { 26 | var items []models.Pool 27 | if res := db.DB.Find(&items); res.Error != nil { 28 | Error(c, http.StatusInternalServerError, res.Error) // 500 29 | return 30 | } 31 | c.JSON(http.StatusOK, items) // 200 32 | } 33 | 34 | // GetPool Get an existing pool 35 | // @Summary Get an existing pool 36 | // @Tags pools 37 | // @Accept json 38 | // @Produce json 39 | // @Param id path int true "Pool ID" 40 | // @Success 200 {object} models.Pool 41 | // @Failure 400 {object} models.APIError 42 | // @Failure 404 {object} models.APIError 43 | // @Failure 500 {object} models.APIError 44 | // @Router /pools/{id} [get] 45 | func GetPool(c *gin.Context) { 46 | id, err := strconv.Atoi(c.Param("id")) 47 | if err != nil { 48 | Error(c, http.StatusBadRequest, err) // 400 49 | return 50 | } 51 | 52 | // Load the item 53 | var item models.Pool 54 | if res := db.DB.First(&item, id); res.Error != nil { 55 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 56 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 57 | } else { 58 | Error(c, http.StatusInternalServerError, res.Error) // 500 59 | } 60 | return 61 | } 62 | 63 | c.JSON(http.StatusOK, item) // 200 64 | } 65 | 66 | // SearchPool Search for an pool 67 | // @Summary Search for an pool 68 | // @Tags pools 69 | // @Accept json 70 | // @Produce json 71 | // @Param item body models.Pool true "Fields to search for" 72 | // @Success 200 {object} models.Pool 73 | // @Failure 400 {object} models.APIError 74 | // @Failure 404 {object} models.APIError 75 | // @Failure 500 {object} models.APIError 76 | // @Router /pools/search [post] 77 | func SearchPool(c *gin.Context) { 78 | form := make(map[string]interface{}) 79 | 80 | if err := c.ShouldBind(&form); err != nil { 81 | Error(c, http.StatusBadRequest, err) // 400 82 | return 83 | } 84 | 85 | query := db.DB 86 | 87 | for k, v := range form { 88 | query = query.Where(k, v) 89 | } 90 | 91 | // Load the item 92 | var item models.Pool 93 | if res := query.First(&item); res.Error != nil { 94 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 95 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 96 | } else { 97 | Error(c, http.StatusInternalServerError, res.Error) // 500 98 | } 99 | return 100 | } 101 | 102 | c.JSON(http.StatusOK, item) // 200 103 | } 104 | 105 | // CreatePool Create a new pool 106 | // @Summary Create a new pool 107 | // @Tags pools 108 | // @Accept json 109 | // @Produce json 110 | // @Param item body models.PoolForm true "Add ip pool" 111 | // @Success 200 {object} models.Pool 112 | // @Failure 400 {object} models.APIError 113 | // @Failure 500 {object} models.APIError 114 | // @Router /pools [post] 115 | func CreatePool(c *gin.Context) { 116 | var form models.PoolForm 117 | 118 | if err := c.ShouldBind(&form); err != nil { 119 | Error(c, http.StatusBadRequest, err) // 400 120 | return 121 | } 122 | 123 | item := models.Pool{PoolForm: form} 124 | 125 | if res := db.DB.Create(&item); res.Error != nil { 126 | Error(c, http.StatusInternalServerError, res.Error) // 500 127 | return 128 | } 129 | 130 | /* 131 | for i, value := range item.DNS { 132 | var opt models.Option 133 | opt.PoolID = item.ID 134 | opt.OpCode = 6 135 | opt.Data = value 136 | opt.Priority = i + 1 137 | 138 | if res := db.DB.Create(&opt); res.Error != nil { 139 | Error(c, http.StatusInternalServerError, res.Error) // 500 140 | return 141 | } 142 | spew.Dump(opt.ID) 143 | } 144 | */ 145 | c.JSON(http.StatusOK, item) // 200 146 | } 147 | 148 | // GetNextFreeIP Get the next free lease from a pool 149 | // @Summary Get the next free lease from a pool 150 | // @Tags pools 151 | // @Accept json 152 | // @Produce json 153 | // @Param id path int true "Pool ID" 154 | // @Success 200 {object} models.Address 155 | // @Failure 400 {object} models.APIError 156 | // @Failure 404 {object} models.APIError 157 | // @Failure 500 {object} models.APIError 158 | // @Router /pools/{id}/next [get] 159 | func GetNextFreeIP(c *gin.Context) { 160 | id, err := strconv.Atoi(c.Param("id")) 161 | if err != nil { 162 | Error(c, http.StatusBadRequest, err) // 400 163 | return 164 | } 165 | 166 | // Load the item 167 | var item models.PoolWithAddresses 168 | if res := db.DB.Table("pools").Preload("Addresses", "reimage OR expires > NOW()").First(&item, id); res.Error != nil { 169 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 170 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 171 | } else { 172 | Error(c, http.StatusInternalServerError, res.Error) // 500 173 | } 174 | return 175 | } 176 | 177 | ip, err := item.Next() 178 | if err != nil { 179 | Error(c, http.StatusInternalServerError, err) // 500 180 | return 181 | } 182 | 183 | resp := models.Address{ 184 | AddressForm: models.AddressForm{ 185 | IP: ip.String(), 186 | }, 187 | } 188 | 189 | c.JSON(http.StatusOK, resp) // 200 190 | } 191 | 192 | // GetPoolByRelay Get an existing pool by the relay host IP 193 | // @Summary Get an existing pool by the relay host IP 194 | // @Tags pools 195 | // @Accept json 196 | // @Produce json 197 | // @Param relay path string true "Relay host IP" 198 | // @Success 200 {object} models.Pool 199 | // @Failure 400 {object} models.APIError 200 | // @Failure 404 {object} models.APIError 201 | // @Failure 500 {object} models.APIError 202 | // @Router /relay/{relay} [get] 203 | func GetPoolByRelay(c *gin.Context) { 204 | relay := c.Param("relay") 205 | if relay == "" { 206 | Error(c, http.StatusBadRequest, fmt.Errorf("relay host parameter is missing")) // 400 207 | return 208 | } 209 | 210 | // Load the item 211 | 212 | item, err := FindPool(relay) 213 | if err != nil { 214 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) 215 | return 216 | } 217 | 218 | /* 219 | var item models.Pool 220 | if res := db.DB.Where("INET_ATON(net_address) = INET_ATON(?) & ((POWER(2, netmask)-1) <<(32-netmask))", relay).First(&item); res.Error != nil { 221 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 222 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 223 | } else { 224 | Error(c, http.StatusInternalServerError, res.Error) // 500 225 | } 226 | return 227 | } 228 | */ 229 | 230 | c.JSON(http.StatusOK, item) // 200 231 | } 232 | 233 | // UpdatePool Update an existing pool 234 | // @Summary Update an existing pool 235 | // @Tags pools 236 | // @Accept json 237 | // @Produce json 238 | // @Param id path int true "Pool ID" 239 | // @Param item body models.PoolForm true "Update an ip pool" 240 | // @Success 200 {object} models.Pool 241 | // @Failure 400 {object} models.APIError 242 | // @Failure 404 {object} models.APIError 243 | // @Failure 500 {object} models.APIError 244 | // @Router /pools/{id} [patch] 245 | func UpdatePool(c *gin.Context) { 246 | id, err := strconv.Atoi(c.Param("id")) 247 | if err != nil { 248 | Error(c, http.StatusBadRequest, err) // 400 249 | return 250 | } 251 | 252 | // Load the form data 253 | var form models.PoolForm 254 | if err := c.ShouldBind(&form); err != nil { 255 | Error(c, http.StatusBadRequest, err) // 400 256 | return 257 | } 258 | 259 | // Load the item 260 | var item models.Pool 261 | if res := db.DB.First(&item, id); res.Error != nil { 262 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 263 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 264 | } else { 265 | Error(c, http.StatusInternalServerError, res.Error) // 500 266 | } 267 | return 268 | } 269 | 270 | // Merge the item and the form data 271 | if err := mergo.Merge(&item, models.Pool{PoolForm: form}, mergo.WithOverride); err != nil { 272 | Error(c, http.StatusInternalServerError, err) // 500 273 | } 274 | 275 | item.OnlyServeReimage = form.OnlyServeReimage 276 | 277 | // Save it 278 | if res := db.DB.Save(&item); res.Error != nil { 279 | Error(c, http.StatusInternalServerError, res.Error) // 500 280 | return 281 | } 282 | 283 | c.JSON(http.StatusOK, item) // 200 284 | } 285 | 286 | // DeletePool Remove an existing pool 287 | // @Summary Remove an existing pool 288 | // @Tags pools 289 | // @Accept json 290 | // @Produce json 291 | // @Param id path int true "Pool ID" 292 | // @Success 204 293 | // @Failure 404 {object} models.APIError 294 | // @Failure 500 {object} models.APIError 295 | // @Router /pools/{id} [delete] 296 | func DeletePool(c *gin.Context) { 297 | id, err := strconv.Atoi(c.Param("id")) 298 | if err != nil { 299 | Error(c, http.StatusBadRequest, err) // 400 300 | return 301 | } 302 | 303 | // Load the item 304 | var item models.Pool 305 | if res := db.DB.First(&item, id); res.Error != nil { 306 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 307 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 308 | } else { 309 | Error(c, http.StatusInternalServerError, res.Error) // 500 310 | } 311 | return 312 | } 313 | 314 | //check if a group is using the pool 315 | var group models.Group 316 | db.DB.First(&group, "pool_id = ?", item.ID) 317 | 318 | if group.Name != "" { 319 | c.JSON(http.StatusConflict, "the pool is being used by groups, please re-assign the groups to another pool and then delete the pool") 320 | } else { 321 | // Delete it 322 | if res := db.DB.Delete(&item); res.Error != nil { 323 | Error(c, http.StatusInternalServerError, res.Error) // 500 324 | return 325 | } 326 | 327 | c.JSON(http.StatusNoContent, gin.H{}) //204 328 | } 329 | 330 | } 331 | 332 | func FindPool(ip string) (*models.PoolWithAddresses, error) { 333 | var pools []models.Pool 334 | if res := db.DB.Table("pools").Find(&pools); res.Error != nil { 335 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 336 | return nil, fmt.Errorf("no matching pool found") 337 | } 338 | return nil, res.Error 339 | } 340 | var pool models.PoolWithAddresses 341 | for _, v := range pools { 342 | _, ipv4Net, err := net.ParseCIDR(ip + "/" + strconv.Itoa(v.Netmask)) 343 | if err != nil { 344 | continue 345 | } 346 | 347 | if ipv4Net.IP.String() == v.NetAddress { 348 | pool.Pool = v 349 | break 350 | } 351 | } 352 | 353 | if pool.ID == 0 { 354 | return nil, fmt.Errorf("no matching pool found") 355 | } 356 | 357 | if res := db.DB.Table("pools").Preload("Addresses").First(&pool, pool.ID); res.Error != nil { 358 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 359 | return nil, fmt.Errorf("no matching pool found") 360 | } 361 | return nil, res.Error 362 | } 363 | return &pool, nil 364 | } 365 | -------------------------------------------------------------------------------- /api/users.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/imdario/mergo" 11 | "github.com/maxiepax/go-via/db" 12 | "github.com/maxiepax/go-via/models" 13 | "github.com/sirupsen/logrus" 14 | "golang.org/x/crypto/bcrypt" 15 | "gorm.io/gorm" 16 | ) 17 | 18 | // ListUsers Get a list of all users 19 | // @Summary Get all users 20 | // @Tags users 21 | // @Accept json 22 | // @Produce json 23 | // @Success 200 {array} models.User 24 | // @Failure 500 {object} models.APIError 25 | // @Router /users [get] 26 | func ListUsers(c *gin.Context) { 27 | var items []models.User 28 | if res := db.DB.Find(&items); res.Error != nil { 29 | Error(c, http.StatusInternalServerError, res.Error) // 500 30 | return 31 | } 32 | c.JSON(http.StatusOK, items) // 200 33 | } 34 | 35 | // GetUser Get an existing user 36 | // @Summary Get an existing user 37 | // @Tags users 38 | // @Accept json 39 | // @Produce json 40 | // @Param id path int true "User ID" 41 | // @Success 200 {object} models.User 42 | // @Failure 400 {object} models.APIError 43 | // @Failure 404 {object} models.APIError 44 | // @Failure 500 {object} models.APIError 45 | // @Router /users/{id} [get] 46 | func GetUser(c *gin.Context) { 47 | id, err := strconv.Atoi(c.Param("id")) 48 | if err != nil { 49 | Error(c, http.StatusBadRequest, err) // 400 50 | return 51 | } 52 | 53 | // Load the item 54 | var item models.User 55 | if res := db.DB.First(&item, id); res.Error != nil { 56 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 57 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 58 | } else { 59 | Error(c, http.StatusInternalServerError, res.Error) // 500 60 | } 61 | return 62 | } 63 | 64 | c.JSON(http.StatusOK, item) // 200 65 | } 66 | 67 | // SearchUser Search for a user 68 | // @Summary Search for a user 69 | // @Tags users 70 | // @Accept json 71 | // @Produce json 72 | // @Param item body models.User true "Fields to search for" 73 | // @Success 200 {object} models.User 74 | // @Failure 400 {object} models.APIError 75 | // @Failure 404 {object} models.APIError 76 | // @Failure 500 {object} models.APIError 77 | // @Router /users/search [post] 78 | func SearchUser(c *gin.Context) { 79 | form := make(map[string]interface{}) 80 | 81 | if err := c.ShouldBind(&form); err != nil { 82 | Error(c, http.StatusBadRequest, err) // 400 83 | return 84 | } 85 | 86 | query := db.DB 87 | 88 | for k, v := range form { 89 | query = query.Where(k, v) 90 | } 91 | 92 | // Load the item 93 | var item models.User 94 | if res := query.First(&item); res.Error != nil { 95 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 96 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 97 | } else { 98 | Error(c, http.StatusInternalServerError, res.Error) // 500 99 | } 100 | return 101 | } 102 | 103 | c.JSON(http.StatusOK, item) // 200 104 | } 105 | 106 | // CreateUser Create a new user 107 | // @Summary Create a new user 108 | // @Tags users 109 | // @Accept json 110 | // @Produce json 111 | // @Param item body models.UserForm true "Add an user" 112 | // @Success 200 {object} models.User 113 | // @Failure 400 {object} models.APIError 114 | // @Failure 500 {object} models.APIError 115 | // @Router /users [post] 116 | func CreateUser(c *gin.Context) { 117 | var form models.UserForm 118 | 119 | if err := c.ShouldBind(&form); err != nil { 120 | Error(c, http.StatusBadRequest, err) // 400 121 | return 122 | } 123 | 124 | item := models.User{UserForm: form} 125 | 126 | // hash and salt the plaintext password 127 | hp := HashAndSalt([]byte(item.Password)) 128 | item.Password = hp 129 | if res := db.DB.Create(&item); res.Error != nil { 130 | Error(c, http.StatusInternalServerError, res.Error) // 500 131 | return 132 | } 133 | 134 | c.JSON(http.StatusOK, item) // 200 135 | } 136 | 137 | // UpdateUser Update an existing user 138 | // @Summary Update an existing user 139 | // @Tags users 140 | // @Accept json 141 | // @Produce json 142 | // @Param id path int true "User ID" 143 | // @Param item body models.UserForm true "Update a user" 144 | // @Success 200 {object} models.User 145 | // @Failure 400 {object} models.APIError 146 | // @Failure 404 {object} models.APIError 147 | // @Failure 500 {object} models.APIError 148 | // @Router /users/{id} [patch] 149 | func UpdateUser(c *gin.Context) { 150 | id, err := strconv.Atoi(c.Param("id")) 151 | if err != nil { 152 | Error(c, http.StatusBadRequest, err) // 400 153 | return 154 | } 155 | 156 | // Load the form data 157 | var form models.UserForm 158 | if err := c.ShouldBind(&form); err != nil { 159 | Error(c, http.StatusBadRequest, err) // 400 160 | return 161 | } 162 | 163 | // Load the item 164 | var item models.User 165 | if res := db.DB.First(&item, id); res.Error != nil { 166 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 167 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 168 | } else { 169 | Error(c, http.StatusInternalServerError, res.Error) // 500 170 | } 171 | return 172 | } 173 | 174 | // Merge the item and the form data 175 | if err := mergo.Merge(&item, models.User{UserForm: form}, mergo.WithOverride); err != nil { 176 | Error(c, http.StatusInternalServerError, err) // 500 177 | } 178 | 179 | // hash and salt the plaintext password 180 | hp := HashAndSalt([]byte(item.Password)) 181 | item.Password = hp 182 | 183 | // Save it 184 | if res := db.DB.Save(&item); res.Error != nil { 185 | Error(c, http.StatusInternalServerError, res.Error) // 500 186 | return 187 | } 188 | 189 | c.JSON(http.StatusOK, item) // 200 190 | } 191 | 192 | // DeleteUser Remove an existing user 193 | // @Summary Remove an existing user 194 | // @Tags users 195 | // @Accept json 196 | // @Produce json 197 | // @Param id path int true "User ID" 198 | // @Success 204 199 | // @Failure 404 {object} models.APIError 200 | // @Failure 500 {object} models.APIError 201 | // @Router /users/{id} [delete] 202 | func DeleteUser(c *gin.Context) { 203 | id, err := strconv.Atoi(c.Param("id")) 204 | if err != nil { 205 | Error(c, http.StatusBadRequest, err) // 400 206 | return 207 | } 208 | 209 | // Load the item 210 | var item models.User 211 | if res := db.DB.First(&item, id); res.Error != nil { 212 | if errors.Is(res.Error, gorm.ErrRecordNotFound) { 213 | Error(c, http.StatusNotFound, fmt.Errorf("not found")) // 404 214 | } else { 215 | Error(c, http.StatusInternalServerError, res.Error) // 500 216 | } 217 | return 218 | } 219 | 220 | // Save it 221 | if res := db.DB.Delete(&item); res.Error != nil { 222 | Error(c, http.StatusInternalServerError, res.Error) // 500 223 | return 224 | } 225 | 226 | c.JSON(http.StatusNoContent, gin.H{}) //204 227 | } 228 | 229 | // functions to hash and compare passwords 230 | 231 | func HashAndSalt(pwd []byte) string { 232 | // Generate hashed and salted password 233 | hash, err := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost) 234 | if err != nil { 235 | logrus.WithFields(logrus.Fields{ 236 | "error": err, 237 | }).Error("couldnt salt and hash password") 238 | } 239 | return string(hash) 240 | } 241 | func ComparePasswords(hashedPwd string, plainPwd []byte, username string) bool { 242 | // compare a password to the hashed and salted value 243 | byteHash := []byte(hashedPwd) 244 | err := bcrypt.CompareHashAndPassword(byteHash, plainPwd) 245 | if err != nil { 246 | logrus.WithFields(logrus.Fields{ 247 | "username": username, 248 | "error": err, 249 | }).Error("invalid password supplied") 250 | return false 251 | } 252 | return true 253 | } 254 | -------------------------------------------------------------------------------- /api/version.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func Version(version string, commit string, date string) func(c *gin.Context) { 10 | return func(c *gin.Context) { 11 | 12 | type Version struct { 13 | Version string 14 | Commit string 15 | Date string 16 | } 17 | 18 | item := Version{version, commit, date} 19 | 20 | c.JSON(http.StatusOK, item) // 200 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": { 3 | "interfaces": ["en0"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Debug bool 5 | Port int `default:"8443"` 6 | File string 7 | Network Network 8 | DisableDhcp bool 9 | } 10 | 11 | type Network struct { 12 | Interfaces []string 13 | } 14 | -------------------------------------------------------------------------------- /crypto/main.go: -------------------------------------------------------------------------------- 1 | package ca 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "encoding/pem" 10 | "log" 11 | "math/big" 12 | "os" 13 | "time" 14 | 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | func CreateCA() { 19 | ca := &x509.Certificate{ 20 | SerialNumber: big.NewInt(1653), 21 | Subject: pkix.Name{ 22 | Organization: []string{"VMware ESX Server Default Certificate"}, 23 | Country: []string{"US"}, 24 | Province: []string{"California"}, 25 | Locality: []string{"Palo Alto"}, 26 | }, 27 | NotBefore: time.Now(), 28 | NotAfter: time.Now().AddDate(10, 0, 0), 29 | IsCA: true, 30 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 31 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 32 | BasicConstraintsValid: true, 33 | } 34 | 35 | priv, _ := rsa.GenerateKey(rand.Reader, 2048) 36 | pub := &priv.PublicKey 37 | ca_b, err := x509.CreateCertificate(rand.Reader, ca, ca, pub, priv) 38 | if err != nil { 39 | log.Println("create ca failed", err) 40 | return 41 | } 42 | 43 | // Public key 44 | certOut, err := os.Create("cert/ca.crt") 45 | if err != nil { 46 | logrus.Fatal(err) 47 | } 48 | pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: ca_b}) 49 | certOut.Close() 50 | logrus.WithFields(logrus.Fields{ 51 | "cert": "ca.pem created", 52 | }).Info("cert") 53 | 54 | // Private key 55 | keyOut, err := os.OpenFile("cert/ca.key", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 56 | if err != nil { 57 | logrus.Fatal(err) 58 | } 59 | pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) 60 | keyOut.Close() 61 | logrus.WithFields(logrus.Fields{ 62 | "cert": "ca.key created", 63 | }).Info("cert") 64 | } 65 | 66 | func CreateCert(path string, name string, cn string) { 67 | 68 | // Load CA 69 | catls, err := tls.LoadX509KeyPair("cert/ca.crt", "cert/ca.key") 70 | if err != nil { 71 | logrus.Fatal(err) 72 | } 73 | ca, err := x509.ParseCertificate(catls.Certificate[0]) 74 | if err != nil { 75 | logrus.Fatal(err) 76 | } 77 | 78 | // Prepare certificate 79 | cert := &x509.Certificate{ 80 | SerialNumber: big.NewInt(1658), 81 | Subject: pkix.Name{ 82 | Organization: []string{"VMware ESX Server Default Certificate"}, 83 | Country: []string{"US"}, 84 | Province: []string{"California"}, 85 | Locality: []string{"Palo Alto"}, 86 | CommonName: cn, 87 | }, 88 | NotBefore: time.Now(), 89 | NotAfter: time.Now().AddDate(10, 0, 0), 90 | SubjectKeyId: []byte{1, 2, 3, 4, 6}, 91 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 92 | KeyUsage: x509.KeyUsageDigitalSignature, 93 | } 94 | // add SAN 95 | cert.DNSNames = []string{cn} 96 | cert.EmailAddresses = []string{"ssl-certificates@vmware.com"} 97 | 98 | priv, _ := rsa.GenerateKey(rand.Reader, 2048) 99 | pub := &priv.PublicKey 100 | 101 | // Sign the certificate 102 | cert_b, err := x509.CreateCertificate(rand.Reader, cert, ca, pub, catls.PrivateKey) 103 | if err != nil { 104 | logrus.Fatal(err) 105 | } 106 | 107 | // Public key 108 | certOut, err := os.Create(path + "/" + name + ".crt") 109 | if err != nil { 110 | logrus.Fatal(err) 111 | } 112 | pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: cert_b}) 113 | certOut.Close() 114 | logrus.WithFields(logrus.Fields{ 115 | "cert": path + "/" + name + ".crt created", 116 | }).Info("cert") 117 | 118 | // Private key 119 | keyOut, err := os.OpenFile(path+"/"+name+".key", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 120 | if err != nil { 121 | logrus.Fatal(err) 122 | } 123 | pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) 124 | keyOut.Close() 125 | logrus.WithFields(logrus.Fields{ 126 | "cert": path + "/" + name + ".key created", 127 | }).Info("cert") 128 | 129 | } 130 | -------------------------------------------------------------------------------- /db/main.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sirupsen/logrus" 7 | "gorm.io/driver/sqlite" 8 | "gorm.io/gorm" 9 | "gorm.io/gorm/logger" 10 | ) 11 | 12 | var DB *gorm.DB 13 | 14 | func Connect(debug bool) { 15 | 16 | c := &gorm.Config{ 17 | SkipDefaultTransaction: true, 18 | DisableForeignKeyConstraintWhenMigrating: true, 19 | } 20 | 21 | if debug { 22 | c.Logger = logger.Default.LogMode(logger.Info) 23 | } 24 | 25 | //check if database is present 26 | if _, err := os.Stat("database/sqlite-database.db"); os.IsNotExist(err) { 27 | //Database does not exist, so create it. 28 | os.MkdirAll("database", os.ModePerm) 29 | logrus.Info("No database found, creating database/sqlite-database.db") 30 | file, err := os.Create("database/sqlite-database.db") 31 | if err != nil { 32 | logrus.Fatal(err.Error()) 33 | } 34 | file.Close() 35 | logrus.Info("database/sqlite-database.db created") 36 | } else { 37 | //Database exists, moving on. 38 | logrus.Info("Existing database sqlite-database.db found") 39 | } 40 | 41 | var err error 42 | 43 | DB, err = gorm.Open(sqlite.Open("database/sqlite-database.db"), c) 44 | if err != nil { 45 | logrus.Error("Failed to open the SQLite database.") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maxiepax/go-via 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/agiledragon/gomonkey/v2 v2.3.1 // indirect 7 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 8 | github.com/davecgh/go-spew v1.1.1 9 | github.com/fatih/camelcase v1.0.0 // indirect 10 | github.com/fatih/structs v1.1.0 // indirect 11 | github.com/gin-contrib/cors v1.3.1 12 | github.com/gin-gonic/gin v1.7.3 13 | github.com/go-openapi/spec v0.20.6 // indirect 14 | github.com/go-openapi/swag v0.21.1 // indirect 15 | github.com/gofrs/uuid v4.0.0+incompatible // indirect 16 | github.com/google/gopacket v1.1.19 17 | github.com/gorilla/websocket v1.4.2 // indirect 18 | github.com/imdario/mergo v0.3.11 19 | github.com/kdomanski/iso9660 v0.2.0 20 | github.com/koding/multiconfig v0.0.0-20171124222453-69c27309b2d7 21 | github.com/lib/pq v1.8.0 // indirect 22 | github.com/mailru/easyjson v0.7.7 // indirect 23 | github.com/mattn/go-sqlite3 v1.14.6 // indirect 24 | github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 25 | github.com/otiai10/copy v1.7.0 // indirect 26 | github.com/pin/tftp v0.0.0-20210325153949-b0a0cac76b6a 27 | github.com/rakyll/statik v0.1.7 28 | github.com/shopspring/decimal v1.2.0 // indirect 29 | github.com/sirupsen/logrus v1.7.0 30 | github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 31 | github.com/swaggo/gin-swagger v1.3.0 32 | github.com/swaggo/swag v1.8.4 33 | github.com/urfave/cli/v2 v2.11.1 // indirect 34 | github.com/vmware/govmomi v0.24.1 35 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 36 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect 37 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect 38 | golang.org/x/tools v0.1.11 // indirect 39 | gopkg.in/yaml.v2 v2.4.0 // indirect 40 | gorm.io/datatypes v1.0.0 41 | gorm.io/driver/sqlite v1.1.4 42 | gorm.io/gorm v1.20.12 43 | nhooyr.io/websocket v1.8.6 44 | ) 45 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:generate bash -c "go get github.com/swaggo/swag/cmd/swag && swag init" 2 | //go:generate bash -c "cd web && rm -rf ./web/dist && npm install --legacy-peer-deps && npm run build && cd .. && go get github.com/rakyll/statik && statik -src ./web/dist -f" 3 | 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "net" 9 | "net/http" 10 | "os" 11 | "strconv" 12 | 13 | "github.com/maxiepax/go-via/api" 14 | "github.com/maxiepax/go-via/config" 15 | ca "github.com/maxiepax/go-via/crypto" 16 | "github.com/maxiepax/go-via/db" 17 | "github.com/maxiepax/go-via/models" 18 | "github.com/maxiepax/go-via/secrets" 19 | "github.com/maxiepax/go-via/websockets" 20 | "github.com/rakyll/statik/fs" 21 | 22 | swaggerFiles "github.com/swaggo/files" 23 | ginSwagger "github.com/swaggo/gin-swagger" 24 | 25 | "github.com/gin-contrib/cors" 26 | "github.com/gin-gonic/gin" 27 | "github.com/koding/multiconfig" 28 | 29 | "github.com/sirupsen/logrus" 30 | 31 | _ "github.com/maxiepax/go-via/docs" 32 | _ "github.com/maxiepax/go-via/statik" 33 | ) 34 | 35 | var ( 36 | version = "dev" 37 | commit = "none" 38 | date = "unknown" 39 | ) 40 | 41 | // @title go-via 42 | // @version 0.1 43 | // @description VMware Imaging Appliances written in GO with full HTTP-REST 44 | 45 | // @BasePath /v1 46 | 47 | func main() { 48 | 49 | logServer := websockets.NewLogServer() 50 | logrus.AddHook(logServer.Hook) 51 | 52 | //setup logging 53 | logrus.WithFields(logrus.Fields{ 54 | "version": version, 55 | "commit": commit, 56 | "date": date, 57 | }).Infof("Startup") 58 | 59 | //enable config 60 | d := multiconfig.New() 61 | 62 | conf := new(config.Config) 63 | 64 | //try to load environment variables and flags. 65 | err := d.Load(conf) 66 | if err != nil { 67 | logrus.WithFields(logrus.Fields{ 68 | "err": err, 69 | }).Info("failed to load config") 70 | } 71 | 72 | //if a file has been implied, also load the content of the configuration file. 73 | if conf.File != "" { 74 | d = multiconfig.NewWithPath(conf.File) 75 | 76 | err = d.Load(conf) 77 | if err != nil { 78 | logrus.WithFields(logrus.Fields{ 79 | "err": err, 80 | }).Info("failed to load config") 81 | } 82 | } 83 | 84 | //validate configuration file 85 | err = d.Validate(conf) 86 | if err != nil { 87 | flag.Usage() 88 | logrus.WithFields(logrus.Fields{ 89 | "err": err, 90 | }).Info("failed to load config") 91 | } 92 | 93 | //if no environemnt variables, or configuration file has been declared, serve on all interfaces. 94 | if len(conf.Network.Interfaces) == 0 { 95 | logrus.Warning("no interfaces have been configured, trying to find interfaces to serve to, will serve on all.") 96 | i, err := net.Interfaces() 97 | if err != nil { 98 | logrus.WithFields(logrus.Fields{ 99 | "err": err, 100 | }).Info("failed to find a usable interface") 101 | } 102 | for _, v := range i { 103 | // dont use loopback interfaces 104 | if v.Flags&net.FlagLoopback != 0 { 105 | continue 106 | } 107 | // dont use ptp interfaces 108 | if v.Flags&net.FlagPointToPoint != 0 { 109 | continue 110 | } 111 | _, _, err := findIPv4Addr(&v) 112 | if err != nil { 113 | logrus.WithFields(logrus.Fields{ 114 | "err": err, 115 | "iface": v.Name, 116 | }).Warning("interaces does not have a usable ipv4 address") 117 | continue 118 | } 119 | conf.Network.Interfaces = append(conf.Network.Interfaces, v.Name) 120 | } 121 | } 122 | 123 | // load secrets key 124 | key := secrets.Init() 125 | 126 | //connect to database 127 | //db.Connect(true) 128 | if conf.Debug { 129 | db.Connect(true) 130 | logrus.SetLevel(logrus.DebugLevel) 131 | } else { 132 | db.Connect(false) 133 | gin.SetMode(gin.ReleaseMode) 134 | } 135 | 136 | //migrate all models 137 | err = db.DB.AutoMigrate(&models.Pool{}, &models.Address{}, &models.Option{}, &models.DeviceClass{}, &models.Group{}, &models.Image{}, &models.User{}) 138 | if err != nil { 139 | logrus.Fatal(err) 140 | } 141 | 142 | //create the device classes for x86 and arm 143 | //64bit x86 UEFI 144 | var x86_64 models.DeviceClass 145 | 146 | if res := db.DB.FirstOrCreate(&x86_64, models.DeviceClass{DeviceClassForm: models.DeviceClassForm{Name: "PXE-UEFI_x64", VendorClass: "PXEClient:Arch:00007"}}); res.Error != nil { 147 | logrus.Warning(res.Error) 148 | } 149 | //64bit ARM UEFI 150 | var arm_64 models.DeviceClass 151 | if res := db.DB.FirstOrCreate(&arm_64, models.DeviceClass{DeviceClassForm: models.DeviceClassForm{Name: "PXE-UEFI_ARM64", VendorClass: "PXEClient:Arch:00011"}}); res.Error != nil { 152 | logrus.Warning(res.Error) 153 | } 154 | 155 | //create admin user if it doesn't exist 156 | var adm models.User 157 | hp := api.HashAndSalt([]byte("VMware1!")) 158 | if res := db.DB.Where(models.User{UserForm: models.UserForm{Username: "admin"}}).Attrs(models.User{UserForm: models.UserForm{Password: hp}}).FirstOrCreate(&adm); res.Error != nil { 159 | logrus.Warning(res.Error) 160 | } 161 | 162 | // DHCPd 163 | if !conf.DisableDhcp { 164 | for _, v := range conf.Network.Interfaces { 165 | go serve(v) 166 | } 167 | } 168 | 169 | // TFTPd 170 | go TFTPd(conf) 171 | 172 | //REST API 173 | r := gin.New() 174 | r.Use(cors.Default()) 175 | 176 | statikFS, err := fs.New() 177 | if err != nil { 178 | logrus.Fatal(err) 179 | } 180 | 181 | // ks.cfg is served at top to not place it behind BasicAuth 182 | r.GET("ks.cfg", api.Ks(key)) 183 | 184 | // middleware to check if user is logged in 185 | r.Use(func(c *gin.Context) { 186 | username, password, hasAuth := c.Request.BasicAuth() 187 | if !hasAuth { 188 | logrus.WithFields(logrus.Fields{ 189 | "login": "unauthorized request", 190 | }).Info("auth") 191 | c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") 192 | c.AbortWithStatus(http.StatusUnauthorized) 193 | return 194 | } 195 | 196 | //get the user that is trying to authenticate 197 | var user models.User 198 | if res := db.DB.Select("username", "password").Where("username = ?", username).First(&user); res.Error != nil { 199 | logrus.WithFields(logrus.Fields{ 200 | "username": username, 201 | "status": "supplied username does not exist", 202 | }).Info("auth") 203 | c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") 204 | c.AbortWithStatus(http.StatusUnauthorized) 205 | return 206 | } 207 | 208 | //check if passwords match 209 | if api.ComparePasswords(user.Password, []byte(password), username) { 210 | logrus.WithFields(logrus.Fields{ 211 | "username": username, 212 | "status": "successfully authenticated", 213 | }).Debug("auth") 214 | } else { 215 | logrus.WithFields(logrus.Fields{ 216 | "username": username, 217 | "status": "invalid password supplied", 218 | }).Info("auth") 219 | c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") 220 | c.AbortWithStatus(http.StatusUnauthorized) 221 | return 222 | } 223 | c.Next() 224 | }) 225 | 226 | r.NoRoute(func(c *gin.Context) { 227 | c.Request.URL.Path = "/web/" // force us to always return index.html and not the requested page to be compatible with HTML5 routing 228 | http.FileServer(statikFS).ServeHTTP(c.Writer, c.Request) 229 | }) 230 | 231 | ui := r.Group("/") 232 | { 233 | ui.GET("/web/*all", gin.WrapH(http.FileServer(statikFS))) 234 | 235 | ui.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) 236 | } 237 | 238 | v1 := r.Group("/v1") 239 | { 240 | //v1.GET("log", logServer.Handle) 241 | 242 | pools := v1.Group("/pools") 243 | { 244 | pools.GET("", api.ListPools) 245 | pools.GET(":id", api.GetPool) 246 | pools.POST("/search", api.SearchPool) 247 | pools.POST("", api.CreatePool) 248 | pools.PATCH(":id", api.UpdatePool) 249 | pools.DELETE(":id", api.DeletePool) 250 | 251 | pools.GET(":id/next", api.GetNextFreeIP) 252 | } 253 | relay := v1.Group("/relay") 254 | { 255 | relay.GET(":relay", api.GetPoolByRelay) 256 | } 257 | 258 | addresses := v1.Group("/addresses") 259 | { 260 | addresses.GET("", api.ListAddresses) 261 | addresses.GET(":id", api.GetAddress) 262 | addresses.POST("/search", api.SearchAddress) 263 | addresses.POST("", api.CreateAddress) 264 | addresses.PATCH(":id", api.UpdateAddress) 265 | addresses.DELETE(":id", api.DeleteAddress) 266 | } 267 | 268 | options := v1.Group("/options") 269 | { 270 | options.GET("", api.ListOptions) 271 | options.GET(":id", api.GetOption) 272 | options.POST("/search", api.SearchOption) 273 | options.POST("", api.CreateOption) 274 | options.PATCH(":id", api.UpdateOption) 275 | options.DELETE(":id", api.DeleteOption) 276 | } 277 | 278 | deviceClass := v1.Group("/device_classes") 279 | { 280 | deviceClass.GET("", api.ListDeviceClasses) 281 | deviceClass.GET(":id", api.GetDeviceClass) 282 | deviceClass.POST("/search", api.SearchDeviceClass) 283 | deviceClass.POST("", api.CreateDeviceClass) 284 | deviceClass.PATCH(":id", api.UpdateDeviceClass) 285 | deviceClass.DELETE(":id", api.DeleteDeviceClass) 286 | } 287 | 288 | groups := v1.Group("/groups") 289 | { 290 | groups.GET("", api.ListGroups) 291 | groups.GET(":id", api.GetGroup) 292 | groups.POST("", api.CreateGroup(key)) 293 | groups.PATCH(":id", api.UpdateGroup(key)) 294 | groups.DELETE(":id", api.DeleteGroup) 295 | } 296 | 297 | images := v1.Group("/images") 298 | { 299 | images.GET("", api.ListImages) 300 | images.GET(":id", api.GetImage) 301 | images.POST("", api.CreateImage(conf)) 302 | images.PATCH(":id", api.UpdateImage) 303 | images.DELETE(":id", api.DeleteImage) 304 | } 305 | 306 | users := v1.Group("/users") 307 | { 308 | users.GET("", api.ListUsers) 309 | users.GET(":id", api.GetUser) 310 | users.POST("", api.CreateUser) 311 | users.PATCH(":id", api.UpdateUser) 312 | users.DELETE(":id", api.DeleteUser) 313 | } 314 | 315 | postconfig := v1.Group("/postconfig") 316 | { 317 | postconfig.GET("", api.PostConfig(key)) 318 | postconfig.GET(":id", api.PostConfigID(key)) 319 | } 320 | 321 | v1.GET("log", logServer.Handle) 322 | 323 | v1.GET("version", api.Version(version, commit, date)) 324 | } 325 | 326 | /* r.GET("postconfig", api.PostConfig) */ 327 | 328 | // check if ./cert/server.crt exists, if not we will create the folder, and initiate a new CA and a self-signed certificate 329 | crt, err := os.Stat("./cert/server.crt") 330 | if os.IsNotExist(err) { 331 | // create folder for certificates 332 | logrus.WithFields(logrus.Fields{ 333 | "certificate": "server.crt does not exist, initiating new CA and creating self-signed ceritificate server.crt", 334 | }).Info("cert") 335 | os.MkdirAll("cert", os.ModePerm) 336 | ca.CreateCA() 337 | ca.CreateCert("./cert", "server", "server") 338 | } else { 339 | logrus.WithFields(logrus.Fields{ 340 | crt.Name(): "server.crt found", 341 | }).Info("cert") 342 | } 343 | //enable HTTPS 344 | listen := ":" + strconv.Itoa(conf.Port) 345 | logrus.WithFields(logrus.Fields{ 346 | "port": listen, 347 | }).Info("Webserver") 348 | err = r.RunTLS(listen, "./cert/server.crt", "./cert/server.key") 349 | 350 | logrus.WithFields(logrus.Fields{ 351 | "error": err, 352 | }).Error("Webserver") 353 | 354 | } 355 | -------------------------------------------------------------------------------- /models/address.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type AddressForm struct { 8 | IP string `json:"ip" gorm:"type:varchar(15);not null;index:uniqIp,unique"` 9 | Mac string `json:"mac" gorm:"type:varchar(17);not null"` 10 | Hostname string `json:"hostname" gorm:"type:varchar(255)"` 11 | Domain string `json:"domain" gorm:"type:varchar(255)"` 12 | Reimage bool `json:"reimage" gorm:"type:bool;index:uniqIp,unique"` 13 | PoolID NullInt32 `json:"pool_id" gorm:"type:BIGINT" swaggertype:"integer"` 14 | GroupID NullInt32 `json:"group_id" gorm:"type:BIGINT" swaggertype:"integer"` 15 | Progress int `json:"progress" gorm:"type:INT"` 16 | Progresstext string `json:"progresstext" gorm:"type:varchar(255)"` 17 | Ks string `json:"ks" gorm:"type:text"` 18 | } 19 | 20 | type Address struct { 21 | ID int `json:"id" gorm:"primary_key"` 22 | 23 | Pool Pool `json:"pool" gorm:"foreignkey:PoolID"` 24 | Group Group `json:"group" gorm:"foreignkey:GroupID"` 25 | 26 | AddressForm 27 | 28 | FirstSeen time.Time `json:"first_seen"` 29 | LastSeen time.Time `json:"last_seen"` 30 | 31 | // DHCP parameters 32 | LastSeenRelay string `json:"last_seen_relay" gorm:"type:varchar(15)"` 33 | MissingOptions string `json:"missing_options" gorm:"type:varchar(255)"` 34 | Expires time.Time `json:"expires_at"` 35 | 36 | CreatedAt time.Time `json:"created_at"` 37 | UpdatedAt time.Time `json:"updated_at"` 38 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 39 | } 40 | -------------------------------------------------------------------------------- /models/api.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type APIError struct { 4 | ErrorStatus int `json:"error_status"` 5 | ErrorMessage string `json:"error_message"` 6 | } 7 | -------------------------------------------------------------------------------- /models/device_class.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type DeviceClassForm struct { 8 | Name string `json:"name" gorm:"type:varchar(255)"` 9 | VendorClass string `json:"vendor_class" gorm:"type:varchar(255)"` 10 | } 11 | 12 | type DeviceClass struct { 13 | ID int `json:"id" gorm:"primary_key"` 14 | 15 | DeviceClassForm 16 | 17 | CreatedAt time.Time `json:"created_at"` 18 | UpdatedAt time.Time `json:"updated_at"` 19 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 20 | } 21 | -------------------------------------------------------------------------------- /models/group.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/datatypes" 7 | ) 8 | 9 | type GroupForm struct { 10 | PoolID int `json:"pool_id" gorm:"type:BIGINT"` 11 | Name string `json:"name" gorm:"type:varchar(255)"` 12 | DNS string `json:"dns" gorm:"type:varchar(255)"` 13 | NTP string `json:"ntp" gorm:"type:varchar(255)"` 14 | Password string `json:"password" gorm:"type:varchar(255)"` 15 | ImageID int `json:"image_id" gorm:"type:INT"` 16 | Ks string `json:"ks" gorm:"type:text"` 17 | Syslog string `json:"syslog" gorm:"type:varchar(255)"` 18 | Vlan string `json:"vlan" gorm:"type:INT"` 19 | CallbackURL string `json:"callbackurl"` 20 | BootDisk string `json:"bootdisk" gorm:"type:varchar(255)"` 21 | Options datatypes.JSON `json:"options" sql:"type:JSONB" swaggertype:"object,string"` 22 | } 23 | 24 | type NoPWGroupForm struct { 25 | PoolID int `json:"pool_id" gorm:"type:BIGINT"` 26 | Name string `json:"name" gorm:"type:varchar(255)"` 27 | DNS string `json:"dns" gorm:"type:varchar(255)"` 28 | NTP string `json:"ntp" gorm:"type:varchar(255)"` 29 | ImageID int `json:"image_id" gorm:"type:INT"` 30 | Ks string `json:"ks" gorm:"type:text"` 31 | Syslog string `json:"syslog" gorm:"type:varchar(255)"` 32 | Vlan string `json:"vlan" gorm:"type:INT"` 33 | CallbackURL string `json:"callbackurl"` 34 | BootDisk string `json:"bootdisk" gorm:"type:varchar(255)"` 35 | Options datatypes.JSON `json:"options" sql:"type:JSONB" swaggertype:"object,string"` 36 | } 37 | 38 | type Group struct { 39 | ID int `json:"id" gorm:"primary_key"` 40 | 41 | GroupForm 42 | 43 | Pool *Pool `json:"pool,omitempty" gorm:"foreignkey:PoolID"` 44 | Option []Option `json:"option,omitempty" gorm:"foreignkey:PoolID"` 45 | Address []Address `json:"address,omitempty" gorm:"foreignkey:GroupID"` 46 | 47 | CreatedAt time.Time `json:"created_at"` 48 | UpdatedAt time.Time `json:"updated_at"` 49 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 50 | } 51 | 52 | type NoPWGroup struct { 53 | ID int `json:"id" gorm:"primary_key"` 54 | 55 | NoPWGroupForm 56 | 57 | Pool *Pool `json:"pool,omitempty" gorm:"foreignkey:PoolID"` 58 | Option []Option `json:"option,omitempty" gorm:"foreignkey:PoolID"` 59 | Address []Address `json:"address,omitempty" gorm:"foreignkey:GroupID"` 60 | 61 | CreatedAt time.Time `json:"created_at"` 62 | UpdatedAt time.Time `json:"updated_at"` 63 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 64 | } 65 | 66 | func (NoPWGroup) TableName() string { 67 | return "groups" 68 | } 69 | 70 | type GroupOptions struct { 71 | SSH bool `json:"ssh"` 72 | SuppressShellWarning bool `json:"suppressshellwarning"` 73 | EraseDisks bool `json:"erasedisks"` 74 | AllowLegacyCPU bool `json:"allowlegacycpu"` 75 | Certificate bool `json:"certificate"` 76 | CreateVMFS bool `json:"createvmfs"` 77 | } 78 | -------------------------------------------------------------------------------- /models/image.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ImageForm struct { 8 | ISOImage string `json:"iso_image" gorm:"type:varchar(255)"` 9 | Path string `json:"path" gorm:"type:varchar(255)"` 10 | Size int64 `json:"size" gorm:"type:BIGINT"` 11 | Hash string `json:"hash" gorm:"type:varchar(255)"` 12 | Description string `json:"description" gorm:"type:text"` 13 | } 14 | 15 | type Image struct { 16 | ID int `json:"id" gorm:"primary_key"` 17 | 18 | ImageForm 19 | 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 23 | } 24 | -------------------------------------------------------------------------------- /models/nullable_int.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "strconv" 7 | ) 8 | 9 | type NullInt32 struct { 10 | sql.NullInt32 11 | } 12 | 13 | // NullInt64 MarshalJSON interface redefinition 14 | func (r NullInt32) MarshalJSON() ([]byte, error) { 15 | if r.Valid { 16 | return json.Marshal(r.Int32) 17 | } else { 18 | return json.Marshal(nil) 19 | } 20 | } 21 | 22 | func (ns *NullInt32) UnmarshalJSON(text []byte) error { 23 | txt := string(text) 24 | ns.Valid = true 25 | if txt == "null" { 26 | ns.Valid = false 27 | return nil 28 | } 29 | i, err := strconv.ParseInt(txt, 10, 32) 30 | if err != nil { 31 | ns.Valid = false 32 | return err 33 | } 34 | j := int32(i) 35 | ns.Int32 = j 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /models/option.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/google/gopacket/layers" 11 | ) 12 | 13 | type OptionForm struct { 14 | PoolID int `json:"pool_id" gorm:"type:BIGINT"` 15 | AddressID int `json:"address_id" gorm:"type:BIGINT"` 16 | DeviceClassID int `json:"device_class_id" gorm:"type:BIGINT"` 17 | OpCode byte `json:"opcode" gorm:"type:SMALLINT;unsigned;not null" binding:"required" ` 18 | Data string `json:"data" gorm:"type:varchar(255);not null" binding:"required" ` 19 | Priority int `json:"priority" gorm:"type:SMALLINT;not null" binding:"required" ` 20 | } 21 | 22 | type Option struct { 23 | ID int `json:"id" gorm:"primary_key"` 24 | 25 | OptionForm 26 | 27 | Pool *Pool `json:"pool,omitempty" gorm:"foreignkey:PoolID"` 28 | Address *Address `json:"address,omitempty" gorm:"foreignkey:AddressID"` 29 | 30 | CreatedAt time.Time `json:"created_at"` 31 | UpdatedAt time.Time `json:"updated_at"` 32 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 33 | } 34 | 35 | func (o Option) Level() int { 36 | if o.DeviceClassID > 0 { 37 | if o.AddressID > 0 { 38 | return 5 39 | } 40 | 41 | if o.PoolID > 0 { 42 | return 4 43 | } 44 | 45 | return 3 46 | } 47 | 48 | if o.AddressID > 0 { 49 | return 2 50 | } 51 | 52 | if o.PoolID > 0 { 53 | return 1 54 | } 55 | 56 | return 0 57 | } 58 | 59 | func (o Option) ToDHCPOption() (opt layers.DHCPOption, merge bool, err error) { 60 | code := layers.DHCPOpt(o.OpCode) 61 | switch code { 62 | case // string 63 | layers.DHCPOptHostname, 64 | layers.DHCPOptMeritDumpFile, 65 | layers.DHCPOptDomainName, 66 | layers.DHCPOptRootPath, 67 | layers.DHCPOptExtensionsPath, 68 | layers.DHCPOptNISDomain, 69 | layers.DHCPOptNetBIOSTCPScope, 70 | layers.DHCPOptXFontServer, 71 | layers.DHCPOptXDisplayManager, 72 | layers.DHCPOptMessage, 73 | layers.DHCPOptDomainSearch, 74 | layers.DHCPOptSIPServers, 75 | 66, // TFTP server name 76 | 67: // TFTP file name 77 | 78 | return NewStringOption(code, o.Data), false, nil 79 | case // net.IP 80 | layers.DHCPOptSubnetMask, 81 | layers.DHCPOptBroadcastAddr, 82 | layers.DHCPOptSolicitAddr: 83 | 84 | return NewIPOption(code, net.ParseIP(o.Data)), false, nil 85 | case // n*net.IP 86 | layers.DHCPOptRouter, 87 | layers.DHCPOptTimeServer, 88 | layers.DHCPOptNameServer, 89 | layers.DHCPOptDNS, 90 | layers.DHCPOptLogServer, 91 | layers.DHCPOptCookieServer, 92 | layers.DHCPOptLPRServer, 93 | layers.DHCPOptImpressServer, 94 | layers.DHCPOptResLocServer, 95 | layers.DHCPOptSwapServer, 96 | layers.DHCPOptNISServers, 97 | layers.DHCPOptNTPServers, 98 | layers.DHCPOptNetBIOSTCPNS, 99 | layers.DHCPOptNetBIOSTCPDDS: 100 | 101 | return NewIPOption(code, net.ParseIP(o.Data).To4()), true, nil 102 | case // uint16 103 | layers.DHCPOptBootfileSize, 104 | layers.DHCPOptDatagramMTU, 105 | layers.DHCPOptInterfaceMTU, 106 | layers.DHCPOptMaxMessageSize: 107 | 108 | i, err := strconv.Atoi(o.Data) 109 | if err != nil { 110 | return opt, false, err 111 | } 112 | 113 | return NewUint16Option(code, i), false, nil 114 | case // n*uint16 115 | layers.DHCPOptPathPlateuTableOption: 116 | 117 | i, err := strconv.Atoi(o.Data) 118 | if err != nil { 119 | return opt, false, err 120 | } 121 | 122 | return NewUint16Option(code, i), true, nil 123 | case // int32 (signed seconds from UTC) 124 | layers.DHCPOptTimeOffset: 125 | 126 | i, err := strconv.Atoi(o.Data) 127 | if err != nil { 128 | return opt, false, err 129 | } 130 | 131 | return NewInt32Option(code, i), false, nil 132 | case // uint32 133 | layers.DHCPOptT1, 134 | layers.DHCPOptT2, 135 | layers.DHCPOptLeaseTime, 136 | layers.DHCPOptPathMTUAgingTimeout, 137 | layers.DHCPOptARPTimeout, 138 | layers.DHCPOptTCPKeepAliveInt: 139 | 140 | i, err := strconv.Atoi(o.Data) 141 | if err != nil { 142 | return opt, false, err 143 | } 144 | 145 | return NewUint32Option(code, i), false, nil 146 | } 147 | 148 | return opt, false, fmt.Errorf("unsupported dhcp option type %d", o.OpCode) 149 | } 150 | 151 | func NewUint16Option(t layers.DHCPOpt, v int) layers.DHCPOption { 152 | vi := uint16(v) 153 | buf := make([]byte, 2) 154 | binary.BigEndian.PutUint16(buf, vi) 155 | 156 | return layers.NewDHCPOption(t, buf) 157 | } 158 | 159 | func NewInt32Option(t layers.DHCPOpt, v int) layers.DHCPOption { 160 | vi := int32(v) 161 | buf := make([]byte, 4) 162 | binary.BigEndian.PutUint32(buf, uint32(vi)) 163 | 164 | return layers.NewDHCPOption(t, buf) 165 | } 166 | 167 | func NewUint32Option(t layers.DHCPOpt, v int) layers.DHCPOption { 168 | vi := uint32(v) 169 | buf := make([]byte, 4) 170 | binary.BigEndian.PutUint32(buf, vi) 171 | 172 | return layers.NewDHCPOption(t, buf) 173 | } 174 | 175 | func NewStringOption(t layers.DHCPOpt, v string) layers.DHCPOption { 176 | return layers.NewDHCPOption(t, []byte(v)) 177 | } 178 | 179 | func NewIPOption(t layers.DHCPOpt, v net.IP) layers.DHCPOption { 180 | return layers.NewDHCPOption(t, v) 181 | } 182 | -------------------------------------------------------------------------------- /models/pool.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/maxiepax/go-via/db" 11 | "gorm.io/gorm" 12 | ) 13 | 14 | type PoolForm struct { 15 | Name string `json:"name" gorm:"type:varchar(255);not null" binding:"required" ` 16 | StartAddress string `json:"start_address" gorm:"type:varchar(15);not null" binding:"required" ` 17 | EndAddress string `json:"end_address" gorm:"type:varchar(15);not null" binding:"required" ` 18 | Netmask int `json:"netmask" gorm:"type:integer;not null" binding:"required" ` 19 | LeaseTime int `json:"lease_time" gorm:"type:bigint" binding:"required" ` 20 | Gateway string `json:"gateway" gorm:"type:varchar(15)" binding:"required" ` 21 | OnlyServeReimage bool `json:"only_serve_reimage" gorm:"type:boolean"` 22 | 23 | AuthorizedVlan int `json:"authorized_vlan" gorm:"type:bigint"` 24 | ManagedRef string `json:"managed_reference"` 25 | } 26 | 27 | type Pool struct { 28 | ID int `json:"id" gorm:"primary_key"` 29 | 30 | NetAddress string `json:"net_address" gorm:"type:varchar(15);not null"` 31 | PoolForm 32 | 33 | CreatedAt time.Time `json:"created_at"` 34 | UpdatedAt time.Time `json:"updated_at"` 35 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 36 | } 37 | 38 | type PoolWithAddresses struct { 39 | Pool 40 | Addresses []Address `json:"address,omitempty" gorm:"foreignkey:PoolID"` 41 | } 42 | 43 | func (p *Pool) BeforeCreate(tx *gorm.DB) error { 44 | return p.BeforeSave(tx) 45 | } 46 | 47 | func (p *Pool) BeforeSave(tx *gorm.DB) error { 48 | if p.Netmask < 1 || p.Netmask > 32 { 49 | return fmt.Errorf("invalid netmask") 50 | } 51 | 52 | cidrMask := "/" + strconv.Itoa(p.Netmask) 53 | _, startNet, err := net.ParseCIDR(p.StartAddress + cidrMask) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | _, endNet, err := net.ParseCIDR(p.EndAddress + cidrMask) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if !startNet.IP.Equal(endNet.IP) { 64 | return fmt.Errorf("start and end address do not belong to the same network") 65 | } 66 | 67 | p.NetAddress = startNet.IP.String() 68 | 69 | return nil 70 | } 71 | 72 | // Next returns the next free address in the pool (that is not reserved nor already leased) 73 | func (p *PoolWithAddresses) Next() (ip net.IP, err error) { 74 | cidrMask := "/" + strconv.Itoa(p.Netmask) 75 | startIP, startNet, err := net.ParseCIDR(p.StartAddress + cidrMask) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | endIP, _, err := net.ParseCIDR(p.EndAddress + cidrMask) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if startIP.IsUnspecified() { 86 | return nil, fmt.Errorf("start address is unspecified") 87 | } 88 | 89 | for ip := startIP; startNet.Contains(ip); next(ip) { 90 | if ip.IsMulticast() || ip.IsLoopback() { 91 | continue 92 | } 93 | 94 | if err := p.IsAvailable(ip); err == nil { 95 | return ip, nil 96 | } 97 | 98 | if ip.Equal(endIP) { 99 | break 100 | } 101 | } 102 | 103 | return nil, fmt.Errorf("could not find a free address") 104 | } 105 | 106 | func (p *PoolWithAddresses) IsAvailable(ip net.IP) error { 107 | return p.IsAvailableExcept(ip, "") 108 | } 109 | 110 | func (p *PoolWithAddresses) Contains(ip net.IP) (bool, error) { 111 | cidrMask := "/" + strconv.Itoa(p.Netmask) 112 | _, startNet, err := net.ParseCIDR(p.StartAddress + cidrMask) 113 | if err != nil { 114 | return false, err 115 | } 116 | 117 | return startNet.Contains(ip), nil 118 | } 119 | 120 | func (p *PoolWithAddresses) IsAvailableExcept(ip net.IP, exclude string) error { 121 | ok, err := p.Contains(ip) 122 | if err != nil { 123 | return err 124 | } 125 | if !ok { 126 | return fmt.Errorf("does not belong to the pool") 127 | } 128 | 129 | s := ip.String() 130 | if s == p.Gateway { 131 | return fmt.Errorf("cant use the gateway address") 132 | } 133 | 134 | // Check all loaded addresses 135 | for _, v := range p.Addresses { 136 | if v.IP == s && v.Expires.After(time.Now()) && v.Mac != exclude { 137 | return fmt.Errorf("already leased (%d)", v.ID) 138 | } 139 | } 140 | 141 | // Check reservations as well 142 | var reservations []Address 143 | db.DB.Where("ip = ? AND reimage", s).Find(&reservations) 144 | for _, v := range reservations { 145 | if v.IP == s && v.Mac != exclude { 146 | return fmt.Errorf("already reserved") 147 | } 148 | } 149 | 150 | return nil 151 | } 152 | 153 | // Credit to Mikio Hara and pnovotnak https://stackoverflow.com/questions/36166791/how-to-get-broadcast-address-of-ipv4-net-ipnet 154 | func (p *Pool) LastAddr() (net.IP, error) { 155 | cidrMask := "/" + strconv.Itoa(p.Netmask) 156 | _, startNet, err := net.ParseCIDR(p.StartAddress + cidrMask) 157 | if err != nil { 158 | return net.IP{}, err 159 | } 160 | 161 | if startNet.IP.To4() == nil { 162 | return net.IP{}, fmt.Errorf("does not support IPv6 addresses") 163 | } 164 | ip := make(net.IP, len(startNet.IP.To4())) 165 | binary.BigEndian.PutUint32(ip, binary.BigEndian.Uint32(startNet.IP.To4())|^binary.BigEndian.Uint32(startNet.Mask)) 166 | return ip, nil 167 | } 168 | 169 | func next(ip net.IP) { 170 | for j := len(ip) - 1; j >= 0; j-- { 171 | ip[j]++ 172 | if ip[j] > 0 { 173 | break 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type UserForm struct { 8 | Username string `json:"username" gorm:"type:varchar(255)"` 9 | Password string `json:"password" gorm:"type:varchar(255)"` 10 | Email string `json:"email" gorm:"type:varchar(255)"` 11 | Comment string `json:"comment" gorm:"type:varchar(255)"` 12 | } 13 | 14 | type User struct { 15 | ID int `json:"id" gorm:"primary_key"` 16 | 17 | UserForm 18 | 19 | CreatedAt time.Time `json:"created_at"` 20 | UpdatedAt time.Time `json:"updated_at"` 21 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 22 | } 23 | -------------------------------------------------------------------------------- /secrets/main.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/hex" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "os" 12 | 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // Credits to this person for excellent code: https://www.melvinvivas.com/how-to-encrypt-and-decrypt-data-using-aes/ 17 | 18 | func Init() string { 19 | var key []byte 20 | if _, err := os.Stat("secret/secret.key"); os.IsNotExist(err) { 21 | //secrets file does not exist, create folder and file 22 | os.MkdirAll("secret", os.ModePerm) 23 | logrus.WithFields(logrus.Fields{ 24 | "key": "no secrets file has been detected, attempting to create a new one and generate secret key", 25 | }).Info("secrets") 26 | file, err := os.Create("secret/secret.key") 27 | if err != nil { 28 | logrus.Fatal(err.Error()) 29 | } 30 | logrus.WithFields(logrus.Fields{ 31 | "key": "secret/secret.key created", 32 | }).Info("secrets") 33 | 34 | //generate a random 32 byte AES-256 key 35 | bytes := make([]byte, 32) 36 | if _, err := rand.Read(bytes); err != nil { 37 | panic(err.Error()) 38 | } 39 | 40 | //convert key to string and write to file 41 | hexkey := hex.EncodeToString(bytes) 42 | wr, err := file.WriteString(hexkey) 43 | if err != nil { 44 | logrus.Fatal(err.Error()) 45 | } 46 | logrus.WithFields(logrus.Fields{ 47 | "key": "secret key persisted to file", 48 | "bytes": wr, 49 | }).Info("secrets") 50 | file.Close() 51 | key, _ = ioutil.ReadFile("secret/secret.key") 52 | } else { 53 | //Database exists, moving on. 54 | logrus.WithFields(logrus.Fields{ 55 | "key": "found existing secret key!", 56 | }).Info("secrets") 57 | key, _ = ioutil.ReadFile("secret/secret.key") 58 | } 59 | return string(key) 60 | } 61 | 62 | func Encrypt(stringToEncrypt string, keyString string) (encryptedString string) { 63 | 64 | //Since the key is in string, we need to convert decode it to bytes 65 | key, _ := hex.DecodeString(keyString) 66 | plaintext := []byte(stringToEncrypt) 67 | 68 | //Create a new Cipher Block from the key 69 | block, err := aes.NewCipher(key) 70 | if err != nil { 71 | panic(err.Error()) 72 | } 73 | 74 | //Create a new GCM - https://en.wikipedia.org/wiki/Galois/Counter_Mode 75 | //https://golang.org/pkg/crypto/cipher/#NewGCM 76 | aesGCM, err := cipher.NewGCM(block) 77 | if err != nil { 78 | panic(err.Error()) 79 | } 80 | 81 | //Create a nonce. Nonce should be from GCM 82 | nonce := make([]byte, aesGCM.NonceSize()) 83 | if _, err = io.ReadFull(rand.Reader, nonce); err != nil { 84 | panic(err.Error()) 85 | } 86 | 87 | //Encrypt the data using aesGCM.Seal 88 | //Since we don't want to save the nonce somewhere else in this case, we add it as a prefix to the encrypted data. The first nonce argument in Seal is the prefix. 89 | ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil) 90 | return fmt.Sprintf("%x", ciphertext) 91 | } 92 | 93 | func Decrypt(encryptedString string, keyString string) (decryptedString string) { 94 | 95 | key, _ := hex.DecodeString(keyString) 96 | enc, _ := hex.DecodeString(encryptedString) 97 | 98 | //Create a new Cipher Block from the key 99 | block, err := aes.NewCipher(key) 100 | if err != nil { 101 | panic(err.Error()) 102 | } 103 | 104 | //Create a new GCM 105 | aesGCM, err := cipher.NewGCM(block) 106 | if err != nil { 107 | panic(err.Error()) 108 | } 109 | 110 | //Get the nonce size 111 | nonceSize := aesGCM.NonceSize() 112 | 113 | //Extract the nonce from the encrypted data 114 | nonce, ciphertext := enc[:nonceSize], enc[nonceSize:] 115 | 116 | //Decrypt the data 117 | plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) 118 | if err != nil { 119 | panic(err.Error()) 120 | } 121 | 122 | return fmt.Sprintf("%s", plaintext) 123 | } 124 | -------------------------------------------------------------------------------- /serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | //"github.com/davecgh/go-spew/spew" 8 | "github.com/google/gopacket" 9 | "github.com/google/gopacket/layers" 10 | "github.com/mdlayher/raw" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func serve(intf string) { 15 | // Select interface to used 16 | ifi, err := net.InterfaceByName(intf) 17 | if err != nil { 18 | logrus.WithFields(logrus.Fields{ 19 | "if": intf, 20 | "err": err, 21 | }).Fatalf("dhcp: failed to open interface") 22 | } 23 | 24 | // Find the ip-address 25 | ip, ipNet, err := findIPv4Addr(ifi) 26 | if err != nil { 27 | logrus.WithFields(logrus.Fields{ 28 | "if": intf, 29 | "err": err, 30 | }).Fatalf("dhcp: failed to get interface IPv4 address") 31 | } 32 | 33 | mac := ifi.HardwareAddr 34 | 35 | // Open a raw socket using ethertype 0x0800 (IPv4) 36 | c, err := raw.ListenPacket(ifi, 0x0800, &raw.Config{}) 37 | if err != nil { 38 | logrus.Fatalf("dhcp: failed to listen: %v", err) 39 | } 40 | defer c.Close() 41 | 42 | logrus.WithFields(logrus.Fields{ 43 | "mac": mac, 44 | "ip": ip, 45 | "int": intf, 46 | }).Infof("Starting dhcp server") 47 | 48 | // Accept frames up to interface's MTU in size 49 | b := make([]byte, ifi.MTU) 50 | 51 | // Keep reading frames 52 | for { 53 | n, src, err := c.ReadFrom(b) 54 | if err != nil { 55 | logrus.Fatalf("dhcp: failed to receive message: %v", err) 56 | } 57 | 58 | packet := gopacket.NewPacket(b[:n], layers.LayerTypeEthernet, gopacket.Default) 59 | 60 | ethLayer := packet.Layer(layers.LayerTypeEthernet) 61 | ipv4Layer := packet.Layer(layers.LayerTypeIPv4) 62 | udpLayer := packet.Layer(layers.LayerTypeUDP) 63 | dhcpLayer := packet.Layer(layers.LayerTypeDHCPv4) 64 | 65 | if ethLayer != nil && ipv4Layer != nil && udpLayer != nil && dhcpLayer != nil { 66 | eth, _ := ethLayer.(*layers.Ethernet) 67 | ipv4, _ := ipv4Layer.(*layers.IPv4) 68 | udp, _ := udpLayer.(*layers.UDP) 69 | req, _ := dhcpLayer.(*layers.DHCPv4) 70 | 71 | //spew.Dump(req) 72 | 73 | t := findMsgType(req) 74 | sourceNet := ip 75 | source := "broadcast" 76 | if ipNet != nil && !ipNet.Contains(ipv4.SrcIP) && !ipv4.SrcIP.Equal(net.IPv4zero) { 77 | sourceNet = ipv4.SrcIP 78 | source = "unicast" 79 | } 80 | 81 | if (req.RelayAgentIP != nil && !req.RelayAgentIP.Equal(net.IP{0, 0, 0, 0})) { 82 | sourceNet = req.RelayAgentIP 83 | source = "relayed" 84 | } 85 | 86 | resp, err := processPacket(t, req, sourceNet, ip) 87 | 88 | if err != nil { 89 | logrus.WithFields(logrus.Fields{ 90 | "type": t.String(), 91 | "client-mac": req.ClientHWAddr.String(), 92 | "source": sourceNet.String(), 93 | "relay": req.RelayAgentIP, 94 | "error": err, 95 | }).Warnf("dhcp: failed to process %s %s", source, t) 96 | continue 97 | } 98 | 99 | // Copy some information from the request like option 82 (agent info) to the response 100 | resp.Flags = req.Flags 101 | for _, v := range req.Options { 102 | if v.Type == layers.DHCPOptClientID { 103 | resp.Options = append(resp.Options, v) 104 | } 105 | if v.Type == layers.DHCPOptHostname { 106 | resp.Options = append(resp.Options, v) 107 | } 108 | if v.Type == 82 { 109 | resp.Options = append(resp.Options, v) 110 | } 111 | } 112 | 113 | layers := buildHeaders(mac, ip, eth, ipv4, udp) 114 | layers = append(layers, resp) 115 | 116 | buf := gopacket.NewSerializeBuffer() 117 | opts := gopacket.SerializeOptions{ 118 | FixLengths: true, 119 | ComputeChecksums: true, 120 | } 121 | err = gopacket.SerializeLayers(buf, opts, layers...) 122 | if err != nil { 123 | logrus.WithFields(logrus.Fields{ 124 | "response": findMsgType(resp).String(), 125 | "client-mac": req.ClientHWAddr.String(), 126 | "ip": resp.YourClientIP, 127 | "relay": req.RelayAgentIP, 128 | }).Warnf("dhcp: failed to serialise response to %s %s", source, t) 129 | continue 130 | } 131 | 132 | c.WriteTo(buf.Bytes(), src) 133 | 134 | //spew.Dump(resp) 135 | logrus.WithFields(logrus.Fields{ 136 | "response": findMsgType(resp).String(), 137 | "client-mac": req.ClientHWAddr.String(), 138 | "ip": resp.YourClientIP, 139 | "relay": req.RelayAgentIP, 140 | }).Infof("dhcp: answered %s %s with %s", source, t, findMsgType(resp)) 141 | for _, v := range resp.Options { 142 | logrus.Debug(v) 143 | } 144 | } 145 | } 146 | } 147 | 148 | func findMsgType(p *layers.DHCPv4) layers.DHCPMsgType { 149 | var msgType layers.DHCPMsgType 150 | for _, o := range p.Options { 151 | if o.Type == layers.DHCPOptMessageType { 152 | msgType = layers.DHCPMsgType(o.Data[0]) 153 | } 154 | } 155 | 156 | return msgType 157 | } 158 | 159 | func buildHeaders(mac net.HardwareAddr, ip net.IP, srcEth *layers.Ethernet, srcIP4 *layers.IPv4, srcUDP *layers.UDP) []gopacket.SerializableLayer { 160 | eth := &layers.Ethernet{ 161 | SrcMAC: mac, 162 | DstMAC: srcEth.SrcMAC, 163 | EthernetType: layers.EthernetTypeIPv4, 164 | } 165 | ip4 := &layers.IPv4{ 166 | SrcIP: ip, 167 | DstIP: srcIP4.SrcIP, 168 | Version: 4, 169 | TOS: 0x10, 170 | TTL: 128, 171 | Protocol: layers.IPProtocolUDP, 172 | Flags: layers.IPv4DontFragment, 173 | } 174 | 175 | udp := &layers.UDP{ 176 | SrcPort: 67, // bootps 177 | DstPort: 67, // bootps 178 | } 179 | 180 | // Answer to broadcast address if source address is 0.0.0.0 181 | if srcIP4.SrcIP.Equal(net.IPv4zero) { 182 | ip4.DstIP = net.IPv4(255, 255, 255, 255) 183 | udp.DstPort = 68 184 | } 185 | 186 | udp.SetNetworkLayerForChecksum(ip4) 187 | 188 | return []gopacket.SerializableLayer{eth, ip4, udp} 189 | } 190 | 191 | func findIPv4Addr(ifi *net.Interface) (net.IP, *net.IPNet, error) { 192 | addrs, err := ifi.Addrs() 193 | if err != nil { 194 | return nil, nil, err 195 | } 196 | for _, addr := range addrs { 197 | switch v := addr.(type) { 198 | case *net.IPAddr: 199 | if addr := v.IP.To4(); addr != nil { 200 | return addr, nil, nil 201 | } 202 | case *net.IPNet: 203 | if addr := v.IP.To4(); addr != nil { 204 | return addr, v, nil 205 | } 206 | } 207 | } 208 | 209 | return nil, nil, fmt.Errorf("could not find IPv4 address") 210 | } 211 | -------------------------------------------------------------------------------- /tftpd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2015 VMware, Inc. All Rights Reserved. 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 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | package main 15 | 16 | import ( 17 | "bytes" 18 | "encoding/json" 19 | "fmt" 20 | "io" 21 | "io/ioutil" 22 | "net" 23 | "os" 24 | "path" 25 | "regexp" 26 | "strconv" 27 | "strings" 28 | "time" 29 | 30 | "github.com/maxiepax/go-via/config" 31 | "github.com/maxiepax/go-via/db" 32 | "github.com/maxiepax/go-via/models" 33 | "github.com/sirupsen/logrus" 34 | "gorm.io/gorm/clause" 35 | 36 | "github.com/pin/tftp" 37 | ) 38 | 39 | func readHandler(conf *config.Config) func(string, io.ReaderFrom) error { 40 | return func(filename string, rf io.ReaderFrom) error { 41 | 42 | // get the requesting ip-address and our source address 43 | raddr := rf.(tftp.OutgoingTransfer).RemoteAddr() 44 | laddr := rf.(tftp.RequestPacketInfo).LocalIP() 45 | 46 | //strip the port 47 | ip, _, _ := net.SplitHostPort(raddr.String()) 48 | 49 | //get the object that correlates with the ip 50 | var address models.Address 51 | db.DB.Preload(clause.Associations).First(&address, "ip = ?", ip) 52 | 53 | //get the image info that correlates with the pool the ip is in 54 | var image models.Image 55 | db.DB.First(&image, "id = ?", address.Group.ImageID) 56 | 57 | logrus.WithFields(logrus.Fields{ 58 | "raddr": raddr, 59 | "laddr": laddr, 60 | "filename": filename, 61 | "imageid": image.ID, 62 | "addressid": address.ID, 63 | }).Debug("tftpd") 64 | 65 | //if the filename is mboot.efi, we hijack it and serve the mboot.efi file that is part of that specific image, this guarantees that you always get an mboot file that works for the build 66 | switch filename { 67 | case "mboot.efi": 68 | logrus.WithFields(logrus.Fields{ 69 | ip: "requesting mboot.efi", 70 | }).Info("tftpd") 71 | logrus.WithFields(logrus.Fields{ 72 | "id": address.ID, 73 | "percentage": 10, 74 | "progresstext": "mboot.efi", 75 | }).Info("progress") 76 | filename, _ = mbootPath(image.Path) 77 | address.Progress = 10 78 | address.Progresstext = "mboot.efi" 79 | db.DB.Save(&address) 80 | case "crypto64.efi": 81 | logrus.WithFields(logrus.Fields{ 82 | ip: "requesting crypto64.efi", 83 | }).Info("tftpd") 84 | logrus.WithFields(logrus.Fields{ 85 | "id": address.ID, 86 | "percentage": 12, 87 | "progresstext": "crypto64.efi", 88 | }).Info("progress") 89 | filename, _ = crypto64Path(image.Path) 90 | address.Progress = 12 91 | address.Progresstext = "crypto64.efi" 92 | db.DB.Save(&address) 93 | case "boot.cfg": 94 | serveBootCfg(filename, address, image, rf, conf) 95 | case "/boot.cfg": 96 | serveBootCfg(filename, address, image, rf, conf) 97 | default: 98 | //if no case matches, chroot to /tftp 99 | if _, err := os.Stat("tftp/" + filename); err == nil { 100 | filename = "tftp/" + filename 101 | logrus.WithFields(logrus.Fields{ 102 | "lowercase file": filename, 103 | }).Debug("tftpd") 104 | } else { 105 | dir, file := path.Split(filename) 106 | upperfile := strings.ToUpper(string(file)) 107 | filename = "tftp/" + dir + upperfile 108 | logrus.WithFields(logrus.Fields{ 109 | "uppercase file": filename, 110 | }).Debug("tftpd") 111 | } 112 | } 113 | 114 | // get the filesize to send filelength 115 | fi, err := os.Stat(filename) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | //set the filesize so that its advertized. 121 | rf.(tftp.OutgoingTransfer).SetSize(fi.Size()) 122 | 123 | file, err := os.Open(filename) 124 | if err != nil { 125 | logrus.WithFields(logrus.Fields{ 126 | "could not open file": err, 127 | }).Debug("tftpd") 128 | return err 129 | } 130 | n, err := rf.ReadFrom(file) 131 | if err != nil { 132 | logrus.WithFields(logrus.Fields{ 133 | "could not read from file": err, 134 | }).Debug("tftpd") 135 | return err 136 | } 137 | logrus.WithFields(logrus.Fields{ 138 | "id": address.ID, 139 | "ip": address.IP, 140 | "host": address.Hostname, 141 | "file": filename, 142 | "bytes": n, 143 | }).Info("tftpd") 144 | return nil 145 | } 146 | } 147 | 148 | func TFTPd(conf *config.Config) { 149 | s := tftp.NewServer(readHandler(conf), nil) 150 | s.SetTimeout(5 * time.Second) // optional 151 | err := s.ListenAndServe(":69") // blocks until s.Shutdown() is called 152 | if err != nil { 153 | logrus.WithFields(logrus.Fields{ 154 | "could not start tftp server:": err, 155 | }).Info("tftpd") 156 | os.Exit(1) 157 | } 158 | } 159 | 160 | func mbootPath(imagePath string) (string, error) { 161 | //check these paths if the file exists. 162 | paths := []string{"/EFI/BOOT/BOOTX64.EFI", "/EFI/BOOT/BOOTAA64.EFI", "/MBOOT.EFI", "/mboot.efi", "/efi/boot/bootx64.efi", "/efi/boot/bootaa64.efi"} 163 | 164 | for _, v := range paths { 165 | if _, err := os.Stat(imagePath + v); err == nil { 166 | return imagePath + v, nil 167 | } 168 | } 169 | //couldn't find the file 170 | return "", fmt.Errorf("could not locate a mboot.efi") 171 | 172 | } 173 | 174 | func crypto64Path(imagePath string) (string, error) { 175 | //check these paths if the file exists. 176 | paths := []string{"/EFI/BOOT/CRYPTO64.EFI", "/efi/boot/crypto64.efi"} 177 | 178 | for _, v := range paths { 179 | if _, err := os.Stat(imagePath + v); err == nil { 180 | return imagePath + v, nil 181 | } 182 | } 183 | //couldn't find the file 184 | return "", fmt.Errorf("could not locate a crypto64.efi") 185 | 186 | } 187 | 188 | func serveBootCfg(filename string, address models.Address, image models.Image, rf io.ReaderFrom, conf *config.Config) { 189 | //if the filename is boot.cfg, or /boot.cfg, we serve the boot cfg that belongs to that build. unfortunately, it seems boot.cfg or /boot.cfg varies in builds. 190 | 191 | // get the requesting ip-address and our source address 192 | raddr := rf.(tftp.OutgoingTransfer).RemoteAddr() 193 | laddr := rf.(tftp.RequestPacketInfo).LocalIP() 194 | 195 | //strip the port 196 | ip, _, _ := net.SplitHostPort(raddr.String()) 197 | 198 | logrus.WithFields(logrus.Fields{ 199 | ip: "requesting boot.cfg", 200 | }).Info("tftpd") 201 | logrus.WithFields(logrus.Fields{ 202 | "id": address.ID, 203 | "percentage": 15, 204 | "progresstext": "installation", 205 | }).Info("progress") 206 | address.Progress = 15 207 | address.Progresstext = "installation" 208 | db.DB.Save(&address) 209 | 210 | bc, err := ioutil.ReadFile(image.Path + "/BOOT.CFG") 211 | if err != nil { 212 | logrus.Warn(err) 213 | return 214 | } 215 | 216 | // strip slashes from paths in file 217 | re := regexp.MustCompile("/") 218 | bc = re.ReplaceAllLiteral(bc, []byte("")) 219 | 220 | // add kickstart path to kernelopt 221 | re = regexp.MustCompile("kernelopt=.*") 222 | o := re.Find(bc) 223 | bc = re.ReplaceAllLiteral(bc, append(o, []byte(" ks=https://"+laddr.String()+":"+strconv.Itoa(conf.Port)+"/ks.cfg")...)) 224 | 225 | // append the mac address of the hardware interface to ensure ks.cfg request comes from the right interface, along with ip, netmask and gateway. 226 | nm := net.CIDRMask(address.Pool.Netmask, 32) 227 | netmask := ipv4MaskString(nm) 228 | 229 | re = regexp.MustCompile("kernelopt=.*") 230 | o = re.Find(bc) 231 | bc = re.ReplaceAllLiteral(bc, append(o, []byte(" netdevice="+address.Mac+" ip="+address.IP+" netmask="+netmask+" gateway="+address.Pool.Gateway)...)) 232 | 233 | // if vlan is configured for the group, append the vlan to kernelopts 234 | if address.Group.Vlan != "" { 235 | re = regexp.MustCompile("kernelopt=.*") 236 | o = re.Find(bc) 237 | bc = re.ReplaceAllLiteral(bc, append(o, []byte(" vlanid="+address.Group.Vlan)...)) 238 | } 239 | 240 | // load options from the group 241 | options := models.GroupOptions{} 242 | json.Unmarshal(address.Group.Options, &options) 243 | 244 | // if autopart is configured for the group, append autopart to kernelopt - https://kb.vmware.com/s/article/77009 245 | /* 246 | if options.AutoPart { 247 | re = regexp.MustCompile("kernelopt=.*") 248 | o = re.Find(bc) 249 | bc = re.ReplaceAllLiteral(bc, append(o, []byte(" autoPartitionOnlyOnceAndSkipSsd=true")...)) 250 | }*/ 251 | 252 | // add allowLegacyCPU=true to kernelopt 253 | if options.AllowLegacyCPU { 254 | re = regexp.MustCompile("kernelopt=.*") 255 | o = re.Find(bc) 256 | bc = re.ReplaceAllLiteral(bc, append(o, []byte(" allowLegacyCPU=true")...)) 257 | } 258 | 259 | // replace prefix with prefix=foldername 260 | split := strings.Split(image.Path, "/") 261 | re = regexp.MustCompile("prefix=") 262 | o = re.Find(bc) 263 | bc = re.ReplaceAllLiteral(bc, append(o, []byte(split[1])...)) 264 | 265 | // Make a buffer to read from 266 | buff := bytes.NewBuffer(bc) 267 | 268 | // Send the data from the buffer to the client 269 | rf.(tftp.OutgoingTransfer).SetSize(int64(buff.Len())) 270 | n, err := rf.ReadFrom(buff) 271 | if err != nil { 272 | //fmt.Fprintf(os.Stderr, "%v\n", err) 273 | logrus.WithFields(logrus.Fields{ 274 | "os.Stderr": err, 275 | }).Debug("tftpd") 276 | return 277 | } 278 | 279 | logrus.WithFields(logrus.Fields{ 280 | "file": filename, 281 | "bytes": n, 282 | }).Info("tftpd") 283 | //return nil 284 | } 285 | 286 | func ipv4MaskString(m []byte) string { 287 | if len(m) != 4 { 288 | panic("ipv4Mask: len must be 4 bytes") 289 | } 290 | 291 | return fmt.Sprintf("%d.%d.%d.%d", m[0], m[1], m[2], m[3]) 292 | } 293 | -------------------------------------------------------------------------------- /web/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Web 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 11.2.2. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /web/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "web": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/web", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "aot": true, 26 | "assets": [ 27 | "src/favicon.ico", 28 | "src/assets" 29 | ], 30 | "styles": [ 31 | "node_modules/@clr/ui/clr-ui.min.css", 32 | "src/styles.scss" 33 | ], 34 | "scripts": [] 35 | }, 36 | "configurations": { 37 | "production": { 38 | "fileReplacements": [ 39 | { 40 | "replace": "src/environments/environment.ts", 41 | "with": "src/environments/environment.prod.ts" 42 | } 43 | ], 44 | "optimization": true, 45 | "outputHashing": "all", 46 | "sourceMap": false, 47 | "namedChunks": false, 48 | "extractLicenses": true, 49 | "vendorChunk": false, 50 | "buildOptimizer": true, 51 | "budgets": [ 52 | { 53 | "type": "initial", 54 | "maximumWarning": "2mb", 55 | "maximumError": "5mb" 56 | }, 57 | { 58 | "type": "anyComponentStyle", 59 | "maximumWarning": "6kb", 60 | "maximumError": "10kb" 61 | } 62 | ] 63 | } 64 | } 65 | }, 66 | "serve": { 67 | "builder": "@angular-devkit/build-angular:dev-server", 68 | "options": { 69 | "browserTarget": "web:build" 70 | }, 71 | "configurations": { 72 | "production": { 73 | "browserTarget": "web:build:production" 74 | } 75 | } 76 | }, 77 | "extract-i18n": { 78 | "builder": "@angular-devkit/build-angular:extract-i18n", 79 | "options": { 80 | "browserTarget": "web:build" 81 | } 82 | }, 83 | "test": { 84 | "builder": "@angular-devkit/build-angular:karma", 85 | "options": { 86 | "main": "src/test.ts", 87 | "polyfills": "src/polyfills.ts", 88 | "tsConfig": "tsconfig.spec.json", 89 | "karmaConfig": "karma.conf.js", 90 | "assets": [ 91 | "src/favicon.ico", 92 | "src/assets" 93 | ], 94 | "styles": [ 95 | "src/styles.scss" 96 | ], 97 | "scripts": [] 98 | } 99 | }, 100 | "lint": { 101 | "builder": "@angular-devkit/build-angular:tslint", 102 | "options": { 103 | "tsConfig": [ 104 | "tsconfig.app.json", 105 | "tsconfig.spec.json", 106 | "e2e/tsconfig.json" 107 | ], 108 | "exclude": [ 109 | "**/node_modules/**" 110 | ] 111 | } 112 | }, 113 | "e2e": { 114 | "builder": "@angular-devkit/build-angular:protractor", 115 | "options": { 116 | "protractorConfig": "e2e/protractor.conf.js", 117 | "devServerTarget": "web:serve" 118 | }, 119 | "configurations": { 120 | "production": { 121 | "devServerTarget": "web:serve:production" 122 | } 123 | } 124 | } 125 | } 126 | } 127 | }, 128 | "defaultProject": "web" 129 | } -------------------------------------------------------------------------------- /web/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | SELENIUM_PROMISE_MANAGER: false, 20 | baseUrl: 'http://localhost:4200/', 21 | framework: 'jasmine', 22 | jasmineNodeOpts: { 23 | showColors: true, 24 | defaultTimeoutInterval: 30000, 25 | print: function() {} 26 | }, 27 | onPrepare() { 28 | require('ts-node').register({ 29 | project: require('path').join(__dirname, './tsconfig.json') 30 | }); 31 | jasmine.getEnv().addReporter(new SpecReporter({ 32 | spec: { 33 | displayStacktrace: StacktraceOption.PRETTY 34 | } 35 | })); 36 | } 37 | }; -------------------------------------------------------------------------------- /web/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { browser, logging } from 'protractor'; 2 | import { AppPage } from './app.po'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', async () => { 12 | await page.navigateTo(); 13 | expect(await page.getTitleText()).toEqual('web app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /web/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | async navigateTo(): Promise { 5 | return browser.get(browser.baseUrl); 6 | } 7 | 8 | async getTitleText(): Promise { 9 | return element(by.css('app-root .content span')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /web/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../out-tsc/e2e", 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, './coverage/web'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | port: 9876, 37 | colors: true, 38 | logLevel: config.LOG_INFO, 39 | autoWatch: true, 40 | browsers: ['Chrome'], 41 | singleRun: false, 42 | restartOnFileChange: true 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build --deployUrl=/web/", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~11.2.3", 15 | "@angular/cdk": "^11.2.12", 16 | "@angular/common": "^11.2.14", 17 | "@angular/compiler": "~11.2.3", 18 | "@angular/core": "^11.2.14", 19 | "@angular/flex-layout": "^11.0.0-beta.33", 20 | "@angular/forms": "~11.2.3", 21 | "@angular/platform-browser": "~11.2.3", 22 | "@angular/platform-browser-dynamic": "~11.2.3", 23 | "@angular/router": "~11.2.3", 24 | "@cds/angular": "^5.0.2", 25 | "@cds/city": "^1.1.0", 26 | "@cds/core": "^5.0.2", 27 | "@clr/angular": "5.0.2", 28 | "@clr/icons": "^5.0.2", 29 | "@clr/ui": "5.0.2", 30 | "@webcomponents/custom-elements": "^1.5.0", 31 | "reconnecting-websocket": "^4.4.0", 32 | "rxjs": "~6.6.0", 33 | "terser": "^5.14.2", 34 | "tslib": "^2.0.0", 35 | "zone.js": "~0.11.3" 36 | }, 37 | "devDependencies": { 38 | "@angular-devkit/build-angular": "~0.1102.2", 39 | "@angular/cli": "~11.2.2", 40 | "@angular/compiler-cli": "~11.2.3", 41 | "@types/jasmine": "~3.6.0", 42 | "@types/node": "^12.11.1", 43 | "codelyzer": "^6.0.0", 44 | "jasmine-core": "~3.6.0", 45 | "jasmine-spec-reporter": "~5.0.0", 46 | "karma": "~6.1.0", 47 | "karma-chrome-launcher": "~3.1.0", 48 | "karma-coverage": "~2.0.3", 49 | "karma-jasmine": "~4.0.0", 50 | "karma-jasmine-html-reporter": "^1.5.0", 51 | "protractor": "~7.0.0", 52 | "ts-node": "~8.3.0", 53 | "tslint": "~6.1.0", 54 | "typescript": "~4.1.2" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /web/src/app/api.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ApiService } from './api.service'; 4 | 5 | describe('ApiService', () => { 6 | let service: ApiService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ApiService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/src/app/api.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpRequest, HttpEvent } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class ApiService { 9 | constructor(private httpClient: HttpClient) {} 10 | 11 | public getHosts() { 12 | return this.httpClient.get( 13 | 'https://' + window.location.host + '/v1/addresses' 14 | ); 15 | } 16 | 17 | public addHost(data) { 18 | return this.httpClient.post( 19 | 'https://' + window.location.host + '/v1/addresses', 20 | data 21 | ); 22 | } 23 | 24 | public updateHost(id, data) { 25 | return this.httpClient.patch( 26 | `https://${window.location.host}/v1/addresses/${id}`, 27 | data 28 | ); 29 | } 30 | 31 | public reimageHost(id) { 32 | return this.httpClient.patch( 33 | 'https://' + window.location.host + `/v1/addresses/${id}`, 34 | { 35 | reimage: true, 36 | progress: 0, 37 | progresstext: 'reimaging', 38 | } 39 | ); 40 | } 41 | 42 | public cancelImageHost(id) { 43 | return this.httpClient.patch( 44 | 'https://' + window.location.host + `/v1/addresses/${id}`, 45 | { 46 | reimage: false, 47 | progress: 0, 48 | progresstext: 'reimaging canceled', 49 | } 50 | ); 51 | } 52 | 53 | public deleteHost(id) { 54 | return this.httpClient.delete( 55 | 'https://' + window.location.host + `/v1/addresses/${id}` 56 | ); 57 | } 58 | 59 | public getPools() { 60 | return this.httpClient.get( 61 | 'https://' + window.location.host + '/v1/pools' 62 | ); 63 | } 64 | 65 | public addPool(data) { 66 | return this.httpClient.post( 67 | 'https://' + window.location.host + '/v1/pools', 68 | data 69 | ); 70 | } 71 | 72 | public updatePool(id, data) { 73 | return this.httpClient.patch( 74 | `https://${window.location.host}/v1/pools/${id}`, 75 | data 76 | ); 77 | } 78 | 79 | public deletePool(id) { 80 | return this.httpClient.delete( 81 | 'https://' + window.location.host + `/v1/pools/${id}` 82 | ); 83 | } 84 | 85 | public getGroups() { 86 | return this.httpClient.get( 87 | 'https://' + window.location.host + '/v1/groups' 88 | ); 89 | } 90 | 91 | public addGroup(data) { 92 | return this.httpClient.post( 93 | 'https://' + window.location.host + '/v1/groups', 94 | data 95 | ); 96 | } 97 | 98 | public updateGroup(id, data) { 99 | return this.httpClient.patch( 100 | `https://${window.location.host}/v1/groups/${id}`, 101 | data 102 | ); 103 | } 104 | 105 | public deleteGroup(id) { 106 | return this.httpClient.delete( 107 | 'https://' + window.location.host + `/v1/groups/${id}` 108 | ); 109 | } 110 | 111 | public getImages() { 112 | return this.httpClient.get( 113 | 'https://' + window.location.host + '/v1/images' 114 | ); 115 | } 116 | 117 | public addImage(file: File, hash: string, description: string): Observable> { 118 | const formData: FormData = new FormData(); 119 | 120 | formData.append('file[]', file); 121 | formData.append('hash', hash || ""); 122 | formData.append('description', description || ""); 123 | 124 | const req = new HttpRequest( 125 | 'POST', 126 | 'https://' + window.location.host + `/v1/images`, 127 | formData, 128 | { 129 | reportProgress: true, 130 | responseType: 'json' 131 | } 132 | ); 133 | 134 | return this.httpClient.request(req); 135 | } 136 | 137 | public deleteImage(id) { 138 | return this.httpClient.delete( 139 | 'https://' + window.location.host + `/v1/images/${id}` 140 | ); 141 | } 142 | 143 | public getUsers() { 144 | return this.httpClient.get( 145 | 'https://' + window.location.host + '/v1/users' 146 | ); 147 | } 148 | 149 | public addUser(data) { 150 | return this.httpClient.post( 151 | 'https://' + window.location.host + '/v1/users', 152 | data 153 | ); 154 | } 155 | 156 | public updateUser(id, data) { 157 | return this.httpClient.patch( 158 | `https://${window.location.host}/v1/users/${id}`, 159 | data 160 | ); 161 | } 162 | 163 | public deleteUser(id) { 164 | return this.httpClient.delete( 165 | 'https://' + window.location.host + `/v1/users/${id}` 166 | ); 167 | } 168 | 169 | public getVersion() { 170 | return this.httpClient.get( 171 | 'https://' + window.location.host + '/v1/version' 172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /web/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { HelpComponent } from './help/help.component'; 4 | import { ManageDhcpPoolsComponent } from './manage-dhcp-pools/manage-dhcp-pools.component'; 5 | import { ManageGroupsComponent } from './manage-groups/manage-groups.component'; 6 | import { ManageImagesComponent } from './manage-images/manage-images.component'; 7 | import { ManageUsersComponent } from './manage-users/manage-users.component'; 8 | import { LogsComponent } from './logs/logs.component'; 9 | 10 | 11 | const routes: Routes = [ 12 | { path: 'manage-dhcp-pools', component: ManageDhcpPoolsComponent }, 13 | { path: 'manage-groups', component: ManageGroupsComponent }, 14 | { path: 'manage-images', component: ManageImagesComponent }, 15 | { path: 'manage-users', component: ManageUsersComponent }, 16 | { path: 'help', component: HelpComponent }, 17 | { path: 'logs', component: LogsComponent }, 18 | { path: '', component: ManageDhcpPoolsComponent }, 19 | ]; 20 | 21 | @NgModule({ 22 | imports: [RouterModule.forRoot(routes)], 23 | exports: [RouterModule] 24 | }) 25 | export class AppRoutingModule { } 26 | -------------------------------------------------------------------------------- /web/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | 7 | go-via - v{{ version?.Version }} - commit {{ version?.Commit}} 8 |
9 |
10 |
11 | 12 | DHCP Pools 13 | Images 14 | Groups 15 | Users 16 | Help 17 | Logs 18 | 19 |
20 | 21 |
22 |
23 |
24 | 25 | -------------------------------------------------------------------------------- /web/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxiepax/go-via/ae294215e1ff360ccefaf7d8b88b32f60685ad8f/web/src/app/app.component.scss -------------------------------------------------------------------------------- /web/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | imports: [ 9 | RouterTestingModule 10 | ], 11 | declarations: [ 12 | AppComponent 13 | ], 14 | }).compileComponents(); 15 | }); 16 | 17 | it('should create the app', () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app).toBeTruthy(); 21 | }); 22 | 23 | it(`should have as title 'web'`, () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | const app = fixture.componentInstance; 26 | expect(app.title).toEqual('web'); 27 | }); 28 | 29 | it('should render title', () => { 30 | const fixture = TestBed.createComponent(AppComponent); 31 | fixture.detectChanges(); 32 | const compiled = fixture.nativeElement; 33 | expect(compiled.querySelector('.content span').textContent).toContain('web app is running!'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /web/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ApiService } from './api.service'; 3 | 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | templateUrl: './app.component.html', 8 | styleUrls: ['./app.component.scss'] 9 | }) 10 | export class AppComponent { 11 | version; 12 | 13 | constructor(private apiService: ApiService) { 14 | 15 | } 16 | 17 | ngOnInit(): void { 18 | this.apiService.getVersion().subscribe((data: any) => { 19 | this.version = data; 20 | }); 21 | 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | 4 | import { AppRoutingModule } from './app-routing.module'; 5 | import { AppComponent } from './app.component'; 6 | import { ClarityModule } from '@clr/angular'; 7 | import { CdsModule } from '@cds/angular'; 8 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 9 | import { ReactiveFormsModule } from '@angular/forms'; 10 | import { FormsModule } from '@angular/forms'; 11 | import { HttpClientModule } from '@angular/common/http'; 12 | import { HelpComponent } from './help/help.component'; 13 | import { ManageDhcpPoolsComponent } from './manage-dhcp-pools/manage-dhcp-pools.component'; 14 | import { ManageGroupsComponent } from './manage-groups/manage-groups.component'; 15 | import { ManageImagesComponent } from './manage-images/manage-images.component'; 16 | import { LogsComponent } from './logs/logs.component'; 17 | import { FlexLayoutModule } from "@angular/flex-layout"; 18 | import { ManageUsersComponent } from './manage-users/manage-users.component'; 19 | 20 | 21 | 22 | 23 | @NgModule({ 24 | declarations: [ 25 | AppComponent, 26 | HelpComponent, 27 | ManageDhcpPoolsComponent, 28 | ManageGroupsComponent, 29 | ManageImagesComponent, 30 | LogsComponent, 31 | ManageUsersComponent 32 | ], 33 | imports: [ 34 | BrowserModule, 35 | AppRoutingModule, 36 | ClarityModule, 37 | CdsModule, 38 | BrowserAnimationsModule, 39 | HttpClientModule, 40 | ReactiveFormsModule, 41 | FormsModule, 42 | FlexLayoutModule, 43 | ], 44 | providers: [], 45 | bootstrap: [AppComponent] 46 | }) 47 | export class AppModule { } 48 | -------------------------------------------------------------------------------- /web/src/app/help/help.component.html: -------------------------------------------------------------------------------- 1 |

Howto use VIA

2 | 3 |

Creating

4 | 5 |

Create a pool

6 | First, create a pool of ips. Hosts will be able to request these ips via DHCP when you allow them to be re-imaged. If a 7 | host is not set to be re-imaged, the DHCP server will refuse to hand out addresses. 8 | 9 |

Upload a ESXi ISO Image

10 | Second step is to upload an ESXi ISO. No other operating systems are currently supported since a lot of background 11 | crunching is done to ensure that files are in the right place,and that the boot.cfg and ks.cfg files look correct. 12 | Custom ISOs however with custom drivers etc shouldn't be a problem as long as they look similar to official builds. 13 | 14 |

Create a group

15 | The group you create will have all the common configuration parameters for your hosts. e.g. ntp, dns, initial deployment 16 | password, ks config etc. 17 | When you've created a group, start adding hosts to it. 18 | If you want to change something in the future, it's now very easy to just change this on the group level, instead of 19 | having to modify every single host. 20 |
21 | Options:
22 | - Boot Device: If you don't specify a boot device --firstdisk is used. If you're server doesn't comply with this, you 23 | can manually specify the mpx device to be uesd.
24 | - Management vlan: Enter a vlan-id to configure vmk0 and 'VM Network' portgroup with a vlan-id.
25 | - Calback URL: when the host has been installed/upgraded and all configuration options have been applied, a http-post 26 | will be performed to the specified URL. The post is a json string with information about the host, group and pool, with 27 | sensitive information removed.
28 | - DNS Suffix: Will configure the dns suffix for the host. If you enter the FQDN of sfo-m01-esx01.rainpole.io , the 29 | suffix is rainpole.io.
30 | - SSH: Will enable the service SSH on the host, and silence the warning that ssh is enabled on the host.
31 | - NTP: Will configure the NTP settings enterd on the host.
32 | - (WARNING) Erase all disks: During deployment, this will erase all data and partitions on all disks connected to the 33 | host. This includes all LUNs that may be masked to the host!
34 | - Allow Legacy CPU: If your hardware is not on the CGP and you recieve "CPU is not supported by ESXi X.X.X", enabeling 35 | this option will bypass the security check, please note this is only inteded for lab purpose!
36 | - Generate Certificate: ESXi installer certificate uses localhost.localdomain insatead of a valid SAN. This causes 37 | security focused installers like Cloud Foundation -> Cloud Builder to fail. go-via will generate a valid certificate 38 | based on information from imaging and push the new certificate to the host. Sadly there is no way to restart process 39 | hostd without doing it via SSH, so this operation will generate a reboot.
40 | - Create VMFS: Installer will automatically create a datastore from the remaining space on the boot device.
41 | - Advanced Options:
42 | - ks.cfg: this field allows you to override the default kickstart configuration file for the entire group. 43 | 44 |

Create a host

45 | Create hosts by defining a FQDN, ip address and mac-address.
46 | - Advanced Options:
47 | - ks.cfg: this field allows you to override the default kickstart configuration file for this specific host. This will override any Group level ks.cfg. 48 | 49 | 50 |

Start imaging

51 | To initiate a host imaging/re-imaging, simply press the re-image button next to the host, and reboot it. If the host is 52 | configured to network boot, the DHCP server will now offer the host an ip-address, and boot options to allow the host to 53 | perform a fully unattended deployment. 54 | When the host has been fully imaged, the DHCP server will set the host object to not be imaged again, this ensures that 55 | you dont accidentally re-image your hosts, or get stuck in an image loop. 56 | 57 |

Editing

58 | 59 |

Edit a pool

60 | To edit a pool, simply click Edit Scope next to the scope you wish to edit. 61 | 62 |

Edit a group

63 | To edit a group, click Edit Group below the group. 64 | 65 |

Edit a host

66 | To edit a host, click Edit next to the host. 67 | 68 |

Deleting

69 | 70 |

Deleting pools

71 | To delete a pool, ensure that no groups or hosts are using the pool first 72 | 73 |

Deleting Images

74 | To delete an image, edit each group and select another image first, when no groups are using the image you can remove 75 | the image. 76 | 77 |

Deleting Groups

78 | To delete a group, first remove all the hosts in the group. 79 | 80 |

Deleting hosts

81 | To remove a host, simply click remove next to the host and it will be removed. 82 | 83 |

Backup

84 |

Database

85 | Everything stateful is kept in the database/qslite-database.sql file. Please see sqlite recommended way of taking backup 86 | of database. 87 | 88 |

Config file

89 | Config file is simply a static file, just back it up as a file. 90 | 91 |

Secrets

92 | If no secrets file is found go-via will create one, and generate a SHA-256 hash. It is critical that this file is backed 93 | up to ensure that you can decrypt secrets.
94 | If you loose this file, a new file be generated with a new hash, and all secrets will be invalid and lost forever. 95 | Backup the secrets file as a regular file. 96 | 97 |

Certificates

98 | If no custom certificates are placed in the certificates folder go-via will generate self-signed ones. It's not critical 99 | to backup these certificates but to avoid browsers getting annoyed you may as well back these up as regular files. 100 | 101 |

Restore

102 | If the server is lost, just deploy the latest verison of go-via and place backed up database, config, certificate and 103 | secret in their corresponding folder and start go-via again. 104 | 105 |

Other

106 | 107 |

Cancel re-image

108 | If you no longer want to re-image a host, press cancel to remove the re-image flag. Please note this does not stop a 109 | imaging that is in progress and reboot your host, it simply tells the DHCPd service to stop serving a lease. 110 | 111 |

Starting over

112 | To start over, simply remove the sqlite-database.db file, when you start the application it will detect if the database 113 | is present, if it's not it will create it. 114 | 115 |

configuration file

116 | You can select what interface to listen to in the configuration file. 117 | 118 |

HTTP-REST

119 | Everything is called via HTTP-REST, for documentation go to "/swagger/index.html" 120 | 121 |

Kickstart Config

122 | You can edit the kickstart config file either at group, or host level. The order is Host->Group->Default.
123 | Syntax is Go template engine (https://pkg.go.dev/text/template)
124 | Default kickstart file: 125 |
126 |

# Accept the VMware End User License Agreement
127 | vmaccepteula
128 |
129 | # Set the root password for the DCUI and Tech Support Mode
130 | rootpw {{ .password }}
131 |
132 | {{ if .erasedisks }}
133 | # Remove ALL partitions
134 | clearpart --overwritevmfs --alldrives {{ end }}
135 |
136 | {{ if .bootdisk }}
137 | install --disk=/vmfs/devices/disks/{{.bootdisk}} --overwritevmfs --novmfsondisk
138 | {{ else }}
139 | # Install on the first local disk available on machine
140 | install --overwritevmfs {{ if not .createvmfs }} --novmfsondisk {{ end }} --firstdisk="localesx,usb,ahci,vmw_ahci,VMware"
141 | {{ end }}
142 |
143 | # Set the network to static on the first network adapter
144 | network --bootproto=static --ip={{ .ip }} --gateway={{ .gateway }} --netmask={{ .netmask }} --nameserver={{ .dns }} --hostname={{ .hostname }} --device={{ .mac }} {{if .vlan}} --vlanid={{.vlan}} {{end}}
145 |
146 | reboot

147 |
-------------------------------------------------------------------------------- /web/src/app/help/help.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxiepax/go-via/ae294215e1ff360ccefaf7d8b88b32f60685ad8f/web/src/app/help/help.component.scss -------------------------------------------------------------------------------- /web/src/app/help/help.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { HelpComponent } from './help.component'; 4 | 5 | describe('HelpComponent', () => { 6 | let component: HelpComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ HelpComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(HelpComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /web/src/app/help/help.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-help', 5 | templateUrl: './help.component.html', 6 | styleUrls: ['./help.component.scss'] 7 | }) 8 | export class HelpComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /web/src/app/logs/logs.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{ row.time }}
{{ row.level }}
{{ row.msg }}
{{ payload.key }}: {{ payload.value }}
4 |
5 |
-------------------------------------------------------------------------------- /web/src/app/logs/logs.component.scss: -------------------------------------------------------------------------------- 1 | .info { 2 | background: lightgray; 3 | } 4 | 5 | .debug { 6 | background: orange; 7 | } 8 | 9 | .warning { 10 | background: yellow; 11 | } 12 | 13 | .service { 14 | background: #e6ffff; 15 | } 16 | 17 | -------------------------------------------------------------------------------- /web/src/app/logs/logs.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LogsComponent } from './logs.component'; 4 | 5 | describe('LogsComponent', () => { 6 | let component: LogsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ LogsComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(LogsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /web/src/app/logs/logs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | 4 | @Component({ 5 | selector: 'app-logs', 6 | templateUrl: './logs.component.html', 7 | styleUrls: ['./logs.component.scss'] 8 | }) 9 | export class LogsComponent implements OnInit { 10 | 11 | data = []; 12 | 13 | 14 | constructor() { 15 | const ws = new WebSocket('wss://' + window.location.host + '/v1/log') 16 | ws.addEventListener('message', event => { 17 | const { time, level, msg, ...payload } = JSON.parse(event.data); 18 | const data = { 19 | time, 20 | level, 21 | msg, 22 | payload, 23 | }; 24 | this.data.push(data); 25 | }) 26 | } 27 | 28 | ngOnInit(): void { 29 | 30 | } 31 | } 32 | 33 | 34 | // 35 | 36 | -------------------------------------------------------------------------------- /web/src/app/manage-dhcp-pools/manage-dhcp-pools.component.html: -------------------------------------------------------------------------------- 1 |

Pools

2 |

3 | Please configure an ip-scope that the VIA service will offer bare metal 4 | provisioning to. 5 |

6 | 7 | 8 | 25 | 26 |

DHCP Scopes

27 | 28 | Network 29 | Name 30 | Start Address 31 | End Address 32 | Gateway 33 | Action 34 | 35 | 36 | {{ pool.net_address }}/{{ pool.netmask }} 37 | {{ pool.name }} 38 | {{ pool.start_address }} 39 | {{ pool.end_address }} 40 | {{ pool.gateway }} 41 | 42 | 45 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 104 | 115 | -------------------------------------------------------------------------------- /web/src/app/manage-dhcp-pools/manage-dhcp-pools.component.scss: -------------------------------------------------------------------------------- 1 | .form-input { 2 | padding-left: 0px; 3 | } -------------------------------------------------------------------------------- /web/src/app/manage-dhcp-pools/manage-dhcp-pools.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ManageDhcpPoolsComponent } from './manage-dhcp-pools.component'; 4 | 5 | describe('ManageDhcpPoolsComponent', () => { 6 | let component: ManageDhcpPoolsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ManageDhcpPoolsComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ManageDhcpPoolsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /web/src/app/manage-dhcp-pools/manage-dhcp-pools.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ApiService } from '../api.service'; 3 | import { 4 | FormBuilder, 5 | FormControl, 6 | FormGroup, 7 | Validators 8 | } from '@angular/forms'; 9 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 10 | 11 | @Component({ 12 | selector: 'app-manage-dhcp-pools', 13 | templateUrl: './manage-dhcp-pools.component.html', 14 | styleUrls: ['./manage-dhcp-pools.component.scss'] 15 | }) 16 | export class ManageDhcpPoolsComponent implements OnInit { 17 | pool; 18 | pools; 19 | errors; 20 | form: FormGroup; 21 | showPoolModalMode = ""; 22 | 23 | constructor( 24 | private apiService: ApiService, 25 | private formBuilder: FormBuilder 26 | ) { 27 | this.form = this.formBuilder.group({ 28 | net_address: ['', [Validators.required]], 29 | netmask: ['', [Validators.required]], 30 | name: ['', [Validators.required]], 31 | start_address: ['', [Validators.required]], 32 | end_address: ['', [Validators.required]], 33 | gateway: ['', [Validators.required]], 34 | }); 35 | } 36 | 37 | ngOnInit(): void { 38 | this.apiService.getPools().subscribe((data: any) => { 39 | this.pools = data; 40 | }); 41 | } 42 | 43 | 44 | showPoolModal(mode, id = null) { 45 | this.showPoolModalMode = mode; 46 | if (mode === "edit") { 47 | this.pool = this.pools.find(pool => pool.id === id); 48 | this.form.patchValue({ 49 | ...this.pool, 50 | }); 51 | } 52 | if (mode === "add") { 53 | this.form.reset(); 54 | } 55 | } 56 | 57 | submit() { 58 | const data = { 59 | ...this.form.value, 60 | only_serve_reimage: true, 61 | lease_time: 7000, 62 | }; 63 | 64 | this.apiService.addPool(data).subscribe((resp: any) => { 65 | if (resp.error) { 66 | this.errors = resp.error; 67 | } 68 | if (resp) { 69 | this.pools.push(resp); 70 | this.form.reset(); 71 | } 72 | }); 73 | 74 | this.showPoolModalMode = ''; 75 | } 76 | 77 | remove(id) { 78 | this.apiService.deletePool(id).subscribe((data: any) => { 79 | this.pools = this.pools.filter(item => item.id !== id); 80 | }, (data: any) => { 81 | if (data.error) { 82 | this.errors = [data.error]; 83 | } 84 | }); 85 | } 86 | 87 | updatePool() { 88 | const data = { 89 | ...this.form.value, 90 | only_serve_reimage: true, 91 | lease_time: 7000, 92 | }; 93 | 94 | this.apiService.updatePool(this.pool.id, data).subscribe((resp: any) => { 95 | if (resp.error) { 96 | this.errors = resp.error; 97 | } 98 | if (resp) { 99 | this.pools = this.pools.filter(item => item.id !== resp.id); 100 | this.pools.push(resp); 101 | this.showPoolModalMode = ''; 102 | } 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /web/src/app/manage-groups/manage-groups.component.html: -------------------------------------------------------------------------------- 1 |

Host groups

2 |

Host groups are used to organize your hosts into groups that share the same image and configuration properties. This 3 | saves you time by being able to organize all hosts that will look the same, and simply configure the group with 4 | properties.

5 | 6 | 7 | 24 | 25 |
26 |
{{group.name}}
27 | 28 | 29 | fqdn 30 | ip address 31 | mac address 32 | progress 33 | action 34 | 35 | 36 | {{host.hostname}}.{{host.domain}} 37 | {{host.ip}} 38 | {{host.mac}} 39 | 40 |
41 | 42 |
43 | {{ progresstext[host.id] }} 44 |
45 | 46 | 47 | 48 | 50 | 52 | 53 |
54 |
55 | 56 | 57 | 58 |
59 | 60 | 61 | 62 | 63 | 104 | 113 | 114 | 115 | 116 | 117 | 118 | 209 | 230 | -------------------------------------------------------------------------------- /web/src/app/manage-groups/manage-groups.component.scss: -------------------------------------------------------------------------------- 1 | .form-input { 2 | padding-left: 0px; 3 | } 4 | 5 | .postconfig { 6 | margin-top: 10px; 7 | margin-bottom: 5px; 8 | } 9 | 10 | .postconfig-subtext { 11 | margin-top: -25px; 12 | } 13 | 14 | .modal-vertical { 15 | flex-direction: column; 16 | .alert { 17 | margin-bottom: 12px; 18 | } 19 | } 20 | 21 | .modal-buttons { 22 | display:flex; 23 | justify-content: flex-end; 24 | } 25 | 26 | #ks_textarea { 27 | padding-top:7px; 28 | } 29 | 30 | #host_ks { 31 | padding-top: 10px; 32 | } -------------------------------------------------------------------------------- /web/src/app/manage-groups/manage-groups.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ManageGroupsComponent } from './manage-groups.component'; 4 | 5 | describe('ManageGroupsComponent', () => { 6 | let component: ManageGroupsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ManageGroupsComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ManageGroupsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /web/src/app/manage-images/manage-images.component.html: -------------------------------------------------------------------------------- 1 |

Images

2 | 3 |
4 |
5 |
6 |
7 | 8 |
9 |
10 | 18 |
19 |
20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 |
29 | 30 | 31 | 38 | 39 |
40 |
41 | 42 |
43 | 50 |
51 |
52 | 53 |
54 | 55 |
56 | 57 | 82 | 83 | 84 | ISO Image 85 | Size(MB) 86 | Description 87 | action 88 | 89 | 90 | {{ image.iso_image }} 91 | {{ image.size }} 92 | {{ image.description }} 93 | 94 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /web/src/app/manage-images/manage-images.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxiepax/go-via/ae294215e1ff360ccefaf7d8b88b32f60685ad8f/web/src/app/manage-images/manage-images.component.scss -------------------------------------------------------------------------------- /web/src/app/manage-images/manage-images.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ManageImagesComponent } from './manage-images.component'; 4 | 5 | describe('ManageImagesComponent', () => { 6 | let component: ManageImagesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ManageImagesComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ManageImagesComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /web/src/app/manage-images/manage-images.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ApiService } from '../api.service'; 3 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 4 | 5 | import { HttpEventType, HttpResponse } from '@angular/common/http'; 6 | import { Observable } from 'rxjs'; 7 | 8 | @Component({ 9 | selector: 'app-manage-images', 10 | templateUrl: './manage-images.component.html', 11 | styleUrls: ['./manage-images.component.scss'] 12 | }) 13 | export class ManageImagesComponent implements OnInit { 14 | images; 15 | 16 | //file upload 17 | selectedFiles?: FileList; 18 | hash: string; 19 | description: string; 20 | currentFile?: File; 21 | progress = 0; 22 | message = ''; 23 | fileInfos?: Observable; 24 | 25 | constructor(private apiService: ApiService) { 26 | 27 | } 28 | 29 | ngOnInit(): void { 30 | this.apiService.getImages().subscribe((images: any) => { 31 | this.images = images; 32 | }); 33 | } 34 | 35 | selectFile(event: any): void { 36 | this.selectedFiles = event.target.files; 37 | } 38 | 39 | upload(): void { 40 | this.progress = 0; 41 | if (this.selectedFiles) { 42 | const file: File | null = this.selectedFiles.item(0); 43 | 44 | if (file) { 45 | this.currentFile = file; 46 | 47 | this.apiService.addImage(this.currentFile, this.hash, this.description).subscribe( 48 | (event: any) => { 49 | if (event.type === HttpEventType.UploadProgress) { 50 | this.progress = Math.round(100 * event.loaded / event.total); 51 | } else if (event instanceof HttpResponse) { 52 | this.message = event.body.message; 53 | this.images.push(event.body); 54 | } 55 | }, 56 | (err: any) => { 57 | this.progress = 0; 58 | 59 | this.message = err?.error?.message || err?.error?.error_message || 'Could not upload the file!'; 60 | 61 | this.currentFile = undefined; 62 | }); 63 | } 64 | 65 | this.selectedFiles = undefined; 66 | } 67 | } 68 | 69 | remove(id) { 70 | this.apiService.deleteImage(id).subscribe((data: any) => { 71 | this.images = this.images.filter(item => item.id !== id); 72 | }, (data: any) => { 73 | if (data.error) { 74 | this.message = data.error; 75 | } 76 | }); 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /web/src/app/manage-users/manage-users.component.html: -------------------------------------------------------------------------------- 1 |

Users

2 |

3 | go-via uses BasicAuth, all users added are by default Admin. 4 |

5 | 6 | 7 | 8 | 25 | 26 |
{{ error.value }}
27 | 28 | 29 | Username 30 | Email 31 | Comment 32 | Action 33 | 34 | 35 | {{ user.username }} 36 | {{ user.email }} 37 | {{ user.comment }} 38 | 39 | 40 | 43 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 87 | 98 | -------------------------------------------------------------------------------- /web/src/app/manage-users/manage-users.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxiepax/go-via/ae294215e1ff360ccefaf7d8b88b32f60685ad8f/web/src/app/manage-users/manage-users.component.scss -------------------------------------------------------------------------------- /web/src/app/manage-users/manage-users.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ManageUsersComponent } from './manage-users.component'; 4 | 5 | describe('ManageUsersComponent', () => { 6 | let component: ManageUsersComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ManageUsersComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ManageUsersComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /web/src/app/manage-users/manage-users.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ApiService } from '../api.service'; 3 | import { 4 | FormBuilder, 5 | FormControl, 6 | FormGroup, 7 | Validators 8 | } from '@angular/forms'; 9 | 10 | @Component({ 11 | selector: 'app-manage-users', 12 | templateUrl: './manage-users.component.html', 13 | styleUrls: ['./manage-users.component.scss'] 14 | }) 15 | export class ManageUsersComponent implements OnInit { 16 | user; 17 | users; 18 | errors; 19 | form: FormGroup; 20 | showUserModalMode = ""; 21 | 22 | constructor( 23 | private apiService: ApiService, 24 | private formBuilder: FormBuilder 25 | ) { 26 | this.form = this.formBuilder.group({ 27 | username: ['', [Validators.required]], 28 | password: ['', [Validators.required]], 29 | email: ['', [Validators.required]], 30 | comment: ['', [Validators.required]], 31 | }); 32 | } 33 | 34 | ngOnInit(): void { 35 | this.apiService.getUsers().subscribe((data: any) => { 36 | this.users = data; 37 | }); 38 | } 39 | 40 | showUserModal(mode, id=null) { 41 | this.showUserModalMode = mode; 42 | if (mode === "edit") { 43 | this.user = this.users.find(user => user.id === id); 44 | this.form.patchValue({ 45 | ...this.user, 46 | password: undefined, 47 | }); 48 | } 49 | if (mode === "add") { 50 | this.form.reset(); 51 | } 52 | } 53 | 54 | submit() { 55 | const data = { 56 | ...this.form.value, 57 | }; 58 | 59 | this.apiService.addUser(data).subscribe((resp: any) => { 60 | if (resp.error) { 61 | this.errors = resp.error; 62 | } 63 | if (resp) { 64 | this.users.push(resp); 65 | this.form.reset(); 66 | } 67 | }); 68 | 69 | this.showUserModalMode = ''; 70 | } 71 | 72 | remove(id) { 73 | this.apiService.deleteUser(id).subscribe((data: any) => { 74 | this.users = this.users.filter(item => item.id !== id); 75 | }); 76 | } 77 | 78 | update() { 79 | const data = { 80 | ...this.form.value, 81 | }; 82 | 83 | this.apiService.updateUser(this.user.id, data).subscribe((resp: any) => { 84 | if (resp.error) { 85 | this.errors = resp.error; 86 | } 87 | if (resp) { 88 | this.users = this.users.filter(item => item.id !== resp.id); 89 | this.users.push(resp); 90 | this.showUserModalMode = ''; 91 | } 92 | }); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /web/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxiepax/go-via/ae294215e1ff360ccefaf7d8b88b32f60685ad8f/web/src/assets/.gitkeep -------------------------------------------------------------------------------- /web/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /web/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /web/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxiepax/go-via/ae294215e1ff360ccefaf7d8b88b32f60685ad8f/web/src/favicon.ico -------------------------------------------------------------------------------- /web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | go-via 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /web/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * IE11 requires the following for NgClass support on SVG elements 23 | */ 24 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 25 | 26 | /** 27 | * Web Animations `@angular/platform-browser/animations` 28 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 29 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 30 | */ 31 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 32 | 33 | /** 34 | * By default, zone.js will patch all possible macroTask and DomEvents 35 | * user can disable parts of macroTask/DomEvents patch by setting following flags 36 | * because those flags need to be set before `zone.js` being loaded, and webpack 37 | * will put import in the top of bundle, so user need to create a separate file 38 | * in this directory (for example: zone-flags.ts), and put the following flags 39 | * into that file, and then add the following code before importing zone.js. 40 | * import './zone-flags'; 41 | * 42 | * The flags allowed in zone-flags.ts are listed here. 43 | * 44 | * The following flags will work for all browsers. 45 | * 46 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 47 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 48 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 49 | * 50 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 51 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 52 | * 53 | * (window as any).__Zone_enable_cross_context_check = true; 54 | * 55 | */ 56 | 57 | /*************************************************************************************************** 58 | * Zone JS is required by default for Angular itself. 59 | */ 60 | import 'zone.js/dist/zone'; // Included with Angular CLI. 61 | 62 | 63 | /*************************************************************************************************** 64 | * APPLICATION IMPORTS 65 | */ 66 | -------------------------------------------------------------------------------- /web/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import 'normalize.css/normalize.css'; 3 | @import '@cds/city/css/bundles/default.min.css'; 4 | @import '@cds/core/global.min.css'; -------------------------------------------------------------------------------- /web/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context(path: string, deep?: boolean, filter?: RegExp): { 12 | keys(): string[]; 13 | (id: string): T; 14 | }; 15 | }; 16 | 17 | // First, initialize the Angular testing environment. 18 | getTestBed().initTestEnvironment( 19 | BrowserDynamicTestingModule, 20 | platformBrowserDynamicTesting() 21 | ); 22 | // Then we find all the tests. 23 | const context = require.context('./', true, /\.spec\.ts$/); 24 | // And load the modules. 25 | context.keys().map(context); 26 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "downlevelIteration": true, 10 | "experimentalDecorators": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "module": "es2020", 15 | "lib": [ 16 | "es2018", 17 | "dom" 18 | ] 19 | }, 20 | "angularCompilerOptions": { 21 | "enableI18nLegacyMessageIdFormat": false 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /web/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "align": { 8 | "options": [ 9 | "parameters", 10 | "statements" 11 | ] 12 | }, 13 | "array-type": false, 14 | "arrow-return-shorthand": true, 15 | "curly": true, 16 | "deprecation": { 17 | "severity": "warning" 18 | }, 19 | "eofline": true, 20 | "import-blacklist": [ 21 | true, 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": { 26 | "options": [ 27 | "spaces" 28 | ] 29 | }, 30 | "max-classes-per-file": false, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-empty": false, 55 | "no-inferrable-types": [ 56 | true, 57 | "ignore-params" 58 | ], 59 | "no-non-null-assertion": true, 60 | "no-redundant-jsdoc": true, 61 | "no-switch-case-fall-through": true, 62 | "no-var-requires": false, 63 | "object-literal-key-quotes": [ 64 | true, 65 | "as-needed" 66 | ], 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "semicolon": { 72 | "options": [ 73 | "always" 74 | ] 75 | }, 76 | "space-before-function-paren": { 77 | "options": { 78 | "anonymous": "never", 79 | "asyncArrow": "always", 80 | "constructor": "never", 81 | "method": "never", 82 | "named": "never" 83 | } 84 | }, 85 | "typedef": [ 86 | true, 87 | "call-signature" 88 | ], 89 | "typedef-whitespace": { 90 | "options": [ 91 | { 92 | "call-signature": "nospace", 93 | "index-signature": "nospace", 94 | "parameter": "nospace", 95 | "property-declaration": "nospace", 96 | "variable-declaration": "nospace" 97 | }, 98 | { 99 | "call-signature": "onespace", 100 | "index-signature": "onespace", 101 | "parameter": "onespace", 102 | "property-declaration": "onespace", 103 | "variable-declaration": "onespace" 104 | } 105 | ] 106 | }, 107 | "variable-name": { 108 | "options": [ 109 | "ban-keywords", 110 | "check-format", 111 | "allow-pascal-case" 112 | ] 113 | }, 114 | "whitespace": { 115 | "options": [ 116 | "check-branch", 117 | "check-decl", 118 | "check-operator", 119 | "check-separator", 120 | "check-type", 121 | "check-typecast" 122 | ] 123 | }, 124 | "component-class-suffix": true, 125 | "contextual-lifecycle": true, 126 | "directive-class-suffix": true, 127 | "no-conflicting-lifecycle": true, 128 | "no-host-metadata-property": true, 129 | "no-input-rename": true, 130 | "no-inputs-metadata-property": true, 131 | "no-output-native": true, 132 | "no-output-on-prefix": true, 133 | "no-output-rename": true, 134 | "no-outputs-metadata-property": true, 135 | "template-banana-in-box": true, 136 | "template-no-negated-async": true, 137 | "use-lifecycle-interface": true, 138 | "use-pipe-transform-interface": true, 139 | "directive-selector": [ 140 | true, 141 | "attribute", 142 | "app", 143 | "camelCase" 144 | ], 145 | "component-selector": [ 146 | true, 147 | "element", 148 | "app", 149 | "kebab-case" 150 | ] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /websockets/main.go: -------------------------------------------------------------------------------- 1 | package websockets 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/sirupsen/logrus" 11 | "nhooyr.io/websocket" 12 | ) 13 | 14 | type LogServer struct { 15 | Hook *hook 16 | 17 | // subscriberMessageBuffer controls the max number 18 | // of messages that can be queued for a subscriber 19 | // before it is kicked. 20 | // 21 | // Defaults to 16. 22 | subscriberMessageBuffer int 23 | 24 | subscribersMu sync.Mutex 25 | subscribers map[*subscriber]struct{} 26 | 27 | historyMu sync.Mutex 28 | history [][]byte 29 | } 30 | 31 | type subscriber struct { 32 | msgs chan []byte 33 | closeSlow func() 34 | } 35 | 36 | type hook struct { 37 | formatter logrus.Formatter 38 | ls *LogServer 39 | } 40 | 41 | func (hook *hook) Fire(entry *logrus.Entry) error { 42 | json, err := hook.formatter.Format(entry) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | ls := hook.ls 48 | 49 | ls.subscribersMu.Lock() 50 | defer ls.subscribersMu.Unlock() 51 | 52 | for s := range ls.subscribers { 53 | select { 54 | case s.msgs <- json: 55 | default: 56 | go s.closeSlow() 57 | } 58 | } 59 | 60 | ls.historyMu.Lock() 61 | if len(ls.history) < 50 { 62 | ls.history = append(ls.history, json) 63 | } else { 64 | ls.history = append(ls.history[1:], json) 65 | } 66 | ls.historyMu.Unlock() 67 | 68 | return nil 69 | } 70 | 71 | // Levels define on which log levels this hook would trigger 72 | func (hook *hook) Levels() []logrus.Level { 73 | return []logrus.Level{ 74 | logrus.PanicLevel, 75 | logrus.FatalLevel, 76 | logrus.ErrorLevel, 77 | logrus.WarnLevel, 78 | logrus.InfoLevel, 79 | } 80 | } 81 | 82 | func NewLogServer() *LogServer { 83 | ls := &LogServer{ 84 | subscriberMessageBuffer: 16, 85 | subscribers: make(map[*subscriber]struct{}), 86 | history: make([][]byte, 0), 87 | } 88 | ls.Hook = &hook{ 89 | formatter: &logrus.JSONFormatter{}, 90 | ls: ls, 91 | } 92 | 93 | return ls 94 | } 95 | 96 | func (ls *LogServer) Handle(c *gin.Context) { 97 | conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{ 98 | InsecureSkipVerify: true, 99 | }) 100 | if err != nil { 101 | logrus.WithFields(logrus.Fields{ 102 | "err": err, 103 | }).Warn("could not accept websocket") 104 | return 105 | } 106 | defer conn.Close(websocket.StatusInternalError, "") 107 | 108 | err = ls.subscribe(c.Request.Context(), conn) 109 | if errors.Is(err, context.Canceled) { 110 | return 111 | } 112 | if websocket.CloseStatus(err) == websocket.StatusNormalClosure || 113 | websocket.CloseStatus(err) == websocket.StatusGoingAway { 114 | return 115 | } 116 | if err != nil { 117 | logrus.WithFields(logrus.Fields{ 118 | "err": err, 119 | }).Warn("websocket was closed unexpected") 120 | return 121 | } 122 | } 123 | 124 | func (ls *LogServer) subscribe(ctx context.Context, c *websocket.Conn) error { 125 | ctx = c.CloseRead(ctx) 126 | 127 | s := &subscriber{ 128 | msgs: make(chan []byte, ls.subscriberMessageBuffer), 129 | closeSlow: func() { 130 | c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages") 131 | }, 132 | } 133 | 134 | ls.historyMu.Lock() 135 | history := ls.history 136 | ls.historyMu.Unlock() 137 | 138 | for _, msg := range history { 139 | err := writeTimeout(ctx, time.Second*5, c, msg) 140 | if err != nil { 141 | return err 142 | } 143 | } 144 | 145 | ls.addSubscriber(s) 146 | defer ls.deleteSubscriber(s) 147 | 148 | for { 149 | select { 150 | case msg := <-s.msgs: 151 | err := writeTimeout(ctx, time.Second*5, c, msg) 152 | if err != nil { 153 | return err 154 | } 155 | case <-ctx.Done(): 156 | return ctx.Err() 157 | } 158 | } 159 | } 160 | 161 | func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error { 162 | ctx, cancel := context.WithTimeout(ctx, timeout) 163 | defer cancel() 164 | 165 | return c.Write(ctx, websocket.MessageText, msg) 166 | } 167 | 168 | func (ls *LogServer) addSubscriber(s *subscriber) { 169 | ls.subscribersMu.Lock() 170 | ls.subscribers[s] = struct{}{} 171 | ls.subscribersMu.Unlock() 172 | } 173 | 174 | func (ls *LogServer) deleteSubscriber(s *subscriber) { 175 | ls.subscribersMu.Lock() 176 | delete(ls.subscribers, s) 177 | ls.subscribersMu.Unlock() 178 | } 179 | --------------------------------------------------------------------------------